From bd662c09e6c7a8c7f22555ce04935df9fe74b620 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 1 Jul 2026 14:34:10 -0700 Subject: [PATCH 1/2] fix(viewer): anchor scroll explicitly when surfaces resize late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface iframes report their heights whenever their sandboxed documents get around to it — on slow networks that's seconds after a deep-link scroll has settled, so the target drifted below the fold (deterministic on WebKit, which has no native scroll anchoring, and on Chrome when the stream is still collapsed at scrollTop~0 where native anchoring is suppressed). Two cooperating mechanisms, neither a settle-timer heuristic: - applyFrameHeight now compensates the scroller's scrollTop when a frame entirely above the viewport resizes — manual scroll anchoring, identical across browsers and shadow-DOM embeds (overflow-anchor: none prevents Chrome double-compensating). Works however late a resize lands. - The deep-link poll keeps a pin armed after the initial settle: stream height changes re-run scrollIntoView, and any real user input (wheel/ touch/pointer/key) disarms it instantly — a stalled surface can never hold the scroll position hostage. --- .changeset/late-surfaces-stay-anchored.md | 10 + e2e/scroll-anchoring.spec.ts | 214 ++++++++++++++++++++++ viewer/src/Card.tsx | 86 +++++++-- viewer/src/styles.css | 6 + 4 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 .changeset/late-surfaces-stay-anchored.md create mode 100644 e2e/scroll-anchoring.spec.ts 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..f6549de 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 }) { 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; From fd06965d39f487386ec1724e3c254744304f1c76 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 1 Jul 2026 15:13:39 -0700 Subject: [PATCH 2/2] fix(viewer): cancel a card's stale pin when live auto-follow retargets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a new post arrives while the user is near the bottom, upsertPost moves scrollTarget to it. The previous target's pin stayed armed (pre-existing with the 5s poll, longer-lived now), leaving two pins fighting over the scroll position on a churning live session. The claiming card's own scrollTarget reset to null is not a move — only a real retarget cancels. --- viewer/src/Card.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index f6549de..58396fe 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -261,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);