From a93ff583130c8527b9a8067c487795dc9493bcda Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:20:03 +0800 Subject: [PATCH 1/2] fix(frontend): stop reduced-motion transcript guards from compounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows reports prefers-reduced-motion: reduce whenever "Animate controls and elements inside windows" is off. The global reduced-motion reset turned every transition into a 0.01ms `transition: all`, which still starts from the old value: transform and padding writes were invisible to same-frame getBoundingClientRect/scrollHeight reads. The reader-extent guard then re-measured its own unapplied transform as fresh displacement and compounded the visual offset on every scroll event (7088 → 14177 → 21265 px in the reported diagnostics), ending in a correction to scrollTop 0 and degraded question-jump navigation. - Reduced-motion CSS now removes transitions (`transition: none`) instead of shortening them, so geometry lands synchronously. Animations keep the 0.01ms squash so fill-mode: forwards end states survive. - The reader-extent guard and the anchor-compensation loop derive the physical drift from the item list's applied computed transform rather than the remembered offset, so a lagging or externally cleared guard can no longer compound. - Race tests cover the unapplied and applied transform cases; both fail against the previous hook. - Bundle budgets step one decimal: 462.7 → 462.9 KiB gzip (462.827 measured), 2469.4 → 2469.9 KiB raw (2469.815 measured). --- .../frontend/scripts/check-bundle-budget.mjs | 9 ++- .../transcript-reader-extent-race.test.tsx | 76 +++++++++++++++++++ ...transcript-reader-extent-stability.test.ts | 13 ++++ .../src/lib/transcriptAnchorCompensation.ts | 4 +- .../lib/transcriptReaderExtentStability.ts | 22 ++++++ .../lib/useTranscriptReaderExtentStability.ts | 17 ++--- desktop/frontend/src/styles.css | 12 +-- 7 files changed, 135 insertions(+), 18 deletions(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 0f1a6384a2..3f7350337d 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -182,7 +182,10 @@ console.log("\nbundle budgets"); // bring the merged stable path to 462.587 KiB. Windows' embedded build metadata // lands just above the rounded 462.6 KiB boundary; retain one cross-platform // decimal step without widening any chunk or raw gate. -const initialJSBudgetKiB = 462.7; +// Reading the applied item-list transform (instead of the remembered offset) +// keeps the reader/anchor visual guards from compounding under reduced-motion +// WebView2; the merged path measures 462.827 KiB. Retain one decimal step. +const initialJSBudgetKiB = 462.9; 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 @@ -328,6 +331,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // The passive reader-anchor lease for delayed WebView2 range commits measures // 2469.347 KiB raw (+0.222 KiB, +0.009%). Retain only the next one-decimal // ceiling; gzip and largest-chunk budgets remain unchanged. -const rawInitialBudgetKiB = 2_469.4; +// Reading the applied item-list transform for the reader/anchor visual guards +// adds 0.5 KiB raw on top; the merged path measures 2469.815 KiB. +const rawInitialBudgetKiB = 2_469.9; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx b/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx index 0d110880fe..213e58911b 100644 --- a/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx +++ b/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx @@ -272,6 +272,82 @@ check( "same-scrollTop anchor displacement stays inside the reader writer lane", ); +// Under prefers-reduced-motion Windows/WebView2 lets a guard transform lag +// behind its same-frame write, and another guard owner can drop the shared +// attribute. Row geometry then does not carry the remembered offset. The guard +// must derive the physical drift from the transform the browser actually +// applied, never compounding 681 → 1362 → 2043. +const visualOffsetOf = () => Number.parseFloat( + scrollElement.style.getPropertyValue("--transcript-reader-visual-offset"), +) || 0; +const itemList = dom.window.document.createElement("div"); +itemList.dataset.testid = "virtuoso-item-list"; +itemList.style.transform = "none"; +scrollElement.append(itemList); +const startUnappliedGuardTransaction = async () => { + await act(async () => arbiter?.reset()); + scrollExtent = 23_806; + scrollElement.scrollTop = 22_608; + rowElement.getBoundingClientRect = () => rectAt(12); + await act(async () => arbiter?.deliverScroll()); + await act(async () => arbiter?.releaseTailFollow()); + await act(async () => arbiter?.onWheelIntent({ + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: 24, + target: scrollElement, + } as React.WheelEvent)); + scrollByCalls = 0; + scrollWrites.length = 0; + scrollExtent += 681; + rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608)); +}; +await startUnappliedGuardTransaction(); +await act(async () => arbiter?.deliverScroll()); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `an unapplied guard is written once from the physical drift (${visualOffsetOf()}px)`); +await act(async () => arbiter?.deliverScroll()); +await act(async () => arbiter?.deliverScroll()); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `repeated observations before the transform lands do not compound the guard (${visualOffsetOf()}px)`); +await flushFrames(); +check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1, + `the correction targets the physical anchor, not a compounded guard (${lastScrollByTop}px)`); +check(scrollElement.dataset.transcriptReaderVisualGuard === undefined, + "the unapplied guard releases after the anchor is physically restored"); + +// The applied transform is the truth even when the remembered offset is gone: +// a mounted item list carrying the guard transform must still be subtracted. +const syncItemListTransform = () => { + const applied = scrollElement.dataset.transcriptReaderVisualGuard === "true" ? visualOffsetOf() : 0; + itemList.style.transform = applied === 0 ? "none" : `matrix(1, 0, 0, 1, 0, ${applied})`; +}; +await startUnappliedGuardTransaction(); +rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608) + ( + Number.parseFloat(itemList.style.transform.split(",")[5]) || 0 +)); +await act(async () => arbiter?.deliverScroll()); +syncItemListTransform(); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `an applied guard is written from the physical drift (${visualOffsetOf()}px)`); +await act(async () => arbiter?.deliverScroll()); +syncItemListTransform(); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `an applied guard stays put across observations (${visualOffsetOf()}px)`); +await flushFrames(); +syncItemListTransform(); +check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1, + `the correction subtracts the applied transform (${lastScrollByTop}px)`); +await flushFrames(); +syncItemListTransform(); +check(scrollElement.dataset.transcriptReaderVisualGuard === undefined, + "the applied guard releases once the correction lands"); +itemList.remove(); +rowElement.getBoundingClientRect = () => rectAt(12 + (Number.parseFloat( + scrollElement.style.getPropertyValue("--transcript-reader-visual-offset"), +) || 0)); + // A corrupted extent can momentarily collapse all the way to one viewport. // That sample is not evidence that the transcript became non-scrollable: the // active reader guard must keep manual ownership until geometry rebounds. diff --git a/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts b/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts index 92c534c324..713bf6acca 100644 --- a/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts +++ b/desktop/frontend/src/__tests__/transcript-reader-extent-stability.test.ts @@ -11,6 +11,7 @@ import { transcriptReaderIdleDeadlineReached, transcriptReaderTransactionCanReuse, transcriptReaderExtentCanCorrect, + transcriptTransformTranslateY, } from "../lib/transcriptReaderExtentStability"; console.log("\ntranscript reader extent stability"); @@ -163,4 +164,16 @@ for (const event of observingEvents) { `${event} leaves rebound observation active`); } +assert.equal(transcriptTransformTranslateY("none"), 0, "an unset transform applies no visual offset"); +assert.equal(transcriptTransformTranslateY(""), 0, "an empty computed transform applies no visual offset"); +assert.equal(transcriptTransformTranslateY("matrix(1, 0, 0, 1, 0, 7088.5)"), 7088.5, "a 2D matrix exposes its translateY"); +assert.equal(transcriptTransformTranslateY("matrix(1, 0, 0, 1, 0, -12)"), -12, "a negative translateY survives parsing"); +assert.equal( + transcriptTransformTranslateY("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 96, 0, 1)"), + 96, + "a 3D matrix exposes its translateY component", +); +assert.equal(transcriptTransformTranslateY("translateY(12px)"), undefined, "an unserialized transform yields no measurement"); +assert.equal(transcriptTransformTranslateY("rotate(45deg)"), undefined, "an unrelated transform yields no measurement"); + console.log("transcript reader extent stability tests passed"); diff --git a/desktop/frontend/src/lib/transcriptAnchorCompensation.ts b/desktop/frontend/src/lib/transcriptAnchorCompensation.ts index 278d5198c2..c4b7de2525 100644 --- a/desktop/frontend/src/lib/transcriptAnchorCompensation.ts +++ b/desktop/frontend/src/lib/transcriptAnchorCompensation.ts @@ -8,7 +8,7 @@ import { captureVisibleTranscriptLayoutAnchor, type TranscriptLayoutAnchor, } from "./transcriptVirtuosoRecovery"; -import { MIN_REVERSE_JUMP_PX } from "./transcriptReaderExtentStability"; +import { MIN_REVERSE_JUMP_PX, transcriptAppliedVisualOffset } from "./transcriptReaderExtentStability"; // Fractional row metrics can shift by 1-2px while Virtuoso's estimate tree is // converging during ordinary traversal. Treat that as layout noise so a @@ -100,7 +100,7 @@ export function createTranscriptAnchorCompensation({ const row = anchorRow(compensation.anchor.rowKey, element); if (!row) return null; const rendered = row.getBoundingClientRect().top - element.getBoundingClientRect().top - compensation.anchor.offset; - return rendered - compensation.visualOffset; + return rendered - transcriptAppliedVisualOffset(element, compensation.visualOffset); }; const guardLargeDrift = (compensation: ActiveAnchorCompensation, element: HTMLDivElement) => { diff --git a/desktop/frontend/src/lib/transcriptReaderExtentStability.ts b/desktop/frontend/src/lib/transcriptReaderExtentStability.ts index 8fd43ac6cd..0abc087603 100644 --- a/desktop/frontend/src/lib/transcriptReaderExtentStability.ts +++ b/desktop/frontend/src/lib/transcriptReaderExtentStability.ts @@ -20,6 +20,28 @@ export function transcriptReaderDirection(deltaY: number): -1 | 1 | undefined { export function transcriptReaderTransactionCanReuse(direction: -1 | 1, deltaY: number): boolean { return transcriptReaderDirection(deltaY) === direction; } + +export function transcriptTransformTranslateY(transform: string): number | undefined { + if (transform === "" || transform === "none") return 0; + const match = /^matrix(3d)?\((.*)\)$/.exec(transform); + if (!match) return undefined; + const value = Number(match[2].split(",")[match[1] ? 13 : 5]); + return Number.isFinite(value) ? value : undefined; +} + +/** + * The visual-guard offset the browser has actually applied to the item list. + * A guard written by a previous observation may not be reflected in row + * geometry yet (or any more): a same-frame read under a reduced-motion + * transition, or another owner clearing the shared attribute. Deriving the + * physical drift from the remembered offset would compound the guard on every + * observation, so measure the applied transform; `remembered` is the fallback. + */ +export function transcriptAppliedVisualOffset(element: HTMLElement, remembered: number): number { + const list = element.querySelector('[data-testid="virtuoso-item-list"]'); + const view = element.ownerDocument.defaultView; + return (list && view && transcriptTransformTranslateY(view.getComputedStyle(list).transform)) ?? remembered; +} const REVERSE_JUMP_VIEWPORT_RATIO = 0.5; const EXTENT_REBOUND_VIEWPORT_RATIO = 0.5; diff --git a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts index 715b37ccab..e59814ad81 100644 --- a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts +++ b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import type { TranscriptScrollMode } from "./transcriptScrollArbiter"; -import { MIN_REVERSE_JUMP_PX, TRANSCRIPT_READER_IDLE_MS, TRANSCRIPT_READER_SETTLE_MS, transcriptReaderDirection } from "./transcriptReaderExtentStability"; +import { MIN_REVERSE_JUMP_PX, TRANSCRIPT_READER_IDLE_MS, TRANSCRIPT_READER_SETTLE_MS, transcriptAppliedVisualOffset, transcriptReaderDirection } from "./transcriptReaderExtentStability"; import { nativeTranscriptBottomTop, nativeTranscriptDistanceFromBottom, TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX } from "./transcriptScrollGeometry"; import { recordTranscriptScrollDiagnostic, type TranscriptScrollWriteRecord } from "./transcriptScrollProbe"; import { transcriptElementViewportIsBlank } from "./transcriptVirtuosoRecovery"; @@ -227,9 +227,11 @@ export function useTranscriptReaderExtentStability({ const renderedAnchorDrift = anchorRow && transaction.anchor ? anchorRow.getBoundingClientRect().top - viewport.top - transaction.anchor.offset : 0; - // The row rect includes the current visual guard. Remove it so a stable - // guard does not look like a fresh displacement on every observation. - const physicalAnchorDrift = renderedAnchorDrift - transaction.visualOffset; + // The row rect includes whatever visual guard the browser has applied so + // far. Remove that, not the remembered offset, so a guard that has not + // landed yet cannot look like a fresh displacement and compound itself. + const appliedVisualOffset = transcriptAppliedVisualOffset(element, transaction.visualOffset); + const physicalAnchorDrift = renderedAnchorDrift - appliedVisualOffset; const reverseAnchorDisplacement = transaction.direction * physicalAnchorDrift; // Extent collapse needs the half-viewport transient threshold, but the // user-visible screen anchor has the stricter 96px acceptance contract. @@ -264,9 +266,6 @@ export function useTranscriptReaderExtentStability({ ); } if (anchorRow && transaction.anchor) { - // DOM geometry includes the transform already applied by a previous - // observation. Subtract it before deriving the next absolute guard so - // repeated scroll events cannot compound the visual compensation. transaction.visualOffset = -physicalAnchorDrift; element.dataset.transcriptReaderVisualGuard = "true"; element.style.setProperty("--transcript-reader-visual-offset", `${transaction.visualOffset}px`); @@ -449,10 +448,10 @@ export function useTranscriptReaderExtentStability({ } const viewportTop = element.getBoundingClientRect().top; const targetTop = anchorRow && transaction.anchor - // getBoundingClientRect includes the temporary list transform. Use + // getBoundingClientRect includes the applied list transform. Use // the unguarded row position to derive the physical scrollTop that // can replace that transform without a visual jump. - ? element.scrollTop + anchorRow.getBoundingClientRect().top - transaction.visualOffset - viewportTop - transaction.anchor.offset + ? element.scrollTop + anchorRow.getBoundingClientRect().top - transcriptAppliedVisualOffset(element, transaction.visualOffset) - viewportTop - transaction.anchor.offset : transaction.expectedTop; const correction = Math.max(0, Math.min(nativeTranscriptBottomTop(element), targetTop)) - element.scrollTop; if ((transaction.mountAnchorWritten ? Math.abs(correction) : transaction.direction * correction) > 1) { diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 950760b8f7..09c3e5859f 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -244,8 +244,8 @@ /* motion — shared timing + easing tokens. Durations mirror the values * already used across the app (0.12 / 0.18 / 0.34 / 0.42s); easings reuse the * existing curves. The global prefers-reduced-motion block at EOF collapses - * every animation/transition to ~0ms, so these tokens are safe to reuse - * freely without per-rule reduce overrides. */ + * every animation to ~0ms and removes transitions, so these tokens are safe + * to reuse freely without per-rule reduce overrides. */ --dur-fast: 120ms; /* color/border hovers, tooltips */ --dur-base: 180ms; /* popovers, menus, small enters */ --dur-slow: 340ms; /* drawers, modals, panel slides */ @@ -27449,15 +27449,17 @@ body > .mermaid-diagram--fullscreen { } /* Respect the OS reduced-motion preference: squash all animations and force -/* Respect the OS reduced-motion preference: squash all animations and force - instant scrolling so the UI feels immediate rather than web-like. */ + instant scrolling so the UI feels immediate rather than web-like. Transitions + go to `none`, not 0.01ms: a near-zero `transition: all` still starts from the + old value, so transform/padding writes stay invisible to same-frame geometry + reads and the transcript scroll guards compound their compensation. */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + transition: none !important; scroll-behavior: auto !important; } } From d8840832f522b538fc82c42e493adcfd551ec00c Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:16:58 +0800 Subject: [PATCH 2/2] test(frontend): split visual-guard races and guard the reduced-motion reset Problem: repolint rejected the PR head because the two new visual-guard scenarios pushed transcript-reader-extent-race.test.tsx to 853 lines, over the 800-line test-file ceiling. The CSS half of the fix also had no automated guard: nothing stopped a later edit from restoring `transition-duration: 0.01ms` and silently reintroducing the lagging same-frame geometry reads. Fix: - Move the unapplied/applied visual-guard scenarios into transcript-reader-visual-guard-race.test.tsx with the same JSDOM + fake rAF harness, restore the extent race file to its base contents, and add the new file to test:transcript and the motion CI contract. - Extend check-motion-ci-contract.mjs so the global universal prefers-reduced-motion reset must use `transition: none !important` and must not shorten transitions. Verification: - transcript-reader-visual-guard-race: 9 pass on the PR head; 2 fail against the pre-fix hook (-2043px guard, 1154px correction). - check-motion-ci-contract passes on the PR head and fails when the reset is reverted to transition-duration. - go run ./tools/repolint clean; pnpm test:transcript, test:motion, test:typecheck, lint:hooks pass; git diff --check clean. --- desktop/frontend/package.json | 2 +- .../scripts/check-motion-ci-contract.mjs | 18 ++ .../transcript-reader-extent-race.test.tsx | 76 ------- ...anscript-reader-visual-guard-race.test.tsx | 199 ++++++++++++++++++ 4 files changed, 218 insertions(+), 77 deletions(-) create mode 100644 desktop/frontend/src/__tests__/transcript-reader-visual-guard-race.test.tsx diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 8df3976675..173d0d749b 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -26,7 +26,7 @@ "test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs", "test:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs", "test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx", - "test:transcript": "tsx src/__tests__/transcript-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-live-turn-stability.test.tsx && tsx src/__tests__/transcript-geometry-environment.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-same-tab-tail-race.test.tsx && tsx src/__tests__/transcript-history-prepend-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-tail-clamp-race.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && 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-selection-rendering.test.ts && 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-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-live-turn-stability.test.tsx && tsx src/__tests__/transcript-geometry-environment.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-reader-visual-guard-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-same-tab-tail-race.test.tsx && tsx src/__tests__/transcript-history-prepend-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-tail-clamp-race.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && 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-selection-rendering.test.ts && 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": "node bench/transcript-selection.mjs && node bench/transcript-scroll-stability.mjs && node bench/composer-transcript-stability.mjs", "test:transcript-reader-browser": "node bench/transcript-reader-transaction.mjs", "pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && 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:diagnostics && pnpm test:transcript", diff --git a/desktop/frontend/scripts/check-motion-ci-contract.mjs b/desktop/frontend/scripts/check-motion-ci-contract.mjs index 9e7219115a..f518fb4fdd 100644 --- a/desktop/frontend/scripts/check-motion-ci-contract.mjs +++ b/desktop/frontend/scripts/check-motion-ci-contract.mjs @@ -148,6 +148,7 @@ if (!packageJSON.scripts?.["test:motion-browser"]?.includes("approval-animation. const transcriptScript = packageJSON.scripts?.["test:transcript"] ?? ""; for (const required of [ "transcript-virtuoso-index.test.ts", + "transcript-reader-visual-guard-race.test.tsx", "transcript-scroll-release.test.ts", "nested-scroll-handoff.test.ts", "creation-transcript-scrollbar.test.ts", @@ -175,6 +176,23 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability } } +// A near-zero `transition: all` still starts from the old value, so same-frame +// geometry reads miss transform/padding writes and the transcript guards +// compound. The global reduced-motion reset must remove transitions outright. +const stylesSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/styles.css"), "utf8"); +const globalReducedMotion = stylesSource.match( + /@media \(prefers-reduced-motion: reduce\) \{\s*\*,\s*\*::before,\s*\*::after \{([^}]*)\}/, +); +if (!globalReducedMotion) { + throw new Error("motion-ci-contract: styles.css must keep the global universal prefers-reduced-motion reset"); +} +if (!globalReducedMotion[1].includes("transition: none !important")) { + throw new Error("motion-ci-contract: the global reduced-motion reset must use `transition: none !important`"); +} +if (/transition-duration/.test(globalReducedMotion[1])) { + throw new Error("motion-ci-contract: the global reduced-motion reset must not shorten transitions (same-frame geometry reads would lag)"); +} + const transcriptCommand = "pnpm --dir frontend test:transcript"; const desktopLinuxJob = jobBody("desktop", "desktop-macos"); const transcriptRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript(?:\s|$)/g)?.length ?? 0; diff --git a/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx b/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx index 213e58911b..0d110880fe 100644 --- a/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx +++ b/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx @@ -272,82 +272,6 @@ check( "same-scrollTop anchor displacement stays inside the reader writer lane", ); -// Under prefers-reduced-motion Windows/WebView2 lets a guard transform lag -// behind its same-frame write, and another guard owner can drop the shared -// attribute. Row geometry then does not carry the remembered offset. The guard -// must derive the physical drift from the transform the browser actually -// applied, never compounding 681 → 1362 → 2043. -const visualOffsetOf = () => Number.parseFloat( - scrollElement.style.getPropertyValue("--transcript-reader-visual-offset"), -) || 0; -const itemList = dom.window.document.createElement("div"); -itemList.dataset.testid = "virtuoso-item-list"; -itemList.style.transform = "none"; -scrollElement.append(itemList); -const startUnappliedGuardTransaction = async () => { - await act(async () => arbiter?.reset()); - scrollExtent = 23_806; - scrollElement.scrollTop = 22_608; - rowElement.getBoundingClientRect = () => rectAt(12); - await act(async () => arbiter?.deliverScroll()); - await act(async () => arbiter?.releaseTailFollow()); - await act(async () => arbiter?.onWheelIntent({ - ctrlKey: false, - deltaMode: 0, - deltaX: 0, - deltaY: 24, - target: scrollElement, - } as React.WheelEvent)); - scrollByCalls = 0; - scrollWrites.length = 0; - scrollExtent += 681; - rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608)); -}; -await startUnappliedGuardTransaction(); -await act(async () => arbiter?.deliverScroll()); -check(Math.abs(visualOffsetOf() + 681) <= 1, - `an unapplied guard is written once from the physical drift (${visualOffsetOf()}px)`); -await act(async () => arbiter?.deliverScroll()); -await act(async () => arbiter?.deliverScroll()); -check(Math.abs(visualOffsetOf() + 681) <= 1, - `repeated observations before the transform lands do not compound the guard (${visualOffsetOf()}px)`); -await flushFrames(); -check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1, - `the correction targets the physical anchor, not a compounded guard (${lastScrollByTop}px)`); -check(scrollElement.dataset.transcriptReaderVisualGuard === undefined, - "the unapplied guard releases after the anchor is physically restored"); - -// The applied transform is the truth even when the remembered offset is gone: -// a mounted item list carrying the guard transform must still be subtracted. -const syncItemListTransform = () => { - const applied = scrollElement.dataset.transcriptReaderVisualGuard === "true" ? visualOffsetOf() : 0; - itemList.style.transform = applied === 0 ? "none" : `matrix(1, 0, 0, 1, 0, ${applied})`; -}; -await startUnappliedGuardTransaction(); -rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608) + ( - Number.parseFloat(itemList.style.transform.split(",")[5]) || 0 -)); -await act(async () => arbiter?.deliverScroll()); -syncItemListTransform(); -check(Math.abs(visualOffsetOf() + 681) <= 1, - `an applied guard is written from the physical drift (${visualOffsetOf()}px)`); -await act(async () => arbiter?.deliverScroll()); -syncItemListTransform(); -check(Math.abs(visualOffsetOf() + 681) <= 1, - `an applied guard stays put across observations (${visualOffsetOf()}px)`); -await flushFrames(); -syncItemListTransform(); -check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1, - `the correction subtracts the applied transform (${lastScrollByTop}px)`); -await flushFrames(); -syncItemListTransform(); -check(scrollElement.dataset.transcriptReaderVisualGuard === undefined, - "the applied guard releases once the correction lands"); -itemList.remove(); -rowElement.getBoundingClientRect = () => rectAt(12 + (Number.parseFloat( - scrollElement.style.getPropertyValue("--transcript-reader-visual-offset"), -) || 0)); - // A corrupted extent can momentarily collapse all the way to one viewport. // That sample is not evidence that the transcript became non-scrollable: the // active reader guard must keep manual ownership until geometry rebounds. diff --git a/desktop/frontend/src/__tests__/transcript-reader-visual-guard-race.test.tsx b/desktop/frontend/src/__tests__/transcript-reader-visual-guard-race.test.tsx new file mode 100644 index 0000000000..2e4a153ccf --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-reader-visual-guard-race.test.tsx @@ -0,0 +1,199 @@ +// Run: tsx src/__tests__/transcript-reader-visual-guard-race.test.tsx +// +// Visual-guard races split out of transcript-reader-extent-race.test.tsx +// (800-line test-file ceiling). Under prefers-reduced-motion, Windows/WebView2 +// lets the guard transform lag behind its same-frame write, and another guard +// owner can drop the shared attribute. The reader guard must derive the +// physical drift from the transform the browser actually applied, never +// compounding 681 → 1362 → 2043. Same JSDOM + fake rAF harness with a stubbed +// VirtuosoHandle as the extent race file. + +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 visual guard races"); + +const dom = new JSDOM('
', { + 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(); +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(".transcript__row")!; +rowElement.dataset.index = "0"; +scrollElement.getBoundingClientRect = () => rectAt(0); +rowElement.getBoundingClientRect = () => rectAt(12); +Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 }); +let scrollExtent = 23_806; +Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent }); +Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 22_608 }); + +let scrollByCalls = 0; +let lastScrollByTop = 0; +const virtuosoHandle = { + scrollBy: (options?: { top?: number }) => { + scrollByCalls += 1; + lastScrollByTop = options?.top ?? 0; + scrollElement.scrollTop += lastScrollByTop; + }, + scrollTo: (options?: { top?: number }) => { + scrollElement.scrollTop = options?.top ?? scrollElement.scrollTop; + }, + scrollToIndex: () => {}, + getState: () => {}, +} as unknown as VirtuosoHandle; + +let arbiter: ReturnType | undefined; +function Probe() { + arbiter = useTranscriptScrollArbiter(); + return null; +} + +const root = createRoot(dom.window.document.getElementById("root")!); +await act(async () => root.render()); +await act(async () => { + (arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle; + arbiter!.scrollerRef(scrollElement); +}); + +const visualOffsetOf = () => Number.parseFloat( + scrollElement.style.getPropertyValue("--transcript-reader-visual-offset"), +) || 0; +const itemList = dom.window.document.createElement("div"); +itemList.dataset.testid = "virtuoso-item-list"; +itemList.style.transform = "none"; +scrollElement.append(itemList); + +// A downward wheel gesture whose same-scrollTop estimate growth displaces the +// anchor row by 681px on screen. The row rect deliberately ignores the guard +// CSS variable: the transform has not been applied by the browser yet. +const startDisplacedTransaction = async () => { + await act(async () => arbiter?.reset()); + scrollExtent = 23_806; + scrollElement.scrollTop = 22_608; + rowElement.getBoundingClientRect = () => rectAt(12); + await act(async () => arbiter?.deliverScroll()); + await act(async () => arbiter?.releaseTailFollow()); + await act(async () => arbiter?.onWheelIntent({ + ctrlKey: false, + deltaMode: 0, + deltaX: 0, + deltaY: 24, + target: scrollElement, + } as React.WheelEvent)); + scrollByCalls = 0; + scrollWrites.length = 0; + scrollExtent += 681; + rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608)); +}; + +await startDisplacedTransaction(); +await act(async () => arbiter?.deliverScroll()); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `an unapplied guard is written once from the physical drift (${visualOffsetOf()}px)`); +await act(async () => arbiter?.deliverScroll()); +await act(async () => arbiter?.deliverScroll()); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `repeated observations before the transform lands do not compound the guard (${visualOffsetOf()}px)`); +await flushFrames(); +check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1, + `the correction targets the physical anchor, not a compounded guard (${lastScrollByTop}px)`); +check(scrollElement.dataset.transcriptReaderVisualGuard === undefined, + "the unapplied guard releases after the anchor is physically restored"); +check( + scrollWrites.filter((write) => write.owner === "reader-stability" && write.kind === "scrollBy").length === 1, + "the unapplied-guard correction stays inside the reader writer lane", +); + +// The applied transform is the truth even when the remembered offset is gone: +// a mounted item list carrying the guard transform must still be subtracted. +const syncItemListTransform = () => { + const applied = scrollElement.dataset.transcriptReaderVisualGuard === "true" ? visualOffsetOf() : 0; + itemList.style.transform = applied === 0 ? "none" : `matrix(1, 0, 0, 1, 0, ${applied})`; +}; +await startDisplacedTransaction(); +rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608) + ( + Number.parseFloat(itemList.style.transform.split(",")[5]) || 0 +)); +await act(async () => arbiter?.deliverScroll()); +syncItemListTransform(); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `an applied guard is written from the physical drift (${visualOffsetOf()}px)`); +await act(async () => arbiter?.deliverScroll()); +syncItemListTransform(); +check(Math.abs(visualOffsetOf() + 681) <= 1, + `an applied guard stays put across observations (${visualOffsetOf()}px)`); +await flushFrames(); +syncItemListTransform(); +check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1, + `the correction subtracts the applied transform (${lastScrollByTop}px)`); +await flushFrames(); +syncItemListTransform(); +check(scrollElement.dataset.transcriptReaderVisualGuard === undefined, + "the applied guard releases once the correction lands"); + +await act(async () => root.unmount()); +dom.window.close(); + +if (failed > 0) { + console.error(`\n${failed} transcript reader visual guard race test(s) failed; ${passed} passed.`); + process.exit(1); +} +console.log(`\n${passed} transcript reader visual guard race tests passed.`);