diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ffa06519d1..73004ffc06 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -555,6 +555,9 @@ importers:
semver:
specifier: 7.7.4
version: 7.7.4
+ tabbable:
+ specifier: ^6.5.0
+ version: 6.5.0
unified:
specifier: ^11.0.5
version: 11.0.5
@@ -5435,6 +5438,9 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ tabbable@6.5.0:
+ resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==}
+
tar-fs@3.0.4:
resolution: {integrity: sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==}
@@ -11621,6 +11627,8 @@ snapshots:
symbol-tree@3.2.4: {}
+ tabbable@6.5.0: {}
+
tar-fs@3.0.4:
dependencies:
mkdirp-classic: 0.5.3
diff --git a/src/web-ui/package.json b/src/web-ui/package.json
index 503be5c64d..635bf97c59 100644
--- a/src/web-ui/package.json
+++ b/src/web-ui/package.json
@@ -89,6 +89,7 @@
"remark-rehype": "^11.1.2",
"remark-stringify": "^11.0.0",
"semver": "7.7.4",
+ "tabbable": "^6.5.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"yaml": "^2.8.2",
diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md
index 40ea894a1e..b1d9a6f566 100644
--- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md
+++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md
@@ -31,8 +31,41 @@ to content, it stops being a reservation and becomes the compensation engine
that was removed in "remove synthetic tail-space scrolling" — do not rebuild
that under a new name.
+## Opening Reveal
+
+The opening transcript uses opacity and `aria-hidden` until its existing reveal
+condition settles. `FlowChatOpeningBoundary` provides a local pointer shield,
+non-passive scroll interception, and focus guards that skip the transcript in
+both Tab directions. Programmatic focus is returned outside the transcript with
+`preventScroll`; React capture handlers also block descendant portal activation.
+The sidebar and composer remain available. Focus candidate checks run only in
+response to interaction and prune opening transcript subtrees before measuring.
+
+Avoid inherited `visibility` and whole-transcript `inert` transitions here: a
+visibility reveal measured 260.9ms of style work, and a staged opacity/inert
+trace isolated 177.6ms to removing inert (aria-hidden 0.2ms, opacity 0.4ms).
+The boundary replacement still needs runtime performance and keyboard/reader
+validation. It does not reproduce inert's browser find-in-page exclusion. A new
+auto-opening portal must coordinate its presentation with opening; ancestor
+aria-hidden and the pointer shield cannot hide a portalled surface. Removing a
+geometry read alone only moves pending style/layout work. Read-only probes now
+measure before and after the actual React reveal commit. A staged diagnostic
+measured 176.7ms and 177.7ms of style work when removing the before/after focus
+guards. Both guards now remain mounted after reveal with `tabIndex=-1` and focus
+redirection disabled, preserving transcript sibling structure. The intrusive
+staged diagnostic has been removed; the saving from stable guards still needs
+runtime confirmation using the read-only reveal probes.
+
## Transcript Width
+The scroller's `--_flow-chat-top-mask-start` is registered as non-inheriting:
+only its own edge mask consumes the value. An opening trace placed a 562.3ms
+forced style/layout read immediately after `data-scroll-at-start` changed this
+variable. The registration isolates that state change from message descendants;
+A same-session follow-up reduced the maximum follow height read from 562.3ms
+to 3.6ms and advanced the second opening frame callback from 1803.3ms to
+1197.7ms. Initial row measurement and reveal-related style/layout stalls remain.
+
The main transcript scroller uses `overflow-y: scroll` together with
`scrollbar-gutter: stable`. Its native scrollbar space must remain present when
collapsing content removes vertical overflow, including desktop WebViews where
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 2830aefabb..dda1220a22 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
@@ -31,18 +31,20 @@ each missing what the other had.
| `modelRoundItemMemo.test.ts` | settled rows refresh continuation labels and tool grouping hints without invalidating equivalent hints |
| `flowChatTailFollow.test.ts` | the three-quarter reservation and `hold-tail` geometry |
| `flowChatCollapseMotion.test.ts` | collapse does not move earlier content |
-| `useFlowChatFollowOutput.test.tsx` | one-shot new-Turn reveal, frame loop, blank crossing, resize realign |
+| `useFlowChatFollowOutput.test.tsx` | one-shot new-Turn reveal, frame loop, blank crossing, resize realign, opening readback publication and ownership/lifecycle gates |
| `../../tool-cards/useToolCardHeightContract.test.tsx` | tool cards reflow rather than compensate |
| `flowChatHistoryBoundary.test.ts` | the screenful lead, and the latch's own predicate |
| `flowChatLiveTailWindow.test.ts` | "does the transcript still reach the newest Turn" |
| `flowChatViewportAnchor.test.ts` | anchor geometry and the DOM contract |
| `useFlowChatViewportAnchor.test.tsx` | capture, restore, carry, the settle window |
| `VirtualMessageList.session-boundary.test.tsx` | prepend compensation, the ask, navigation-target current Turn with gesture/follow/session handoff, and search placement only outside the readable viewport |
+| `FlowChatOpeningBoundary.test.tsx` | opening-only activation/scroll isolation, bidirectional focus skipping, programmatic focus return, and reveal cleanup; DOM contracts only |
| `ModernFlowChatContainer.history-state.test.tsx` | history presentation and the submission event |
| `flowChatViewportOwnership.test.ts` | the priority order, preemption, expiry |
| `../../../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.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 |
| `FlowChatTurnRail.test.tsx` | single-marker emphasis, neighboring hover fan, independent keyboard focus, reduced motion, and rail navigation |
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 fda42037b6..24c399b3e5 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
@@ -48,6 +48,49 @@ than its content's.
## What Belongs to the Virtualizer
+On a tail-following open, the virtualizer seeds its initial offset at the last
+item's estimated start. A desktop trace previously mounted rows 0..13 before
+moving to 22..33, with 372.3ms charged to the first head-row measurement. The
+seed selects a tail window without first mounting the head; real heights and
+the existing follow owner still determine the settled position. This is a
+one-time seed, not an ongoing tail lock. Initial empty hydration waits for items
+before consuming it. History-window presentation and saved reading-position
+restoration retain the default initial window. Tests cover window selection
+using the real virtualizer with supplied DOM geometry. A same-session desktop
+retest started at rows 27..33: rowRef total fell from 377.3ms to 4.2ms and the
+post-reveal probe completed at 806.7ms instead of 1540.3ms. This is a single-trace
+comparison, not paint timing or remote validation. The remaining tail-window
+contraction led to the measurement reconciliation described below.
+
+Opening measurement reconciliation now runs after a row size enters TanStack's
+cache and before the queued render chooses its next window. Only an active,
+unsuspended, still-opening transcript whose current owner is `follow-output`
+asks the existing follow scheduler to reconcile. The offset observer then
+publishes the actual scroll position without a synchronous React flush. No
+displacement permission is broadened, and historical reading, user takeover,
+and post-reveal streaming keep their existing rules. A pending debounced native
+scroll-end sample must not overwrite this publication with its older offset.
+The motivating trace measured a 729px shrink of rows 22..26 followed by window
+contraction/remount and 113.8ms of removal-related style work. Tests reproduce
+the contraction with reconciliation disabled and retain the same row nodes
+with it enabled, including a delayed native scroll event and scroll-end timer.
+A same-session desktop retest kept rows 22..33 mounted: row cleanup calls fell
+from five to zero, and the post-reveal probe completed at 596.4ms instead of
+786.8ms. This single-trace comparison does not establish paint timing or remote
+behavior; other main-thread stalls remain.
+
+Opening follow corrections also publish their immediate `scrollTop` readback
+through `syncViewportOffset`, including a target that is already reached. The
+list connects the follow callback to this adapter method; follow never imports
+the virtualizer. Only active, unsuspended opening follow with viewport ownership
+publishes, and refused writes publish nothing. Equal offsets do not notify React.
+This lets range selection proceed before the native scroll event without adding
+a synchronous flush or clearing measured sizes. Measurement reconciliation uses
+the same observer channel, with its pending flag cleared before calling follow
+to avoid recursive correction. Native events remain enabled. Tests withhold them
+and check window expansion, node retention, stale scroll-end delivery and user
+takeover; runtime savings and remote behavior still require separate validation.
+
FlowChat virtualizes with **TanStack Virtual**, behind `useFlowChatVirtualizer.ts`.
Nothing else imports it. The rest of FlowChat asks for offsets in scroller
coordinates and gets them back; there is no index space of the virtualizer's own
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.test.tsx
new file mode 100644
index 0000000000..7c3544fa72
--- /dev/null
+++ b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.test.tsx
@@ -0,0 +1,158 @@
+// @vitest-environment jsdom
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FlowChatOpeningBoundary } from './FlowChatOpeningBoundary';
+import { Portal } from '@openbitfun/ui';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+describe('FlowChat opening interaction boundary', () => {
+ let host: HTMLDivElement;
+ let root: Root;
+ const action = vi.fn();
+ const focused = vi.fn();
+ function render(opening = true) {
+ act(() => root.render(<>
+ Before
+
+ Message action
+
+ Disabled
+
Hidden
+ Accessibility hidden
+
+ >));
+ }
+ const element = (selector: string) => host.querySelector(selector)!;
+ function tab(shiftKey: boolean, destination: string) {
+ document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', {
+ key: 'Tab', shiftKey, bubbles: true, cancelable: true,
+ }));
+ // jsdom does not implement the browser's default Tab traversal.
+ element(destination).focus();
+ }
+ beforeEach(() => {
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ action.mockReset();
+ focused.mockReset();
+ // Supply boxes solely for tabbable's visibility filter, not layout validation.
+ vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue([new DOMRect(0, 0, 1, 1)] as unknown as DOMRectList);
+ });
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ vi.restoreAllMocks();
+ });
+
+ it('skips the transcript in both Tab directions without focusing message controls', () => {
+ render();
+ element('[data-before]').focus();
+ tab(false, '[data-flowchat-opening-guard="before"]');
+ expect(document.activeElement).toBe(element('[data-after]'));
+ tab(true, '[data-flowchat-opening-guard="after"]');
+ expect(document.activeElement).toBe(element('[data-before]'));
+ expect(focused).not.toHaveBeenCalled();
+ });
+
+ it('redirects a positive-tabindex control that bypasses a guard', () => {
+ render();
+ element('[data-inside]').tabIndex = 1;
+ element('[data-before]').focus();
+ tab(false, '[data-inside]');
+ expect(document.activeElement).toBe(element('[data-after]'));
+ expect(focused).not.toHaveBeenCalled();
+ });
+
+ it('returns programmatic focus to the outside control without scrolling', () => {
+ render();
+ const before = element('[data-before]');
+ before.focus();
+ const focus = vi.spyOn(before, 'focus');
+ element('[data-inside]').focus();
+ expect(document.activeElement).toBe(before);
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
+ expect(focused).not.toHaveBeenCalled();
+ });
+
+ it('does not scan transcript descendants when choosing an outside focus target', () => {
+ render();
+ element('[data-before]').focus();
+ const read = vi.fn();
+ Object.defineProperty(element('[data-inside]'), 'getClientRects', { value: read });
+ tab(false, '[data-flowchat-opening-guard="before"]');
+ expect(read).not.toHaveBeenCalled();
+ });
+
+ it('blocks activation and native scroll gestures only inside the opening region', () => {
+ render();
+ element('[data-inside]').click();
+ expect(action).not.toHaveBeenCalled();
+ for (const type of ['wheel', 'touchmove', 'contextmenu', 'pointerdown', 'selectstart']) {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ element('[data-inside]').dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ const outside = new Event(type, { bubbles: true, cancelable: true });
+ element('[data-after]').dispatchEvent(outside);
+ expect(outside.defaultPrevented).toBe(false);
+ }
+ });
+
+ it('releases focus, activation and scroll protection after reveal', () => {
+ render();
+ const guards = [...host.querySelectorAll('[data-flowchat-opening-guard]')];
+ element('[data-after]').focus();
+ render(false);
+ expect(document.activeElement).toBe(element('[data-after]'));
+ element('[data-inside]').focus();
+ element('[data-inside]').click();
+ expect(focused).toHaveBeenCalledTimes(1);
+ expect(action).toHaveBeenCalledTimes(1);
+ const event = new Event('wheel', { bubbles: true, cancelable: true });
+ element('[data-inside]').dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ expect([...host.querySelectorAll('[data-flowchat-opening-guard]')]).toEqual(guards);
+ for (const guard of guards) {
+ expect(guard.tabIndex).toBe(-1);
+ guard.focus();
+ expect(document.activeElement).toBe(guard);
+ }
+ expect(element('[data-transcript]').hasAttribute('aria-hidden')).toBe(false);
+ });
+
+ it('blocks React-owned portal activation and focus while opening', () => {
+ act(() => root.render(<>
+ Before
+
+ Portal action
+
+ >));
+ const before = element('[data-before]');
+ before.focus();
+ const portal = document.querySelector('[data-portal]')!;
+ portal.click();
+ portal.focus();
+ expect(action).not.toHaveBeenCalled();
+ expect(focused).not.toHaveBeenCalled();
+ expect(document.activeElement).toBe(before);
+ });
+
+ it('does not retain autofocus inside the initial commit', () => {
+ act(() => root.render(
+
+ ));
+ expect(document.activeElement).not.toBe(element('[data-inside]'));
+ expect(focused).not.toHaveBeenCalled();
+ });
+
+ it('cleans up native interception on unmount', () => {
+ render();
+ const inside = element('[data-inside]');
+ act(() => root.render(null));
+ const event = new Event('wheel', { bubbles: true, cancelable: true });
+ inside.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ });
+});
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.tsx
new file mode 100644
index 0000000000..0d6ed31838
--- /dev/null
+++ b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.tsx
@@ -0,0 +1,149 @@
+import { useLayoutEffect, useRef, type HTMLAttributes, type SyntheticEvent } from 'react';
+import { isTabbable } from 'tabbable';
+
+type Props = HTMLAttributes & { opening: boolean };
+
+/** Keeps the measurable transcript unavailable without changing inherited DOM state. */
+export function FlowChatOpeningBoundary({ opening, children, ...props }: Props) {
+ const rootRef = useRef(null);
+ const beforeRef = useRef(null);
+ const afterRef = useRef(null);
+ const outsideFocusRef = useRef(null);
+ const directionRef = useRef<1 | -1 | null>(null);
+ const redirectingRef = useRef(false);
+
+ const skipTranscript = (direction: 1 | -1) => {
+ const boundary = direction === 1 ? afterRef.current : beforeRef.current;
+ if (!boundary) return;
+ const doc = boundary.ownerDocument;
+ const candidates: HTMLElement[] = [];
+ // Prune opening transcripts before checking visibility. Never measure their
+ // descendants while finding a destination for a real keyboard interaction.
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode(node) {
+ const element = node as HTMLElement;
+ if (element.matches('[data-flowchat-opening="true"], [inert], [hidden], [aria-hidden="true"]')) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ if (element !== boundary && element.hasAttribute('data-flowchat-opening-guard')) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ return isTabbable(element) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
+ },
+ });
+ while (walker.nextNode()) candidates.push(walker.currentNode as HTMLElement);
+ candidates.sort((a, b) => (a.tabIndex || Infinity) - (b.tabIndex || Infinity));
+ const index = candidates.indexOf(boundary);
+ const target = index < 0 ? undefined : candidates[index + direction];
+ if (target && !target.hasAttribute('data-flowchat-opening-guard')) {
+ target.focus({ preventScroll: true });
+ } else {
+ // At the document edge, leave the browser's next Tab free to reach chrome.
+ boundary.focus({ preventScroll: true });
+ }
+ };
+
+ const redirectFocus = (target: EventTarget | null, previous: EventTarget | null) => {
+ if (redirectingRef.current) return;
+ redirectingRef.current = true;
+ try {
+ if (directionRef.current) {
+ skipTranscript(directionRef.current);
+ } else {
+ const root = rootRef.current;
+ const candidate = previous instanceof HTMLElement ? previous : outsideFocusRef.current;
+ if (candidate instanceof HTMLElement && candidate.isConnected
+ && candidate !== target && !root?.contains(candidate)
+ && !candidate.closest('[inert], [hidden], [aria-hidden="true"]')) {
+ candidate.focus({ preventScroll: true });
+ }
+ if (target instanceof HTMLElement && target.ownerDocument.activeElement === target) {
+ target.blur();
+ }
+ }
+ } finally {
+ directionRef.current = null;
+ redirectingRef.current = false;
+ }
+ };
+
+ useLayoutEffect(() => {
+ const root = rootRef.current;
+ if (!opening || !root) return;
+ const doc = root.ownerDocument;
+ const rememberFocus = (event: FocusEvent) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && !root.contains(target)
+ && !target.hasAttribute('data-flowchat-opening-guard')) outsideFocusRef.current = target;
+ };
+ const rememberDirection = (event: KeyboardEvent) => {
+ directionRef.current = event.key === 'Tab' ? (event.shiftKey ? -1 : 1) : null;
+ };
+ const clearDirection = () => { directionRef.current = null; };
+ const block = (event: Event) => { event.preventDefault(); event.stopImmediatePropagation(); };
+ // A native, non-passive listener is needed to cancel wheel/touch scrolling.
+ root.addEventListener('wheel', block, { capture: true, passive: false });
+ root.addEventListener('touchmove', block, { capture: true, passive: false });
+ root.addEventListener('selectstart', block, true);
+ doc.addEventListener('focusin', rememberFocus);
+ doc.addEventListener('keydown', rememberDirection, true);
+ doc.addEventListener('keyup', clearDirection, true);
+ doc.addEventListener('pointerdown', clearDirection, true);
+ if (root.contains(doc.activeElement)) redirectFocus(doc.activeElement, null);
+ else outsideFocusRef.current = doc.activeElement;
+ return () => {
+ root.removeEventListener('wheel', block, true);
+ root.removeEventListener('touchmove', block, true);
+ root.removeEventListener('selectstart', block, true);
+ doc.removeEventListener('focusin', rememberFocus);
+ doc.removeEventListener('keydown', rememberDirection, true);
+ doc.removeEventListener('keyup', clearDirection, true);
+ doc.removeEventListener('pointerdown', clearDirection, true);
+ };
+ // The boundary is installed once per opening; handlers only read live refs.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [opening]);
+
+ const block = (event: SyntheticEvent) => {
+ if (!opening) return;
+ event.preventDefault();
+ event.stopPropagation();
+ };
+ // Removing either guard invalidated transcript styles and cost about 177ms in
+ // staged traces. Keep both siblings mounted, then only leave the Tab order
+ // at reveal so the browser retains the stable style tree.
+ const guard = (side: 'before' | 'after') => (
+ {
+ if (!opening || redirectingRef.current) return;
+ redirectingRef.current = true;
+ try { skipTranscript(directionRef.current ?? (side === 'before' ? 1 : -1)); }
+ finally { redirectingRef.current = false; directionRef.current = null; }
+ }}
+ />
+ );
+
+ return <>
+ {guard('before')}
+ {
+ if (!opening) return;
+ event.stopPropagation();
+ redirectFocus(event.target, event.relatedTarget);
+ }}
+ onPointerDownCapture={block} onPointerOverCapture={block}
+ onMouseDownCapture={block} onMouseOverCapture={block}
+ onClickCapture={block} onDoubleClickCapture={block} onContextMenuCapture={block}
+ onKeyDownCapture={block} onKeyUpCapture={block} onDragStartCapture={block}
+ >
+ {children}
+ {opening &&
}
+
+ {guard('after')}
+ >;
+}
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts
index 63bfae317a..a576a11af1 100644
--- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts
+++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts
@@ -10,6 +10,16 @@ function readSource(relativePath: string): string {
}
describe('FlowChat transcript edge fade', () => {
+ it('keeps the changing mask stop local to the scroller', () => {
+ const stylesheet = readSource('./VirtualMessageList.scss');
+ const registration = stylesheet.match(/@property\s+--_flow-chat-top-mask-start\s*\{([^}]+)\}/)?.[1];
+
+ expect(registration).toBeDefined();
+ expect(registration).toMatch(/syntax:\s*['"]\*['"]/);
+ expect(registration).toMatch(/inherits:\s*false\s*;/);
+ expect(registration).toMatch(/initial-value:\s*transparent 0\s*;/);
+ });
+
it('fades the transcript at the top and before the live ChatInput edge', () => {
const component = readSource('./VirtualMessageList.tsx');
const stylesheet = readSource('./VirtualMessageList.scss');
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss
index 41e44a279a..1ef7cbcfa7 100644
--- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss
+++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss
@@ -1,6 +1,16 @@
/**
* Virtualized message list styles.
*/
+// The mask stop used to inherit through every message row, so changing the
+// edge state could invalidate the whole transcript. Keep it local to the
+// scroller; the opening trace reduced the follow-up style stall from 562.3ms
+// to 3.6ms.
+@property --_flow-chat-top-mask-start {
+ syntax: '*';
+ inherits: false;
+ initial-value: transparent 0;
+}
+
.virtual-message-list {
width: 100%;
height: 100%;
@@ -92,11 +102,28 @@
/*
* The opening viewport settles over several frames as items measure and
- * history pages in. Keep it laid out and measurable but unpainted until then;
- * see the opening-reveal effect in VirtualMessageList.tsx.
+ * history pages in. Keep it measurable but transparent until then. Opacity
+ * does not inherit into every message. A local shield, focus guards, and
+ * aria-hidden isolate the opening transcript without toggling subtree inertness.
*/
&[data-open-viewport-settled='false'] {
- visibility: hidden;
+ opacity: 0;
+ }
+
+ &__opening-shield {
+ position: absolute;
+ inset: 0;
+ z-index: 11; // Above the scroller and the list's navigation buttons.
+ touch-action: none;
+ }
+
+ &__opening-guard {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ opacity: 0;
+ pointer-events: none;
}
&--empty {
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 10f4e5a597..8df92d20f7 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
@@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({
followsNow: false,
scheduleFollowToLatest: vi.fn(),
startAtTailOnMount: true,
+ virtualizerStartsAtTail: false,
+ reconcileOpeningMeasurement: null as null | (() => boolean),
revealNewTurnTail: null as null | ((turnId: string) => boolean),
/**
* The register the list built, reached through the hook it hands it to.
@@ -127,7 +129,11 @@ vi.mock('./useFlowChatVirtualizer', async () => {
items: Array>;
getItemKey: (item: Record) => string;
scrollerRef: { current: HTMLElement | null };
+ startAtTailOnMount?: boolean;
+ reconcileOpeningMeasurement?: () => boolean;
}) => {
+ mocks.virtualizerStartsAtTail = options.startAtTailOnMount === true;
+ mocks.reconcileOpeningMeasurement = options.reconcileOpeningMeasurement ?? null;
const rows = options.items.map((item, index) => ({
index,
key: options.getItemKey(item),
@@ -367,6 +373,43 @@ describe('VirtualMessageList natural scroll contract', () => {
vi.unstubAllGlobals();
});
+ it('reconciles opening measurements only while follow owns the active viewport', () => {
+ act(() => root.render( ));
+ expect(mocks.reconcileOpeningMeasurement?.()).toBe(false);
+ mocks.followsNow = true;
+ mocks.viewportOwner!.claim('follow-output');
+ mocks.scheduleFollowToLatest.mockClear();
+ expect(mocks.reconcileOpeningMeasurement?.()).toBe(true);
+ expect(mocks.scheduleFollowToLatest).toHaveBeenCalledTimes(1);
+ mocks.viewportOwner!.claim('user-gesture');
+ expect(mocks.reconcileOpeningMeasurement?.()).toBe(false);
+ expect(mocks.scheduleFollowToLatest).toHaveBeenCalledTimes(1);
+ act(() => root.render( ));
+ expect(mocks.reconcileOpeningMeasurement?.()).toBe(false);
+ });
+
+ it('isolates the opening transcript at its boundary until reveal', async () => {
+ act(() => root.render( ));
+ expect(mocks.virtualizerStartsAtTail).toBe(true);
+ const list = container.querySelector('[data-testid="flowchat-message-list"]')!;
+ expect(list.getAttribute('data-open-viewport-settled')).toBe('false');
+ expect(list.hasAttribute('inert')).toBe(false);
+ expect(container.querySelectorAll('[data-flowchat-opening-guard]')).toHaveLength(2);
+ expect(list.querySelector('.virtual-message-list__opening-shield')).not.toBeNull();
+ expect(list.getAttribute('aria-hidden')).toBe('true');
+
+ await settleOpenReveal();
+
+ expect(list.getAttribute('data-open-viewport-settled')).toBe('true');
+ expect(list.hasAttribute('inert')).toBe(false);
+ expect(list.hasAttribute('aria-hidden')).toBe(false);
+ expect(container.querySelectorAll('[data-flowchat-opening-guard][tabindex="-1"]')).toHaveLength(2);
+ expect(list.querySelector('.virtual-message-list__opening-shield')).toBeNull();
+ mocks.followsNow = true;
+ mocks.viewportOwner!.claim('follow-output');
+ expect(mocks.reconcileOpeningMeasurement?.()).toBe(false);
+ });
+
it('renders only the current input layout inset in the Footer', () => {
act(() => root.render( ));
const footer = container.querySelector('.message-list-footer');
@@ -903,21 +946,23 @@ describe('VirtualMessageList natural scroll contract', () => {
});
}
- it('treats a scroll under a scrollbar press as intent', () => {
+ it('treats a scroll under a scrollbar press as intent', async () => {
act(() => root.render( ));
+ await settleOpenReveal();
pressAt(CONTENT_BOX_WIDTH + 6);
expect(mocks.handleUserScrollIntent).toHaveBeenCalled();
});
- it('leaves a scroll under a press on the transcript alone', () => {
+ it('leaves a scroll under a press on the transcript alone', async () => {
// Layout growth and virtualizer remeasurement emit scroll events too, so
// the press is what qualifies one — not the event itself.
act(() => root.render( ));
+ await settleOpenReveal();
pressAt(CONTENT_BOX_WIDTH - 200);
expect(mocks.handleUserScrollIntent).not.toHaveBeenCalled();
});
- it('gives up an aim still in flight, which the claim alone cannot reach', () => {
+ it('gives up an aim still in flight, which the claim alone cannot reach', async () => {
/*
* The register refuses the re-aim's writes only while the gesture's hold
* is live — 200ms after the last notch, against a five-second re-aim —
@@ -926,12 +971,14 @@ describe('VirtualMessageList natural scroll contract', () => {
* for 7784 12ms after that.
*/
act(() => root.render( ));
+ await settleOpenReveal();
pressAt(CONTENT_BOX_WIDTH + 6);
expect(mocks.cancelAim).toHaveBeenCalled();
});
- it('disarms on release, so a later scroll is not intent', () => {
+ it('disarms on release, so a later scroll is not intent', async () => {
act(() => root.render( ));
+ await settleOpenReveal();
pressAt(CONTENT_BOX_WIDTH + 6);
mocks.handleUserScrollIntent.mockClear();
@@ -1509,6 +1556,7 @@ describe('VirtualMessageList natural scroll contract', () => {
);
expect(mocks.startAtTailOnMount).toBe(false);
+ expect(mocks.virtualizerStartsAtTail).toBe(false);
expect(scroller.scrollTop).toBe(140);
await settleOpenReveal();
expect(container.querySelector('[data-open-viewport-settled="true"]')).not.toBeNull();
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 97ac4d444d..74275e126b 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 { FlowChatOpeningBoundary } from './FlowChatOpeningBoundary';
import { useFlowChatVolatileContext } from './FlowChatContext';
import {
estimateVirtualMessageItemHeightWithContext,
@@ -521,8 +522,11 @@ const VirtualMessageListSession = forwardRef boolean>(() => false);
const virtualizer = useFlowChatVirtualizer({
items: virtualItems,
+ startAtTailOnMount: presentationMode !== 'history-window' && !shouldRestoreInitialSnapshot,
+ reconcileOpeningMeasurement: () => reconcileOpeningMeasurementRef.current(),
scrollerRef: scrollerElementRef,
headerRef: headerElementRef,
getItemKey: getVirtualItemStableKey,
@@ -702,10 +706,19 @@ const VirtualMessageListSession = forwardRef {
+ if (isOpenViewportSettledRef.current || isViewportSuspendedRef.current
+ || !isViewportActive || !isFollowingOutputNow()
+ || viewportOwner.currentOwner() !== 'follow-output') return false;
+ scheduleFollowToLatest();
+ return true;
+ };
+
/**
* The anchor stands down for anyone aiming at a target of their own — and for
* nobody else, the reader included.
@@ -2598,7 +2611,7 @@ const VirtualMessageListSession = forwardRef
-
+
);
});
diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx
index 9a321025e4..b6904c0848 100644
--- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx
@@ -59,6 +59,10 @@ interface HarnessProps {
scrollToContentEnd?: (behavior: ScrollBehavior) => void;
revealNewTurnTail?: (turnId: string) => boolean;
isOpeningViewport?: boolean;
+ onOpeningOffset?: (actualOffsetPx: number) => void;
+ isViewportActive?: boolean;
+ isViewportSuspended?: boolean;
+ startAtTailOnMount?: boolean;
onController: (controller: Controller) => void;
/** The register the hook writes through, for a test that has to hold it. */
onViewportOwner?: (owner: FlowChatViewportOwnerApi) => void;
@@ -72,6 +76,10 @@ function Harness({
scrollToContentEnd = () => {},
revealNewTurnTail = () => false,
isOpeningViewport = false,
+ onOpeningOffset,
+ isViewportActive = true,
+ isViewportSuspended = false,
+ startAtTailOnMount = true,
onController,
onViewportOwner,
}: HarnessProps) {
@@ -86,13 +94,16 @@ function Harness({
dialogTurnCount,
virtualItemCount: 2,
isStreaming,
- isViewportActive: true,
+ isViewportActive,
+ isViewportSuspended: () => isViewportSuspended,
+ startAtTailOnMount,
scrollerRef,
// Sized from live layout, exactly as the component's state does.
getTailSpacerPx: () => tailSpacerPxForViewport(scroller.clientHeight, BOTTOM_INSET),
scrollToContentEnd,
revealNewTurnTail,
isOpeningViewport: () => isOpeningViewport,
+ onOpeningOffset,
viewportOwner,
});
onController(controller);
@@ -307,6 +318,92 @@ describe('useFlowChatFollowOutput', () => {
expect(scroller.scrollTop).toBe(1000);
});
+ describe('opening offset publication', () => {
+ const mountOpening = (props: Partial = {}) => {
+ act(() => root.render( { controller = next; }}
+ {...props}
+ />));
+ };
+
+ beforeEach(() => {
+ setScrollerMetrics(scroller, {
+ scrollHeight: 1500 + TAIL_SPACER, clientHeight: VIEWPORT, scrollTop: 0,
+ });
+ });
+
+ it('publishes the clamped readback in the follow frame without a scroll event', () => {
+ let actualTop = 0;
+ Object.defineProperty(scroller, 'scrollTop', {
+ configurable: true,
+ get: () => actualTop,
+ set: (value: number) => { actualTop = Math.min(value, 900); },
+ });
+ const onOpeningOffset = vi.fn();
+ mountOpening({ onOpeningOffset });
+ expect(onOpeningOffset).toHaveBeenCalledWith(900);
+ onOpeningOffset.mockClear();
+ actualTop = 0;
+ runNextFrame();
+ expect(onOpeningOffset).toHaveBeenCalledExactlyOnceWith(900);
+ expect(scroller.scrollTop).toBe(900);
+ });
+
+ it('publishes an already-reached target and uses the latest callback', () => {
+ scroller.scrollTop = 1000;
+ const previous = vi.fn();
+ const current = vi.fn();
+ mountOpening({ onOpeningOffset: previous });
+ previous.mockClear();
+ mountOpening({ onOpeningOffset: current });
+ current.mockClear();
+ runNextFrame();
+ expect(previous).not.toHaveBeenCalled();
+ expect(current).toHaveBeenCalledExactlyOnceWith(1000);
+ });
+
+ it.each([0, 1000])('does not publish under a higher-priority owner at offset %s', offset => {
+ scroller.scrollTop = offset;
+ const onOpeningOffset = vi.fn();
+ let owner: FlowChatViewportOwnerApi;
+ mountOpening({ onOpeningOffset, onViewportOwner: next => { owner = next; } });
+ act(() => owner.claim('one-shot-navigation', { holdForMs: 1000 }));
+ expect(owner!.currentOwner()).toBe('one-shot-navigation');
+ scroller.scrollTop = offset;
+ onOpeningOffset.mockClear();
+ act(() => controller?.scheduleFollowToLatest());
+ expect(scroller.scrollTop).toBe(offset);
+ expect(onOpeningOffset).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { isOpeningViewport: false },
+ { isViewportActive: false },
+ { isViewportSuspended: true },
+ { startAtTailOnMount: false },
+ ])('does not publish outside active opening follow: %j', boundary => {
+ const onOpeningOffset = vi.fn();
+ mountOpening({ onOpeningOffset, ...boundary });
+ act(() => controller?.scheduleFollowToLatest());
+ expect(onOpeningOffset).not.toHaveBeenCalled();
+ });
+
+ it('stops publishing when the reader takes over', () => {
+ const onOpeningOffset = vi.fn();
+ mountOpening({ onOpeningOffset });
+ runNextFrame();
+ onOpeningOffset.mockClear();
+ act(() => controller?.handleUserScrollIntent());
+ scroller.scrollTop = 100;
+ act(() => controller?.scheduleFollowToLatest());
+ expect(scroller.scrollTop).toBe(100);
+ expect(onOpeningOffset).not.toHaveBeenCalled();
+ });
+ });
+
it('does not strand the viewport inside the tail spacer after opening', () => {
// Regression: the gap tolerance is a streaming allowance. Applied to a
// foreign forward move it parked the content end mid-viewport forever,
diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts
index ae8941bd22..03919d3b12 100644
--- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts
+++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts
@@ -84,6 +84,8 @@ interface UseFlowChatFollowOutputOptions {
revealNewTurnTail: (turnId: string) => boolean;
/** True while the transcript is still hidden for the opening reveal. */
isOpeningViewport: () => boolean;
+ /** Immediate position readback after an opening follow correction. */
+ onOpeningOffset?: (actualOffsetPx: number) => void;
/**
* Who is moving the viewport. Every write below goes through it, so that
* nothing else has to carry a private opinion about when this hook is busy.
@@ -210,6 +212,7 @@ export function useFlowChatFollowOutput({
scrollToContentEnd,
revealNewTurnTail,
isOpeningViewport,
+ onOpeningOffset,
viewportOwner,
viewportId = 0,
}: UseFlowChatFollowOutputOptions): UseFlowChatFollowOutputResult {
@@ -220,6 +223,8 @@ export function useFlowChatFollowOutput({
const latestTurnIdRef = useRef(latestTurnId);
const isViewportSuspendedRef = useRef(isViewportSuspended);
isViewportSuspendedRef.current = isViewportSuspended;
+ const onOpeningOffsetRef = useRef(onOpeningOffset);
+ onOpeningOffsetRef.current = onOpeningOffset;
const followFrameRef = useRef(null);
const previousSessionIdRef = useRef(activeSessionId);
const previousLatestTurnIdRef = useRef(latestTurnId);
@@ -589,7 +594,9 @@ export function useFlowChatFollowOutput({
settleFramesRef.current = SETTLE_FRAMES;
}
- const onTarget = Math.abs(next.target - scroller.scrollTop) <= BOTTOM_EPSILON_PX;
+ let actualOffsetPx = scroller.scrollTop;
+ let writeGranted = true;
+ const onTarget = Math.abs(next.target - actualOffsetPx) <= BOTTOM_EPSILON_PX;
/*
* What the loop decided this frame, coalesced by the decision.
*
@@ -690,7 +697,7 @@ export function useFlowChatFollowOutput({
})
? nextEasedScrollTopPx(fromPx, next.target)
: { offsetPx: next.target, outcome: 'snapped' as const };
- viewportOwner.write({ owner: 'follow-output', topPx: step.offsetPx });
+ writeGranted = viewportOwner.write({ owner: 'follow-output', topPx: step.offsetPx });
/*
* Read back rather than taken from the step. The register can refuse
* this write outright, and a refused follow moves nothing — believing
@@ -698,7 +705,8 @@ export function useFlowChatFollowOutput({
* smoothest one in the session, and would book the frame below forever
* over travel that never happens.
*/
- const movedPx = scroller.scrollTop - fromPx;
+ actualOffsetPx = scroller.scrollTop;
+ const movedPx = actualOffsetPx - fromPx;
/*
* An ease in flight is a reason to run again, and the only one it has
* once the target stops moving: the budget is refreshed by the *target*
@@ -719,6 +727,14 @@ export function useFlowChatFollowOutput({
});
}
}
+ // Native scroll delivery can lag a frame behind this write. Publish the
+ // DOM readback through the existing virtualizer observer channel so range
+ // selection catches up in the same task without a synchronous flush.
+ if (writeGranted && isOpeningViewport() && isFollowingOutputRef.current
+ && followPhaseRef.current === 'following-tail' && isViewportActiveRef.current
+ && !document.hidden && viewportOwner.currentOwner() === 'follow-output') {
+ onOpeningOffsetRef.current?.(actualOffsetPx);
+ }
}, [
endSmoothScrollYield,
getTailSpacerPx,
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
new file mode 100644
index 0000000000..6f68774c3c
--- /dev/null
+++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx
@@ -0,0 +1,200 @@
+// @vitest-environment jsdom
+import React, { act, 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';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const windows: number[][] = [];
+let latestApi: FlowChatVirtualizer;
+let reconcileEnabled = false;
+let shortOverscan = false;
+let viewportSuspended = false;
+function Harness({ count, tail }: { count: number; tail: boolean }) {
+ const scrollerRef = useRef(null);
+ const headerRef = useRef(null);
+ const api = useFlowChatVirtualizer({
+ items: Array.from({ length: count }, (_, index) => index),
+ scrollerRef, headerRef,
+ getItemKey: String,
+ estimateItemHeightPx: () => 100,
+ startAtTailOnMount: tail,
+ isViewportSuspended: () => viewportSuspended,
+ reconcileOpeningMeasurement: () => {
+ const scroller = scrollerRef.current;
+ if (!reconcileEnabled || !scroller) return false;
+ scroller.scrollTop = Math.max(0, scroller.scrollHeight - 500);
+ // Opening follow publishes from inside measurement reconciliation too.
+ latestApi.syncViewportOffset(scroller.scrollTop);
+ return true;
+ },
+ scrollPaddingStartPx: 0,
+ writeViewport: ({ topPx }) => {
+ const element = scrollerRef.current;
+ if (!element) return false;
+ // Model a browser clamping a write while no rows have mounted yet.
+ element.scrollTop = Math.max(0, Math.min(topPx, element.scrollHeight - 500));
+ return true;
+ },
+ });
+ latestApi = api;
+ useLayoutEffect(() => { windows.push(api.rows.map(row => row.index)); });
+ return
+
+
+ {api.rows.map(row =>
)}
+
+
;
+}
+
+describe('initial virtual window with the real virtualizer', () => {
+ let host: HTMLDivElement;
+ let root: Root;
+ beforeEach(() => {
+ windows.length = 0;
+ reconcileEnabled = false;
+ shortOverscan = false;
+ viewportSuspended = false;
+ vi.useFakeTimers();
+ vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1));
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+ vi.stubGlobal('ResizeObserver', class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ // Explicit geometry supplies jsdom's missing layout, not performance proof.
+ vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
+ if (this.hasAttribute('data-scroller')) return 500;
+ if (!this.hasAttribute('data-virtual-index')) return 0;
+ return shortOverscan && Number(this.getAttribute('data-virtual-index')) < 27 ? 10 : 80;
+ });
+ 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 items = this.querySelector('[data-items]');
+ return items ? Number.parseFloat(items.style.paddingTop || '0')
+ + Number.parseFloat(items.style.paddingBottom || '0')
+ + [...items.children].reduce((sum, row) => sum + (row as HTMLElement).offsetHeight, 0) : 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 render = (count: number, tail: boolean) => act(() => root.render( ));
+
+ it('mounts the tail first without measuring head rows, then accepts user scrolling', () => {
+ render(34, true);
+ const populated = windows.filter(window => window.length);
+ expect(populated.length).toBeGreaterThan(0);
+ expect(populated[0].at(-1)).toBe(33);
+ expect(populated.every(window => window[0] > 0)).toBe(true);
+ const scroller = host.querySelector('[data-scroller]')!;
+ act(() => {
+ scroller.scrollTop = 0;
+ scroller.dispatchEvent(new Event('scroll'));
+ });
+ expect(windows.at(-1)?.[0]).toBe(0);
+ });
+
+ it('waits for initially empty data before consuming the tail seed', () => {
+ render(0, true);
+ expect(windows.every(window => window.length === 0)).toBe(true);
+ render(34, true);
+ const first = windows.find(window => window.length)!;
+ expect(first[0]).toBeGreaterThan(0);
+ expect(first.at(-1)).toBe(33);
+ });
+
+ it('preserves the default head window for history and reading restoration', () => {
+ render(34, false);
+ expect(windows.find(window => window.length)?.[0]).toBe(0);
+ });
+
+ it('handles a single row and does not re-seed after later data changes', () => {
+ render(1, true);
+ expect(windows.find(window => window.length)).toEqual([0]);
+ windows.length = 0;
+ render(34, true);
+ expect(windows.find(window => window.length)?.[0]).toBe(0);
+ });
+
+ it('expands the opening window from a readback before any native scroll arrives', () => {
+ shortOverscan = true;
+ render(34, true);
+ reconcileEnabled = true;
+ expect(windows.at(-1)![0]).toBe(27);
+ const scroller = host.querySelector('[data-scroller]')!;
+ act(() => {
+ latestApi.scrollToOffset(scroller.scrollHeight - 500, { owner: 'follow-output' });
+ latestApi.syncViewportOffset(scroller.scrollTop);
+ });
+ const first = windows.at(-1)![0];
+ expect(first).toBeLessThan(27);
+ expect(windows.at(-1)!.at(-1)).toBe(33);
+ const row = host.querySelector(`[data-virtual-index="${first}"]`);
+ const commits = windows.length;
+ act(() => latestApi.syncViewportOffset(scroller.scrollTop));
+ expect(windows).toHaveLength(commits);
+ act(() => {
+ scroller.dispatchEvent(new Event('scroll'));
+ vi.advanceTimersByTime(200);
+ });
+ expect(windows.at(-1)![0]).toBe(first);
+ expect(host.querySelector(`[data-virtual-index="${first}"]`)).toBe(row);
+ reconcileEnabled = false;
+ act(() => {
+ scroller.scrollTop = 0;
+ scroller.dispatchEvent(new Event('scroll'));
+ });
+ expect(windows.at(-1)![0]).toBe(0);
+ });
+
+ it('does not publish readbacks while the viewport is suspended', () => {
+ render(34, true);
+ const commits = windows.length;
+ const scroller = host.querySelector('[data-scroller]')!;
+ viewportSuspended = true;
+ act(() => latestApi.syncViewportOffset(scroller.scrollTop));
+ expect(windows).toHaveLength(commits);
+ viewportSuspended = false;
+ act(() => latestApi.syncViewportOffset(scroller.scrollTop));
+ expect(windows.at(-1)![0]).toBe(0);
+ });
+
+ it.each([false, true])('reconciles measured overscan before delayed events (enabled=%s)', enabled => {
+ shortOverscan = true;
+ render(34, true);
+ reconcileEnabled = enabled;
+ const scroller = host.querySelector('[data-scroller]')!;
+ act(() => {
+ latestApi.scrollToOffset(scroller.scrollHeight - 500, { owner: 'follow-output' });
+ scroller.dispatchEvent(new Event('scroll'));
+ });
+ const first = windows.at(-1)![0];
+ if (!enabled) {
+ // Control: the old cached offset contracts the window back to the last row.
+ expect(first).toBe(27);
+ return;
+ }
+ expect(first).toBeLessThan(27);
+ const overscanRow = host.querySelector(`[data-virtual-index="${first}"]`);
+ expect(overscanRow).not.toBeNull();
+ // Native scroll dispatch is intentionally withheld after reconciliation.
+ // The old scroll-end timeout must not restore its captured, outdated offset.
+ act(() => vi.advanceTimersByTime(200));
+ expect(windows.at(-1)![0]).toBe(first);
+ expect(host.querySelector(`[data-virtual-index="${first}"]`)).toBe(overscanRow);
+ act(() => scroller.dispatchEvent(new Event('scroll')));
+ expect(windows.at(-1)![0]).toBe(first);
+ expect(host.querySelector(`[data-virtual-index="${first}"]`)).toBe(overscanRow);
+ });
+});
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 3c35e1e798..02cbc5f5fe 100644
--- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts
+++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts
@@ -27,6 +27,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
+ observeElementOffset as observeTanStackElementOffset,
observeElementRect as observeTanStackElementRect,
useVirtualizer,
type Rect,
@@ -161,6 +162,11 @@ export interface UseFlowChatVirtualizerOptions {
estimateContext?: VirtualItemHeightEstimateContext;
/** Stable identity for data that changes an unmeasured row's estimate. */
estimateContextRevision?: string | number;
+ /** Seed the first window near the tail; subsequent positioning belongs to follow. */
+ startAtTailOnMount?: boolean;
+ /** Reconcile an opening follow target after a measured size changes. True
+ * means the owner handled it and the actual offset can be published now. */
+ reconcileOpeningMeasurement?: () => boolean;
/** The host has temporarily withdrawn the scroller, such as window minimization. */
isViewportSuspended?: () => boolean;
/**
@@ -206,6 +212,8 @@ export interface FlowChatVirtualizer {
* the items, before the library's own measurement has caught up.
*/
measureRenderedItems: () => void;
+ /** Publish an immediate DOM offset readback without moving the viewport. */
+ syncViewportOffset: (actualOffsetPx: number) => void;
/**
* The items intersecting the viewport right now, read from live geometry.
*
@@ -325,6 +333,8 @@ export function useFlowChatVirtualizer({
estimateItemHeightPx,
estimateContext,
estimateContextRevision,
+ startAtTailOnMount = false,
+ reconcileOpeningMeasurement,
isViewportSuspended = () => false,
scrollPaddingStartPx,
writeViewport,
@@ -332,6 +342,16 @@ export function useFlowChatVirtualizer({
}: UseFlowChatVirtualizerOptions): FlowChatVirtualizer {
const itemsRef = useRef(items);
itemsRef.current = items;
+ const initialTailRef = useRef(startAtTailOnMount);
+ const hasInitialItemsRef = useRef(items.length > 0);
+ if (items.length > 0) hasInitialItemsRef.current = true;
+ const reconcileOpeningMeasurementRef = useRef(reconcileOpeningMeasurement);
+ reconcileOpeningMeasurementRef.current = reconcileOpeningMeasurement;
+ const pendingMeasurementRef = useRef(false);
+ const publishMeasuredOffsetRef = useRef<((actualOffsetPx?: number) => void) | null>(null);
+ const syncViewportOffset = useCallback((actualOffsetPx: number) => {
+ if (Number.isFinite(actualOffsetPx)) publishMeasuredOffsetRef.current?.(actualOffsetPx);
+ }, []);
const writeViewportRef = useRef(writeViewport);
writeViewportRef.current = writeViewport;
/**
@@ -405,9 +425,56 @@ export function useFlowChatVirtualizer({
}, [estimateContextRevision]);
const virtualizer = useVirtualizer({
+ // 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
+ // avoids that head window; real measurement and follow still own settling.
+ // Same-session retest: rowRef total 377.3 -> 4.2ms; reveal probe completed
+ // at 1540.3 -> 806.7ms. These are single-trace timings, not paint guarantees.
+ // Keep an empty hydration from consuming the one-time initial offset.
+ enabled: !initialTailRef.current || hasInitialItemsRef.current,
+ initialOffset: () => initialTailRef.current
+ ? itemsRef.current.slice(0, -1).reduce((offset, item) => (
+ offset + estimateItemHeightRef.current(item, estimateContextRef.current)
+ ), 0)
+ : 0,
count: items.length,
getScrollElement: () => scrollerRef.current,
observeElementRect: observeFlowChatViewportRect,
+ observeElementOffset: (instance, callback) => {
+ let synchronized = false;
+ const publish = (actualOffsetPx?: number) => {
+ const scroller = instance.scrollElement;
+ if (!scroller || scroller !== scrollerRef.current || isViewportSuspendedRef.current()) return;
+ const actualOffset = actualOffsetPx ?? scroller.scrollTop;
+ synchronized = true;
+ // false avoids a nested flushSync while React is attaching measured rows.
+ if (instance.scrollOffset !== actualOffset) callback(actualOffset, false);
+ };
+ publishMeasuredOffsetRef.current = publish;
+ const cleanup = observeTanStackElementOffset(instance, (offset, isScrolling) => {
+ // TanStack's debounced scroll-end callback captures the last native-event
+ // offset. It must not undo a newer synchronous measurement reconciliation.
+ if (synchronized && !isScrolling && instance.scrollElement) offset = instance.scrollElement.scrollTop;
+ if (isScrolling) synchronized = false;
+ callback(offset, isScrolling);
+ });
+ return () => {
+ cleanup?.();
+ if (publishMeasuredOffsetRef.current === publish) publishMeasuredOffsetRef.current = null;
+ };
+ },
+ onChange: (_instance, sync) => {
+ if (sync || !pendingMeasurementRef.current) return;
+ pendingMeasurementRef.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?.();
+ },
estimateSize,
getItemKey: resolveItemKey,
// Items carry their own index attribute already; measuring reads it back.
@@ -434,6 +501,7 @@ export function useFlowChatVirtualizer({
// An instance field rather than an option, so it is assigned here — before
// any measurement callback can reach `resizeItem`.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta) => {
+ pendingMeasurementRef.current = true;
const scroller = scrollerRef.current;
if (!scroller) return false;
if (isViewportSuspendedRef.current()) return false;
@@ -642,6 +710,7 @@ export function useFlowChatVirtualizer({
measureRowElement,
getItemBounds,
measureRenderedItems,
+ syncViewportOffset,
getVisibleItemRange,
scrollItemIntoView,
scrollToOffset,
diff --git a/src/web-ui/src/infrastructure/markdown/Markdown.scss b/src/web-ui/src/infrastructure/markdown/Markdown.scss
index a963f5eab2..f8bf37a449 100644
--- a/src/web-ui/src/infrastructure/markdown/Markdown.scss
+++ b/src/web-ui/src/infrastructure/markdown/Markdown.scss
@@ -716,8 +716,11 @@
// Paint only the arriving glyph ranges. currentColor preserves links, syntax
// colors and the active theme; no spans or layout-affecting row animations.
+// Global highlight rules cause pseudo-style work even with no registered ranges.
+// Scope to actual arriving text parents; the hook retains this marker only until
+// their final arrival settles, including the last 160 ms after streaming ends.
@for $level from 0 through 31 {
- ::highlight(openbitfun-stream-reveal-#{$level}) {
+ [data-stream-reveal-active]::highlight(openbitfun-stream-reveal-#{$level}) {
color: color-mix(in srgb, currentColor #{20% + $level * 2.5%}, transparent);
}
}
diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx
index 8a549fcf0b..82f5128203 100644
--- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx
+++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx
@@ -45,8 +45,12 @@ describe('streaming text arrival paint', () => {
expect(view.container.querySelector('p')!.firstChild).toBe(textNode);
expect(view.container.querySelector('span')).toBeNull();
expect(visibleRanges().join('')).not.toContain('Copy');
+ expect(view.container.querySelectorAll('[data-stream-reveal-active]')).toHaveLength(1);
+ expect(view.container.querySelector('p')!.hasAttribute('data-stream-reveal-active')).toBe(true);
act(() => vi.advanceTimersByTime(STREAMING_TEXT_REVEAL_MS + 20));
expect(visibleRanges()).toEqual([]);
+ expect(highlights.size).toBe(0);
+ expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull();
});
it('does not restart earlier arrivals when another batch arrives or the stream completes', () => {
const view = render( );
@@ -55,6 +59,7 @@ describe('streaming text arrival paint', () => {
view.rerender( );
expect(visibleRanges().sort()).toEqual(['B', 'C']);
view.rerender( );
+ expect(view.container.querySelector('p')!.hasAttribute('data-stream-reveal-active')).toBe(true);
act(() => vi.advanceTimersByTime(80));
expect(visibleRanges()).toEqual(['C']);
act(() => vi.advanceTimersByTime(100));
@@ -78,8 +83,10 @@ describe('streaming text arrival paint', () => {
expect(visibleRanges()).toEqual(['B', 'Y']);
first.unmount();
expect(visibleRanges()).toEqual(['Y']);
+ expect(second.container.querySelector('p')!.hasAttribute('data-stream-reveal-active')).toBe(true);
second.unmount();
expect(visibleRanges()).toEqual([]);
+ expect(highlights.size).toBe(0);
});
it('honors reduced motion and works without the highlight API', () => {
vi.stubGlobal('matchMedia', () => ({ matches: true }));
@@ -90,4 +97,91 @@ describe('streaming text arrival paint', () => {
view.rerender( );
expect(view.container.textContent).toBe('ABCCopy');
});
+
+ it('retains the parent marker across frames and new arrivals, removing it only on settlement', () => {
+ const view = render( );
+ const parent = view.container.querySelector('p')!;
+ const add = vi.spyOn(parent, 'setAttribute');
+ const remove = vi.spyOn(parent, 'removeAttribute');
+ view.rerender( );
+ act(() => vi.advanceTimersByTime(80));
+ view.rerender( );
+ act(() => vi.advanceTimersByTime(96));
+ expect(add.mock.calls.filter(([name]) => name === 'data-stream-reveal-active')).toHaveLength(1);
+ expect(remove.mock.calls.filter(([name]) => name === 'data-stream-reveal-active')).toHaveLength(0);
+ act(() => vi.advanceTimersByTime(100));
+ expect(remove.mock.calls.filter(([name]) => name === 'data-stream-reveal-active')).toHaveLength(1);
+ expect(highlights.size).toBe(0);
+ });
+
+ it('scopes rich text to its actual parents and cleans detached parents on replacement', () => {
+ function Rich({ suffix, replaced = false }: { suffix: string; replaced?: boolean }) {
+ const ref = useRef(null);
+ useStreamingTextReveal(ref, replaced ? 'Replacement' : `History${suffix}`, true);
+ return {replaced ?
Replacement
: <>
+
History
{suffix}
+ >}
;
+ }
+ const view = render( );
+ view.rerender( );
+ const link = view.container.querySelector('a')!;
+ expect([...view.container.querySelectorAll('[data-stream-reveal-active]')]).toEqual([link]);
+ expect(visibleRanges()).toEqual(['New']);
+ view.rerender( );
+ expect(link.hasAttribute('data-stream-reveal-active')).toBe(false);
+ expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull();
+ expect(highlights.size).toBe(0);
+ });
+
+ it('clears active paint on visibility changes without deleting unrelated highlights', () => {
+ const foreign = new Set();
+ highlights.set('search', foreign);
+ const view = render( );
+ view.rerender( );
+ const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
+ act(() => document.dispatchEvent(new Event('visibilitychange')));
+ expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull();
+ expect([...highlights.keys()]).toEqual(['search']);
+ hidden.mockRestore();
+ act(() => vi.advanceTimersByTime(200));
+ expect([...highlights.keys()]).toEqual(['search']);
+ });
+
+ it('clears active paint when reduced motion becomes enabled', () => {
+ const media = new EventTarget() as EventTarget & { matches: boolean };
+ media.matches = false;
+ vi.stubGlobal('matchMedia', () => media);
+ const view = render( );
+ view.rerender( );
+ media.matches = true;
+ act(() => media.dispatchEvent(new Event('change')));
+ expect(highlights.size).toBe(0);
+ expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull();
+ });
+
+ it('keeps a shared text-parent marker until its last renderer owner releases it', () => {
+ const element = document.createElement('div');
+ document.body.append(element);
+ const ref = { current: element };
+ function Owner({ source }: { source: string }) {
+ useStreamingTextReveal(ref, source, true);
+ return null;
+ }
+ element.textContent = 'A';
+ const view = render(<> >);
+ try {
+ element.textContent = 'AB';
+ view.rerender(<> >);
+ expect(visibleRanges()).toEqual(['B', 'B']);
+ view.rerender(<> >);
+ expect(visibleRanges()).toEqual(['B']);
+ expect(element.hasAttribute('data-stream-reveal-active')).toBe(true);
+ view.unmount();
+ expect(element.hasAttribute('data-stream-reveal-active')).toBe(false);
+ expect(highlights.size).toBe(0);
+ } finally {
+ view.unmount();
+ element.remove();
+ }
+ });
});
diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts
index d92bde9ea5..e241da7f5a 100644
--- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts
+++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts
@@ -3,6 +3,9 @@ import { useLayoutEffect, useRef, type RefObject } from 'react';
export const STREAMING_TEXT_REVEAL_MS = 160;
const LEVELS = 32;
const NAME = 'openbitfun-stream-reveal-';
+const ACTIVE_ATTRIBUTE = 'data-stream-reveal-active';
+// Several renderer owners may share a text parent; only the last releases it.
+const activeElementOwners = new WeakMap();
type TextHighlight = Set;
type HighlightAPI = {
CSS?: { highlights?: Map };
@@ -10,6 +13,18 @@ type HighlightAPI = {
};
interface Arrival { start: number; end: number; at: number }
interface TextRun { node: Text; start: number; end: number }
+interface OwnedRange {
+ registry: Map;
+ name: string;
+ highlight: TextHighlight;
+ range: Range;
+}
+
+function pruneEmptyBuckets(entries: OwnedRange[]): void {
+ for (const { registry, name, highlight } of entries) {
+ if (highlight.size === 0 && registry.get(name) === highlight) registry.delete(name);
+ }
+}
function readRuns(root: HTMLElement): { runs: TextRun[]; text: string } {
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
@@ -37,19 +52,51 @@ export function useStreamingTextReveal(
): void {
const previous = useRef<{ source: string; text: string } | null>(null);
const arrivals = useRef([]);
- const owned = useRef<{ highlight: TextHighlight; range: Range }[]>([]);
+ const owned = useRef([]);
+ const activeElements = useRef(new Set());
const frame = useRef(null);
- const stop = () => {
- if (frame.current !== null) cancelAnimationFrame(frame.current);
+ const frameView = useRef(null);
+ const cancelFrame = () => {
+ if (frame.current !== null) frameView.current?.cancelAnimationFrame(frame.current);
frame.current = null;
- for (const { highlight, range } of owned.current) highlight.delete(range);
+ };
+ const releaseRanges = () => {
+ const released = owned.current;
+ for (const { highlight, range } of released) highlight.delete(range);
owned.current = [];
+ return released;
};
+ const updateActiveElements = (next: Set) => {
+ for (const element of activeElements.current) {
+ if (next.has(element)) continue;
+ const owners = (activeElementOwners.get(element) ?? 1) - 1;
+ if (owners > 0) activeElementOwners.set(element, owners);
+ else {
+ activeElementOwners.delete(element);
+ element.removeAttribute(ACTIVE_ATTRIBUTE);
+ }
+ }
+ for (const element of next) {
+ if (activeElements.current.has(element)) continue;
+ const owners = activeElementOwners.get(element) ?? 0;
+ activeElementOwners.set(element, owners + 1);
+ if (owners === 0) element.setAttribute(ACTIVE_ATTRIBUTE, '');
+ }
+ activeElements.current = next;
+ };
+ const stop = () => {
+ cancelFrame();
+ pruneEmptyBuckets(releaseRanges());
+ updateActiveElements(new Set());
+ };
+ const stopRef = useRef(stop);
+ stopRef.current = stop;
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const view = root.ownerDocument.defaultView;
+ frameView.current = view;
const api = view as (Window & HighlightAPI) | null;
const registry = api?.CSS?.highlights;
const Highlight = api?.Highlight;
@@ -59,19 +106,19 @@ export function useStreamingTextReveal(
const current = readRuns(root);
previous.current = { source, text: current.text };
const reduced = view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
- if (!registry || !Highlight || reduced || root.ownerDocument.hidden) {
- stop();
+ if (!view || !registry || !Highlight || reduced || root.ownerDocument.hidden) {
+ stopRef.current();
arrivals.current = [];
return;
}
const appended = before && source.startsWith(before.source) && source.length > before.source.length;
- if (before && !source.startsWith(before.source)) arrivals.current = [];
+ if (before && (!source.startsWith(before.source) || !current.text.startsWith(before.text))) arrivals.current = [];
// Reinterpreting Markdown may replace earlier nodes. Never replay those
// letters; only a genuinely appended visible suffix gets a new arrival.
if (streaming && appended && current.text.startsWith(before.text)) {
- arrivals.current.push({ start: before.text.length, end: current.text.length, at: performance.now() });
+ arrivals.current.push({ start: before.text.length, end: current.text.length, at: view.performance.now() });
}
- stop();
+ cancelFrame();
// Resolve ranges once per content commit. Animation frames only move those
// ranges between paint buckets; they do not walk a long transcript again.
const resolved = arrivals.current.map(arrival => {
@@ -97,7 +144,14 @@ export function useStreamingTextReveal(
return { arrival, ranges };
});
const paint = (now: number) => {
- stop();
+ cancelFrame();
+ if (!root.isConnected || root.ownerDocument.hidden) {
+ stopRef.current();
+ arrivals.current = [];
+ return;
+ }
+ const released = releaseRanges();
+ const nextElements = new Set();
arrivals.current = arrivals.current.filter(arrival => now - arrival.at < STREAMING_TEXT_REVEAL_MS);
for (const { arrival, ranges } of resolved) {
if (now - arrival.at >= STREAMING_TEXT_REVEAL_MS) continue;
@@ -110,27 +164,40 @@ export function useStreamingTextReveal(
registry.set(name, highlight);
}
for (const range of ranges) {
+ const parent = range.startContainer.parentElement;
+ if (!parent || !root.contains(parent)) continue;
highlight.add(range);
- owned.current.push({ highlight, range });
+ owned.current.push({ registry, name, highlight, range });
+ nextElements.add(parent);
}
+ if (highlight.size === 0 && registry.get(name) === highlight) registry.delete(name);
}
- if (arrivals.current.length) frame.current = requestAnimationFrame(paint);
+ // Keep scope stable across frames/batches. Only actual arriving text
+ // parents get the 32 pseudo styles, never the whole transcript. Global
+ // Local desktop trace: this scope reduced two opening style updates from
+ // 128.3/223.3 ms to 45.4/61.4 ms, and pre-reveal style work from 451.5 ms
+ // to 172.4 ms. Overall opening latency still includes other work.
+ updateActiveElements(nextElements);
+ // Prune after adding the new frame, preserving shared buckets still in use.
+ pruneEmptyBuckets(released);
+ if (arrivals.current.length) frame.current = view.requestAnimationFrame(paint);
};
// Layout timing styles newly committed glyphs before their first paint.
- paint(performance.now());
+ paint(view.performance.now());
}, [source, streaming, rootRef]);
useLayoutEffect(() => {
- const media = window.matchMedia?.('(prefers-reduced-motion: reduce)');
- const clear = () => { stop(); arrivals.current = []; };
+ const document = rootRef.current?.ownerDocument;
+ const media = document?.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)');
+ const clear = () => { stopRef.current(); arrivals.current = []; };
const onPreference = () => { if (media?.matches) clear(); };
- const onVisibility = () => { if (document.hidden) clear(); };
+ const onVisibility = () => { if (document?.hidden) clear(); };
media?.addEventListener?.('change', onPreference);
- document.addEventListener('visibilitychange', onVisibility);
+ document?.addEventListener('visibilitychange', onVisibility);
return () => {
clear();
media?.removeEventListener?.('change', onPreference);
- document.removeEventListener('visibilitychange', onVisibility);
+ document?.removeEventListener('visibilitychange', onVisibility);
};
- }, []);
+ }, [rootRef]);
}