Compare commits

..

No commits in common. "e5b7e036e1a60e7e270592c4b9382ee23ed45ccb" and "d1f7034aa5ed23a4e3ef7885c287319fbd6e26a1" have entirely different histories.

8 changed files with 16 additions and 218 deletions

View File

@ -1,38 +0,0 @@
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,26 +29,6 @@
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,11 +1,10 @@
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 { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { lazy, 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,
@ -18,16 +17,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 = lazyWithRecovery("pages/Auth", () => const AuthPage = lazy(() =>
import("./pages/Auth").then((module) => ({ default: module.AuthPage })), import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
); );
const PasswordResetPage = lazyWithRecovery("pages/Auth", () => const PasswordResetPage = lazy(() =>
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })), import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
); );
const OnboardingPage = lazyWithRecovery("pages/Onboarding", () => const OnboardingPage = lazy(() =>
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })), import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
); );
const WelcomePage = lazyWithRecovery("pages/Welcome", () => const WelcomePage = lazy(() =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })), import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
); );

View File

@ -1,78 +0,0 @@
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

@ -1,58 +0,0 @@
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,10 +12,6 @@ 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,6 +139,7 @@ import {
import { import {
type ClipboardEvent, type ClipboardEvent,
type DragEvent, type DragEvent,
lazy,
type MutableRefObject, type MutableRefObject,
memo, memo,
type RefObject, type RefObject,
@ -200,7 +201,6 @@ 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,29 +282,27 @@ import {
} from "./shell/message-cards"; } from "./shell/message-cards";
import { WindowChrome } from "./WindowChrome"; import { WindowChrome } from "./WindowChrome";
const BotContextMenu = lazyWithRecovery("BotContextMenu", () => const BotContextMenu = lazy(() =>
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })), import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
); );
const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () => const MessagingSettingsOverlay = lazy(() =>
import("./MessagingSettingsOverlay").then((module) => ({ import("./MessagingSettingsOverlay").then((module) => ({
default: module.MessagingSettingsOverlay, default: module.MessagingSettingsOverlay,
})), })),
); );
const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () => const SettingsOverlay = lazy(() =>
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })), import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
); );
const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () => const PeerMessagesOverlay = lazy(() =>
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })), import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
); );
const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () => const PluginsOverlay = lazy(() =>
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })), import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
); );
const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () => const McpServersOverlay = lazy(() =>
import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })), import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })),
); );
const CallView = lazyWithRecovery("CallView", () => const CallView = lazy(() => import("./CallView").then((module) => ({ default: module.CallView })));
import("./CallView").then((module) => ({ default: module.CallView })),
);
type Panel = type Panel =
| "computer" | "computer"

View File

@ -25,16 +25,15 @@ import {
Toggle, Toggle,
} from "@rakazo/ui-web"; } from "@rakazo/ui-web";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Suspense, useEffect, useId, useRef, useState } from "react"; import { lazy, 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 = lazyWithRecovery("ScratchpadSection", () => const ScratchpadSection = lazy(() =>
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })), import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
); );
const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () => const KnowledgeSection = lazy(() =>
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })), import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
); );