Compare commits

..

2 Commits

Author SHA1 Message Date
cynarAI
e5b7e036e1
fix(web): reload once when the module graph is aborted while booting
The lazy-route recovery cannot help when the browser aborts a module of
the static import graph: nothing of the app runs, the root stays empty,
and the only trace is a console error. A Web E2E run hit this right
after a page reload, when four modules of the shell failed with
net::ERR_NETWORK_CHANGED and the shell never rendered.

An inline script in the HTML entry now listens for the error event of
the module script and reloads the page once, guarded by a session flag
that the entry module clears after a successful boot. The existing
preload recovery stays in place; it only covers Vite's chunk preloads of
production builds.

A new E2E spec aborts one module request during boot and one lazy route
request, and expects the sign-in page to render in both cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 18:01:19 +00:00
cynarAI
d5dacb12f1
fix(web): recover lazy routes and overlays from a failed module fetch
When the browser aborts a module request while a page loads, the lazy
import rejects and the route throws, leaving a blank page. Chromium does
this whenever a network interface appears or disappears mid-request
(net::ERR_NETWORK_CHANGED), which happens on a laptop that changes
networks and on the CI host every time a container starts or stops. A
Web E2E run hit exactly this: two modules of the onboarding page failed
to load and the suite waited on an empty document.

Lazy routes and overlays now load through a helper that retries the
import once and, when the browser keeps rejecting it from its module
map, reloads the page a single time before surfacing the error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 17:17:05 +00:00
8 changed files with 218 additions and 16 deletions

View File

@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
/** Abort the first request for a module once, then let every later request through. */
async function abortFirstRequest(page: Page, pattern: string) {
const counter = { aborted: 0 };
await page.route(pattern, async (route) => {
if (counter.aborted === 0) {
counter.aborted += 1;
await route.abort("failed");
return;
}
await route.continue();
});
return counter;
}
test("the app reloads itself once when a module request aborts while booting", async ({ page }) => {
const counter = await abortFirstRequest(page, "**/src/App.tsx");
await page.goto("/sign-in", { waitUntil: "commit" });
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
expect(counter.aborted).toBe(1);
expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull();
});
test("a lazy route recovers when its module request aborts", async ({ page }) => {
const counter = await abortFirstRequest(page, "**/src/pages/Auth.tsx");
await page.goto("/sign-in");
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
expect(counter.aborted).toBe(1);
expect(
await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")),
).toBeNull();
});

View File

@ -29,6 +29,26 @@
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a"); if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
})(); })();
</script> </script>
<script>
// Reload once when the browser aborts part of the module graph while the app
// boots (a network interface changed mid-request). main.tsx clears the flag
// after a successful boot; a second failure in a row is left to the browser.
addEventListener(
"error",
(event) => {
const target = event.target;
if (!(target instanceof HTMLScriptElement) || target.type !== "module") return;
try {
if (sessionStorage.getItem("rakazo:boot-reload")) return;
sessionStorage.setItem("rakazo:boot-reload", "1");
} catch {
return;
}
location.reload();
},
true,
);
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,10 +1,11 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts"; import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
import { Button, Skeleton } from "@rakazo/ui-web"; import { Button, Skeleton } from "@rakazo/ui-web";
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Navigate, Route, Routes, useSearchParams } from "react-router-dom"; import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
import { LoadingState } from "./components/ai/primitives"; import { LoadingState } from "./components/ai/primitives";
import { authClient } from "./lib/auth"; import { authClient } from "./lib/auth";
import { lazyWithRecovery } from "./lib/lazy-recovery";
import { markAfterPaint, markOnce } from "./lib/performance"; import { markAfterPaint, markOnce } from "./lib/performance";
import { import {
holdUnreachableGate, holdUnreachableGate,
@ -17,16 +18,16 @@ import { LocalSettingsPage } from "./pages/LocalSettings";
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback"; import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
import { ShellPage } from "./pages/Shell"; import { ShellPage } from "./pages/Shell";
const AuthPage = lazy(() => const AuthPage = lazyWithRecovery("pages/Auth", () =>
import("./pages/Auth").then((module) => ({ default: module.AuthPage })), import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
); );
const PasswordResetPage = lazy(() => const PasswordResetPage = lazyWithRecovery("pages/Auth", () =>
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })), import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
); );
const OnboardingPage = lazy(() => const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })), import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
); );
const WelcomePage = lazy(() => const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })), import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
); );

View File

@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { loadWithRecovery } from "./lazy-recovery.js";
function fakeStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
removeItem: (key: string) => void values.delete(key),
values,
};
}
const fetchFailure = () => new TypeError("Failed to fetch dynamically imported module");
const flag = "rakazo:module-reload:pages/Onboarding";
describe("loadWithRecovery", () => {
it("returns the module and clears the reload flag when the import succeeds", async () => {
const storage = fakeStorage({ [flag]: "1" });
const reload = vi.fn();
const loaded = await loadWithRecovery("pages/Onboarding", async () => "module", {
storage,
reload,
});
expect(loaded).toBe("module");
expect(storage.values.has(flag)).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
it("retries the import in place before touching the page", async () => {
const storage = fakeStorage();
const reload = vi.fn();
const load = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(fetchFailure())
.mockResolvedValueOnce("module");
const loaded = await loadWithRecovery("pages/Onboarding", load, {
storage,
reload,
retryDelayMs: 0,
});
expect(loaded).toBe("module");
expect(load).toHaveBeenCalledTimes(2);
expect(reload).not.toHaveBeenCalled();
expect(storage.values.has(flag)).toBe(false);
});
it("reloads the page once when the retry fails as well", async () => {
const storage = fakeStorage();
const reload = vi.fn();
const load = vi.fn<() => Promise<string>>().mockRejectedValue(fetchFailure());
const pending = loadWithRecovery("pages/Onboarding", load, {
storage,
reload,
retryDelayMs: 0,
});
const outcome = await Promise.race([
pending.then(() => "resolved"),
new Promise<string>((resolve) => setTimeout(() => resolve("still pending"), 20)),
]);
expect(outcome).toBe("still pending");
expect(load).toHaveBeenCalledTimes(2);
expect(reload).toHaveBeenCalledTimes(1);
expect(storage.values.get(flag)).toBe("1");
});
it("surfaces the failure instead of reloading again after a reload did not help", async () => {
const storage = fakeStorage({ [flag]: "1" });
const reload = vi.fn();
const failure = fetchFailure();
const load = vi.fn<() => Promise<string>>().mockRejectedValue(failure);
await expect(
loadWithRecovery("pages/Onboarding", load, { storage, reload, retryDelayMs: 0 }),
).rejects.toBe(failure);
expect(reload).not.toHaveBeenCalled();
expect(storage.values.get(flag)).toBe("1");
});
});

View File

@ -0,0 +1,58 @@
import type { ComponentType } from "react";
import { lazy } from "react";
export interface RecoveryOptions {
/** Pause before the in-place retry; the default covers a brief interface change. */
retryDelayMs?: number;
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
reload?: () => void;
}
const STORAGE_PREFIX = "rakazo:module-reload:";
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
/**
* Load a lazily imported module, recovering from a fetch the browser aborted
* (a network interface changed, the deploy behind the URL moved on). The first
* recovery is a plain retry; when that fails too, the page reloads once, because
* browsers keep a failed module in their module map and keep rejecting the same
* import without touching the network. A second failure after the reload is
* surfaced to the caller.
*/
export async function loadWithRecovery<T>(
key: string,
load: () => Promise<T>,
options: RecoveryOptions = {},
): Promise<T> {
const storage = options.storage ?? window.sessionStorage;
const reload = options.reload ?? (() => window.location.reload());
const storageKey = `${STORAGE_PREFIX}${key}`;
try {
const loaded = await load();
storage.removeItem(storageKey);
return loaded;
} catch {
await delay(options.retryDelayMs ?? 300);
}
try {
const loaded = await load();
storage.removeItem(storageKey);
return loaded;
} catch (error) {
if (storage.getItem(storageKey)) throw error;
storage.setItem(storageKey, "1");
reload();
return new Promise<never>(() => undefined);
}
}
/** `React.lazy` for route and overlay modules that must survive a failed module fetch. */
export function lazyWithRecovery<T extends ComponentType<any>>(
key: string,
load: () => Promise<{ default: T }>,
) {
return lazy(() => loadWithRecovery(key, load));
}

View File

@ -12,6 +12,10 @@ import { resolveUiLocale } from "./lib/ui-locale";
import "./styles.css"; import "./styles.css";
markOnce("rk:renderer:module-evaluated"); markOnce("rk:renderer:module-evaluated");
try {
// Set by the inline boot script in index.html before it reloads once.
sessionStorage.removeItem("rakazo:boot-reload");
} catch {}
installPreloadRecovery(); installPreloadRecovery();
applyUiDirection(resolveUiLocale()); applyUiDirection(resolveUiLocale());
applyUiAppearance(); applyUiAppearance();

View File

@ -139,7 +139,6 @@ import {
import { import {
type ClipboardEvent, type ClipboardEvent,
type DragEvent, type DragEvent,
lazy,
type MutableRefObject, type MutableRefObject,
memo, memo,
type RefObject, type RefObject,
@ -201,6 +200,7 @@ import {
resolveWorkingGroupBots, resolveWorkingGroupBots,
} from "../lib/group-stage"; } from "../lib/group-stage";
import { buildHeuteBrief } from "../lib/heute-brief"; import { buildHeuteBrief } from "../lib/heute-brief";
import { lazyWithRecovery } from "../lib/lazy-recovery";
import { localTimezone } from "../lib/local-timezone"; import { localTimezone } from "../lib/local-timezone";
import { copyableMessageText } from "../lib/message-text"; import { copyableMessageText } from "../lib/message-text";
import { messageProviderLabel } from "../lib/messaging"; import { messageProviderLabel } from "../lib/messaging";
@ -282,27 +282,29 @@ import {
} from "./shell/message-cards"; } from "./shell/message-cards";
import { WindowChrome } from "./WindowChrome"; import { WindowChrome } from "./WindowChrome";
const BotContextMenu = lazy(() => const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })), import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
); );
const MessagingSettingsOverlay = lazy(() => const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () =>
import("./MessagingSettingsOverlay").then((module) => ({ import("./MessagingSettingsOverlay").then((module) => ({
default: module.MessagingSettingsOverlay, default: module.MessagingSettingsOverlay,
})), })),
); );
const SettingsOverlay = lazy(() => const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () =>
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })), import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
); );
const PeerMessagesOverlay = lazy(() => const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () =>
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })), import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
); );
const PluginsOverlay = lazy(() => const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () =>
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })), import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
); );
const McpServersOverlay = lazy(() => const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () =>
import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })), import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })),
); );
const CallView = lazy(() => import("./CallView").then((module) => ({ default: module.CallView }))); const CallView = lazyWithRecovery("CallView", () =>
import("./CallView").then((module) => ({ default: module.CallView })),
);
type Panel = type Panel =
| "computer" | "computer"

View File

@ -25,15 +25,16 @@ import {
Toggle, Toggle,
} from "@rakazo/ui-web"; } from "@rakazo/ui-web";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { lazy, Suspense, useEffect, useId, useRef, useState } from "react"; import { Suspense, useEffect, useId, useRef, useState } from "react";
import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { rpc } from "../../lib/rpc"; import { rpc } from "../../lib/rpc";
import { AvatarStudioPopover } from "./avatar-studio-popover"; import { AvatarStudioPopover } from "./avatar-studio-popover";
const ScratchpadSection = lazy(() => const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })), import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
); );
const KnowledgeSection = lazy(() => const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () =>
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })), import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
); );