Skip to content

perf(desktop): make scrollback paging paint from memory and stop page cascades - #6601

Open
tlongwell-block wants to merge 7 commits into
mainfrom
meli/scrollback-paging
Open

perf(desktop): make scrollback paging paint from memory and stop page cascades#6601
tlongwell-block wants to merge 7 commits into
mainfrom
meli/scrollback-paging

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Summary

Scrollback in the desktop timeline felt slow even though the relay answers a history page in well under half a second: the second page lagged, and one upward gesture often pulled many pages. Research: RESEARCH/BUZZ_DESKTOP_SCROLLBACK_PAGING_DEEP_DIVE_2026_08_22.md (workspace). Four independent causes, one commit each.

What

Settle hold bounded; page cascades stoppedc9a26d31f
useSettleGatedPrependMessages held a landed page on any wheel/scroll input with a 4s deadline and assumed motion at hold start, so an at-rest reader paid a quiet window on every page and a reader with fingers on the trackpad waited seconds for a 450ms page. Motion is now tracked for the scroller's whole life (lastMotionTsRef) so an at-rest hold admits after the stable-frame count, and SETTLE_HOLD_DEADLINE_MS drops 4000 → 1500 (trackpad momentum — the only phase with the dropped-write hazard — decays well inside it). Retiring the hold directly on wheel was tried and rejected: ~30px anchor drift under live input.
loadOlderViaVirtualizer now also refuses while isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) — the same predicate the spinner uses — closing the resolve→commit gap where WebKit's at-rest scroll events started page N+1.

Page N+1 staged locallydb4bf9d33, 856b32b02, 7442713de (Max)
After a page commits, pageOlderMessages fetches exactly one successor into a stagedPage slot on ChannelWindowStore — outside projection, so nothing renders until the reader asks. The next pass consumes it when its startCursor equals the retained tail cursor, otherwise it is discarded; appendOlder/replaceNewestChannelWindow clear the slot so a head refresh can never serve stale chain data. In-flight stages are tracked per channel with a retainedTailIs identity guard, so a reader who outruns the stage awaits the one request already in flight instead of issuing a duplicate. The trigger moves from a 200px sentinel to offset <= viewportSize * 1.5, still wrapped by armUpwardMomentum so layout scrolls near the top cannot poison the reader's next input. Net: from the second page on, scrollback paints from memory while the network runs one page ahead.

Relay responses gzipped on the desktop legc706a0308 (Perci)
reqwest gzip for the app relay client: the measured 74KB history page negotiates its 26KB representation. Client construction split into http_client.rs; both media-proxy paths use a dedicated .no_gzip() + no-redirect client so upstream Content-Length/Content-Range survive for video seeking. No relay change: no seeded local benchmark exists to prove a ≥50ms win on the same REPEATABLE READ session.

Retention eviction held at the history boundary9bb578146 (Wren)
useTimelineRetention.onScrollEnd arms a 3s eviction guard when the reader ends a scroll within 1.5 viewports of the top, so rows are not evicted from under a fetch in flight (the −570/−590px mid-fetch scrollHeight shrink in traces B/C).

Related issue

None found for scrollback paging latency specifically; sibling perf PR is #6572 (see below).

Testing

Each behavioral test shown failing on the previous code.

  • useSettleGatedPrependMessages.test.mjs +2: at-rest hold admits without a charged quiet window; motion before the hold still charges it. Red 2/2 on old hook.
  • tests/e2e/virtualization.spec.ts 09 rewritten: 60 wheel bursts → commit gapSinceInput < 80ms, sinceSpinner < 1500 + slack, anchor drift at commit < 5px. Old hook: red (gap 125ms). New: 10/10 repeats.
  • tests/e2e/scroll-history.spec.ts "one gesture pages once": emulates WebKit's at-rest scroll events; ceiling ≤ 4≤ 1 (→ ≤ 2 fetches with L2 staging). Without the re-fire gate: fails at 2.
  • useTimelineRetention.test.mjs +1 (Wren, fail-first).
  • http_client.rs loopback contract tests (Perci): app client advertises+decodes gzip (red without the Cargo feature); media client does not advertise gzip.
  • pageOlderMessages.test.mjs +4 / channelWindowStore.test.mjs +3 (Max): one successor staged outside projection; next pass consumes it with no network wait; trigger during staging awaits the same request (one request total); a replaced tail does not reuse its stale in-flight stage (red with the identity guard removed); newest refresh drops the staged successor.
  • tests/e2e/scroll-history.spec.ts "one gesture pages once" extended: with the mock delay raised to 2s, the next gesture paints the staged page within 500ms and the request count rises by exactly one (the background stage).
  • tests/e2e/virtualization.spec.ts 08 rewritten: park at 2× viewport, wheel into the 1.5× band, pre-prepend rollback < 5px on both the staged-local and network commit paths. Red with shift={isPrepend}shift={false} (5020px).
  • tests/e2e/timeline-no-shift.spec.ts estimate-stability fixture shrunk to one channel-window page so "ordinary scroll" cannot enter the lookahead band; a deliberate estimate call from the scroll path still fails it (61 vs 51).
  • tests/e2e/scroll-history.spec.ts "fast middle-page scroll settles with continuous mounted coverage" now starts its burst with one wheel event — d73ba9c19. The test drove the scroller with bare scrollTop writes, which are not reader input, so bottom intent stayed armed; once the settle hold admitted pages ~100ms sooner (c9a26d31f) the prepend's extent resize re-pinned the floor mid-burst (197px hole, 4/10 WebKit, 5/12 Chromium; 0 on the previous commit, 0 on main). A real trackpad pass always starts with a wheel event, so this is test fidelity, not a product change. Fail-first: with cancel() no longer clearing bottom intent the wheel-first test is red 4/4 at 198px; clean product 16/16 WebKit, 12/12 Chromium.
  • Desktop suite 5406/5406, typecheck clean, biome unchanged (2 pre-existing warnings) — at 7442713de; d73ba9c19 is test-only on top (lefthook pre-commit clean). Tauri crate 2755 passed / 0 failed / 18 ignored at c706a0308; no Rust change since.
  • Playwright smoke shard 4/4 at 7442713de (251 tests, local darwin): 248 pass. The 3 failures are pre-existing on main 4baccd539: workflow-local-controls snapshot (local baseline drift) and thread-unread 07/11 (parallel-load flake: 4/40 on main vs 1/40 here, same assertion). Shards 1–3: this PR's CI.
  • Playwright webkit-paging project (scroll-history, timeline-no-shift, virtualization) at d73ba9c19: green except timeline-no-shift "prepend plus late row reflow", whose reflow sampler returns 0 samples ~2/20 here and 1/20 on main 4baccd539 — pre-existing sampler timing, branch-independent.
  • Reviews at exact head d73ba9c19 (Wren, git rev-parse HEAD asserted in the same shell): Minimalness 9.3 / Elegance 9.2 / Correctness 9.2 on code + automated evidence; L3 native contract tests 2/2, focused renderer/store/gate/retention 44/44, git diff --check clean.
  • Pending — live WKWebView pass. Gestures A/B/C from the deep-dive doc against the prod relay (fetch→paint per page, no geometry jump) plus one live <video> seek through the media proxy (range streaming on the .no_gzip() client). Agent sessions have no WindowServer/Accessibility (screencapture fails, Apple Events -1743), so this needs an interactive Desktop session; opened at Tyler's request with the gate called out rather than manufactured. Receipt to be added here before merge.

Relationship to #6572

Disjoint TS files. One Rust conflict in src-tauri/src/app_state.rs (its pool bump 10s/1 → 300s/2 vs the http_client.rs move); whichever lands second keeps crate::http_client::build() and ports 300s/2 into build() only (not base(), which also feeds the media client).

Not in this PR

Relay aux-hop latency (~130ms/page on prod) — motivation recorded, no safe seeded benchmark to prove a change; follow-up if wanted.

Wren and others added 7 commits August 22, 2026 18:44
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Scrollback paging felt slow even with a fast relay because a fetched page
could sit unpainted for seconds, and one gesture often pulled several pages.

useSettleGatedPrependMessages held a landed page on ANY wheel/scroll input
with a 4s deadline, and assumed motion at hold start — so even a scroller at
rest paid a full quiet window, and a reader keeping fingers on the trackpad
waited seconds for a 450ms page. Track motion for the scroller's whole life
(lastMotionTsRef) so an at-rest hold admits after the stable-frame count,
and cut SETTLE_HOLD_DEADLINE_MS to 1500: trackpad momentum — the only phase
with the dropped-write hazard — decays well inside that bound. Retiring the
hold directly on wheel was tried and rejected: it drifted the anchor ~30px
under live input.

loadOlderViaVirtualizer refused to re-fire only while isFetchingOlder or
the settle hold was set. Between the fetch resolving and the prepend
committing, WebKit's scroll events at the boundary started the next page,
cascading one gesture into many. Add isRenderedTimelineBehindHistoryPrepend
to the refuse predicate — the same signal the spinner already uses.

Tests (each fails on the previous code):
- useSettleGatedPrependMessages.test.mjs: at-rest hold admits without a
  charged quiet window; motion before the hold still charges it.
- virtualization 09: commit under 60 wheel bursts is bounded
  (gapSinceInput < 80ms, sinceSpinner < deadline+slack) and the anchor
  holds within 5px at commit.
- scroll-history "one gesture pages once": emulate WebKit's at-rest scroll
  events and tighten the ceiling from <=4 pages to <=1.

Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
"fast middle-page scroll settles with continuous mounted coverage" drove
the scroller with bare scrollTop writes right after a prepend. Those are
not reader input, so the virtualizer's bottom intent stayed armed, and
when the prepend's extent resize fired inside the burst window the
settle hook legitimately re-pinned the floor — the coverage read then
measured the wrong viewport (197px hole). The test used to win that race
only because the at-rest settle hold took ~100ms longer to admit the
page; bounding the hold made the race visible (4/10 on WebKit, 5/12 on
Chromium; 0 on the previous commit).

Dispatch one wheel event before the burst, as a real trackpad pass does.
Proven fail-first: with cancel() no longer clearing bottom intent the
test is red 4/4 at 198px; clean product 16/16 Chromium, 12/12 isolated.

Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner August 23, 2026 03:21
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