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/config/index.d.ts b/packages/start/config/index.d.ts index bb1613f64..edaa8ab3a 100644 --- a/packages/start/config/index.d.ts +++ b/packages/start/config/index.d.ts @@ -23,6 +23,14 @@ type SolidStartInlineConfig = { routeDir?: string; middleware?: string; 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?: { islands?: boolean; }; diff --git a/packages/start/config/index.js b/packages/start/config/index.js index 53d1946af..ba4349e91 100644 --- a/packages/start/config/index.js +++ b/packages/start/config/index.js @@ -81,6 +81,7 @@ export function defineConfig(baseConfig = {}) { routeDir: "./routes", ssr: true, devOverlay: true, + reloadOnRouteChunkError: true, experimental: { islands: false }, @@ -175,6 +176,9 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(true), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "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.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define } @@ -242,6 +246,9 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(false), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "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(server?.baseURL ?? ""), "import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define @@ -303,6 +310,9 @@ export function defineConfig(baseConfig = {}) { "import.meta.env.SSR": JSON.stringify(true), "import.meta.env.START_SSR": JSON.stringify(start.ssr), "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.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define } diff --git a/packages/start/env.d.ts b/packages/start/env.d.ts index 14f796189..b2435f4be 100644 --- a/packages/start/env.d.ts +++ b/packages/start/env.d.ts @@ -22,5 +22,6 @@ interface SolidStartMetaEnv { START_SSR: boolean; START_ISLANDS: boolean; START_DEV_OVERLAY: boolean; + START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean; SERVER_BASE_URL: string; } diff --git a/packages/start/src/router/lazyRoute.ts b/packages/start/src/router/lazyRoute.ts index ea39432d7..ddd1f3fc2 100644 --- a/packages/start/src/router/lazyRoute.ts +++ b/packages/start/src/router/lazyRoute.ts @@ -1,9 +1,11 @@ /// import { createComponent, lazy, onCleanup, type Component, type JSX } from "solid-js"; +import { isServer } from "solid-js/web"; import { appendStyles, cleanupStyles, preloadStyles, updateStyles } from "vinxi/css"; import { renderAsset } from "../server/renderAsset"; import { Asset } from "../server/types"; +import { withRouteChunkReload } from "../shared/route-chunk-reload"; export default function lazyRoute>(component: any, clientManifest: any, serverManifest: any, exported = "default") { return lazy(async () => { @@ -42,7 +44,13 @@ export default function lazyRoute>(component: any, }; return { default: Comp }; } else { - const mod = await component.import(); + // A route's chunk can go missing when a deploy replaces it while the page + // is open. Reloading picks up the new version. + const load = + !isServer && import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR + ? withRouteChunkReload(() => component.import()) + : () => component.import(); + const mod = await load(); const Component = mod[exported]; let assets = await clientManifest.inputs?.[component.src].assets(); const styles = assets.filter( 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..169fa395d --- /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"; + +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; + } +}