Skip to content

Commit 6a9fb75

Browse files
lxsmnsycclaudebrenelz
authored
feat(router): reload once when a route chunk fails to load (#2348)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
1 parent 2ff5882 commit 6a9fb75

6 files changed

Lines changed: 190 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/src/config/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ export interface SolidStartOptions {
6767
*/
6868
devOverlay?: boolean;
6969

70+
/**
71+
* Reload the page once when a route's code fails to load during client-side
72+
* navigation, such as when a deploy replaced it while the page was open.
73+
* Only applies to production builds.
74+
*
75+
* @default true
76+
*/
77+
reloadOnRouteChunkError?: boolean;
78+
7079
/**
7180
* Experimental features.
7281
*/
@@ -232,6 +241,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
232241
routeDir: "./routes",
233242
ssr: true,
234243
devOverlay: true,
244+
reloadOnRouteChunkError: true,
235245
experimental: {
236246
islands: false,
237247
},
@@ -374,6 +384,9 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
374384
"import.meta.env.START_CLIENT_ENTRY": JSON.stringify(handlers.client),
375385
"import.meta.env.START_CLIENT_ENTRY_URL": JSON.stringify(clientEntryUrl),
376386
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
387+
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
388+
start.reloadOnRouteChunkError,
389+
),
377390
"import.meta.env.SERVER_BASE_URL": JSON.stringify(resolveServerBaseUrl(config)),
378391
"import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || "json"),
379392
},

‎packages/start/src/env.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ interface SolidStartMetaEnv {
1313
START_CLIENT_ENTRY: string;
1414
START_CLIENT_ENTRY_URL: string;
1515
START_ISLANDS: boolean;
16+
START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean;
1617
// START_DEV_OVERLAY: boolean;
1718
}

‎packages/start/src/router.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { getRequestEvent, isServer } from "solid-js/web";
33

44
import { pageRoutes as routeConfigs } from "./server/routes.ts";
55
import type { PageEvent } from "./server/types.ts";
6+
import { withRouteChunkReload } from "./shared/route-chunk-reload.ts";
67

78
const components: Record<string, Component> = {};
89

910
export function createRoutes() {
1011
function createRoute(route: any) {
1112
const component =
12-
route.$component && (components[route.$component.src] ??= lazy(route.$component.import));
13+
route.$component && (components[route.$component.src] ??= lazy(loadComponent(route)));
1314

1415
return {
1516
...route,
@@ -26,6 +27,14 @@ export function createRoutes() {
2627
return routes;
2728
}
2829

30+
function loadComponent(route: any): () => Promise<any> {
31+
// A route's chunk can go missing when a deploy replaces it while the page is
32+
// open. Reloading picks up the new version.
33+
return !isServer && import.meta.env.PROD && import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR
34+
? withRouteChunkReload(route.$component.import)
35+
: route.$component.import;
36+
}
37+
2938
let routes: any[];
3039

3140
/**
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.ts";
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)