diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts index 457198eaa4e..6fc3e11493c 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts @@ -27,6 +27,7 @@ import { ClEAR_VIEW_CACHE_COMMAND, POPUP_VIEW_CACHE_KEY, SAVE_VIEW_CACHE_COMMAND, + ViewCacheState, } from "../../services/popup-view-cache-background.service"; /** @@ -42,8 +43,8 @@ export class PopupViewCacheService implements ViewCacheService { private messageSender = inject(MessageSender); private router = inject(Router); - private _cache: Record; - private get cache(): Record { + private _cache: Record; + private get cache(): Record { if (!this._cache) { throw new Error("Dirty View Cache not initialized"); } @@ -64,15 +65,9 @@ export class PopupViewCacheService implements ViewCacheService { filter((e) => e instanceof NavigationEnd), /** Skip the first navigation triggered by `popupRouterCacheGuard` */ skip(1), - filter((e: NavigationEnd) => - // viewing/editing a cipher and navigating back to the vault list should not clear the cache - ["/view-cipher", "/edit-cipher", "/tabs/vault"].every( - (route) => !e.urlAfterRedirects.startsWith(route), - ), - ), ) - .subscribe((e) => { - return this.clearState(); + .subscribe(() => { + return this.clearState(true); }); } @@ -85,13 +80,20 @@ export class PopupViewCacheService implements ViewCacheService { key, injector = inject(Injector), initialValue, + persistNavigation, } = options; - const cachedValue = this.cache[key] ? deserializer(JSON.parse(this.cache[key])) : initialValue; + const cachedValue = this.cache[key] + ? deserializer(JSON.parse(this.cache[key].value)) + : initialValue; const _signal = signal(cachedValue); + const viewCacheOptions = { + ...(persistNavigation && { persistNavigation }), + }; + effect( () => { - this.updateState(key, JSON.stringify(_signal())); + this.updateState(key, JSON.stringify(_signal()), viewCacheOptions); }, { injector }, ); @@ -123,15 +125,24 @@ export class PopupViewCacheService implements ViewCacheService { return control; } - private updateState(key: string, value: string) { + private updateState(key: string, value: string, options: ViewCacheState["options"]) { this.messageSender.send(SAVE_VIEW_CACHE_COMMAND, { key, value, + options, }); } - private clearState() { - this._cache = {}; // clear local cache - this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, {}); + private clearState(routeChange: boolean = false) { + if (routeChange) { + // Only keep entries with `persistNavigation` + this._cache = Object.fromEntries( + Object.entries(this._cache).filter(([, { options }]) => options?.persistNavigation), + ); + } else { + // Clear all entries + this._cache = {}; + } + this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, { routeChange: routeChange }); } } diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts index b6009c4cc2e..2ec75791d1b 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts @@ -14,6 +14,7 @@ import { ClEAR_VIEW_CACHE_COMMAND, POPUP_VIEW_CACHE_KEY, SAVE_VIEW_CACHE_COMMAND, + ViewCacheState, } from "../../services/popup-view-cache-background.service"; import { PopupViewCacheService } from "./popup-view-cache.service"; @@ -35,6 +36,7 @@ export class TestComponent { signal = this.viewCacheService.signal({ key: "test-signal", initialValue: "initial signal", + persistNavigation: true, }); } @@ -42,11 +44,11 @@ describe("popup view cache", () => { const configServiceMock = mock(); let testBed: TestBed; let service: PopupViewCacheService; - let fakeGlobalState: FakeGlobalState>; + let fakeGlobalState: FakeGlobalState>; let messageSenderMock: MockProxy; let router: Router; - const initServiceWithState = async (state: Record) => { + const initServiceWithState = async (state: Record) => { await fakeGlobalState.update(() => state); await service.init(); }; @@ -106,7 +108,11 @@ describe("popup view cache", () => { }); it("should initialize signal from state", async () => { - await initServiceWithState({ "foo-123": JSON.stringify("bar") }); + await initServiceWithState({ + "foo-123": { + value: JSON.stringify("bar"), + }, + }); const injector = TestBed.inject(Injector); @@ -120,7 +126,11 @@ describe("popup view cache", () => { }); it("should initialize form from state", async () => { - await initServiceWithState({ "test-form-cache": JSON.stringify({ name: "baz" }) }); + await initServiceWithState({ + "test-form-cache": { + value: JSON.stringify({ name: "baz" }), + }, + }); const fixture = TestBed.createComponent(TestComponent); const component = fixture.componentRef.instance; @@ -138,7 +148,11 @@ describe("popup view cache", () => { }); it("should utilize deserializer", async () => { - await initServiceWithState({ "foo-123": JSON.stringify("bar") }); + await initServiceWithState({ + "foo-123": { + value: JSON.stringify("bar"), + }, + }); const injector = TestBed.inject(Injector); @@ -178,6 +192,9 @@ describe("popup view cache", () => { expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, { key: "test-signal", value: JSON.stringify("Foobar"), + options: { + persistNavigation: true, + }, }); }); @@ -192,18 +209,63 @@ describe("popup view cache", () => { expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, { key: "test-form-cache", value: JSON.stringify({ name: "Foobar" }), + options: {}, }); }); it("should clear on 2nd navigation", async () => { - await initServiceWithState({ temp: "state" }); + await initServiceWithState({ + temp: { + value: "state", + options: {}, + }, + }); await router.navigate(["a"]); expect(messageSenderMock.send).toHaveBeenCalledTimes(0); - expect(service["_cache"]).toEqual({ temp: "state" }); + expect(service["_cache"]).toEqual({ + temp: { + value: "state", + options: {}, + }, + }); await router.navigate(["b"]); - expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {}); + expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, { + routeChange: true, + }); expect(service["_cache"]).toEqual({}); }); + + it("should respect persistNavigation setting on 2nd navigation", async () => { + await initServiceWithState({ + keepState: { + value: "state", + options: { + persistNavigation: true, + }, + }, + removeState: { + value: "state", + options: { + persistNavigation: false, + }, + }, + }); + + await router.navigate(["a"]); // first navigation covered in previous test + + await router.navigate(["b"]); + expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, { + routeChange: true, + }); + expect(service["_cache"]).toEqual({ + keepState: { + value: "state", + options: { + persistNavigation: true, + }, + }, + }); + }); }); diff --git a/apps/browser/src/platform/services/popup-view-cache-background.service.ts b/apps/browser/src/platform/services/popup-view-cache-background.service.ts index 98a6065189b..79c04e90aad 100644 --- a/apps/browser/src/platform/services/popup-view-cache-background.service.ts +++ b/apps/browser/src/platform/services/popup-view-cache-background.service.ts @@ -16,8 +16,27 @@ import { fromChromeEvent } from "../browser/from-chrome-event"; const popupClosedPortName = "new_popup"; +export type ViewCacheOptions = { + /** + * Optional flag to persist the cached value between navigation events. + */ + persistNavigation?: boolean; +}; + +export type ViewCacheState = { + /** + * The cached value + */ + value: string; // JSON value + + /** + * Options for managing/clearing the cache + */ + options?: ViewCacheOptions; +}; + /** 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( +export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record( POPUP_VIEW_MEMORY, "popup-view-cache", { @@ -36,9 +55,15 @@ export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition( export const SAVE_VIEW_CACHE_COMMAND = new CommandDefinition<{ key: string; value: string; + options: ViewCacheOptions; }>("save-view-cache"); -export const ClEAR_VIEW_CACHE_COMMAND = new CommandDefinition("clear-view-cache"); +export const ClEAR_VIEW_CACHE_COMMAND = new CommandDefinition<{ + /** + * Flag to indicate the clear request was triggered by a route change in popup. + */ + routeChange: boolean; +}>("clear-view-cache"); export class PopupViewCacheBackgroundService { private popupViewCacheState = this.globalStateProvider.get(POPUP_VIEW_CACHE_KEY); @@ -61,10 +86,13 @@ export class PopupViewCacheBackgroundService { this.messageListener .messages$(SAVE_VIEW_CACHE_COMMAND) .pipe( - concatMap(async ({ key, value }) => + concatMap(async ({ key, value, options }) => this.popupViewCacheState.update((state) => ({ ...state, - [key]: value, + [key]: { + value, + options, + }, })), ), ) @@ -72,7 +100,19 @@ export class PopupViewCacheBackgroundService { this.messageListener .messages$(ClEAR_VIEW_CACHE_COMMAND) - .pipe(concatMap(() => this.popupViewCacheState.update(() => null))) + .pipe( + concatMap(({ routeChange }) => + this.popupViewCacheState.update((state) => { + if (routeChange && state) { + // Only remove keys that are not marked with `persistNavigation` + return Object.fromEntries( + Object.entries(state).filter(([, { options }]) => options?.persistNavigation), + ); + } + return null; + }), + ), + ) .subscribe(); // on popup closed, with 2 minute delay that is cancelled by re-opening the popup diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 5f2ec858ed6..b4cf79e7422 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -50,6 +50,7 @@ export class VaultPopupItemsService { private cachedSearchText = inject(PopupViewCacheService).signal({ key: "vault-search-text", initialValue: "", + persistNavigation: true, }); readonly searchText$ = toObservable(this.cachedSearchText); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 6cce5796cbe..f11fa0f63f0 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -188,6 +188,7 @@ export class VaultPopupListFiltersService { key: "vault-filters", initialValue: {}, deserializer: (v) => v, + persistNavigation: true, }); this.deserializeFilters(cachedFilters()); diff --git a/libs/angular/src/platform/abstractions/view-cache.service.ts b/libs/angular/src/platform/abstractions/view-cache.service.ts index a282ef67967..c5ae6c77d1f 100644 --- a/libs/angular/src/platform/abstractions/view-cache.service.ts +++ b/libs/angular/src/platform/abstractions/view-cache.service.ts @@ -18,6 +18,11 @@ type BaseCacheOptions = { /** An optional injector. Required if the method is called outside of an injection context. */ injector?: Injector; + + /** + * Optional flag to persist the cached value between navigation events. + */ + persistNavigation?: boolean; } & (T extends JsonValue ? Deserializer : Required>); export type SignalCacheOptions = BaseCacheOptions & {