Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 633b7b2a75 perf(history): keep the history side panel mounted while hidden
Notes and SFTP already park behind a hidden class. History returned null,
so each Notes to History switch remounted the virtual list. Keep that tree
mounted, subscribe to shell history only while it is visible, and remeasure
the list when it is shown again.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 06:15:20 +00:00
4 changed files with 582 additions and 38 deletions

No files matched your search

+3 -2
View File
@@ -270,8 +270,8 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
&& state.entries.length === 0;
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
if (!isVisible) return null;
// Stay mounted when the side panel parks this tree. Returning null here
// would remount the virtual list on every Notes <-> History switch.
return (
<div
className="h-full flex flex-col bg-background overflow-hidden"
@@ -394,6 +394,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
<VariableSizeVirtualList
ref={listRef}
items={listRows}
measureEnabled={isVisible}
getItemHeight={getRowHeight}
getItemKey={(row, index) =>
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
@@ -0,0 +1,446 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import React, { act, useLayoutEffect } from 'react';
import {
publishShellHistorySnapshot,
} from '../../application/state/shellHistoryStore.ts';
import {
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
sidePanelLiveStore,
} from '../../application/state/sidePanelLiveStore.ts';
import type { ShellHistoryEntry } from '../../domain/models.ts';
import { HistorySidePanel } from '../HistorySidePanel.tsx';
import {
createDomRenderer,
dispatchDomEvent,
installDomEnvironment,
} from '../test-support/renderReactDom.tsx';
import { SidePanelHistorySlot } from './terminalLayerSidePanelSlots.tsx';
const slotsSource = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
const historySource = readFileSync(new URL('../HistorySidePanel.tsx', import.meta.url), 'utf8');
const listSource = readFileSync(new URL('../ui/VariableSizeVirtualList.tsx', import.meta.url), 'utf8');
function historySlotSource(): string {
const start = slotsSource.indexOf('function SidePanelHistorySlotInner');
const end = slotsSource.indexOf('export const SidePanelHistorySlot');
assert.ok(start >= 0 && end > start);
return slotsSource.slice(start, end);
}
test('history keep-alive source stays mounted and gates the shell-history subscription', () => {
const slot = historySlotSource();
assert.doesNotMatch(slot, /if \(!isVisible\) return null/);
assert.match(slot, /sidePanelHiddenPanelClassName\(!isVisible\)/);
assert.match(slot, /isVisible \? subscribeShellHistory : subscribeShellHistoryNoop/);
assert.match(slot, /getShellHistorySnapshot,\s*getShellHistorySnapshot/);
assert.doesNotMatch(slot, /getEmptyShellHistorySnapshot/);
assert.equal(slot.match(/useRemoteHistoryState\(\)/g)?.length, 1);
assert.match(slot, /isVisible=\{isVisible\}/);
assert.match(slot, /retainedLiveRef/);
assert.match(slot, /live !== SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT/);
assert.doesNotMatch(historySource, /if \(!isVisible\) return null/);
assert.match(historySource, /measureEnabled=\{isVisible\}/);
assert.match(listSource, /remeasure/);
assert.match(listSource, /scrollToIndex\(index, 'auto'\)/);
assert.match(listSource, /if \(!measureEnabledRef\.current && next === 0\) return/);
});
const hostA = {
id: 'host-a',
label: 'a',
hostname: 'a.example',
username: 'root',
tags: [] as string[],
os: 'linux' as const,
protocol: 'ssh' as const,
};
const hostB = { ...hostA, id: 'host-b', label: 'b', hostname: 'b.example' };
function shellEntry(id: string, command: string): ShellHistoryEntry {
return {
id,
command,
hostId: hostA.id,
hostLabel: hostA.label,
sessionId: 'sess-1',
timestamp: 1,
};
}
function liveSnapshot(host: typeof hostA) {
return {
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
focusedHost: host,
focusedSessionId: 'tab-1',
historySessionId: 'sess-1',
};
}
let probeRenders = 0;
function HistoryProbe(props: {
focusedHost?: { id?: string } | null;
globalEntries?: Array<{ command?: string }>;
isVisible?: boolean;
}) {
probeRenders += 1;
return React.createElement('div', {
'data-section': 'history-probe',
'data-host': props.focusedHost?.id ?? '',
'data-count': String(props.globalEntries?.length ?? 0),
'data-visible': String(!!props.isVisible),
'data-command': (props.globalEntries ?? []).map((entry) => entry.command ?? '').join('|'),
});
}
const historySlotCtx = {
HistorySidePanel: HistoryProbe,
handleHistoryPaste() {},
handleHistoryDelete() {},
handleHistoryClear() {},
handleHistoryRun() {},
};
function resetHistoryStores() {
publishShellHistorySnapshot([]);
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
}
describe('history side panel keep-alive', { concurrency: false }, () => {
test('hidden history stays mounted and shell-history publishes do not render it', async (t) => {
resetHistoryStores();
probeRenders = 0;
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
t.after(async () => {
await renderer.unmount();
dom.cleanup();
resetHistoryStores();
});
sidePanelLiveStore.update(liveSnapshot(hostA));
publishShellHistorySnapshot([shellEntry('1', 'ls')]);
const renderSlot = (isVisible: boolean) => renderer.render(React.createElement(SidePanelHistorySlot, {
activeTabId: 'tab-1',
ctx: historySlotCtx,
isVisible,
}));
await renderSlot(true);
const probe = () => dom.document.querySelector('[data-section="history-probe"]') as HTMLElement | null;
const visibleProbe = probe();
assert.ok(visibleProbe);
visibleProbe.dataset.keepalive = 'yes';
assert.equal(visibleProbe.dataset.host, 'host-a');
assert.equal(visibleProbe.dataset.count, '1');
assert.equal(visibleProbe.dataset.visible, 'true');
assert.doesNotMatch(visibleProbe.parentElement?.className ?? '', /\bhidden\b/);
await act(async () => {
publishShellHistorySnapshot([shellEntry('1', 'ls'), shellEntry('2', 'pwd')]);
});
assert.equal(probe()?.dataset.count, '2');
const rendersAfterVisiblePublish = probeRenders;
await renderSlot(false);
const parked = probe();
assert.ok(parked);
assert.equal(parked.dataset.keepalive, 'yes');
assert.equal(parked.dataset.host, 'host-a');
assert.equal(parked.dataset.count, '2');
assert.equal(parked.dataset.command, 'ls|pwd');
assert.match(parked.parentElement?.className ?? '', /\bhidden\b/);
const rendersWhileParked = probeRenders;
await act(async () => {
publishShellHistorySnapshot([
shellEntry('1', 'ls'),
shellEntry('2', 'pwd'),
shellEntry('3', 'whoami'),
]);
sidePanelLiveStore.update(liveSnapshot(hostB));
});
assert.equal(probeRenders, rendersWhileParked);
assert.equal(probe()?.dataset.count, '2');
assert.equal(probe()?.dataset.command, 'ls|pwd');
assert.equal(probe()?.dataset.host, 'host-a');
assert.ok(probeRenders >= rendersAfterVisiblePublish);
await renderSlot(true);
assert.equal(probe()?.dataset.keepalive, 'yes');
assert.equal(probe()?.dataset.count, '3');
assert.equal(probe()?.dataset.command, 'ls|pwd|whoami');
assert.equal(probe()?.dataset.host, 'host-b');
assert.equal(probe()?.dataset.visible, 'true');
assert.doesNotMatch(probe()?.parentElement?.className ?? '', /\bhidden\b/);
});
test('showing history again remeasures the virtual list without remounting or refetching', async (t) => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const heightBox = { value: 300 };
const resizeCallbacks: ResizeObserverCallback[] = [];
const previousObserver = globalThis.ResizeObserver;
const previousClientHeight = Object.getOwnPropertyDescriptor(dom.window.HTMLElement.prototype, 'clientHeight');
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return heightBox.value;
},
});
class ResizeObserverStub implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback);
}
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub;
dom.window.ResizeObserver = ResizeObserverStub;
const elementProto = dom.window.HTMLElement.prototype;
elementProto.scrollIntoView ??= () => {};
let fetches = 0;
const onFetch = () => {
fetches += 1;
};
const remoteEntries = Array.from({ length: 40 }, (_, index) => ({
id: `e-${index}`,
command: `echo ${index}`,
source: 'bash' as const,
}));
const host = hostA;
const panelProps = (isVisible: boolean, fetchedAt: number | null) => React.createElement(HistorySidePanel, {
focusedHost: host,
focusedSessionId: 'sess-1',
state: {
entries: remoteEntries,
loading: false,
error: null,
fetchedAt,
},
globalEntries: [],
onFetch,
onPasteToTerminal: () => {},
onRunInTerminal: () => {},
isVisible,
});
const fireResize = () => {
for (const callback of [...resizeCallbacks]) {
callback([], {} as ResizeObserver);
}
};
const panelOf = () => dom.document.querySelector('[data-section="history-panel"]') as HTMLElement | null;
const rowsOf = () => panelOf()?.querySelectorAll('[role="button"]').length ?? 0;
const scrollerOf = () => panelOf()?.querySelector('.overflow-y-auto') as HTMLDivElement | null;
t.after(async () => {
await renderer.unmount();
globalThis.ResizeObserver = previousObserver;
if (previousClientHeight) {
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', previousClientHeight);
}
dom.cleanup();
});
await renderer.render(panelProps(true, 1));
await act(async () => {
await Promise.resolve();
});
const panel = panelOf();
assert.ok(panel);
panel.dataset.keepalive = 'yes';
const visibleRows = rowsOf();
assert.ok(visibleRows > 8, `visible window should paint rows, got ${visibleRows}`);
assert.equal(fetches, 0);
const scroller = scrollerOf();
assert.ok(scroller);
await act(async () => {
scroller.scrollTop = 600;
scroller.dispatchEvent(new dom.window.Event('scroll'));
});
const scrolledRows = rowsOf();
assert.ok(scrolledRows > 8);
assert.equal(scroller.scrollTop, 600);
heightBox.value = 0;
await renderer.render(panelProps(false, 1));
await act(async () => {
const parkedScroller = scrollerOf();
assert.ok(parkedScroller);
parkedScroller.scrollTop = 0;
parkedScroller.dispatchEvent(new dom.window.Event('scroll'));
fireResize();
});
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(rowsOf(), scrolledRows);
assert.equal(fetches, 0);
heightBox.value = 480;
await renderer.render(panelProps(true, 1));
await act(async () => {
await Promise.resolve();
fireResize();
});
const shown = panelOf();
assert.ok(shown);
assert.equal(shown.dataset.keepalive, 'yes');
const shownScroller = scrollerOf();
assert.ok(shownScroller);
assert.equal(shownScroller.scrollTop, 600);
assert.ok(rowsOf() > scrolledRows, `taller viewport should extend the window, got ${rowsOf()} then ${scrolledRows}`);
assert.equal(fetches, 0);
const input = shown.querySelector('input');
assert.ok(input);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')?.set;
assert.ok(setValue);
setValue.call(input, 'echo 12');
await dispatchDomEvent(input, new dom.window.Event('input', { bubbles: true }));
assert.equal(input.value, 'echo 12');
await renderer.render(panelProps(false, 1));
await renderer.render(panelProps(true, 1));
const restoredInput = panelOf()?.querySelector('input');
assert.equal(restoredInput?.value, 'echo 12');
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(fetches, 0);
});
test('hidden history does not fetch, and showing again does not refetch a cached host', async (t) => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let fetches = 0;
const onFetch = () => {
fetches += 1;
};
const panelProps = (isVisible: boolean, fetchedAt: number | null) => React.createElement(HistorySidePanel, {
focusedHost: hostA,
focusedSessionId: 'sess-1',
state: {
entries: [],
loading: false,
error: null,
fetchedAt,
},
globalEntries: [],
onFetch,
onPasteToTerminal: () => {},
onRunInTerminal: () => {},
isVisible,
});
t.after(async () => {
await renderer.unmount();
dom.cleanup();
});
await renderer.render(panelProps(false, null));
assert.equal(fetches, 0);
assert.ok(dom.document.querySelector('[data-section="history-panel"]'));
await renderer.render(panelProps(true, null));
assert.equal(fetches, 1);
await renderer.render(panelProps(false, 50));
await renderer.render(panelProps(true, 50));
assert.equal(fetches, 1);
});
test('remeasure waits until a parent layout effect gives the list a real height', async (t) => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const heightBox = { value: 0 };
const previousObserver = globalThis.ResizeObserver;
const previousClientHeight = Object.getOwnPropertyDescriptor(dom.window.HTMLElement.prototype, 'clientHeight');
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub;
dom.window.ResizeObserver = ResizeObserverStub;
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return heightBox.value;
},
});
// Child layout effects run before the parent. Height stays 0 until this
// parent effect, matching the portal move out of the hidden parking host.
function DeferredHeightHarness({ visible }: { visible: boolean }) {
useLayoutEffect(() => {
heightBox.value = visible ? 420 : 0;
}, [visible]);
return React.createElement(HistorySidePanel, {
focusedHost: hostA,
focusedSessionId: 'sess-1',
state: {
entries: Array.from({ length: 40 }, (_, index) => ({
id: `e-${index}`,
command: `echo ${index}`,
source: 'bash' as const,
})),
loading: false,
error: null,
fetchedAt: 1,
},
globalEntries: [],
onFetch: () => {},
onPasteToTerminal: () => {},
onRunInTerminal: () => {},
isVisible: visible,
});
}
const panelOf = () => dom.document.querySelector('[data-section="history-panel"]') as HTMLElement | null;
const rowsOf = () => panelOf()?.querySelectorAll('[role="button"]').length ?? 0;
const scrollerOf = () => panelOf()?.querySelector('.overflow-y-auto') as HTMLDivElement | null;
t.after(async () => {
await renderer.unmount();
globalThis.ResizeObserver = previousObserver;
if (previousClientHeight) {
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientHeight', previousClientHeight);
}
dom.cleanup();
});
await renderer.render(React.createElement(DeferredHeightHarness, { visible: true }));
await act(async () => {
await Promise.resolve();
});
assert.ok(rowsOf() > 8, `deferred remeasure should paint rows, got ${rowsOf()}`);
const panel = panelOf();
assert.ok(panel);
panel.dataset.keepalive = 'yes';
const scroller = scrollerOf();
assert.ok(scroller);
await act(async () => {
scroller.scrollTop = 600;
scroller.dispatchEvent(new dom.window.Event('scroll'));
});
const scrolledRows = rowsOf();
await renderer.render(React.createElement(DeferredHeightHarness, { visible: false }));
await act(async () => {
const parked = scrollerOf();
assert.ok(parked);
parked.scrollTop = 0;
parked.dispatchEvent(new dom.window.Event('scroll'));
});
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(rowsOf(), scrolledRows);
heightBox.value = 0;
await renderer.render(React.createElement(DeferredHeightHarness, { visible: true }));
await act(async () => {
await Promise.resolve();
});
assert.equal(panelOf()?.dataset.keepalive, 'yes');
assert.equal(scrollerOf()?.scrollTop, 600);
assert.ok(rowsOf() > 8);
});
});
@@ -47,9 +47,7 @@ const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform
const EMPTY_VAULT_NOTES: never[] = [];
const EMPTY_VAULT_HOSTS: never[] = [];
const EMPTY_VAULT_SNIPPETS: never[] = [];
const EMPTY_SHELL_HISTORY = Object.freeze([]) as readonly never[];
const subscribeShellHistoryNoop = () => () => {};
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
@@ -533,14 +531,26 @@ function SidePanelHistorySlotInner({
}) {
const live = useSidePanelLiveSnapshotForTab(activeTabId ?? '', isVisible);
// Own remote-history state here so fetch/loading does not re-render TerminalLayer.
// One instance for the life of the slot; HistorySidePanel fetches only when
// visible and the host cache is still empty.
const remoteHistory = useRemoteHistoryState();
// Gate store subscription while History is hidden so command appends do not
// re-render this retained mount (panel still mounts for fast reopen).
// Subscribe only while visible so command appends do not re-render the parked
// list. Keep reading the live snapshot (same idea as notes) so the hide
// render does not swap in an empty list and unmount rows.
const shellHistory = useSyncExternalStore(
isVisible ? subscribeShellHistory : subscribeShellHistoryNoop,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
getShellHistorySnapshot,
getShellHistorySnapshot,
);
// While hidden the live-store read is the inactive snapshot. Keep the last
// real host/session so search, scroll, and the remote-history cache lookup
// survive parking. Never retain the inactive sentinel itself: a visible
// panel must show `live` (possibly empty) rather than the previous tab.
const retainedLiveRef = useRef(live);
if (isVisible && live !== SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT) {
retainedLiveRef.current = live;
}
const panelLive = isVisible ? live : retainedLiveRef.current;
const {
HistorySidePanel,
@@ -550,21 +560,19 @@ function SidePanelHistorySlotInner({
handleHistoryRun,
} = ctx;
if (!isVisible) return null;
return (
<div className="absolute inset-0 z-10">
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<HistorySidePanel
focusedHost={live.focusedHost}
focusedSessionId={live.historySessionId}
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
focusedHost={panelLive.focusedHost}
focusedSessionId={panelLive.historySessionId}
state={remoteHistory.getState(panelLive.focusedHost?.id, panelLive.historySessionId)}
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
onFetch={remoteHistory.fetch}
onDeleteGlobalEntry={handleHistoryDelete}
onClearGlobalHistory={handleHistoryClear}
onPasteToTerminal={handleHistoryPaste}
onRunInTerminal={handleHistoryRun}
isVisible
isVisible={isVisible}
/>
</div>
);
+112 -23
View File
@@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -15,6 +16,8 @@ const DEFAULT_OVERSCAN = 6;
export type VariableSizeVirtualListHandle = {
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
/** Read clientHeight and restore parked scroll after a display:none ancestor. */
remeasure: () => void;
};
interface VariableSizeVirtualListProps<T> {
@@ -25,6 +28,24 @@ interface VariableSizeVirtualListProps<T> {
overscan?: number;
getItemKey: (item: T, index: number) => string;
renderItem: (item: T, index: number) => React.ReactNode;
/**
* When false, a 0 clientHeight does not collapse the window or accept a
* scroll reset. History parks under display:none; turning this back on
* remeasures and re-anchors the parked offset.
*/
measureEnabled?: boolean;
}
function findOffsetIndex(offsets: number[], scrollTop: number): number {
if (offsets.length === 0) return 0;
let lo = 0;
let hi = offsets.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2);
if ((offsets[mid] ?? 0) <= scrollTop) lo = mid;
else hi = mid - 1;
}
return lo;
}
function VariableSizeVirtualListInner<T>(
@@ -36,12 +57,20 @@ function VariableSizeVirtualListInner<T>(
overscan = DEFAULT_OVERSCAN,
getItemKey,
renderItem,
measureEnabled = true,
}: VariableSizeVirtualListProps<T>,
ref: React.ForwardedRef<VariableSizeVirtualListHandle>,
) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollTopRef = useRef(0);
const measureEnabledRef = useRef(measureEnabled);
const itemsRef = useRef(items);
const getItemHeightRef = useRef(getItemHeight);
measureEnabledRef.current = measureEnabled;
itemsRef.current = items;
getItemHeightRef.current = getItemHeight;
const layout = useMemo(() => {
const offsets: number[] = [];
@@ -52,6 +81,8 @@ function VariableSizeVirtualListInner<T>(
}
return { offsets, totalHeight: total };
}, [getItemHeight, items]);
const layoutRef = useRef(layout);
layoutRef.current = layout;
const effectiveScrollTop = clampScrollTop(
scrollTop,
@@ -66,6 +97,7 @@ function VariableSizeVirtualListInner<T>(
if (container.scrollTop !== effectiveScrollTop) {
container.scrollTop = effectiveScrollTop;
}
scrollTopRef.current = effectiveScrollTop;
if (scrollTop !== effectiveScrollTop) {
setScrollTop(effectiveScrollTop);
}
@@ -76,7 +108,11 @@ function VariableSizeVirtualListInner<T>(
if (!container) return;
const updateViewportHeight = () => {
setViewportHeight(container.clientHeight);
const next = container.clientHeight;
// display:none reports 0. Dropping the window here unmounts the parked
// rows; the next show would rebuild them until the user resizes.
if (!measureEnabledRef.current && next === 0) return;
setViewportHeight((current) => (current === next ? current : next));
};
updateViewportHeight();
@@ -85,32 +121,85 @@ function VariableSizeVirtualListInner<T>(
return () => observer.disconnect();
}, []);
const scrollToIndex = useCallback((index: number, align: 'auto' | 'center' = 'auto') => {
const container = containerRef.current;
const currentItems = itemsRef.current;
const offsets = layoutRef.current.offsets;
if (!container || index < 0 || index >= currentItems.length) return;
const itemTop = offsets[index] ?? 0;
const itemHeight = getItemHeightRef.current(currentItems[index] as T, index);
const itemBottom = itemTop + itemHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (align === 'center') {
container.scrollTop = Math.max(
0,
itemTop - (container.clientHeight - itemHeight) / 2,
);
} else if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
scrollTopRef.current = container.scrollTop;
setScrollTop(container.scrollTop);
}, []);
const remeasure = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const nextHeight = container.clientHeight;
if (!measureEnabledRef.current && nextHeight === 0) return;
if (nextHeight > 0) {
setViewportHeight((current) => (current === nextHeight ? current : nextHeight));
}
const currentItems = itemsRef.current;
const anchor = scrollTopRef.current;
if (nextHeight <= 0 || currentItems.length === 0) return;
const offsets = layoutRef.current.offsets;
const index = findOffsetIndex(offsets, anchor);
const itemTop = offsets[index] ?? 0;
const itemHeight = getItemHeightRef.current(currentItems[index] as T, index);
const itemBottom = itemTop + itemHeight;
const intersects = itemTop < anchor + nextHeight && itemBottom > anchor;
// A display:none ancestor often zeros scrollTop. Put the parked offset
// back before scrollToIndex, or "already visible" is computed against 0
// and the list jumps.
if (container.scrollTop !== anchor) {
container.scrollTop = anchor;
}
if (!intersects) {
scrollToIndex(index, 'auto');
return;
}
scrollTopRef.current = container.scrollTop;
setScrollTop((current) => (current === container.scrollTop ? current : container.scrollTop));
}, [scrollToIndex]);
useImperativeHandle(ref, () => ({
scrollToIndex: (index: number, align = 'auto') => {
const container = containerRef.current;
if (!container || index < 0 || index >= items.length) return;
scrollToIndex,
remeasure,
}), [remeasure, scrollToIndex]);
const itemTop = layout.offsets[index] ?? 0;
const itemHeight = getItemHeight(items[index], index);
const itemBottom = itemTop + itemHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (align === 'center') {
container.scrollTop = Math.max(
0,
itemTop - (container.clientHeight - itemHeight) / 2,
);
} else if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
setScrollTop(container.scrollTop);
},
}), [getItemHeight, items, layout.offsets]);
useLayoutEffect(() => {
if (!measureEnabled) return;
remeasure();
// Parent layout effects move the side-panel portal out of the hidden
// parking host after this effect. Remeasure once that move has landed.
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) remeasure();
});
return () => {
cancelled = true;
};
}, [measureEnabled, remeasure]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
if (!measureEnabledRef.current) return;
scrollTopRef.current = event.currentTarget.scrollTop;
setScrollTop(event.currentTarget.scrollTop);
}, []);