diff --git a/.changeset/late-surfaces-stay-anchored.md b/.changeset/late-surfaces-stay-anchored.md new file mode 100644 index 0000000..376f38f --- /dev/null +++ b/.changeset/late-surfaces-stay-anchored.md @@ -0,0 +1,10 @@ +--- +"sideshow": patch +--- + +Keep the scroll position anchored when surfaces resize late. On slow networks a +surface iframe can report its real height seconds after the page settles; the +viewer now compensates scrollTop when a surface above the viewport resizes (in +every browser and embed, not just where native scroll anchoring happens to +apply), and a deep-linked post stays pinned until you scroll — never held by a +timer, never fighting your input. diff --git a/e2e/scroll-anchoring.spec.ts b/e2e/scroll-anchoring.spec.ts new file mode 100644 index 0000000..5e4b6de --- /dev/null +++ b/e2e/scroll-anchoring.spec.ts @@ -0,0 +1,214 @@ +// Scroll anchoring under slow networks. Surface iframes report their real +// heights whenever their sandboxed documents get around to it — on slow wifi +// that's seconds after the deep-link scroll has "settled", and a CDN-loaded +// surface (mermaid) resizes long after its frame's load event. The engine must +// (a) keep a deep-linked post anchored while surfaces above it settle, however +// late, (b) do so in every browser — WebKit has no native scroll anchoring, +// and Chrome suppresses its own at scrollTop 0 — and (c) never fight the user: +// real input ends the deep-link pin instantly, and thereafter scrollTop +// compensation keeps the view stable without moving it. +import { expect, publish, test } from "./fixtures.ts"; + +// A surface whose frame loads fast but grows late — the CDN-mermaid shape. +const lateGrower = (delayMs: number, height = 900) => ` +
+

Late grower

+
+ +`; + +const gapToBottom = (page: import("@playwright/test").Page) => + page.locator("main").evaluate((main) => { + return Math.round(main.scrollHeight - main.clientHeight - main.scrollTop); + }); + +test("deep-linked post stays anchored while surfaces above it grow late", async ({ + page, + server, +}) => { + // Two growers: one inside any plausible settle window (1s), one well past it + // (3.5s) — a fix that waits for a quiet period instead of observing height + // changes loses to the second one. + const top = await publish(server.url, { + html: lateGrower(1000), + title: "Grower 1s", + agent: "pi", + }); + await publish(server.url, { + html: lateGrower(3500, 700), + title: "Grower 3.5s", + agent: "pi", + session: top.sessionId, + }); + const target = await publish(server.url, { + html: '

Target

', + title: "Target", + agent: "pi", + session: top.sessionId, + }); + + await page.goto(`${server.url}/session/${top.sessionId}/s/${target.id}`); + await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(3); + + // Wait for the second grower to fire. + const frame2 = page.locator(`.card[data-id="${top.id}"] + .card iframe`).first(); + await expect + .poll(async () => (await frame2.boundingBox())?.height ?? 0, { timeout: 10_000 }) + .toBeGreaterThan(600); + + await expect.poll(() => gapToBottom(page), { timeout: 3000 }).toBeLessThanOrEqual(4); + await expect(page).toHaveURL(new RegExp(`/session/${top.sessionId}/s/${target.id}$`)); +}); + +test("slow network: deep-linked last post is in view once surfaces settle", async ({ + page, + server, +}) => { + // Model a slow-wifi reload faithfully: EVERY surface document is delayed, so + // at deep-link time the stream is collapsed 24px strips and the initial + // scroll lands near scrollTop 0 — where Chrome suppresses native anchoring. + const posts = []; + let sessionId: string | undefined; + for (let i = 0; i < 8; i++) { + const p = await publish(server.url, { + html: `

Post ${i + 1}

`, + title: `Post ${i + 1}`, + agent: "pi", + ...(sessionId ? { session: sessionId } : {}), + }); + sessionId = p.sessionId; + posts.push(p); + } + const target = posts[posts.length - 1]; + + await page.route("**/s/*", async (route) => { + await new Promise((r) => setTimeout(r, 2000)); + await route.continue(); + }); + + await page.goto(`${server.url}/session/${sessionId}/s/${target.id}`); + await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(8); + + await expect + .poll( + () => + page + .locator(".card:not(#whatsNew) iframe") + .evaluateAll((frames) => frames.every((f) => f.getBoundingClientRect().height > 300)), + { timeout: 15_000 }, + ) + .toBe(true); + + await expect + .poll( + () => + page.locator(`.card[data-id="${target.id}"]`).evaluate((el) => { + const r = el.getBoundingClientRect(); + return r.top < window.innerHeight && r.bottom > 0; + }), + { timeout: 3000 }, + ) + .toBe(true); +}); + +test("user scroll ends the deep-link pin — a stalled surface can't hold the view", async ({ + page, + server, +}) => { + const top = await publish(server.url, { + html: '

Stalled

', + title: "Stalled", + agent: "pi", + }); + await publish(server.url, { + html: '

Middle

', + title: "Middle", + agent: "pi", + session: top.sessionId, + }); + const target = await publish(server.url, { + html: '

Target

', + title: "Target", + agent: "pi", + session: top.sessionId, + }); + + // The first card's surface fetch stalls (slow wifi) far past the test. + await page.route(`**/s/${top.id}?*`, async (route) => { + await new Promise((r) => setTimeout(r, 20_000)); + await route.continue().catch(() => {}); + }); + + await page.goto(`${server.url}/session/${top.sessionId}/s/${target.id}`); + await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(3); + await page.waitForTimeout(2500); // other frames sized, pin armed on the stalled one + + const readTop = () => page.locator("main").evaluate((m) => m.scrollTop); + const anchored = await readTop(); + + // The user scrolls up to read something. + await page.mouse.move(400, 300); + await page.mouse.wheel(0, -600); + await page.waitForTimeout(200); + const afterUserScroll = await readTop(); + expect(afterUserScroll).toBeLessThan(anchored - 100); + + // 1.5s later the view is still theirs — nothing yanked it back. + await page.waitForTimeout(1500); + expect(Math.abs((await readTop()) - afterUserScroll)).toBeLessThanOrEqual(50); +}); + +test("reading position is preserved when a surface above the viewport grows late", async ({ + page, + server, +}) => { + // No deep link at all: the user opens a session, scrolls down to read, and a + // surface far above the viewport finally reports its height. scrollTop + // compensation must keep the visible content exactly where it was (WebKit + // has no native anchoring; Chrome's native anchoring is disabled by + // overflow-anchor so the engine's own compensation is what's under test). + const top = await publish(server.url, { + html: lateGrower(3000), + title: "Grower", + agent: "pi", + }); + for (let i = 0; i < 4; i++) { + await publish(server.url, { + html: `

Filler ${i + 1}

`, + title: `Filler ${i + 1}`, + agent: "pi", + session: top.sessionId, + }); + } + const last = await publish(server.url, { + html: '

Reading here

', + title: "Reading here", + agent: "pi", + session: top.sessionId, + }); + + await page.goto(`${server.url}/session/${top.sessionId}`); + await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(6); + // Let the fast surfaces size, then scroll to the bottom like a reader would. + await page.waitForTimeout(1000); + await page.locator(`.card[data-id="${last.id}"]`).scrollIntoViewIfNeeded(); + + const readingTop = () => + page + .locator(`.card[data-id="${last.id}"]`) + .evaluate((el) => Math.round(el.getBoundingClientRect().top)); + const before = await readingTop(); + + // The grower fires at 3s, far above the viewport. + const growerFrame = page.locator(`.card[data-id="${top.id}"] iframe`).first(); + await expect + .poll(async () => (await growerFrame.boundingBox())?.height ?? 0, { timeout: 10_000 }) + .toBeGreaterThan(700); + await page.waitForTimeout(300); + + expect(Math.abs((await readingTop()) - before)).toBeLessThanOrEqual(4); +}); diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index b799407..58396fe 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -79,7 +79,24 @@ export function frameForSource(source: unknown): { id: string; iframe: HTMLIFram const MIN_FRAME_H = 24; const MAX_FRAME_H = 4000; export function applyFrameHeight(iframe: HTMLIFrameElement, reportedHeight: unknown): void { - iframe.style.height = Math.min(Math.max(Number(reportedHeight), MIN_FRAME_H), MAX_FRAME_H) + "px"; + const next = Math.min(Math.max(Number(reportedHeight), MIN_FRAME_H), MAX_FRAME_H); + const prev = iframe.getBoundingClientRect(); + iframe.style.height = next + "px"; + // Manual scroll anchoring. Surface heights arrive whenever the sandboxed + // document gets around to reporting them — seconds or minutes after load on + // a slow network. When a frame that lies entirely ABOVE the scroll viewport + // grows, everything the user is looking at shifts down by the same amount + // (WebKit has no native scroll anchoring, and Chrome's is suppressed at + // scrollTop 0 or when the grower itself is the anchor node), so compensate + // scrollTop by the delta — the view stays put, however late the resize + // lands. A frame intersecting the viewport instead grows visibly downward, + // which is native anchoring's behaviour too — no compensation. The scroller + // sets `overflow-anchor: none` so Chrome can't double-compensate. + const delta = next - prev.height; + if (!delta || prev.height === 0) return; // no change, or hidden/unlaid-out + const scroller = iframe.closest("main, #standalone"); + if (!(scroller instanceof HTMLElement)) return; + if (prev.bottom <= scroller.getBoundingClientRect().top) scroller.scrollTop += delta; } // While a deep-link scroll poll is active, IntersectionObserver callbacks on @@ -139,39 +156,80 @@ function pollScrollIntoView(el: HTMLElement, postId: string): () => void { return () => {}; } + // Two mechanisms keep the deep-linked card in place. applyFrameHeight owns + // the steady state: once the target sits at the viewport top, every frame + // above it is above the viewport, so late resizes are compensated there — + // forever, no matter when they land. This pin owns the messy start, when the + // stream is still collapsed and unsized cards above the target are *inside* + // the viewport (compensation must not fire for visible growth): it re-runs + // scrollIntoView whenever the stream's height changes. Crucially it is ended + // by the USER, not by a settle timer — any wheel/touch/pointer/key input + // hands control back instantly, so a stalled surface can never hold the + // scroll position hostage, and there is no quiet-window to lose to a + // CDN-loaded surface that resizes 10s+ after its frame loads. + const PIN_MAX_MS = 30_000; deepLinkScrolling = true; const started = performance.now(); + const scope = (el.closest("#stream") as HTMLElement | null) ?? el.parentElement ?? el; + const inputHost = (el.closest("main") as HTMLElement | null) ?? scope; let lastTop: number | null = null; let stableChecks = 0; let stopped = false; + let urlWritten = false; let timer: ReturnType | undefined; - const finish = () => { - deepLinkScrolling = false; + const scroll = () => el.scrollIntoView({ behavior: "instant", block: "start" }); + + const writeUrl = () => { + if (urlWritten) return; + urlWritten = true; focusPost(postId); }; + // The pin: re-anchor on any stream height change (frames report through the + // bridge at arbitrary times, so this is observation-driven, not polled). + const observer = window.ResizeObserver + ? new ResizeObserver(() => { + if (!stopped) scroll(); + }) + : undefined; + observer?.observe(scope); + + const inputEvents = ["wheel", "touchstart", "pointerdown", "keydown"] as const; + const disarm = () => stop(true); + for (const type of inputEvents) inputHost.addEventListener(type, disarm, { passive: true }); + const maxTimer = setTimeout(disarm, PIN_MAX_MS); + + function stop(write: boolean) { + if (stopped) return; + stopped = true; + if (timer !== undefined) clearTimeout(timer); + clearTimeout(maxTimer); + observer?.disconnect(); + for (const type of inputEvents) inputHost.removeEventListener(type, disarm); + deepLinkScrolling = false; + if (write) writeUrl(); + } + + // Initial settle loop: scroll until the position stabilises, then write the + // URL (stable for 3 consecutive checks → done; hard cap at 5 s). The pin + // above stays armed afterwards — only user input or PIN_MAX_MS ends it. const tick = () => { if (stopped) return; - el.scrollIntoView({ behavior: "instant", block: "start" }); - const top = el.getBoundingClientRect().top; - if (lastTop !== null && Math.abs(top - lastTop) <= 5) stableChecks += 1; + scroll(); + const t = el.getBoundingClientRect().top; + if (lastTop !== null && Math.abs(t - lastTop) <= 5) stableChecks += 1; else stableChecks = 0; - lastTop = top; - // Stable for 3 consecutive checks → done; hard cap at 5 s. + lastTop = t; if (stableChecks >= 3 || performance.now() - started >= 5000) { - finish(); + writeUrl(); return; } timer = setTimeout(tick, 50); }; tick(); - return () => { - stopped = true; - if (timer !== undefined) clearTimeout(timer); - deepLinkScrolling = false; - }; + return () => stop(false); } export function Card(props: { post: Post; standalone?: boolean }) { @@ -203,7 +261,15 @@ export function Card(props: { post: Post; standalone?: boolean }) { // becomes the target. createEffect tracks scrollTarget(); onMount covers // the initial render (card ref isn't assigned when the effect first runs). const scrollIfTarget = () => { - if (!card || scrollTarget() !== props.post.id) return; + if (!card) return; + if (scrollTarget() !== props.post.id) { + // The target moved to another post (live auto-follow while a pin from a + // deep link or an earlier post is still armed): cancel ours so two pins + // never fight over the scroll position. scrollTarget() returning to null + // is the claiming card's own reset — not a move — so leave the pin alone. + if (scrollTarget() !== null) stopPoll?.(); + return; + } setScrollTarget(null); stopPoll?.(); stopPoll = pollScrollIntoView(card, props.post.id); diff --git a/viewer/src/styles.css b/viewer/src/styles.css index a003365..f223f23 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -425,6 +425,11 @@ main { flex: 1; overflow-y: auto; min-width: 0; + /* The engine does its own scroll anchoring (applyFrameHeight compensates + scrollTop when a surface above the viewport resizes) — identical across + browsers and shadow-DOM embeds. Disable the native kind so Chrome can't + double-compensate. */ + overflow-anchor: none; } .session-head { position: sticky; @@ -469,6 +474,7 @@ main { height: 100%; overflow-y: auto; background: var(--bg); + overflow-anchor: none; /* see main — the engine anchors scroll itself */ } .standalone-main { max-width: 860px;