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/route-chunk-reload.md
Original file line number Diff line number Diff line change
@@ -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`.
13 changes: 13 additions & 0 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -232,6 +241,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
routeDir: "./routes",
ssr: true,
devOverlay: true,
reloadOnRouteChunkError: true,
experimental: {
islands: false,
},
Expand Down Expand Up @@ -374,6 +384,9 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
"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"),
},
Expand Down
1 change: 1 addition & 0 deletions packages/start/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 10 additions & 1 deletion packages/start/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Component> = {};

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,
Expand All @@ -26,6 +27,14 @@ export function createRoutes() {
return routes;
}

function loadComponent(route: any): () => Promise<any> {
// 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[];

/**
Expand Down
98 changes: 98 additions & 0 deletions packages/start/src/shared/route-chunk-reload.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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<typeof vi.fn> };

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();
});
});
63 changes: 63 additions & 0 deletions packages/start/src/shared/route-chunk-reload.ts
Original file line number Diff line number Diff line change
@@ -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<T>(load: () => Promise<T>): () => Promise<T> {
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;
}
}
Loading