Compare commits
2 Commits
d1f7034aa5
...
e5b7e036e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b7e036e1 | ||
|
|
d5dacb12f1 |
38
apps/web/e2e/module-recovery.spec.ts
Normal file
38
apps/web/e2e/module-recovery.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
78
apps/web/src/lib/lazy-recovery.test.ts
Normal file
78
apps/web/src/lib/lazy-recovery.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/web/src/lib/lazy-recovery.ts
Normal file
58
apps/web/src/lib/lazy-recovery.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user