Skip to content

Flash-free scroll restore on open (editor + chat) + chat batch-load fixes#2276

Merged
requilence merged 14 commits into
developfrom
perf/scroll-restore-flash
Jun 29, 2026
Merged

Flash-free scroll restore on open (editor + chat) + chat batch-load fixes#2276
requilence merged 14 commits into
developfrom
perf/scroll-restore-flash

Conversation

@requilence

Copy link
Copy Markdown
Contributor

Why

Opening an object or chat restored the saved scroll after the content had already painted at the top → a visible flash + jump. This makes the restore happen before the first paint, so the first frame is already in the right place. While testing the chat side, three related batch-loading bugs surfaced and are fixed here too.

Editor

  • Pre-paint, element-anchored restore (useScrollRestore hook): save the topmost-visible block + offset on scroll; on open, position synchronously in useLayoutEffect (before paint), anchored to the element so it stays exact even as async content settles. Bounded ResizeObserver re-pins through the settle window; legacy pixel getScroll kept as fallback.
  • Hoisted the #blockLast filler measure so a near-bottom target is reachable on frame one; skip focus.scroll on open when a saved scroll exists.
  • Removed the 0.12s open fade (edit.tsx) — unnecessary latency once restore is pre-paint.

Chat

  • Pre-paint scroll on re-open — cached messages are already rendered at mount, so scroll straight to the last-read message / bottom before paint (no stale "past" frame, no jump). overflow-anchor holds it across init()'s async refresh.
  • Attachments no longer vanish on batch loadloadDeps destroys + re-subscribes the whole per-chat deps subscription; the batch paths passed only the new page, dropping already-loaded messages' attachments from S.Detail. Now subscribe deps for the full window.
  • Fast-scroll to the hard top no longer stallsoverflow-anchor is suppressed at scrollTop 0, so a fast scroll pinned the view there and the scroll-driven prefetch couldn't re-fire. At scrollTop 0 only, anchor the top message and restore its position pre-paint so the view lands off 0 and loads keep flowing.
  • Prefetch 2 viewports ahead of each edge (was 1) — more lead time so a fast scroll rarely reaches the edge in the first place.

Tests / verification

  • New unit tests: src/ts/lib/util/scrollAnchor.test.ts (restore math + settle tracker).
  • bun run typecheck + bun run lint clean.
  • Each behavior manually verified (editor open at saved position; chat re-open; attachments persist across batches; fast-scroll-to-top keeps loading).

Notes

  • Design spec + implementation plan included under docs/superpowers/.
  • Cold first-open of a chat (no cache) and full list virtualization are deliberately out of scope — tracked separately (see JS-9822 discussion).

On re-open the previous messages are still cached and rendered by the time a
useLayoutEffect runs, so scroll straight to the target (last-read message, else
bottom) BEFORE the first paint — the first frame is already positioned, with no
stale 'past' frame and no jump, matching the editor's pre-paint restore. No hide
or delay. init() refreshes asynchronously and re-confirms the same target;
Chromium's overflow-anchor keeps the viewport stable. Keyed to the chat subId so
in-place chat->chat switches re-position and same-chat dependency churn doesn't.
Cold opens (no cache) fall through to init()'s normal async positioning.
…ndow)

loadDeps destroys and re-subscribes the whole per-chat deps subscription to
only the ids it is given. The batch-load paths passed only the NEW page, so
already-loaded messages' image/file attachments were dropped from S.Detail and
their images vanished on every batch load. Subscribe deps for the full window
(existing + new) in loadMessages and subscribeMessages(non-clear), matching the
live-message path (onMessageAdd) that was already correct.
The 'load older' prefetch is scroll-event driven (fires when scrollTop is within
~1 viewport of the top), and the chat relies on the browser's overflow-anchor to
hold position on prepend. But overflow-anchor is suppressed at scrollTop 0, so a
fast scroll that reaches the hard top pins the view at 0: the prepend doesn't
shift it down and no further scroll event fires, so pagination stalls.

Fix: only at scrollTop 0, capture the top message before the prepend and restore
its position pre-paint (useLayoutEffect on dummy), landing the view just below
the newly-loaded batch — off 0 — so the prefetch keeps firing as you scroll. For
scrollTop > 0 nothing changes (overflow-anchor still handles it). The head is
never evicted by a prepend, so the anchor always survives.
@requilence
requilence merged commit f3dabda into develop Jun 29, 2026
3 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant