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