fix(desktop): retire prepend mode on every reader wheel (#2913)

## Summary

- retire Virtua prepend reconciliation on every ordinary reader wheel
event, including events suppressed from its separate wheel-timing
heuristic
- keep Ctrl+wheel browser zoom excluded from reader ownership
- arm the Ctrl+wheel prepend probe before pagination begins so it cannot
miss the commit
- preserve ESM/CJS patch parity and update the patch lock hash

## Why main was red

PR #2855 added the reader-wheel retirement action after Virtua's
existing suppression guard. Once the first event set that guard, later
events in the same wheel burst returned before retiring prepend mode. A
late ResizeObserver correction could then pull the viewport backward by
20–40px. The same test had failed twice on #2855 but passed its final
retry, so the PR job appeared green; the merge commit lost all three
retries.

The separate Ctrl+wheel failure was a test race: its MutationObserver
was registered after the request had already been triggered and could
miss the prepend commit.

## Validation

- desktop full unit suite: 3,515 passed
- pre-push desktop check: passed
- `git diff --check`: passed
- CI is the E2E verification; no local E2E was run

Fixes the main-branch failure in CI run 30180099007.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-25 17:22:15 -07:00
committed by GitHub
co-authored by Carl
parent 25e7864b35
commit 07d0265cfc
4 changed files with 49 additions and 33 deletions
@@ -21,7 +21,17 @@ test("reader wheel retires Virtua shift mode without publishing scroll end", ()
.filter(Boolean),
);
assert.deepEqual(addedActionBodies, [["I = 0;"], ["w = 0;"]]);
assert.match(patch, /\+\s+e\.q\(9\);/);
assert.match(patch, /\+\s+e\.B\(9\);/);
assert.match(
patch,
/if \(!e\.M\(\) \|\| t\.ctrlKey\) return;\n\+\s+e\.q\(9\);\n\+\s+if \(f\) return;/,
);
assert.match(
patch,
/if \(!e\.M\(\) \|\| t\.ctrlKey\) return;\n\+\s+e\.B\(9\);\n\+\s+if \(c\) return;/,
);
assert.doesNotMatch(
patch,
/\+\s+if \((?:f|c) \|\| !e\.M\(\) \|\| t\.ctrlKey\) return;/,
);
assert.doesNotMatch(patch, /\+\s+e\.(?:q|B)\(2\);/);
});
+26 -24
View File
@@ -252,6 +252,29 @@ test.describe("list virtualization", () => {
});
await page.waitForTimeout(150);
const before = await sampleVisibleAnchor();
const ctrlWheelPromise =
pageIndex === 0
? timeline.evaluate(
(scroller) =>
new Promise<boolean>((resolve) => {
const s = scroller as HTMLElement;
const observer = new MutationObserver(() => {
observer.disconnect();
// Ctrl+wheel is browser zoom, not reader scroll intent. Fire
// it synchronously with the prepend DOM commit, before the
// ResizeObserver measurement batch reconciles estimated rows.
s.dispatchEvent(
new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }),
);
resolve(true);
});
observer.observe(s.firstElementChild ?? s, {
childList: true,
subtree: true,
});
}),
)
: Promise.resolve(false);
const wheelTracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
let previousScrollTop = s.scrollTop;
@@ -297,25 +320,8 @@ test.describe("list virtualization", () => {
});
const committedAnchor = await sampleVisibleAnchor(before.id);
const motion = await timeline.evaluate(
async (scroller, { anchorId, anchorTop, oldHeight, testCtrlWheel }) => {
async (scroller, { anchorId, anchorTop, oldHeight }) => {
const s = scroller as HTMLElement;
let ctrlWheelDispatched = false;
const mutationObserver = testCtrlWheel
? new MutationObserver(() => {
if (ctrlWheelDispatched) return;
ctrlWheelDispatched = true;
// Ctrl+wheel is browser zoom, not reader scroll intent. Fire it
// synchronously with the prepend DOM commit, before the
// ResizeObserver measurement batch reconciles estimated rows.
s.dispatchEvent(
new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }),
);
})
: null;
mutationObserver?.observe(s.firstElementChild ?? s, {
childList: true,
subtree: true,
});
let maxDrift = 0;
let sawPrepend = false;
let sawAnchorAfterPrepend = false;
@@ -343,20 +349,16 @@ test.describe("list virtualization", () => {
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
mutationObserver?.disconnect();
return { ctrlWheelDispatched, maxDrift, sawPrepend };
return { maxDrift, sawPrepend };
},
{
anchorId: committedAnchor.id,
anchorTop: committedAnchor.top,
oldHeight: before.scrollHeight,
// Exercise browser-zoom input once while a real prepend transaction
// still owns its measurement reconciliation.
testCtrlWheel: pageIndex === 0,
},
);
expect(motion.sawPrepend).toBe(true);
if (pageIndex === 0) expect(motion.ctrlWheelDispatched).toBe(true);
if (pageIndex === 0) expect(await ctrlWheelPromise).toBe(true);
expect(motion.maxDrift).toBeLessThan(5);
await expect
+8 -4
View File
@@ -54,11 +54,13 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1
}
l && (d = 1 + (2147483647 & d), o && z && ($ += z, z = 0), O.forEach(([e, t]) => {
l & e && t(n);
@@ -186,6 +198,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
@@ -186,6 +198,8 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
c = l(), d && (a = !0), i && e.q(6, i()), e.q(1, n()), h();
}, p = t => {
if (f || !e.M() || t.ctrlKey) return;
- if (f || !e.M() || t.ctrlKey) return;
+ if (!e.M() || t.ctrlKey) return;
+ e.q(9);
+ if (f) return;
const r = l() - c;
150 > r && 50 < r && (o ? t.deltaX : t.deltaY) && (f = !0);
}, v = () => {
@@ -150,11 +152,13 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed
}
d && (i = 1 + (2147483647 & i), o && p && (g += p, p = 0), O.forEach(([e, t]) => {
d & e && t(n);
@@ -190,6 +202,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
@@ -190,6 +202,8 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
l = i(), d && (a = !0), s && e.B(6, s()), e.B(1, n()), h();
}, p = t => {
if (c || !e.M() || t.ctrlKey) return;
- if (c || !e.M() || t.ctrlKey) return;
+ if (!e.M() || t.ctrlKey) return;
+ e.B(9);
+ if (c) return;
const n = i() - l;
150 > n && 50 < n && (o ? t.deltaX : t.deltaY) && (c = !0);
}, v = () => {
+3 -3
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f
virtua@0.49.3: 63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc
virtua@0.49.3: 30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4
importers:
@@ -232,7 +232,7 @@ importers:
version: 2.1.0
virtua:
specifier: 0.49.3
version: 0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 0.49.3(patch_hash=30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
yaml:
specifier: ^2.8.3
version: 2.9.0
@@ -6926,7 +6926,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
virtua@0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
virtua@0.49.3(patch_hash=30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)