From 7143ff5728452bb5a56f3cef79e6c944d47f2675 Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 14 Oct 2026 15:12:55 +0800 Subject: [PATCH] fix(flowchat): bound synchronous virtualizer updates Keep geometry and viewport reconciliation synchronous while bounding notification-driven React commits. Coalesce further render requests into an animation frame and cancel deferred work when the viewport unmounts. Add regression coverage for tiny rows, physical clamping, reader takeover, descendant layout readbacks and StrictMode session remounts. Document the notification contract and its native-runtime verification limits. Validation: 161 focused tests passed; Web UI type checking passed. Web UI lint completed with no errors and 10 existing warnings. --- .../apps/design-lab/src/design-layout.css | 24 ++- .../design-lab/src/pages/OverviewPage.css | 25 +-- .../design-lab/src/pages/OverviewPage.tsx | 10 +- .../src/preview/MultiSurfaceMorph.css | 10 +- scripts/check-core-boundaries.test.mjs | 25 +++ .../rules/source/required-rules.mjs | 4 +- .../modern/FLOWCHAT_VERIFICATION.md | 1 + .../modern/FLOWCHAT_VIRTUALIZATION.md | 20 ++ .../useFlowChatVirtualizer.cascade.test.tsx | 197 ++++++++++++++++++ .../modern/useFlowChatVirtualizer.ts | 83 +++++++- 10 files changed, 355 insertions(+), 44 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.cascade.test.tsx diff --git a/design-system/apps/design-lab/src/design-layout.css b/design-system/apps/design-lab/src/design-layout.css index b8505e1c68..77c3710be9 100644 --- a/design-system/apps/design-lab/src/design-layout.css +++ b/design-system/apps/design-lab/src/design-layout.css @@ -85,6 +85,26 @@ line-height: var(--openbitfun-type-body-md-line-height); } .lab-shell [hidden] { display: none !important; } +/* Product-site typography belongs to the shell; previews retain their published type roles. */ +.lab-shell .lab-page.design-home, +.lab-topbar, +.design-home-slogan { + font-family: var(--_lab-font); +} +/* Both brand lockups share the shell's typography, with spacing and color owned locally. */ +.design-wordmark-link, +.design-home-footer-wordmark { + font-size: var(--_lab-wordmark-size); + font-weight: var(--_lab-wordmark-weight); + letter-spacing: var(--_lab-wordmark-tracking); + line-height: var(--_lab-wordmark-leading); +} +/* Keep the full-bleed footer aligned with the shell column at every viewport width. */ +.design-home-footer { + inline-size: 100vw; + margin-inline: calc(50% - 50vw); + padding-inline: max(var(--openbitfun-space-6), calc((100vw - var(--_lab-content-width)) / 2 + var(--openbitfun-space-6))); +} /* The homepage's closing band is the one block that leaves the centred column and runs edge to edge (see .design-home-footer). Where the platform reserves space for a scrollbar, 100vw is wider than what is visible, so the band is clipped instead of widening the document. clip is not a scroll @@ -130,10 +150,6 @@ gap: var(--openbitfun-space-3); color: var(--openbitfun-color-content-primary); text-decoration: none; - font-size: var(--_lab-wordmark-size); - font-weight: var(--_lab-wordmark-weight); - letter-spacing: var(--_lab-wordmark-tracking); - line-height: var(--_lab-wordmark-leading); } /* The app mark is silver artwork on a transparent canvas. The site flattens it to solid ink in its light header; the Lab does the same, and lifts it back for the dark theme. */ diff --git a/design-system/apps/design-lab/src/pages/OverviewPage.css b/design-system/apps/design-lab/src/pages/OverviewPage.css index 2ffaead98b..1e1566154b 100644 --- a/design-system/apps/design-lab/src/pages/OverviewPage.css +++ b/design-system/apps/design-lab/src/pages/OverviewPage.css @@ -44,16 +44,6 @@ min-width: 0; } -/* Typography: the homepage runs the product site's own face — IBM Plex Mono, regular weight, - tight leading, slightly negative tracking. The topbar follows on every route, because shell - chrome that changed font between pages would read as a bug. Component instances inside the - preview frame keep the design system's published type roles, so their text stays on the sans - stack. Every other page still renders in the design system's own sans. */ -.lab-shell .lab-page.design-home, -.lab-topbar { - font-family: var(--_lab-font); -} - /* The headline states the design stance and what it is a stance about: one sentence, no subtitle. Weight is the real 600 cut (see the @font-face block in design-layout.css) — the reference site has no 600, and a synthesised bold on a display-size mono headline looks @@ -65,7 +55,6 @@ .design-home-slogan { max-width: 60rem; margin: 0; - font-family: var(--_lab-font); font-size: var(--openbitfun-type-display-fluid-title-font-size); font-weight: var(--openbitfun-font-weight-semibold); line-height: var(--openbitfun-type-display-fluid-title-line-height); @@ -320,10 +309,6 @@ tile; if a surface-inverse token is ever added, this is the line that should switch to it. */ .design-home-footer { --_lab-band-pad-block: calc(var(--openbitfun-space-12) * var(--_lab-home-air)); - /* The band is the viewport, not the page's column, so it reproduces the column gutter in its own - padding: the shell's auto margin plus the page's 36px inner padding. Below the shell's width cap - that expression goes negative, which is what the max() floors. */ - --_lab-column-gutter: max(var(--openbitfun-space-6), calc((100vw - var(--_lab-content-width)) / 2 + var(--openbitfun-space-6))); --_lab-on-dark: var(--openbitfun-color-content-on-dark); --_lab-on-dark-secondary: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 72%, transparent); --_lab-on-dark-muted: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 50%, transparent); @@ -332,15 +317,11 @@ display: flex; flex-direction: column; gap: calc(var(--openbitfun-space-12) * var(--_lab-home-air)); - /* Full bleed: the band leaves the page's centred column and runs to both edges, so it carries no - radius and the page keeps no block-end padding under it. */ - inline-size: 100vw; - margin-inline: calc(50% - 50vw); + /* Full-bleed geometry and column alignment are owned by design-layout.css. */ /* The band opens on the page's full rhythm but closes on half of it: the small print is the last line on the page, so the space under it only has to hold the band's edge rather than separate it from anything. */ padding-block: var(--_lab-band-pad-block) calc(var(--openbitfun-space-6) * var(--_lab-home-air)); - padding-inline: var(--_lab-column-gutter); color: var(--_lab-on-dark); background: var(--openbitfun-color-content-on-light); } @@ -372,10 +353,6 @@ margin: 0; color: var(--_lab-on-dark); text-decoration: none; - font-size: var(--_lab-wordmark-size); - font-weight: var(--_lab-wordmark-weight); - letter-spacing: var(--_lab-wordmark-tracking); - line-height: var(--_lab-wordmark-leading); } .design-home-footer-wordmark-arrow { diff --git a/design-system/apps/design-lab/src/pages/OverviewPage.tsx b/design-system/apps/design-lab/src/pages/OverviewPage.tsx index cbec78959a..e5f2cff6f9 100644 --- a/design-system/apps/design-lab/src/pages/OverviewPage.tsx +++ b/design-system/apps/design-lab/src/pages/OverviewPage.tsx @@ -91,10 +91,10 @@ export function OverviewPage({ onNavigate, colorScheme, contrast, density, token const previewSessions: readonly PreviewSessionRow[] = [ { id: "claw", icon: "user", name: "Claw" }, { id: "claw-session", indent: true, name: "New Claw Session" }, - { id: "bitfun", icon: "folder", name: "BitFun" }, - { id: "bitfun-session", indent: true, name: "New Code Session" }, - { id: "bitfun-welcome", indent: true, name: t("design.homePreview.welcome"), selected: true }, - { id: "bitfun-more", name: t("design.homePreview.showMore"), metadata: }, + { id: "openbitfun", icon: "folder", name: "OpenBitFun" }, + { id: "openbitfun-session", indent: true, name: "New Code Session" }, + { id: "openbitfun-welcome", indent: true, name: t("design.homePreview.welcome"), selected: true }, + { id: "openbitfun-more", name: t("design.homePreview.showMore"), metadata: }, { id: "root", icon: "session", name: "root", metadata: 38.47.103.247 }, { id: "root-session", indent: true, name: "New Code Session" }, { id: "root-relay", indent: true, name: "relay-server account" }, @@ -184,7 +184,7 @@ export function OverviewPage({ onNavigate, colorScheme, contrast, density, token {t("design.homePreview.contextMachine")} - + 1.0.0-explore diff --git a/design-system/apps/design-lab/src/preview/MultiSurfaceMorph.css b/design-system/apps/design-lab/src/preview/MultiSurfaceMorph.css index 54a3b8b341..d2421158a3 100644 --- a/design-system/apps/design-lab/src/preview/MultiSurfaceMorph.css +++ b/design-system/apps/design-lab/src/preview/MultiSurfaceMorph.css @@ -27,7 +27,7 @@ position: relative; inline-size: var(--_morph-w); block-size: var(--_morph-h); - border: var(--_lab-rule); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); border-radius: var(--_morph-r); overflow: hidden; background: var(--openbitfun-color-surface-canvas); @@ -105,7 +105,7 @@ } .design-morph-phone-head { - border-block-end: var(--_lab-rule); + border-block-end: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } .design-morph-phone-thread { @@ -149,7 +149,7 @@ content: ""; position: absolute; inset: var(--openbitfun-space-3); - border: var(--_lab-rule); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); border-radius: var(--openbitfun-radius-pill); } @@ -166,7 +166,7 @@ .design-morph-watch-wave > i { inline-size: 3px; - block-size: var(--_morph-wave, 40%); + block-size: var(--_morph-wave); border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-accent-default); } @@ -196,7 +196,7 @@ gap: var(--openbitfun-space-4); inline-size: min(calc(100% - var(--openbitfun-space-16)), 640px); padding: var(--openbitfun-space-3) var(--openbitfun-space-5); - border: var(--_lab-rule); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); border-radius: var(--openbitfun-radius-2xl); background: var(--openbitfun-color-surface-panel); color: var(--openbitfun-color-content-primary); diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 78087242ce..d79942c768 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -72,6 +72,31 @@ const MODULES = [ const TEST_ROOT = join('C:', 'repo'); +test('builtin agent catalog requires the GeneralPurpose fast model default', () => { + const rule = requiredContentRules.find( + rule => rule.path === 'src/crates/execution/agent-runtime/src/agents.rs' + && rule.patterns.some(pattern => pattern.message.includes('GeneralPurpose')), + ); + assert.ok(rule); + const mapping = `builtin_agent_spec( + "GeneralPurpose", + SubAgent, + "fast", + SubagentVisibilityPolicy::public(), + )`; + const catalog = `fn builtin_agent_definition_specs() { + ${mapping}; + SubagentVisibilityPolicy::restricted(["Claw"]); + }`; + const accepts = text => rule.patterns.every(({ regex }) => regex.test(text)); + assert.ok(accepts(catalog)); + assert.ok(!accepts(catalog.replace(mapping, ''))); + assert.ok(!accepts(catalog.replace('"fast"', '"primary"'))); + assert.ok(!accepts(catalog.replace('SubAgent,', 'Mode,'))); + assert.ok(!accepts(catalog.replace('"GeneralPurpose"', '"Explore"'))); + assert.ok(!accepts(catalog.replace('SubagentVisibilityPolicy::restricted(["Claw"]);', ''))); +}); + test('tool-runtime search regex stays available without enabling HTML extractors', () => { const rule = requiredContentRules.find( rule => rule.path === 'src/crates/execution/tool-execution/Cargo.toml', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 3b4fddadaa..5e23239f38 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -7827,8 +7827,8 @@ export const requiredContentRules = [ message: 'missing builtin agent definition catalog owner', }, { - regex: /builtin_agent_spec\(\s*"GeneralPurpose",\s*SubAgent,\s*"primary"/, - message: 'missing GeneralPurpose runtime default model mapping', + regex: /builtin_agent_spec\(\s*"GeneralPurpose",\s*SubAgent,\s*"fast"/, + message: 'missing GeneralPurpose runtime fast default model mapping', }, { regex: /SubagentVisibilityPolicy::restricted\(\["Claw"\]\)/, diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 4b9f52024c..481b8c423e 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -52,6 +52,7 @@ each missing what the other had. | `../../../infrastructure/diagnostics/flowChatViewportDiagnostics.test.ts` | coalescing, placement sampling, the switch | | `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary | | `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer | +| `useFlowChatVirtualizer.cascade.test.tsx` | bounded synchronous measurement notifications, cross-frame convergence, ordinary synchronous opening, reader takeover and StrictMode/session cleanup; real hooks with supplied geometry, not native paint evidence | | `useFlowChatVirtualizer.initial-window.test.tsx` | tail-first window, empty hydration, head default, one-time seed, user-scroll takeover, opening readbacks before native events, suspended viewport, measured-window reconciliation with delayed scroll/scroll-end delivery; supplied DOM geometry, not performance validation | | `useFlowChatVirtualizer.aim.test.tsx` | the re-aim, and giving it up on takeover | | `VirtualMessageList.layout.test.ts` | the item-height estimate and the spacer | 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 cdceb2e938..26152289a1 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 @@ -124,6 +124,26 @@ the saved hint, attempts, or member identities. ## Measurement compensation and cached offsets +Geometry updates and React rendering have separate delivery contracts. The +virtualizer updates its size cache, applies owner-approved compensation and +publishes the physical offset synchronously. Its local core adapter coalesces +render requests within a commit and permits at most 16 notification-driven +commits before yielding to an animation frame. The frame renders current state; +it does not retain a scroll target or replay a former owner's write. Cleanup +cancels the frame, including session remounts and StrictMode effect replay. + +This bounds a converging measurement cascade as well as an oscillating one: +many near-empty rows can require more than React's nested-update limit while +the window replaces estimates with measurements. Do not defer only the opening +reconciliation callback: follow layout effects and physical range clamping can +also advance the window. Explicit `measureRenderedItems` remains synchronous +for callers that must read geometry before the next React commit. Normal small +cascades still settle synchronously; the budget reset alone causes no render. +`useFlowChatVirtualizer.cascade.test.tsx` covers tiny and zero-height rows, +convergence across frames, reader takeover and remount cleanup using real hooks +and supplied geometry. It is not proof of the production crash's data trigger +or native paint timing. + 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 diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.cascade.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.cascade.test.tsx new file mode 100644 index 0000000000..c7cba580fe --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.cascade.test.tsx @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import React, { act, StrictMode, useCallback, useLayoutEffect, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFlowChatVirtualizer, type FlowChatVirtualizer } from './useFlowChatVirtualizer'; +import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; +import { useFlowChatViewportOwner } from './useFlowChatViewportOwner'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +// Real hooks and TanStack, supplied DOM geometry. This is an update/lifecycle +// regression, not evidence of native WebView paint or the user's exact dataset. +describe('virtual measurement cascades', () => { + let host: HTMLDivElement; + let root: Root; + let api: FlowChatVirtualizer; + let follow: ReturnType; + let frames: Map; + let frameId: number; + let commits: number; + let height: number; + let opening: boolean; + let childReadback: boolean; + let time: number; + const items = Array.from({ length: 1000 }, (_, index) => index); + + function Row({ index, measure }: { index: number; measure: (element: HTMLElement | null) => void }) { + useLayoutEffect(() => { + if (!childReadback) return; + const scroller = host.querySelector('[data-scroller]')!; + scroller.scrollTop = 0; + api.syncViewportOffset(0); + }, []); + return
; + } + + function Harness() { + const scrollerRef = useRef(null); + const headerRef = useRef(null); + const owner = useFlowChatViewportOwner(scrollerRef); + const reconcile = useRef<() => boolean>(() => false); + const attachScroller = useCallback((element: HTMLDivElement | null) => { + scrollerRef.current = element; + if (!element) return; + let top = 0; + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => Math.min(top, Math.max(0, element.scrollHeight - 500)), + set: value => { top = Math.max(0, Math.min(value, element.scrollHeight - 500)); }, + }); + }, []); + api = useFlowChatVirtualizer({ + items, scrollerRef, headerRef, getItemKey: String, + estimateItemHeightPx: () => 100, startAtTailOnMount: true, + shiftViewport: owner.shift, writeViewport: owner.write, + reconcileOpeningMeasurement: () => reconcile.current(), scrollPaddingStartPx: 0, + }); + follow = useFlowChatFollowOutput({ + activeSessionId: 'cascade-session', virtualItemCount: items.length, + isViewportActive: true, isStreaming: false, startAtTailOnMount: !childReadback, + scrollerRef, viewportOwner: owner, viewportId: 987, + readLayoutTarget: () => Math.max(0, (scrollerRef.current?.scrollHeight ?? 0) - 500), + hasRenderedOutput: () => true, isOpeningViewport: () => opening, + cancelNavigation: api.cancelAim, cancelPendingPlacement: () => {}, + placeSubmittedMessage: () => null, + onViewportOffset: offset => api.syncViewportOffset(offset), + }); + reconcile.current = () => { + if (!opening || !follow.isFollowingOutputNow() || owner.currentOwner() !== 'follow-output') return false; + follow.scheduleFollowToLatest(); + return true; + }; + useLayoutEffect(() => { commits += 1; }); + return
+
+
+ {api.rows.map(row => )} +
+
; + } + + beforeEach(() => { + frames = new Map(); + frameId = 0; + commits = 0; + time = 0; + height = 1; + opening = true; + childReadback = false; + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { + return this.hasAttribute('data-scroller') ? 500 : this.hasAttribute('data-virtual-index') ? height : 0; + }); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(500); + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function () { + const content = this.querySelector('[data-items]'); + return content ? Number.parseFloat(content.style.paddingTop || '0') + + Number.parseFloat(content.style.paddingBottom || '0') + content.children.length * height : 0; + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const tick = () => act(() => { + time += 16; + for (const [id, callback] of [...frames]) { + if (!frames.delete(id)) continue; + callback(time); + } + }); + function settle() { + let quiet = 0; + for (let frame = 0; frame < 120 && quiet < 3; frame++) { + const before = commits; + tick(); + quiet = commits === before ? quiet + 1 : 0; + } + expect(quiet).toBe(3); + } + + it.each([0, 1])('converges after yielding when actual rows are %spx', rowHeight => { + height = rowHeight; + act(() => root.render()); + const initialCommits = commits; + settle(); + expect(commits).toBeGreaterThan(initialCommits); + expect(api.rows.at(-1)?.index).toBe(999); + for (const row of api.rows) expect(row.endPx - row.startPx).toBe(rowHeight); + const scroller = host.querySelector('[data-scroller]')!; + expect(scroller.scrollTop).toBe(Math.max(0, scroller.scrollHeight - 500)); + }); + + it('also bounds physical-clamp readbacks outside opening', () => { + opening = false; + act(() => root.render()); + settle(); + expect(api.rows.length).toBeGreaterThan(0); + }); + + it('keeps ordinary opening synchronous without an extra render for the budget reset', () => { + height = 80; + act(() => root.render()); + expect(api.rows.at(-1)?.index).toBe(999); + const before = commits; + settle(); + expect(commits).toBe(before); + }); + + it('does not lose a descendant layout readback behind the preceding render request', () => { + height = 100; + childReadback = true; + act(() => root.render()); + expect(api.rows[0]?.index).toBe(0); + }); + + it('uses current reader ownership instead of replaying the deferred tail placement', () => { + act(() => root.render()); + const scroller = host.querySelector('[data-scroller]')!; + act(() => { + follow.exitFollowOutput('user-scroll'); + scroller.scrollTop = 0; + scroller.dispatchEvent(new Event('scroll')); + }); + settle(); + expect(follow.isFollowingOutputNow()).toBe(false); + expect(scroller.scrollTop).toBe(0); + expect(api.rows[0]?.index).toBe(0); + }); + + it('cancels deferred work on session remount and unmount, including StrictMode', () => { + act(() => root.render()); + expect(frames.size).toBeGreaterThan(0); + const oldFrames = [...frames.keys()]; + height = 80; + act(() => root.render()); + for (const id of oldFrames) expect(frames.has(id)).toBe(false); + settle(); + expect(api.rows.at(-1)?.index).toBe(999); + act(() => root.render(null)); + expect(frames.size).toBe(0); + }); +}); 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 6520d9e5cf..c39b1b6043 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -25,14 +25,15 @@ * scroll has been corrected but the items have not moved yet. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type RefObject } from 'react'; +import { flushSync } from 'react-dom'; import { observeElementOffset as observeTanStackElementOffset, observeElementRect as observeTanStackElementRect, measureElement as measureTanStackElement, - useVirtualizer, + Virtualizer, type Rect, - type Virtualizer, + type VirtualizerOptions, } from '@tanstack/react-virtual'; import { roundViewportPx, @@ -46,6 +47,80 @@ import { type VirtualItemHeightEstimateContext, } from './virtualMessageListLayout'; +// Leave room for descendant refs and follow effects within React's nested +// update limit. Ordinary opening settles synchronously; a long measurement +// cascade must yield even when every individual correction is making progress. +const MAX_SYNCHRONOUS_VIRTUALIZER_COMMITS = 16; + +/** + * Core adapter: geometry stays synchronous, but React notification is bounded. + * The upstream React adapter dispatches before its onChange callback, so that + * callback cannot gate rendering. Keep its core mount/update lifecycle here. + */ +function useMeasuredVirtualizer(options: VirtualizerOptions) { + const [, rerender] = useReducer((revision: number) => revision + 1, 0); + const [instance] = useState(() => new Virtualizer(options)); + const mounted = useRef(false); + const frame = useRef(null); + const commits = useRef(0); + const requested = useRef(false); + const pending = useRef(false); + // Descendant layout effects can publish measurements before our own layout + // effect runs. A request from the previous render cannot cover those writes. + requested.current = false; + + const requestRender = useCallback((sync: boolean) => { + if (!mounted.current || (!sync && requested.current)) return; + pending.current = true; + if (frame.current === null) { + frame.current = requestAnimationFrame(() => { + frame.current = null; + commits.current = 0; + if (!mounted.current || !pending.current) return; + pending.current = false; + // The frame starts a new React update chain. Subsequent notifications + // arm the next frame and share its budget instead of replaying offsets. + requested.current = true; + commits.current = 1; + rerender(); + }); + } + if (commits.current >= MAX_SYNCHRONOUS_VIRTUALIZER_COMMITS) return; + pending.current = false; + requested.current = true; + commits.current += 1; + if (sync) flushSync(rerender); + else rerender(); + }, []); + + instance.setOptions({ + ...options, + onChange: (virtualizer, sync) => { + // Reconcile against the updated cache before asking React to select a + // window. Deferring this callback would replay stale scroll ownership. + options.onChange?.(virtualizer, sync); + requestRender(sync); + }, + }); + useLayoutEffect(() => { + mounted.current = true; + const cleanup = instance._didMount(); + return () => { + mounted.current = false; + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + pending.current = false; + requested.current = false; + commits.current = 0; + cleanup(); + }; + }, [instance]); + useLayoutEffect(() => { + instance._willUpdate(); + }); + return instance; +} + /** A nearby pixel window, bounded in leaves so dense groups mount incrementally. */ export function timelineOverscanRange(start: number, end: number, count: number, viewportPx: number, heightAt: (index: number) => number): number[] { @@ -452,7 +527,7 @@ export function useFlowChatVirtualizer({ const attachingRowsRef = useRef(new Set()); const measurementBatchRef = useRef | null>(null); - const virtualizer = useVirtualizer({ + const virtualizer = useMeasuredVirtualizer({ // A live-tail open previously mounted rows 0..13 before moving to 22..33; // the first head measurement flushed 372.3ms of pending layout in a trace. // Seed the last item's estimated start without reading DOM geometry. This