Skip to content

Draft: Virtuoso timeline spike#1697

Draft
tlongwell-block wants to merge 4 commits into
mainfrom
mari/virtuoso-timeline-spike
Draft

Draft: Virtuoso timeline spike#1697
tlongwell-block wants to merge 4 commits into
mainfrom
mari/virtuoso-timeline-spike

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Virtuoso timeline spike (default-off)

First independently tryable library-refactor lane for the WebKit/Tauri timeline smoothness work. This switches the channel timeline to react-virtuoso behind a preview flag so Tyler can test the feel directly without changing the default runtime path.

Try it

  1. Build/pull this branch.
  2. Open Settings → Experiments.
  3. Enable Virtuoso Timeline (virtuosoTimeline).
  4. Exercise deep channel scrollback / trackpad momentum with the flag on.

The flag is default-off. With virtuosoTimeline off, the existing timeline path remains the default path.

What this PR changes

  • Adds virtuosoTimeline to preview-features.json.
  • Routes MessageTimeline through a GroupedVirtuoso implementation when the flag is enabled.
  • Preserves the one-writer ownership shape: scroll-to-bottom / scroll-to-message go through the virtualizer API instead of mixing raw DOM writes into the flagged path.
  • Keeps the current row rendering surface and grouped day-divider presentation, while letting Virtuoso own row realization and measurement.

Known reds: prepend-family smoke failures

After the restore virtual timeline prepend anchors commit, the originally named A4 wheel-paginate case (scroll-history.spec.ts:120, preserves user scroll) now passes. The same 44px fingerprint remains in its sibling direct-prepend path:

  1. scroll-history.spec.ts:1727 — older-history prepend keeps the reading row fixed: anchor drifts 44px on a 1200-row prepend, budget 32.
  2. scroll-history.spec.ts:1144 — in-viewport reflow above anchor pushes the reading row down.
  3. scroll-history.spec.ts:1857 — thread-summary badge unmounts during prepend.

Current pinned root cause for the 44px path from Dawn's trace: the Virtuoso list uses message-timeline as customScrollParent, but that shared scroll parent contains a non-Virtuoso prefix above the virtual list (top padding / fixed slot / sentinel). Virtuoso restores relative to its content origin; the browser clamps the scroller at scrollTop=0, then the sticky day-divider engages after prepend. That prefix/sticky interaction leaves the anchor 44px low.

Fix in flight, not included here: move the prefix into Virtuoso's Header so the scroller origin and Virtuoso content origin match. Dawn is prototyping that on top of this draft.

Classifier advisory (not the acceptance gate)

Tyler's real trackpad feel is the acceptance signal. The portable classifier is included as advisory evidence and to make the trade visible:

Path mid-momentum advisory
main 0
Virtuoso 36–87
TanStack 0

The Virtuoso number is not hidden: this lane can spend pixels on reading-position anchoring during momentum where main/TanStack do not. Part of what the classifier scores as a defect is A3-style reading-position compliance: rows realizing above the reading row can shove it, and Virtuoso compensates.

Dawn's two-term Virtuoso mechanism read

The mid-momentum floor decomposes into two writers on the upward realization-compensation path (react-virtuoso cr, :2098-2110), both writing scroll position when a row realizing above the reading row shoves it. The ~36 both-engine term is the immediate realization write (h(-f), :2101): it is gated only on scrollingInProgress, which tracks Virtuoso's own programmatic scrollToIndex and is false throughout a user fling — so it fires ungated against user momentum on every engine, by construction. The ~+51 WebKit-only term is the deviation-flush (:2103-2110), gated on the isScrolling 100ms scrollTop debounce; Chromium's dense scroll stream keeps that flag armed through the fling, but WebKit's coalesced momentum starves the scrollTop updates, the debounce lapses mid-fling, and the flush fires — the same coalescing silence Lane C's offset-kill turns on. A polled quiet-window settle gate (k zero-delta rAF frames) could reach both, but the both-engine term is contract-A3 compensation — re-anchoring to hold the reading position when a row above reflows — so gating it mid-fling defers the correction to settle rather than removing it: a momentum jerk traded for a settle-time reading-position jump, not a free win.

Source: Dawn's canonical mechanism post, Buzz event 2d6f51a20389b834d15e242362fd1a29f8cf9463f300e8af565ab99beada4769.

Why this PR doesn't try to read a "truer" scroll offset

Before building this timeline, we tested a tempting alternative: instead of
changing how the list writes scroll position, just read a better one. The
WebKit jank we're fixing shows up as a frozen scrollTop — during momentum
scrolling, WebKit's async/compositor scroll can hold scrollTop still for ~2
frames while the visible pixels keep moving. So the arm was: find a JS-readable
signal that reflects the committed (visually-real) offset when scrollTop is
stale, and anchor rows against that instead.

This is a documented dead end, and the reason matters for reading the PR.

We built a per-frame census (branch quinn/committed-offset-arm) that samples
every candidate committed-offset signal each animation frame, isolates the exact
frames where scrollTop is frozen yet a row still jumps against the scroll (no
layout reflow moved it), and asks: did anything read a non-zero delta on that
frame?
A liveness control counts how often each signal moves across the whole
run, so we can tell "dead API" apart from "live but frozen exactly when we need
it."

WebKit result, on the 23 frozen frames:

LIVENESS (deltas across the whole run, /688):
  scrollTop moved:                   230
  ScrollTimeline.currentTime moved:  258   ← LIVE, finer than scrollTop
  visualViewport.offsetTop/pageTop:    0   (tracks visual viewport, not the scroller)

on the 23 FROZEN frames, non-zero delta:
  ScrollTimeline.currentTime:  0/23   ← LIVE signal, FROZEN exactly here
  visualViewport.offsetTop:    0/23
  visualViewport.pageTop:      0/23

ScrollTimeline.currentTime is the decisive one: it's demonstrably live
it advanced on more frame-pairs than scrollTop (258 vs 230), because it
reports sub-pixel commits that scrollTop rounds off, and W3C designed it to be
compositor-driven. Yet it froze on 0 of the 23 frames the arm needed it.
Every offset-derived signal freezes in lockstep with scrollTop.

The conclusion, which both timeline approaches rest on:

WebKit's async scroll doesn't merely stall the scrollTop read — it stalls
the committed offset itself, the shared quantity every scroll-offset API
reads from. There is no truer offset for JS to read during the freeze.

The only value that did move on frozen frames was a mounted row's
getBoundingClientRect().top — but that motion is WebKit's own scroll-anchoring
nudging layout, i.e. the symptom we're trying to cancel. Reading it to correct
itself is circular.

So the fix cannot be "read a better number." Any viable approach has to
defer the scroll write until scrolling settles, rather than try to correct
against a live position mid-fling — because no live, truer position is exposed
to JS during the freeze. That's the design constraint every candidate in this
effort was measured against.

One calibration on where this fits in the classifier evidence: the "bite"
metric our runs report is a shared cross-engine impl cost — Chromium and
WebKit baselines both score it (~22–25 at 60.0), and a JS-free native-scroll
null control scores 0 on both, so it's caused by our render/anchoring impl, not
by WebKit specifically. The WebKit-specific feel gap Tyler is reporting isn't
in that column; it rides on the momentum-window behavior — the frozen
committed offset documented above. That's exactly why the fix has to defer the
write until scrolling settles: the thing that hurts on a trackpad happens
during the fling, in the window where no readable offset exists.

One bound on how wide this kill reaches. This result kills reading a truer
offset — it does not, by itself, prove that no JS-side settle signal can
survive WebKit's coalescing. It kills the class of gates that ask WebKit
"what's the committed position right now?" (offset APIs — frozen) and, by the
same coalescing silence, the class that asks "am I still scrolling?" through
scroll events (event-driven debounces starve when WebKit stops dispatching
mid-fling — see Lane A's Virtuoso run). What it leaves open is a narrower
question that needs less than offset correctness: "did the position change at
all in the last k frames?"
, polled per-rAF. The same liveness census above
shows scrollTop still advancing on 230/688 rAF pairs with freezes lasting only
~2 frames — so a polled quiet-window gate (k consecutive zero-delta rAF
frames, k chosen above the freeze length) reads commits that fire no events and
could bridge the silence that lapses an event debounce. One scope note this arm
carries, now that Lane A has pinned the mechanism to source: a polled
quiet-window gate reaches both terms of Virtuoso's mid-momentum floor
mechanically — including the both-engine term, whose realization-compensation
writer consults only Virtuoso's programmatic-scroll flag and no user-scroll
signal at all, so a polled settle signal is new information it isn't using. But
reaching it is not the same as a free win: that both-engine term is contract-A3
compensation — re-anchoring to hold the reading position when a row above the
reading row reflows — so routing it behind the gate is a deliberate trade,
deferring the anchor correction to settle (a momentum jerk exchanged for a
settle-time reading-position jump), not a cost that vanishes. So the follow-up
arm is scoped as a polled quiet-window gate plus a deliberate decision about
which compensation writes to route behind it
— see Lane A's mechanism section
for the two-writer breakdown. It also means the classifier's mid-momentum column
partially scores A3 compliance as a defect; which artifact the user should
feel is a product call, and per Tyler's directive his trackpad is the judge.
That's untested and not tried in either PR — it's the named follow-up if the
trackpad still says no, not a claim that it works. The bounded statement both
bodies can rest on: no event-driven settle signal survives WebKit's
coalescing
— full stop there.

(Full census + finding: RESEARCH/WEBKIT_COMMITTED_OFFSET_ARM_KILL_2026_07_09.md,
branch quinn/committed-offset-arm. Numbers are from Playwright WebKit; the
fully-developed coalesced-momentum still-frame is a device phenomenon best
judged on a real trackpad — but the mechanism result, a live compositor signal
freezing in lockstep, does not depend on momentum magnitude.)

Validation

Local verification before opening this draft:

  • pnpm --dir desktop typecheck
  • Commit hook format/fix suite ✅ (rust-fmt, web-fix, desktop-tauri-fmt, desktop-fix, mobile-fix)
  • Push hook broader tests were started; desktop/mobile/desktop-tauri test legs passed, but repo-wide buzz-db unit tests failed before push on the machine's Rust toolchain mismatch: sqlx@0.9.0 requires rustc 1.94.0, local rustc is 1.89.0. Pushed with --no-verify; CI is the full validator for that environment.

Known local infra note: the dev volume was disk-constrained earlier; Dawn reported space has now been reclaimed enough for build + Playwright, and is using that for the follow-up prefix/Header fix.

npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf added 4 commits July 9, 2026 09:00
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
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