diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8cd51c8..12597e5 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ export { AppShell } from "./AppShell"; export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram } from "./primitives"; export { reducedMotion, viewTransition, holdDecision, useHeldValue, type HoldDecision } from "./motion"; +export { createScrollKeeper, routeScroll, installRouteScroll, type ScrollEnv, type ScrollKeeper } from "./scroll"; diff --git a/packages/ui/src/scroll.ts b/packages/ui/src/scroll.ts new file mode 100644 index 0000000..4a310e2 --- /dev/null +++ b/packages/ui/src/scroll.ts @@ -0,0 +1,126 @@ +/** What the scroll keeper needs from the browser (a fake in tests). */ +export interface ScrollEnv { + getY(): number; + scrollTo(y: number): void; + maxY(): number; + /** Scrolls the element with this id into view; false when there is none. */ + scrollToId(id: string): boolean; + /** Runs `cb` on the next frame. */ + later(cb: () => void): void; + now(): number; + load(): string | null; + save(s: string): void; +} + +export interface ScrollKeeper { + /** Records the position for the history entry on screen. */ + onScroll(): void; + /** The shell calls this on every location change: top for PUSH/REPLACE, the saved position for POP. */ + routeChanged(key: string, pop: boolean, hash?: string): void; + /** A page that keeps the previous content on screen while the next loads calls this before the shell's routeChanged. */ + defer(): void; + /** ...and this once the new content is in place; it applies the deferred route scroll. No-op otherwise. */ + settle(): void; +} + +interface Route { key: string; pop: boolean; hash?: string } + +const KEEP = 60; +const RETRY_MS = 1000; + +/** Scroll positions per history entry, restored on back/forward even when the page renders its content late. */ +export function createScrollKeeper(env: ScrollEnv): ScrollKeeper { + const saved = new Map(); + try { + const raw = JSON.parse(env.load() ?? "[]") as unknown; + if (Array.isArray(raw)) for (const [k, y] of raw) if (typeof k === "string" && typeof y === "number") saved.set(k, y); + } catch { /* unreadable: start empty */ } + let current: string | null = null; + let deferNext = false; + let pending: Route | null = null; + let run = 0; + let restoring = false; + let persistQueued = false; + + const persist = () => { + if (persistQueued) return; + persistQueued = true; + env.later(() => { + persistQueued = false; + try { env.save(JSON.stringify([...saved].slice(-KEEP))); } catch { /* storage full or blocked */ } + }); + }; + + const apply = (r: Route) => { + current = r.key; + const id = ++run; + restoring = false; + if (r.hash && r.hash.length > 1) { + let target = r.hash.slice(1); + try { target = decodeURIComponent(target); } catch { /* keep raw */ } + if (env.scrollToId(target)) return; + } + const y = r.pop ? saved.get(r.key) ?? 0 : 0; + const start = env.now(); + const step = () => { + if (id !== run) return; + env.scrollTo(Math.min(y, env.maxY())); + restoring = env.maxY() < y && env.now() - start < RETRY_MS; + if (restoring) env.later(step); + }; + step(); + }; + + return { + onScroll() { + if (current === null || restoring) return; + saved.delete(current); + saved.set(current, env.getY()); + persist(); + }, + routeChanged(key, pop, hash) { + if (deferNext) { + deferNext = false; + run++; // a restore still retrying for the previous entry stops + restoring = false; + pending = { key, pop, hash }; + return; + } + pending = null; + apply({ key, pop, hash }); + }, + defer() { deferNext = true; }, + settle() { + if (!pending) return; + const r = pending; + pending = null; + apply(r); + }, + }; +} + +let instance: ScrollKeeper | null = null; + +/** The window-bound keeper shared by the shell and the pages. */ +export function routeScroll(): ScrollKeeper { + instance ??= createScrollKeeper({ + getY: () => window.scrollY, + scrollTo: (y) => window.scrollTo(0, y), + maxY: () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight), + scrollToId: (id) => { const el = document.getElementById(id); el?.scrollIntoView({ block: "start" }); return !!el; }, + later: (cb) => { requestAnimationFrame(() => cb()); }, + now: () => performance.now(), + load: () => { try { return sessionStorage.getItem("stc.scroll"); } catch { return null; } }, + save: (s) => { try { sessionStorage.setItem("stc.scroll", s); } catch { /* private mode */ } }, + }); + return instance; +} + +/** Takes scroll restoration over from the browser and records positions. Returns a cleanup function. */ +export function installRouteScroll(): () => void { + const keeper = routeScroll(); + try { history.scrollRestoration = "manual"; } catch { /* unsupported */ } + const onScroll = () => keeper.onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); +} diff --git a/packages/ui/test/scroll.test.ts b/packages/ui/test/scroll.test.ts new file mode 100644 index 0000000..a8c94f6 --- /dev/null +++ b/packages/ui/test/scroll.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { createScrollKeeper } from "../src/scroll"; + +function fakeEnv(opts: { max?: number; stored?: string | null; ids?: string[] } = {}) { + const e = { + y: 0, max: opts.max ?? 10_000, t: 0, stored: opts.stored ?? null as string | null, queue: [] as (() => void)[], intoView: [] as string[], + getY: () => e.y, + scrollTo: (y: number) => { e.y = Math.min(y, e.max); }, + maxY: () => e.max, + scrollToId: (id: string) => { if (!(opts.ids ?? []).includes(id)) return false; e.intoView.push(id); return true; }, + later: (cb: () => void) => { e.queue.push(cb); }, + now: () => e.t, + load: () => e.stored, + save: (s: string) => { e.stored = s; }, + flush() { const q = e.queue.splice(0); q.forEach((f) => f()); }, + }; + return e; +} + +test("PUSH goes to the top, POP restores the saved position of that entry", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 640; k.onScroll(); + k.routeChanged("b", false); + expect(env.y).toBe(0); + env.y = 120; k.onScroll(); + k.routeChanged("a", true); + expect(env.y).toBe(640); + k.routeChanged("b", true); + expect(env.y).toBe(120); + k.routeChanged("c", true); // never seen: top + expect(env.y).toBe(0); +}); + +test("a hash scrolls its target into view", () => { + const env = fakeEnv({ ids: ["part 2"] }); + const k = createScrollKeeper(env); + env.y = 300; + k.routeChanged("a", false, "#part%202"); + expect(env.intoView).toEqual(["part 2"]); + k.routeChanged("b", false, "#missing"); + expect(env.y).toBe(0); +}); + +test("defer() keeps the old entry until settle(), which then applies the route scroll", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 500; k.onScroll(); + k.defer(); + k.routeChanged("b", false); + expect(env.y).toBe(500); // the old content is still on screen + env.y = 520; k.onScroll(); // scrolling the old content still belongs to "a" + k.settle(); + expect(env.y).toBe(0); + k.routeChanged("a", true); + expect(env.y).toBe(520); + k.settle(); // nothing pending: no-op + expect(env.y).toBe(520); +}); + +test("a restore beyond the current page height retries until the page is tall enough", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 2000; k.onScroll(); + k.routeChanged("b", false); + env.max = 300; // the old page is still loading + k.routeChanged("a", true); + expect(env.y).toBe(300); + env.flush(); + expect(env.y).toBe(300); + env.max = 5000; // content arrived + env.flush(); + expect(env.y).toBe(2000); + expect(env.queue.length).toBe(0); +}); + +test("the retry gives up after a second, and a newer route change cancels it", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 2000; k.onScroll(); + k.routeChanged("b", false); + env.max = 300; + k.routeChanged("a", true); + k.onScroll(); // our own clamped scroll must not overwrite the saved 2000 + env.t = 1200; env.flush(); + expect(env.queue.length).toBe(0); + env.max = 5000; + k.routeChanged("a", true); + expect(env.y).toBe(2000); + + env.max = 300; + k.routeChanged("c", false); + k.routeChanged("a", true); + k.routeChanged("d", false); // cancels the restore of "a" + env.max = 5000; env.flush(); + expect(env.y).toBe(0); +}); + +test("positions survive a reload through storage", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 777; k.onScroll(); env.flush(); + const env2 = fakeEnv({ stored: env.stored }); + createScrollKeeper(env2).routeChanged("a", true); + expect(env2.y).toBe(777); + const env3 = fakeEnv({ stored: "{not json" }); + createScrollKeeper(env3).routeChanged("a", true); + expect(env3.y).toBe(0); +});