mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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]);
|
||||
|
||||
Reference in new issue
Block a user