fix(desktop): retain transcript anchor on plain click / 修复普通点击触发会话回跳 (#9721)

* fix(desktop): retain transcript anchor on plain click

Problem:
After manual reader scroll, a plain transcript click could snap Virtuoso back to a stale pre-wheel anchor.

Root cause:
Pointerdown claimed selection scroll ownership before a non-collapsed range existed, releasing the retained reader layout lease during an ordinary click. Settled selections also retained a historical ownership flag that could release a later reader lease again.

Fix:
Keep pointerdown provisional, claim selection ownership only for active native or logical ranges, mark ownership released when a gesture settles, and make later selection cleanup respect that state. Add deterministic and Chromium/WebKit regressions, stabilize the retained-heap sample without relaxing its 2 MiB gate, and integrate the narrow bundle ratchets with the latest main-v2 baseline.

Verification:
- pnpm test:transcript
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm test:transcript-browser
- PLAYWRIGHT_BROWSERS_PATH=.pw-browsers REASONIX_TRANSCRIPT_READER_ITERATIONS=0 pnpm test:transcript-reader-browser
- go run ./tools/repolint

* fix(desktop): preserve settled reader intent on click

Problem:
A plain transcript click after reader scrolling could recreate reader intent even though no selection owned the scroll surface. This permanently suppressed steady-state anchor compensation.

Root cause:
The provisional pointerdown path called the streaming and tail-follow cancellation callback before a non-collapsed selection claimed scroll ownership.

Fix:
Defer cancellation to the selection ownership claim. Add deterministic callback-count coverage and Chromium/WebKit assertions that a settled reader intent stays false while the layout lease, scroll offset, and visible anchor remain stable.

Verification:
- pnpm test:transcript
- pnpm test:typecheck
- pnpm build
- REASONIX_TRANSCRIPT_READER_ITERATIONS=0 pnpm test:transcript-reader-browser
- git diff --check
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-02 15:08:36 +08:00
1 parent ac17fb7a72
commit f8d0419cd4
6 files changed
+242 -34

No files matched your search

@@ -76,6 +76,21 @@ async function waitStable(page, requireTail = false) {
}), { requireTail });
}
async function loadPlainClickFixture(page) {
await page.click('.project-tree__topic-main:has-text("bench:selection-table")');
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:selection-table")
&& element instanceof HTMLElement
&& element.textContent?.includes("SELECTION REPAINT TARGET")
&& Number.parseInt(element.dataset.transcriptRowCount ?? "0", 10) > 0
&& element.dataset.transcriptHydrating === "false";
}, undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".transcript-navigation-overlay"), undefined, { timeout: 15_000 });
await waitStable(page);
return page.locator(".transcript");
}
async function loadLongFixture(page) {
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
await page.waitForFunction(() => {
@@ -143,6 +158,106 @@ async function loadLongFixture(page) {
return transcript;
}
async function runPlainClickHandoff(page, transcript, label) {
const box = await transcript.boundingBox();
if (!box) throw new Error(`${label}: transcript is unavailable for plain-click handoff`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -560);
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return element?.dataset.scrollMode === "manual"
&& element.dataset.transcriptReaderLayoutLease === "true";
}, undefined, { timeout: 2_000 });
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return element?.dataset.scrollMode === "manual"
&& element.dataset.transcriptReaderIntent === "false"
&& element.dataset.transcriptReaderLayoutLease === "true";
}, undefined, { timeout: 10_000 });
await waitStable(page);
const before = await page.evaluate(() => {
const element = document.querySelector(".transcript");
if (!(element instanceof HTMLElement)) return null;
const viewport = element.getBoundingClientRect();
const centerY = (viewport.top + viewport.bottom) / 2;
const ys = [];
for (let y = viewport.top + 48; y <= viewport.bottom - 48; y += 20) ys.push(y);
ys.sort((left, right) => Math.abs(left - centerY) - Math.abs(right - centerY));
const xs = [0.5, 0.35, 0.65].map((ratio) => viewport.left + viewport.width * ratio);
let click = null;
let row = null;
for (const y of ys) {
for (const x of xs) {
const hit = document.elementFromPoint(x, y);
if (!hit || hit.closest("button, a, input, textarea, select, [contenteditable='true']")) continue;
const selectable = hit.closest("[data-transcript-selectable]");
const candidateRow = selectable?.closest(".transcript__row[data-row-key]");
if (!selectable || !element.contains(selectable) || !(candidateRow instanceof HTMLElement)) continue;
click = { x, y };
row = candidateRow;
break;
}
if (row) break;
}
if (!(row instanceof HTMLElement) || !click) {
return {
diagnostic: {
selectableCount: element.querySelectorAll("[data-transcript-selectable]").length,
mountedRows: element.querySelectorAll(".transcript__row[data-row-key]").length,
visibleRows: [...element.querySelectorAll(".transcript__row")].filter((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.bottom > viewport.top && rect.top < viewport.bottom;
}).length,
centerHit: document.elementFromPoint((viewport.left + viewport.right) / 2, centerY)?.className,
},
};
}
const rowRect = row.getBoundingClientRect();
return {
rowKey: row.dataset.rowKey,
rowTop: rowRect.top - viewport.top,
scrollTop: element.scrollTop,
readerIntent: element.dataset.transcriptReaderIntent,
lease: element.dataset.transcriptReaderLayoutLease,
click,
};
});
if (!before?.rowKey) throw new Error(`${label}: no visible selectable row for plain-click handoff ${JSON.stringify(before?.diagnostic)}`);
await page.mouse.click(before.click.x, before.click.y);
await waitStable(page);
const after = await page.evaluate((rowKey) => {
const element = document.querySelector(".transcript");
if (!(element instanceof HTMLElement)) return null;
const viewport = element.getBoundingClientRect();
const row = [...element.querySelectorAll(".transcript__row[data-row-key]")]
.find((candidate) => candidate instanceof HTMLElement && candidate.dataset.rowKey === rowKey);
if (!(row instanceof HTMLElement)) return {
rowTop: null,
scrollTop: element.scrollTop,
mounted: element.querySelectorAll(".transcript__row[data-row-key]").length,
readerIntent: element.dataset.transcriptReaderIntent,
lease: element.dataset.transcriptReaderLayoutLease,
};
return {
rowTop: row.getBoundingClientRect().top - viewport.top,
scrollTop: element.scrollTop,
mounted: element.querySelectorAll(".transcript__row[data-row-key]").length,
readerIntent: element.dataset.transcriptReaderIntent,
lease: element.dataset.transcriptReaderLayoutLease,
};
}, before.rowKey);
const visualDelta = after?.rowTop == null ? Number.POSITIVE_INFINITY : Math.abs(after.rowTop - before.rowTop);
const scrollDelta = after == null ? Number.POSITIVE_INFINITY : Math.abs(after.scrollTop - before.scrollTop);
const detail = JSON.stringify({ before: { rowKey: before.rowKey, rowTop: before.rowTop, scrollTop: before.scrollTop, readerIntent: before.readerIntent, lease: before.lease }, after });
assert(before.readerIntent === "false" && before.lease === "true", `${label}: plain-click handoff starts after reader intent settles with its layout lease retained${before.readerIntent === "false" && before.lease === "true" ? "" : ` ${detail}`}`);
assert(after?.readerIntent === "false", `${label}: plain transcript click does not recreate reader intent${after?.readerIntent === "false" ? "" : ` ${detail}`}`);
assert(after?.lease === "true", `${label}: plain transcript click keeps the reader layout lease${after?.lease === "true" ? "" : ` ${detail}`}`);
assert(scrollDelta <= 2, `${label}: plain transcript click preserves scrollTop (${scrollDelta.toFixed(1)}px)${scrollDelta <= 2 ? "" : ` ${detail}`}`);
assert(visualDelta <= 2, `${label}: plain transcript click preserves the visible row anchor (${visualDelta.toFixed(1)}px)${visualDelta <= 2 ? "" : ` ${detail}`}`);
}
async function runIteration(page, transcript, label, iteration) {
const inputStates = [];
const setupMode = await transcript.getAttribute("data-scroll-mode");
@@ -310,6 +425,8 @@ async function runBrowser(browserType, label) {
}));
assert(final.distance <= 4 && final.mode === "tail-follow", `${label}: final viewport reaches the physical tail`);
assert(final.occupied, `${label}: final viewport has no blank range`);
const plainClickTranscript = await loadPlainClickFixture(page);
await runPlainClickHandoff(page, plainClickTranscript, label);
} finally {
await browser.close();
}
@@ -557,6 +557,8 @@ try {
const target = document.querySelector("[data-transcript-selectable]");
const transcript = document.querySelector(".transcript");
if (!(target instanceof HTMLElement) || !(transcript instanceof HTMLElement)) return "missing";
const text = document.createTreeWalker(target, NodeFilter.SHOW_TEXT).nextNode();
if (!(text instanceof Text) || text.data.length === 0) return "missing-text";
const rect = target.getBoundingClientRect();
target.dispatchEvent(new PointerEvent("pointerdown", {
bubbles: true,
@@ -566,9 +568,16 @@ try {
clientX: rect.left + 4,
clientY: rect.top + 4,
}));
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, Math.min(1, text.data.length));
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event("selectionchange"));
return transcript.dataset.scrollMode;
});
assert(staleSelectionMode === "selection", "lost WebView2 pointerup leaves selection owning transcript scroll");
assert(staleSelectionMode === "selection", "lost WebView2 pointerup after a real range leaves selection owning transcript scroll");
await page.mouse.click(
questionTargetPoint.x,
questionTargetPoint.y,
+38 -15
View File
@@ -58,13 +58,19 @@ async function waitForVisibleSelectionStart(page, { preferHighest, wheelDelta, t
range.selectNodeContents(node);
rects.push(...range.getClientRects());
}
const start = rects.find((rect) => rect.width > 8 && rect.bottom > viewport.top && rect.top < viewport.bottom) ?? candidate.rect;
const start = rects.find((rect) => {
const visibleWidth = Math.min(rect.right, viewport.right) - Math.max(rect.left, viewport.left);
return visibleWidth > 12 && rect.bottom > viewport.top && rect.top < viewport.bottom;
});
if (!start) return null;
const startX = Math.min(start.right - 4, start.left + Math.max(start.width * 0.45, 60));
const visibleLeft = Math.max(start.left, viewport.left);
const visibleRight = Math.min(start.right, viewport.right);
const visibleWidth = visibleRight - visibleLeft;
const startX = visibleLeft + visibleWidth * 0.3;
const y = (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2;
return {
start: { x: startX, y },
activate: { x: Math.min(start.right - 2, startX + 30), y },
activate: { x: visibleLeft + visibleWidth * 0.7, y },
edge: { x: startX, y: prefer ? viewport.top + 2 : viewport.bottom - 2 },
anchorTurn: candidate.turn,
};
@@ -295,10 +301,18 @@ try {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Performance.enable");
const retainedHeap = async () => {
await cdp.send("HeapProfiler.collectGarbage");
await page.waitForTimeout(100);
const metrics = await cdp.send("Performance.getMetrics");
return metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0;
let retained = Number.POSITIVE_INFINITY;
// Chromium can publish one stale post-GC heap sample while sweeping is
// still settling. Use the lowest of three explicit collections as the
// retained floor so the 2 MiB gate measures live data, not GC timing.
for (let attempt = 0; attempt < 3; attempt += 1) {
await cdp.send("HeapProfiler.collectGarbage");
await page.waitForTimeout(100);
const metrics = await cdp.send("Performance.getMetrics");
const sample = metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value;
if (typeof sample === "number") retained = Math.min(retained, sample);
}
return Number.isFinite(retained) ? retained : 0;
};
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
@@ -368,13 +382,14 @@ try {
downState = await page.evaluate(({ x, y }) => ({
mode: document.querySelector(".transcript")?.dataset.scrollMode,
target: document.elementFromPoint(x, y)?.outerHTML.slice(0, 300),
selectable: Boolean(document.elementFromPoint(x, y)?.closest("[data-transcript-selectable]")),
}), points.start);
if (downState.mode === "selection") break;
if (downState.mode === "manual" && downState.selectable) break;
await page.mouse.up();
await page.waitForTimeout(100);
points = await waitForVisibleSelectionStart(page, { preferHighest: true });
}
assert(downState.mode === "selection", `primary pointerdown transfers scroll ownership to selection (${downState.mode}; ${downState.target})`);
assert(downState.mode === "manual" && downState.selectable, `primary pointerdown keeps provisional selection in manual mode (${downState.mode}; ${downState.target})`);
await page.evaluate(() => {
window.__transcriptProgrammaticWrites = [];
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => {
@@ -382,7 +397,9 @@ try {
};
});
await page.mouse.move(points.activate.x, points.activate.y, { steps: 6 });
await page.waitForTimeout(50);
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "selection", undefined, { timeout: 2_000 });
const activeSelectionMode = await page.locator(".transcript").getAttribute("data-scroll-mode");
assert(activeSelectionMode === "selection", "a non-collapsed native range transfers scroll ownership to selection");
// Cross at least one mounted row before relying on logical edge scrolling.
// Small wheel steps expose a neighbouring selectable without skipping the
// 20-turn target range on fast Chromium/Windows runners.
@@ -611,12 +628,15 @@ try {
await page.evaluate(() => {
window.__logicalClipboardText = null;
});
let forwardDownMode = null;
let forwardDownState = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
await page.mouse.move(forwardPoints.start.x, forwardPoints.start.y);
await page.mouse.down();
forwardDownMode = await page.locator(".transcript").getAttribute("data-scroll-mode");
if (forwardDownMode === "selection") break;
forwardDownState = await page.evaluate(({ x, y }) => ({
mode: document.querySelector(".transcript")?.dataset.scrollMode,
selectable: Boolean(document.elementFromPoint(x, y)?.closest("[data-transcript-selectable]")),
}), forwardPoints.start);
if (forwardDownState.mode === "manual" && forwardDownState.selectable) break;
await page.mouse.up();
await page.waitForTimeout(100);
forwardPoints = await waitForVisibleSelectionStart(page, {
@@ -625,8 +645,8 @@ try {
timeout: 1_200,
});
}
assert(forwardDownMode === "selection",
`forward pointerdown transfers scroll ownership to selection (${forwardDownMode})`);
assert(forwardDownState.mode === "manual" && forwardDownState.selectable,
`forward pointerdown keeps provisional selection in manual mode (${JSON.stringify(forwardDownState)})`);
await page.evaluate(() => {
window.__transcriptProgrammaticWrites = [];
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => {
@@ -634,6 +654,9 @@ try {
};
});
await page.mouse.move(forwardPoints.activate.x, forwardPoints.activate.y, { steps: 6 });
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "selection", undefined, { timeout: 2_000 });
assert(await page.locator(".transcript").getAttribute("data-scroll-mode") === "selection",
"a forward non-collapsed range transfers scroll ownership to selection");
const forwardTargetTurn = forwardPoints.anchorTurn + 21;
let forwardFocus = null;
for (let index = 0; index < 120 && !forwardFocus; index += 1) {
@@ -176,8 +176,10 @@ console.log("\nbundle budgets");
// compact shared helpers keep the combined initial path within the same gate.
// Merge-Back adds identity-bound inspection, navigation, and retained-recovery
// orchestration on top. The merged stable build measures 461.338 KiB and the
// test channel measures 461.323 KiB; retain each exact one-decimal ceiling.
const initialJSBudgetKiB = 461.4;
// test channel measures 461.323 KiB. Deferring selection ownership until a
// real range exists (#9703/#9711) moves the combined startup path to 461.4
// KiB; retain only the next one-decimal ceiling.
const initialJSBudgetKiB = 461.5;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
@@ -316,7 +318,9 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// Merge-Back's startup ownership and failure-atomic navigation fence add the
// remaining bounded payload. The retained recovery receipt makes the stable
// path 2465.105 KiB raw; the merged test channel measures 2464.979 KiB.
// Retain only each channel's exact one-decimal ceiling.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_465.0 : 2_465.2;
// The #9703/#9711 provisional-selection handoff moves the measured stable path
// to 2465.3 KiB and the test channel to 2465.2 KiB; retain their exact
// one-decimal ceilings.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_465.2 : 2_465.3;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
@@ -81,11 +81,13 @@ function Harness({
tabId,
onReady,
setMode,
cancelStreamingScroll,
virtualRevision = 0,
}: {
tabId: string;
onReady: (api: RetentionApi) => void;
setMode: (mode: TranscriptScrollMode, reason?: string) => void;
cancelStreamingScroll: () => void;
virtualRevision?: number;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -99,7 +101,7 @@ function Harness({
selectableRows,
scrollRef,
setScrollMode: setMode,
cancelStreamingScroll: () => {},
cancelStreamingScroll,
});
useLayoutEffect(() => {
retention.reconcileLogicalFocus();
@@ -135,17 +137,45 @@ console.log("\ntranscript selection retention");
const root = createRoot(document.getElementById("root")!);
let api: RetentionApi | null = null;
let mode: TranscriptScrollMode = "tail-follow";
const modeTransitions: TranscriptScrollMode[] = [];
let streamingCancellationCount = 0;
const onReady = (next: RetentionApi) => { api = next; };
const setMode = (next: TranscriptScrollMode) => { mode = next; };
const setMode = (next: TranscriptScrollMode) => {
mode = next;
modeTransitions.push(next);
};
const cancelStreamingScroll = () => { streamingCancellationCount += 1; };
await act(async () => {
root.render(<Harness tabId="tab-a" onReady={onReady} setMode={setMode} />);
root.render(<Harness tabId="tab-a" onReady={onReady} setMode={setMode} cancelStreamingScroll={cancelStreamingScroll} />);
});
modeTransitions.length = 0;
const plainClickTarget = document.querySelector<HTMLElement>("[data-row-key='row-a'] [data-transcript-selectable]")!;
await act(async () => {
plainClickTarget.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0 }));
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
});
eq(transcriptSelectionStore.getSnapshot().mode, "none", "plain click clears its provisional native selection");
eq(modeTransitions, [], "plain click never acquires or releases selection scroll ownership");
eq(streamingCancellationCount, 0, "plain click keeps the settled reader transaction intact");
modeTransitions.length = 0;
await selectAcrossRows();
eq(mode, "manual", "settled native selection releases scroll ownership without delayed anchor reconciliation");
eq(modeTransitions, ["selection", "manual"], "a real native selection acquires ownership before releasing it");
eq(streamingCancellationCount, 1, "a real native selection cancels streaming follow exactly once");
modeTransitions.length = 0;
await act(async () => {
plainClickTarget.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0 }));
document.getSelection()?.removeAllRanges();
document.dispatchEvent(new window.Event("selectionchange"));
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
});
eq(transcriptSelectionStore.getSnapshot().mode, "none", "plain click dismisses a previously settled native selection");
eq(modeTransitions, [], "plain click after a settled selection does not release reader ownership again");
await act(async () => {
root.render(<Harness tabId="tab-b" onReady={onReady} setMode={setMode} />);
root.render(<Harness tabId="tab-b" onReady={onReady} setMode={setMode} cancelStreamingScroll={cancelStreamingScroll} />);
});
await drainFrames();
eq(mode, "tail-follow", "tab reset rejects delayed selection settle callbacks");
@@ -191,7 +221,7 @@ eq(frames.size, 2, "a false loaded-history boundary cannot strand the active dra
committedCaretOffset = 2;
await act(async () => {
root.render(<Harness tabId="tab-b" onReady={onReady} setMode={setMode} virtualRevision={1} />);
root.render(<Harness tabId="tab-b" onReady={onReady} setMode={setMode} cancelStreamingScroll={cancelStreamingScroll} virtualRevision={1} />);
});
api?.reconcileLogicalFocus();
await flushFramesOnce();
@@ -206,6 +236,9 @@ await act(async () => {
eq(transcriptSelectionStore.getSnapshot().mode, "logical-settled", "cross-row selection settles in logical mode when caret APIs are available");
eq(transcriptSelectionStore.getSnapshot().focus?.textOffset, 5, "pointerup applies its exact final focus before settling");
eq(mode, "manual", "settled logical selection releases scroll ownership");
modeTransitions.length = 0;
await act(async () => transcriptSelectionStore.clear("logical-action-complete"));
eq(modeTransitions, [], "clearing a settled logical selection does not release reader ownership again");
await act(async () => {
first.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 0, clientY: 10 }));
@@ -27,6 +27,7 @@ type TrackedSelection = {
focusKey: string;
dragging: boolean;
logical: boolean;
ownsScroll: boolean;
pointerId: number;
captureElement: HTMLElement;
};
@@ -99,17 +100,30 @@ export function useTranscriptSelectionRetention({
const clear = useCallback((reason = "clear") => {
const tracked = selectionRef.current;
if (!tracked && transcriptSelectionStore.getSnapshot().mode === "none") return;
const snapshot = transcriptSelectionStore.getSnapshot();
if (!tracked && snapshot.mode === "none") return;
// A pointerdown only opens a provisional browser selection. Releasing
// manual mode here would cancel the reader's retained layout lease and let
// Virtuoso contract to a stale pre-wheel anchor on an ordinary click
// (#9703/#9711). Only a non-collapsed selection owns scroll state.
const releaseScrollOwnership = tracked?.ownsScroll ?? snapshot.mode !== "none";
lifecycleGenerationRef.current += 1;
cancelFrames();
releasePointerCapture(tracked);
selectionRef.current = null;
lastPointerRef.current = null;
transcriptSelectionStore.clear(reason);
setScrollMode("manual", reason);
if (releaseScrollOwnership) setScrollMode("manual", reason);
publish();
}, [cancelFrames, publish, releasePointerCapture, setScrollMode]);
const claimScrollOwnership = useCallback((tracked: TrackedSelection, reason: string) => {
if (tracked.ownsScroll) return;
cancelStreamingScroll();
tracked.ownsScroll = true;
setScrollMode("selection", reason);
}, [cancelStreamingScroll, setScrollMode]);
// A fresh user gesture (e.g. the jump-to-bottom click) while a gesture is
// still marked dragging means the original pointerup was lost (released
// outside the window, WebView2 pointer quirks). Ending the stale gesture
@@ -187,8 +201,8 @@ export function useTranscriptSelectionRetention({
);
if (snapshotId == null) return false;
tracked.logical = true;
claimScrollOwnership(tracked, "cross-row-selection");
document.getSelection()?.removeAllRanges();
setScrollMode("selection", "cross-row-selection");
try {
tracked.captureElement.setPointerCapture(tracked.pointerId);
} catch {
@@ -197,7 +211,7 @@ export function useTranscriptSelectionRetention({
scheduleEdgeScroll();
publish();
return true;
}, [publish, scheduleEdgeScroll, setScrollMode, tabId]);
}, [claimScrollOwnership, publish, scheduleEdgeScroll, tabId]);
// Virtuoso can recycle the pointer-down row mid-drag. The browser then
// either collapses the native Range or migrates its anchor into whatever
@@ -240,21 +254,20 @@ export function useTranscriptSelectionRetention({
: null;
clear("new-pointer-selection");
lifecycleGenerationRef.current += 1;
cancelStreamingScroll();
selectionRef.current = {
anchorKey,
anchorPoint: anchorPoint?.rowKey === anchorKey ? anchorPoint : null,
focusKey: anchorKey,
dragging: true,
logical: false,
ownsScroll: false,
pointerId: event.pointerId,
captureElement: event.currentTarget,
};
lastPointerRef.current = { x: event.clientX, y: event.clientY };
transcriptSelectionStore.beginNative(tabId ?? "");
setScrollMode("selection", "pointerdown");
publish();
}, [cancelStreamingScroll, clear, publish, setScrollMode, tabId]);
}, [clear, publish, tabId]);
useEffect(() => {
const onSelectionChange = () => {
@@ -277,6 +290,10 @@ export function useTranscriptSelectionRetention({
if (!anchor || !focus) return;
tracked.focusKey = focus.rowKey;
transcriptSelectionStore.updateNativeRange(anchor, focus);
// Some WebViews deliver one final selectionchange after pointerup. The
// settled range may still update the selection store, but it must not
// reacquire scroll ownership after the gesture released it.
if (tracked.dragging) claimScrollOwnership(tracked, "native-selection");
if (anchor.rowKey === focus.rowKey || !supportsCaretPoint(document)) {
publish();
return;
@@ -323,6 +340,7 @@ export function useTranscriptSelectionRetention({
releasePointerCapture(tracked);
transcriptSelectionStore.settleLogical();
setScrollMode("manual", "logical-settled");
tracked.ownsScroll = false;
publish();
return;
}
@@ -333,6 +351,9 @@ export function useTranscriptSelectionRetention({
clear("empty-pointerup");
return;
}
// selectionchange normally claims ownership first, but WebView hosts may
// coalesce it with pointerup. Preserve the same transition in that case.
claimScrollOwnership(tracked, "native-selection");
transcriptSelectionStore.settleNative();
const settledSelection = { ...tracked };
const generation = lifecycleGenerationRef.current;
@@ -340,6 +361,7 @@ export function useTranscriptSelectionRetention({
publish();
if (generation === lifecycleGenerationRef.current && selectionRef.current === settledSelection) {
setScrollMode("manual", "native-selection-settled");
settledSelection.ownsScroll = false;
}
};
@@ -403,7 +425,7 @@ export function useTranscriptSelectionRetention({
document.removeEventListener("keydown", onKeyDown);
scroll?.removeEventListener("scroll", onScroll);
};
}, [clear, promoteDeadNativeGesture, promoteTrackedToLogical, publish, releasePointerCapture, scheduleEdgeScroll, scheduleLogicalFocus, scrollRef, setScrollMode, tabId, updateLogicalFocus]);
}, [claimScrollOwnership, clear, promoteDeadNativeGesture, promoteTrackedToLogical, publish, releasePointerCapture, scheduleEdgeScroll, scheduleLogicalFocus, scrollRef, setScrollMode, tabId, updateLogicalFocus]);
useEffect(() => {
lifecycleGenerationRef.current += 1;
@@ -427,7 +449,7 @@ export function useTranscriptSelectionRetention({
releasePointerCapture(tracked);
selectionRef.current = null;
lastPointerRef.current = null;
setScrollMode("manual", "logical-selection-cleared");
if (tracked.ownsScroll) setScrollMode("manual", "logical-selection-cleared");
publish();
}), [cancelFrames, publish, releasePointerCapture, setScrollMode]);