From bcee354e57482d5464be7b70b2bf22aa857abdeb Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 22 Sep 2026 11:46:37 +0800 Subject: [PATCH 1/8] perf(flow-chat): Defer collapsed thinking Markdown Avoid parsing and mounting hidden Markdown when collapsed thinking cards enter the virtual window. Mount content on expansion and release it after the collapse transition settles, preserving rapid reopen behavior and existing typewriter reveal semantics. Cover collapsed content updates, forced expansion, transition completion, cancellation, and reopening with focused tests. Document the lifecycle. --- .../modern/FLOWCHAT_VIRTUALIZATION.md | 12 +++++ .../tool-cards/ModelThinkingDisplay.test.tsx | 53 +++++++++++++++++-- .../tool-cards/ModelThinkingDisplay.tsx | 31 ++++++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 24c399b3e5..8788a3d881 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -20,6 +20,18 @@ cancelled rounds precede it. That boundary prevents cross-round grouping from hiding the label; ordinary within-round tool folding remains available. Round ids and virtual row keys stay unchanged, with no viewport writes or mount animation. +## Collapsed thinking content lifetime + +Thinking cards mount their Markdown body only while expanded or finishing a +collapse transition. Initially collapsed rows therefore do not parse or build +hidden Markdown when virtualization remounts them. Closing content is released +when the actual grid transition finishes or is cancelled; without a transition +(including reduced motion), it is released immediately. Reopening invalidates +the pending release. The typewriter and reveal gate retain their existing +lifetime. `ModelThinkingDisplay.test.tsx` covers this lifecycle with supplied +animation promises; browser animation fidelity and scroll performance still +require runtime verification. + ## Embedded session lifetime `BtwSessionPanel` keeps a lightweight tab-owned wrapper while its content is diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx index a8ed7c303c..a5dd6f220f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { FlowThinkingItem } from '../types/flow-chat'; import { ModelThinkingDisplay } from './ModelThinkingDisplay'; +const markdownRender = vi.hoisted(() => vi.fn()); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, values?: { count?: number }) => ({ @@ -40,9 +42,10 @@ vi.mock('./useToolCardHeightContract', () => ({ })); vi.mock('@/infrastructure/markdown', () => ({ - MarkdownRenderer: ({ content }: { content: string }) => ( -
{content}
- ), + MarkdownRenderer: ({ content }: { content: string }) => { + markdownRender(content); + return
{content}
; + }, })); function summaryItem(content: string): FlowThinkingItem { @@ -72,6 +75,7 @@ describe('ModelThinkingDisplay reasoning summary', () => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); + markdownRender.mockClear(); }); afterEach(() => { @@ -92,8 +96,51 @@ describe('ModelThinkingDisplay reasoning summary', () => { expect(panel?.getAttribute('data-expanded')).toBe('false'); expect(label?.textContent).toBe('Preparing the repair'); expect(label?.textContent).not.toContain('characters'); + expect(markdownRender).not.toHaveBeenCalled(); + }); + + it('does not render a large collapsed reasoning body, including content updates', async () => { + const item = { ...summaryItem('**Reasoning**\n\n'.repeat(6000)), + reasoningKind: 'reasoning' as const, isStreaming: false, status: 'completed' as const }; + await act(async () => root.render()); + await act(async () => root.render()); + expect(markdownRender).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="chat-thinking-content"]')).toBeNull(); }); + it('mounts and releases content when forced expansion changes without an animation', async () => { + const item = summaryItem('**Full summary**'); + await act(async () => root.render()); + expect(container.querySelector('[data-testid="thinking-markdown"]')?.textContent).toBe(item.content); + await act(async () => root.render()); + expect(container.querySelector('[data-testid="thinking-markdown"]')).toBeNull(); + }); + + it.each(['finish', 'cancel', 'reopen'] as const)( + 'retains closing content until the actual transition settles: %s', async outcome => { + await act(async () => root.render()); + const toggle = container.querySelector('[data-testid="chat-thinking-toggle"]') as HTMLElement; + await act(async () => toggle.click()); + const body = container.querySelector('[data-testid="thinking-markdown"]'); + let finish!: () => void; + let cancel!: () => void; + const finished = new Promise((resolve, reject) => { + finish = resolve; + cancel = () => reject(new Error('Transition cancelled')); + }); + const expandContainer = container.querySelector('[data-openbitfun-part="expandContainer"]') as HTMLElement; + Object.defineProperty(expandContainer, 'getAnimations', { + value: () => [{ transitionProperty: 'grid-template-rows', finished }], + }); + await act(async () => toggle.click()); + expect(container.querySelector('[data-testid="thinking-markdown"]')).toBe(body); + if (outcome === 'reopen') await act(async () => toggle.click()); + await act(async () => { if (outcome === 'finish') finish(); else cancel(); }); + expect(container.querySelector('[data-testid="thinking-markdown"]')).toBe(outcome === 'reopen' ? body : null); + }, + ); + it('uses design-system thinking and disclosure icons in the header', async () => { await act(async () => { root.render(); diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index 47b730c428..68df35bd84 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -71,6 +71,32 @@ export const ModelThinkingDisplay: React.FC = ({ )); const [isExpanded, setIsExpanded] = useState(shouldDefaultExpanded); + const [retainClosingContent, setRetainClosingContent] = useState(shouldDefaultExpanded); + const expandContainerRef = useRef(null); + const shouldMountContent = isExpanded || retainClosingContent; + + // Keep the existing collapse transition, but never build a hidden Markdown + // tree on an initially collapsed virtual-row mount. Observe actual CSS + // transitions so reduced motion and cancelled transitions also release it. + useLayoutEffect(() => { + if (isExpanded) { + setRetainClosingContent(true); + return; + } + if (!retainClosingContent) return; + const transitions = expandContainerRef.current?.getAnimations?.().filter(animation => ( + 'transitionProperty' in animation && animation.transitionProperty === 'grid-template-rows' + )) ?? []; + if (transitions.length === 0) { + setRetainClosingContent(false); + return; + } + let cancelled = false; + void Promise.allSettled(transitions.map(animation => animation.finished)).then(() => { + if (!cancelled) setRetainClosingContent(false); + }); + return () => { cancelled = true; }; + }, [isExpanded, retainClosingContent]); const userToggledRef = useRef(false); const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: thinkingItem.id, @@ -400,6 +426,7 @@ export const ModelThinkingDisplay: React.FC = ({
= ({ data-openbitfun-component="model-thinking-display" data-openbitfun-part="expandContainer" > -
+ {shouldMountContent &&
= ({ className="thinking-markdown" />
-
+
}
); From cbb350e1b8ee2b7c8e6beb03fb5be5926f750ee3 Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 22 Sep 2026 13:28:35 +0800 Subject: [PATCH 2/8] fix(flow-chat): Sync compensated scroll offsets Publish the actual viewport offset after accepted row measurement compensation so window selection uses the updated position. This avoids unmounting measured rows and mounting them again on delayed scroll events. Cover delayed scroll and scroll-end delivery with the real virtualizer. --- .../modern/FLOWCHAT_VIRTUALIZATION.md | 13 ++++++++ ...lowChatVirtualizer.initial-window.test.tsx | 30 +++++++++++++++++++ .../modern/useFlowChatVirtualizer.ts | 10 ++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 8788a3d881..678428d518 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -20,6 +20,19 @@ cancelled rounds precede it. That boundary prevents cross-round grouping from hiding the label; ordinary within-round tool folding remains available. Round ids and virtual row keys stay unchanged, with no viewport writes or mount animation. +## Measurement compensation and cached offsets + +When the viewport owner accepts a shift for a measured row wholly above the +reader, the virtualizer publishes the actual scroll offset after updating its +size cache, before selecting the next rendered window. This applies during +ordinary reading as well as opening reconciliation. Waiting for the native +scroll event leaves the old offset paired with new row positions and can remove +newly measured rows, then mount them again on that event. A refused shift does +not trigger this readback. All viewport writes remain with the existing owner. +`useFlowChatVirtualizer.initial-window.test.tsx` covers delayed scroll and +scroll-end delivery using the real virtualizer and supplied geometry; it does +not establish browser performance. + ## Collapsed thinking content lifetime Thinking cards mount their Markdown body only while expanded or finishing a diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx index 6f68774c3c..874f8ef51b 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx @@ -11,6 +11,7 @@ let latestApi: FlowChatVirtualizer; let reconcileEnabled = false; let shortOverscan = false; let viewportSuspended = false; +let shiftEnabled = false; function Harness({ count, tail }: { count: number; tail: boolean }) { const scrollerRef = useRef(null); const headerRef = useRef(null); @@ -21,6 +22,12 @@ function Harness({ count, tail }: { count: number; tail: boolean }) { estimateItemHeightPx: () => 100, startAtTailOnMount: tail, isViewportSuspended: () => viewportSuspended, + shiftViewport: delta => { + const scroller = scrollerRef.current; + if (!shiftEnabled || !scroller) return false; + scroller.scrollTop += delta; + return true; + }, reconcileOpeningMeasurement: () => { const scroller = scrollerRef.current; if (!reconcileEnabled || !scroller) return false; @@ -56,6 +63,7 @@ describe('initial virtual window with the real virtualizer', () => { reconcileEnabled = false; shortOverscan = false; viewportSuspended = false; + shiftEnabled = false; vi.useFakeTimers(); vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); vi.stubGlobal('cancelAnimationFrame', vi.fn()); @@ -170,6 +178,28 @@ describe('initial virtual window with the real virtualizer', () => { expect(windows.at(-1)![0]).toBe(0); }); + it('keeps the measured reading window stable before the compensation scroll event arrives', () => { + shortOverscan = true; + render(34, true); + shiftEnabled = true; + const scroller = host.querySelector('[data-scroller]')!; + act(() => { + scroller.scrollTop = 2500; + scroller.dispatchEvent(new Event('scroll')); + }); + act(() => latestApi.measureRenderedItems()); + expect(scroller.scrollTop).toBeLessThan(2500); + const committedWindow = [...windows.at(-1)!]; + const mountedRows = [...host.querySelectorAll('[data-virtual-index]')]; + // Scroll-end still holds the offset from before the owner applied the shift. + act(() => vi.advanceTimersByTime(200)); + expect(windows.at(-1)).toEqual(committedWindow); + expect([...host.querySelectorAll('[data-virtual-index]')]).toEqual(mountedRows); + act(() => scroller.dispatchEvent(new Event('scroll'))); + expect(windows.at(-1)).toEqual(committedWindow); + expect([...host.querySelectorAll('[data-virtual-index]')]).toEqual(mountedRows); + }); + it.each([false, true])('reconciles measured overscan before delayed events (enabled=%s)', enabled => { shortOverscan = true; render(34, true); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts index 02cbc5f5fe..47b21ae4b9 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -348,6 +348,7 @@ export function useFlowChatVirtualizer({ const reconcileOpeningMeasurementRef = useRef(reconcileOpeningMeasurement); reconcileOpeningMeasurementRef.current = reconcileOpeningMeasurement; const pendingMeasurementRef = useRef(false); + const pendingMeasurementShiftRef = useRef(false); const publishMeasuredOffsetRef = useRef<((actualOffsetPx?: number) => void) | null>(null); const syncViewportOffset = useCallback((actualOffsetPx: number) => { if (Number.isFinite(actualOffsetPx)) publishMeasuredOffsetRef.current?.(actualOffsetPx); @@ -467,13 +468,19 @@ export function useFlowChatVirtualizer({ onChange: (_instance, sync) => { if (sync || !pendingMeasurementRef.current) return; pendingMeasurementRef.current = false; + const shifted = pendingMeasurementShiftRef.current; + pendingMeasurementShiftRef.current = false; // A trace showed rows 22..26 shrinking by 729px while range selection // still used 8669px. They unmounted, then remounted on the delayed 7940px // scroll event (113.8ms style work on removal). Reconcile through follow, // then publish the real offset before React selects the next window. // Retest: five row cleanups became zero; post-reveal sampling advanced // from 786.8ms to 596.4ms (single desktop trace, not paint timing). - if (reconcileOpeningMeasurementRef.current?.()) publishMeasuredOffsetRef.current?.(); + const reconciled = reconcileOpeningMeasurementRef.current?.(); + // Ordinary reading also shifts the real viewport when measured rows above + // it shrink. Publish that readback after the size cache updates, before + // selecting a window from the old offset and unmounting those same rows. + if (reconciled || shifted) publishMeasuredOffsetRef.current?.(); }, estimateSize, getItemKey: resolveItemKey, @@ -515,6 +522,7 @@ export function useFlowChatVirtualizer({ // move the reader's existing content and needs a viewport shift. const fullyAboveViewport = isItemFullyAboveViewport(item.end, beforeScrollTopPx); const applied = fullyAboveViewport ? shiftViewport(delta) : false; + if (applied) pendingMeasurementShiftRef.current = true; const virtualItem = itemsRef.current[item.index]; if (isViewportDiagnosticsEnabled()) { const diagnosticItem = virtualItem as { From c6d352ca2e0a93f8ba4e24efff34ffab6f20705c Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 22 Sep 2026 16:30:03 +0800 Subject: [PATCH 3/8] perf(ui): Narrow sibling selector invalidation Narrow Markdown, usage metadata, dispatch checks, and NumberInput sibling selectors to avoid invalidating unrelated transcript elements when virtual rows change. Preserve spacing and border behavior, and add renderer coverage plus frontend CSS invalidation guidance. Matched DOM samples support style-time reductions for usage metadata and dispatch checks. NumberInput and remaining Markdown rules have invalidation evidence, but no confirmed overall scrolling speedup. Validation: focused component tests, TypeScript, SCSS compilation, and theme color audits passed. The full Web UI check remains blocked by existing mobile typography violations. --- design-system/AGENTS.md | 6 ++++ .../NumberInput/NumberInput.module.css | 6 +++- .../components/NumberInput/NumberInput.tsx | 4 +-- src/web-ui/AGENTS-CN.md | 7 ++++ src/web-ui/AGENTS.md | 7 ++++ .../dispatch/DispatchInstallDialog.scss | 12 ++++--- .../dispatch/DispatchInstallDialog.tsx | 1 + .../usage/SessionUsageReportCard.scss | 6 +++- .../usage/SessionUsageReportCard.tsx | 4 +-- .../src/infrastructure/markdown/Markdown.scss | 33 +++++++++++++++---- .../markdown/MarkdownRenderer.test.tsx | 31 +++++++++++++++++ .../markdown/MarkdownRenderer.tsx | 15 +++++++-- 12 files changed, 112 insertions(+), 20 deletions(-) diff --git a/design-system/AGENTS.md b/design-system/AGENTS.md index 2cc72f685c..1e21892aec 100644 --- a/design-system/AGENTS.md +++ b/design-system/AGENTS.md @@ -17,6 +17,12 @@ This file applies to `design-system/**`. Repository-wide rules in the root `AGEN - Design Lab may alias `@openbitfun/ui` to source only during Vite development for HMR. Its production build must consume package exports. - `@openbitfun/ui/registry` is the source of truth for published components. Design Lab derives navigation, counts, token scopes, and detail routes from that registry; Lab-only previews or copy must never add, retain, or remove a package component. +## CSS invalidation + +- Prefer component-owned classes for sibling rules in dynamic views; tag-only or universal sibling selectors can invalidate unrelated elements even under a scoped ancestor or CSS Module. +- Use layout `gap` only where spacing semantics remain equivalent. Preserve adjacency, specificity, and state overrides when narrowing selectors; `:where()` can add class constraints without raising specificity. +- Use Selector Stats for cause analysis and ordinary traces for performance comparisons. Invalidation records alone do not prove a scrolling speedup. See `src/web-ui/AGENTS.md` for generated-content guidance; do not blanket-ban sibling selectors. + ## Publication boundary - Public manifests expose only `dist/`, README, and package metadata. diff --git a/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css b/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css index a75e39ce0c..35578ce10d 100644 --- a/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css +++ b/design-system/packages/ui/src/components/NumberInput/NumberInput.module.css @@ -17,7 +17,11 @@ .root[data-disabled="true"] .unit { color: var(--openbitfun-color-content-disabled); } .root[data-variant="default"] .buttons { grid-template-columns: 1fr; grid-template-rows: repeat(2, minmax(0, 1fr)); } .root[data-variant="default"] .buttons button { min-inline-size: 20px; font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-meta-line-height); } - .root[data-variant="default"] .buttons button + button { border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border); } + /* Chromium's former button + button invalidation set also selected unrelated + transcript buttons. Scope sibling invalidation to this component's step + buttons. A desktop trace recorded 289 tag invalidations across 77 nodes + with zero final selector matches; an overall scroll speedup is unconfirmed. */ + .root[data-variant="default"] .buttons .stepButton + .stepButton { border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border); } @media (prefers-reduced-motion: reduce) { .control { transition: none; } } @media (forced-colors: active) { .control { border-color: ButtonText; } .root .control:focus-within { border-color: Highlight; } } } diff --git a/design-system/packages/ui/src/components/NumberInput/NumberInput.tsx b/design-system/packages/ui/src/components/NumberInput/NumberInput.tsx index cadcf33f9d..1d7c2f3afa 100644 --- a/design-system/packages/ui/src/components/NumberInput/NumberInput.tsx +++ b/design-system/packages/ui/src/components/NumberInput/NumberInput.tsx @@ -147,8 +147,8 @@ export const NumberInput = forwardRef(functi {unit && {unit}} {showButtons && variant !== "compact" && ( - - + + )} diff --git a/src/web-ui/AGENTS-CN.md b/src/web-ui/AGENTS-CN.md index b00d94d96e..d9b3be5a63 100644 --- a/src/web-ui/AGENTS-CN.md +++ b/src/web-ui/AGENTS-CN.md @@ -45,6 +45,13 @@ Remote Connect 使用全局 GitHub 账户和官方版本化 Relay。账户控件 `i18nService.t(...)` 必须有 bootstrap namespace 覆盖。 - 遵循 `src/web-ui/LOGGING.md`:仅英文、无 emoji、结构化日志 +## 动态界面的 CSS 失效范围 + +- 对频繁增删 DOM 的组件自有兄弟关系样式,优先使用专属类名,避免仅按标签或通配符匹配。CSS Modules 和祖先作用域不能隔离浏览器的样式失效工作。 +- 现有布局与间距语义允许时使用 flex/grid 的 `gap`;不要机械地将同类相邻关系替换为 `:not(:first-child)`,也不要仅为此切换布局模式。 +- 生成内容可以保留语义标签选择器。对实测热点添加渲染器专属类名;用 `:where(.owned-class)` 限定复合选择器时可保持原优先级。保留内嵌 HTML、嵌套列表、混合表格单元格及数学内容的行为。 +- 改写后核对匹配集合与层叠效果。短时 Selector Stats 录制用于定位失效原因,普通录制用于验证收益;失效次数不等于可感知提升。不全面禁止兄弟选择器。 + ## 命令 这里只维护开发/构建入口;验证命令统一放在下方“验证”章节。 diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 2b125b2805..1edaad58d8 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -56,6 +56,13 @@ workspace connections remain independent of Relay sign-in. namespace coverage. - Follow `src/web-ui/LOGGING.md`: English only, no emojis, structured logs +## CSS invalidation in dynamic views + +- For component-owned sibling styles in frequently changing DOM, prefer dedicated classes over tag-only or universal sibling selectors. CSS Modules and an ancestor scope do not isolate browser invalidation work. +- Use flex/grid `gap` when the existing layout and spacing semantics permit it. Do not mechanically replace same-tag adjacency with `:not(:first-child)` or change layout mode. +- Generated content may retain semantic tag selectors. For measured hotspots, add renderer-owned classes; `:where(.owned-class)` can narrow a compound selector without increasing specificity. Preserve raw HTML, nested lists, mixed table cells, and math rendering behavior. +- Check matching and cascade equivalence when rewriting selectors. Use short Selector Stats captures to identify invalidation causes and ordinary traces to measure speedups; invalidation counts alone do not establish user-visible gains. Do not impose a blanket ban on sibling selectors. + ## Commands Keep development/build entry points here. Verification commands are maintained diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss index d594c280e7..b6693c3061 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss @@ -100,16 +100,20 @@ display: grid; gap: 0; + // The former div + div rule invalidated unrelated transcript divs in Chromium. + // Match owned check-row classes instead. Equal-DOM trace sample after the + // metadata fix: 93 added / 1161 retained elements, style 46 -> 23ms and + // elementCount 3111 -> 1432 (single desktop run, not a performance guarantee). + > .dispatch-install-dialog__check-row + .dispatch-install-dialog__check-row { + border-top: 1px solid var(--openbitfun-color-border-subtle); + } + > div { display: grid; grid-template-columns: minmax(104px, auto) minmax(0, 1fr); gap: var(--openbitfun-space-2); padding: var(--openbitfun-space-2) 0; - + div { - border-top: 1px solid var(--openbitfun-color-border-subtle); - } - > span { color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-label-sm-font-size); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index c7cb0365a2..dd2e2bc3c4 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -479,6 +479,7 @@ export const DispatchInstallDialog: React.FC = ({ {probe ? (
31ms and elementCount 3867 -> 2027 (single desktop run). + > .session-usage-report-card__compact-meta-item + .session-usage-report-card__compact-meta-item { padding-inline-start: var(--openbitfun-space-2); border-inline-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx index 766063532f..cd226347d7 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx @@ -246,8 +246,8 @@ export const SessionUsageReportCard: React.FC = ({
- {formatUsageTimestamp(generatedAt ?? report.generatedAt, t)} - {t('usage.card.turns', { count: report.scope.turnCount })} + {formatUsageTimestamp(generatedAt ?? report.generatedAt, t)} + {t('usage.card.turns', { count: report.scope.turnCount })}
diff --git a/src/web-ui/src/infrastructure/markdown/Markdown.scss b/src/web-ui/src/infrastructure/markdown/Markdown.scss index d2829a838c..0ba0801ef7 100644 --- a/src/web-ui/src/infrastructure/markdown/Markdown.scss +++ b/src/web-ui/src/infrastructure/markdown/Markdown.scss @@ -66,7 +66,20 @@ margin-bottom: 0 !important; } -.markdown-renderer > * + * { +// Universal sibling selectors here and in the nested-list gap rule below caused +// Chromium to invalidate the virtual-message-list__items subtree on row insertion +// and removal (confirmed by StyleInvalidatorInvalidationTracking). Select the +// same non-first element children without `* + *`; :where keeps specificity +// unchanged so paragraph/heading overrides retain their existing precedence. +// Desktop trace comparison (2026-09-22, selector stats OFF), at equal DOM counts: +// - Add row 27 (37 added / 451 retained elements): style 53 -> 35ms, +// trace elementCount 3050 -> 2348. +// - Add row 20 (53 added / 771 retained): 77 -> 59ms, 5061 -> 3867. +// - Remove rows 29/30 (77 removed / 950 retained): 81 -> 46ms, 5789 -> 4301. +// These are single-run samples, not a universal speedup; elementCount is the +// trace's style calculation count, not a count of unique DOM nodes. Other costly +// invalidations remained, so this change does not claim to eliminate scroll jank. +.markdown-renderer > :where(:not(:first-child)) { margin-top: var(--markdown-block-gap); } @@ -77,7 +90,12 @@ line-height: inherit; } -.markdown-renderer p + p { +// Tag-only sibling rules caused unrelated elements to enter Chromium style +// invalidation (93 paragraph, 70 list-item and 104 table-cell records in one +// desktop trace). Owned classes narrow the candidates; :where() preserves +// specificity and same-tag adjacency, including sanitized HTML and math output. +// Overall scrolling improvement has not been measured for this change. +.markdown-renderer p:where(.markdown-paragraph) + p:where(.markdown-paragraph) { margin-top: var(--markdown-paragraph-gap); } @@ -156,7 +174,7 @@ word-wrap: break-word; } -.markdown-renderer li + li { +.markdown-renderer li:where(.markdown-list-item) + li:where(.markdown-list-item) { margin-top: var(--markdown-list-item-gap); } @@ -165,7 +183,8 @@ margin-bottom: 0; } -.markdown-renderer li > * + * { +// Same universal-sibling invalidation fix as the block-gap rule above. +.markdown-renderer li > :where(:not(:first-child)) { margin-top: var(--markdown-list-nested-gap); } @@ -457,7 +476,7 @@ vertical-align: middle; } -.markdown-renderer div[align="center"] > p + p { +.markdown-renderer div[align="center"] > p:where(.markdown-paragraph) + p:where(.markdown-paragraph) { margin-top: 0.2rem; } @@ -556,8 +575,8 @@ vertical-align: top; } -.markdown-renderer .table-wrapper th + th, -.markdown-renderer .table-wrapper td + td { +.markdown-renderer .table-wrapper th:where(.markdown-header-cell) + th:where(.markdown-header-cell), +.markdown-renderer .table-wrapper td:where(.markdown-data-cell) + td:where(.markdown-data-cell) { border-left: 1px solid var(--markdown-table-col-divider); } diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx index fd21cca639..1e7b0216d0 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx @@ -185,6 +185,37 @@ describe('Markdown file links', () => { vi.clearAllMocks(); }); + it('preserves same-tag sibling matches for generated and raw HTML content', async () => { + const content = `First paragraph. + +Second paragraph. + +- First item + - Nested item + - Next nested item +- Second item + +| A | B | +| - | - | +| C | D | + +

One

text

Two


Three

+
ABCDE
`; + await act(async () => root.render()); + for (const [tag, className] of [ + ['p', 'markdown-paragraph'], ['li', 'markdown-list-item'], + ['th', 'markdown-header-cell'], ['td', 'markdown-data-cell'], + ]) { + const oldMatches = [...container.querySelectorAll(`${tag} + ${tag}`)]; + expect(oldMatches.length).toBeGreaterThan(0); + expect([...container.querySelectorAll(`${tag}:where(.${className}) + ${tag}:where(.${className})`)]) + .toEqual(oldMatches); + expect([...container.querySelectorAll(tag)].every(node => node.classList.contains(className))).toBe(true); + } + expect([...container.querySelectorAll('div[align="center"] > p:where(.markdown-paragraph) + p:where(.markdown-paragraph)')] + .map(node => node.textContent)).toEqual(['Two']); + }); + it.each([false, true])('keeps fullwidth parentheses outside bare web links (escaped=%s)', async escaped => { const url = 'http://127.0.0.1:8000'; const bare = escaped ? url.replace(':', '\\:') : url; diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx index 0f61bee463..859fe74949 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx @@ -1707,14 +1707,23 @@ export const MarkdownRenderer = React.memo(({ return
    {children}
; }, - li({ children, ...props }: any) { - return
  • {children}
  • ; + li({ node: _node, children, className, ...props }: any) { + return
  • {children}
  • ; + }, + + th({ node: _node, children, className, ...props }: any) { + return {children}; + }, + + td({ node: _node, children, className, ...props }: any) { + return {children}; }, - p({ children, align, style, ...props }: any) { + p({ node: _node, children, align, style, className, ...props }: any) { return (

    {children} From 991e5c05f66818cb24613a3d16a5a5618e4dbf7b Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 22 Sep 2026 16:22:56 +0800 Subject: [PATCH 4/8] fix(flow-chat): Snapshot height before history prepend Capture scroll height immediately before DOM mutation when history moves the old head, rather than reusing a previous commit's height. Keep compensation bounds and viewport ownership unchanged. Cover intervening geometry changes, consecutive prepends, and suspended viewport recovery. Browser performance and remote behavior remain separate validation requirements. --- .../modern/FLOWCHAT_HISTORY_PAGING.md | 12 ++++ .../modern/FlowChatPrependSnapshot.test.tsx | 42 ++++++++++++++ .../modern/FlowChatPrependSnapshot.tsx | 32 +++++++++++ ...rtualMessageList.session-boundary.test.tsx | 57 ++++++++++++++++++- .../components/modern/VirtualMessageList.tsx | 16 +++--- 5 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.tsx diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md index b17c098c1f..af197896e5 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md @@ -1,5 +1,17 @@ # FlowChat History Paging +## Prepend geometry snapshot + +`FlowChatPrependSnapshot` captures the old DOM scroll height in React's +`getSnapshotBeforeUpdate`, only when a new head precedes the previous first +item. The parent layout effect consumes that snapshot after mutation using the +existing compensation bounds and viewport register. Ordinary virtual-window +updates, tail appends and head trims do not read scroll height for this baseline. +This avoids a synchronous layout read on every scroll-driven commit while +including geometry changes since the last React render. Tests model growth at +DOM mutation and verify snapshot ordering; runtime performance and remote +scenarios require separate validation. + The anchor renews its settle budget only when a correction reduces its measured residual (or reaches tolerance), or while a missing Turn is still awaited. An ineffective correction is remembered for that exact anchor/viewport geometry; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.test.tsx new file mode 100644 index 0000000000..46983483f4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import React, { act, useLayoutEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { FlowChatPrependSnapshot } from './FlowChatPrependSnapshot'; + +it('captures current pre-mutation geometry only for a prepend, before parent layout effects', () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + const scrollerRef = { current: null as HTMLDivElement | null }; + const snapshotRef = { current: null as { firstKey: string; scrollHeight: number } | null }; + const reads = vi.fn(() => Number(scrollerRef.current?.dataset.height)); + const observed: unknown[] = []; + function List({ keys, height }: { keys: string[]; height: number }) { + useLayoutEffect(() => { observed.push(snapshotRef.current); }); + return +

    + ; + } + try { + act(() => root.render()); + Object.defineProperty(scrollerRef.current, 'scrollHeight', { get: reads }); + act(() => root.render()); + act(() => root.render()); + expect(reads).not.toHaveBeenCalled(); + // A layout change between React commits must be reflected in the baseline. + scrollerRef.current!.dataset.height = '225'; + act(() => root.render()); + expect(reads).toHaveBeenCalledOnce(); + expect(observed.at(-1)).toEqual({ firstKey: 'b', scrollHeight: 225 }); + expect(scrollerRef.current!.dataset.height).toBe('400'); + act(() => root.render()); + expect(observed.at(-1)).toBeNull(); + act(() => root.render()); + expect(reads).toHaveBeenCalledOnce(); + } finally { + act(() => root.unmount()); + host.remove(); + } +}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.tsx new file mode 100644 index 0000000000..5ee67b7ce2 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatPrependSnapshot.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +interface Props { + itemKeys: readonly string[]; + scrollerRef: React.RefObject; + snapshotRef: React.MutableRefObject<{ firstKey: string; scrollHeight: number } | null>; + children: React.ReactNode; +} + +/** Read the old DOM only when history is actually inserted before its head. + * A height saved after the previous commit can become stale as images or fonts + * settle between commits. Capture immediately before mutation instead; the + * viewport owner still decides and bounds compensation after mutation. + */ +export class FlowChatPrependSnapshot extends React.Component { + getSnapshotBeforeUpdate(previous: Props): null { + this.props.snapshotRef.current = null; + const firstKey = previous.itemKeys[0]; + if (firstKey && this.props.itemKeys[0] !== firstKey + && this.props.itemKeys.indexOf(firstKey) > 0) { + const scroller = this.props.scrollerRef.current; + if (scroller) this.props.snapshotRef.current = { firstKey, scrollHeight: scroller.scrollHeight }; + } + return null; + } + + // React requires this lifecycle with getSnapshotBeforeUpdate. The parent + // consumes the ref in its layout effect, after DOM mutation and before paint. + componentDidUpdate(): void {} + + render() { return this.props.children; } +} diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 8df92d20f7..d077e25af0 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -1002,17 +1002,19 @@ describe('VirtualMessageList natural scroll contract', () => { options: { scrollHeightPx: number; growthPx: number; scrollTopPx?: number }, run: (scroller: HTMLElement) => void, ) { - let scrollHeightPx = options.scrollHeightPx; const restoreLayout = fakeLayout({ clientHeight: 600, - scrollHeight: () => scrollHeightPx, + // DOM geometry grows at mutation, not when new props are prepared. + // The prepend snapshot must still see the old range before that point. + scrollHeight: () => options.scrollHeightPx + ( + container.querySelector('[data-turn-id="turn-old-0"]') ? options.growthPx : 0 + ), turnTopFromScrollerTop: 500, }); try { act(() => root.render()); const scroller = container.querySelector('[data-flowchat-scroller]')!; scroller.scrollTop = options.scrollTopPx ?? 500; - scrollHeightPx += options.growthPx; run(scroller); } finally { restoreLayout(); @@ -1029,6 +1031,55 @@ describe('VirtualMessageList natural scroll contract', () => { act(() => root.render()); } + it('consumes each consecutive prepend once', () => { + const restoreLayout = fakeLayout({ + clientHeight: 600, + scrollHeight: () => 3000 + container.querySelectorAll('[data-turn-id^="batch-"]').length * 40, + turnTopFromScrollerTop: 500, + }); + try { + act(() => root.render()); + const scroller = container.querySelector('[data-flowchat-scroller]')!; + scroller.scrollTop = 500; + for (const batch of ['batch-a', 'batch-b']) { + mocks.items = [userMessage(batch, `${batch}-message`, 'Older'), ...mocks.items]; + act(() => root.render()); + } + expect(scroller.scrollTop).toBe(580); + act(() => root.render()); + expect(scroller.scrollTop).toBe(580); + } finally { restoreLayout(); } + }); + + it('does not replay a prepend received while the viewport is suspended', () => { + const layout = { + clientWidth: 1000, clientHeight: 600, + scrollHeight: () => 3000 + (container.querySelector('[data-turn-id="turn-old-0"]') ? 80 : 0), + turnTopFromScrollerTop: 500, + }; + const restoreLayout = fakeLayout(layout); + try { + act(() => root.render()); + const scroller = container.querySelector('[data-flowchat-scroller]')!; + scroller.scrollTop = 500; + const observer = resizeObservers.find(candidate => candidate.targets.has(scroller))!; + layout.clientHeight = 0; + act(() => observer.notify()); + mocks.items = [userMessage('turn-old-0', 'message-old-0', 'Older'), ...mocks.items]; + act(() => root.render()); + expect(scroller.scrollTop).toBe(500); + animationFrames.clear(); + layout.clientHeight = 600; + act(() => observer.notify()); + const resume = [...animationFrames.values()][0]; + expect(resume).toBeDefined(); + act(() => resume(16)); + const afterResume = scroller.scrollTop; + act(() => root.render()); + expect(scroller.scrollTop).toBe(afterResume); + } finally { restoreLayout(); } + }); + it('moves the viewport by the height that was prepended', () => { // Three 40px items arrived above, so the reader's content is 120px lower // and the viewport follows it. Anything less leaves them looking at diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index d440c49c24..1a97cd9c5c 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -76,6 +76,7 @@ import { type HistoryBoundaryProximity, } from './flowChatHistoryBoundary'; import { VirtualItemRenderer } from './VirtualItemRenderer'; +import { FlowChatPrependSnapshot } from './FlowChatPrependSnapshot'; import { FlowChatOpeningBoundary } from './FlowChatOpeningBoundary'; import { useFlowChatVolatileContext } from './FlowChatContext'; import { @@ -780,11 +781,8 @@ const VirtualMessageListSession = forwardRef(null); - /** - * The scroll range as of the last render, so a prepend can be told what the - * transcript actually grew by rather than only what was reserved for it. - */ - const previousScrollHeightRef = useRef(0); + const prependSnapshotRef = useRef<{ firstKey: string; scrollHeight: number } | null>(null); + const prependItemKeys = useMemo(() => virtualItems.map(getVirtualItemStableKey), [virtualItems]); useLayoutEffect(() => { const previousFirstKey = firstItemKeyRef.current; const nextFirstKey = virtualItems[0] ? getVirtualItemStableKey(virtualItems[0]) : null; @@ -792,10 +790,12 @@ const VirtualMessageListSession = forwardRef + ); }); From 2f9207e712325d06bc18eeaa31e718820825afc7 Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 22 Sep 2026 16:23:09 +0800 Subject: [PATCH 5/8] refactor(ui): Batch overflow measurements per frame Coalesce label geometry reads before publishing overflow state through a per-window frame queue. Preserve reentrant requests, cancel stale results, and isolate callback failures while reporting their errors. Document the deferred overflow indicator and tooltip timing. Cover batch ordering, cancellation, reentrancy, and window isolation without claiming an overall scrolling speedup. --- design-system/packages/ui/README.md | 12 +++ .../primitives/OverflowText/OverflowText.tsx | 18 +++- .../OverflowText/overflowMeasurementQueue.ts | 69 ++++++++++++++ .../packages/ui/tests/overflow-text.test.mjs | 3 +- .../src/shared/ui/OverflowText.test.tsx | 40 +++++++- .../ui/overflowMeasurementQueue.test.ts | 94 +++++++++++++++++++ 6 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 design-system/packages/ui/src/primitives/OverflowText/overflowMeasurementQueue.ts create mode 100644 src/web-ui/src/shared/ui/overflowMeasurementQueue.test.ts diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index a29c4c7e97..3aa189fa36 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -189,6 +189,18 @@ overrides that text; `title=""` opts out when a surrounding native title owns th content. An explicit enclosing `Tooltip` suppresses automatic nested tooltips. Do not use marquee as the sole way to access information on touch surfaces. +Overflow measurement is deferred to a shared animation-frame queue per window. +Mount, content, resize, and font notifications coalesce; all queued labels read +geometry before publishing state. Unmounted labels cancel their pending work. +Overflow indicators and automatic tooltips become available after that frame, +while the complete accessible text is present immediately. This avoids forcing +layout separately inside each label's React mount effect. +Requests made during a batch survive for the next frame; cancellation also +discards unpublished results. A failing label does not abort other labels, +and its error is reported asynchronously. This scheduling contract does not +claim an overall scrolling speedup; first-frame visual behavior requires +browser validation. + Multi-line descriptions should normally wrap. Editable fields, source code, structured paths that need to preserve their suffix, and native controls keep their appropriate text treatment instead of receiving a blanket fade rule. diff --git a/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx b/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx index 090940a449..6587a20c2a 100644 --- a/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx +++ b/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx @@ -15,6 +15,7 @@ import { classNames } from "../../internal/classNames"; import { TooltipTriggerContext } from "../../internal/tooltipTriggerContext"; import { Tooltip } from "../../components/Tooltip"; import styles from "./OverflowText.module.css"; +import { cancelOverflowMeasurement, scheduleOverflowMeasurement } from "./overflowMeasurementQueue"; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect @@ -86,7 +87,7 @@ export const OverflowText = forwardRef( assignRef(forwardedRef, element); }, [forwardedRef]); - const updateOverflow = useCallback(() => { + const readOverflow = useCallback(() => { const element = elementRef.current; const content = contentRef.current ?? element; if (!element || !content) return; @@ -104,10 +105,17 @@ export const OverflowText = forwardRef( if (current.distance === distance && current.isOverflowing === isOverflowing) return; const next = { distance, isOverflowing }; - measurementRef.current = next; - setMeasurement(next); + return () => { + measurementRef.current = next; + setMeasurement(next); + }; }, [lines]); + const updateOverflow = useCallback(() => { + const view = elementRef.current?.ownerDocument.defaultView; + if (view) scheduleOverflowMeasurement(view, readOverflow); + }, [readOverflow]); + const prepareTooltip = useCallback(() => { const element = elementRef.current; const trigger = triggerRef.current; @@ -130,7 +138,9 @@ export const OverflowText = forwardRef( useIsomorphicLayoutEffect(() => { updateOverflow(); - }, [behavior, children, lines, overflowStyle, updateOverflow]); + const view = elementRef.current?.ownerDocument.defaultView; + return () => { if (view) cancelOverflowMeasurement(view, readOverflow); }; + }, [behavior, children, lines, overflowStyle, readOverflow, updateOverflow]); useEffect(() => { if (measurementRef.current.isOverflowing) prepareTooltip(); diff --git a/design-system/packages/ui/src/primitives/OverflowText/overflowMeasurementQueue.ts b/design-system/packages/ui/src/primitives/OverflowText/overflowMeasurementQueue.ts new file mode 100644 index 0000000000..5228be955d --- /dev/null +++ b/design-system/packages/ui/src/primitives/OverflowText/overflowMeasurementQueue.ts @@ -0,0 +1,69 @@ +type ReadMeasurement = () => (() => void) | undefined; + +interface MeasurementJob { + read: ReadMeasurement; + cancelled: boolean; +} +interface MeasurementQueue { + pending: Map; + active: Map; + frame: number | null; +} + +const queues = new WeakMap(); + +/** Batch geometry reads before React state publication. Overflow UI may update + * on the next frame; this is a scheduling contract, not a scroll speed guarantee. + */ +export function scheduleOverflowMeasurement(view: Window, read: ReadMeasurement): void { + let queue = queues.get(view); + if (!queue) { + queue = { pending: new Map(), active: new Map(), frame: null }; + queues.set(view, queue); + } + // A new request supersedes any result already read in the current batch. + const active = queue.active.get(read); + if (active) active.cancelled = true; + queue.pending.set(read, { read, cancelled: false }); + if (queue.frame !== null) return; + const current = queue; + current.frame = view.requestAnimationFrame(() => { + current.frame = null; + // Detach this batch so reentrant requests survive for the next frame. + const batch = current.pending; + current.pending = new Map(); + current.active = batch; + const publications: Array<{ job: MeasurementJob; publish: () => void }> = []; + const report = (error: unknown) => { + // Preserve uncaught-error reporting without aborting other labels. + view.setTimeout(() => { throw error; }, 0); + }; + try { + for (const job of batch.values()) { + if (job.cancelled) continue; + try { + const publish = job.read(); + if (publish) publications.push({ job, publish }); + } catch (error) { report(error); } + } + for (const { job, publish } of publications) { + if (job.cancelled) continue; + try { publish(); } catch (error) { report(error); } + } + } finally { + current.active = new Map(); + } + }); +} + +export function cancelOverflowMeasurement(view: Window, read: ReadMeasurement): void { + const queue = queues.get(view); + if (!queue) return; + const active = queue.active.get(read); + if (active) active.cancelled = true; + queue.pending.delete(read); + if (queue.pending.size === 0 && queue.frame !== null) { + view.cancelAnimationFrame(queue.frame); + queue.frame = null; + } +} diff --git a/design-system/packages/ui/tests/overflow-text.test.mjs b/design-system/packages/ui/tests/overflow-text.test.mjs index 4a57456491..53d2489bcf 100644 --- a/design-system/packages/ui/tests/overflow-text.test.mjs +++ b/design-system/packages/ui/tests/overflow-text.test.mjs @@ -64,8 +64,9 @@ test("OverflowText measures real clipping for fade and marquee treatments", asyn assert.match(source, /measurementRef\.current/); assert.match( source, - /useIsomorphicLayoutEffect\(\(\) => \{\s*updateOverflow\(\);\s*\}, \[behavior, children, lines, overflowStyle, updateOverflow\]\);/s, + /scheduleOverflowMeasurement\(view, readOverflow\)/, ); + assert.match(source, /cancelOverflowMeasurement\(view, readOverflow\)/); assert.match(source, /new ResizeObserver\(updateOverflow\)/); assert.match(source, /resizeObserver\?\.observe\(contentRef\.current\)/); assert.match(source, /--_overflow-text-marquee-distance/); diff --git a/src/web-ui/src/shared/ui/OverflowText.test.tsx b/src/web-ui/src/shared/ui/OverflowText.test.tsx index 3c5d480a40..29820cc37b 100644 --- a/src/web-ui/src/shared/ui/OverflowText.test.tsx +++ b/src/web-ui/src/shared/ui/OverflowText.test.tsx @@ -15,7 +15,11 @@ describe('overflow text full-content access', () => { const resizeCallbacks = new Set<() => void>(); const longLabel = 'Run independent tasks concurrently whenever possible'; - const render = (content: React.ReactNode) => act(() => root.render(content)); + const flushMeasurement = () => act(() => vi.advanceTimersByTime(1)); + const render = (content: React.ReactNode) => { + act(() => root.render(content)); + flushMeasurement(); + }; const hover = (element: Element) => act(() => { element.dispatchEvent(new MouseEvent('mouseenter')); }); @@ -75,6 +79,37 @@ describe('overflow text full-content access', () => { expect(button.getAttribute('aria-describedby')).toBe('help'); }); + it('defers mount reads and coalesces repeated resize notifications for all labels', () => { + const reads: string[] = []; + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { + reads.push(this.textContent ?? ''); + // No label may publish an overflow state while this batch is reading. + expect(host.querySelector('[data-overflow="true"]')).toBeNull(); + return 500; + }); + act(() => root.render(<>FirstSecond)); + act(() => { + resizeCallbacks.forEach(callback => { callback(); callback(); }); + }); + expect(reads).toEqual([]); + flushMeasurement(); + expect(reads).toEqual(['First', 'Second']); + expect(host.querySelectorAll('[data-overflow="true"]')).toHaveLength(2); + }); + + it('cancels pending measurements on unmount and measures the latest props only', () => { + const read = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get'); + act(() => root.render({longLabel})); + act(() => root.render(null)); + flushMeasurement(); + expect(read).not.toHaveBeenCalled(); + act(() => root.render({longLabel})); + act(() => root.render(Short)); + flushMeasurement(); + expect(read).toHaveBeenCalledOnce(); + expect(host.querySelector('[data-overflow]')?.getAttribute('data-overflow')).toBe('false'); + }); + it('keeps interaction-only ellipsis idle on virtual selection and reveals full text on focus', () => { render(