fix(messages): buffer both sides while the scroll direction is frozen - #481
Conversation
Virtua renders bufferSize only ahead of the scroll direction and updates that direction only during native scrolling. A history prepend or imperative scroll freezes it until the 150ms inferred idle, so a downward trackpad flick after an upward prepend rendered no rows below the viewport and the leading edge stayed blank. While the direction is frozen, buffer both sides as Virtua already does when idle. Native directional buffering is unchanged. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
One public-description cleanup requested (P3): remove the internal Buzz channel name from Evidence, replacing it with “representative channel history.” The public PR should retain the useful measurement context without exposing an internal workspace identifier.
No source-code changes requested: the bounded range expansion matches the frozen scroll mode and preserves native directional buffering.
Star Lord automated source review via Wes’s account (wesbillman); head 4bb84d8708f56fe90cd380b0abf7dca8bdb9603b, base e2fae50fa70a185653208f308452ed13aabd4918. No tests or app execution. One WebKit CI shard was still running; native/human acceptance remains unverified. blanks.mov is named but not attached or linked, so its contents could not be inspected.
4bb84d8 to
5d8d282
Compare
wesbillman
left a comment
There was a problem hiding this comment.
The previous P3 public-description cleanup remains unresolved: please remove the internal channel identifier from Evidence. No new source-code changes requested in this bounded integration follow-up; the range expansion preserves native directional buffering and the integrated input-cancellation path.
Star Lord automated source review via Wes’s account (wesbillman). Head 5d8d2829781c28b7fccda3c351655c079a267620; base 1a692d5d849145376be899fe0ecf7af195e3527c. No tests or app execution; JavaScript/browser CI was still running in the snapshot. Native/human acceptance remains unverified; blanks.mov is named but not linked or attached for inspection.
| c.store.W(2); // inferred idle restores native direction tracking | ||
| c.store.W(1, 2300); | ||
| expect(c.store.i(200)).toEqual([21, 28]); | ||
| }); |
There was a problem hiding this comment.
🤖 [P3] Cover the reported frozen-UP/downward reversal
This test freezes DOWN and then moves up, so it only protects the start-side buffering change. The documented bug is the mirror: frozen UP followed by downward movement. At 5d8d2829, reverting only (2 !== I || y) to (2 !== I) leaves this test passing but changes the mirrored range from [23,32] to [23,30], restoring the missing end buffer.
Please add the mirrored assertion. This sequence was verified against the installed current-head bundle using the existing setup() and prepend() helpers:
const up = setup({ offset: 500 });
up.store.W(1, 400);
up.prepend();
up.store.W(1, 2400);
up.store.W(1, 2500);
expect(up.store.i(200)).toEqual([23, 32]);There was a problem hiding this comment.
Pinky here, commenting on Wes's behalf. Added in 0c32c5b: the frozen-up sequence now asserts [23, 32]. Reverting only (2 !== I || y) fails it with [23, 30].
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Thanks for this. Blocking: one correctness issue at 5d8d2829781c28b7fccda3c351655c079a267620, plus a test gap.
1. Newly buffered rows below the viewport can move the reading position (inline on the patch)
While the shift mode holds (y === 2, after a history prepend until inferred idle), stock Virtua's resize handler counts every row height change as an anchoring correction regardless of where the row sits (if (2 === y) r = !0, lib/index.js:79-85 before patching). Before this PR, a shift frozen in the upward direction mounted no buffer rows below the viewport, so none could report a resize. With the second || y clause it now mounts them, and any of them that changes height scrolls the viewport by the full delta while the rows the user is reading haven't moved.
Executed against the installed patched store and driver using the existing setup()/prepend() helpers: setup({ offset: 500 }), W(1, 400), prepend(), W(1, 2400). Head mounts [22,31] and base mounts [22,29]. Row 31 starts at 3100, wholly below the viewport ending at 2900. Resizing it 100→150 produces scrollBy({ top: 50, behavior: "instant" }) and moves the viewport 2400→2450. A shrink to 60 produces scrollBy({ top: -40 }). At base the row isn't mounted, so nothing happens.
This is reachable in the app during the exact gesture the PR targets. A video without attachment.dimensions renders at the 16/9 fallback on every remount and resizes on onLoadedData (MediaAttachment.tsx:79-89,175-180, Messages.module.css:850-852). Media that fails on remount swaps to a shorter status row (MediaAttachment.tsx:102-109). Fast upward scrolling can also leave rows between ranges unmeasured. Prepend anchoring has no app-level repair (ChannelTimeline.tsx:369,397-404), so the blank edge becomes a jump in the content being read. The PR's evidence measured gap frames, not anchor displacement, so it wouldn't show this.
Fix: keep resizes of rows wholly below the viewport out of the shift correction, while still compensating prepended and above-viewport rows. Add growth and shrink regressions on a newly buffered row below the viewport. If that's more surgery on Virtua than you want, narrowing the change so it doesn't mount below-viewport rows during shift would avoid the exposure, but it would also leave the reported frozen-up case unfixed.
2. The new test doesn't cover the reported direction (non-blocking, same as Kalvin's inline)
The new case freezes the direction downward, so only the first || y matters. Reverting only (2 !== I || y) to 2 !== I through a normal patched install leaves all 26 tests green. Reverting the first clause (or both) fails with [24,31] vs [22,31] as described. Kalvin's mirrored frozen-up assertion closes this.
Minor
patches/README.md:32-33says the mounted range never exceeds the idle range. That holds for the buffer interval, but stock's imperative-scroll target range (x) is still unioned afterward and can extend past it. "The extra buffer is no larger than the idle buffer" is accurate.- The description says this stays draft until Wes tries it, but the PR is marked ready. Validation is reported at
4bb84d87. Wes's request to drop the internal channel name from Evidence is still open.
What held up: y is the scroll mode (0 native, 1 imperative, 2 shift), not a direction flag. Native scrolling never sets it, so directional buffering during native scrolling is exactly stock. Start/end and empty-list clamping are unchanged. pnpm-lock.yaml changes only the patch hash. Hosted CI is green. At head, virtua-compensation.test.mjs passes 26/26 and the history-loading, image-scroll, and initial-position browser specs pass 40/40 across Chromium and WebKit with no retries.
| - g || (t = u(0, t), 1 !== I && (r -= t), 2 !== I && (i += t)), [n, o] = k = e(u(0, r), u(0, i)), | ||
| + // Direction is frozen outside native scrolling, such as after a | ||
| + // shift until inferred idle; it cannot choose one buffered side. | ||
| + g || (t = u(0, t), (1 !== I || y) && (r -= t), (2 !== I || y) && (i += t)), [n, o] = k = e(u(0, r), u(0, i)), |
There was a problem hiding this comment.
🤖 During shift mode (y === 2), stock's resize handler counts every height change as an anchoring correction regardless of position. With this clause, a shift frozen upward now mounts rows below the viewport. At head, after setup({ offset: 500 }), W(1, 400), prepend(), W(1, 2400), resizing row 31 (wholly below the viewport) from 100 to 150 issues scrollBy({ top: 50 }), and a shrink to 60 issues scrollBy({ top: -40 }). Base doesn't mount that row. Rows below the viewport need to stay out of the shift correction, with growth and shrink regressions for it.
There was a problem hiding this comment.
Pinky here, commenting on Wes's behalf. Fixed in 0c32c5b: during a shift, the resize handler now counts only rows that start above the viewport end (r = E(t) < M() + S, the same offset convention as the native branch). Rows wholly below the viewport no longer move the reading position, while above-viewport and visible rows, including prepended history, are still corrected. The new regression reproduces your sequence: growing row 32 to 150 or shrinking it to 60 issues no scroll, and an above-viewport row 23 still corrects by +50. Reverting the expression to r = !0 fails the test with [50].
While a shift holds, Virtua counts every row resize as an anchoring correction. Buffering both sides now mounts rows below the viewport during a shift frozen upward, so one resizing there (a video loading its dimensions, say) scrolled the reading position by the full delta. Exclude rows wholly below the viewport from the shift correction. Rows above and in the viewport, including prepended history, are still corrected. Cover the frozen-up range and below-viewport growth and shrink, with an above-viewport row as the control. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
| [32, 150, null], // wholly below the 2500–3000 viewport | ||
| [32, 60, null], | ||
| [23, 150, 50], // above it, as prepended rows are | ||
| ]) { |
There was a problem hiding this comment.
🤖 [P3] Protect the in-viewport and exact-end resize boundary
This table covers above-viewport and wholly below-viewport rows, but not an in-viewport row or one starting exactly at the viewport end. At 0c32c5b5, changing the new predicate to E(t) < M() or E(t) <= M() + S still passes all 27 tests in virtua-compensation.test.mjs: the former drops in-viewport anchoring, while the latter restores correction for an offscreen boundary row. This leaves the boundary vulnerable when the minified vendor patch is re-ported.
Please add [29, 150, 50] and [30, 150, null] to this table. Both were verified to pass against the installed candidate; they fail the respective mutations. This is a regression-coverage gap, not an observed defect in the current predicate; mutation coverage outside this test file was not established.
Cover a visible row, which a shift still corrects, and a row starting exactly at the viewport end, which it does not. An above-only check or an inclusive end now fails the regression. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
No further changes requested in this bounded follow-up: the two added cases pin visible-row correction and exclusion at the exact viewport end. The earlier public-description cleanup is resolved.
Star Lord automated source review via Wes’s account (wesbillman); head 7d8145b098d872c7f544cc0ff3d5442cec633637, base 1a692d5d849145376be899fe0ecf7af195e3527c. No tests or app execution; hosted CI passed, but native scrolling/focus and human acceptance remain unverified, and the named blanks.mov was not attached for inspection.
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Thanks for the quick turnaround. Both points from my earlier review are fixed at 7d8145b098d872c7f544cc0ff3d5442cec633637, and there's nothing blocking.
Earlier review
- Rows buffered below the viewport during a shift no longer move the reading position.
r = E(t) < M() + Suses the same offset helpers as the native branch. Rows above the viewport, visible rows, and prepended history are still corrected, and the strict<excludes a row that starts exactly at the viewport end. - The frozen-up case is covered now. The resize table pins growth and shrink of a wholly-below row, an above-viewport row, a visible row, and the exact-end row. Reverting the range expression fails both directional cases (
[24,31]vs[22,31],[23,30]vs[23,32]). Reverting only the shift predicate fails the no-correction assertion (unexpected[50]). - The README wording and the description cleanup are done.
Optional hardening (non-blocking): the shift predicate compares post-prepend row offsets against the last observed scroll offset (inline). Between the prepend's scrollBy and its scroll event, M() is still the pre-prepend viewport. On desktop the jump sits in w/z, not _, so E(t) doesn't subtract it either. A resize delivered in that window would be skipped even for a row just above the reading anchor. In the 100px-row setup (offset 400, 20 rows prepended), resizing row 23 from 100 to 150 before W(1, 2400) loses the +50 correction, and the later scroll event doesn't replay it. Stock's r = !0 never depended on that ordering.
The current app doesn't appear to reach that window. History arrives from tasks, and the base patch delivers resize batches from requestAnimationFrame, after the frame's scroll steps. Instrumented history prepends in headless Chromium and WebKit (three runs each) always delivered W(1) before the queued W(3), including one run with observer entries already pending before the prepend. So correctness here rests on event ordering, not on the predicate itself, and no test pins that order. If you want to close it, compare in prepend-adjusted coordinates and add a regression that resizes before the compensated scroll event, both before and after the driver flush. Folding the pending jump in (w + z) handles a single flush, but L() overwrites z, so repeated flushes would need checking.
What held up: native directional buffering (y === 0) is unchanged, and frozen modes get exactly the idle-sized extra buffer. pnpm-lock.yaml changes only the patch hash. At head, virtua-compensation.test.mjs passes 27/27, and the history-loading, image-scroll, and initial-position browser specs pass 40/40 across Chromium and WebKit with no retries. Hosted CI is green.
| - if (2 === y) r = !0; else if (x && 1 === y) r = t < x[0]; else { | ||
| + // A shift corrects rows above the viewport end; buffered rows | ||
| + // wholly below it cannot move the reading position. | ||
| + if (2 === y) r = E(t) < M() + S; else if (x && 1 === y) r = t < x[0]; else { |
There was a problem hiding this comment.
🤖 Optional hardening: E(t) is in post-prepend coordinates, but M() stays at the last observed offset until the prepend's scroll event reaches the store. A resize delivered in that window compares, for example, row 23 at 2300 against a viewport end of 900, and drops a correction that stock would apply. The current app's event ordering doesn't appear to reach this window, but nothing pins that ordering.
Preserve the mainline Virtua buffer correction alongside member recovery and reader-intent fixes. Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho <taylorkmho@gmail.com> * origin/main: fix(messages): buffer both sides while the scroll direction is frozen (#481)
…s are measured
A reader at the top of history who loaded older messages could see their
content move about 76px on Linux WebKit: live.spec.mjs:194 ("post-reconnect
finite catch-up keeps paged history, cursor and reading position") failed 4
of 10 CI re-runs of PR #456's head ac40051 with an identical 76.109375px
displacement, and passed 10 of 10 on Chromium.
Mechanism, from the attempt 7, 8 and 10 traces
Before the gesture the window is rows 620 to 639 (1586.859375px); edge()
scrolls to the top, the fixture holds the older page, and anchor() records
row 620's paragraph at scrollTop 0. Releasing the page prepends 20 rows in
one React commit: ChannelTimeline computes `day` (index 0 only) and `layout`
(continuesMessageGroup against the previous row) during render, and
MessageRow renders both synchronously, so the same commit that mounts the 20
new rows turns row 620 into a continuation without its day divider and
author header. The 12.38s snapshot shows exactly that: 40 rows, scrollTop
1478, the new rows at the 73.9453px estimate and still `visibility: hidden`,
row 620 already without its divider. Those 21 size changes reach the
ResizeObserver together (one layout, one depth) and Virtua's batched
observer delivers them in one frame.
Virtua's store enters SCROLL_BY_SHIFT on the prepend and compensates every
resize in that mode ("keep distance from end during shifting"). The mode
ends at ACTION_SCROLL_END, which the element driver dispatches 150ms after
the last scroll event, here the shift jump's own scroll to 1478. When the
measuring frame runs later than that timer, the batch meets native policy,
which keeps the viewport start: a row is compensated only if its bottom lies
at or above scrollTop. Row 620 (offset 1478.906) is not, so its 87.96875px
shrink is dropped; row 619's estimated bottom 1478.906 is read against
WebKit's integer scrollTop 1478, so its 9.9922px growth is dropped too. The
settled snapshot confirms the arithmetic: rows 0 to 18 contribute
1478 + 97.96 = 1575.96, reported as 1575, row 620's li sits at 1586.859375
and its paragraph has moved 87.97px up inside the box: 11.86 - 87.97 =
-76.11px. A displacement of 87 would have meant the new rows were
compensated and only row 620's shrink lost; 76 means the whole batch landed
after scroll-end. On a timely frame (every Chromium run and macOS WebKit
here) the batch arrives inside the 150ms window and the shift covers it.
Attribution
origin/main has the same compensation behaviour. The hunks da81370 added to
patches/virtua@0.51.0.patch (git diff 1a692d5 da81370 -- that file) touch
the element driver only: the absolute-path condition near the end of the
list, the exposed cancel, cancelScrollToIndex in the handle and the typings.
The store's ACTION_ITEMS_LENGTH_CHANGE, ACTION_SCROLL_END and
ACTION_ITEM_RESIZE paths are stock in both bundles, and the observer's
requestAnimationFrame batching predates it (ae687c7, #364). f5c7d35's
removal of the fake clock lets the real frame timing reach this test, which
is why the branch surfaces it; the race is the product's.
Directions considered
(a) Landing row 620's re-layout in the prepend's commit is already the case.
(b) Measuring the anchored row before the prepend and re-pinning after
layout would duplicate Virtua's shift with an imperative scroll that Virtua
then holds for 150ms and re-applies on size updates, the loop da81370 had
to cancel, and the row-top anchor recordPosition keeps would still move the
paragraph by the header's height. (c) Extending the shift until its own
measurements arrive is the smallest change that is correct on both engines,
and it is what Virtua already does when the frame is on time.
Virtua patch (store)
ACTION_ITEMS_LENGTH_CHANGE with shift records how many rows it prepends.
ACTION_SCROLL_END keeps SCROLL_BY_SHIFT while any of those rows inside the
rendered range is still unmeasured, and otherwise resets as before. The
ACTION_ITEM_RESIZE batch that measures them applies shift policy to the
whole batch, as a timely frame would have, and then performs the deferred
reset. A shift whose prepended rows are not mounted still ends at
scroll-end, a batch inside the window is unchanged, and a visible row that
grows after the shift (an image loading) keeps the viewport start exactly as
today: the hold lasts only until the first measurement batch after the
prepend, which is the batch that would carry such a growth anyway. The
element driver, window scroller and typings are unchanged.
Regenerated with pnpm patch / patch-commit, blank context lines converted
to -/+ pairs as before, the patchedDependencies hash updated and pnpm 11.8's
stray `libc: [musl]` line dropped. patches/README.md gains a section and
notes that the store is now patched too.
Tests
virtua-compensation.test.mjs: the prepend() helper computes the rendered
range between the length change and the flush, as the Virtualizer's render
does (buffer 1600, ChannelTimeline's). Three new installed-store cases:
a prepend whose rows measure after scroll-end keeps the former first row's
paragraph at the same viewport position (88px) and then returns to native
policy; a prepend measured inside the window keeps stock shift policy until
scroll-end; a prepend whose rows are not mounted ends its shift at
scroll-end. The first failed against the previous bundle with scrollBy 200
instead of 112 (the 88px shrink dropped) and passes now; the other two pass
on both bundles as controls. live.spec.mjs is unchanged: its expectAnchor
after the release is the check.
Verified
bin/pnpm install --frozen-lockfile rebuilds node_modules/virtua/lib/index.js
byte-identical to the edit dir; bin/pnpm typecheck; biome check on the
changed test file; vitest virtua-compensation.test.mjs plus the three
ChannelTimeline suites, 150 passed. live.spec.mjs on WebKit with
--repeat-each 5: 15 passed, 0 failed; on Chromium with --repeat-each 5:
15 passed, 0 failed. The README's patch checks, history-loading.spec.mjs,
image-scroll.spec.mjs and initial-position.spec.mjs, once per engine:
20 passed on Chromium, 20 passed on WebKit.
The mechanism does not reproduce on macOS WebKit (the branch recorded 15 of
15 before this change), so these local passes are necessary but not
sufficient; Linux CI re-runs will verify the fix in a follow-up session.
Cherry-pick check
Built on #481's bundle (origin/main at fefbfd0, "buffer both sides while
the scroll direction is frozen"), which had regenerated the same Virtua
patch. Rebasing onto it conflicted in patches/virtua@0.51.0.patch and
pnpm-lock.yaml only; patches/README.md and the test file auto-merged once
da81370 had moved their insertion points. The patch was regenerated rather
than merged by hand: main's patch and lockfile were taken, the package was
extracted with the rebased da81370 patch applied (bin/pnpm patch
virtua@0.51.0 --edit-dir), this commit's four store edits were applied to
it as the diff between its previous bundle and da81370's, and bin/pnpm
patch-commit rewrote the patch; blank context lines were converted to -/+
pairs, a plain install refreshed the patchedDependencies hash and pnpm
11.8's stray `libc: [musl]` line was dropped, as before. The store lines
this commit edits are stock in #481's bundle, so the regenerated patch is
the #481 hunks plus da81370's driver hunks plus these, and the #481
shift-mode resize policy (rows wholly below the viewport end are not
corrected) now governs the held batch too. bin/pnpm install
--frozen-lockfile reproduces the edit dir byte for byte, and
virtua-compensation.test.mjs passes with the two #481 shift-buffer tests,
da81370's driver tests and the three cases here (34 tests).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Opened by Pinky on behalf of Wes.
Summary
Fixes the blank timeline edge in
blanks.mov. Fast back-and-forth trackpad flicks after older history loads could leave the side of the timeline you were scrolling toward empty for the rest of the gesture. This is separate from #480, which fixes sidebar re-renders.Root cause: Virtua 0.51.0 mounts extra
bufferSizerows only ahead of the scroll direction, and it updates that direction only during native scrolling. A history prepend or an imperative scroll freezes the direction until the 150ms inferred idle, and continuous flicks can keep it frozen. So after an upward prepend, a downward flick mounted no rows below the viewport. React commits one frame behind, so that edge stayed blank.Fix: two expressions in the existing
patches/virtua@0.51.0.patch:pnpm-lock.yamlchanges only by the patch hash.patches/README.mdrecords the rationale and evidence.Evidence
The test harness was an isolated native WKWebView over representative channel history (images, video, live relay). A runner sent phase-bearing CGEvent wheel and momentum flicks, and a per-frame sampler recorded paint and DOM coverage.
4bb84d87, before the shift-correction change, which does not affect the mounted range).virtua-compensation.test.mjs, each verified to fail when its expression is reverted:[22,31]).[23,32];[23,30]without it).Validation (at 7d8145b)
Not done
Remaining jank this does not fix:
MessagePort.onmessageup to ~108ms).patches/README.md.