Skip to content

Commit 95ca993

Browse files
lxsmnsycclaude
andauthored
feat(1.x): reload once when a route chunk fails to load (#2349)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 92d9603 commit 95ca993

7 files changed

Lines changed: 194 additions & 1 deletion

File tree

‎.changeset/route-chunk-reload.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Reload the page once when a route's code fails to load during client-side navigation, such as when a deploy replaced it while the page was open. Before, the navigation ended on an error page until the user refreshed by hand. The page now reloads at the destination and loads the new version. A route that still fails right after the reload shows the error instead of reloading again. This only applies to production builds, and can be turned off with `reloadOnRouteChunkError: false`.

‎packages/start/config/index.d.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ type SolidStartInlineConfig = {
2323
routeDir?: string;
2424
middleware?: string;
2525
devOverlay?: boolean;
26+
/**
27+
* Reload the page once when a route's code fails to load during client-side
28+
* navigation, such as when a deploy replaced it while the page was open.
29+
* Only applies to production builds.
30+
*
31+
* @default true
32+
*/
33+
reloadOnRouteChunkError?: boolean;
2634
experimental?: {
2735
islands?: boolean;
2836
};

‎packages/start/config/index.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export function defineConfig(baseConfig = {}) {
8181
routeDir: "./routes",
8282
ssr: true,
8383
devOverlay: true,
84+
reloadOnRouteChunkError: true,
8485
experimental: {
8586
islands: false
8687
},
@@ -175,6 +176,9 @@ export function defineConfig(baseConfig = {}) {
175176
"import.meta.env.SSR": JSON.stringify(true),
176177
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
177178
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
179+
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
180+
start.reloadOnRouteChunkError
181+
),
178182
"import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode),
179183
...userConfig.define
180184
}
@@ -242,6 +246,9 @@ export function defineConfig(baseConfig = {}) {
242246
"import.meta.env.SSR": JSON.stringify(false),
243247
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
244248
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
249+
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
250+
start.reloadOnRouteChunkError
251+
),
245252
"import.meta.env.SERVER_BASE_URL": JSON.stringify(server?.baseURL ?? ""),
246253
"import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode),
247254
...userConfig.define
@@ -303,6 +310,9 @@ export function defineConfig(baseConfig = {}) {
303310
"import.meta.env.SSR": JSON.stringify(true),
304311
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
305312
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
313+
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
314+
start.reloadOnRouteChunkError
315+
),
306316
"import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode),
307317
...userConfig.define
308318
}

‎packages/start/env.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ interface SolidStartMetaEnv {
2222
START_SSR: boolean;
2323
START_ISLANDS: boolean;
2424
START_DEV_OVERLAY: boolean;
25+
START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean;
2526
SERVER_BASE_URL: string;
2627
}

‎packages/start/src/router/lazyRoute.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/// <reference types="vinxi/types/client" />
22
import { createComponent, lazy, onCleanup, type Component, type JSX } from "solid-js";
3+
import { isServer } from "solid-js/web";
34
import { appendStyles, cleanupStyles, preloadStyles, updateStyles } from "vinxi/css";
45

56
import { renderAsset } from "../server/renderAsset";
67
import { Asset } from "../server/types";
8+
import { withRouteChunkReload } from "../shared/route-chunk-reload";
79

810
export default function lazyRoute<T extends Record<string, any>>(component: any, clientManifest: any, serverManifest: any, exported = "default") {
911
return lazy(async () => {
@@ -42,7 +44,13 @@ export default function lazyRoute<T extends Record<string, any>>(component: any,
4244
};
4345
return { default: Comp };
4446
} else {
45-
const mod = await component.import();
47+
// A route's chunk can go missing when a deploy replaces it while the page
48+
// is open. Reloading picks up the new version.
49+
const load =
50+
!isServer && import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR
51+
? withRouteChunkReload(() => component.import())
52+
: () => component.import();
53+
const mod = await load();
4654
const Component = mod[exported];
4755
let assets = await clientManifest.inputs?.[component.src].assets();
4856
const styles = assets.filter(
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { withRouteChunkReload } from "./route-chunk-reload";
3+
4+
function createStorage() {
5+
const values = new Map<string, string>();
6+
return {
7+
getItem: (key: string) => values.get(key) ?? null,
8+
setItem: (key: string, value: string) => void values.set(key, value)
9+
};
10+
}
11+
12+
const failingLoad = () =>
13+
Promise.reject(new TypeError("Failed to fetch dynamically imported module"));
14+
15+
describe("withRouteChunkReload", () => {
16+
let location: { href: string; reload: ReturnType<typeof vi.fn> };
17+
18+
beforeEach(() => {
19+
vi.useFakeTimers();
20+
location = { href: "http://localhost/", reload: vi.fn() };
21+
vi.stubGlobal("location", location);
22+
vi.stubGlobal("sessionStorage", createStorage());
23+
vi.spyOn(console, "warn").mockImplementation(() => {});
24+
});
25+
26+
afterEach(() => {
27+
vi.unstubAllGlobals();
28+
vi.restoreAllMocks();
29+
vi.useRealTimers();
30+
});
31+
32+
it("returns the module when the import succeeds", async () => {
33+
const module = { default: () => null };
34+
35+
expect(await withRouteChunkReload(() => Promise.resolve(module))()).toBe(module);
36+
await vi.runAllTimersAsync();
37+
expect(location.reload).not.toHaveBeenCalled();
38+
});
39+
40+
it("resolves with an empty component and reloads once the router reaches the destination", async () => {
41+
const module = (await withRouteChunkReload(failingLoad)()) as { default: () => unknown };
42+
expect(module.default()).toBeNull();
43+
44+
// The router has not finished the navigation yet.
45+
await vi.advanceTimersByTimeAsync(100);
46+
expect(location.reload).not.toHaveBeenCalled();
47+
48+
// It finishes, so the URL is the destination.
49+
location.href = "http://localhost/about";
50+
await vi.advanceTimersByTimeAsync(50);
51+
expect(location.reload).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it("reloads after the timeout when the URL does not change", async () => {
55+
await withRouteChunkReload(failingLoad)();
56+
57+
await vi.advanceTimersByTimeAsync(900);
58+
expect(location.reload).not.toHaveBeenCalled();
59+
await vi.advanceTimersByTimeAsync(200);
60+
expect(location.reload).toHaveBeenCalledTimes(1);
61+
});
62+
63+
it("rethrows a second failure shortly after a reload instead of reloading again", async () => {
64+
const load = withRouteChunkReload(failingLoad);
65+
66+
await load();
67+
await expect(load()).rejects.toThrow("Failed to fetch dynamically imported module");
68+
await vi.runAllTimersAsync();
69+
expect(location.reload).toHaveBeenCalledTimes(1);
70+
});
71+
72+
it("reloads again once the earlier reload is long enough ago", async () => {
73+
const load = withRouteChunkReload(failingLoad);
74+
75+
await load();
76+
await vi.runAllTimersAsync();
77+
vi.setSystemTime(Date.now() + 60_000);
78+
await load();
79+
await vi.runAllTimersAsync();
80+
81+
expect(location.reload).toHaveBeenCalledTimes(2);
82+
});
83+
84+
it("rethrows without reloading when sessionStorage is unavailable", async () => {
85+
vi.stubGlobal("sessionStorage", {
86+
getItem() {
87+
throw new Error("SecurityError");
88+
},
89+
setItem() {
90+
throw new Error("SecurityError");
91+
}
92+
});
93+
94+
await expect(withRouteChunkReload(failingLoad)()).rejects.toThrow();
95+
await vi.runAllTimersAsync();
96+
expect(location.reload).not.toHaveBeenCalled();
97+
});
98+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
const STORAGE_KEY = "solid-start:route-chunk-reload";
2+
3+
/** A failure within this time of the last reload is not reloaded again. */
4+
const RELOAD_WINDOW_MS = 10_000;
5+
6+
/** How long to wait for the router to move to the destination URL. */
7+
const NAVIGATION_TIMEOUT_MS = 1_000;
8+
9+
/**
10+
* Wraps the import of a route's component. If the import fails, as it does
11+
* when a deploy replaced the route's chunk while the page was open, the page
12+
* reloads once to load the new version.
13+
*
14+
* The router only moves to the destination URL once the route has loaded, so
15+
* the failed import resolves with an empty component instead. The router then
16+
* finishes the navigation, and the page reloads at the destination.
17+
*
18+
* A second failure within `RELOAD_WINDOW_MS` is rethrown, so a chunk that is
19+
* still missing after the reload shows the normal error instead of reloading
20+
* in a loop.
21+
*
22+
* @see https://github.com/solidjs/solid-start/issues/1644
23+
*/
24+
export function withRouteChunkReload<T>(load: () => Promise<T>): () => Promise<T> {
25+
return () =>
26+
load().catch(error => {
27+
if (!recordReload()) throw error;
28+
console.warn("[solid-start] A route failed to load, reloading the page.", error);
29+
reloadAfterNavigation(location.href);
30+
return { default: () => null } as T;
31+
});
32+
}
33+
34+
/** Reloads once the URL has changed from `from`, or after `NAVIGATION_TIMEOUT_MS`. */
35+
function reloadAfterNavigation(from: string): void {
36+
const start = Date.now();
37+
const check = () => {
38+
if (location.href !== from || Date.now() - start >= NAVIGATION_TIMEOUT_MS) {
39+
location.reload();
40+
} else {
41+
setTimeout(check, 20);
42+
}
43+
};
44+
setTimeout(check, 0);
45+
}
46+
47+
/**
48+
* Records a reload. Returns false when one already happened within
49+
* `RELOAD_WINDOW_MS`, or when it cannot be recorded.
50+
*/
51+
function recordReload(): boolean {
52+
try {
53+
const now = Date.now();
54+
const last = Number(sessionStorage.getItem(STORAGE_KEY));
55+
if (last && now - last < RELOAD_WINDOW_MS) return false;
56+
sessionStorage.setItem(STORAGE_KEY, String(now));
57+
return true;
58+
} catch {
59+
// Without storage, a repeated failure looks like a first one, which could
60+
// reload forever.
61+
return false;
62+
}
63+
}

0 commit comments

Comments
 (0)