Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ console.log("\nbundle budgets");
// WebView2; the merged path measures 462.827 KiB. Retain one decimal step.
// Generation-bound native-thumb transactions and the rebased custom-scrollbar
// drag add 0.3 KiB gzip; the merged path measures 463.102 KiB.
const initialJSBudgetKiB = 463.2;
// Absorbing content-preserving block-window prepends into the active reader
// transaction adds 0.2 KiB gzip on top; the merged path measures 463.292 KiB,
// 8 bytes under the next decimal. Retain one cross-platform decimal step.
const initialJSBudgetKiB = 463.4;
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
Expand Down Expand Up @@ -337,6 +340,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// adds 0.5 KiB raw on top; the merged path measures 2469.815 KiB.
// The scrollbar generation fence and drag rebase add 1.1 KiB raw; the merged
// path measures 2470.932 KiB.
const rawInitialBudgetKiB = 2_471.0;
// The reader-transaction offset absorption adds 0.8 KiB raw on top; the merged
// path measures 2471.741 KiB.
const rawInitialBudgetKiB = 2_471.8;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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 { setTranscriptScrollDiagnosticSink, type TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";

let passed = 0;
Expand Down Expand Up @@ -188,6 +188,75 @@ await flushFrames();
syncItemListTransform();
check(scrollElement.dataset.transcriptReaderVisualGuard === undefined,
"the applied guard releases once the correction lands");
itemList.style.transform = "none";

// Field #9711 (d9cd713, Windows, all rows mounted): the reader scrolls up
// inside a long Markdown answer whose row starts above the viewport. The
// answer's block window prepends 7,252px of older blocks inside that row and
// compensates scrollTop by the same amount, so visible content does not move.
// The anchor row's top edge is now 7,252px higher relative to the viewport
// and scrollTop moved against the reader. Neither is a displacement of what
// the reader sees: the transaction must absorb the compensation instead of
// restoring the pre-prepend scrollTop and skipping the reader into the new
// blocks.
await act(async () => arbiter?.reset());
scrollExtent = 27_812;
scrollElement.scrollTop = 19_267;
// The long answer row starts 1,450px above the viewport and spans it.
const tallRowAt = (top: number) => ({ ...rectAt(top), bottom: top + 9_000, height: 9_000 });
rowElement.getBoundingClientRect = () => tallRowAt(-1_450 - (scrollElement.scrollTop - 19_267));
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.releaseTailFollow());
await act(async () => arbiter?.onWheelIntent({
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: -63.49,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
scrollElement.scrollTop = 19_204;
await act(async () => arbiter?.deliverScroll());
scrollByCalls = 0;
scrollWrites.length = 0;
const anomalies: Array<Record<string, unknown>> = [];
setTranscriptScrollDiagnosticSink((type, fields) => {
if (type === "scroll-anomaly") anomalies.push(fields);
});
// In-row prepend: extent grows above the visible blocks, the block window
// compensates scrollTop, the row's top edge moves up by the same amount.
scrollExtent += 7_252;
let compensated = false;
await act(async () => { compensated = Boolean(arbiter?.writeOffset("block-window-prepend", scrollElement.scrollTop + 7_252)); });
check(compensated && scrollElement.scrollTop === 19_204 + 7_252,
`the block-window prepend compensation is written through the arbiter (${scrollElement.scrollTop})`);
await act(async () => arbiter?.deliverScroll());
check(anomalies.length === 0,
`an in-row prepend with exact compensation is not a reader anomaly (${anomalies.length} recorded)`);
check(scrollElement.dataset.transcriptReaderVisualGuard === undefined,
"an in-row prepend with exact compensation raises no visual guard");
for (let frame = 0; frame < 4; frame += 1) await flushFrames();
check(scrollByCalls === 0 && scrollWrites.filter((write) => write.owner === "reader-stability").length === 0,
`the reader guard does not restore the pre-prepend scrollTop (${scrollByCalls} corrections)`);
check(scrollElement.scrollTop === 19_204 + 7_252,
`the compensated scrollTop survives (${scrollElement.scrollTop})`);
// The next wheel step continues from the compensated position.
scrollElement.scrollTop -= 190;
await act(async () => arbiter?.onWheelIntent({
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: -190.48,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
await act(async () => arbiter?.deliverScroll());
check(anomalies.length === 0, "continuing to scroll after the absorbed prepend stays anomaly-free");
// A genuine reverse jump after the absorbed prepend is still caught: the
// row moves up on screen by 700px without any scrollTop change.
rowElement.getBoundingClientRect = () => tallRowAt(-1_450 - 7_252 - (scrollElement.scrollTop - 19_267) - 700);
await act(async () => arbiter?.deliverScroll());
check(anomalies.length === 1 && Number(anomalies[0].reverseDisplacement) >= 96,
`a real displacement after the absorbed prepend is still detected (${anomalies.length})`);
setTranscriptScrollDiagnosticSink(() => {});

await act(async () => root.unmount());
dom.window.close();
Expand Down
34 changes: 33 additions & 1 deletion desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,37 @@ export function useTranscriptReaderExtentStability({
schedule(transaction);
}, [geometryCommitReadyRef, schedule]);

/**
* A content-preserving offset write (an in-row block-window prepend that
* grows the row above the reader's view and compensates scrollTop by the
* same amount) moves the native scrollTop and the anchor row's top edge
* without moving anything the reader sees. Re-baseline the transaction to
* the compensated position so neither shows up as a reverse displacement.
*/
const absorbOffsetWrite = useCallback((element: HTMLDivElement, delta: number) => {
const transaction = transactionRef.current;
if (!transaction || transaction.element !== element || delta === 0) return;
transaction.baselineTop += delta;
transaction.lastAcceptedTop += delta;
transaction.expectedTop = Math.max(0, Math.min(nativeTranscriptBottomTop(element), transaction.expectedTop + delta));
if (transaction.anchor) transaction.anchor.offset -= delta;
transaction.baselineHeight = element.scrollHeight;
transaction.minimumHeight = element.scrollHeight;
transaction.lastHeight = element.scrollHeight;
transaction.correctionHeight = element.scrollHeight;
transaction.transientCandidateHeight = element.scrollHeight;
transaction.transientStableFrames = 0;
transaction.lastBottomDistance = nativeTranscriptDistanceFromBottom(element);
recordTranscriptScrollDiagnostic("reader-transaction", {
transactionId: transaction.id,
ownershipEpoch: transaction.ownershipEpoch,
direction: transaction.direction,
phase: transaction.phase,
result: "absorbed-offset",
extentDelta: delta,
});
}, []);

const arm = useCallback((deltaY: number, canClaimTail: boolean) => {
const element = scrollRef.current;
if (!element || !Number.isFinite(deltaY) || deltaY === 0) return { started: false as const };
Expand Down Expand Up @@ -700,8 +731,9 @@ export function useTranscriptReaderExtentStability({
cancel,
observe,
holdGeometryCommit,
absorbOffsetWrite,
anchorIsMounted,
isActive,
active: active || readerLayoutLease,
}), [active, anchorIsMounted, arm, cancel, holdGeometryCommit, readerLayoutLease, observe, isActive]);
}), [absorbOffsetWrite, active, anchorIsMounted, arm, cancel, holdGeometryCommit, readerLayoutLease, observe, isActive]);
}
10 changes: 7 additions & 3 deletions desktop/frontend/src/lib/useTranscriptScrollArbiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function useTranscriptScrollArbiter({
cancel: cancelReaderTransaction,
observe: observeReaderTransaction,
holdGeometryCommit: holdReaderGeometryCommit,
absorbOffsetWrite: absorbReaderOffsetWrite,
anchorIsMounted: readerAnchorIsMounted,
isActive: readerTransactionIsActive,
active: readerTransactionActive,
Expand Down Expand Up @@ -242,13 +243,16 @@ export function useTranscriptScrollArbiter({
case "SCROLL_TO_INDEX":
writer.write({ owner: "jump", operation: "scrollToIndex", index: command.index, behavior: command.behavior, reason: writeSource, phase: "mount-anchor", expectedSurfaceGeneration: generationRef.current, expectedOwnershipEpoch: ownershipEpochRef.current, expectedGeometryRevision: geometryRevisionRef.current });
return;
case "SCROLL_TO_OFFSET":
writer.write({ owner: command.owner, operation: "scrollTo", top: command.top, behavior: command.behavior, reason: writeSource, expectedSurfaceGeneration: generationRef.current, expectedOwnershipEpoch: ownershipEpochRef.current, expectedGeometryRevision: geometryRevisionRef.current });
case "SCROLL_TO_OFFSET": {
const before = scrollRef.current?.scrollTop ?? 0;
const written = writer.write({ owner: command.owner, operation: "scrollTo", top: command.top, behavior: command.behavior, reason: writeSource, expectedSurfaceGeneration: generationRef.current, expectedOwnershipEpoch: ownershipEpochRef.current, expectedGeometryRevision: geometryRevisionRef.current });
if (written && scrollRef.current && command.owner === "block-window-prepend") absorbReaderOffsetWrite(scrollRef.current, scrollRef.current.scrollTop - before);
return;
}
case "CANCEL_RECOVERY":
cancelInFlightRecovery(command.id, command.reason);
}
}, [cancelInFlightRecovery, tailSettle, writer]);
}, [absorbReaderOffsetWrite, cancelInFlightRecovery, tailSettle, writer]);

const dispatch = useCallback((event: TranscriptScrollEvent) => {
if (
Expand Down