From 414fb86948e4a413b35e5fb8915e2c81f62c610c Mon Sep 17 00:00:00 2001 From: Cristian Chinchilla Valerin Date: Fri, 16 Oct 2026 14:00:40 -0600 Subject: [PATCH 1/2] perf: code-split routes and chain components, set bundle size budgets in CI --- .github/workflows/ci.yml | 5 + package.json | 1 + scripts/bundle-budget-gate.mjs | 172 ++++++++++++++++++++++++++ scripts/bundle-budget-gate.test.mjs | 122 ++++++++++++++++++ src/App.tsx | 76 +++++++----- src/components/RouteErrorBoundary.tsx | 75 +++++++++++ src/components/RouteFallback.tsx | 17 +++ src/pages/Receive.tsx | 53 ++++++-- src/pages/Send.tsx | 38 ++++-- 9 files changed, 504 insertions(+), 55 deletions(-) create mode 100644 scripts/bundle-budget-gate.mjs create mode 100644 scripts/bundle-budget-gate.test.mjs create mode 100644 src/components/RouteErrorBoundary.tsx create mode 100644 src/components/RouteFallback.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7473748..10a6a53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: - run: pnpm format:check - run: pnpm build + # Fails when the initial load, the Stellar scanner worker, or any + # lazy-loaded route/chain chunk grows past its budget. See + # scripts/bundle-budget-gate.mjs for what each budget covers and why. + - run: pnpm build:budget-check + # Fails when a new critical (or more than the accepted number of high) # production dependency finding appears. See docs/SECURITY_AUDIT.md. - run: pnpm audit:gate diff --git a/package.json b/package.json index ec99e1c..f793f32 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "format:check": "prettier --check .", "audit:prod": "pnpm audit --prod", "audit:gate": "node scripts/audit-gate.mjs", + "build:budget-check": "node scripts/bundle-budget-gate.mjs", "prepare": "husky", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/scripts/bundle-budget-gate.mjs b/scripts/bundle-budget-gate.mjs new file mode 100644 index 0000000..871bb4a --- /dev/null +++ b/scripts/bundle-budget-gate.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// CI gate for production bundle size budgets (Wave 10, #219). +// +// Reads `dist/` after `pnpm build` and checks three things: +// 1. Initial load — every chunk referenced synchronously from +// dist/index.html (the `'; + expect(extractInitialLoadPaths(html)).toEqual(['/assets/index-ABC123.js']); + }); + + it('finds modulepreload links alongside the entry script', () => { + const html = [ + '', + '', + ].join('\n'); + expect(extractInitialLoadPaths(html)).toEqual([ + '/assets/vendor-XYZ.js', + '/assets/index-ABC.js', + ]); + }); + + it('ignores cross-origin scripts (e.g. analytics)', () => { + const html = [ + '', + '', + ].join('\n'); + expect(extractInitialLoadPaths(html)).toEqual(['/assets/index-ABC.js']); + }); + + it('returns an empty array when there is no module entry', () => { + expect(extractInitialLoadPaths('')).toEqual([]); + }); +}); + +describe('checkBudgets', () => { + let distDir; + + async function writeFixture({ entryBytes = 100, workerBytes = 100, extraChunks = {} } = {}) { + distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-')); + await mkdir(path.join(distDir, 'assets'), { recursive: true }); + await writeFile( + path.join(distDir, 'index.html'), + '', + ); + await writeFile(path.join(distDir, 'assets', 'index-ABC.js'), 'x'.repeat(entryBytes)); + await writeFile( + path.join(distDir, 'assets', 'stellar-scanner.worker-XYZ.js'), + 'x'.repeat(workerBytes), + ); + for (const [name, bytes] of Object.entries(extraChunks)) { + await writeFile(path.join(distDir, 'assets', name), 'x'.repeat(bytes)); + } + } + + afterEach(async () => { + if (distDir) await rm(distDir, { recursive: true, force: true }); + }); + + it('passes with no findings when everything is under budget', async () => { + await writeFixture({ extraChunks: { 'Vault-abc123.js': 1000 } }); + const findings = await checkBudgets({ distDir, log: () => {} }); + expect(findings).toEqual([]); + }); + + it('flags an over-budget initial load', async () => { + await writeFixture({ entryBytes: INITIAL_LOAD_BUDGET_BYTES + 1 }); + const findings = await checkBudgets({ distDir, log: () => {} }); + expect(findings).toEqual([expect.stringContaining('Initial load')]); + }); + + it('flags an over-budget scanner worker', async () => { + await writeFixture({ workerBytes: WORKER_BUDGET_BYTES + 1 }); + const findings = await checkBudgets({ distDir, log: () => {} }); + expect(findings).toEqual([expect.stringContaining('stellar-scanner.worker')]); + }); + + it('flags an over-budget lazy chunk', async () => { + await writeFixture({ + extraChunks: { 'Portfolio-huge123.js': PER_CHUNK_BUDGET_BYTES + 1 }, + }); + const findings = await checkBudgets({ distDir, log: () => {} }); + expect(findings).toEqual([expect.stringContaining('Portfolio-huge123.js')]); + }); + + it('does not double-count the entry chunk as a lazy chunk', async () => { + await writeFixture({ entryBytes: PER_CHUNK_BUDGET_BYTES + 1 }); + const findings = await checkBudgets({ distDir, log: () => {} }); + // Over the (much larger) initial-load budget only if huge enough to + // also exceed INITIAL_LOAD_BUDGET_BYTES; here it shouldn't be flagged + // as a *per-chunk* violation under its own entry filename. + expect(findings.some((f) => f.includes('assets/index-ABC.js'))).toBe(false); + }); + + it('throws BundleBudgetError when the scanner worker chunk is missing', async () => { + distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-')); + await mkdir(path.join(distDir, 'assets'), { recursive: true }); + await writeFile( + path.join(distDir, 'index.html'), + '', + ); + await writeFile(path.join(distDir, 'assets', 'index-ABC.js'), 'x'); + + await expect(checkBudgets({ distDir, log: () => {} })).rejects.toThrow(BundleBudgetError); + }); + + it('throws BundleBudgetError when index.html has no module entry', async () => { + distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-')); + await mkdir(path.join(distDir, 'assets'), { recursive: true }); + await writeFile(path.join(distDir, 'index.html'), ''); + + await expect(checkBudgets({ distDir, log: () => {} })).rejects.toThrow(BundleBudgetError); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 6b6e7c2..15f2b52 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,15 @@ -import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Header } from '@/components/Header'; import { AutoSign } from '@/components/AutoSign'; import { TelemetryBanner } from '@/components/TelemetryBanner'; import { OfflineBanner } from '@/components/OfflineBanner'; import { UpdatePrompt } from '@/components/UpdatePrompt'; -import Send from '@/pages/Send'; -import Receive from '@/pages/Receive'; -import Privacy from '@/pages/Privacy'; -import Settings from '@/pages/Settings'; +import { RouteFallback } from '@/components/RouteFallback'; +import { RouteErrorBoundary } from '@/components/RouteErrorBoundary'; import { HelpButton } from '@/components/HelpButton'; -import Vault from '@/pages/Vault'; -import Notifications from '@/pages/Notifications'; import { useNotificationSW } from '@/hooks/useNotificationSW'; import { useOfflineReconcile } from '@/hooks/useOfflineReconcile'; -import Schedule from '@/pages/Schedule'; -import StellarSplit from '@/pages/StellarSplit'; -import Names from '@/pages/Names'; -import NameProfile from '@/pages/NameProfile'; -import NamesAuctions from '@/pages/NamesAuctions'; -import Activity from '@/pages/Activity'; -import Portfolio from '@/pages/Portfolio'; -import Debug from '@/pages/Debug'; import { useChain } from '@/context/ChainContext'; import { useStealthKeys } from '@/context/StealthKeysContext'; import { KeyVault } from '@/vault/KeyVault'; @@ -34,6 +22,24 @@ import { import { parseStellarQrPayload } from '@/utils/qr'; import { useOfflineQueueStore } from '@/stores/offlineQueueStore'; +// Code-split per route so the initial bundle only ships the app shell. +// Each page (and the chain-specific wallet SDKs it pulls in — see +// pages/Send.tsx and pages/Receive.tsx) loads on first navigation instead +// of up front. RouteErrorBoundary + RouteFallback below handle the load. +const Send = lazy(() => import('@/pages/Send')); +const Receive = lazy(() => import('@/pages/Receive')); +const Privacy = lazy(() => import('@/pages/Privacy')); +const Vault = lazy(() => import('@/pages/Vault')); +const Notifications = lazy(() => import('@/pages/Notifications')); +const Schedule = lazy(() => import('@/pages/Schedule')); +const StellarSplit = lazy(() => import('@/pages/StellarSplit')); +const Names = lazy(() => import('@/pages/Names')); +const NameProfile = lazy(() => import('@/pages/NameProfile')); +const NamesAuctions = lazy(() => import('@/pages/NamesAuctions')); +const Activity = lazy(() => import('@/pages/Activity')); +const Portfolio = lazy(() => import('@/pages/Portfolio')); +const Debug = lazy(() => import('@/pages/Debug')); + function SessionLock({ onUnlock }: { onUnlock: () => void }) { const [passphrase, setPassphrase] = useState(''); const [busy, setBusy] = useState(false); @@ -204,24 +210,28 @@ export function App() {
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
diff --git a/src/components/RouteErrorBoundary.tsx b/src/components/RouteErrorBoundary.tsx new file mode 100644 index 0000000..c5a25a0 --- /dev/null +++ b/src/components/RouteErrorBoundary.tsx @@ -0,0 +1,75 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface RouteErrorBoundaryProps { + children: ReactNode; +} + +interface RouteErrorBoundaryState { + error: Error | null; +} + +/** + * Catches failures from lazy-loaded route chunks (React.lazy in App.tsx). + * A dynamic import() rejects when a chunk 404s — most commonly because the + * user has an old tab open across a new deploy and the hashed chunk URL no + * longer exists, or a flaky connection dropped the request. "Retry" just + * re-renders the children, which re-triggers the same import() and usually + * succeeds on a transient network blip. If the chunk itself is gone (stale + * deploy), the full reload picks up the current index.html and asset + * manifest instead of retrying the same dead URL forever. + */ +export class RouteErrorBoundary extends Component< + RouteErrorBoundaryProps, + RouteErrorBoundaryState +> { + state: RouteErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): RouteErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('Route chunk failed to load', error, info.componentStack); + } + + private retry = () => { + this.setState({ error: null }); + }; + + render() { + if (this.state.error) { + return ( +
+

+ This page failed to load +

+

+ That can happen after a new version ships while this tab was open, or on a flaky + connection. +

+
+ + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/RouteFallback.tsx b/src/components/RouteFallback.tsx new file mode 100644 index 0000000..2d19602 --- /dev/null +++ b/src/components/RouteFallback.tsx @@ -0,0 +1,17 @@ +/** + * Suspense fallback shown while a lazy-loaded route chunk is downloading. + * Routes are code-split (see App.tsx) so the initial bundle only ships the + * app shell; this fills the gap between navigation and the chunk arriving. + */ +export function RouteFallback() { + return ( +
+

Loading…

+
+ ); +} diff --git a/src/pages/Receive.tsx b/src/pages/Receive.tsx index 6854cad..066c804 100644 --- a/src/pages/Receive.tsx +++ b/src/pages/Receive.tsx @@ -1,11 +1,24 @@ -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { useChain } from '@/context/ChainContext'; -import { HorizenReceive } from '@/components/HorizenReceive'; -import { StellarReceive } from '@/components/StellarReceive'; -import { SolanaReceive } from '@/components/SolanaReceive'; -import { CkbReceive } from '@/components/CkbReceive'; +import { RouteFallback } from '@/components/RouteFallback'; import { trackPageView } from '@/lib/telemetry'; +// Same rationale as pages/Send.tsx: each chain's wallet SDK (and, for +// Stellar, the scanner worker it spins up) only needs to load for the +// chain the user actually picked. +const HorizenReceive = lazy(() => + import('@/components/HorizenReceive').then((m) => ({ default: m.HorizenReceive })), +); +const StellarReceive = lazy(() => + import('@/components/StellarReceive').then((m) => ({ default: m.StellarReceive })), +); +const SolanaReceive = lazy(() => + import('@/components/SolanaReceive').then((m) => ({ default: m.SolanaReceive })), +); +const CkbReceive = lazy(() => + import('@/components/CkbReceive').then((m) => ({ default: m.CkbReceive })), +); + export default function Receive() { const { chain } = useChain(); @@ -13,8 +26,30 @@ export default function Receive() { trackPageView('/receive'); }, []); - if (chain === 'stellar') return ; - if (chain === 'solana') return ; - if (chain === 'ckb') return ; - return ; + if (chain === 'stellar') { + return ( + }> + + + ); + } + if (chain === 'solana') { + return ( + }> + + + ); + } + if (chain === 'ckb') { + return ( + }> + + + ); + } + return ( + }> + + + ); } diff --git a/src/pages/Send.tsx b/src/pages/Send.tsx index 191a149..e72edcc 100644 --- a/src/pages/Send.tsx +++ b/src/pages/Send.tsx @@ -1,12 +1,24 @@ -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { useChain } from '@/context/ChainContext'; -import { HorizenSend } from '@/components/HorizenSend'; -import { StellarSend } from '@/components/StellarSend'; -import { SolanaSend } from '@/components/SolanaSend'; -import { CkbSend } from '@/components/CkbSend'; +import { RouteFallback } from '@/components/RouteFallback'; import { OfflineQueuePanel } from '@/components/OfflineQueuePanel'; import { trackPageView } from '@/lib/telemetry'; +// Each chain pulls in its own wallet SDK (wagmi/RainbowKit, Freighter + +// @stellar/stellar-sdk, @solana/wallet-adapter, @ckb-ccc). Only one chain is +// ever rendered at a time, so load just that one instead of bundling all +// four wallet stacks into the initial /send chunk. +const HorizenSend = lazy(() => + import('@/components/HorizenSend').then((m) => ({ default: m.HorizenSend })), +); +const StellarSend = lazy(() => + import('@/components/StellarSend').then((m) => ({ default: m.StellarSend })), +); +const SolanaSend = lazy(() => + import('@/components/SolanaSend').then((m) => ({ default: m.SolanaSend })), +); +const CkbSend = lazy(() => import('@/components/CkbSend').then((m) => ({ default: m.CkbSend }))); + export default function Send() { const { chain } = useChain(); @@ -20,32 +32,32 @@ export default function Send() { if (chain === 'stellar') { return ( - <> + }> {queuePanel} - + ); } if (chain === 'solana') { return ( - <> + }> {queuePanel} - + ); } if (chain === 'ckb') { return ( - <> + }> {queuePanel} - + ); } return ( - <> + }> {queuePanel} - + ); } From fbde362cf49c8307461cd9943fad4b976b683866 Mon Sep 17 00:00:00 2001 From: Cristian Chinchilla Valerin Date: Sat, 17 Oct 2026 22:17:56 -0600 Subject: [PATCH 2/2] fix(routes): retry lazy route chunks with a fresh import attempt --- src/App.tsx | 63 +++++++++++----------- src/components/RouteErrorBoundary.test.tsx | 38 +++++++++++++ src/components/RouteErrorBoundary.tsx | 46 ++++++++++++---- 3 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 src/components/RouteErrorBoundary.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 15f2b52..14c3950 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; +import { Suspense, useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Header } from '@/components/Header'; import { AutoSign } from '@/components/AutoSign'; @@ -6,7 +6,7 @@ import { TelemetryBanner } from '@/components/TelemetryBanner'; import { OfflineBanner } from '@/components/OfflineBanner'; import { UpdatePrompt } from '@/components/UpdatePrompt'; import { RouteFallback } from '@/components/RouteFallback'; -import { RouteErrorBoundary } from '@/components/RouteErrorBoundary'; +import { RouteErrorBoundary, LazyPage } from '@/components/RouteErrorBoundary'; import { HelpButton } from '@/components/HelpButton'; import { useNotificationSW } from '@/hooks/useNotificationSW'; import { useOfflineReconcile } from '@/hooks/useOfflineReconcile'; @@ -26,19 +26,22 @@ import { useOfflineQueueStore } from '@/stores/offlineQueueStore'; // Each page (and the chain-specific wallet SDKs it pulls in — see // pages/Send.tsx and pages/Receive.tsx) loads on first navigation instead // of up front. RouteErrorBoundary + RouteFallback below handle the load. -const Send = lazy(() => import('@/pages/Send')); -const Receive = lazy(() => import('@/pages/Receive')); -const Privacy = lazy(() => import('@/pages/Privacy')); -const Vault = lazy(() => import('@/pages/Vault')); -const Notifications = lazy(() => import('@/pages/Notifications')); -const Schedule = lazy(() => import('@/pages/Schedule')); -const StellarSplit = lazy(() => import('@/pages/StellarSplit')); -const Names = lazy(() => import('@/pages/Names')); -const NameProfile = lazy(() => import('@/pages/NameProfile')); -const NamesAuctions = lazy(() => import('@/pages/NamesAuctions')); -const Activity = lazy(() => import('@/pages/Activity')); -const Portfolio = lazy(() => import('@/pages/Portfolio')); -const Debug = lazy(() => import('@/pages/Debug')); +// Loaders stay as plain module-level functions (not lazy() itself) so +// LazyPage can construct a fresh lazy component on retry; see +// RouteErrorBoundary.tsx for why that distinction matters. +const loadSend = () => import('@/pages/Send'); +const loadReceive = () => import('@/pages/Receive'); +const loadPrivacy = () => import('@/pages/Privacy'); +const loadVault = () => import('@/pages/Vault'); +const loadNotifications = () => import('@/pages/Notifications'); +const loadSchedule = () => import('@/pages/Schedule'); +const loadStellarSplit = () => import('@/pages/StellarSplit'); +const loadNames = () => import('@/pages/Names'); +const loadNameProfile = () => import('@/pages/NameProfile'); +const loadNamesAuctions = () => import('@/pages/NamesAuctions'); +const loadActivity = () => import('@/pages/Activity'); +const loadPortfolio = () => import('@/pages/Portfolio'); +const loadDebug = () => import('@/pages/Debug'); function SessionLock({ onUnlock }: { onUnlock: () => void }) { const [passphrase, setPassphrase] = useState(''); @@ -213,21 +216,21 @@ export function App() { }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/components/RouteErrorBoundary.test.tsx b/src/components/RouteErrorBoundary.test.tsx new file mode 100644 index 0000000..e2eb423 --- /dev/null +++ b/src/components/RouteErrorBoundary.test.tsx @@ -0,0 +1,38 @@ +/** @vitest-environment jsdom */ +import { Suspense, type ComponentType } from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { LazyPage, RouteErrorBoundary, type PageLoader } from './RouteErrorBoundary'; + +describe('RouteErrorBoundary retry', () => { + it('re-attempts a fresh import on retry instead of replaying the cached rejection', async () => { + // Gated on a flag the test flips, not a call-count cutover: an uncommitted + // Suspense boundary can internally replay its first mount attempt several + // times in React's dev build, which would make a fixed "resolve on call N" + // mock flaky. The loader keeps rejecting through all of that replay noise, + // and only starts succeeding once the test simulates the fix being + // available and clicks Retry. + let shouldSucceed = false; + const loader: PageLoader = () => { + if (!shouldSucceed) { + return Promise.reject(new Error('chunk failed')); + } + return Promise.resolve({ default: (() =>
Loaded page
) as ComponentType }); + }; + + render( + + Loading...}> + + + , + ); + + await waitFor(() => screen.getByRole('alert')); + + shouldSucceed = true; + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + + await waitFor(() => screen.getByText('Loaded page')); + }); +}); diff --git a/src/components/RouteErrorBoundary.tsx b/src/components/RouteErrorBoundary.tsx index c5a25a0..83e57b3 100644 --- a/src/components/RouteErrorBoundary.tsx +++ b/src/components/RouteErrorBoundary.tsx @@ -1,4 +1,11 @@ -import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { + Component, + lazy, + useMemo, + type ComponentType, + type ErrorInfo, + type ReactNode, +} from 'react'; interface RouteErrorBoundaryProps { children: ReactNode; @@ -8,15 +15,36 @@ interface RouteErrorBoundaryState { error: Error | null; } +export type PageLoader = () => Promise<{ default: ComponentType }>; + +/** + * Renders a lazy-loaded page. The lazy() component is built here, inside + * render, rather than once at module scope (as App.tsx's routes used to do): + * React.lazy calls its loader once per component instance and replays a + * cached rejection on every later render of that same instance, so a + * module-level lazy() can never actually be retried after a chunk load + * fails. Keeping it in useMemo instead ties its lifetime to this instance, + * so RouteErrorBoundary's retry (which remounts its children, see below) + * gets a fresh lazy component, and therefore a fresh import() attempt. + */ +export function LazyPage({ loader }: { loader: PageLoader }) { + const Page = useMemo(() => lazy(loader), [loader]); + return ; +} + /** - * Catches failures from lazy-loaded route chunks (React.lazy in App.tsx). - * A dynamic import() rejects when a chunk 404s — most commonly because the - * user has an old tab open across a new deploy and the hashed chunk URL no - * longer exists, or a flaky connection dropped the request. "Retry" just - * re-renders the children, which re-triggers the same import() and usually - * succeeds on a transient network blip. If the chunk itself is gone (stale - * deploy), the full reload picks up the current index.html and asset - * manifest instead of retrying the same dead URL forever. + * Catches failures from lazy-loaded route chunks (LazyPage/React.lazy in + * App.tsx). A dynamic import() rejects when a chunk 404s, most commonly + * because the user has an old tab open across a new deploy and the hashed + * chunk URL no longer exists, or a flaky connection dropped the request. + * "Retry" clears the error, which swaps this boundary's rendered output + * back from the fallback to `children`: since that's a different subtree + * at this position, React remounts it from scratch, giving any LazyPage + * inside a fresh lazy component and a fresh import() attempt instead of + * replaying the same rejected one forever. That usually succeeds on a + * transient network blip. If the chunk itself is gone (stale deploy), the + * full reload picks up the current index.html and asset manifest instead + * of retrying the same dead URL forever. */ export class RouteErrorBoundary extends Component< RouteErrorBoundaryProps,