From 228106ed004e63d3c0f1fc5da754b52f3af21e30 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 10:00:02 +0800 Subject: [PATCH] feat(start): reload the page once when a chunk fails to load - New start.reloadOnChunkError option, on by default. - The generated client entry of a production build listens for Vite's vite:preloadError event and reloads the page, as SolidStart's reloadOnRouteChunkError does. - A second failure within 10 seconds does not reload again. The last reload time is kept in sessionStorage. - A listener that calls preventDefault() skips the reload. - New chunk-reload mode in the start-ssr e2e suite. Co-Authored-By: Claude Opus 5.5 --- .changeset/reload-on-chunk-error.md | 5 + README.md | 25 +- examples/start-ssr/src/ChunkReloadApp.tsx | 30 +++ examples/start-ssr/src/ChunkReloadLazy.ts | 3 + examples/start-ssr/test/run.mjs | 263 +++++++++++++++++++++- examples/start-ssr/vite.config.ts | 14 ++ src/ssr/index.ts | 55 +++++ 7 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 .changeset/reload-on-chunk-error.md create mode 100644 examples/start-ssr/src/ChunkReloadApp.tsx create mode 100644 examples/start-ssr/src/ChunkReloadLazy.ts diff --git a/.changeset/reload-on-chunk-error.md b/.changeset/reload-on-chunk-error.md new file mode 100644 index 0000000..9f4ee84 --- /dev/null +++ b/.changeset/reload-on-chunk-error.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +New `start.reloadOnChunkError` option, on by default. In a production build, the generated client entry reloads the page once when a code-split chunk fails to load, as it does when a deploy replaced the chunk while the page was open. A second failure within 10 seconds does not reload again. Set it to `false` to turn it off. diff --git a/README.md b/README.md index 66f402e..7d0fb4b 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ The object form carries the options (`start: true` is pure sugar for `start: {}` — both mean the identical start mode with defaults, and `false`/absent means off): `app`, `document`, `entryServer`, `entryClient`, `middleware`, `setup`, `instrument`, `clientSetup`, `renderMode`, `env`, -`devtools`, `errorBoundary`, +`devtools`, `errorBoundary`, `reloadOnChunkError`, `css`, `external`, `node`, all documented below. Install `@solidjs/start-devtools` as a development dependency to add the @@ -571,7 +571,7 @@ runs before it hydrates (SSR mode) or mounts (client mode) the app. Use it for browser setup that must be in place before the first render: - a server-function transport wrapper or request-header hook, -- a `vite:preloadError` listener, +- a `vite:preloadError` listener of your own (see `reloadOnChunkError`), - an error reporter's `init()`. ```ts @@ -579,7 +579,8 @@ for browser setup that must be in place before the first render: solid({ start: { clientSetup: './src/client-setup.ts' }, ssr: true }); // src/client-setup.ts -window.addEventListener('vite:preloadError', () => window.location.reload()); +import { initErrorReporter } from './error-reporter'; +initErrorReporter(); ``` How it runs: @@ -751,6 +752,24 @@ generated-entry codegen. Disable it with `start: { errorBoundary: false }` when application middleware owns error handling (an error middleware only sees the throw when no boundary catches it first). Default: `true`. +**`reloadOnChunkError`** reloads the page once when a code-split chunk fails +to load. This happens when a deploy replaced the chunk while the page was +open. The generated client entry listens for Vite's `vite:preloadError` +event, which fires when a dynamic import or its preloaded files fail. + +- A second failure within 10 seconds of the reload does not reload again. + A chunk that is still missing shows the normal error instead of reloading + in a loop. The time of the last reload is kept in `sessionStorage`, and + without storage the page never reloads. +- A listener that calls `event.preventDefault()` turns the reload off for + that failure. A listener added in `clientSetup` runs first. +- The failed import still rejects, so the app's own error handling runs + until the page reloads. +- It applies to production builds with a generated client entry. An + authored `entry-client` can add the listener itself. + +Disable it with `start: { reloadOnChunkError: false }`. Default: `true`. + **`css.filter`** — include/exclude patterns ([picomatch](https://github.com/micromatch/picomatch) globs or regexes; relative globs resolve against the Vite root) for the module graphs the dev diff --git a/examples/start-ssr/src/ChunkReloadApp.tsx b/examples/start-ssr/src/ChunkReloadApp.tsx new file mode 100644 index 0000000..61ffbf8 --- /dev/null +++ b/examples/start-ssr/src/ChunkReloadApp.tsx @@ -0,0 +1,30 @@ +// App root for the chunk-reload mode (SSR_CHUNK_RELOAD): the real App plus a +// button that loads src/ChunkReloadLazy.ts through a dynamic import. The test +// deletes that chunk from the build before clicking, the way a deploy +// replaces it while a page is open. +import { createSignal } from 'solid-js'; +import App from './App'; + +export default function ChunkReloadApp() { + const [status, setStatus] = createSignal('idle'); + // `await`, not `import().then(onLoad, onError)`: Vite wraps a chained + // `.then()` in its preload helper too, so that rejection handler would + // catch the error before `vite:preloadError` fires. + const load = async () => { + try { + const mod = await import('./ChunkReloadLazy'); + setStatus(mod.default()); + } catch { + setStatus('failed'); + } + }; + return ( + <> + + +

{status()}

+ + ); +} diff --git a/examples/start-ssr/src/ChunkReloadLazy.ts b/examples/start-ssr/src/ChunkReloadLazy.ts new file mode 100644 index 0000000..27c7c19 --- /dev/null +++ b/examples/start-ssr/src/ChunkReloadLazy.ts @@ -0,0 +1,3 @@ +// Code-split chunk for the chunk-reload mode. The marker locates the chunk +// file in dist/client/assets. +export default () => 'CHUNK-RELOAD-LAZY-loaded'; diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 9fef49a..e2b23b8 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -162,6 +162,10 @@ // the app graph and finishes its top-level await before hydrate(); an // edit reloads the page; an authored entry-client with the option is a // config error (see runClientSetupMode). +// - `start.reloadOnChunkError` (chunk-reload mode, SSR_CHUNK_RELOAD): in a +// production build a failed chunk load reloads the page once, a second +// failure within 10 seconds does not, and `false` leaves the listener +// out (see runChunkReloadMode). // - `performanceTracks` (on by default): under dev serve the generated and // authored client entries import the plugin's virtual module ahead of // everything else, the served module calls enablePerformanceTracks(), @@ -173,7 +177,7 @@ // // Requires the plugin built (pnpm build at the repo root) and Google Chrome. // Usage: node test/run.mjs -// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|nonce|base|builder-order|builder-prepare|extra-input|file-names|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node|client-setup] +// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|nonce|base|builder-order|builder-prepare|extra-input|file-names|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node|client-setup|chunk-reload] // (default: all) import { spawn, execSync, execFileSync } from 'node:child_process'; @@ -186,6 +190,7 @@ import { rmSync, existsSync, readdirSync, + renameSync, readFileSync, writeFileSync, } from 'node:fs'; @@ -7338,6 +7343,260 @@ async function runClientSetupMode() { } } +// `start.reloadOnChunkError`: the generated client entry of a production +// build reloads the page once when a code-split chunk fails to load. The +// test deletes the chunk src/ChunkReloadApp.tsx loads on click, the way a +// deploy replaces it while a page is open, then asserts in the browser: +// - the first failure reloads the page, +// - a second failure within the 10-second window does not reload again, +// - with the chunk back, the import works and nothing reloads, +// - `reloadOnChunkError: false` leaves the listener out entirely. +async function runChunkReloadMode() { + const mode = 'chunk-reload'; + console.log(`\n=== ${mode.toUpperCase()} ===`); + const port = 3189; + const origin = `http://localhost:${port}`; + const assetsDir = path.join(exampleDir, 'dist/client/assets'); + const LISTENER_MARKER = 'solid:chunk-reload'; + const findAssets = (marker) => + readdirSync(assetsDir).filter( + (f) => f.endsWith('.js') && readFileSync(path.join(assetsDir, f), 'utf-8').includes(marker), + ); + + // Polls an expression that may throw while the page is reloading. + const waitForSafe = async (cdp, expression, timeoutMs = 10000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + if (await cdp.evalJs(expression)) return true; + } catch {} + await new Promise((r) => setTimeout(r, 100)); + } + return false; + }; + // Clicks the counter until it moves, which proves the page hydrated. + const waitHydrated = async (cdp) => { + await waitForSafe( + cdp, + 'document.readyState === "complete" && !!document.querySelector("#increment")', + ); + return waitForSafe( + cdp, + '(document.querySelector("#increment").click(), document.querySelector("#count")?.textContent !== "0")', + ); + }; + + const withChrome = async (run) => { + const chrome = startProcess(CHROME, [ + '--headless=new', + `--remote-debugging-port=${CDP_PORT}`, + `--user-data-dir=/tmp/start-ssr-chrome-${mode}`, + '--no-first-run', + '--disable-extensions', + 'about:blank', + ]); + const cdp = await connectChrome(); + try { + await run(cdp); + } finally { + cdp.close(); + const exited = new Promise((r) => chrome.once('exit', r)); + try { + process.kill(-chrome.pid, 'SIGTERM'); + } catch {} + await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]); + try { + rmSync(`/tmp/start-ssr-chrome-${mode}`, { recursive: true, force: true, maxRetries: 5 }); + } catch {} + } + }; + + let server; + let serverLog = ''; + const startServer = (env) => { + server = startProcess('node', ['server.js'], { + cwd: exampleDir, + env: { ...env, PORT: String(port), NODE_ENV: 'production' }, + }); + server.stdout.on('data', (d) => (serverLog += d)); + server.stderr.on('data', (d) => (serverLog += d)); + return waitForHttp(origin + '/', 30000, { headers: { accept: 'text/html' } }); + }; + const stopServer = () => { + try { + process.kill(-server.pid, 'SIGTERM'); + } catch {} + server = undefined; + }; + // Moves the lazy chunk out of the build and returns a function that puts + // it back. + const hideChunk = () => { + const [chunk] = findAssets('CHUNK-RELOAD-LAZY'); + if (!chunk) throw new Error('lazy chunk not found in dist/client/assets'); + const file = path.join(assetsDir, chunk); + renameSync(file, file + '.hidden'); + return () => renameSync(file + '.hidden', file); + }; + let restoreChunk; + + try { + // ---- Dev: no listener --------------------------------------------- + const { default: solidPlugin } = await import('@solidjs/vite-plugin'); + const probe = await createServer({ + root: exampleDir, + configFile: false, + logLevel: 'silent', + plugins: [solidPlugin({ ssr: true, start: { devtools: false } })], + server: { middlewareMode: true }, + }); + try { + const loaded = await probe.environments.client.pluginContainer.load( + 'virtual:solid-ssr-entry-client.tsx', + ); + const code = (typeof loaded === 'string' ? loaded : loaded?.code) || ''; + record( + mode, + 'dev', + 'the dev client entry has no reload listener', + code.includes('hydrate(') && !code.includes(LISTENER_MARKER), + code.slice(0, 600), + ); + } finally { + await probe.close(); + } + + // ---- Prod: default on --------------------------------------------- + const env = { ...process.env, SSR_CHUNK_RELOAD: '1', SSR_DEVTOOLS: '0' }; + console.log(' building…'); + execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env }); + const withListener = findAssets(LISTENER_MARKER); + record( + mode, + 'build', + 'only the client entry chunk carries the reload listener', + withListener.length === 1 && withListener[0].includes('entry-client'), + withListener.join(', '), + ); + await startServer(env); + await withChrome(async (cdp) => { + await cdp.send('Page.navigate', { url: origin + '/' }); + record(mode, 'prod', 'page hydrated', await waitHydrated(cdp)); + + restoreChunk = hideChunk(); + await cdp.evalJs('window.__beforeReload = true'); + await cdp.evalJs('document.querySelector("#load-chunk").click()'); + const reloaded = await waitForSafe( + cdp, + 'window.__beforeReload === undefined && document.readyState === "complete"', + ); + record( + mode, + 'prod', + 'a failed chunk load reloads the page', + reloaded, + reloaded + ? '' + : `status: ${await cdp.evalJs('document.querySelector("#chunk-status")?.textContent').catch(() => '?')}; ` + + `requests: ${cdp.requests.filter((u) => u.includes('ChunkReload')).join(', ')}; ` + + `errors: ${cdp.exceptions.join(' | ')}`, + ); + const stamp = await waitForSafe( + cdp, + `Number(sessionStorage.getItem(${JSON.stringify(LISTENER_MARKER)})) > 0`, + ); + record(mode, 'prod', 'the reload time is kept in sessionStorage', stamp); + + await waitHydrated(cdp); + await cdp.evalJs('window.__beforeReload = true'); + await cdp.evalJs('document.querySelector("#load-chunk").click()'); + const failed = await waitForSafe( + cdp, + 'document.querySelector("#chunk-status")?.textContent === "failed"', + ); + await new Promise((r) => setTimeout(r, 1500)); + const stayed = await cdp.evalJs('window.__beforeReload === true').catch(() => false); + record( + mode, + 'prod', + 'a second failure within the window does not reload again (the import rejects)', + failed && stayed, + `failed: ${failed}, stayed: ${stayed}`, + ); + + restoreChunk(); + restoreChunk = undefined; + await cdp.evalJs('sessionStorage.clear()'); + await cdp.send('Page.navigate', { url: origin + '/' }); + await waitHydrated(cdp); + await cdp.evalJs('window.__beforeReload = true'); + await cdp.evalJs('document.querySelector("#load-chunk").click()'); + const loaded = await waitForSafe( + cdp, + 'document.querySelector("#chunk-status")?.textContent === "CHUNK-RELOAD-LAZY-loaded"', + ); + const noReload = await cdp.evalJs('window.__beforeReload === true').catch(() => false); + record( + mode, + 'prod', + 'with the chunk present the import works and nothing reloads', + loaded && noReload, + `loaded: ${loaded}, noReload: ${noReload}`, + ); + }); + stopServer(); + + // ---- Prod: reloadOnChunkError: false ------------------------------ + const offEnv = { ...env, SSR_CHUNK_RELOAD: 'off' }; + console.log(' building (reloadOnChunkError: false)…'); + execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env: offEnv }); + record( + mode, + 'build-off', + 'no chunk carries the reload listener', + findAssets(LISTENER_MARKER).length === 0, + findAssets(LISTENER_MARKER).join(', '), + ); + await startServer(offEnv); + await withChrome(async (cdp) => { + await cdp.send('Page.navigate', { url: origin + '/' }); + await waitHydrated(cdp); + restoreChunk = hideChunk(); + await cdp.evalJs('window.__beforeReload = true'); + await cdp.evalJs('document.querySelector("#load-chunk").click()'); + const failed = await waitForSafe( + cdp, + 'document.querySelector("#chunk-status")?.textContent === "failed"', + ); + await new Promise((r) => setTimeout(r, 1500)); + const stayed = await cdp.evalJs('window.__beforeReload === true').catch(() => false); + record( + mode, + 'prod-off', + 'a failed chunk load does not reload the page', + failed && stayed, + `failed: ${failed}, stayed: ${stayed}`, + ); + restoreChunk(); + restoreChunk = undefined; + }); + } catch (e) { + record( + mode, + 'run', + 'mode completed', + false, + String(e?.stack ?? e) + (serverLog ? `\nserver: ${serverLog.slice(-2000)}` : ''), + ); + } finally { + restoreChunk?.(); + if (server) stopServer(); + // Leave dist in the standard state for anyone poking at it. + try { + execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe' }); + } catch {} + } +} + const ALL_MODES = [ 'dev', 'prod', @@ -7365,6 +7624,7 @@ const ALL_MODES = [ 'vitest', 'node', 'client-setup', + 'chunk-reload', ]; const arg = process.argv[2]; const modes = ALL_MODES.includes(arg) ? [arg] : ALL_MODES; @@ -7394,6 +7654,7 @@ for (const mode of modes) { else if (mode === 'vitest') await runVitestMode(); else if (mode === 'node') await runNodeMode(); else if (mode === 'client-setup') await runClientSetupMode(); + else if (mode === 'chunk-reload') await runChunkReloadMode(); else await runDetectMode(); } diff --git a/examples/start-ssr/vite.config.ts b/examples/start-ssr/vite.config.ts index 65f1e49..93faebc 100644 --- a/examples/start-ssr/vite.config.ts +++ b/examples/start-ssr/vite.config.ts @@ -34,6 +34,9 @@ import solidPlugin from '@solidjs/vite-plugin'; // - SSR_CLIENT_SETUP=1 wires src/client-setup.ts through `start.clientSetup` // (client-setup mode) and wraps App in src/ClientSetupApp.tsx, which // records whether the setup module settled before hydration rendered. +// - SSR_CHUNK_RELOAD=1 swaps in src/ChunkReloadApp.tsx (chunk-reload mode), +// whose button loads a code-split chunk. `off` also sets +// `start.reloadOnChunkError: false`. // - SOLID_OBSERVE=1 (observe mode) turns on `observe`: the `observe` export // condition everywhere and the compiler's `sourceNames` for both // postures, so the built server AND client bundles carry component labels. @@ -239,6 +242,17 @@ export default defineConfig({ ...(process.env.SSR_CLIENT_SETUP ? { clientSetup: './src/client-setup.ts', app: './src/ClientSetupApp.tsx' } : {}), + // SSR_CHUNK_RELOAD (chunk-reload mode): src/ChunkReloadApp.tsx + // adds a button that loads a code-split chunk. `off` also sets + // `start.reloadOnChunkError: false`. + ...(process.env.SSR_CHUNK_RELOAD + ? { + app: './src/ChunkReloadApp.tsx', + ...(process.env.SSR_CHUNK_RELOAD === 'off' + ? { reloadOnChunkError: false } + : {}), + } + : {}), // SSR_RENDER_MODE (render-mode mode): `middleware` → the // per-request policy in src/middleware.ts (`event.renderMode`); // anything else verbatim (`async`, or an invalid value / a diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 805df8d..5e35791 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -342,6 +342,27 @@ export interface StartOptions { * @default true */ errorBoundary?: boolean; + /** + * Reload the page once when a code-split chunk fails to load, as it does + * when a deploy replaced the chunk while the page was open. The generated + * client entry listens for Vite's `vite:preloadError` event, which fires + * when a dynamic import or its preloaded files fail. + * + * - A second failure within 10 seconds of the reload does not reload + * again, so a chunk that is still missing shows the normal error + * instead of reloading in a loop. The time of the last reload is kept + * in `sessionStorage`. Without storage the page never reloads. + * - A listener that calls `event.preventDefault()` turns the reload off + * for that failure. A listener added in `start.clientSetup` runs first. + * - The failed import still rejects, so the app's own error handling + * runs until the page reloads. + * + * Applies to production builds and generated client entries only. An + * authored `entry-client` can add the listener itself. + * + * @default true + */ + reloadOnChunkError?: boolean; /** * Let a host integration own the server environment — build wiring and * HTTP serving alike. The plugin skips its start-mode server-build config and @@ -435,6 +456,11 @@ const ENTRY_CLIENT_ID = 'virtual:solid-ssr-entry-client.tsx'; const DOCUMENT_ID = 'virtual:solid-ssr-document.tsx'; const ERROR_BOUNDARY_ID = 'virtual:solid-ssr-error-boundary.tsx'; +// `start.reloadOnChunkError`: the sessionStorage key holding the time of the +// last reload, and how long after it a failure does not reload again. +const CHUNK_RELOAD_KEY = 'solid:chunk-reload'; +const CHUNK_RELOAD_WINDOW_MS = 10_000; + const MANIFEST_ID = 'virtual:solid-manifest'; const SERVER_FUNCTION_HANDLER_ID = 'virtual:solid-server-function-handler'; const STORAGE_SOURCE = '@solidjs/web/storage'; @@ -719,6 +745,7 @@ export function startServe( // codegen: with the option off, none of these imports exist anywhere. const serverComponents = !!internal.serverComponents; const errorBoundary = options.errorBoundary !== false; + const reloadOnChunkError = options.reloadOnChunkError !== false; const styleFilter = internal.styleFilter; // `'auto'` resolves against the project root in configResolved, before // any of the (lazy) uses in entry codegen and the entry transform. @@ -930,6 +957,32 @@ export function startServe( : []; } + // `start.reloadOnChunkError`: added to the generated client entry body, so + // a listener from `start.clientSetup` (imported earlier) runs first and + // can call preventDefault() to keep the page from reloading. The listener + // never calls preventDefault() itself: the failed import still rejects. + function chunkReloadListener(): string[] { + if (!isBuild || !reloadOnChunkError) return []; + return [ + `window.addEventListener('vite:preloadError', (event) => {`, + ` if (event.defaultPrevented) return;`, + ` try {`, + ` const now = Date.now();`, + ` const last = Number(sessionStorage.getItem(${JSON.stringify(CHUNK_RELOAD_KEY)}));`, + ` if (last && now - last < ${CHUNK_RELOAD_WINDOW_MS}) return;`, + ` sessionStorage.setItem(${JSON.stringify(CHUNK_RELOAD_KEY)}, String(now));`, + ` } catch {`, + // Without storage a repeated failure looks like a first one, which + // could reload forever. + ` return;`, + ` }`, + ` console.warn('[@solidjs/vite-plugin] A chunk failed to load, reloading the page.', event.payload);`, + ` location.reload();`, + `});`, + ``, + ]; + } + function documentTree(root: string, wrapper?: string): string[] { const content = wrapper ? `<${wrapper}><${root} />` : `<${root} />`; return isBuild && errorBoundary @@ -1087,6 +1140,7 @@ export function startServe( ...(toolbar ? [`import { DevToolbar } from ${JSON.stringify(DEVTOOLS_PACKAGE)};`] : []), `import App from ${JSON.stringify(app)};`, ``, + ...chunkReloadListener(), `render(() => ${ isBuild && errorBoundary ? '' @@ -1109,6 +1163,7 @@ export function startServe( `import Document from ${JSON.stringify(documentSpec())};`, `import App from ${JSON.stringify(app)};`, ``, + ...chunkReloadListener(), ...(serverComponents ? [ // Installs the t=0 document-adoption registry and the transport