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;