feat(frontend): bidirectional lazy load for anchored grid returns

Returning to the grid at a deep position (deep link / hard reload to a
file, then back → /files?anchor=<id>) used to load only a tiny forward
window at the anchor. Now the grid fills the viewport around the anchor
and pages in both directions as the user scrolls.

- loadAroundAnchor fetches a window centred on the anchor and pre-fills a
  few pages each way sequentially, then centres on the anchor once. Doing
  the initial fill explicitly (rather than via the sentinels) keeps the
  pages contiguous and leaves the sentinels out of range, so there's no
  mount-time load storm.
- loading starts true when the URL carries an ?anchor, so the child
  InfiniteScroll sentinels (whose effects run before this page's reset
  effect on mount) can't fire a stray page-1 loadMore that interleaves
  with loadAroundAnchor.
- loadPrev pages backward (direction=backward) and prepends, then shifts
  the scroller down by the added height via flushSync (no paint between
  prepend and correction) so the viewport stays visually fixed.
- InfiniteScroll gains an `edge` prop; a top instance (shown only when
  hasPrev) drives upward loading. Both loaders share the `loading` guard.
- Mock: honour direction=backward and emit prev_cursor; the Go backend
  already supports backward keyset pagination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 01:16:32 +03:00
parent dc1af8c585
commit e801eec47d
3 changed files with 163 additions and 38 deletions
+14 -1
View File
@@ -606,12 +606,25 @@ export function mockApiPlugin(): Plugin {
return json(res, 200, { items: slice, next_cursor, prev_cursor });
}
const direction = qs.get('direction') ?? 'forward';
if (direction === 'backward' && cursor) {
// Cursor marks the current top boundary; return the page before it.
const end = Number(Buffer.from(cursor, 'base64').toString());
const start = Math.max(0, end - limit);
const slice = MOCK_FILES.slice(start, end);
const prev_cursor = start > 0
? Buffer.from(String(start)).toString('base64') : null;
const next_cursor = Buffer.from(String(end)).toString('base64');
return json(res, 200, { items: slice, next_cursor, prev_cursor });
}
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
const slice = MOCK_FILES.slice(offset, offset + limit);
const nextOffset = offset + slice.length;
const next_cursor = nextOffset < MOCK_FILES.length
? Buffer.from(String(nextOffset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
const prev_cursor = offset > 0
? Buffer.from(String(offset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor });
}
// GET /tags/{id}/rules