Skip to content
Open
68 changes: 68 additions & 0 deletions apps/web/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({ showBootError: vi.fn().mockName("showBootError") }));

vi.mock("./lib/bootError", () => ({ showBootError: mocks.showBootError }));

// Mirrors Vite's build preload helper when main's CSS or JS chunk fails to load.
vi.mock("./main", () => {
const error = new TypeError("Failed to fetch dynamically imported module: /assets/main.js");
const event = Object.assign(new Event("vite:preloadError", { cancelable: true }), {
payload: error,
});
window.dispatchEvent(event);
throw error;
});

function createStorageStub(initial: Record<string, string> = {}): Storage {
const store = new Map(Object.entries(initial));
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
store.set(key, value);
},
removeItem: (key) => {
store.delete(key);
},
clear: () => store.clear(),
key: (index) => [...store.keys()][index] ?? null,
get length() {
return store.size;
},
};
}

async function bootWithFailingMainChunk(storage: Storage) {
vi.resetModules();
mocks.showBootError.mockClear();
const reload = vi.fn().mockName("location.reload");
vi.stubGlobal(
"window",
Object.assign(new EventTarget(), { sessionStorage: storage, location: { reload } }),
);
try {
const { boot } = await import("./bootstrap");
await boot;
return { reload, showBootError: mocks.showBootError };
} finally {
vi.unstubAllGlobals();
}
}

describe("bootstrap", () => {
it("reloads once instead of showing the boot error when the main chunk fails to load", async () => {
const { reload, showBootError } = await bootWithFailingMainChunk(createStorageStub());

expect(reload).toHaveBeenCalledTimes(1);
expect(showBootError).not.toHaveBeenCalled();
});

it("shows the boot error when the main chunk fails again after the reload", async () => {
const { reload, showBootError } = await bootWithFailingMainChunk(
createStorageStub({ "t3code:chunk-load-reloaded": "1" }),
);

expect(reload).not.toHaveBeenCalled();
expect(showBootError).toHaveBeenCalledTimes(1);
});
});
9 changes: 8 additions & 1 deletion apps/web/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { showBootError } from "./lib/bootError";
import { chunkLoadState, handleChunkLoadErrors } from "./lib/chunkReloadGuard";

handleChunkLoadErrors();

// Shared dev chunks need the React refresh preamble first. Catch failures before React mounts.
void import("./main").then(({ startup }) => startup).catch(showBootError);
export const boot = import("./main")
.then(({ startup }) => startup)
.catch((error: unknown) => {
if (!chunkLoadState.reloadScheduled) showBootError(error);
});
8 changes: 7 additions & 1 deletion apps/web/src/bundledDev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ it("initializes React refresh before a shared UI chunk runs in bundled dev", asy
try {
await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true });
await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}');
for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) {
for (const file of [
"index.html",
"src/bootstrap.ts",
"src/lib/bootError.ts",
"src/lib/chunkReloadGuard.ts",
]) {
await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file));
}
await NodeFSP.writeFile(
Expand Down Expand Up @@ -94,6 +99,7 @@ export const startup = Promise.resolve().then(() => globalThis.onStarted(Shared(
`import assert from "node:assert/strict";
const started = Promise.withResolvers();
globalThis.window = globalThis;
globalThis.addEventListener = () => {};
globalThis.document = {
createElement: () => ({ relList: { supports: () => true } }),
getElementById: () => null,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/lib/chunkReloadGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,16 @@ export function clearChunkReloadGuard(getStorage: () => Storage = () => window.s
getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY);
} catch {}
}

export const chunkLoadState = { failed: false, reloadScheduled: false };

// Registered from the entry before main loads, so main-chunk and route-chunk failures share one decision.
export function handleChunkLoadErrors() {
window.addEventListener("vite:preloadError", (event) => {
chunkLoadState.failed = true;
if (reloadOnceForChunkLoadError()) {
chunkLoadState.reloadScheduled = true;
event.preventDefault();
}
});
}
18 changes: 4 additions & 14 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,12 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig";
import { isElectron } from "./env";
import { startAppServices } from "./appInit";
import { AppRoot } from "./AppRoot";
import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";
import { chunkLoadState, clearChunkReloadGuard } from "./lib/chunkReloadGuard";

const router = startAppServices();

const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;

let chunkLoadFailed = false;
let reloadScheduled = false;
window.addEventListener("vite:preloadError", (event) => {
chunkLoadFailed = true;
if (reloadOnceForChunkLoadError()) {
reloadScheduled = true;
event.preventDefault();
}
});

const app = <AppRoot router={router} />;

// Split by runtime to keep Clerk out of local startup and clerk-js out of browser builds.
Expand All @@ -40,8 +30,8 @@ export const startup = Promise.all([
])
.then(([ManagedAuthShell]) => {
// Route chunk failures resolve router.load() and surface later through the error boundary.
if (reloadScheduled) return;
if (!chunkLoadFailed) clearChunkReloadGuard();
if (chunkLoadState.reloadScheduled) return;
if (!chunkLoadState.failed) clearChunkReloadGuard();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
{ManagedAuthShell && clerkPublishableKey ? (
Expand All @@ -53,6 +43,6 @@ export const startup = Promise.all([
);
})
.catch((error: unknown) => {
if (reloadScheduled) return;
if (chunkLoadState.reloadScheduled) return;
throw error;
});
Loading