feat(ui): scroll keeper with deferred settle

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:33:44 +01:00
co-authored by Claude Opus 5.5
parent b1d17956f4
commit 635fd66d04
3 changed files with 241 additions and 0 deletions
+1
View File
@@ -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";
+126
View File
@@ -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<string, number>();
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);
}
+114
View File
@@ -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);
});