From c542eb086421d00872d2f9df7be50e2e20114146 Mon Sep 17 00:00:00 2001 From: Joe Parks <26990067+jowparks@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:14:32 -0700 Subject: [PATCH 1/5] feat(internal-explorer): drive shadow-delta view from recent shadow blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical chain tip rarely contains a block with a shadow replacement, so the shadow-delta list was almost always empty. When Show shadow Δ is on, drive the list from the new shadow-metrics /shadow-blocks endpoint (dense, newest first) and resolve each canonical block by number to fill the base columns and delta baseline. --- app/api/internal-explorer/block-list.ts | 10 +++ .../blocks-by-numbers/route.ts | 55 +++++++++++++ .../internal-explorer/shadow-blocks/route.ts | 34 ++++++++ app/api/internal-explorer/shadow.ts | 14 ++++ app/internal-explorer/blocks/page.tsx | 77 +++++++++++++++---- app/internal-explorer/library/client.ts | 20 +++++ 6 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 app/api/internal-explorer/blocks-by-numbers/route.ts create mode 100644 app/api/internal-explorer/shadow-blocks/route.ts diff --git a/app/api/internal-explorer/block-list.ts b/app/api/internal-explorer/block-list.ts index 36ec49d..9f6053b 100644 --- a/app/api/internal-explorer/block-list.ts +++ b/app/api/internal-explorer/block-list.ts @@ -120,6 +120,16 @@ async function fetchBlockByNumber( }; } +export async function fetchBlocksByNumbers( + rpcUrl: string, + numbers: number[], +): Promise { + const blocks = await Promise.all( + numbers.map((blockNumber) => fetchBlockByNumber(rpcUrl, blockNumber)), + ); + return blocks.filter((block): block is BlockSummary => block !== null); +} + export async function listBlocks(rpcUrl: string, query: BlockListQuery): Promise { const latestBlockNumber = await fetchLatestBlockNumber(rpcUrl); if (latestBlockNumber === null) { diff --git a/app/api/internal-explorer/blocks-by-numbers/route.ts b/app/api/internal-explorer/blocks-by-numbers/route.ts new file mode 100644 index 0000000..25309a8 --- /dev/null +++ b/app/api/internal-explorer/blocks-by-numbers/route.ts @@ -0,0 +1,55 @@ +import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { BlockListUnavailableError, fetchBlocksByNumbers } from '../block-list'; +import { getRpcUrl } from '../config'; +import { explorerDisabledResponse } from '../guard'; + +export const runtime = 'nodejs'; + +export type { BlockSummary } from '../block-list'; + +const MAX_NUMBERS = 100; + +export async function GET(request: Request) { + const disabled = explorerDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const chain = resolveExplorerChain(url.searchParams.get('chain')); + + const raw = url.searchParams.get('numbers'); + if (!raw) { + return Response.json({ error: 'Missing block numbers' }, { status: 400 }); + } + + const numbers = raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + + if (numbers.length === 0 || numbers.length > MAX_NUMBERS) { + return Response.json({ error: 'Invalid block numbers' }, { status: 400 }); + } + + if (numbers.some((value) => !/^(0|[1-9]\d*)$/.test(value))) { + return Response.json({ error: 'Invalid block numbers' }, { status: 400 }); + } + + const parsed = numbers.map(Number); + if (parsed.some((value) => !Number.isSafeInteger(value))) { + return Response.json({ error: 'Invalid block numbers' }, { status: 400 }); + } + + try { + return Response.json(await fetchBlocksByNumbers(getRpcUrl(chain), parsed)); + } catch (error) { + return Response.json( + { + error: + error instanceof BlockListUnavailableError + ? 'Block list unavailable' + : 'Internal server error', + }, + { status: error instanceof BlockListUnavailableError ? 503 : 500 }, + ); + } +} diff --git a/app/api/internal-explorer/shadow-blocks/route.ts b/app/api/internal-explorer/shadow-blocks/route.ts new file mode 100644 index 0000000..870b974 --- /dev/null +++ b/app/api/internal-explorer/shadow-blocks/route.ts @@ -0,0 +1,34 @@ +import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { getShadowMetricsUrl } from '../config'; +import { explorerDisabledResponse } from '../guard'; +import { fetchRecentShadowBlocks } from '../shadow'; + +export const runtime = 'nodejs'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +function parsePositiveInt(value: string | null): number | undefined { + if (value === null) return undefined; + if (!/^[1-9]\d*$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +export async function GET(request: Request) { + const disabled = explorerDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const chain = resolveExplorerChain(url.searchParams.get('chain')); + + const baseUrl = getShadowMetricsUrl(chain); + if (!baseUrl) { + return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); + } + + const limit = Math.min(parsePositiveInt(url.searchParams.get('limit')) ?? DEFAULT_LIMIT, MAX_LIMIT); + const before = parsePositiveInt(url.searchParams.get('before')); + + return Response.json(await fetchRecentShadowBlocks(baseUrl, { limit, before })); +} diff --git a/app/api/internal-explorer/shadow.ts b/app/api/internal-explorer/shadow.ts index a6dc4cb..756411a 100644 --- a/app/api/internal-explorer/shadow.ts +++ b/app/api/internal-explorer/shadow.ts @@ -147,6 +147,20 @@ export async function fetchShadowCandidatesBatch( } } +export async function fetchRecentShadowBlocks( + baseUrl: string, + options: { limit?: number; before?: number } = {}, +): Promise { + const root = baseUrl.replace(/\/$/, ''); + const params = new URLSearchParams(); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + if (options.before !== undefined) params.set('before', String(options.before)); + const query = params.toString(); + const url = query ? `${root}/shadow-blocks?${query}` : `${root}/shadow-blocks`; + const summaries = await fetchShadowMetrics(url); + return summaries.map(normalizeShadowSummary); +} + export async function fetchShadowBlockSummary( baseUrl: string, hash: string, diff --git a/app/internal-explorer/blocks/page.tsx b/app/internal-explorer/blocks/page.tsx index cecee63..c83c29c 100644 --- a/app/internal-explorer/blocks/page.tsx +++ b/app/internal-explorer/blocks/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useMemo, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import { Card } from '../../components/ui/Card'; import { Spinner } from '../../components/ui/Spinner'; @@ -29,17 +29,18 @@ function BlocksContent() { const [error, setError] = useState(null); const [showShadowDelta, setShowShadowDelta] = useState(false); const [shadowCandidates, setShadowCandidates] = useState>({}); - const shadowKey = useMemo( - () => (data?.blocks ?? []).map((block) => block.hash.toLowerCase()).sort().join(','), - [data?.blocks], - ); + // Canonical-block view: paginate the chain tip directly. The shadow-delta + // view has its own source below, so this fetch stands down when it is on. useEffect(() => { + if (showShadowDelta) return undefined; + let cancelled = false; const controller = new AbortController(); setLoading(true); setError(null); setData(null); + setShadowCandidates({}); explorerApi .blocksPage(chain, { cursor, limit: PAGE_LIMIT }, controller.signal) @@ -58,26 +59,72 @@ function BlocksContent() { cancelled = true; controller.abort(); }; - }, [chain, cursor]); + }, [chain, cursor, showShadowDelta]); + // The canonical tip rarely holds a block with a shadow replacement, so this + // view is driven by recent shadow blocks (dense, newest first) with each + // canonical block resolved by number to fill the base columns. useEffect(() => { - if (!showShadowDelta || shadowKey.length === 0) { - setShadowCandidates({}); - return undefined; - } + if (!showShadowDelta) return undefined; + let cancelled = false; const controller = new AbortController(); - const hashes = shadowKey.split(','); + setLoading(true); + setError(null); + setData(null); + setShadowCandidates({}); explorerApi - .shadowCandidatesBatch(chain, hashes, controller.signal) - .then((response) => setShadowCandidates(response)) - .catch(() => setShadowCandidates({})); + .recentShadowBlocks(chain, { limit: PAGE_LIMIT }, 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 }, + }); + return; + } + + const numbers = shadows.map((shadow) => shadow.number); + const canonicalBlocks = await explorerApi.blocksByNumbers(chain, numbers, controller.signal); + if (cancelled) return; + + const canonicalByNumber = new Map(canonicalBlocks.map((block) => [block.number, block])); + const orderedBlocks = shadows + .map((shadow) => canonicalByNumber.get(shadow.number)) + .filter((block): block is NonNullable => block !== undefined); + const candidates = Object.fromEntries( + shadows + .filter((shadow) => shadow.canonicalHash !== undefined) + .map((shadow) => [shadow.canonicalHash!.toLowerCase(), [shadow]]), + ); + + setShadowCandidates(candidates); + setData({ + blocks: orderedBlocks, + page: { + cursor: null, + limit: PAGE_LIMIT, + latestBlockNumber: orderedBlocks[0]?.number ?? 0, + nextCursor: null, + hasMore: false, + }, + }); + }) + .catch(() => { + if (controller.signal.aborted || cancelled) return; + setError('Failed to fetch shadow blocks'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); return () => { + cancelled = true; controller.abort(); }; - }, [chain, shadowKey, showShadowDelta]); + }, [chain, showShadowDelta]); return (
diff --git a/app/internal-explorer/library/client.ts b/app/internal-explorer/library/client.ts index 4c888cd..9533372 100644 --- a/app/internal-explorer/library/client.ts +++ b/app/internal-explorer/library/client.ts @@ -8,6 +8,7 @@ import type { ExplorerChain } from '../chains'; import type { BlockDetailResponse, BlocksResponse, + BlockSummary, LatestActiveBlockResponse, BundleHistoryResponse, RejectedTransactionsResponse, @@ -119,4 +120,23 @@ export const explorerApi = { chain, signal, ), + recentShadowBlocks: ( + chain: ExplorerChain, + options?: { limit?: number; before?: number }, + signal?: AbortSignal, + ) => + get( + withQuery('/api/internal-explorer/shadow-blocks', { + limit: options?.limit, + before: options?.before, + }), + chain, + signal, + ), + blocksByNumbers: (chain: ExplorerChain, numbers: number[], signal?: AbortSignal) => + get( + withQuery('/api/internal-explorer/blocks-by-numbers', { numbers: numbers.join(',') }), + chain, + signal, + ), }; From bd9be8e9dee8f188d12a1ad8aea36384fe235492 Mon Sep 17 00:00:00 2001 From: Joe Parks <26990067+jowparks@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:25:14 -0700 Subject: [PATCH 2/5] feat(internal-explorer): persist shadow-delta toggle in URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Show shadow Δ' checkbox lived in per-page useState, so it reset on refresh and never carried between the dashboard and the /blocks page. Extract a useShadowDelta hook backed by a ?shadowDelta=1 query param (mirroring useExplorerChain) and use it in both places, so the selection survives refresh, stays shareable, and is consistent across pages. --- app/internal-explorer/blocks/page.tsx | 3 +- .../library/useShadowDelta.ts | 39 +++++++++++++++++++ app/internal-explorer/page.tsx | 3 +- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 app/internal-explorer/library/useShadowDelta.ts diff --git a/app/internal-explorer/blocks/page.tsx b/app/internal-explorer/blocks/page.tsx index c83c29c..d90d7c3 100644 --- a/app/internal-explorer/blocks/page.tsx +++ b/app/internal-explorer/blocks/page.tsx @@ -15,11 +15,13 @@ import { formatInteger } from '../library/explorer-format'; import { explorerHref } from '../library/links'; import type { BlocksResponse, ShadowBlockSummary } from '../library/types'; import { useExplorerChain } from '../library/useExplorerChain'; +import { useShadowDelta } from '../library/useShadowDelta'; const PAGE_LIMIT = 25; function BlocksContent() { const { chain } = useExplorerChain(); + const { showShadowDelta, setShowShadowDelta } = useShadowDelta(); const searchParams = useSearchParams(); const cursorParam = searchParams.get('cursor'); const cursor = cursorParam !== null ? Number(cursorParam) : undefined; @@ -27,7 +29,6 @@ function BlocksContent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [showShadowDelta, setShowShadowDelta] = useState(false); const [shadowCandidates, setShadowCandidates] = useState>({}); // Canonical-block view: paginate the chain tip directly. The shadow-delta diff --git a/app/internal-explorer/library/useShadowDelta.ts b/app/internal-explorer/library/useShadowDelta.ts new file mode 100644 index 0000000..aa3b060 --- /dev/null +++ b/app/internal-explorer/library/useShadowDelta.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useCallback } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +type UseShadowDelta = { + /** Whether the shadow-delta view is enabled, read from `?shadowDelta=`. */ + showShadowDelta: boolean; + /** Update `?shadowDelta=` in place, preserving the path and other query params. */ + setShowShadowDelta: (next: boolean) => void; +}; + +// Reads the shadow-delta toggle from the URL (`?shadowDelta=1`) and provides a +// setter that rewrites just that query param via router.replace — so the toggle +// persists across refresh and navigation, stays shareable, and never pushes a +// new history entry. Mirrors useExplorerChain. +export function useShadowDelta(): UseShadowDelta { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const showShadowDelta = searchParams.get('shadowDelta') === '1'; + + const setShowShadowDelta = useCallback( + (next: boolean) => { + const params = new URLSearchParams(searchParams.toString()); + if (next) { + params.set('shadowDelta', '1'); + } else { + params.delete('shadowDelta'); + } + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [router, pathname, searchParams], + ); + + return { showShadowDelta, setShowShadowDelta }; +} diff --git a/app/internal-explorer/page.tsx b/app/internal-explorer/page.tsx index 10832d8..c234aa9 100644 --- a/app/internal-explorer/page.tsx +++ b/app/internal-explorer/page.tsx @@ -33,6 +33,7 @@ import { type ShadowBlockSummary, } from './library/types'; import { useExplorerChain } from './library/useExplorerChain'; +import { useShadowDelta } from './library/useShadowDelta'; type Tab = 'blocks' | 'rejected'; @@ -212,7 +213,7 @@ function BlocksTab({ }) { const [blocks, setBlocks] = useState([]); const [loading, setLoading] = useState(true); - const [showShadowDelta, setShowShadowDelta] = useState(false); + const { showShadowDelta, setShowShadowDelta } = useShadowDelta(); const [shadowCandidates, setShadowCandidates] = useState>({}); const shadowKey = useMemo( () => blocks.map((block) => block.hash.toLowerCase()).sort().join(','), From 1409d839acc23c2316a33a4c74c351070b7d83a6 Mon Sep 17 00:00:00 2001 From: Joe Parks <26990067+jowparks@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:58:09 -0700 Subject: [PATCH 3/5] fix(internal-explorer): harden shadow-blocks proxy + dense homepage toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings: - fetchBlocksByNumbers throws BlockListUnavailableError on any missing block instead of silently dropping rows, matching listBlocks. - shadow-blocks route returns 400 on malformed limit/before and maps ShadowUnavailableError to 503, matching shadow-block/[hash]. - Homepage 'Show shadow Δ' toggle now drives a dense shadow-block list (same source as the blocks page) instead of the mostly-empty tip. --- app/api/internal-explorer/block-list.ts | 7 +- .../internal-explorer/shadow-blocks/route.ts | 26 +++++-- app/internal-explorer/page.tsx | 75 ++++++++++++++----- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/app/api/internal-explorer/block-list.ts b/app/api/internal-explorer/block-list.ts index 9f6053b..7410277 100644 --- a/app/api/internal-explorer/block-list.ts +++ b/app/api/internal-explorer/block-list.ts @@ -127,7 +127,12 @@ export async function fetchBlocksByNumbers( const blocks = await Promise.all( numbers.map((blockNumber) => fetchBlockByNumber(rpcUrl, blockNumber)), ); - return blocks.filter((block): block is BlockSummary => block !== null); + + if (blocks.some((block) => block === null)) { + throw new BlockListUnavailableError('failed to fetch one or more blocks'); + } + + return blocks as BlockSummary[]; } export async function listBlocks(rpcUrl: string, query: BlockListQuery): Promise { diff --git a/app/api/internal-explorer/shadow-blocks/route.ts b/app/api/internal-explorer/shadow-blocks/route.ts index 870b974..0ef5d32 100644 --- a/app/api/internal-explorer/shadow-blocks/route.ts +++ b/app/api/internal-explorer/shadow-blocks/route.ts @@ -1,18 +1,19 @@ import { resolveExplorerChain } from '../../../internal-explorer/chains'; import { getShadowMetricsUrl } from '../config'; import { explorerDisabledResponse } from '../guard'; -import { fetchRecentShadowBlocks } from '../shadow'; +import { ShadowUnavailableError, fetchRecentShadowBlocks } from '../shadow'; export const runtime = 'nodejs'; const DEFAULT_LIMIT = 25; const MAX_LIMIT = 100; -function parsePositiveInt(value: string | null): number | undefined { +// undefined = absent; null = present but malformed (caller should reject). +function parsePositiveInt(value: string | null): number | undefined | null { if (value === null) return undefined; - if (!/^[1-9]\d*$/.test(value)) return undefined; + if (!/^[1-9]\d*$/.test(value)) return null; const parsed = Number(value); - return Number.isSafeInteger(parsed) ? parsed : undefined; + return Number.isSafeInteger(parsed) ? parsed : null; } export async function GET(request: Request) { @@ -27,8 +28,21 @@ export async function GET(request: Request) { return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); } - const limit = Math.min(parsePositiveInt(url.searchParams.get('limit')) ?? DEFAULT_LIMIT, MAX_LIMIT); + const rawLimit = parsePositiveInt(url.searchParams.get('limit')); const before = parsePositiveInt(url.searchParams.get('before')); + if (rawLimit === null || before === null) { + return Response.json({ error: 'Invalid limit or before' }, { status: 400 }); + } + + const limit = Math.min(rawLimit ?? DEFAULT_LIMIT, MAX_LIMIT); - return Response.json(await fetchRecentShadowBlocks(baseUrl, { limit, before })); + try { + return Response.json(await fetchRecentShadowBlocks(baseUrl, { limit, before })); + } catch (error) { + console.error('Error fetching recent shadow blocks:', error); + return Response.json( + { error: 'Shadow blocks unavailable' }, + { status: error instanceof ShadowUnavailableError ? 503 : 500 }, + ); + } } diff --git a/app/internal-explorer/page.tsx b/app/internal-explorer/page.tsx index c234aa9..e3f654d 100644 --- a/app/internal-explorer/page.tsx +++ b/app/internal-explorer/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { Suspense, useCallback, useEffect, useState } from 'react'; import type { FormEvent } from 'react'; import { Banner } from '../components/ui/Banner'; @@ -204,6 +204,8 @@ function BlockRow({ ); } +const HOME_BLOCK_LIMIT = 10; + function BlocksTab({ chain, onError, @@ -215,14 +217,15 @@ function BlocksTab({ const [loading, setLoading] = useState(true); const { showShadowDelta, setShowShadowDelta } = useShadowDelta(); const [shadowCandidates, setShadowCandidates] = useState>({}); - const shadowKey = useMemo( - () => blocks.map((block) => block.hash.toLowerCase()).sort().join(','), - [blocks], - ); + // Canonical-block view: poll the chain tip directly. The shadow-delta view + // has its own source below, so this fetch stands down when it is on. useEffect(() => { + if (showShadowDelta) return undefined; + let cancelled = false; setLoading(true); + setShadowCandidates({}); async function load() { try { @@ -241,26 +244,62 @@ function BlocksTab({ cancelled = true; window.clearInterval(timer); }; - }, [chain]); + }, [chain, showShadowDelta]); + // The canonical tip rarely holds a block with a shadow replacement, so this + // view is driven by recent shadow blocks (dense, newest first) with each + // canonical block resolved by number to fill the base columns. useEffect(() => { - if (!showShadowDelta || shadowKey.length === 0) { - setShadowCandidates({}); - return undefined; - } + if (!showShadowDelta) return undefined; - const controller = new AbortController(); - const hashes = shadowKey.split(','); + let cancelled = false; + setLoading(true); - explorerApi - .shadowCandidatesBatch(chain, hashes, controller.signal) - .then((response) => setShadowCandidates(response)) - .catch(() => setShadowCandidates({})); + async function load() { + const controller = new AbortController(); + try { + const shadows = await explorerApi.recentShadowBlocks( + chain, + { limit: HOME_BLOCK_LIMIT }, + controller.signal, + ); + if (cancelled) return; + if (shadows.length === 0) { + setBlocks([]); + setShadowCandidates({}); + return; + } + + const numbers = shadows.map((shadow) => shadow.number); + const canonicalBlocks = await explorerApi.blocksByNumbers(chain, numbers, controller.signal); + if (cancelled) return; + + const canonicalByNumber = new Map(canonicalBlocks.map((block) => [block.number, block])); + const orderedBlocks = shadows + .map((shadow) => canonicalByNumber.get(shadow.number)) + .filter((block): block is BlockSummary => block !== undefined); + const candidates = Object.fromEntries( + shadows + .filter((shadow) => shadow.canonicalHash !== undefined) + .map((shadow) => [shadow.canonicalHash!.toLowerCase(), [shadow]]), + ); + + setBlocks(orderedBlocks); + setShadowCandidates(candidates); + } catch { + // Keep last good data across transient poll failures. + } finally { + if (!cancelled) setLoading(false); + } + } + void load(); + const timer = window.setInterval(() => void load(), 2000); return () => { - controller.abort(); + cancelled = true; + window.clearInterval(timer); }; - }, [chain, shadowKey, showShadowDelta]); + }, [chain, showShadowDelta]); return (
From 3d717f546fc4e83d85e929e01b953955ec93e682 Mon Sep 17 00:00:00 2001 From: Joe Parks <26990067+jowparks@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:03:18 -0700 Subject: [PATCH 4/5] fix(internal-explorer): clear stale rows and guard overlapping shadow polls On the homepage the shadow-delta effect now clears blocks and candidates when it activates, so switching modes never shows the previous source's rows (or leaves them if the first poll fails). An in-flight guard skips a poll while the prior two-request chain is still running, preventing stacked calls and out-of-order writes. --- app/internal-explorer/page.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/internal-explorer/page.tsx b/app/internal-explorer/page.tsx index e3f654d..19ccefa 100644 --- a/app/internal-explorer/page.tsx +++ b/app/internal-explorer/page.tsx @@ -253,9 +253,16 @@ function BlocksTab({ if (!showShadowDelta) return undefined; let cancelled = false; + let inFlight = false; setLoading(true); + setBlocks([]); + setShadowCandidates({}); async function load() { + // This poll chains two requests, so skip if the previous one is still + // running to avoid stacking calls and clobbering fresh data out of order. + if (inFlight) return; + inFlight = true; const controller = new AbortController(); try { const shadows = await explorerApi.recentShadowBlocks( @@ -289,6 +296,7 @@ function BlocksTab({ } catch { // Keep last good data across transient poll failures. } finally { + inFlight = false; if (!cancelled) setLoading(false); } } From 6ff8c6204b634ad8e624e9d0913bd3c637f84209 Mon Sep 17 00:00:00 2001 From: Joe Parks <26990067+jowparks@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:05:58 -0700 Subject: [PATCH 5/5] fix(internal-explorer): clear canonical rows and abort polls on cleanup Mirror the shadow-effect hardening on the canonical effect: clear blocks when it activates so toggling shadow off never shows stale shadow rows, and abort the in-flight request on cleanup. The shadow effect now tracks its active controller and aborts it too, so rapid toggles no longer leave abandoned requests. --- app/internal-explorer/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/internal-explorer/page.tsx b/app/internal-explorer/page.tsx index 19ccefa..dc1bfef 100644 --- a/app/internal-explorer/page.tsx +++ b/app/internal-explorer/page.tsx @@ -224,12 +224,14 @@ function BlocksTab({ if (showShadowDelta) return undefined; let cancelled = false; + const controller = new AbortController(); setLoading(true); + setBlocks([]); setShadowCandidates({}); async function load() { try { - const data = await explorerApi.blocks(chain); + const data = await explorerApi.blocks(chain, controller.signal); if (!cancelled) setBlocks(data.blocks); } catch { // Transient errors are expected between polls; keep the last good data. @@ -242,6 +244,7 @@ function BlocksTab({ const timer = window.setInterval(() => void load(), 2000); return () => { cancelled = true; + controller.abort(); window.clearInterval(timer); }; }, [chain, showShadowDelta]); @@ -254,6 +257,7 @@ function BlocksTab({ let cancelled = false; let inFlight = false; + let activeController: AbortController | null = null; setLoading(true); setBlocks([]); setShadowCandidates({}); @@ -264,6 +268,7 @@ function BlocksTab({ if (inFlight) return; inFlight = true; const controller = new AbortController(); + activeController = controller; try { const shadows = await explorerApi.recentShadowBlocks( chain, @@ -305,6 +310,7 @@ function BlocksTab({ const timer = window.setInterval(() => void load(), 2000); return () => { cancelled = true; + activeController?.abort(); window.clearInterval(timer); }; }, [chain, showShadowDelta]);