Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reload-on-chunk-error.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -571,15 +571,16 @@ 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
// vite.config.ts
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:
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions examples/start-ssr/src/ChunkReloadApp.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<App />
<button id="load-chunk" onClick={load}>
Load chunk
</button>
<p id="chunk-status">{status()}</p>
</>
);
}
3 changes: 3 additions & 0 deletions examples/start-ssr/src/ChunkReloadLazy.ts
Original file line number Diff line number Diff line change
@@ -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';
263 changes: 262 additions & 1 deletion examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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';
Expand All @@ -186,6 +190,7 @@ import {
rmSync,
existsSync,
readdirSync,
renameSync,
readFileSync,
writeFileSync,
} from 'node:fs';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down
Loading
Loading