Skip to content

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
mainfrom
elucid/scroll-anchoring
Closed

fix(viewer): explicit scroll anchoring — late surface resizes no longer push deep-linked posts below the fold#198
elucid wants to merge 2 commits into
mainfrom
elucid/scroll-anchoring

Conversation

@elucid

@elucid elucid commented Jul 1, 2026

Copy link
Copy Markdown
Member

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 postMessage their height whenever they get around to it. On a slow link that's seconds after the deep-link scroll has "settled" — the old pollScrollIntoView declared 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:

  1. 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, compensate scrollTop by 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: none on 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.

  2. 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 scrollIntoView on 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 — on main, 5 of 8 runs are red, including Chromium:

Test main this PR
Deep link with growers above at 1s and 3.5s ❌ chromium + webkit
Slow wifi: every surface fetch delayed, deep link to last of 8 ❌ chromium + webkit
User scrolls away while a surface above stalls ✅ (guards the pin against fighting real input)
Reading mid-stream, surface above viewport grows at 3s ❌ webkit

Also verified green on installed Google Chrome 149 (channel: "chrome").

Validation

  • npm test, typecheck (node + workers + viewer), lint, format:check, security:audit
  • Full e2e suite: 170/170 on chromium + webkit ✅

Notes

  • Replaces the approach on elucid/fix-stale-scroll-anchor (unpushed), which widened the settle window by watching iframe load events + 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.
  • The /s//p/ canonical URL rename from that branch is deliberately not included — it's a separate concern for its own PR.
  • Follow-up idea (separate): a stalled surface still renders as a title-only 24px strip until its fetch completes; a loading placeholder would make slow wifi read as "loading", not truncation.

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.
@elucid elucid marked this pull request as draft July 1, 2026 22:12
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.
@elucid elucid closed this Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant