diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 979d4d6..12dd355 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,10 +1,11 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts"; 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 { LoadingState } from "./components/ai/primitives"; import { authClient } from "./lib/auth"; +import { lazyWithRecovery } from "./lib/lazy-recovery"; import { markAfterPaint, markOnce } from "./lib/performance"; import { holdUnreachableGate, @@ -17,16 +18,16 @@ import { LocalSettingsPage } from "./pages/LocalSettings"; import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback"; import { ShellPage } from "./pages/Shell"; -const AuthPage = lazy(() => +const AuthPage = lazyWithRecovery("pages/Auth", () => import("./pages/Auth").then((module) => ({ default: module.AuthPage })), ); -const PasswordResetPage = lazy(() => +const PasswordResetPage = lazyWithRecovery("pages/Auth", () => import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })), ); -const OnboardingPage = lazy(() => +const OnboardingPage = lazyWithRecovery("pages/Onboarding", () => import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })), ); -const WelcomePage = lazy(() => +const WelcomePage = lazyWithRecovery("pages/Welcome", () => import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })), ); diff --git a/apps/web/src/lib/lazy-recovery.test.ts b/apps/web/src/lib/lazy-recovery.test.ts new file mode 100644 index 0000000..d75d128 --- /dev/null +++ b/apps/web/src/lib/lazy-recovery.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadWithRecovery } from "./lazy-recovery.js"; + +function fakeStorage(initial: Record = {}) { + 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>() + .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>().mockRejectedValue(fetchFailure()); + const pending = loadWithRecovery("pages/Onboarding", load, { + storage, + reload, + retryDelayMs: 0, + }); + const outcome = await Promise.race([ + pending.then(() => "resolved"), + new Promise((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>().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"); + }); +}); diff --git a/apps/web/src/lib/lazy-recovery.ts b/apps/web/src/lib/lazy-recovery.ts new file mode 100644 index 0000000..6a5a651 --- /dev/null +++ b/apps/web/src/lib/lazy-recovery.ts @@ -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; + reload?: () => void; +} + +const STORAGE_PREFIX = "rakazo:module-reload:"; + +function delay(ms: number) { + return new Promise((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( + key: string, + load: () => Promise, + options: RecoveryOptions = {}, +): Promise { + 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(() => undefined); + } +} + +/** `React.lazy` for route and overlay modules that must survive a failed module fetch. */ +export function lazyWithRecovery>( + key: string, + load: () => Promise<{ default: T }>, +) { + return lazy(() => loadWithRecovery(key, load)); +} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index ee2d117..6d0ba02 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -139,7 +139,6 @@ import { import { type ClipboardEvent, type DragEvent, - lazy, type MutableRefObject, memo, type RefObject, @@ -201,6 +200,7 @@ import { resolveWorkingGroupBots, } from "../lib/group-stage"; import { buildHeuteBrief } from "../lib/heute-brief"; +import { lazyWithRecovery } from "../lib/lazy-recovery"; import { localTimezone } from "../lib/local-timezone"; import { copyableMessageText } from "../lib/message-text"; import { messageProviderLabel } from "../lib/messaging"; @@ -282,27 +282,29 @@ import { } from "./shell/message-cards"; import { WindowChrome } from "./WindowChrome"; -const BotContextMenu = lazy(() => +const BotContextMenu = lazyWithRecovery("BotContextMenu", () => import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })), ); -const MessagingSettingsOverlay = lazy(() => +const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () => import("./MessagingSettingsOverlay").then((module) => ({ default: module.MessagingSettingsOverlay, })), ); -const SettingsOverlay = lazy(() => +const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () => import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })), ); -const PeerMessagesOverlay = lazy(() => +const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () => import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })), ); -const PluginsOverlay = lazy(() => +const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () => import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })), ); -const McpServersOverlay = lazy(() => +const McpServersOverlay = lazyWithRecovery("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 = | "computer" diff --git a/apps/web/src/pages/shell/bot-panel.tsx b/apps/web/src/pages/shell/bot-panel.tsx index fcbede4..74c336c 100644 --- a/apps/web/src/pages/shell/bot-panel.tsx +++ b/apps/web/src/pages/shell/bot-panel.tsx @@ -25,15 +25,16 @@ import { Toggle, } from "@rakazo/ui-web"; 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 { AvatarStudioPopover } from "./avatar-studio-popover"; -const ScratchpadSection = lazy(() => +const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () => import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })), ); -const KnowledgeSection = lazy(() => +const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () => import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })), );