-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[PM-3530][PM-8588] persist extension route history (#9556)
Save the extension popup route history and restore it after closing and re-opening the popup. --------- Co-authored-by: Justin Baur <[email protected]>
- Loading branch information
1 parent
b2db633
commit 295fb8f
Showing
14 changed files
with
356 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
131 changes: 131 additions & 0 deletions
131
apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
import { Location } from "@angular/common"; | ||
import { Injectable, inject } from "@angular/core"; | ||
import { | ||
ActivatedRouteSnapshot, | ||
CanActivateFn, | ||
NavigationEnd, | ||
Router, | ||
UrlSerializer, | ||
} from "@angular/router"; | ||
import { filter, first, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; | ||
|
||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; | ||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; | ||
import { GlobalStateProvider } from "@bitwarden/common/platform/state"; | ||
|
||
import { POPUP_ROUTE_HISTORY_KEY } from "../../../platform/services/popup-view-cache-background.service"; | ||
import BrowserPopupUtils from "../browser-popup-utils"; | ||
|
||
/** | ||
* Preserves route history when opening and closing the popup | ||
* | ||
* Routes marked with `doNotSaveUrl` will not be stored | ||
**/ | ||
@Injectable({ | ||
providedIn: "root", | ||
}) | ||
export class PopupRouterCacheService { | ||
private router = inject(Router); | ||
private state = inject(GlobalStateProvider).get(POPUP_ROUTE_HISTORY_KEY); | ||
private location = inject(Location); | ||
|
||
constructor() { | ||
// init history with existing state | ||
this.history$() | ||
.pipe(first()) | ||
.subscribe((history) => history.forEach((location) => this.location.go(location))); | ||
|
||
// update state when route change occurs | ||
this.router.events | ||
.pipe( | ||
filter((event) => event instanceof NavigationEnd), | ||
filter((_event: NavigationEnd) => { | ||
const state: ActivatedRouteSnapshot = this.router.routerState.snapshot.root; | ||
|
||
let child = state.firstChild; | ||
while (child.firstChild) { | ||
child = child.firstChild; | ||
} | ||
|
||
return !child?.data?.doNotSaveUrl ?? true; | ||
}), | ||
switchMap((event) => this.push(event.url)), | ||
) | ||
.subscribe(); | ||
} | ||
|
||
history$(): Observable<string[]> { | ||
return this.state.state$; | ||
} | ||
|
||
async setHistory(state: string[]): Promise<string[]> { | ||
return this.state.update(() => state); | ||
} | ||
|
||
/** Get the last item from the history stack, or `null` if empty */ | ||
last$(): Observable<string | null> { | ||
return this.history$().pipe( | ||
map((history) => { | ||
if (!history || history.length === 0) { | ||
return null; | ||
} | ||
return history[history.length - 1]; | ||
}), | ||
); | ||
} | ||
|
||
/** | ||
* If in browser popup, push new route onto history stack | ||
*/ | ||
private async push(url: string): Promise<boolean> { | ||
if (!BrowserPopupUtils.inPopup(window) || url === (await firstValueFrom(this.last$()))) { | ||
return; | ||
} | ||
await this.state.update((prevState) => (prevState == null ? [url] : prevState.concat(url))); | ||
} | ||
|
||
/** | ||
* Navigate back in history | ||
*/ | ||
async back() { | ||
await this.state.update((prevState) => prevState.slice(0, -1)); | ||
|
||
const url = this.router.url; | ||
this.location.back(); | ||
if (url !== this.router.url) { | ||
return; | ||
} | ||
|
||
// if no history is present, fallback to vault page | ||
await this.router.navigate([""]); | ||
} | ||
} | ||
|
||
/** | ||
* Redirect to the last visited route. Should be applied to root route. | ||
* | ||
* If `FeatureFlag.PersistPopupView` is disabled, do nothing. | ||
**/ | ||
export const popupRouterCacheGuard = (() => { | ||
const configService = inject(ConfigService); | ||
const popupHistoryService = inject(PopupRouterCacheService); | ||
const urlSerializer = inject(UrlSerializer); | ||
|
||
return configService.getFeatureFlag$(FeatureFlag.PersistPopupView).pipe( | ||
switchMap((featureEnabled) => { | ||
if (!featureEnabled) { | ||
return of(true); | ||
} | ||
|
||
return popupHistoryService.last$().pipe( | ||
map((url: string) => { | ||
if (!url) { | ||
return true; | ||
} | ||
|
||
return urlSerializer.parse(url); | ||
}), | ||
); | ||
}), | ||
); | ||
}) satisfies CanActivateFn; |
113 changes: 113 additions & 0 deletions
113
apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import { Component } from "@angular/core"; | ||
import { TestBed } from "@angular/core/testing"; | ||
import { Router, UrlSerializer, UrlTree } from "@angular/router"; | ||
import { RouterTestingModule } from "@angular/router/testing"; | ||
import { mock } from "jest-mock-extended"; | ||
import { firstValueFrom, of } from "rxjs"; | ||
|
||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; | ||
import { GlobalStateProvider } from "@bitwarden/common/platform/state"; | ||
import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; | ||
|
||
import { PopupRouterCacheService, popupRouterCacheGuard } from "./popup-router-cache.service"; | ||
|
||
const flushPromises = async () => await new Promise(process.nextTick); | ||
|
||
@Component({ template: "" }) | ||
export class EmptyComponent {} | ||
|
||
describe("Popup router cache guard", () => { | ||
const configServiceMock = mock<ConfigService>(); | ||
const fakeGlobalStateProvider = new FakeGlobalStateProvider(); | ||
|
||
let testBed: TestBed; | ||
let serializer: UrlSerializer; | ||
let router: Router; | ||
|
||
let service: PopupRouterCacheService; | ||
|
||
beforeEach(async () => { | ||
jest.spyOn(configServiceMock, "getFeatureFlag$").mockReturnValue(of(true)); | ||
|
||
testBed = TestBed.configureTestingModule({ | ||
imports: [ | ||
RouterTestingModule.withRoutes([ | ||
{ path: "a", component: EmptyComponent }, | ||
{ path: "b", component: EmptyComponent }, | ||
{ | ||
path: "c", | ||
component: EmptyComponent, | ||
data: { doNotSaveUrl: true }, | ||
}, | ||
]), | ||
], | ||
providers: [ | ||
{ provide: ConfigService, useValue: configServiceMock }, | ||
{ provide: GlobalStateProvider, useValue: fakeGlobalStateProvider }, | ||
], | ||
}); | ||
|
||
await testBed.compileComponents(); | ||
|
||
router = testBed.inject(Router); | ||
serializer = testBed.inject(UrlSerializer); | ||
|
||
service = testBed.inject(PopupRouterCacheService); | ||
|
||
await service.setHistory([]); | ||
}); | ||
|
||
it("returns true if the history stack is empty", async () => { | ||
const response = await firstValueFrom( | ||
testBed.runInInjectionContext(() => popupRouterCacheGuard()), | ||
); | ||
|
||
expect(response).toBe(true); | ||
}); | ||
|
||
it("redirects to the latest stored route", async () => { | ||
await router.navigate(["a"]); | ||
await router.navigate(["b"]); | ||
|
||
const response = (await firstValueFrom( | ||
testBed.runInInjectionContext(() => popupRouterCacheGuard()), | ||
)) as UrlTree; | ||
|
||
expect(serializer.serialize(response)).toBe("/b"); | ||
}); | ||
|
||
it("back method redirects to the previous route", async () => { | ||
await router.navigate(["a"]); | ||
await router.navigate(["b"]); | ||
|
||
// wait for router events subscription | ||
await flushPromises(); | ||
|
||
expect(await firstValueFrom(service.history$())).toEqual(["/a", "/b"]); | ||
|
||
await service.back(); | ||
|
||
expect(await firstValueFrom(service.history$())).toEqual(["/a"]); | ||
}); | ||
|
||
it("does not save ignored routes", async () => { | ||
await router.navigate(["a"]); | ||
await router.navigate(["b"]); | ||
await router.navigate(["c"]); | ||
|
||
const response = (await firstValueFrom( | ||
testBed.runInInjectionContext(() => popupRouterCacheGuard()), | ||
)) as UrlTree; | ||
|
||
expect(serializer.serialize(response)).toBe("/b"); | ||
}); | ||
|
||
it("does not save duplicate routes", async () => { | ||
await router.navigate(["a"]); | ||
await router.navigate(["a"]); | ||
|
||
await flushPromises(); | ||
|
||
expect(await firstValueFrom(service.history$())).toEqual(["/a"]); | ||
}); | ||
}); |
Oops, something went wrong.