mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
96e4ff5932
* feat(client): retry a failed page chunk once before giving up A dynamic import fails for two different reasons, and only one of them is worth a second attempt. If the file is gone because a deploy happened while the tab was open, nothing but a reload helps and reloadOnceForChunk already owns that path. If the fetch itself went wrong — hotel wifi, an aborted request, a service worker handing back a truncated entry — one retry fixes it. The retry cannot reuse the original specifier, since Vite rewrites it into a hashed asset URL at build time; the only usable URL is the one the browser names in the failure message, and a fresh query on it misses both the HTTP cache and the Workbox precache. This is also where a healthy load is acknowledged. Clearing the reload marker used to sit in main.tsx right after render(), which runs before React commits and long before any chunk is fetched, so the marker was gone before it was ever needed. * perf(client): load the page chunks on demand Every one of the twenty pages was a static import, so the entry chunk carried the planner, the journal, the atlas and the vacation planner for anyone who only opened the dashboard. They now load through lazyWithRetry; the entry chunk drops from 5,183 kB to 1,243 kB (gzip 1,300 -> 349). LoginPage stays static on purpose: anyone logged out lands there, and every other route redirects there first. The Suspense boundary sits above <Routes> rather than inside ProtectedRoute so it stays mounted across navigations. react-router runs location updates inside a transition, which keeps the current page on screen while the next chunk arrives instead of flashing a spinner on every jump. The precache is unchanged by design: workbox picks up the new chunks, so the install size stays where it was (107 -> 178 entries, +31 KiB) and every route remains available offline. This buys first paint, not install size.
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
import React, { Suspense } from 'react'
|
|
import { render, screen } from '@testing-library/react'
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import ErrorBoundary from '../components/shared/ErrorBoundary'
|
|
import { lazyWithRetry, retryUrlFor } from './lazyWithRetry'
|
|
|
|
// Every case needs its own URL: the one-shot guard is a module-level Set that
|
|
// lives for the whole file.
|
|
const viteMessage = (url: string) => `Failed to fetch dynamically imported module: ${url}`
|
|
|
|
beforeEach(() => {
|
|
sessionStorage.clear()
|
|
})
|
|
|
|
describe('retryUrlFor', () => {
|
|
it('FE-UTIL-LAZYRETRY-001: busts the cache on the URL the browser named', () => {
|
|
const url = retryUrlFor(new Error(viteMessage('https://trek.example/assets/AtlasPage-a1b2.js')))
|
|
expect(url).toMatch(/^https:\/\/trek\.example\/assets\/AtlasPage-a1b2\.js\?t=\d+$/)
|
|
})
|
|
|
|
it('FE-UTIL-LAZYRETRY-002: retries a given chunk only once', () => {
|
|
const message = viteMessage('https://trek.example/assets/VacayPage-c3d4.js')
|
|
expect(retryUrlFor(new Error(message))).not.toBeNull()
|
|
// A second attempt would be the loop the route boundary is there to break.
|
|
expect(retryUrlFor(new Error(message))).toBeNull()
|
|
})
|
|
|
|
it('FE-UTIL-LAZYRETRY-003: leaves ordinary errors to the boundary', () => {
|
|
expect(retryUrlFor(new Error('Cannot read properties of undefined'))).toBeNull()
|
|
})
|
|
|
|
it('FE-UTIL-LAZYRETRY-004: gives up when the message names no URL', () => {
|
|
// Safari's wording, which carries no URL at all.
|
|
expect(retryUrlFor(new Error('Importing a module script failed.'))).toBeNull()
|
|
// And a relative path leaves nothing to bust.
|
|
expect(retryUrlFor(new Error(viteMessage('/assets/JourneyPage-e5f6.js')))).toBeNull()
|
|
})
|
|
|
|
it('FE-UTIL-LAZYRETRY-005: does not try to import a stylesheet', () => {
|
|
const css = new Error('Unable to preload CSS for https://trek.example/assets/PhotoLightbox-g7h8.css')
|
|
expect(retryUrlFor(css)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('lazyWithRetry', () => {
|
|
it('FE-UTIL-LAZYRETRY-006: renders the default export once the chunk arrives', async () => {
|
|
const Page = lazyWithRetry(async () => ({ default: () => <div>Atlas</div> }))
|
|
render(
|
|
<Suspense fallback={<span>loading</span>}>
|
|
<Page />
|
|
</Suspense>
|
|
)
|
|
expect(await screen.findByText('Atlas')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-UTIL-LAZYRETRY-007: clears the reload marker once a chunk has loaded', async () => {
|
|
// Set by reloadOnceForChunk() before the reload that got us here. Clearing it
|
|
// any earlier — main.tsx used to do it right after render() — would let a
|
|
// genuinely missing chunk reload the tab over and over.
|
|
sessionStorage.setItem('trek:chunk-reload', '1750000000000')
|
|
|
|
const Page = lazyWithRetry(async () => ({ default: () => <div>Dashboard</div> }))
|
|
render(
|
|
<Suspense fallback={<span>loading</span>}>
|
|
<Page />
|
|
</Suspense>
|
|
)
|
|
|
|
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
|
|
expect(sessionStorage.getItem('trek:chunk-reload')).toBeNull()
|
|
})
|
|
|
|
it('FE-UTIL-LAZYRETRY-008: hands a non-chunk failure to the boundary', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const Page = lazyWithRetry<React.ComponentType>(() => Promise.reject(new Error('boom')))
|
|
|
|
render(
|
|
<ErrorBoundary boundaryId="test" fallback={<div>caught</div>}>
|
|
<Suspense fallback={<span>loading</span>}>
|
|
<Page />
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
)
|
|
|
|
expect(await screen.findByText('caught')).toBeInTheDocument()
|
|
// The marker belongs to reloadOnceForChunk; a plain throw must not touch it.
|
|
expect(sessionStorage.getItem('trek:chunk-reload')).toBeNull()
|
|
})
|
|
})
|