From 1dfa3aee2fd112b540b51532e41e85f68592f22c Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Sat, 22 Aug 2026 03:14:15 +0800 Subject: [PATCH] perf(ui): stage history overscan restoration --- .../MessageList.activationOverscan.test.tsx | 102 ++++++++++++++++++ .../ui/src/components/chat/MessageList.tsx | 4 +- .../components/chat/useActivationOverscan.ts | 34 ++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx create mode 100644 packages/ui/src/components/chat/useActivationOverscan.ts diff --git a/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx b/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx new file mode 100644 index 00000000..16b6d446 --- /dev/null +++ b/packages/ui/src/components/chat/MessageList.activationOverscan.test.tsx @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { useActivationOverscan } from './useActivationOverscan'; + +type Frame = FrameRequestCallback; + +describe('MessageList activation overscan', () => { + let windowInstance: Window; + let host: HTMLDivElement; + let root: Root; + let pendingFrames: Map; + let nextFrameId: number; + let renderCount: number; + + beforeEach(() => { + windowInstance = new Window(); + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + HTMLElement: windowInstance.HTMLElement, + Element: windowInstance.Element, + Node: windowInstance.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + pendingFrames = new Map(); + nextFrameId = 1; + renderCount = 0; + Object.defineProperty(windowInstance, 'requestAnimationFrame', { + configurable: true, + value: (callback: Frame) => { + const frameId = nextFrameId; + nextFrameId += 1; + pendingFrames.set(frameId, callback); + return frameId; + }, + }); + Object.defineProperty(windowInstance, 'cancelAnimationFrame', { + configurable: true, + value: (id: number) => { + pendingFrames.delete(id); + }, + }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + windowInstance.close(); + }); + + const Harness = ({ normalOverscan }: { normalOverscan: number }) => { + renderCount += 1; + const overscan = useActivationOverscan(true, normalOverscan); + return
; + }; + + const runNextFrame = async (timestamp: number): Promise => { + const nextFrame = pendingFrames.entries().next(); + if (nextFrame.done) throw new Error('No animation frame is pending'); + + const [frameId, callback] = nextFrame.value; + pendingFrames.delete(frameId); + await act(async () => callback(timestamp)); + }; + + test('restores normal overscan in at most two renders after the first paint opportunity', async () => { + await act(async () => root.render()); + expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0'); + expect(renderCount).toBe(1); + + await runNextFrame(0); + expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0'); + expect(renderCount).toBe(1); + + await runNextFrame(16); + expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('4'); + expect(renderCount).toBe(2); + + await runNextFrame(32); + expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('8'); + expect(renderCount).toBe(3); + }); + + test('cancels every pending restoration frame when the list unmounts', async () => { + for (const framesToRun of [0, 1, 2]) { + await act(async () => root.render()); + for (let frame = 0; frame < framesToRun; frame += 1) { + await runNextFrame(frame * 16); + } + + expect(pendingFrames.size).toBe(1); + await act(async () => root.render(null)); + expect(pendingFrames.size).toBe(0); + } + }); +}); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index d573c1a9..74b4b6db 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -28,6 +28,7 @@ import { getShellBridgeAssistantDetails, type ShellBridgeDetails, } from './lib/shellBridge'; +import { useActivationOverscan } from './useActivationOverscan'; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5; const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = []; @@ -952,6 +953,7 @@ type StaticHistoryListProps = { const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => { const isTanstack = engine === 'tanstack'; + const overscan = useActivationOverscan(isTanstack, resolveTanstackOverscan()); // --- Quiet-window prepend (mobile) -------------------------------------- // Gesture tracking for the deferred-prepend decision. Refs only: reading @@ -1051,7 +1053,7 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, enabled: isTanstack, getScrollElement: () => scrollRef?.current ?? null, estimateSize: () => estimatedEntrySizeRef.current, - overscan: resolveTanstackOverscan(), + overscan, scrollToFn: (offset, options, instance) => { // Expose the new total height before core writes an anchor // correction so the browser does not clamp the offset to the old diff --git a/packages/ui/src/components/chat/useActivationOverscan.ts b/packages/ui/src/components/chat/useActivationOverscan.ts new file mode 100644 index 00000000..866b0504 --- /dev/null +++ b/packages/ui/src/components/chat/useActivationOverscan.ts @@ -0,0 +1,34 @@ +import * as React from 'react'; + +export const useActivationOverscan = (enabled: boolean, normalOverscan: number): number => { + const [recoveryStep, setRecoveryStep] = React.useState(0); + + React.useEffect(() => { + if (!enabled) { + setRecoveryStep(0); + return; + } + + let halfOverscanFrame: number | undefined; + const paintOpportunityFrame = window.requestAnimationFrame(() => { + halfOverscanFrame = window.requestAnimationFrame(() => { + React.startTransition(() => setRecoveryStep(1)); + }); + }); + return () => { + window.cancelAnimationFrame(paintOpportunityFrame); + if (halfOverscanFrame !== undefined) window.cancelAnimationFrame(halfOverscanFrame); + }; + }, [enabled]); + + React.useEffect(() => { + if (!enabled || recoveryStep !== 1) return; + const normalOverscanFrame = window.requestAnimationFrame(() => { + React.startTransition(() => setRecoveryStep(2)); + }); + return () => window.cancelAnimationFrame(normalOverscanFrame); + }, [enabled, recoveryStep]); + + if (!enabled || recoveryStep >= 2) return normalOverscan; + return recoveryStep === 0 ? 0 : Math.ceil(normalOverscan / 2); +};