fix(viewer): explicit scroll anchoring — late surface resizes no longer push deep-linked posts below the fold#198
Closed
elucid wants to merge 2 commits into
Closed
fix(viewer): explicit scroll anchoring — late surface resizes no longer push deep-linked posts below the fold#198elucid wants to merge 2 commits into
elucid wants to merge 2 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On a slow network, opening a deep link to a post at the end of a long session leaves the viewport parked mid-stream — the linked post (and everything after it) sits below the fold. Originally observed on conference wifi against sideshow-cloud in Chrome/Arc 149, reloading
/session/:id/p/:lastPost~10/10 times.Mechanism. Surface iframes are sandboxed documents that self-measure and
postMessagetheir height whenever they get around to it. On a slow link that's seconds after the deep-link scroll has "settled" — the oldpollScrollIntoViewdeclared victory after 150ms of stability (5s cap), so every later resize above the target shifted it down with nobody left to re-anchor. Worst offender: a mermaid surface, whose iframe loads fast but only resizes 8–12s later when the CDN module lands.Why it looked browser/host-dependent. Chrome has native scroll anchoring, which silently compensated in most layouts — but it's suppressed when the scroller sits at/near
scrollTop: 0, which is exactly where a slow-wifi deep link lands (every surface is still an unsized 24px strip, so the stream barely scrolls). WebKit has no scroll anchoring at all, so it reproduces deterministically. The fix makes anchoring explicit so all browsers and shadow-DOM embeds behave identically.Fix
Two cooperating mechanisms — neither is a settle-timer heuristic, because any quiet-window guess loses to a surface that resizes later than the window:
Manual scroll anchoring in
applyFrameHeight. Every late height report already funnels through this one choke point. If the resized frame lies entirely above the scroll viewport, compensatescrollTopby the delta — the visible content stays put, however late the resize lands (1s or 40s), including shrinks (version-swap churn on live sessions).overflow-anchor: noneon the scroller stops Chrome double-compensating. A frame intersecting the viewport grows visibly downward instead, matching native anchoring semantics. This also protects plain mid-stream reading, not just deep links.Deep-link pin, ended by the user — not by a timer. Covers the one case compensation can't: while the stream is still collapsed, the unsized cards above the target are inside the viewport. The pin re-runs
scrollIntoViewon stream height changes (ResizeObserver, observation-driven) and disarms the instant the user wheels/touches/clicks/keys, so a stalled surface can never hold the scroll position hostage (30s hard cap as backstop). The old stability loop is kept solely to decide when to write the URL (focusPost).Repro / red–green
e2e/scroll-anchoring.spec.ts— onmain, 5 of 8 runs are red, including Chromium:Also verified green on installed Google Chrome 149 (
channel: "chrome").Validation
npm test,typecheck(node + workers + viewer),lint,format:check,security:audit✅Notes
elucid/fix-stale-scroll-anchor(unpushed), which widened the settle window by watching iframeloadevents + a 500ms quiet period. That still drifted for any surface resizing after the window (the mermaid/CDN case, reproduced failing at a 3s grower) and force-scrolled every 500ms for up to 30s while a frame stalled, locking out user scrolling./s/→/p/canonical URL rename from that branch is deliberately not included — it's a separate concern for its own PR.