mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #9140 from SivanCola/fix/windows-transcript-extent-oscillation
fix(desktop): preserve reader intent across extent rebounds / 修复会话高度回弹导致的反向跳动
This commit is contained in:
13 files changed
+829
-71
No files matched your search
@@ -488,16 +488,13 @@ try {
|
||||
assert(beforeGrowth.anchorKey && beforeGrowth.grownKey, "bench exposes a visible anchor and mounted dynamic row above it");
|
||||
|
||||
const gestureStart = beforeGrowth.top;
|
||||
await transcript.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const visible = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const above = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom <= viewport.top)
|
||||
.sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom)[0];
|
||||
if (above instanceof HTMLElement) above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
|
||||
await transcript.evaluate((element, grownKey) => {
|
||||
const above = [...element.querySelectorAll(".transcript__row")]
|
||||
.find((row) => row.dataset.rowKey === grownKey);
|
||||
if (above instanceof HTMLElement) {
|
||||
above.dataset.growthReplayBasePadding = above.style.paddingBottom;
|
||||
above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
|
||||
}
|
||||
window.__reasonixScrollSamples = [];
|
||||
const sample = () => {
|
||||
const currentRows = [...element.querySelectorAll(".transcript__row")];
|
||||
@@ -510,7 +507,7 @@ try {
|
||||
};
|
||||
element.addEventListener("scroll", sample, { passive: true });
|
||||
sample();
|
||||
});
|
||||
}, beforeGrowth.grownKey);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const afterGrowth = await transcript.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
@@ -518,6 +515,120 @@ try {
|
||||
}));
|
||||
assert(afterGrowth.top >= gestureStart - 2, `dynamic measurement never reverses an upward gesture into a multi-screen jump (${gestureStart} → ${afterGrowth.top})`);
|
||||
assert(afterGrowth.samples.every((sample) => sample.occupied), "dynamic measurement never exposes a blank transcript viewport");
|
||||
await transcript.evaluate((element, grownKey) => {
|
||||
const grown = [...element.querySelectorAll(".transcript__row")]
|
||||
.find((row) => row.dataset.rowKey === grownKey);
|
||||
if (!(grown instanceof HTMLElement)) return;
|
||||
grown.style.paddingBottom = grown.dataset.growthReplayBasePadding || "";
|
||||
delete grown.dataset.growthReplayBasePadding;
|
||||
}, beforeGrowth.grownKey);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
// Replay the returned 146-row Windows failure with real DOM geometry. A
|
||||
// downward wheel lands while the tail extent briefly loses 2,500px and regains
|
||||
// it 50ms later. Native clamping may move for the collapsed frame, but the
|
||||
// rebound must restore the user's logical direction without blank recovery.
|
||||
await transcript.evaluate((element) => {
|
||||
element.dataset.extentReplayOverflowAnchor = element.style.overflowAnchor;
|
||||
element.style.overflowAnchor = "none";
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 500);
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
await transcript.evaluate((element) => new Promise((resolve) => {
|
||||
const style = document.createElement("style");
|
||||
style.id = "reader-extent-replay-style";
|
||||
style.textContent = '.transcript[data-extent-replay-expanded="true"] > [data-viewport-type="element"]::after { content: ""; display: block; height: 2500px; }';
|
||||
document.head.append(style);
|
||||
element.dataset.extentReplayExpanded = "true";
|
||||
let stableFrames = 0;
|
||||
let previousHeight = element.scrollHeight;
|
||||
const settle = () => {
|
||||
const currentHeight = element.scrollHeight;
|
||||
stableFrames = Math.abs(currentHeight - previousHeight) <= 0.5 ? stableFrames + 1 : 0;
|
||||
previousHeight = currentHeight;
|
||||
if (stableFrames >= 4) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(settle);
|
||||
};
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
await page.waitForTimeout(100);
|
||||
await moveToOuterReaderGutter(page, transcript);
|
||||
const beforeExtentReplay = await transcript.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const visible = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const anchor = visible.find((row) => row.getBoundingClientRect().top >= viewport.top) ?? visible[0];
|
||||
window.__readerExtentProbe = { writes: [], samples: [], done: false };
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => window.__readerExtentProbe.writes.push(write);
|
||||
const sample = () => {
|
||||
const current = document.querySelector(".transcript");
|
||||
if (!(current instanceof HTMLElement)) return;
|
||||
const rect = current.getBoundingClientRect();
|
||||
const occupied = [...current.querySelectorAll(".transcript__row")].some((row) => {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
return rowRect.bottom > rect.top && rowRect.top < rect.bottom;
|
||||
});
|
||||
window.__readerExtentProbe.samples.push({ top: current.scrollTop, height: current.scrollHeight, occupied });
|
||||
if (!window.__readerExtentProbe.done) requestAnimationFrame(sample);
|
||||
};
|
||||
element.addEventListener("wheel", () => requestAnimationFrame(() => {
|
||||
delete element.dataset.extentReplayExpanded;
|
||||
const collapsedTop = Math.max(0, element.scrollTop - 2_000);
|
||||
element.scrollTop = collapsedTop;
|
||||
setTimeout(() => {
|
||||
element.dataset.extentReplayExpanded = "true";
|
||||
// Chromium restores its own scroll anchor on macOS. Keep the returned
|
||||
// WebView2 trace's clamped landing so this replay exercises our guard.
|
||||
element.scrollTop = collapsedTop;
|
||||
}, 50);
|
||||
}), { capture: true, once: true });
|
||||
requestAnimationFrame(sample);
|
||||
return {
|
||||
top: element.scrollTop,
|
||||
anchorKey: anchor?.dataset.rowKey ?? null,
|
||||
anchorOffset: anchor ? anchor.getBoundingClientRect().top - viewport.top : null,
|
||||
};
|
||||
});
|
||||
assert(beforeExtentReplay.anchorKey, "extent replay starts from a visible logical anchor");
|
||||
await page.mouse.wheel(0, 133);
|
||||
await page.waitForTimeout(260);
|
||||
const afterExtentReplay = await transcript.evaluate((element, before) => {
|
||||
window.__readerExtentProbe.done = true;
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
|
||||
element.style.overflowAnchor = element.dataset.extentReplayOverflowAnchor || "";
|
||||
delete element.dataset.extentReplayOverflowAnchor;
|
||||
const anchor = [...element.querySelectorAll(".transcript__row")]
|
||||
.find((row) => row.dataset.rowKey === before.anchorKey);
|
||||
return {
|
||||
top: element.scrollTop,
|
||||
anchorOffset: anchor ? anchor.getBoundingClientRect().top - element.getBoundingClientRect().top : null,
|
||||
writes: window.__readerExtentProbe.writes,
|
||||
samples: window.__readerExtentProbe.samples,
|
||||
mode: element.dataset.scrollMode,
|
||||
};
|
||||
}, beforeExtentReplay);
|
||||
const readerStabilityWrites = afterExtentReplay.writes.filter((write) => write.owner === "reader-stability");
|
||||
const preservedDirection = afterExtentReplay.top >= beforeExtentReplay.top - 2;
|
||||
assert(preservedDirection, preservedDirection
|
||||
? `transient extent rebound cannot reverse a downward wheel (${beforeExtentReplay.top} → ${afterExtentReplay.top})`
|
||||
: `transient extent rebound cannot reverse a downward wheel (${beforeExtentReplay.top} → ${afterExtentReplay.top}; anchor=${beforeExtentReplay.anchorOffset}→${afterExtentReplay.anchorOffset}; mode=${afterExtentReplay.mode}; writes=${JSON.stringify(afterExtentReplay.writes)}; samples=${JSON.stringify(afterExtentReplay.samples)})`);
|
||||
assert(readerStabilityWrites.length === 1, readerStabilityWrites.length === 1
|
||||
? "transient extent rebound uses one bounded reader-stability write"
|
||||
: `transient extent rebound uses one bounded reader-stability write (${readerStabilityWrites.length}; ${JSON.stringify(afterExtentReplay.samples)})`);
|
||||
assert(afterExtentReplay.writes.every((write) => write.owner !== "recovery"),
|
||||
"nonblank extent rebound never enters blank recovery");
|
||||
assert(afterExtentReplay.samples.every((sample) => sample.occupied),
|
||||
"transient extent rebound keeps mounted coverage in every sampled frame");
|
||||
await transcript.evaluate((element) => {
|
||||
delete element.dataset.extentReplayExpanded;
|
||||
document.getElementById("reader-extent-replay-style")?.remove();
|
||||
});
|
||||
|
||||
// Rapid direction changes are the exact user report. Sample every frame and
|
||||
// require that Virtuoso always maintains mounted coverage. Keep a real
|
||||
@@ -971,7 +1082,7 @@ try {
|
||||
// frames before watching the idle tail. A single bottom sample can race a
|
||||
// late markdown/row remeasurement on loaded CI runners and contaminate the
|
||||
// idle probe with legitimate convergence motion.
|
||||
await waitForStableTranscriptGeometry(reducedPage, { requireTail: true });
|
||||
await waitForStableTranscriptGeometry(reducedPage, { timeout: 30_000, requireTail: true });
|
||||
const reducedIdle = await reducedPage.evaluate(() => new Promise((resolve) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
const writes = [];
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
|
||||
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
|
||||
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
|
||||
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
|
||||
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
|
||||
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
|
||||
|
||||
@@ -74,7 +74,12 @@ console.log("\nbundle budgets");
|
||||
// recovery probe add less than 0.1% gzip; retain them with a 0.5 KiB (0.118%)
|
||||
// production ratchet rather than weakening either recovery contract. The
|
||||
// bounded allowance also covers small gzip drift from the embedded build SHA.
|
||||
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 428.0 : 425.5;
|
||||
// Reader extent stabilization adds 1.2 KiB gzip (0.28%) in production for its
|
||||
// bounded input, collapse, rebound, and ownership transaction. Retain it with
|
||||
// a 1.5 KiB (0.35%) ratchet instead of weakening the Windows scroll invariant.
|
||||
// Test diagnostics add 1.0 KiB gzip (0.23%) and 4.0 KiB raw (0.17%); keep that
|
||||
// raw allowance channel-specific so the production ceiling does not widen.
|
||||
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 428.0 : 427.0;
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
|
||||
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
|
||||
@@ -107,5 +112,6 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
.reduce((total, path) => total + statSync(path).size, 0);
|
||||
// The maintained Virtuoso engine adds 49.1 KiB raw (2.2%) over the previous
|
||||
// 2268.7 KiB gate. Retain 1% headroom to bound hash/minifier drift.
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_341 * 1024);
|
||||
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_344 : 2_341;
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -0,0 +1,203 @@
|
||||
// Run: tsx src/__tests__/transcript-reader-extent-race.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { VirtuosoHandle } from "react-virtuoso";
|
||||
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
|
||||
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(condition: unknown, label: string) {
|
||||
if (condition) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript reader extent races");
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.Node = dom.window.Node;
|
||||
|
||||
let nextFrame = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const requestFrame = (callback: FrameRequestCallback) => {
|
||||
const id = nextFrame;
|
||||
nextFrame += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
const cancelFrame = (id: number) => void frames.delete(id);
|
||||
globalThis.requestAnimationFrame = requestFrame;
|
||||
globalThis.cancelAnimationFrame = cancelFrame;
|
||||
dom.window.requestAnimationFrame = requestFrame;
|
||||
dom.window.cancelAnimationFrame = cancelFrame;
|
||||
|
||||
async function flushFrames() {
|
||||
const pending = [...frames.values()];
|
||||
frames.clear();
|
||||
await act(async () => pending.forEach((callback) => callback(performance.now())));
|
||||
}
|
||||
|
||||
const scrollWrites: TranscriptScrollWriteRecord[] = [];
|
||||
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };
|
||||
|
||||
const rectAt = (top: number) => ({
|
||||
top,
|
||||
bottom: top + 100,
|
||||
height: 100,
|
||||
left: 0,
|
||||
right: 800,
|
||||
width: 800,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
|
||||
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
|
||||
scrollElement.getBoundingClientRect = () => rectAt(0);
|
||||
rowElement.getBoundingClientRect = () => rectAt(20);
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
|
||||
let scrollExtent = 15_829;
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 14_567.47 });
|
||||
|
||||
let scrollByCalls = 0;
|
||||
let lastScrollByTop = 0;
|
||||
const virtuosoHandle = {
|
||||
scrollBy: (options?: { top?: number }) => {
|
||||
scrollByCalls += 1;
|
||||
lastScrollByTop = options?.top ?? 0;
|
||||
scrollElement.scrollTop += lastScrollByTop;
|
||||
},
|
||||
scrollTo: () => {},
|
||||
scrollToIndex: () => {},
|
||||
getState: () => {},
|
||||
} as unknown as VirtuosoHandle;
|
||||
|
||||
let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
|
||||
function Probe() {
|
||||
arbiter = useTranscriptScrollArbiter();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
await act(async () => root.render(<Probe />));
|
||||
await act(async () => {
|
||||
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
|
||||
arbiter!.scrollerRef(scrollElement);
|
||||
});
|
||||
|
||||
// Returned Windows geometry: the native extent collapses after a downward
|
||||
// wheel and rebounds while scrollTop remains clamped 1,949px too high.
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
scrollWrites.length = 0;
|
||||
await act(async () => arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 133.33,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>));
|
||||
scrollExtent = 13_344;
|
||||
scrollElement.scrollTop = 12_618.67;
|
||||
rowElement.getBoundingClientRect = () => rectAt(1_836);
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
check(arbiter?.modeRef.current === "manual",
|
||||
"a transient physical-bottom clamp cannot claim tail ownership");
|
||||
await act(async () => arbiter?.finishProgrammaticScroll());
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0, "the transaction waits while the native extent remains collapsed");
|
||||
scrollExtent = 15_829;
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
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");
|
||||
|
||||
// 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());
|
||||
scrollExtent = 5_000;
|
||||
scrollElement.scrollTop = 2_000;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await act(async () => arbiter?.onTouchStartIntent({
|
||||
touches: [{ clientY: 100 }],
|
||||
} as unknown as React.TouchEvent<HTMLElement>));
|
||||
await act(async () => arbiter?.onTouchMoveIntent({
|
||||
touches: [{ clientY: 90 }],
|
||||
} as unknown as React.TouchEvent<HTMLElement>));
|
||||
scrollElement.scrollTop = 2_010;
|
||||
await act(async () => arbiter?.onTouchMoveIntent({
|
||||
touches: [{ clientY: 80 }],
|
||||
} as unknown as React.TouchEvent<HTMLElement>));
|
||||
scrollExtent = 4_000;
|
||||
scrollElement.scrollTop = 1_000;
|
||||
rowElement.remove();
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
scrollExtent = 5_000;
|
||||
scrollByCalls = 0;
|
||||
lastScrollByTop = 0;
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 1 && lastScrollByTop === 1_020,
|
||||
`consecutive touch segments use incremental geometry (${lastScrollByTop}px)`);
|
||||
scrollElement.append(rowElement);
|
||||
|
||||
// Ordinary sub-viewport measurement jitter stays browser-owned, and a higher
|
||||
// priority selection cancels the still-pending transaction.
|
||||
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());
|
||||
scrollWrites.length = 0;
|
||||
scrollByCalls = 0;
|
||||
await act(async () => arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 133.33,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>));
|
||||
scrollElement.scrollTop = 1_960;
|
||||
rowElement.getBoundingClientRect = () => rectAt(60);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0 && scrollWrites.length === 0,
|
||||
"sub-viewport reverse jitter never earns a correction");
|
||||
await act(async () => arbiter?.setMode("selection", "test-reader-stability-preemption"));
|
||||
scrollElement.scrollTop = 1_000;
|
||||
rowElement.getBoundingClientRect = () => rectAt(1_060);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0 && scrollWrites.length === 0,
|
||||
"selection ownership cancels a pending reader transaction");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript reader extent race test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript reader extent race tests passed.`);
|
||||
@@ -0,0 +1,155 @@
|
||||
// Run: tsx src/__tests__/transcript-reader-extent-stability.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import type { TranscriptScrollEvent } from "../lib/transcriptScrollArbiter";
|
||||
import {
|
||||
createTranscriptReaderExtentGuard,
|
||||
observeTranscriptReaderExtent,
|
||||
resolveTranscriptReaderExtentCorrection,
|
||||
transcriptScrollEventCancelsReaderExtentGuard,
|
||||
transcriptKeyboardScrollDelta,
|
||||
transcriptReaderExtentCanCorrect,
|
||||
} from "../lib/transcriptReaderExtentStability";
|
||||
|
||||
console.log("\ntranscript reader extent stability");
|
||||
|
||||
const reported = createTranscriptReaderExtentGuard(
|
||||
{ scrollTop: 14_567.47, scrollHeight: 15_829, clientHeight: 725 },
|
||||
{ mode: "manual", rowKey: "visible-row", offset: 20 },
|
||||
133.33,
|
||||
)!;
|
||||
observeTranscriptReaderExtent(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
|
||||
);
|
||||
assert.equal(
|
||||
resolveTranscriptReaderExtentCorrection(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
|
||||
1_836,
|
||||
),
|
||||
undefined,
|
||||
"a still-collapsed extent cannot consume the correction budget",
|
||||
);
|
||||
const reportedCorrection = resolveTranscriptReaderExtentCorrection(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 725 },
|
||||
1_836,
|
||||
);
|
||||
assert.ok(reportedCorrection !== undefined && reportedCorrection > 1_900,
|
||||
`the returned Windows geometry restores its logical anchor (${reportedCorrection})`);
|
||||
|
||||
const fallbackCorrection = resolveTranscriptReaderExtentCorrection(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 725 },
|
||||
);
|
||||
assert.equal(Math.round(fallbackCorrection ?? 0), 2_082,
|
||||
"an unmounted anchor falls back to the expected native wheel landing");
|
||||
|
||||
assert.equal(
|
||||
transcriptReaderExtentCanCorrect(
|
||||
reported,
|
||||
{ scrollTop: 14_500, scrollHeight: 15_829, clientHeight: 725 },
|
||||
),
|
||||
false,
|
||||
"sub-viewport reverse jitter remains browser-owned",
|
||||
);
|
||||
assert.equal(
|
||||
transcriptReaderExtentCanCorrect(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
|
||||
),
|
||||
false,
|
||||
"a real persistent content shrink is not mistaken for a transient rebound",
|
||||
);
|
||||
assert.equal(
|
||||
transcriptReaderExtentCanCorrect(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 900 },
|
||||
),
|
||||
false,
|
||||
"viewport resize invalidates the reader geometry transaction",
|
||||
);
|
||||
|
||||
const upward = createTranscriptReaderExtentGuard(
|
||||
{ scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 },
|
||||
{ mode: "manual", rowKey: "visible-row", offset: 20 },
|
||||
-120,
|
||||
)!;
|
||||
observeTranscriptReaderExtent(
|
||||
upward,
|
||||
{ scrollTop: 1_400, scrollHeight: 4_200, clientHeight: 800 },
|
||||
);
|
||||
assert.equal(
|
||||
resolveTranscriptReaderExtentCorrection(
|
||||
upward,
|
||||
{ scrollTop: 2_600, scrollHeight: 5_000, clientHeight: 800 },
|
||||
-580,
|
||||
),
|
||||
-720,
|
||||
"an upward gesture corrects only a catastrophic downward reversal",
|
||||
);
|
||||
|
||||
const prepend = createTranscriptReaderExtentGuard(
|
||||
{ scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 },
|
||||
{ mode: "manual", rowKey: "visible-row", offset: 20 },
|
||||
-40,
|
||||
)!;
|
||||
observeTranscriptReaderExtent(
|
||||
prepend,
|
||||
{ scrollTop: 3_500, scrollHeight: 6_500, clientHeight: 800 },
|
||||
);
|
||||
assert.equal(
|
||||
resolveTranscriptReaderExtentCorrection(
|
||||
prepend,
|
||||
{ scrollTop: 3_500, scrollHeight: 6_500, clientHeight: 800 },
|
||||
20,
|
||||
),
|
||||
undefined,
|
||||
"prepended history growth preserves Virtuoso's logical-anchor compensation",
|
||||
);
|
||||
|
||||
const keyboardSnapshot = { scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 };
|
||||
assert.equal(transcriptKeyboardScrollDelta(" ", true, keyboardSnapshot), -720,
|
||||
"Shift+Space uses the browser's upward direction");
|
||||
assert.equal(transcriptKeyboardScrollDelta(" ", false, keyboardSnapshot), 720,
|
||||
"Space uses the browser's downward direction");
|
||||
assert.equal(transcriptKeyboardScrollDelta("Home", false, keyboardSnapshot), -2_000,
|
||||
"Home targets the native top");
|
||||
assert.equal(transcriptKeyboardScrollDelta("End", false, keyboardSnapshot), 2_200,
|
||||
"End targets the native bottom");
|
||||
const cancellingEvents: TranscriptScrollEvent["type"][] = [
|
||||
"RESET",
|
||||
"USER_SCROLL_INTENT",
|
||||
"MANUAL_READING",
|
||||
"VIEWPORT_RESIZED",
|
||||
"USER_RESIZE_BEGIN",
|
||||
"SELECTION_BEGIN",
|
||||
"PROGRAMMATIC_BEGIN",
|
||||
"JUMP_TO_BOTTOM",
|
||||
"JUMP_TO_INDEX",
|
||||
"SCROLL_TO_OFFSET",
|
||||
"RECOVERY_BEGIN",
|
||||
];
|
||||
for (const event of cancellingEvents) {
|
||||
assert.equal(transcriptScrollEventCancelsReaderExtentGuard(event), true,
|
||||
`${event} cancels stale reader geometry`);
|
||||
}
|
||||
|
||||
const observingEvents: TranscriptScrollEvent["type"][] = [
|
||||
"READER_INTENT_ENDED",
|
||||
"SCROLL_DELIVERED",
|
||||
"TAIL_CONTENT_CHANGED",
|
||||
"CONTENT_SHRANK",
|
||||
"LAYOUT_HEIGHT_CHANGED",
|
||||
"USER_RESIZE_END",
|
||||
"SELECTION_END",
|
||||
"PROGRAMMATIC_END",
|
||||
"RECOVERY_END",
|
||||
];
|
||||
for (const event of observingEvents) {
|
||||
assert.equal(transcriptScrollEventCancelsReaderExtentGuard(event), false,
|
||||
`${event} leaves rebound observation active`);
|
||||
}
|
||||
|
||||
console.log("transcript reader extent stability tests passed");
|
||||
@@ -371,7 +371,7 @@ for (let i = 0; i < 6; i += 1) await flushFrames();
|
||||
check(scrollToCalls <= 2, `one jump-bottom transaction emits at most two effective writes (${scrollToCalls})`);
|
||||
check(
|
||||
scrollElement.scrollTop === scrollExtent - scrollElement.clientHeight,
|
||||
"the bounded jump-bottom transaction still converges on the final native bottom",
|
||||
`the bounded jump-bottom transaction still converges on the final native bottom (${scrollElement.scrollTop}/${scrollExtent - scrollElement.clientHeight})`,
|
||||
);
|
||||
|
||||
scrollExtent = 500;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run: tsx src/__tests__/transcript-state-snapshot.test.ts
|
||||
|
||||
import type { StateSnapshot } from "react-virtuoso";
|
||||
import { resolveTranscriptStateSnapshot } from "../lib/transcriptStateSnapshot";
|
||||
import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso";
|
||||
import { captureTranscriptVirtuosoState, resolveTranscriptStateSnapshot } from "../lib/transcriptStateSnapshot";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -18,6 +18,12 @@ function check(condition: unknown, label: string) {
|
||||
|
||||
console.log("\ntranscript state snapshot");
|
||||
|
||||
const liveSnapshot = { ranges: [{ startIndex: 0, endIndex: 1, size: 100 }], scrollTop: 42 } as StateSnapshot;
|
||||
const fakeHandle = { getState: (callback: (snapshot: StateSnapshot) => void) => callback(liveSnapshot) } as VirtuosoHandle;
|
||||
check(captureTranscriptVirtuosoState(null) === null, "a missing Virtuoso handle has no snapshot");
|
||||
check(captureTranscriptVirtuosoState(fakeHandle) === liveSnapshot,
|
||||
"snapshot capture returns Virtuoso's synchronous measured state");
|
||||
|
||||
const base: StateSnapshot = {
|
||||
scrollTop: 420,
|
||||
ranges: [
|
||||
|
||||
@@ -14,6 +14,8 @@ export const NESTED_SCROLL_ATTR = "data-nested-scroll";
|
||||
|
||||
const EDGE_EPSILON_PX = 1;
|
||||
const DEFAULT_LINE_HEIGHT_PX = 16;
|
||||
const DOM_DELTA_PIXEL = 0;
|
||||
const DOM_DELTA_PAGE = 2;
|
||||
|
||||
export type NestedScrollHandoffOptions = {
|
||||
/** Outer reading scroller (`.transcript`). */
|
||||
@@ -31,10 +33,11 @@ export type NestedScrollHandoff = {
|
||||
|
||||
/** Normalize WheelEvent line/page deltas before applying them to scrollTop. */
|
||||
export function normalizeWheelDelta(event: Pick<WheelEvent, "deltaX" | "deltaY" | "deltaMode">, viewport: HTMLElement) {
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
|
||||
const deltaMode = event.deltaMode ?? DOM_DELTA_PIXEL;
|
||||
if (deltaMode === DOM_DELTA_PIXEL) {
|
||||
return { x: event.deltaX, y: event.deltaY };
|
||||
}
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||
if (deltaMode === DOM_DELTA_PAGE) {
|
||||
const page = Math.max(1, viewport.clientHeight);
|
||||
return { x: event.deltaX * page, y: event.deltaY * page };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { TranscriptLayoutAnchor } from "./transcriptVirtuosoRecovery";
|
||||
import type { TranscriptScrollEvent } from "./transcriptScrollArbiter";
|
||||
|
||||
const MIN_REVERSE_JUMP_PX = 96;
|
||||
const REVERSE_JUMP_VIEWPORT_RATIO = 0.5;
|
||||
const EXTENT_REBOUND_VIEWPORT_RATIO = 0.5;
|
||||
|
||||
export type TranscriptExtentSnapshot = {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
};
|
||||
|
||||
export type TranscriptReaderExtentGuard = {
|
||||
direction: -1 | 1;
|
||||
baselineTop: number;
|
||||
baselineHeight: number;
|
||||
minimumHeight: number;
|
||||
clientHeight: number;
|
||||
expectedTop: number;
|
||||
anchor?: Extract<TranscriptLayoutAnchor, { mode: "manual" }>;
|
||||
targetAnchorOffset?: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function createTranscriptReaderExtentGuard(
|
||||
snapshot: TranscriptExtentSnapshot,
|
||||
anchor: TranscriptLayoutAnchor | undefined,
|
||||
deltaY: number,
|
||||
): TranscriptReaderExtentGuard | undefined {
|
||||
if (!Number.isFinite(deltaY) || deltaY === 0 || snapshot.clientHeight <= 0) return undefined;
|
||||
const maxTop = Math.max(0, snapshot.scrollHeight - snapshot.clientHeight);
|
||||
const manualAnchor = anchor?.mode === "manual" ? anchor : undefined;
|
||||
return {
|
||||
direction: deltaY < 0 ? -1 : 1,
|
||||
baselineTop: snapshot.scrollTop,
|
||||
baselineHeight: snapshot.scrollHeight,
|
||||
minimumHeight: snapshot.scrollHeight,
|
||||
clientHeight: snapshot.clientHeight,
|
||||
expectedTop: clamp(snapshot.scrollTop + deltaY, 0, maxTop),
|
||||
anchor: manualAnchor,
|
||||
targetAnchorOffset: manualAnchor ? manualAnchor.offset - deltaY : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function observeTranscriptReaderExtent(
|
||||
guard: TranscriptReaderExtentGuard,
|
||||
snapshot: TranscriptExtentSnapshot,
|
||||
): void {
|
||||
guard.minimumHeight = Math.min(guard.minimumHeight, snapshot.scrollHeight);
|
||||
}
|
||||
|
||||
export function transcriptReaderExtentHasCollapsed(guard: TranscriptReaderExtentGuard): boolean {
|
||||
const collapseThreshold = Math.max(MIN_REVERSE_JUMP_PX, guard.clientHeight * REVERSE_JUMP_VIEWPORT_RATIO);
|
||||
return guard.baselineHeight - guard.minimumHeight >= collapseThreshold;
|
||||
}
|
||||
|
||||
export function transcriptReaderExtentCanCorrect(
|
||||
guard: TranscriptReaderExtentGuard,
|
||||
snapshot: TranscriptExtentSnapshot,
|
||||
): boolean {
|
||||
if (Math.abs(snapshot.clientHeight - guard.clientHeight) > 1) return false;
|
||||
const collapseThreshold = Math.max(MIN_REVERSE_JUMP_PX, guard.clientHeight * REVERSE_JUMP_VIEWPORT_RATIO);
|
||||
if (!transcriptReaderExtentHasCollapsed(guard)) return false;
|
||||
const reverseDisplacement = guard.direction > 0
|
||||
? guard.baselineTop - snapshot.scrollTop
|
||||
: snapshot.scrollTop - guard.baselineTop;
|
||||
const reverseThreshold = collapseThreshold;
|
||||
if (reverseDisplacement < reverseThreshold) return false;
|
||||
const reboundTolerance = Math.max(8, guard.clientHeight * EXTENT_REBOUND_VIEWPORT_RATIO);
|
||||
return snapshot.scrollHeight >= guard.baselineHeight - reboundTolerance;
|
||||
}
|
||||
|
||||
export function resolveTranscriptReaderExtentCorrection(
|
||||
guard: TranscriptReaderExtentGuard,
|
||||
snapshot: TranscriptExtentSnapshot,
|
||||
currentAnchorOffset?: number,
|
||||
): number | undefined {
|
||||
if (!transcriptReaderExtentCanCorrect(guard, snapshot)) return undefined;
|
||||
const maxTop = Math.max(0, snapshot.scrollHeight - snapshot.clientHeight);
|
||||
const anchorTarget = guard.anchor
|
||||
&& guard.targetAnchorOffset !== undefined
|
||||
&& currentAnchorOffset !== undefined
|
||||
&& Number.isFinite(currentAnchorOffset)
|
||||
? snapshot.scrollTop + currentAnchorOffset - guard.targetAnchorOffset
|
||||
: guard.expectedTop;
|
||||
const targetTop = clamp(anchorTarget, 0, maxTop);
|
||||
const correction = targetTop - snapshot.scrollTop;
|
||||
return guard.direction * correction > 1 ? correction : undefined;
|
||||
}
|
||||
|
||||
export function transcriptKeyboardScrollDelta(
|
||||
key: string,
|
||||
shiftKey: boolean,
|
||||
snapshot: TranscriptExtentSnapshot,
|
||||
): number | undefined {
|
||||
const page = Math.max(1, snapshot.clientHeight * 0.9);
|
||||
switch (key) {
|
||||
case "ArrowUp": return -40;
|
||||
case "ArrowDown": return 40;
|
||||
case "PageUp": return -page;
|
||||
case "PageDown": return page;
|
||||
case "Home": return -snapshot.scrollTop;
|
||||
case "End": return Math.max(0, snapshot.scrollHeight - snapshot.clientHeight - snapshot.scrollTop);
|
||||
case " ":
|
||||
case "Spacebar":
|
||||
return shiftKey ? -page : page;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function transcriptScrollEventCancelsReaderExtentGuard(type: TranscriptScrollEvent["type"]): boolean {
|
||||
return type === "RESET"
|
||||
|| type === "USER_SCROLL_INTENT"
|
||||
|| type === "MANUAL_READING"
|
||||
|| type === "VIEWPORT_RESIZED"
|
||||
|| type === "USER_RESIZE_BEGIN"
|
||||
|| type === "SELECTION_BEGIN"
|
||||
|| type === "PROGRAMMATIC_BEGIN"
|
||||
|| type === "JUMP_TO_BOTTOM"
|
||||
|| type === "JUMP_TO_INDEX"
|
||||
|| type === "SCROLL_TO_OFFSET"
|
||||
|| type === "RECOVERY_BEGIN";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX = 4;
|
||||
|
||||
export function nativeTranscriptDistanceFromBottom(element: {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
}) {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
}
|
||||
|
||||
export function nativeTranscriptBottomTop(element: { scrollHeight: number; clientHeight: number }) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
}
|
||||
|
||||
export function hasTranscriptScrollableRange(
|
||||
element: { scrollHeight: number; clientHeight: number },
|
||||
threshold = TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
) {
|
||||
return nativeTranscriptBottomTop(element) > threshold;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { StateSnapshot } from "react-virtuoso";
|
||||
import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
/**
|
||||
* One captured Virtuoso state snapshot bound to the row key sequence it was
|
||||
@@ -11,6 +11,14 @@ export type TranscriptStateSnapshot = {
|
||||
snapshot: StateSnapshot;
|
||||
};
|
||||
|
||||
/** Read Virtuoso's live measured tree and scrollTop synchronously. */
|
||||
export function captureTranscriptVirtuosoState(handle: VirtuosoHandle | null): StateSnapshot | null {
|
||||
if (!handle) return null;
|
||||
let state: StateSnapshot | null = null;
|
||||
handle.getState((snapshot) => { state = snapshot; });
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a captured snapshot only for the exact row sequence it measured.
|
||||
* React Virtuoso's restoreStateFrom contract requires the same data and
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type RefObject } from "react";
|
||||
import {
|
||||
createTranscriptReaderExtentGuard,
|
||||
observeTranscriptReaderExtent,
|
||||
resolveTranscriptReaderExtentCorrection,
|
||||
transcriptReaderExtentCanCorrect,
|
||||
transcriptReaderExtentHasCollapsed,
|
||||
type TranscriptReaderExtentGuard,
|
||||
} from "./transcriptReaderExtentStability";
|
||||
import type { TranscriptScrollMode } from "./transcriptScrollArbiter";
|
||||
import { nativeTranscriptDistanceFromBottom } from "./transcriptScrollGeometry";
|
||||
import type { TranscriptScrollWriteRecord } from "./transcriptScrollProbe";
|
||||
import { captureTranscriptLayoutAnchor } from "./transcriptVirtuosoRecovery";
|
||||
|
||||
const READER_EXTENT_STABILITY_MS = 180;
|
||||
|
||||
type ActiveReaderExtentGuard = TranscriptReaderExtentGuard & {
|
||||
element: HTMLDivElement;
|
||||
generation: number;
|
||||
deadline: number;
|
||||
frame: number | null;
|
||||
};
|
||||
|
||||
export function useTranscriptReaderExtentStability({
|
||||
generationRef,
|
||||
modeRef,
|
||||
scrollRef,
|
||||
writeCorrection,
|
||||
}: {
|
||||
generationRef: RefObject<number>;
|
||||
modeRef: RefObject<TranscriptScrollMode>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
writeCorrection: (write: TranscriptScrollWriteRecord) => boolean;
|
||||
}) {
|
||||
const guardRef = useRef<ActiveReaderExtentGuard | null>(null);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
const guard = guardRef.current;
|
||||
guardRef.current = null;
|
||||
if (guard?.frame != null) cancelAnimationFrame(guard.frame);
|
||||
}, []);
|
||||
|
||||
const observe = useCallback((element = scrollRef.current) => {
|
||||
const guard = guardRef.current;
|
||||
if (!element || guard?.element !== element) return false;
|
||||
observeTranscriptReaderExtent(guard, element);
|
||||
return transcriptReaderExtentHasCollapsed(guard);
|
||||
}, [scrollRef]);
|
||||
|
||||
const arm = useCallback((deltaY: number) => {
|
||||
cancel();
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const guard = createTranscriptReaderExtentGuard(
|
||||
element,
|
||||
captureTranscriptLayoutAnchor(element, false),
|
||||
deltaY,
|
||||
);
|
||||
if (!guard) return;
|
||||
const active: ActiveReaderExtentGuard = {
|
||||
...guard,
|
||||
element,
|
||||
generation: generationRef.current,
|
||||
deadline: Date.now() + READER_EXTENT_STABILITY_MS,
|
||||
frame: null,
|
||||
};
|
||||
const tick = () => {
|
||||
active.frame = null;
|
||||
if (
|
||||
guardRef.current !== active
|
||||
|| generationRef.current !== active.generation
|
||||
|| scrollRef.current !== active.element
|
||||
|| modeRef.current !== "manual"
|
||||
) {
|
||||
if (guardRef.current === active) guardRef.current = null;
|
||||
return;
|
||||
}
|
||||
const snapshot = {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
};
|
||||
observeTranscriptReaderExtent(active, snapshot);
|
||||
if (transcriptReaderExtentCanCorrect(active, snapshot)) {
|
||||
const row = active.anchor
|
||||
? Array.from(element.querySelectorAll<HTMLElement>(".transcript__row[data-row-key]"))
|
||||
.find((candidate) => candidate.dataset.rowKey === active.anchor?.rowKey)
|
||||
: undefined;
|
||||
const currentAnchorOffset = row
|
||||
? row.getBoundingClientRect().top - element.getBoundingClientRect().top
|
||||
: undefined;
|
||||
const correction = resolveTranscriptReaderExtentCorrection(active, snapshot, currentAnchorOffset);
|
||||
if (correction !== undefined && writeCorrection({
|
||||
owner: "reader-stability",
|
||||
kind: "scrollBy",
|
||||
top: correction,
|
||||
source: "layout-height-changed",
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
bottomDistance: nativeTranscriptDistanceFromBottom(element),
|
||||
mode: modeRef.current,
|
||||
})) {
|
||||
guardRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= active.deadline) {
|
||||
guardRef.current = null;
|
||||
return;
|
||||
}
|
||||
active.frame = requestAnimationFrame(tick);
|
||||
};
|
||||
guardRef.current = active;
|
||||
active.frame = requestAnimationFrame(tick);
|
||||
}, [cancel, generationRef, modeRef, scrollRef, writeCorrection]);
|
||||
|
||||
useEffect(() => cancel, [cancel]);
|
||||
|
||||
return useMemo(() => ({ arm, cancel, observe }), [arm, cancel, observe]);
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import type {
|
||||
TouchEvent as ReactTouchEvent,
|
||||
WheelEvent as ReactWheelEvent,
|
||||
} from "react";
|
||||
import type { SizeFunction, StateSnapshot, VirtuosoHandle } from "react-virtuoso";
|
||||
import type { SizeFunction, VirtuosoHandle } from "react-virtuoso";
|
||||
import { isEditableTarget } from "./keyboardShortcuts";
|
||||
import { findVerticalScrollTarget } from "./nestedScrollHandoff";
|
||||
import { findVerticalScrollTarget, normalizeWheelDelta } from "./nestedScrollHandoff";
|
||||
import { isNativeVerticalScrollbarPointer, measureTranscriptVirtuosoItem } from "./transcriptNativeScrollbar";
|
||||
import {
|
||||
INITIAL_TRANSCRIPT_SCROLL_STATE,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type TranscriptScrollOwner,
|
||||
type TranscriptScrollState,
|
||||
} from "./transcriptScrollArbiter";
|
||||
import { noteTranscriptRowMeasurement, noteTranscriptScrollWrite, recordTranscriptScrollDiagnostic } from "./transcriptScrollProbe";
|
||||
import { noteTranscriptRowMeasurement, noteTranscriptScrollWrite, recordTranscriptScrollDiagnostic, type TranscriptScrollWriteRecord } from "./transcriptScrollProbe";
|
||||
import {
|
||||
CAPTURE_TRANSCRIPT_SCROLL_DIAGNOSTICS,
|
||||
recordTranscriptScrollTransition,
|
||||
@@ -34,17 +34,22 @@ import type {
|
||||
TranscriptRecoveryRequestSpec,
|
||||
TranscriptRecoveryTerminal,
|
||||
} from "./transcriptScrollRecovery";
|
||||
import {
|
||||
transcriptScrollEventCancelsReaderExtentGuard,
|
||||
transcriptKeyboardScrollDelta,
|
||||
} from "./transcriptReaderExtentStability";
|
||||
import { hasTranscriptScrollableRange, nativeTranscriptBottomTop, nativeTranscriptDistanceFromBottom, TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX } from "./transcriptScrollGeometry";
|
||||
import type { TranscriptRow } from "./transcriptRows";
|
||||
import { captureTranscriptVirtuosoState } from "./transcriptStateSnapshot";
|
||||
import { captureTranscriptLayoutAnchor, type TranscriptLayoutAnchor } from "./transcriptVirtuosoRecovery";
|
||||
import { useTranscriptReaderExtentStability } from "./useTranscriptReaderExtentStability";
|
||||
export type {
|
||||
TranscriptRecoveryRequestSpec,
|
||||
TranscriptRecoveryTerminal,
|
||||
TranscriptScrollArbiterRecoveryApi,
|
||||
} from "./transcriptScrollRecovery";
|
||||
export { hasTranscriptScrollableRange, nativeTranscriptBottomTop, nativeTranscriptDistanceFromBottom, TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX };
|
||||
|
||||
const SCROLL_UP_KEYS = new Set(["ArrowUp", "PageUp", "Home"]);
|
||||
const SCROLL_DOWN_KEYS = new Set(["ArrowDown", "PageDown", "End", " ", "Spacebar"]);
|
||||
export const TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX = 4;
|
||||
const TAIL_STAGNANT_FRAME_LIMIT = 2;
|
||||
// Ignore one-frame extent oscillation; real growth remains displaced and
|
||||
// converges on the following frame (#9028/#9089).
|
||||
@@ -58,25 +63,6 @@ const ANCHOR_RESTORE_BUDGET_MS = 1_000;
|
||||
const RECOVERY_MAX_RETRIES = 2;
|
||||
const RECOVERY_CORRECTION_TOLERANCE_PX = 1;
|
||||
const RECOVERY_STABLE_FRAMES = 2;
|
||||
export function nativeTranscriptDistanceFromBottom(element: {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
}) {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
}
|
||||
|
||||
export function nativeTranscriptBottomTop(element: { scrollHeight: number; clientHeight: number }) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
}
|
||||
|
||||
export function hasTranscriptScrollableRange(
|
||||
element: { scrollHeight: number; clientHeight: number },
|
||||
threshold = TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
) {
|
||||
return nativeTranscriptBottomTop(element) > threshold;
|
||||
}
|
||||
|
||||
/** Single Virtuoso writer for tail-follow, jumps, selection, and recovery.
|
||||
* The reducer arbitrates selection > user > programmatic > recovery > tail. */
|
||||
export function useTranscriptScrollArbiter({
|
||||
@@ -124,6 +110,13 @@ export function useTranscriptScrollArbiter({
|
||||
const [nativeScrollbarDragging, setNativeScrollbarDragging] = useState(false);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null);
|
||||
const writeReaderCorrection = useCallback((write: TranscriptScrollWriteRecord) => {
|
||||
if (!virtuosoRef.current || write.top === undefined) return false;
|
||||
noteTranscriptScrollWrite(write);
|
||||
virtuosoRef.current.scrollBy({ top: write.top, behavior: "auto" });
|
||||
return true;
|
||||
}, []);
|
||||
const readerExtent = useTranscriptReaderExtentStability({ generationRef, modeRef, scrollRef, writeCorrection: writeReaderCorrection });
|
||||
|
||||
// Native-extent writes converge against real DOM geometry and avoid
|
||||
// scrollToIndex("LAST") retries against a stale Virtuoso size tree (#9028).
|
||||
@@ -257,7 +250,8 @@ export function useTranscriptScrollArbiter({
|
||||
followFrameRef.current = null;
|
||||
resizeSettleFrameRef.current = null;
|
||||
cancelTailSettle();
|
||||
}, [cancelTailSettle]);
|
||||
readerExtent.cancel();
|
||||
}, [cancelTailSettle, readerExtent]);
|
||||
|
||||
// Executes the reducer's CANCEL_RECOVERY command. The cancelling event
|
||||
// already cleared recoveryId in the published state, so no RECOVERY_END
|
||||
@@ -331,6 +325,7 @@ export function useTranscriptScrollArbiter({
|
||||
) {
|
||||
cancelTailSettle();
|
||||
}
|
||||
if (transcriptScrollEventCancelsReaderExtentGuard(event.type)) readerExtent.cancel();
|
||||
if (event.type === "RESET") lastGoodAnchorRef.current = null;
|
||||
if (event.type === "USER_SCROLL_INTENT") {
|
||||
const element = scrollRef.current;
|
||||
@@ -343,7 +338,7 @@ export function useTranscriptScrollArbiter({
|
||||
publishState(result.state);
|
||||
for (const command of result.commands) runCommand(command, source);
|
||||
return result;
|
||||
}, [cancelTailSettle, publishState, runCommand]);
|
||||
}, [cancelTailSettle, publishState, readerExtent, runCommand]);
|
||||
|
||||
const endReaderIntent = useCallback(() => {
|
||||
if (readerIntentTimerRef.current !== null) window.clearTimeout(readerIntentTimerRef.current);
|
||||
@@ -361,15 +356,16 @@ export function useTranscriptScrollArbiter({
|
||||
|
||||
const deliverScroll = useCallback((element = scrollRef.current) => {
|
||||
if (!element) return;
|
||||
const transientReaderClamp = readerExtent.observe(element);
|
||||
const distance = nativeTranscriptDistanceFromBottom(element);
|
||||
dispatch({
|
||||
type: "SCROLL_DELIVERED",
|
||||
atBottom: distance <= TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
atBottom: !transientReaderClamp && distance <= TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
scrollable: hasTranscriptScrollableRange(element),
|
||||
substantial: isSubstantialTranscriptDisplacement(distance),
|
||||
});
|
||||
if (stateRef.current.readerIntent) armReaderIntentIdle();
|
||||
}, [armReaderIntentIdle, dispatch]);
|
||||
}, [armReaderIntentIdle, dispatch, readerExtent]);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
|
||||
if (isTranscriptSelectionMode(modeRef.current)) return;
|
||||
@@ -599,7 +595,7 @@ export function useTranscriptScrollArbiter({
|
||||
setScrollElement((current) => current === element ? current : element);
|
||||
}, [deliverScroll, finishNativeScrollbarDrag, invalidateAsyncFrames]);
|
||||
|
||||
const releaseTailFollow = useCallback((claimPhysicalBottom = false) => {
|
||||
const releaseTailFollow = useCallback((claimPhysicalBottom = false, readerDeltaY?: number) => {
|
||||
if (isTranscriptSelectionMode(modeRef.current)) return;
|
||||
const element = scrollRef.current;
|
||||
if (element && !stateRef.current.scrollable && hasTranscriptScrollableRange(element)) {
|
||||
@@ -613,12 +609,14 @@ export function useTranscriptScrollArbiter({
|
||||
) {
|
||||
deliverScroll(element);
|
||||
}
|
||||
if (readerDeltaY !== undefined) readerExtent.arm(readerDeltaY);
|
||||
armReaderIntentIdle();
|
||||
}, [armReaderIntentIdle, deliverScroll, dispatch]);
|
||||
}, [armReaderIntentIdle, deliverScroll, dispatch, readerExtent]);
|
||||
|
||||
const followGrowingTail = useCallback(() => {
|
||||
layoutTransientRef.current = true;
|
||||
armLayoutTransientIdle();
|
||||
readerExtent.observe();
|
||||
if (followFrameRef.current !== null) return;
|
||||
const generation = generationRef.current;
|
||||
const scrollElement = scrollRef.current;
|
||||
@@ -637,7 +635,7 @@ export function useTranscriptScrollArbiter({
|
||||
}
|
||||
dispatch({ type: "LAYOUT_HEIGHT_CHANGED" });
|
||||
});
|
||||
}, [armLayoutTransientIdle, dispatch]);
|
||||
}, [armLayoutTransientIdle, dispatch, readerExtent]);
|
||||
|
||||
const beginUserResize = useCallback(() => {
|
||||
dispatch({ type: "USER_RESIZE_BEGIN" });
|
||||
@@ -676,15 +674,7 @@ export function useTranscriptScrollArbiter({
|
||||
endReaderIntent();
|
||||
}, [dispatch, endReaderIntent]);
|
||||
|
||||
// getState invokes its callback synchronously with the live measured tree
|
||||
// and scrollTop (header height excluded).
|
||||
const captureStateSnapshot = useCallback((): StateSnapshot | null => {
|
||||
const handle = virtuosoRef.current;
|
||||
if (!handle) return null;
|
||||
let state: StateSnapshot | null = null;
|
||||
handle.getState((snapshot) => { state = snapshot; });
|
||||
return state;
|
||||
}, []);
|
||||
const captureStateSnapshot = useCallback(() => captureTranscriptVirtuosoState(virtuosoRef.current), []);
|
||||
|
||||
const restoreTailIfNotScrollable = useCallback(() => {
|
||||
const element = scrollRef.current;
|
||||
@@ -694,12 +684,14 @@ export function useTranscriptScrollArbiter({
|
||||
}, [deliverScroll]);
|
||||
|
||||
const onWheelIntent = useCallback((event: ReactWheelEvent<HTMLElement>) => {
|
||||
if (event.ctrlKey || event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return false;
|
||||
const element = scrollRef.current;
|
||||
if (element && findVerticalScrollTarget(event.target, element, event.deltaY)) return false;
|
||||
if (!element || event.ctrlKey) return false;
|
||||
const delta = normalizeWheelDelta(event, element);
|
||||
if (delta.y === 0 || Math.abs(delta.x) > Math.abs(delta.y)) return false;
|
||||
if (findVerticalScrollTarget(event.target, element, delta.y)) return false;
|
||||
if (restoreTailIfNotScrollable()) return false;
|
||||
if (event.deltaY < 0 || !pinnedRef.current) {
|
||||
releaseTailFollow(event.deltaY > 0);
|
||||
if (delta.y < 0 || !pinnedRef.current) {
|
||||
releaseTailFollow(delta.y > 0, delta.y);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -715,7 +707,9 @@ export function useTranscriptScrollArbiter({
|
||||
if (start == null || current == null || Math.abs(current - start) < 2) return false;
|
||||
if (restoreTailIfNotScrollable()) return false;
|
||||
if (current > start || !pinnedRef.current) {
|
||||
releaseTailFollow(current < start);
|
||||
const deltaY = start - current;
|
||||
touchStartYRef.current = current;
|
||||
releaseTailFollow(deltaY > 0, deltaY);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -728,10 +722,13 @@ export function useTranscriptScrollArbiter({
|
||||
|
||||
const onKeyScrollIntent = useCallback((event: ReactKeyboardEvent<HTMLElement>) => {
|
||||
if (isEditableTarget(event.target)) return false;
|
||||
if (!SCROLL_UP_KEYS.has(event.key) && !SCROLL_DOWN_KEYS.has(event.key)) return false;
|
||||
const element = scrollRef.current;
|
||||
if (!element) return false;
|
||||
const deltaY = transcriptKeyboardScrollDelta(event.key, event.shiftKey, element);
|
||||
if (deltaY === undefined || deltaY === 0) return false;
|
||||
if (restoreTailIfNotScrollable()) return false;
|
||||
if (SCROLL_UP_KEYS.has(event.key) || !pinnedRef.current) {
|
||||
releaseTailFollow(SCROLL_DOWN_KEYS.has(event.key));
|
||||
if (deltaY < 0 || !pinnedRef.current) {
|
||||
releaseTailFollow(deltaY > 0, deltaY);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -757,7 +754,7 @@ export function useTranscriptScrollArbiter({
|
||||
const onNestedScrollIntent = useCallback((deltaY: number) => {
|
||||
if (deltaY === 0 || restoreTailIfNotScrollable()) return false;
|
||||
if (deltaY < 0 || !pinnedRef.current) {
|
||||
releaseTailFollow(deltaY > 0);
|
||||
releaseTailFollow(deltaY > 0, deltaY);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
Reference in new issue
Block a user