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