perf(ui): stage history overscan restoration
This commit is contained in:
@@ -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,
|
getShellBridgeAssistantDetails,
|
||||||
type ShellBridgeDetails,
|
type ShellBridgeDetails,
|
||||||
} from './lib/shellBridge';
|
} from './lib/shellBridge';
|
||||||
|
import { useActivationOverscan } from './useActivationOverscan';
|
||||||
|
|
||||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
|
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
|
||||||
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
|
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 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 isTanstack = engine === 'tanstack';
|
||||||
|
const overscan = useActivationOverscan(isTanstack, resolveTanstackOverscan());
|
||||||
|
|
||||||
// --- Quiet-window prepend (mobile) --------------------------------------
|
// --- Quiet-window prepend (mobile) --------------------------------------
|
||||||
// Gesture tracking for the deferred-prepend decision. Refs only: reading
|
// Gesture tracking for the deferred-prepend decision. Refs only: reading
|
||||||
@@ -1051,7 +1053,7 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef,
|
|||||||
enabled: isTanstack,
|
enabled: isTanstack,
|
||||||
getScrollElement: () => scrollRef?.current ?? null,
|
getScrollElement: () => scrollRef?.current ?? null,
|
||||||
estimateSize: () => estimatedEntrySizeRef.current,
|
estimateSize: () => estimatedEntrySizeRef.current,
|
||||||
overscan: resolveTanstackOverscan(),
|
overscan,
|
||||||
scrollToFn: (offset, options, instance) => {
|
scrollToFn: (offset, options, instance) => {
|
||||||
// Expose the new total height before core writes an anchor
|
// Expose the new total height before core writes an anchor
|
||||||
// correction so the browser does not clamp the offset to the old
|
// 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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user