feat(frontend): cap the Files grid at ~4 viewports with edge windowing
The grid grew without bound as you scrolled (and the section cache then snapshotted the whole thing). It now keeps at most ~4 viewports of rows: once it grows past the cap on one end, loadMore/loadPrev trim the off-screen rows on the other end. The trimmed boundary cursor is dropped and the opposite has-more flag is raised, so scrolling back refills that side from an anchored window (?anchor=<file>), reusing the existing prepend scroll-compensation. This bounds both live memory and the cached snapshot regardless of how deep you scroll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -339,14 +339,32 @@
|
|||||||
// between) so there's no visible jump. Shares the `loading` guard with loadMore
|
// between) so there's no visible jump. Shares the `loading` guard with loadMore
|
||||||
// so the two never mutate files concurrently.
|
// so the two never mutate files concurrently.
|
||||||
async function loadPrev() {
|
async function loadPrev() {
|
||||||
if (loading || !hasPrev || !prevCursor) return;
|
if (loading || !hasPrev) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
let items: File[];
|
||||||
|
let newPrevCursor: string | null;
|
||||||
|
if (prevCursor) {
|
||||||
const params = baseListParams();
|
const params = baseListParams();
|
||||||
params.set('cursor', prevCursor);
|
params.set('cursor', prevCursor);
|
||||||
params.set('direction', 'backward');
|
params.set('direction', 'backward');
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
const items = res.items ?? [];
|
items = res.items ?? [];
|
||||||
|
newPrevCursor = res.prev_cursor ?? null;
|
||||||
|
} else {
|
||||||
|
// The head cursor was dropped when the window trimmed its top. Refetch
|
||||||
|
// the rows just before the current first file from an anchored window.
|
||||||
|
const firstId = files[0]?.id;
|
||||||
|
if (!firstId) {
|
||||||
|
hasPrev = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetchAnchorWindow(firstId);
|
||||||
|
const all = res.items ?? [];
|
||||||
|
const idx = all.findIndex((f) => f.id === firstId);
|
||||||
|
items = idx > 0 ? all.slice(0, idx) : [];
|
||||||
|
newPrevCursor = res.prev_cursor ?? null;
|
||||||
|
}
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
hasPrev = false;
|
hasPrev = false;
|
||||||
return;
|
return;
|
||||||
@@ -359,11 +377,13 @@
|
|||||||
const beforeHeight = scroller.scrollHeight;
|
const beforeHeight = scroller.scrollHeight;
|
||||||
|
|
||||||
files = [...items, ...files];
|
files = [...items, ...files];
|
||||||
prevCursor = res.prev_cursor ?? null;
|
prevCursor = newPrevCursor;
|
||||||
hasPrev = !!res.prev_cursor;
|
hasPrev = !!newPrevCursor;
|
||||||
|
|
||||||
flushSync(); // apply the prepend now, before the browser paints
|
flushSync(); // apply the prepend now, before the browser paints
|
||||||
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
||||||
|
|
||||||
|
trimTail();
|
||||||
} catch {
|
} catch {
|
||||||
hasPrev = false;
|
hasPrev = false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -385,17 +405,85 @@
|
|||||||
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Windowing -----------------------------------------------------------
|
||||||
|
// The grid keeps at most ~4 viewports of rows in memory. As it grows past the
|
||||||
|
// cap on one end, the off-screen rows on the other end are trimmed; the cursor
|
||||||
|
// for the trimmed boundary is dropped (set null) and the opposite `has*` flag
|
||||||
|
// is raised, so scrolling back refills that side from an anchored window.
|
||||||
|
|
||||||
|
const CARD_PITCH = 162; // 160px thumbnail + 2px grid gap
|
||||||
|
|
||||||
|
function windowCap(): number {
|
||||||
|
const scroller = getScroller();
|
||||||
|
const w = scroller.clientWidth || 390;
|
||||||
|
const h = scroller.clientHeight || 700;
|
||||||
|
const cols = Math.max(1, Math.floor(w / CARD_PITCH));
|
||||||
|
const rows = Math.max(1, Math.ceil(h / CARD_PITCH));
|
||||||
|
return Math.max(4 * cols * rows, 2 * LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a window centred on a file (with its boundary cursors), used to refill
|
||||||
|
// a trimmed edge where the original cursor is no longer held.
|
||||||
|
function fetchAnchorWindow(anchorId: string): Promise<FileCursorPage> {
|
||||||
|
const a = baseListParams();
|
||||||
|
a.set('anchor', anchorId);
|
||||||
|
return api.get<FileCursorPage>(`/files?${a}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the off-screen rows above the viewport once the grid grew past the cap.
|
||||||
|
// Run after appended rows have painted so the height delta measures only the
|
||||||
|
// removed top; scroll is compensated so the visible rows don't jump.
|
||||||
|
function trimHead() {
|
||||||
|
const cap = windowCap();
|
||||||
|
if (files.length <= cap) return;
|
||||||
|
flushSync(); // paint the just-appended (below-fold) rows before measuring
|
||||||
|
const scroller = getScroller();
|
||||||
|
const beforeTop = scroller.scrollTop;
|
||||||
|
const beforeHeight = scroller.scrollHeight;
|
||||||
|
files = files.slice(files.length - cap);
|
||||||
|
prevCursor = null;
|
||||||
|
hasPrev = true;
|
||||||
|
flushSync();
|
||||||
|
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symmetric to trimHead for upward growth: drop the off-screen rows below the
|
||||||
|
// viewport. No scroll compensation — the removed rows are past the fold.
|
||||||
|
function trimTail() {
|
||||||
|
const cap = windowCap();
|
||||||
|
if (files.length <= cap) return;
|
||||||
|
files = files.slice(0, cap);
|
||||||
|
nextCursor = null;
|
||||||
|
hasMore = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
|
let newItems: File[];
|
||||||
|
let newNextCursor: string | null;
|
||||||
|
if (nextCursor == null && files.length > 0) {
|
||||||
|
// The tail cursor was dropped when the window trimmed its bottom.
|
||||||
|
// Refetch the rows after the current last file from an anchored window.
|
||||||
|
const lastId = files[files.length - 1]?.id;
|
||||||
|
const res = await fetchAnchorWindow(lastId!);
|
||||||
|
const all = res.items ?? [];
|
||||||
|
const idx = all.findIndex((f) => f.id === lastId);
|
||||||
|
newItems = idx >= 0 ? all.slice(idx + 1) : [];
|
||||||
|
newNextCursor = res.next_cursor ?? null;
|
||||||
|
} else {
|
||||||
const params = baseListParams();
|
const params = baseListParams();
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
if (nextCursor) params.set('cursor', nextCursor);
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
files = [...files, ...(res.items ?? [])];
|
newItems = res.items ?? [];
|
||||||
nextCursor = res.next_cursor ?? null;
|
newNextCursor = res.next_cursor ?? null;
|
||||||
hasMore = !!res.next_cursor;
|
}
|
||||||
|
files = [...files, ...newItems];
|
||||||
|
nextCursor = newNextCursor;
|
||||||
|
hasMore = !!newNextCursor;
|
||||||
|
trimHead();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user