Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/late-surfaces-stay-anchored.md
Original file line number Diff line number Diff line change
@@ -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.
214 changes: 214 additions & 0 deletions e2e/scroll-anchoring.spec.ts
Original file line number Diff line number Diff line change
@@ -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) => `
<div id="grower" style="height:40px;padding:16px;overflow:hidden">
<h2>Late grower</h2>
</div>
<script>
setTimeout(() => {
document.getElementById("grower").style.height = "${height}px";
}, ${delayMs});
</script>
`;

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: '<div style="height:180px"><h2>Target</h2></div>',
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: `<div style="height:${380 + i * 20}px"><h2>Post ${i + 1}</h2></div>`,
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: '<div style="height:400px"><h2>Stalled</h2></div>',
title: "Stalled",
agent: "pi",
});
await publish(server.url, {
html: '<div style="height:600px"><h2>Middle</h2></div>',
title: "Middle",
agent: "pi",
session: top.sessionId,
});
const target = await publish(server.url, {
html: '<div style="height:300px"><h2>Target</h2></div>',
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: `<div style="height:500px"><h2>Filler ${i + 1}</h2></div>`,
title: `Filler ${i + 1}`,
agent: "pi",
session: top.sessionId,
});
}
const last = await publish(server.url, {
html: '<div style="height:300px"><h2>Reading here</h2></div>',
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);
});
96 changes: 81 additions & 15 deletions viewer/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof setTimeout> | 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 }) {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions viewer/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading