perf(ui): stage history overscan restoration

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 1c3c26f2bf
commit 1dfa3aee2f
3 changed files with 139 additions and 1 deletions
@@ -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<number, Frame>;
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 <div data-overscan={overscan} />;
};
const runNextFrame = async (timestamp: number): Promise<void> => {
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(<Harness normalOverscan={8} />));
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(<Harness normalOverscan={16} />));
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);
}
});
});
@@ -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
@@ -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);
};