From 5ff9534db505ff4043136f6c4f69fc0c6e45e12f Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 11:37:25 +0800 Subject: [PATCH] feat(router): reload once when a route chunk fails to load When a deploy replaces a route's chunk while a page is open, navigating to that route fails to import its component and ends on the error page until the user refreshes by hand. Wrap the route component import in production. On failure, resolve with an empty component so the router finishes the navigation, then reload once the URL is the destination. A failure within 10 seconds of the last reload is rethrown, so a chunk that is still missing shows the error instead of reloading in a loop. Add a reloadOnRouteChunkError option, on by default, to turn it off. Closes #1644. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/route-chunk-reload.md | 5 + packages/start/src/config/index.ts | 13 +++ packages/start/src/env.d.ts | 1 + packages/start/src/router.tsx | 11 ++- .../src/shared/route-chunk-reload.spec.ts | 98 +++++++++++++++++++ .../start/src/shared/route-chunk-reload.ts | 63 ++++++++++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 .changeset/route-chunk-reload.md create mode 100644 packages/start/src/shared/route-chunk-reload.spec.ts create mode 100644 packages/start/src/shared/route-chunk-reload.ts diff --git a/.changeset/route-chunk-reload.md b/.changeset/route-chunk-reload.md new file mode 100644 index 000000000..33c0e6a62 --- /dev/null +++ b/.changeset/route-chunk-reload.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +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`. diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 658391c81..ebc37e2ee 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -67,6 +67,15 @@ export interface SolidStartOptions { */ devOverlay?: boolean; + /** + * 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. + * Only applies to production builds. + * + * @default true + */ + reloadOnRouteChunkError?: boolean; + /** * Experimental features. */ @@ -232,6 +241,7 @@ export function solidStart(options?: SolidStartOptions): Array { routeDir: "./routes", ssr: true, devOverlay: true, + reloadOnRouteChunkError: true, experimental: { islands: false, }, @@ -374,6 +384,9 @@ export function solidStart(options?: SolidStartOptions): Array { "import.meta.env.START_CLIENT_ENTRY": JSON.stringify(handlers.client), "import.meta.env.START_CLIENT_ENTRY_URL": JSON.stringify(clientEntryUrl), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), + "import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify( + start.reloadOnRouteChunkError, + ), "import.meta.env.SERVER_BASE_URL": JSON.stringify(resolveServerBaseUrl(config)), "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || "json"), }, diff --git a/packages/start/src/env.d.ts b/packages/start/src/env.d.ts index da45f74d6..17cadf9f2 100644 --- a/packages/start/src/env.d.ts +++ b/packages/start/src/env.d.ts @@ -13,5 +13,6 @@ interface SolidStartMetaEnv { START_CLIENT_ENTRY: string; START_CLIENT_ENTRY_URL: string; START_ISLANDS: boolean; + START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean; // START_DEV_OVERLAY: boolean; } diff --git a/packages/start/src/router.tsx b/packages/start/src/router.tsx index 980942739..731fae378 100644 --- a/packages/start/src/router.tsx +++ b/packages/start/src/router.tsx @@ -3,13 +3,14 @@ import { getRequestEvent, isServer } from "solid-js/web"; import { pageRoutes as routeConfigs } from "./server/routes.ts"; import type { PageEvent } from "./server/types.ts"; +import { withRouteChunkReload } from "./shared/route-chunk-reload.ts"; const components: Record = {}; export function createRoutes() { function createRoute(route: any) { const component = - route.$component && (components[route.$component.src] ??= lazy(route.$component.import)); + route.$component && (components[route.$component.src] ??= lazy(loadComponent(route))); return { ...route, @@ -26,6 +27,14 @@ export function createRoutes() { return routes; } +function loadComponent(route: any): () => Promise { + // A route's chunk can go missing when a deploy replaces it while the page is + // open. Reloading picks up the new version. + return !isServer && import.meta.env.PROD && import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR + ? withRouteChunkReload(route.$component.import) + : route.$component.import; +} + let routes: any[]; /** diff --git a/packages/start/src/shared/route-chunk-reload.spec.ts b/packages/start/src/shared/route-chunk-reload.spec.ts new file mode 100644 index 000000000..ab429c93d --- /dev/null +++ b/packages/start/src/shared/route-chunk-reload.spec.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withRouteChunkReload } from "./route-chunk-reload.ts"; + +function createStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + }; +} + +const failingLoad = () => + Promise.reject(new TypeError("Failed to fetch dynamically imported module")); + +describe("withRouteChunkReload", () => { + let location: { href: string; reload: ReturnType }; + + beforeEach(() => { + vi.useFakeTimers(); + location = { href: "http://localhost/", reload: vi.fn() }; + vi.stubGlobal("location", location); + vi.stubGlobal("sessionStorage", createStorage()); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("returns the module when the import succeeds", async () => { + const module = { default: () => null }; + + expect(await withRouteChunkReload(() => Promise.resolve(module))()).toBe(module); + await vi.runAllTimersAsync(); + expect(location.reload).not.toHaveBeenCalled(); + }); + + it("resolves with an empty component and reloads once the router reaches the destination", async () => { + const module = (await withRouteChunkReload(failingLoad)()) as { default: () => unknown }; + expect(module.default()).toBeNull(); + + // The router has not finished the navigation yet. + await vi.advanceTimersByTimeAsync(100); + expect(location.reload).not.toHaveBeenCalled(); + + // It finishes, so the URL is the destination. + location.href = "http://localhost/about"; + await vi.advanceTimersByTimeAsync(50); + expect(location.reload).toHaveBeenCalledTimes(1); + }); + + it("reloads after the timeout when the URL does not change", async () => { + await withRouteChunkReload(failingLoad)(); + + await vi.advanceTimersByTimeAsync(900); + expect(location.reload).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + expect(location.reload).toHaveBeenCalledTimes(1); + }); + + it("rethrows a second failure shortly after a reload instead of reloading again", async () => { + const load = withRouteChunkReload(failingLoad); + + await load(); + await expect(load()).rejects.toThrow("Failed to fetch dynamically imported module"); + await vi.runAllTimersAsync(); + expect(location.reload).toHaveBeenCalledTimes(1); + }); + + it("reloads again once the earlier reload is long enough ago", async () => { + const load = withRouteChunkReload(failingLoad); + + await load(); + await vi.runAllTimersAsync(); + vi.setSystemTime(Date.now() + 60_000); + await load(); + await vi.runAllTimersAsync(); + + expect(location.reload).toHaveBeenCalledTimes(2); + }); + + it("rethrows without reloading when sessionStorage is unavailable", async () => { + vi.stubGlobal("sessionStorage", { + getItem() { + throw new Error("SecurityError"); + }, + setItem() { + throw new Error("SecurityError"); + }, + }); + + await expect(withRouteChunkReload(failingLoad)()).rejects.toThrow(); + await vi.runAllTimersAsync(); + expect(location.reload).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/start/src/shared/route-chunk-reload.ts b/packages/start/src/shared/route-chunk-reload.ts new file mode 100644 index 000000000..98bb37ec6 --- /dev/null +++ b/packages/start/src/shared/route-chunk-reload.ts @@ -0,0 +1,63 @@ +const STORAGE_KEY = "solid-start:route-chunk-reload"; + +/** A failure within this time of the last reload is not reloaded again. */ +const RELOAD_WINDOW_MS = 10_000; + +/** How long to wait for the router to move to the destination URL. */ +const NAVIGATION_TIMEOUT_MS = 1_000; + +/** + * Wraps the import of a route's component. If the import fails, as it does + * when a deploy replaced the route's chunk while the page was open, the page + * reloads once to load the new version. + * + * The router only moves to the destination URL once the route has loaded, so + * the failed import resolves with an empty component instead. The router then + * finishes the navigation, and the page reloads at the destination. + * + * A second failure within `RELOAD_WINDOW_MS` is rethrown, so a chunk that is + * still missing after the reload shows the normal error instead of reloading + * in a loop. + * + * @see https://github.com/solidjs/solid-start/issues/1644 + */ +export function withRouteChunkReload(load: () => Promise): () => Promise { + return () => + load().catch(error => { + if (!recordReload()) throw error; + console.warn("[solid-start] A route failed to load, reloading the page.", error); + reloadAfterNavigation(location.href); + return { default: () => null } as T; + }); +} + +/** Reloads once the URL has changed from `from`, or after `NAVIGATION_TIMEOUT_MS`. */ +function reloadAfterNavigation(from: string): void { + const start = Date.now(); + const check = () => { + if (location.href !== from || Date.now() - start >= NAVIGATION_TIMEOUT_MS) { + location.reload(); + } else { + setTimeout(check, 20); + } + }; + setTimeout(check, 0); +} + +/** + * Records a reload. Returns false when one already happened within + * `RELOAD_WINDOW_MS`, or when it cannot be recorded. + */ +function recordReload(): boolean { + try { + const now = Date.now(); + const last = Number(sessionStorage.getItem(STORAGE_KEY)); + if (last && now - last < RELOAD_WINDOW_MS) return false; + sessionStorage.setItem(STORAGE_KEY, String(now)); + return true; + } catch { + // Without storage, a repeated failure looks like a first one, which could + // reload forever. + return false; + } +}