From 5a03aa688dccdee671dd6e685a046d89831bd24e Mon Sep 17 00:00:00 2001 From: Joe Parks <26990067+jowparks@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:01:14 -0700 Subject: [PATCH] fix: keep older-blocks pagination in shadow delta view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shadow-delta branch hardcoded nextCursor: null, so "Older blocks →" disappeared whenever shadow delta was enabled. Derive the cursor from the oldest shadow block on a full page (via the API's exclusive `before` param) and thread the URL cursor through the shadow fetch so paging works. --- app/internal-explorer/blocks/page.tsx | 15 +++++---- .../library/shadow-pagination.test.ts | 31 +++++++++++++++++++ .../library/shadow-pagination.ts | 14 +++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 app/internal-explorer/library/shadow-pagination.test.ts create mode 100644 app/internal-explorer/library/shadow-pagination.ts diff --git a/app/internal-explorer/blocks/page.tsx b/app/internal-explorer/blocks/page.tsx index d90d7c3..2ef1ad7 100644 --- a/app/internal-explorer/blocks/page.tsx +++ b/app/internal-explorer/blocks/page.tsx @@ -13,6 +13,7 @@ import { ActiveBlockButton } from '../components/ActiveBlockButton'; import { explorerApi } from '../library/client'; import { formatInteger } from '../library/explorer-format'; import { explorerHref } from '../library/links'; +import { nextShadowCursor } from '../library/shadow-pagination'; import type { BlocksResponse, ShadowBlockSummary } from '../library/types'; import { useExplorerChain } from '../library/useExplorerChain'; import { useShadowDelta } from '../library/useShadowDelta'; @@ -76,13 +77,13 @@ function BlocksContent() { setShadowCandidates({}); explorerApi - .recentShadowBlocks(chain, { limit: PAGE_LIMIT }, controller.signal) + .recentShadowBlocks(chain, { limit: PAGE_LIMIT, before: cursor }, controller.signal) .then(async (shadows) => { if (cancelled) return; if (shadows.length === 0) { setData({ blocks: [], - page: { cursor: null, limit: PAGE_LIMIT, latestBlockNumber: 0, nextCursor: null, hasMore: false }, + page: { cursor: cursor ?? null, limit: PAGE_LIMIT, latestBlockNumber: 0, nextCursor: null, hasMore: false }, }); return; } @@ -101,15 +102,17 @@ function BlocksContent() { .map((shadow) => [shadow.canonicalHash!.toLowerCase(), [shadow]]), ); + const nextCursor = nextShadowCursor(shadows, PAGE_LIMIT); + setShadowCandidates(candidates); setData({ blocks: orderedBlocks, page: { - cursor: null, + cursor: cursor ?? null, limit: PAGE_LIMIT, latestBlockNumber: orderedBlocks[0]?.number ?? 0, - nextCursor: null, - hasMore: false, + nextCursor, + hasMore: nextCursor !== null, }, }); }) @@ -125,7 +128,7 @@ function BlocksContent() { cancelled = true; controller.abort(); }; - }, [chain, showShadowDelta]); + }, [chain, cursor, showShadowDelta]); return (
diff --git a/app/internal-explorer/library/shadow-pagination.test.ts b/app/internal-explorer/library/shadow-pagination.test.ts new file mode 100644 index 0000000..34fc6e4 --- /dev/null +++ b/app/internal-explorer/library/shadow-pagination.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { nextShadowCursor } from './shadow-pagination'; + +const PAGE_LIMIT = 25; + +function shadowsEndingAt(oldest: number, count: number): { number: number }[] { + return Array.from({ length: count }, (_, index) => ({ number: oldest + count - 1 - index })); +} + +describe('nextShadowCursor', () => { + it('returns the oldest block number when a full page is returned', () => { + const shadows = shadowsEndingAt(100, PAGE_LIMIT); + expect(shadows.at(-1)?.number).toBe(100); + expect(nextShadowCursor(shadows, PAGE_LIMIT)).toBe(100); + }); + + it('returns null on a short page (source exhausted)', () => { + const shadows = shadowsEndingAt(100, PAGE_LIMIT - 1); + expect(nextShadowCursor(shadows, PAGE_LIMIT)).toBeNull(); + }); + + it('returns null when the oldest block is genesis (0)', () => { + const shadows = shadowsEndingAt(0, PAGE_LIMIT); + expect(nextShadowCursor(shadows, PAGE_LIMIT)).toBeNull(); + }); + + it('returns null for an empty page', () => { + expect(nextShadowCursor([], PAGE_LIMIT)).toBeNull(); + }); +}); diff --git a/app/internal-explorer/library/shadow-pagination.ts b/app/internal-explorer/library/shadow-pagination.ts new file mode 100644 index 0000000..16b0c5d --- /dev/null +++ b/app/internal-explorer/library/shadow-pagination.ts @@ -0,0 +1,14 @@ +// Pagination cursor for the shadow-delta block view. Shadow blocks come back +// newest-first; the API's `before` param is an *exclusive* block-number cutoff, +// so the next page starts at the oldest block returned (no overlap, no gap). A +// short page means the source is exhausted. +import type { ShadowBlockSummary } from './types'; + +export function nextShadowCursor( + shadows: Pick[], + pageLimit: number, +): number | null { + if (shadows.length < pageLimit) return null; + const oldest = shadows.at(-1)?.number; + return oldest === undefined || oldest <= 0 ? null : oldest; +}