diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index f57b3e2d7f1..dae0a60695c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -266,6 +266,7 @@ export class ViewV2Component { } this.popupScrollPositionService.stop(true); + this.popupScrollPositionService.forceTopOnNextVaultStart(); await this.popupRouterCacheService.back(); this.toastService.showToast({ diff --git a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts index af21f664f2d..a6c88e5a06e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts @@ -108,6 +108,28 @@ describe("VaultPopupScrollPositionService", () => { }); expect((scrollElement as any).scrollTop).toBe(500); })); + + it("forces scroll to top on next start when requested", fakeAsync(() => { + service["scrollPosition"] = 500; + service.forceTopOnNextVaultStart(); + + service.start(scrollElement); + + tick(); + expect((scrollElement as any).scrollTo).toHaveBeenCalledWith({ + behavior: "instant", + top: 0, + }); + expect((scrollElement as any).scrollTop).toBe(0); + + // A follow-up scroll is scheduled to defeat Firefox scroll restoration. + ((scrollElement as any).scrollTo as jest.Mock).mockClear(); + tick(50); + expect((scrollElement as any).scrollTo).toHaveBeenCalledWith({ + behavior: "instant", + top: 0, + }); + })); }); describe("scroll listener", () => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts index 7261fdd6633..9657e2382b9 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts @@ -18,6 +18,9 @@ export class VaultPopupScrollPositionService { /** Subscription associated with the virtual scroll element. */ private scrollSubscription: Subscription | null = null; + /** When true, the next call to `start()` will force scroll position back to the top. */ + private forceTopOnNextStart = false; + constructor() { this.router.events .pipe( @@ -31,8 +34,22 @@ export class VaultPopupScrollPositionService { /** Scrolls the user to the stored scroll position and starts tracking scroll of the page. */ start(scrollElement: HTMLElement) { - if (this.hasScrollPosition()) { - // Use `setTimeout` to scroll after rendering is complete + // Use `setTimeout` to scroll after rendering is complete. + // Firefox can sometimes restore scroll position on history navigation (back/forward) + // after our initial scroll call. When we explicitly want to reset to the top (e.g. after + // deleting an item), we schedule an extra follow-up scroll to ensure the final position is 0. + if (this.forceTopOnNextStart) { + this.forceTopOnNextStart = false; + this.scrollPosition = 0; + // try to set scroll to top immediately + setTimeout(() => { + scrollElement.scrollTo({ top: 0, behavior: "instant" }); + }, 0); + // wait for FF to possibly restore scroll position after UI settles, then enforce top=0 + setTimeout(() => { + scrollElement.scrollTo({ top: 0, behavior: "instant" }); + }, 250); + } else if (this.hasScrollPosition()) { setTimeout(() => { scrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" }); }); @@ -66,6 +83,14 @@ export class VaultPopupScrollPositionService { return this.scrollPosition !== null; } + /** + * Forces the next vault list render to start at scroll position 0. + * Useful for flows where we don't want browser back/forward scroll restoration (e.g. after delete). + */ + forceTopOnNextVaultStart() { + this.forceTopOnNextStart = true; + } + /** Conditionally resets the scroll listeners based on the ending path of the navigation */ private resetListenerForNavigation(event: NavigationEnd): void { // The vault page is the target of the scroll listener, return early