Merge pull request #9140 from SivanCola/fix/windows-transcript-extent-oscillation

fix(desktop): preserve reader intent across extent rebounds / 修复会话高度回弹导致的反向跳动
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-19 23:51:15 +08:00
commit 89c983a9bc
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 = [];
+1 -1
View File
@@ -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;