fix(frontend): correct blank rebound before paint

Problem: Windows WebView2 can publish a restored transcript extent while the currently clamped viewport has no mounted Virtuoso row, exposing one blank frame before the rAF fallback runs.

Root cause: reader extent correction was only attempted from animation frames, so a rebound scroll delivery could already paint the transient empty viewport.

Fix: reuse the single reader-stability budget synchronously for a correctable blank rebound delivery, while retaining the stable covered-frame fallback. Add a focused race contract and a narrow raw bundle ratchet for the bounded handoff.

Verification: pnpm test:transcript; pnpm test:transcript-browser; five consecutive transcript scroll stability replays; pnpm test:remote; pnpm test:performance; pnpm build; repolint; git diff --check.
This commit is contained in:
SivanCola committed 2026-08-29 04:53:54 +08:00
1 parent 1458f69b79
commit babe0cd69a
3 files changed
+57 -7

No files matched your search

@@ -240,6 +240,9 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// The same shell-support surface moves the merged path from 2417.526 to
// 2422.371 KiB raw (+4.845 KiB). Retain 0.129 KiB of bounded headroom without
// widening unrelated chunk ceilings.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_422.5 : 2_422.5;
// The WebView2 extent rebound prepaint handoff adds 0.204 KiB raw so a native
// scroll delivery can restore mounted coverage before the next visible frame.
// Retain 0.096 KiB of headroom without widening gzip or chunk ceilings.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_422.8 : 2_422.8;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
@@ -82,11 +82,13 @@ Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable
let scrollByCalls = 0;
let lastScrollByTop = 0;
let mountCoverageOnScrollBy = false;
const virtuosoHandle = {
scrollBy: (options?: { top?: number }) => {
scrollByCalls += 1;
lastScrollByTop = options?.top ?? 0;
scrollElement.scrollTop += lastScrollByTop;
if (mountCoverageOnScrollBy) rowElement.getBoundingClientRect = () => rectAt(20);
},
scrollTo: (options?: { top?: number }) => {
scrollElement.scrollTop = options?.top ?? scrollElement.scrollTop;
@@ -162,6 +164,36 @@ check(scrollWrites.length === 1 && scrollWrites[0].owner === "reader-stability",
check(arbiter?.modeRef.current === "manual", "the correction preserves manual reader ownership");
reboundCoverageRow.remove();
// A rebound scroll delivery can arrive before the next animation frame. If
// the native extent has already exposed a blank viewport, spend the same
// single correction budget synchronously so the next paint has mounted rows.
await act(async () => arbiter?.reset());
scrollExtent = 5_000;
scrollElement.scrollTop = 2_000;
rowElement.getBoundingClientRect = () => rectAt(20);
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.releaseTailFollow());
await act(async () => arbiter?.onWheelIntent({
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 10,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
scrollExtent = 4_000;
scrollElement.scrollTop = 1_000;
rowElement.getBoundingClientRect = () => rectAt(1_000);
await act(async () => arbiter?.deliverScroll());
scrollExtent = 5_000;
scrollByCalls = 0;
mountCoverageOnScrollBy = true;
await act(async () => arbiter?.deliverScroll());
mountCoverageOnScrollBy = false;
check(scrollByCalls === 1 && rowElement.getBoundingClientRect().top === 20,
"a blank rebound delivery corrects before the next paint");
await flushFrames();
check(scrollByCalls === 1, "the prepaint rebound still spends one correction");
// Touch movement is incremental: the second touchmove protects only its own
// segment rather than replaying the distance from the original touchstart.
await act(async () => arbiter?.reset());
@@ -22,6 +22,8 @@ type ActiveReaderExtentGuard = TranscriptReaderExtentGuard & {
generation: number;
deadline: number;
frame: number | null;
prepaint: boolean;
tick?: () => void;
};
export function useTranscriptReaderExtentStability({
@@ -51,6 +53,14 @@ export function useTranscriptReaderExtentStability({
const guard = guardRef.current;
if (!element || guard?.element !== element) return false;
observeTranscriptReaderExtent(guard, element);
if (
transcriptReaderExtentCanCorrect(guard, element)
&& transcriptElementViewportIsBlank(element)
) {
guard.prepaint = true;
if (guard.frame !== null) cancelAnimationFrame(guard.frame);
guard.tick?.();
}
return transcriptReaderExtentHasCollapsed(guard);
}, [scrollRef]);
@@ -70,10 +80,13 @@ export function useTranscriptReaderExtentStability({
generation: generationRef.current,
deadline: Date.now() + READER_EXTENT_STABILITY_MS,
frame: null,
prepaint: false,
};
let reboundHeight: number | undefined;
const tick = () => {
active.frame = null;
const prepaint = active.prepaint;
active.prepaint = false;
if (
guardRef.current !== active
|| generationRef.current !== active.generation
@@ -89,14 +102,15 @@ export function useTranscriptReaderExtentStability({
clientHeight: element.clientHeight,
};
observeTranscriptReaderExtent(active, snapshot);
// WebView2 can publish the restored native extent one or two paints
// before Virtuoso mounts rows for it. Writing during that gap moves the
// viewport into an empty size tree. Wait for mounted coverage and one
// unchanged-height interval, then spend the single correction budget.
// WebView2 can publish the restored native extent before Virtuoso mounts
// rows for it. A rebound scroll delivery spends the correction before
// paint; the frame fallback waits for coverage and a stable height.
const viewportIsBlank = transcriptElementViewportIsBlank(element);
if (
transcriptReaderExtentCanCorrect(active, snapshot)
&& !transcriptElementViewportIsBlank(element)
&& reboundHeight === snapshot.scrollHeight
&& (prepaint
? viewportIsBlank
: !viewportIsBlank && reboundHeight === snapshot.scrollHeight)
) {
const row = active.anchor
? Array.from(element.querySelectorAll<HTMLElement>(".transcript__row[data-row-key]"))
@@ -128,6 +142,7 @@ export function useTranscriptReaderExtentStability({
}
active.frame = requestAnimationFrame(tick);
};
active.tick = tick;
guardRef.current = active;
active.frame = requestAnimationFrame(tick);
}, [cancel, generationRef, modeRef, scrollRef, writeCorrection]);