From 4d424664cecda4d7bdd11f5f98d1d739a400124a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Apr 2026 00:46:22 +0000 Subject: [PATCH] feat: Batch and preload log row data with throttled requests. Replaces per-row useLoadEntry with a single batched loader hook attached to the LogView Body. The loader watches Virtuoso's itemsRendered callback to track the visible range and: - Triggers a load when the visible range comes within 50 rows of the cached range's edges (or has no cache yet). - Eagerly fetches PRELOAD_DISTANCE (200) rows on each side of the visible range in a single getEntries() call. - Discards in-memory data outside that window after each load. - Throttles to one in-flight request at a time. If the visible range shifts during a load, a follow-up request fires as soon as the in-flight one settles, so the cache eventually catches up after fast scrolling. Later queries clobber earlier ones. - Avoids race conditions: the loader is held in component state and is swapped (and the old one disposed) synchronously when logSearcher or entryNumbers change. The cache is cleared in the same render, so data from a previous filter set is never displayed even briefly. Removes Virtuoso's overscan since this preload window replaces it. Co-authored-by: Max Marrone --- src/components/LogView.tsx | 306 ++++++++++++++++++++++++++++++++----- 1 file changed, 269 insertions(+), 37 deletions(-) diff --git a/src/components/LogView.tsx b/src/components/LogView.tsx index 8304c35..f97b6c3 100644 --- a/src/components/LogView.tsx +++ b/src/components/LogView.tsx @@ -9,6 +9,7 @@ import { import clsx from "clsx"; import { useCallback, + useEffect, useImperativeHandle, useRef, useState, @@ -30,7 +31,16 @@ const CONSUME_GRID_TEMPLATE_COLUMNS: CSSProperties = { gridTemplateColumns: `var(${GRID_TEMPLATE_COLUMNS_VAR})`, }; -const VIRTUOSO_OVERSCAN = 1000; +/** + * How many rows beyond the currently-visible range we eagerly load and keep cached. + */ +const PRELOAD_DISTANCE = 200; + +/** + * When the visible range comes within this many rows of either edge of the + * currently-cached range, trigger a new load to extend the cache. + */ +const TRIGGER_DISTANCE = 50; const DEFAULT_COLUMN_WIDTH = 100; const MIN_COLUMN_WIDTH = 20; @@ -211,14 +221,18 @@ function Body(props: BodyProps): JSX.Element { [entryNumbers, selectedEntryNumber], ); + const { getEntry, setVisibleVirtualizedRange } = useBatchedEntryLoader( + logSearcher, + entryNumbers, + ); + const renderItemContent = useCallback( (virtualizedIndex: number) => { const entryNumber = entryNumbers[virtualizedIndex]; return ( []) => { + itemsRenderedRef.current = items; + if (items.length === 0) { + setVisibleVirtualizedRange(null); + } else { + setVisibleVirtualizedRange({ + firstVirtualizedIndex: items[0].index, + lastVirtualizedIndex: items[items.length - 1].index, + }); + } + }, + [setVisibleVirtualizedRange], + ); + function handleKeyDown(event: React.KeyboardEvent) { if ( !isNavigationKey(event.key) || @@ -273,10 +302,9 @@ function Body(props: BodyProps): JSX.Element { virtuosoScrollerRef.current = scroller; }} totalCount={entryNumbers.length} - overscan={{ main: VIRTUOSO_OVERSCAN, reverse: VIRTUOSO_OVERSCAN }} computeItemKey={(virtualizedIndex) => entryNumbers[virtualizedIndex]} itemContent={renderItemContent} - itemsRendered={(items) => (itemsRenderedRef.current = items)} + itemsRendered={handleItemsRendered} /* Virtuoso adds a tab-stop by default ({0}). Disable that, since we add our own. */ tabIndex={-1} /> @@ -295,8 +323,7 @@ interface LoadedEntryProps { interface EntryRowProps { rowIndex: number; - entryNumber: number; - logSearcher: LogSearcher; + entry: LogEntry | null; columns: LogViewColumn[]; query: string | null; isSelected: boolean; @@ -304,16 +331,7 @@ interface EntryRowProps { } function EntryRow(props: EntryRowProps): JSX.Element { - const { - rowIndex, - entryNumber, - logSearcher, - columns, - query, - isSelected, - onClick, - } = props; - const entry = useLoadEntry(logSearcher, entryNumber); + const { rowIndex, entry, columns, query, isSelected, onClick } = props; if (entry === null) { return ; } @@ -383,29 +401,243 @@ function UnloadedEntry(props: UnloadedEntryProps): JSX.Element { ); } -function useLoadEntry( +interface VirtualizedRange { + firstVirtualizedIndex: number; + lastVirtualizedIndex: number; +} + +interface UseBatchedEntryLoaderResult { + /** Returns the loaded `LogEntry` for the given virtualized index, or `null` if not yet loaded. */ + getEntry: (virtualizedIndex: number) => LogEntry | null; + /** + * Should be called whenever the set of currently-rendered rows changes. + * Drives both eager preloading and cache trimming. + */ + setVisibleVirtualizedRange: (range: VirtualizedRange | null) => void; +} + +/** + * Loads `LogEntry`s in batches around the currently-visible row range. + * + * Batching: when the visible range comes within {@link TRIGGER_DISTANCE} rows + * of the cached range's edge, we issue a single batched request that covers + * the visible range plus {@link PRELOAD_DISTANCE} rows on each side. Anything + * outside that window is discarded from the cache. + * + * Throttling: at most one request is in flight at a time. If the visible + * range moves while a request is in flight, we remember the latest desired + * range and issue a follow-up request as soon as the in-flight one settles. + * This guarantees the cache eventually catches up to the current position + * even after fast scrolling. Later queries clobber earlier ones (when an + * in-flight load returns and its data is no longer near the visible range, + * we keep none of it and immediately dispatch a fresh load). + * + * Race-safety: when the inputs (`logSearcher` or `entryNumbers`) change, the + * cache is cleared synchronously during render so that stale data from a + * previous filter set is never displayed. In-flight results from before the + * change are discarded by comparing against the latest tracked inputs. + */ +function useBatchedEntryLoader( logSearcher: LogSearcher, - entryNumber: number, -): LogEntry | null { - const [loadedEntry, setLoadedEntry] = React.useState(null); - - React.useEffect(() => { - let ignore = false; - // I know of no better way to do this. - // eslint-disable-next-line react-hooks/set-state-in-effect - setLoadedEntry(null); - const load = async () => { - const entries = await logSearcher.getEntries([entryNumber]); - if (!ignore) setLoadedEntry(entries[0]); - }; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - load(); + entryNumbers: number[], +): UseBatchedEntryLoaderResult { + const [cache, setCache] = useState>(() => new Map()); + + // The loader is held in React state (rather than a ref) so we can detect + // input changes during render and synchronously swap it out. This is + // critical for race-safety: it ensures that an in-flight load from the + // previous inputs cannot commit stale data into `cache` after the inputs + // change. We dispose the old loader inline (just toggling a boolean flag, + // no side effects) so that any pending async resolution becomes a no-op. + const [loader, setLoader] = useState( + () => new BatchedEntryLoader(logSearcher, entryNumbers, setCache), + ); + if ( + loader.logSearcher !== logSearcher || + loader.entryNumbers !== entryNumbers + ) { + loader.dispose(); + setLoader(new BatchedEntryLoader(logSearcher, entryNumbers, setCache)); + setCache(new Map()); + } + + // Dispose the loader on unmount so any pending async won't try to setState + // after the component is gone. (Disposal on input change is handled above.) + useEffect(() => { return () => { - ignore = true; + loader.dispose(); }; - }, [logSearcher, entryNumber]); + }, [loader]); + + const setVisibleVirtualizedRange = useCallback( + (range: VirtualizedRange | null) => { + loader.setVisibleRange(range); + }, + [loader], + ); + + const getEntry = useCallback( + (virtualizedIndex: number) => cache.get(virtualizedIndex) ?? null, + [cache], + ); + + return { getEntry, setVisibleVirtualizedRange }; +} - return loadedEntry; +/** + * Imperative state machine for batched, throttled, race-safe entry loading. + * Owned by {@link useBatchedEntryLoader}; instantiated fresh whenever the + * `LogSearcher` or `entryNumbers` change so that earlier loads are + * effectively canceled. + */ +class BatchedEntryLoader { + readonly logSearcher: LogSearcher; + readonly entryNumbers: number[]; + private readonly setCache: (cache: Map) => void; + private visibleRange: VirtualizedRange | null = null; + private cachedRange: VirtualizedRange | null = null; + private inFlight = false; + private disposed = false; + + constructor( + logSearcher: LogSearcher, + entryNumbers: number[], + setCache: (cache: Map) => void, + ) { + this.logSearcher = logSearcher; + this.entryNumbers = entryNumbers; + this.setCache = setCache; + } + + dispose(): void { + this.disposed = true; + } + + setVisibleRange(range: VirtualizedRange | null): void { + this.visibleRange = range; + this.maybeStartLoad(); + } + + private maybeStartLoad(): void { + if (this.disposed) return; + if (this.inFlight) return; + const visible = this.visibleRange; + if (visible === null) return; + if (this.entryNumbers.length === 0) return; + + const cached = this.cachedRange; + const needsLoad = + cached === null || + visible.firstVirtualizedIndex - cached.firstVirtualizedIndex < + TRIGGER_DISTANCE || + cached.lastVirtualizedIndex - visible.lastVirtualizedIndex < + TRIGGER_DISTANCE || + // The visible range escaped the cache entirely (e.g. fast scroll). + visible.lastVirtualizedIndex < cached.firstVirtualizedIndex || + visible.firstVirtualizedIndex > cached.lastVirtualizedIndex; + if (!needsLoad) return; + + const desired = clampRange( + { + firstVirtualizedIndex: visible.firstVirtualizedIndex - PRELOAD_DISTANCE, + lastVirtualizedIndex: visible.lastVirtualizedIndex + PRELOAD_DISTANCE, + }, + 0, + this.entryNumbers.length - 1, + ); + + const indicesToLoad: number[] = []; + const entryNumbersToLoad: number[] = []; + for ( + let i = desired.firstVirtualizedIndex; + i <= desired.lastVirtualizedIndex; + i++ + ) { + indicesToLoad.push(i); + entryNumbersToLoad.push(this.entryNumbers[i]); + } + + this.inFlight = true; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + let loadedEntries: LogEntry[] | null = null; + try { + loadedEntries = await this.logSearcher.getEntries(entryNumbersToLoad); + } finally { + this.inFlight = false; + } + + if (this.disposed || loadedEntries === null) { + return; + } + + // The visible range may have shifted while we were loading. Trim the + // accepted entries to {@link PRELOAD_DISTANCE} of the *current* visible + // range so we don't retain far-away stale data. If the visible range + // moved entirely outside `desired` (a fast scroll), we keep none of + // these entries; the recursive `maybeStartLoad()` below will then fire + // a fresh request for the new position. This is what implements + // "later queries clobber earlier ones". + const currentVisible = this.visibleRange; + const trimRange = + currentVisible === null + ? null + : clampRange( + { + firstVirtualizedIndex: + currentVisible.firstVirtualizedIndex - PRELOAD_DISTANCE, + lastVirtualizedIndex: + currentVisible.lastVirtualizedIndex + PRELOAD_DISTANCE, + }, + 0, + this.entryNumbers.length - 1, + ); + + const newCache = new Map(); + let firstAccepted = Infinity; + let lastAccepted = -Infinity; + if (trimRange !== null) { + for (let i = 0; i < indicesToLoad.length; i++) { + const virtualizedIndex = indicesToLoad[i]; + if ( + virtualizedIndex >= trimRange.firstVirtualizedIndex && + virtualizedIndex <= trimRange.lastVirtualizedIndex + ) { + newCache.set(virtualizedIndex, loadedEntries[i]); + if (virtualizedIndex < firstAccepted) + firstAccepted = virtualizedIndex; + if (virtualizedIndex > lastAccepted) + lastAccepted = virtualizedIndex; + } + } + } + + this.cachedRange = + newCache.size === 0 + ? null + : { + firstVirtualizedIndex: firstAccepted, + lastVirtualizedIndex: lastAccepted, + }; + this.setCache(newCache); + + // Always re-evaluate after a load settles. Even if we kept nothing, + // this gets the next request started so the cache catches up to the + // current scroll position. + this.maybeStartLoad(); + })(); + } +} + +function clampRange( + range: VirtualizedRange, + min: number, + max: number, +): VirtualizedRange { + return { + firstVirtualizedIndex: clamp(range.firstVirtualizedIndex, min, max), + lastVirtualizedIndex: clamp(range.lastVirtualizedIndex, min, max), + }; } function BodyCell(