fix(frontend): wait for mounted extent rebound

Problem: WebView2 can restore the native transcript extent before Virtuoso has remounted visible rows, so reader-stability correction can expose a blank frame.

Root cause: the correction was eligible on the first rebound sample and did not require mounted viewport coverage or stable restored geometry.

Fix: gate the single correction on a nonblank viewport and one unchanged-height interval, with a regression test covering the rebound race.

Verification: pnpm test:transcript; pnpm test:transcript-browser; five consecutive transcript scroll stability replays; pnpm test:remote; pnpm test:performance; pnpm build; repolint.
This commit is contained in:
SivanCola committed 2026-08-29 04:07:45 +08:00
1 parent 79d612b601
commit 444b80284f
2 files changed
+27 -2

No files matched your search

@@ -69,8 +69,12 @@ const rectAt = (top: number) => ({
});
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
const reboundCoverageRow = dom.window.document.createElement("div");
reboundCoverageRow.className = "transcript__row";
reboundCoverageRow.dataset.rowKey = "row-ready";
scrollElement.getBoundingClientRect = () => rectAt(0);
rowElement.getBoundingClientRect = () => rectAt(20);
reboundCoverageRow.getBoundingClientRect = () => rectAt(20);
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
let scrollExtent = 15_829;
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
@@ -145,13 +149,18 @@ await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollByCalls === 0, "the transaction waits while the native extent remains collapsed");
scrollExtent = 15_829;
scrollElement.append(reboundCoverageRow);
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollByCalls === 0,
"the rebound waits for mounted coverage and stable restored geometry");
await flushFrames();
check(scrollByCalls === 1 && lastScrollByTop > 1_900,
`the rebound restores the logical anchor exactly once (${lastScrollByTop}px)`);
check(scrollWrites.length === 1 && scrollWrites[0].owner === "reader-stability",
"the correction is owned by reader stability rather than recovery or tail-follow");
check(arbiter?.modeRef.current === "manual", "the correction preserves manual reader ownership");
reboundCoverageRow.remove();
// Touch movement is incremental: the second touchmove protects only its own
// segment rather than replaying the distance from the original touchstart.
@@ -173,13 +182,16 @@ await act(async () => arbiter?.onTouchMoveIntent({
scrollExtent = 4_000;
scrollElement.scrollTop = 1_000;
rowElement.remove();
scrollElement.append(reboundCoverageRow);
await act(async () => arbiter?.deliverScroll());
scrollExtent = 5_000;
scrollByCalls = 0;
lastScrollByTop = 0;
await flushFrames();
await flushFrames();
check(scrollByCalls === 1 && lastScrollByTop === 1_020,
`consecutive touch segments use incremental geometry (${lastScrollByTop}px)`);
reboundCoverageRow.remove();
scrollElement.append(rowElement);
// Ordinary sub-viewport measurement jitter stays browser-owned, and a higher
@@ -10,7 +10,10 @@ import {
import type { TranscriptScrollMode } from "./transcriptScrollArbiter";
import { nativeTranscriptDistanceFromBottom } from "./transcriptScrollGeometry";
import type { TranscriptScrollWriteRecord } from "./transcriptScrollProbe";
import { captureTranscriptLayoutAnchor } from "./transcriptVirtuosoRecovery";
import {
captureTranscriptLayoutAnchor,
transcriptElementViewportIsBlank,
} from "./transcriptVirtuosoRecovery";
const READER_EXTENT_STABILITY_MS = 180;
@@ -68,6 +71,7 @@ export function useTranscriptReaderExtentStability({
deadline: Date.now() + READER_EXTENT_STABILITY_MS,
frame: null,
};
let reboundHeight: number | undefined;
const tick = () => {
active.frame = null;
if (
@@ -85,7 +89,15 @@ export function useTranscriptReaderExtentStability({
clientHeight: element.clientHeight,
};
observeTranscriptReaderExtent(active, snapshot);
if (transcriptReaderExtentCanCorrect(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.
if (
transcriptReaderExtentCanCorrect(active, snapshot)
&& !transcriptElementViewportIsBlank(element)
&& reboundHeight === snapshot.scrollHeight
) {
const row = active.anchor
? Array.from(element.querySelectorAll<HTMLElement>(".transcript__row[data-row-key]"))
.find((candidate) => candidate.dataset.rowKey === active.anchor?.rowKey)
@@ -109,6 +121,7 @@ export function useTranscriptReaderExtentStability({
return;
}
}
reboundHeight = snapshot.scrollHeight;
if (Date.now() >= active.deadline) {
guardRef.current = null;
return;