From be9a06ae4df9db2f293445087c5a17af4fe63f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E4=B8=AD?= <1053338060@qq.com> Date: Thu, 13 Aug 2026 14:53:47 +0800 Subject: [PATCH 1/2] fix: prevent fast Windows source scroll blanks --- PUBLIC_SOURCE_MANIFEST.json | 36 +-- apps/windows/package.json | 1 + .../components/SessionSourceEditor.test.tsx | 15 ++ .../src/ui/components/SessionSourceEditor.tsx | 57 ++++- .../ui/components/SourceTanStackLab.test.tsx | 11 +- package-lock.json | 7 +- package.json | 4 +- test_tool/editor_ui_performance_lab.mjs | 223 +++++++++++++++--- 8 files changed, 290 insertions(+), 64 deletions(-) diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 456556e..4e868ea 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "license": "GPL-3.0-only", "historyPolicy": "sanitized-single-snapshot", - "generatedAt": "2026-08-13T05:21:04.140Z", + "generatedAt": "2026-08-13T06:51:12.331Z", "fileCount": 662, - "snapshotSha256": "2d3dd4f702abfab6cb7596cdc26ee9dc20e4c14f44cdffbe7e5215f45d2c6961", + "snapshotSha256": "9f9ffe21f36b629bea84e79e2bc0852247ff08efec7cbe73a3fa4333cc1ef928", "files": [ { "path": ".github/ISSUE_TEMPLATE/bug_report.yml", @@ -1333,8 +1333,8 @@ }, { "path": "apps/windows/package.json", - "bytes": 1867, - "sha256": "1f69a2553ef25ebe45fd36b9b47aacee12cc79696232f080b969c37aa3a7e604" + "bytes": 1902, + "sha256": "bf447e38230931494f1cdae3829d75455c5384f38c461954aa8589f015951937" }, { "path": "apps/windows/src/App.test.tsx", @@ -2133,23 +2133,23 @@ }, { "path": "apps/windows/src/ui/components/SessionSourceEditor.test.tsx", - "bytes": 24331, - "sha256": "38b4f1810ceeea0c16c644c66503c8f58cd2071820659fe076da6655b3dbbaf5" + "bytes": 25296, + "sha256": "cedebb9dcb485d445723627bb9242f19be0d9aa0ee783b11c21f04588dd2a0b5" }, { "path": "apps/windows/src/ui/components/SessionSourceEditor.tsx", - "bytes": 70499, - "sha256": "f7ccf688f05acd5f83cd03f4544b4d62f96b635e713894b0a2349b51b83bf102" + "bytes": 72281, + "sha256": "4ea12f507900c330958eaa42eca5d742fd891e13ccfd55b95eac88fb6492dad0" }, { "path": "apps/windows/src/ui/components/SourceTanStackLab.test.tsx", - "bytes": 2545, - "sha256": "b490b7e3041a94c09a1dca5c0124da4063eadc2ed8fd6087577efd27cbab6924" + "bytes": 2817, + "sha256": "f31dac0e71641bea8077e3800d61e574222765f6ec6ea909e4787d34686ed55b" }, { "path": "apps/windows/src/ui/components/SourceTanStackLab.tsx", - "bytes": 4268, - "sha256": "481ea273b60ed9ab4c8ebfb9dbf308b99d8b716ae6f7671abc95ac718e2f1d3b" + "bytes": 4261, + "sha256": "697d5377b5c87770594d10a945439169a910f53f1def978163a38194387e8a57" }, { "path": "apps/windows/src/ui/components/sessionSourceEditorModel.test.ts", @@ -2333,13 +2333,13 @@ }, { "path": "package-lock.json", - "bytes": 354932, - "sha256": "f546ff664639e6445ee05ca3b00de206eb722d218cd9fba3ed7e206b5546613c" + "bytes": 354975, + "sha256": "efe35fef34a3de44e9bbf41aa6577c5bb2073b86609763e35538875311903d40" }, { "path": "package.json", - "bytes": 8069, - "sha256": "5bcb28a9d95ee4a9e9ff7843e2aca4a0d1d265c490ca21182d4cd022beef0f4e" + "bytes": 8494, + "sha256": "35c59f95ac9a6e8b4a9cc7f8a125d1c6b038918b45d46ff8a6e65a863faad6c8" }, { "path": "packages/core-server/package.json", @@ -2998,8 +2998,8 @@ }, { "path": "test_tool/editor_ui_performance_lab.mjs", - "bytes": 143768, - "sha256": "7bb96b6d41a3e7d6c8724df7732bb44dc92ecd96d7a242cbe1eb10f17deaf54a" + "bytes": 152015, + "sha256": "88f97c2dffd82fe250630336edb9b5c289b9d81b14ffd25ebb683951fca1994d" }, { "path": "test_tool/electron_actual_state_probe.mjs", diff --git a/apps/windows/package.json b/apps/windows/package.json index c68b453..4aba271 100644 --- a/apps/windows/package.json +++ b/apps/windows/package.json @@ -15,6 +15,7 @@ "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.7.0", + "@codemirror/view": "6.43.8", "@mathnotes/core-server": "file:../../packages/core-server", "@mathnotes/shared": "file:../../packages/shared", "@tanstack/react-virtual": "^3.14.6", diff --git a/apps/windows/src/ui/components/SessionSourceEditor.test.tsx b/apps/windows/src/ui/components/SessionSourceEditor.test.tsx index bf7441f..1f2669c 100644 --- a/apps/windows/src/ui/components/SessionSourceEditor.test.tsx +++ b/apps/windows/src/ui/components/SessionSourceEditor.test.tsx @@ -7,6 +7,8 @@ import { buildPerformanceLabVirtualLayout, expandPerformanceLabVirtualRange, findPerformanceLabVirtualRange, + getTanStackSourceWindowingEffectiveMode, + getTanStackSourceWindowingFallbackReason, groupAssistantRemarksByBlockId, findEditorCaretTargetAtPoint, SessionSourceEditor, @@ -233,6 +235,19 @@ describe("SessionSourceEditor", () => { expect(screen.queryByTestId("editor-context-menu")).toBeNull(); }); + it("keeps small sessions and sessions with a very long block in normal document flow", () => { + expect(getTanStackSourceWindowingFallbackReason(["one", "two"])).toBe("small-session"); + expect(getTanStackSourceWindowingEffectiveMode(["one", "two"])).toBe("off"); + + const ordinaryBlocks = Array.from({ length: 13 }, (_, index) => `block ${index + 1}\nsecond line`); + expect(getTanStackSourceWindowingFallbackReason(ordinaryBlocks)).toBeNull(); + expect(getTanStackSourceWindowingEffectiveMode(ordinaryBlocks)).toBe("tanstack-virtual"); + + const longBlock = Array.from({ length: 85 }, (_, index) => `line ${index + 1}`).join("\n"); + expect(getTanStackSourceWindowingFallbackReason([...ordinaryBlocks.slice(0, 12), longBlock])).toBe("long-block"); + expect(getTanStackSourceWindowingEffectiveMode([...ordinaryBlocks.slice(0, 12), longBlock])).toBe("off"); + }); + it("sends the exact UTF-16 selection to the AI edit callback from the editor context menu", async () => { Range.prototype.getClientRects = () => [] as unknown as DOMRectList; Range.prototype.getBoundingClientRect = () => diff --git a/apps/windows/src/ui/components/SessionSourceEditor.tsx b/apps/windows/src/ui/components/SessionSourceEditor.tsx index 228c3bf..fd48898 100644 --- a/apps/windows/src/ui/components/SessionSourceEditor.tsx +++ b/apps/windows/src/ui/components/SessionSourceEditor.tsx @@ -179,6 +179,7 @@ type EditorStateSnapshot = { const performanceLabEditorViewLimit = 12; const performanceLabOverscanPx = 1200; const performanceLabVirtualSegmentSize = 12; +const performanceLabLongSourceBlockLineLimit = 85; const performanceLabStorageKey = "mathnotes:editor-windowing-lab"; const performanceLabSourceOverscanStorageKey = "mathnotes:source-overscan-lab"; const performanceLabVirtualHeaderHeight = 39; @@ -217,6 +218,34 @@ export function buildPerformanceLabVirtualLayout(markdowns: readonly string[]): return { offsets, sizes, totalSize }; } +export function getTanStackSourceWindowingFallbackReason( + markdowns: readonly string[] +): "small-session" | "long-block" | null { + // A session with at most twelve blocks gains little from a second layer of + // virtualization because CodeMirror already virtualizes the lines inside + // each block. Keeping these editors in normal document flow also avoids + // competing viewport calculations during native scrollbar drags. + if (markdowns.length <= performanceLabEditorViewLimit) return "small-session"; + + for (const markdown of markdowns) { + let lineCount = 1; + for (let index = 0; index < markdown.length; index += 1) { + if (markdown.charCodeAt(index) !== 10) continue; + lineCount += 1; + if (lineCount >= performanceLabLongSourceBlockLineLimit) return "long-block"; + } + } + return null; +} + +export function getTanStackSourceWindowingEffectiveMode( + markdowns: readonly string[] +): "off" | "tanstack-virtual" { + const fallbackReason = getTanStackSourceWindowingFallbackReason(markdowns); + if (fallbackReason) return "off"; + return "tanstack-virtual"; +} + export function findPerformanceLabVirtualRange({ layout, scrollTop, @@ -325,11 +354,7 @@ export function SessionSourceEditor({ onRerecognizeBlockRequest, onTransferBlocksRequest }: SessionSourceEditorProps) { - const editorWindowingLabMode = getEditorWindowingPerformanceLabMode(); - const dynamicEditorWindowingLab = editorWindowingLabMode === "dynamic"; - const virtualBlockWindowingLab = editorWindowingLabMode === "virtual"; - const tanStackSourceWindowingLab = editorWindowingLabMode === "tanstack-virtual"; - const statefulEditorWindowingLab = dynamicEditorWindowingLab || virtualBlockWindowingLab || tanStackSourceWindowingLab; + const requestedEditorWindowingLabMode = getEditorWindowingPerformanceLabMode(); const editorScrollerRef = useRef(null); const blockElementsRef = useRef(new Map()); const editorViewsRef = useRef(new Map()); @@ -393,10 +418,26 @@ export function SessionSourceEditor({ [document.markdownBlocks] ); const remarksByBlockId = useMemo(() => groupAssistantRemarksByBlockId(assistantRemarks), [assistantRemarks]); - const virtualBlockLayout = useMemo( - () => buildPerformanceLabVirtualLayout(document.markdownBlocks.map((block) => markdownByBlockId[block.blockId] ?? "")), + const sourceMarkdowns = useMemo( + () => document.markdownBlocks.map((block) => markdownByBlockId[block.blockId] ?? ""), [document.markdownBlocks, markdownBlockIds, markdownByBlockId] ); + const tanStackFallbackReason = requestedEditorWindowingLabMode === "tanstack-virtual" + ? getTanStackSourceWindowingFallbackReason(sourceMarkdowns) + : null; + const editorWindowingLabMode: EditorWindowingLabMode = requestedEditorWindowingLabMode === "tanstack-virtual" + ? getTanStackSourceWindowingEffectiveMode(sourceMarkdowns) + : requestedEditorWindowingLabMode; + const dynamicEditorWindowingLab = editorWindowingLabMode === "dynamic"; + const virtualBlockWindowingLab = editorWindowingLabMode === "virtual"; + const tanStackSourceWindowingLab = editorWindowingLabMode === "tanstack-virtual"; + const statefulEditorWindowingLab = dynamicEditorWindowingLab || + virtualBlockWindowingLab || + requestedEditorWindowingLabMode === "tanstack-virtual"; + const virtualBlockLayout = useMemo( + () => buildPerformanceLabVirtualLayout(sourceMarkdowns), + [sourceMarkdowns] + ); useEffect(() => { // A local CodeMirror edit is projected to the aggregate source on the next frame. @@ -1005,6 +1046,8 @@ export function SessionSourceEditor({
diff --git a/apps/windows/src/ui/components/SourceTanStackLab.test.tsx b/apps/windows/src/ui/components/SourceTanStackLab.test.tsx index 99db5c7..e461d40 100644 --- a/apps/windows/src/ui/components/SourceTanStackLab.test.tsx +++ b/apps/windows/src/ui/components/SourceTanStackLab.test.tsx @@ -1,11 +1,11 @@ import { render, waitFor } from "@testing-library/react"; -import { createRef } from "react"; +import { createRef, type CSSProperties } from "react"; import { describe, expect, it, vi } from "vitest"; import { SourceTanStackLab } from "./SourceTanStackLab"; vi.mock("@tanstack/react-virtual", () => ({ useVirtualizer: () => ({ - getVirtualItems: () => [{ index: 0, start: 0 }], + getVirtualItems: () => [{ index: 0, start: 37 }], getTotalSize: () => 100, itemSizeCache: new Map(), measureElement: () => undefined, @@ -73,5 +73,12 @@ describe("SourceTanStackLab preview locate handshake", () => {
); expect(renderItem).toHaveBeenCalledWith(block, 0, expect.any(Function), expect.any(Object)); + const style = (renderItem.mock.calls as unknown as Array<[unknown, number, unknown, CSSProperties]>)[0]?.[3]; + expect(style).toMatchObject({ + position: "absolute", + top: 0, + transform: "translateY(37px)", + width: "100%" + }); }); }); diff --git a/package-lock.json b/package-lock.json index b8423ef..b34b29a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.7.0", + "@codemirror/view": "6.43.8", "@mathnotes/core-server": "file:../../packages/core-server", "@mathnotes/shared": "file:../../packages/shared", "@tanstack/react-virtual": "^3.14.6", @@ -1852,9 +1853,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.3", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.3.tgz", - "integrity": "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng==", + "version": "6.43.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.8.tgz", + "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", diff --git a/package.json b/package.json index d457e2c..11aa62a 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,9 @@ "test:editor-performance": "node test_tool/editor_performance_smoke.mjs", "test:editor-performance:lab": "node test_tool/editor_performance_lab.mjs", "test:editor-performance:ui-lab": "node test_tool/editor_ui_performance_lab.mjs", - "test:windows-scroll-blank": "node test_tool/editor_ui_performance_lab.mjs --experiment=scroll-blank --shapes=80 --fixture-profile=heterogeneous --product-defaults=1 --output=output/performance/windows-scroll-blank-hotfix.json", + "test:windows-scroll-blank": "npm run test:windows-scroll-blank:long && npm run test:windows-scroll-blank:many", + "test:windows-scroll-blank:long": "node test_tool/editor_ui_performance_lab.mjs --experiment=scroll-blank --shapes=2 --fixture-profile=single-long --product-defaults=1 --scroll-direction-ms=200 --output=output/performance/windows-fast-scroll-single-long-v2.json", + "test:windows-scroll-blank:many": "node test_tool/editor_ui_performance_lab.mjs --experiment=scroll-blank --shapes=80 --fixture-profile=heterogeneous --product-defaults=1 --scroll-direction-ms=300 --output=output/performance/windows-fast-scroll-heterogeneous-v2.json", "test:prototype": "node test_tool/prototype_smoke.mjs http://127.0.0.1:4173/", "test:windows-smoke": "node test_tool/windows_app_smoke.mjs http://127.0.0.1:5173/", "test:electron-smoke": "node test_tool/electron_app_smoke.mjs", diff --git a/test_tool/editor_ui_performance_lab.mjs b/test_tool/editor_ui_performance_lab.mjs index 6b99197..dccd55c 100644 --- a/test_tool/editor_ui_performance_lab.mjs +++ b/test_tool/editor_ui_performance_lab.mjs @@ -22,6 +22,8 @@ const virtualInputReliabilityRepeats = Math.max( const requestedFixtureProfile = cliOptions.get("fixture-profile") ?? process.env.EDITOR_UI_FIXTURE_PROFILE; const fixtureProfile = requestedFixtureProfile === "mixed-media" ? "mixed-media" + : requestedFixtureProfile === "single-long" + ? "single-long" : requestedFixtureProfile === "heterogeneous" ? "heterogeneous" : "uniform"; @@ -33,6 +35,10 @@ const sourceOverscan = Math.max( Math.min(16, Number.parseInt(cliOptions.get("source-overscan") ?? process.env.EDITOR_UI_SOURCE_OVERSCAN ?? "8", 10) || 8) ); const sourceScrollShell = (cliOptions.get("source-scroll-shell") ?? process.env.EDITOR_UI_SOURCE_SCROLL_SHELL) === "1"; +const sourceScrollDirectionMs = Math.max( + 120, + Number.parseInt(cliOptions.get("scroll-direction-ms") ?? process.env.EDITOR_UI_SCROLL_DIRECTION_MS ?? "200", 10) || 200 +); const layoutAnchor = (cliOptions.get("layout-anchor") ?? process.env.EDITOR_UI_LAYOUT_ANCHOR) === "1"; const productDefaults = (cliOptions.get("product-defaults") ?? process.env.EDITOR_UI_PRODUCT_DEFAULTS) === "1"; const outputPath = path.resolve( @@ -95,6 +101,7 @@ const report = { dynamicOverscanPx: 1200, sourceOverscan: productDefaults ? "product-default" : sourceOverscan, sourceScrollShell: productDefaults ? "product-default" : sourceScrollShell, + sourceScrollDirectionMs, productDefaults, fixtureProfile, virtualInputReliabilityRepeats, @@ -110,7 +117,7 @@ const report = { stages: ["source-commit", "source-two-frame-paint", "preview-commit", "preview-two-frame-paint", "long-task"] }, note: experiment === "scroll-blank" - ? "Synthetic notes root only. The regression retains real CodeMirror editors for every mounted TanStack item while dragging, including when an older profile has the retired source-scroll-shell setting persisted." + ? "Synthetic notes root only. The regression dispatches physical-speed CDP scrollbar input without awaiting the renderer between steps, samples blank CodeMirror gaps during the drag, and checks the safe normal-flow fallback used by very long blocks." : "Synthetic notes root only. All prototypes are disabled in normal product runs. The virtual source variant estimates the complete scroll range and hydrates the precise visible range from cached EditorState after scrolling settles. The combined measured variant calibrates preview block heights with one ResizeObserver, batches measurements per animation frame, and compensates scrollTop when heights above the visible anchor change." }, results @@ -600,7 +607,7 @@ async function measureSourceScrollbarBlankRegression({ blockCount, notesRoot, pa await page.waitForFunction( () => { const scroller = document.querySelector("[data-testid='session-source-editor']"); - return scroller?.getAttribute("data-editor-windowing-lab") === "tanstack-virtual" && + return scroller?.getAttribute("data-editor-windowing-requested") === "tanstack-virtual" && document.querySelectorAll("[data-testid='source-block-editor'] .cm-editor").length > 0; }, undefined, @@ -611,6 +618,9 @@ async function measureSourceScrollbarBlankRegression({ blockCount, notesRoot, pa if (!box) throw new Error("Source scrollbar regression scroller is not visible"); const scrollBefore = await scroller.evaluate((element) => ({ clientHeight: element.clientHeight, + effectiveEditorMode: element.dataset.editorWindowingLab ?? null, + fallbackReason: element.dataset.editorWindowingFallback ?? null, + requestedEditorMode: element.dataset.editorWindowingRequested ?? null, scrollHeight: element.scrollHeight, scrollTop: element.scrollTop })); @@ -618,42 +628,158 @@ async function measureSourceScrollbarBlankRegression({ blockCount, notesRoot, pa throw new Error("Source scrollbar regression fixture is not scrollable"); } - let actualDragDistance = 0; - let bottomTouches = 0; - let topTouches = 0; - const edgeStates = []; + await page.waitForTimeout(1500); + await page.evaluate(() => { + const source = document.querySelector("[data-testid='session-source-editor']"); + if (!(source instanceof HTMLElement)) throw new Error("Source scroller missing"); + const probe = { + active: true, + frames: [], + longTasks: [], + observer: null, + previousFrameAt: performance.now(), + scrolls: [] + }; + source.addEventListener("scroll", () => { + probe.scrolls.push({ atEpoch: performance.timeOrigin + performance.now(), scrollTop: source.scrollTop }); + }, { passive: true }); + if (typeof PerformanceObserver !== "undefined") { + probe.observer = new PerformanceObserver((list) => { + probe.longTasks.push(...list.getEntries().map((entry) => ({ at: entry.startTime, duration: entry.duration }))); + }); + probe.observer.observe({ entryTypes: ["longtask"] }); + } + const intersectHeight = (rect, viewport) => Math.max( + 0, + Math.min(rect.bottom, viewport.bottom) - Math.max(rect.top, viewport.top) + ); + const frame = (now) => { + if (!probe.active) return; + const viewport = source.getBoundingClientRect(); + const gaps = [...source.querySelectorAll(".cm-gap")] + .map((element) => intersectHeight(element.getBoundingClientRect(), viewport)); + const visibleLines = [...source.querySelectorAll(".cm-line")] + .filter((element) => intersectHeight(element.getBoundingClientRect(), viewport) > 0).length; + probe.frames.push({ + at: now, + clientHeight: source.clientHeight, + dt: now - probe.previousFrameAt, + maxGapPx: gaps.length ? Math.max(...gaps) : 0, + scrollTop: source.scrollTop, + visibleLines + }); + probe.previousFrameAt = now; + requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + window.__mathNotesFastScrollbarProbe = probe; + }); + const thumbHeight = Math.max(24, (scrollBefore.clientHeight / scrollBefore.scrollHeight) * box.height); const topThumbCenter = box.y + thumbHeight / 2; - const topClampY = box.y - 24; - const bottomClampY = box.y + box.height + 24; + // Keep the pointer inside the native scrollbar track. Leaving the BrowserWindow + // can make Chromium release scrollbar capture, which measures the automation + // harness instead of the editor. + const topClampY = box.y + 2; + const bottomClampY = box.y + box.height - 2; const scrollbarX = box.x + box.width - 3; - const slowMove = async (fromY, toY) => { - for (let step = 1; step <= 120; step += 1) { - await page.mouse.move(scrollbarX, fromY + ((toY - fromY) * step) / 120); - await page.waitForTimeout(8); + const cdp = await page.context().newCDPSession(page); + const directionDurationMs = sourceScrollDirectionMs; + const dragCycles = 4; + const dragRepetitions = 3; + const endpointHoldMs = 24; + const stepsPerDirection = 24; + const endpointMarks = []; + const dispatch = (params) => cdp.send("Input.dispatchMouseEvent", { + ...params, + timestamp: Date.now() / 1000 + }); + const moveAtPhysicalSpeed = async (fromY, toY, kind, repetition, pendingDispatches) => { + const directionStartedAt = Date.now(); + for (let step = 1; step <= stepsPerDirection; step += 1) { + const dueAt = directionStartedAt + (directionDurationMs * step) / stepsPerDirection; + const waitMs = Math.max(0, dueAt - Date.now()); + if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs)); + const y = fromY + ((toY - fromY) * step) / stepsPerDirection; + pendingDispatches.push(dispatch({ type: "mouseMoved", x: scrollbarX, y, button: "left", buttons: 1 })); } + const endpointSentAtEpoch = Date.now(); + pendingDispatches.push(dispatch({ type: "mouseMoved", x: scrollbarX, y: toY, button: "left", buttons: 1 })); + await new Promise((resolve) => setTimeout(resolve, endpointHoldMs)); + endpointMarks.push({ + kind, + repetition, + sentAtEpoch: endpointSentAtEpoch, + startedAtEpoch: directionStartedAt + }); }; await scroller.evaluate((element) => { element.scrollTop = 0; }); - for (let cycle = 0; cycle < 4; cycle += 1) { - await page.mouse.move(scrollbarX, topThumbCenter); - await page.mouse.down(); - await slowMove(topThumbCenter, bottomClampY); - await page.waitForTimeout(60); - const bottomState = await scroller.evaluate((element) => ({ - bottomDistance: element.scrollHeight - element.clientHeight - element.scrollTop, - scrollTop: element.scrollTop - })); - actualDragDistance = Math.max(actualDragDistance, bottomState.scrollTop); - if (bottomState.bottomDistance <= 2) bottomTouches += 1; - - await slowMove(bottomClampY, topClampY); - await page.waitForTimeout(60); - const topState = await scroller.evaluate((element) => element.scrollTop); - await page.mouse.up(); + const dragStartedAt = Date.now(); + for (let repetition = 0; repetition < dragRepetitions; repetition += 1) { + const pendingDispatches = []; + await scroller.evaluate((element) => { element.scrollTop = 0; }); + await dispatch({ type: "mouseMoved", x: scrollbarX, y: topThumbCenter, button: "none", buttons: 0 }); + await dispatch({ type: "mousePressed", x: scrollbarX, y: topThumbCenter, button: "left", buttons: 1, clickCount: 1 }); + for (let cycle = 0; cycle < dragCycles; cycle += 1) { + await moveAtPhysicalSpeed(cycle === 0 ? topThumbCenter : topClampY, bottomClampY, "bottom", repetition, pendingDispatches); + await moveAtPhysicalSpeed(bottomClampY, topClampY, "top", repetition, pendingDispatches); + } + pendingDispatches.push(dispatch({ type: "mouseReleased", x: scrollbarX, y: topClampY, button: "left", buttons: 0, clickCount: 1 })); + await Promise.all(pendingDispatches); await page.waitForTimeout(80); - if (topState <= 2) topTouches += 1; - edgeStates.push({ bottomDistance: bottomState.bottomDistance, bottomScrollTop: bottomState.scrollTop, topScrollTop: topState }); } + const dragDurationMs = Date.now() - dragStartedAt; + await page.waitForTimeout(420); + const fastProbe = await page.evaluate(() => { + const probe = window.__mathNotesFastScrollbarProbe; + probe.active = false; + probe.observer?.disconnect(); + const frames = probe.frames.slice(2); + const frameIntervals = frames.map((frame) => frame.dt).sort((left, right) => left - right); + const quantile = (ratio) => frameIntervals[Math.min(frameIntervals.length - 1, Math.floor(frameIntervals.length * ratio))] ?? 0; + let consecutiveBad = 0; + let maxConsecutiveBadFrames = 0; + for (const frame of frames) { + if (frame.maxGapPx > frame.clientHeight * 0.25 && frame.visibleLines <= 1) { + consecutiveBad += 1; + maxConsecutiveBadFrames = Math.max(maxConsecutiveBadFrames, consecutiveBad); + } else { + consecutiveBad = 0; + } + } + return { + frameCount: frames.length, + frameMaxMs: frameIntervals.at(-1) ?? 0, + frameP95Ms: quantile(0.95), + longTasks: probe.longTasks, + maxConsecutiveBadFrames, + maxGapPx: Math.max(0, ...frames.map((frame) => frame.maxGapPx)), + minVisibleLines: Math.min(...frames.map((frame) => frame.visibleLines)), + scrolls: probe.scrolls + }; + }); + const maxScrollTop = scrollBefore.scrollHeight - scrollBefore.clientHeight; + const endpointLatencies = endpointMarks.map((mark, index) => { + const nextEndpointAt = endpointMarks[index + 1]?.sentAtEpoch ?? Number.POSITIVE_INFINITY; + const reached = fastProbe.scrolls.find((event) => event.atEpoch >= mark.startedAtEpoch && event.atEpoch < nextEndpointAt && ( + mark.kind === "bottom" ? event.scrollTop >= maxScrollTop * 0.98 : event.scrollTop <= maxScrollTop * 0.02 + )); + return { ...mark, latencyMs: reached ? Math.max(0, reached.atEpoch - mark.sentAtEpoch) : null }; + }); + const bottomTouches = endpointLatencies.filter((entry) => entry.kind === "bottom" && entry.latencyMs !== null).length; + const topTouches = endpointLatencies.filter((entry) => entry.kind === "top" && entry.latencyMs !== null).length; + const expectedTouches = dragCycles * dragRepetitions; + const reachedEndpointLatencies = endpointLatencies + .map((entry) => entry.latencyMs) + .filter((latency) => latency !== null) + .sort((left, right) => left - right); + const endpointLatencyP95Ms = reachedEndpointLatencies[ + Math.min(reachedEndpointLatencies.length - 1, Math.floor(reachedEndpointLatencies.length * 0.95)) + ] ?? Number.POSITIVE_INFINITY; + const endpointLatencyMaxMs = reachedEndpointLatencies.at(-1) ?? Number.POSITIVE_INFINITY; + const actualDragDistance = Math.max(0, ...fastProbe.scrolls.map((entry) => entry.scrollTop)); + delete fastProbe.scrolls; + await scroller.evaluate((element) => { element.scrollTop = element.scrollHeight * 0.68; }); await waitForIdleFrames(page, 20); const stableHeights = []; @@ -696,8 +822,11 @@ async function measureSourceScrollbarBlankRegression({ blockCount, notesRoot, pa await page.screenshot({ path: screenshotPath, fullPage: false }); if (actualDragDistance <= 0) throw new Error("Native source scrollbar drag did not move the scroller"); - if (bottomTouches !== 4 || topTouches !== 4) { - throw new Error(`Slow scrollbar round trips did not touch both edges (bottom=${bottomTouches}/4 top=${topTouches}/4 states=${JSON.stringify(edgeStates)})`); + if (bottomTouches !== expectedTouches || topTouches !== expectedTouches) { + throw new Error(`Physical-speed scrollbar round trips did not touch both edges (bottom=${bottomTouches}/${expectedTouches} top=${topTouches}/${expectedTouches} endpoints=${JSON.stringify(endpointLatencies)})`); + } + if (endpointLatencyP95Ms > 50 || endpointLatencyMaxMs > 100) { + throw new Error(`Source scrollbar endpoint tracking exceeded the latency budget (p95=${round(endpointLatencyP95Ms)}ms max=${round(endpointLatencyMaxMs)}ms endpoints=${JSON.stringify(endpointLatencies)})`); } if (JSON.stringify(afterFiles) !== JSON.stringify(beforeFiles)) throw new Error("Source scrollbar changed persisted Markdown bytes"); if (geometry.staticSourceCount !== 0) throw new Error(`Retired scrolling shell remounted ${geometry.staticSourceCount} static sources`); @@ -708,6 +837,16 @@ async function measureSourceScrollbarBlankRegression({ blockCount, notesRoot, pa if (maxBlankTailPx > 12) throw new Error(`Visible source block blank tail reached ${maxBlankTailPx}px`); const heightDriftPx = Math.max(...stableHeights) - Math.min(...stableHeights); if (heightDriftPx > 2) throw new Error(`Source scroll height kept drifting after settle (${heightDriftPx}px)`); + if (fastProbe.maxConsecutiveBadFrames > 1) { + throw new Error(`Source editor exposed a blank viewport during physical-speed drag (${JSON.stringify(fastProbe)})`); + } + if (fastProbe.frameP95Ms > 34 || fastProbe.frameMaxMs > 100) { + throw new Error(`Source scrollbar did not track at interactive frame cadence (${JSON.stringify(fastProbe)})`); + } + const longTasksOver100Ms = fastProbe.longTasks.filter((entry) => entry.duration > 100); + if (longTasksOver100Ms.length > 0) { + throw new Error(`Source scrollbar drag produced long tasks over 100ms (${JSON.stringify(longTasksOver100Ms)})`); + } if (runtimeErrors.length > 0) throw new Error(`Electron runtime errors: ${runtimeErrors.join(" | ")}`); process.stdout.write(`[editor ui lab] ${blockCount}: source scrollbar blank regression passed\n`); @@ -716,7 +855,16 @@ async function measureSourceScrollbarBlankRegression({ blockCount, notesRoot, pa sourceScrollbarBlankRegression: { actualDragDistance: round(actualDragDistance), bottomTouches, - edgeStates, + dragCycles, + dragDurationMs, + dragRepetitions, + endpointHoldMs, + endpointLatencyMaxMs: round(endpointLatencyMaxMs), + endpointLatencyP95Ms: round(endpointLatencyP95Ms), + effectiveEditorMode: scrollBefore.effectiveEditorMode, + endpointLatencies, + fallbackReason: scrollBefore.fallbackReason, + fastProbe, maxBlankTailPx: round(maxBlankTailPx), mountedEditorCount: geometry.mountedEditorCount, persistedMarkdownFiles: beforeFiles.length, @@ -3104,6 +3252,15 @@ async function writeSessionFixture(rootDir, blockCount, profile) { } function buildMarkdownBlock(index, profile, protectedBlockId = null) { + if (profile === "single-long" && index === 1) { + const markdown = []; + for (let line = 1; line <= 776; line += 1) { + if (line % 7 === 0) markdown.push(""); + else if (line % 19 === 0) markdown.push(`第 ${line} 行:$\\|T_${line}x\\| \\le C_${line}\\|x\\|$,用于复现单个超长识别块的滚动测量。`); + else markdown.push(`第 ${line} 行:保持长篇数学材料的原始顺序,并验证快速拖动时编辑区仍持续显示合成文本。`); + } + return { markdown: markdown.join("\n"), lock: null }; + } const markdown = [ `### 定理 ${index}:有界算子与紧性`, "", From fb9703c4a2b4f649d36bc0e4fe9f6aa61e095dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E4=B8=AD?= <1053338060@qq.com> Date: Thu, 13 Aug 2026 15:09:59 +0800 Subject: [PATCH 2/2] test: wait for rendered block lock state in smoke --- PUBLIC_SOURCE_MANIFEST.json | 12 ++++++------ .../src/ui/components/SessionSourceEditor.tsx | 1 + test_tool/electron_app_smoke.mjs | 10 ++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 4e868ea..5d701fc 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "license": "GPL-3.0-only", "historyPolicy": "sanitized-single-snapshot", - "generatedAt": "2026-08-13T06:51:12.331Z", + "generatedAt": "2026-08-13T07:09:25.850Z", "fileCount": 662, - "snapshotSha256": "9f9ffe21f36b629bea84e79e2bc0852247ff08efec7cbe73a3fa4333cc1ef928", + "snapshotSha256": "0b83a8e782c8c7757047f97ef8a431156bbafbac2acf5645685fbe4d152d5e53", "files": [ { "path": ".github/ISSUE_TEMPLATE/bug_report.yml", @@ -2138,8 +2138,8 @@ }, { "path": "apps/windows/src/ui/components/SessionSourceEditor.tsx", - "bytes": 72281, - "sha256": "4ea12f507900c330958eaa42eca5d742fd891e13ccfd55b95eac88fb6492dad0" + "bytes": 72334, + "sha256": "56a6593b2eba0181d29140ab1d2542f43bb052fb92b64d089a86e1e3ded1f1f2" }, { "path": "apps/windows/src/ui/components/SourceTanStackLab.test.tsx", @@ -3008,8 +3008,8 @@ }, { "path": "test_tool/electron_app_smoke.mjs", - "bytes": 51960, - "sha256": "604a2c4db8323cf964ab9b6ab818c5fce09b39e6013b2bb13fcd9d4a4b035a7e" + "bytes": 52403, + "sha256": "7d30a27a98bb4d7ed50004d37d9d3ba3d92d6c47b8b6c3e3c9c50e2743523c5e" }, { "path": "test_tool/export_public_source.mjs", diff --git a/apps/windows/src/ui/components/SessionSourceEditor.tsx b/apps/windows/src/ui/components/SessionSourceEditor.tsx index fd48898..ddb8b15 100644 --- a/apps/windows/src/ui/components/SessionSourceEditor.tsx +++ b/apps/windows/src/ui/components/SessionSourceEditor.tsx @@ -1249,6 +1249,7 @@ function SourceBlockSection({ ].filter(Boolean).join(" ")} data-testid="source-block" data-block-id={block.blockId} + data-locked={block.locked ? "true" : "false"} data-locating-nonce={locatingNonce || undefined} data-source={block.sourceId} data-index={virtualIndex} diff --git a/test_tool/electron_app_smoke.mjs b/test_tool/electron_app_smoke.mjs index 20ec12e..6836fe2 100644 --- a/test_tool/electron_app_smoke.mjs +++ b/test_tool/electron_app_smoke.mjs @@ -175,6 +175,11 @@ try { selectionTargetId, { timeout: 5000 } ); + await page.waitForFunction( + (blockId) => document.querySelector(`[data-testid='source-block'][data-block-id='${blockId}']`)?.getAttribute("data-locked") === "true", + selectionTargetId, + { timeout: 5000 } + ); await selectionTarget.getByTestId("source-block-header").click(); await selectionEditor.click(); await page.keyboard.press("Control+Home"); @@ -194,6 +199,11 @@ try { selectionTargetId, { timeout: 5000 } ); + await page.waitForFunction( + (blockId) => document.querySelector(`[data-testid='source-block'][data-block-id='${blockId}']`)?.getAttribute("data-locked") === "false", + selectionTargetId, + { timeout: 5000 } + ); console.log("[electron smoke] stale proposal keeps its preview and reports an apply conflict"); await selectionEditor.click();