diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 44af0a81d8..8619a3247a 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -32,6 +32,8 @@ import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { + applyMainContentScrollTop, + NavigationScrollMemory, resetNavigationScroll, shouldResetScrollOnNavigation, } from "../lib/navigation-scroll"; @@ -87,6 +89,8 @@ export function Layout() { const lastMainScrollTop = useRef(0); const previousPathname = useRef(null); const mainContentRef = useRef(null); + const scrollMemory = useRef(new NavigationScrollMemory()); + const activeScrollKey = useRef(location.key); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [shortcutsOpen, setShortcutsOpen] = useState(false); const matchedCompany = useMemo(() => { @@ -450,7 +454,20 @@ export function Layout() { return scheduleMainContentFocus(mainContent); }, [location.pathname]); + // Continuously record the scroll offset of the active history entry so a + // later back/forward navigation can restore it (see NavigationScrollMemory). useEffect(() => { + const main = mainContentRef.current; + if (!main) return; + const recordScroll = () => { + scrollMemory.current.remember(activeScrollKey.current, main.scrollTop); + }; + main.addEventListener("scroll", recordScroll, { passive: true }); + return () => main.removeEventListener("scroll", recordScroll); + }, []); + + useLayoutEffect(() => { + const main = mainContentRef.current; const shouldResetScroll = shouldResetScrollOnNavigation({ previousPathname: previousPathname.current, pathname: location.pathname, @@ -460,9 +477,22 @@ export function Layout() { previousPathname.current = location.pathname; - if (!shouldResetScroll) return; - resetNavigationScroll(mainContentRef.current); - }, [location.pathname, navigationType]); + const isHistoryPop = navigationType === "POP"; + const restoredScrollTop = isHistoryPop ? scrollMemory.current.recall(location.key) : 0; + activeScrollKey.current = location.key; + + if (isHistoryPop) { + applyMainContentScrollTop(main, restoredScrollTop); + // Cached page content can finish laying out a frame after commit; re-apply + // once it has so the restored offset isn't clamped to a shorter interim height. + const raf = requestAnimationFrame(() => applyMainContentScrollTop(main, restoredScrollTop)); + return () => cancelAnimationFrame(raf); + } + + if (shouldResetScroll) { + resetNavigationScroll(main); + } + }, [location.key, location.pathname, location.state, navigationType]); return ( diff --git a/ui/src/lib/navigation-scroll.test.ts b/ui/src/lib/navigation-scroll.test.ts index 967dfa19f3..8bd40f9ae2 100644 --- a/ui/src/lib/navigation-scroll.test.ts +++ b/ui/src/lib/navigation-scroll.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { + applyMainContentScrollTop, + NavigationScrollMemory, resetNavigationScroll, SIDEBAR_SCROLL_RESET_STATE, shouldResetScrollOnNavigation, @@ -123,4 +125,33 @@ describe("navigation-scroll", () => { expect(document.body.scrollLeft).toBe(0); expect(windowScrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" }); }); + + it("remembers and recalls scroll offsets per history key", () => { + const memory = new NavigationScrollMemory(); + expect(memory.recall("missing")).toBe(0); + + memory.remember("inbox", 640); + memory.remember("issue", 1820); + expect(memory.recall("inbox")).toBe(640); + expect(memory.recall("issue")).toBe(1820); + + memory.remember("inbox", 700); + expect(memory.recall("inbox")).toBe(700); + + memory.remember("inbox", -50); + expect(memory.recall("inbox")).toBe(0); + }); + + it("restores a remembered scroll offset onto the main content element", () => { + const main = document.createElement("main"); + main.scrollTo = vi.fn(); + + applyMainContentScrollTop(main, 540); + + expect(main.scrollTo).toHaveBeenCalledWith({ top: 540, left: 0, behavior: "auto" }); + expect(main.scrollTop).toBe(540); + expect(main.scrollLeft).toBe(0); + + expect(() => applyMainContentScrollTop(null, 540)).not.toThrow(); + }); }); diff --git a/ui/src/lib/navigation-scroll.ts b/ui/src/lib/navigation-scroll.ts index 6ea8a1bb80..bbe2b61c57 100644 --- a/ui/src/lib/navigation-scroll.ts +++ b/ui/src/lib/navigation-scroll.ts @@ -18,6 +18,30 @@ export function shouldResetScrollOnNavigation(params: { return hasSidebarScrollResetState(state); } +// Remembers the `#main-content` scroll offset per browser-history entry so a +// back/forward (POP) navigation can be restored to where the user left off. +// `#main-content` is a single element that survives route changes, so without +// this the offset from the page we navigated away from (e.g. a deep +// issue-detail scroll) bleeds into the page we return to (e.g. the inbox). +export class NavigationScrollMemory { + private positions = new Map(); + + remember(key: string, scrollTop: number): void { + this.positions.set(key, Math.max(0, scrollTop)); + } + + recall(key: string): number { + return this.positions.get(key) ?? 0; + } +} + +export function applyMainContentScrollTop(mainElement: HTMLElement | null, scrollTop: number): void { + if (!mainElement) return; + mainElement.scrollTo?.({ top: scrollTop, left: 0, behavior: "auto" }); + mainElement.scrollTop = scrollTop; + mainElement.scrollLeft = 0; +} + export function resetNavigationScroll(mainElement: HTMLElement | null): void { mainElement?.scrollTo?.({ top: 0, left: 0, behavior: "auto" });