1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-24929] Clear route history cache for add/edit cipher views on tab change (#16755)

* clear route history cache for add/edit cipher views on tab change

* remove duplicate words
This commit is contained in:
Nick Krantz
2025-10-22 08:36:31 -05:00
committed by GitHub
parent 453feb362f
commit 7418f67874
6 changed files with 147 additions and 23 deletions

View File

@@ -319,6 +319,7 @@ import I18nService from "../platform/services/i18n.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { PopupRouterCacheBackgroundService } from "../platform/services/popup-router-cache-background.service";
import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service";
import { BrowserSdkLoadService } from "../platform/services/sdk/browser-sdk-load.service";
import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service";
@@ -488,6 +489,7 @@ export default class MainBackground {
private nativeMessagingBackground: NativeMessagingBackground;
private popupViewCacheBackgroundService: PopupViewCacheBackgroundService;
private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService;
constructor() {
// Services
@@ -684,6 +686,9 @@ export default class MainBackground {
this.globalStateProvider,
this.taskSchedulerService,
);
this.popupRouterCacheBackgroundService = new PopupRouterCacheBackgroundService(
this.globalStateProvider,
);
this.migrationRunner = new MigrationRunner(
this.storageService,
@@ -1514,6 +1519,7 @@ export default class MainBackground {
(this.eventUploadService as EventUploadService).init(true);
this.popupViewCacheBackgroundService.startObservingMessages();
this.popupRouterCacheBackgroundService.init();
await this.vaultTimeoutService.init(true);
this.fido2Background.init();

View File

@@ -5,6 +5,7 @@ import { Injectable, inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
CanActivateFn,
Data,
NavigationEnd,
Router,
UrlSerializer,
@@ -14,7 +15,10 @@ import { filter, first, firstValueFrom, map, Observable, of, switchMap, tap } fr
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { POPUP_ROUTE_HISTORY_KEY } from "../../../platform/services/popup-view-cache-background.service";
import {
POPUP_ROUTE_HISTORY_KEY,
RouteHistoryCacheState,
} from "../../../platform/services/popup-view-cache-background.service";
import BrowserPopupUtils from "../../browser/browser-popup-utils";
/**
@@ -42,8 +46,7 @@ export class PopupRouterCacheService {
this.history$()
.pipe(first())
.subscribe(
(history) =>
Array.isArray(history) && history.forEach((location) => this.location.go(location)),
(history) => Array.isArray(history) && history.forEach(({ url }) => this.location.go(url)),
);
// update state when route change occurs
@@ -54,31 +57,33 @@ export class PopupRouterCacheService {
// `Location.back()` can now be called successfully
this.hasNavigated = true;
}),
filter((_event: NavigationEnd) => {
map((event) => {
const state: ActivatedRouteSnapshot = this.router.routerState.snapshot.root;
let child = state.firstChild;
while (child.firstChild) {
child = child.firstChild;
}
return !child?.data?.doNotSaveUrl;
return { event, data: child.data };
}),
switchMap((event) => this.push(event.url)),
filter(({ data }) => {
return !data?.doNotSaveUrl;
}),
switchMap(({ event, data }) => this.push(event.url, data)),
)
.subscribe();
}
history$(): Observable<string[]> {
history$(): Observable<RouteHistoryCacheState[]> {
return this.state.state$;
}
async setHistory(state: string[]): Promise<string[]> {
async setHistory(state: RouteHistoryCacheState[]): Promise<RouteHistoryCacheState[]> {
return this.state.update(() => state);
}
/** Get the last item from the history stack, or `null` if empty */
last$(): Observable<string | null> {
last$(): Observable<RouteHistoryCacheState | null> {
return this.history$().pipe(
map((history) => {
if (!history || history.length === 0) {
@@ -92,11 +97,24 @@ export class PopupRouterCacheService {
/**
* If in browser popup, push new route onto history stack
*/
private async push(url: string) {
if (!BrowserPopupUtils.inPopup(window) || url === (await firstValueFrom(this.last$()))) {
private async push(url: string, data: Data) {
if (
!BrowserPopupUtils.inPopup(window) ||
url === (await firstValueFrom(this.last$().pipe(map((h) => h?.url))))
) {
return;
}
await this.state.update((prevState) => (prevState == null ? [url] : prevState.concat(url)));
const routeEntry: RouteHistoryCacheState = {
url,
options: {
resetRouterCacheOnTabChange: data?.resetRouterCacheOnTabChange ?? false,
},
};
await this.state.update((prevState) =>
prevState == null ? [routeEntry] : prevState.concat(routeEntry),
);
}
/**
@@ -142,13 +160,13 @@ export const popupRouterCacheGuard = ((): Observable<true | UrlTree> => {
}
return popupHistoryService.last$().pipe(
map((url: string) => {
if (!url) {
map((entry) => {
if (!entry) {
return true;
}
popupHistoryService.markCacheRestored();
return urlSerializer.parse(url);
return urlSerializer.parse(entry.url);
}),
);
}) satisfies CanActivateFn;

View File

@@ -96,11 +96,31 @@ describe("Popup router cache guard", () => {
// wait for router events subscription
await flushPromises();
expect(await firstValueFrom(service.history$())).toEqual(["/a", "/b"]);
expect(await firstValueFrom(service.history$())).toEqual([
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/a",
},
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/b",
},
]);
await service.back();
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
expect(await firstValueFrom(service.history$())).toEqual([
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/a",
},
]);
});
it("does not save ignored routes", async () => {
@@ -121,6 +141,13 @@ describe("Popup router cache guard", () => {
await flushPromises();
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
expect(await firstValueFrom(service.history$())).toEqual([
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/a",
},
]);
});
});

View File

@@ -0,0 +1,55 @@
import { switchMap, filter, map, first, of } from "rxjs";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { BrowserApi } from "../browser/browser-api";
import { fromChromeEvent } from "../browser/from-chrome-event";
import { POPUP_ROUTE_HISTORY_KEY } from "./popup-view-cache-background.service";
export class PopupRouterCacheBackgroundService {
private popupRouteHistoryState = this.globalStateProvider.get(POPUP_ROUTE_HISTORY_KEY);
constructor(private globalStateProvider: GlobalStateProvider) {}
init() {
fromChromeEvent(chrome.tabs.onActivated)
.pipe(
switchMap((tabs) => BrowserApi.getTab(tabs[0].tabId)!),
switchMap((tab) => {
// FireFox sets the `url` to "about:blank" and won't populate the `url` until the `onUpdated` event
if (tab.url !== "about:blank") {
return of(tab);
}
return fromChromeEvent(chrome.tabs.onUpdated).pipe(
first(),
switchMap(([tabId]) => BrowserApi.getTab(tabId)!),
);
}),
map((tab) => tab.url || tab.pendingUrl),
filter((url) => !url?.startsWith(chrome.runtime.getURL(""))),
switchMap(() =>
this.popupRouteHistoryState.update((state) => {
if (!state || state.length === 0) {
return state;
}
const lastRoute = state.at(-1);
if (!lastRoute) {
return state;
}
// When the last route has resetRouterCacheOnTabChange set
// Reset the route history to empty to force the user to the default route
if (lastRoute.options?.resetRouterCacheOnTabChange) {
return [];
}
return state;
}),
),
)
.subscribe();
}
}

View File

@@ -42,6 +42,22 @@ export type ViewCacheState = {
options?: ViewCacheOptions;
};
export type RouteCacheOptions = {
/**
* When true, the route history will be reset on tab change and respective route was the last visited route.
* i.e. Upon the user re-opening the extension the route history will be empty and the user will be taken to the default route.
*/
resetRouterCacheOnTabChange?: boolean;
};
export type RouteHistoryCacheState = {
/** Route URL */
url: string;
/** Options for managing the route history cache */
options?: RouteCacheOptions;
};
/** We cannot use `UserKeyDefinition` because we must be able to store state when there is no active user. */
export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record<ViewCacheState>(
POPUP_VIEW_MEMORY,
@@ -51,9 +67,9 @@ export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record<ViewCacheState>(
},
);
export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition<string[]>(
export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition<RouteHistoryCacheState[]>(
POPUP_VIEW_MEMORY,
"popup-route-history",
"popup-route-history-details",
{
deserializer: (jsonValue) => jsonValue,
},

View File

@@ -59,6 +59,7 @@ import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service";
import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component";
import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component";
import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component";
@@ -98,7 +99,7 @@ import { TabsV2Component } from "./tabs-v2.component";
/**
* Data properties acceptable for use in extension route objects
*/
export interface RouteDataProperties {
export interface RouteDataProperties extends RouteCacheOptions {
elevation: RouteElevation;
/**
@@ -204,7 +205,7 @@ const routes: Routes = [
path: "add-cipher",
component: AddEditV2Component,
canActivate: [authGuard, debounceNavigationGuard()],
data: { elevation: 1 } satisfies RouteDataProperties,
data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties,
runGuardsAndResolvers: "always",
},
{
@@ -214,6 +215,7 @@ const routes: Routes = [
data: {
// Above "trash"
elevation: 3,
resetRouterCacheOnTabChange: true,
} satisfies RouteDataProperties,
runGuardsAndResolvers: "always",
},