Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over 150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1 and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops. Scroll restore stops on user input; pdf.js never starts a load after cleanup. Co-Authored-By: Claude Opus 5.5 <[email protected]>
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
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("user input stops a restore that is still waiting for the page to grow", () => {
|
|
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);
|
|
expect(env.y).toBe(300);
|
|
k.userInput(); // the user starts scrolling
|
|
env.y = 120; k.onScroll();
|
|
env.max = 5000; env.flush();
|
|
expect(env.y).toBe(120);
|
|
k.userInput(); // no restore pending: harmless
|
|
});
|
|
|
|
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);
|
|
});
|