diff --git a/apps/web/src/components/common/ChunkErrorBoundary.tsx b/apps/web/src/components/common/ChunkErrorBoundary.tsx new file mode 100644 index 000000000..5403751c6 --- /dev/null +++ b/apps/web/src/components/common/ChunkErrorBoundary.tsx @@ -0,0 +1,85 @@ +/** + * 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏, + * 同时兜住页面级渲染崩溃,避免任何未捕获错误导致整页白屏无反馈。 + * + * 捕获到 ChunkLoadError / Failed to fetch dynamically imported module: + * 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取, + * 拿到新 chunk 引用,白屏自愈) + * 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新" + * 兜底界面,由用户手动点击 + * + * 其他非 chunk 错误:显示通用错误页 + "返回首页"按钮(跳首页而非刷新当前 URL, + * 避免刷新后再次命中同一路由崩溃形成死循环)。 + */ +import React from "react" +import { Button, Result } from "antd" +import { + getChunkReloadedAt, + goHomeRecover, + isChunkLoadError, + reloadForChunkError, +} from "@/utils/chunkLoadError" + +interface Props { + children: React.ReactNode +} + +interface State { + error: Error | null + isChunkError: boolean + /** 捕获错误时是否已经自动刷新过(决定显示自动刷新中还是手动兜底) */ + alreadyReloaded: boolean +} + +class ChunkErrorBoundary extends React.Component { + state: State = { error: null, isChunkError: false, alreadyReloaded: false } + + static getDerivedStateFromError(error: Error): State { + const chunk = isChunkLoadError(error) + return { + error, + isChunkError: chunk, + alreadyReloaded: chunk ? getChunkReloadedAt() !== null : false, + } + } + + componentDidCatch(error: Error): void { + // 仅 chunk 错误且本次会话没自动刷新过 → 打标记并整页刷新(自愈) + if (isChunkLoadError(error) && getChunkReloadedAt() === null) { + reloadForChunkError() + } + } + + render(): React.ReactNode { + const { error, isChunkError, alreadyReloaded } = this.state + if (!error) return this.props.children + + if (isChunkError && !alreadyReloaded) { + // 已打标记、componentDidCatch 里已触发 reload;极短瞬间展示加载中 + return ( + + ) + } + + // 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用; + // 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环 + return ( + + {isChunkError ? "刷新并返回首页" : "返回首页"} + + } + /> + ) + } +} + +export default ChunkErrorBoundary diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a4253a61a..33cdde527 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ConfigProvider, App as AntApp } from "antd" import zhCN from "antd/locale/zh_CN" import router from "./router" +import ChunkErrorBoundary from "./components/common/ChunkErrorBoundary" import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh" // 应用启动时,如果用户已登录,立即调度主动 token 刷新 @@ -99,7 +100,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + + + diff --git a/apps/web/src/router/appRoutes.tsx b/apps/web/src/router/appRoutes.tsx index 2858217c3..579b1b9a6 100644 --- a/apps/web/src/router/appRoutes.tsx +++ b/apps/web/src/router/appRoutes.tsx @@ -1,6 +1,7 @@ import { Navigate, type RouteObject } from "react-router-dom" import MainLayout from "@/components/layout/MainLayout" import { ProtectedRoute } from "./ProtectedRoute" +import { lazyRoute } from "./lazyRoute" /** * 受保护的 /app 子路由 @@ -13,202 +14,118 @@ const appChildren: RouteObject[] = [ }, { path: "dashboard", - lazy: () => - import("@/pages/dashboard/Dashboard").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/dashboard/Dashboard")), }, { path: "assets", - lazy: () => - import("@/pages/assets/AssetLibrary").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/assets/AssetLibrary")), }, { path: "titles", - lazy: () => - import("@/pages/titles/TitleLibrary").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")), }, { path: "voices", - lazy: () => - import("@/pages/voices/VoiceLibrary").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")), }, { path: "templates", - lazy: () => - import("@/pages/templates/TemplateLibrary").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/templates/TemplateLibrary")), }, { path: "generate", - lazy: () => - import("@/pages/generate/GeneratePage").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/generate/GeneratePage")), }, { path: "history", - lazy: () => - import("@/pages/history/TaskHistory").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/history/TaskHistory")), }, { path: "products", - lazy: () => - import("@/pages/products/ProductLibrary").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/products/ProductLibrary")), }, { path: "products/:id", - lazy: () => - import("@/pages/products/ProductDetail").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/products/ProductDetail")), }, { path: "tasks", - lazy: () => - import("@/pages/tasks/TaskCenter").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/tasks/TaskCenter")), }, { path: "editing-planner", - lazy: () => - import("@/pages/editing-planner/EditingPlanner").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")), }, { path: "my-templates", - lazy: () => - import("@/pages/my-templates/MyTemplates").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")), }, { path: "voice-clone", - lazy: () => - import("@/pages/voice-clone/VoiceClone").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")), }, { path: "voice-materials", - lazy: () => - import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/voice-materials/VoiceMaterialLibrary")), }, { path: "my-voices", - lazy: () => - import("@/pages/my-voices/MyVoices").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/my-voices/MyVoices")), }, { path: "accounts", - lazy: () => - import("@/pages/accounts/Accounts").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/accounts/Accounts")), }, { path: "duplication", - lazy: () => - import("@/pages/duplication/DuplicationUpload").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/duplication/DuplicationUpload")), }, { path: "duplication/results", - lazy: () => - import("@/pages/duplication/DuplicationResults").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/duplication/DuplicationResults")), }, { path: "duplication/:id", - lazy: () => - import("@/pages/duplication/DuplicationDetail").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/duplication/DuplicationDetail")), }, { path: "subscription", - lazy: () => - import("@/pages/subscription/Plans").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/subscription/Plans")), }, { path: "subscription/upgrade", - lazy: () => - import("@/pages/subscription/UpgradeSubscription").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/subscription/UpgradeSubscription")), }, { path: "subscription/billing", - lazy: () => - import("@/pages/subscription/Billing").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/subscription/Billing")), }, { path: "profile", - lazy: () => - import("@/pages/profile/Settings").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/profile/Settings")), }, { path: "admin", children: [ { index: true, - lazy: () => - import("@/pages/admin/AdminComingSoon").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")), }, { path: "users", - lazy: () => - import("@/pages/admin/AdminComingSoon").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")), }, { path: "analytics", - lazy: () => - import("@/pages/admin/AdminComingSoon").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")), }, { path: "monitor", - lazy: () => - import("@/pages/admin/AdminComingSoon").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")), }, { path: "logs", - lazy: () => - import("@/pages/admin/AdminComingSoon").then((m) => ({ - Component: m.default, - })), + lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")), }, ], }, diff --git a/apps/web/src/router/lazyRoute.ts b/apps/web/src/router/lazyRoute.ts new file mode 100644 index 000000000..2a1970935 --- /dev/null +++ b/apps/web/src/router/lazyRoute.ts @@ -0,0 +1,40 @@ +import type { LazyRouteFunction, RouteObject } from "react-router-dom" +import { isChunkLoadError } from "@/utils/chunkLoadError" + +/** + * 给 React Router data router 的路由懒加载包一层自动重试: + * + * - 网络抖动 / 瞬态失败:自动重试最多 2 次(间隔 300ms / 800ms),用户无感恢复 + * - 发版后旧 chunk 404(chunk 文件名已不存在):重试也拿不到旧文件名, + * 重试耗尽后抛出,由全局 ChunkErrorBoundary 捕获并引导整页刷新 + * (刷新后 index.html 是 no-cache 的,会拿到新 chunk 引用) + */ +const RETRY_DELAYS_MS = [300, 800] +const RETRY_COUNT = RETRY_DELAYS_MS.length + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const lazyRoute = ( + factory: () => Promise<{ default: React.ComponentType }>, +): LazyRouteFunction => { + return async () => { + let lastError: unknown + for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) { + try { + const mod = await factory() + if (!mod.default) { + throw new Error("lazyRoute: 目标模块缺少 default 导出") + } + return { Component: mod.default } + } catch (err) { + lastError = err + // 非 chunk 加载错误(代码 bug 等)立即抛出,不浪费重试 + if (!isChunkLoadError(err)) throw err + if (attempt < RETRY_COUNT) { + await sleep(RETRY_DELAYS_MS[attempt]) + } + } + } + throw lastError + } +} diff --git a/apps/web/src/test/components/ChunkErrorBoundary.test.tsx b/apps/web/src/test/components/ChunkErrorBoundary.test.tsx new file mode 100644 index 000000000..c049dcb95 --- /dev/null +++ b/apps/web/src/test/components/ChunkErrorBoundary.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { Button } from "antd" +import { useState } from "react" +import ChunkErrorBoundary from "@/components/common/ChunkErrorBoundary" +import * as chunkUtils from "@/utils/chunkLoadError" + +// reload 函数 mock 掉(jsdom 不支持真实 window.location.reload) +vi.mock("@/utils/chunkLoadError", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + reloadForChunkError: vi.fn(), + goHomeRecover: vi.fn(), + } +}) +const { reloadForChunkError, goHomeRecover } = vi.mocked(chunkUtils) + +/** 渲染时直接抛错的子组件 */ +const Boom: React.FC<{ error: Error }> = ({ error }) => { + throw error +} + +/** 点击按钮后才抛 chunk 错误的子组件 */ +const ChunkBoomButton: React.FC = () => { + const [boom, setBoom] = useState(false) + if (boom) { + throw new TypeError("Failed to fetch dynamically imported module: /assets/x.js") + } + return +} + +const renderBoundary = (ui: React.ReactNode) => + render({ui}) + +beforeEach(() => { + sessionStorage.clear() + vi.clearAllMocks() + // error boundary 捕获后 React 会打 error log,静默掉 + vi.spyOn(console, "error").mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + sessionStorage.clear() +}) + +describe("ChunkErrorBoundary", () => { + it("正常渲染 children", () => { + renderBoundary(
hello-child
) + expect(screen.getByText("hello-child")).toBeInTheDocument() + }) + + it("首次捕获 chunk 错误 → 自动刷新(reloadForChunkError)并显示自动刷新提示", () => { + renderBoundary() + fireEvent.click(screen.getByText("boom")) + expect(reloadForChunkError).toHaveBeenCalledTimes(1) + expect(screen.getByText(/正在自动刷新/)).toBeInTheDocument() + }) + + it("已刷新过仍失败 → 不再自动刷新,显示手动兜底按钮", () => { + // 模拟"本会话已经自动刷新过一次" + sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now())) + renderBoundary( + , + ) + expect(reloadForChunkError).not.toHaveBeenCalled() + expect(screen.getByText("系统已更新")).toBeInTheDocument() + // 点击兜底按钮 → goHomeRecover(跳首页,不刷新当前 URL) + fireEvent.click(screen.getByText("刷新并返回首页")) + expect(goHomeRecover).toHaveBeenCalledTimes(1) + }) + + it("非 chunk 错误 → 显示通用错误页,不触发 chunk 自动刷新", () => { + renderBoundary() + expect(reloadForChunkError).not.toHaveBeenCalled() + expect(screen.getByText("页面出现异常")).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/test/router/lazyRoute.test.ts b/apps/web/src/test/router/lazyRoute.test.ts new file mode 100644 index 000000000..8e64fac7a --- /dev/null +++ b/apps/web/src/test/router/lazyRoute.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { lazyRoute } from "@/router/lazyRoute" + +const chunkErr = () => new TypeError("Failed to fetch dynamically imported module: /assets/x.js") + +/** fake 模块 */ +const Comp = function Comp() {} +const factoryOk = vi.fn(async () => ({ default: Comp })) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe("lazyRoute", () => { + it("首次成功直接返回 Component", async () => { + const result = await lazyRoute(factoryOk)() + expect(result).toEqual({ Component: Comp }) + expect(factoryOk).toHaveBeenCalledTimes(1) + }) + + it("chunk 失败重试:前两次失败、第三次成功 → 不抛出", async () => { + const f = vi + .fn() + .mockRejectedValueOnce(chunkErr()) + .mockRejectedValueOnce(chunkErr()) + .mockResolvedValueOnce({ default: Comp }) + + const result = await lazyRoute(f as never)() + expect(result).toEqual({ Component: Comp }) + expect(f).toHaveBeenCalledTimes(3) + }) + + it("chunk 失败重试 2 次仍失败 → 抛出", async () => { + const f = vi.fn().mockRejectedValue(chunkErr()) + await expect(lazyRoute(f as never)()).rejects.toThrow(/dynamically imported/) + expect(f).toHaveBeenCalledTimes(3) + }) + + it("非 chunk 错误立即抛出,不重试", async () => { + const f = vi.fn().mockRejectedValue(new Error("业务模块内部报错")) + await expect(lazyRoute(f as never)()).rejects.toThrow("业务模块内部报错") + expect(f).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/test/utils/chunkLoadError.test.ts b/apps/web/src/test/utils/chunkLoadError.test.ts new file mode 100644 index 000000000..b7a5b9997 --- /dev/null +++ b/apps/web/src/test/utils/chunkLoadError.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { + getChunkReloadedAt, + goHomeRecover, + isChunkLoadError, + reloadForChunkError, +} from "@/utils/chunkLoadError" + +describe("isChunkLoadError", () => { + it("识别 Vite 动态 import 失败", () => { + const err = new TypeError( + "Failed to fetch dynamically imported module: https://x/assets/AssetLibrary-abc.js", + ) + expect(isChunkLoadError(err)).toBe(true) + }) + + it("识别 Webpack 风格 ChunkLoadError", () => { + const err = new Error("Loading chunk 12 failed.") + err.name = "ChunkLoadError" + expect(isChunkLoadError(err)).toBe(true) + }) + + it("识别字符串形式错误", () => { + expect(isChunkLoadError("Error loading dynamically imported module")).toBe(true) + }) + + it("普通错误不命中", () => { + expect(isChunkLoadError(new Error("Cannot read properties of undefined"))).toBe(false) + expect(isChunkLoadError(null)).toBe(false) + expect(isChunkLoadError(undefined)).toBe(false) + expect(isChunkLoadError({ status: 500 })).toBe(false) + }) +}) + +describe("reload 标记", () => { + beforeEach(() => { + sessionStorage.clear() + // jsdom 未实现真实导航,reload 仅打 "not implemented" 警告,静默掉 + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + sessionStorage.clear() + }) + + it("无标记返回 null", () => { + expect(getChunkReloadedAt()).toBeNull() + }) + + it("reloadForChunkError 写入刷新标记", () => { + expect(() => reloadForChunkError()).not.toThrow() + expect(getChunkReloadedAt()).not.toBeNull() + }) + + it("标记过期(>10min)返回 null", () => { + sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now() - 11 * 60 * 1000)) + expect(getChunkReloadedAt()).toBeNull() + }) + + it("goHomeRecover 清掉标记", () => { + reloadForChunkError() + expect(getChunkReloadedAt()).not.toBeNull() + expect(() => goHomeRecover()).not.toThrow() + expect(sessionStorage.getItem("chunk_error_reloaded_at")).toBeNull() + }) + + it("sessionStorage 抛异常(无痕模式)时降级不崩溃", () => { + const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("Storage disabled") + }) + const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Storage disabled") + }) + expect(getChunkReloadedAt()).toBeNull() + expect(() => reloadForChunkError()).not.toThrow() + expect(() => goHomeRecover()).not.toThrow() + spy.mockRestore() + setSpy.mockRestore() + }) +}) diff --git a/apps/web/src/utils/chunkLoadError.ts b/apps/web/src/utils/chunkLoadError.ts new file mode 100644 index 000000000..7dce61a66 --- /dev/null +++ b/apps/web/src/utils/chunkLoadError.ts @@ -0,0 +1,84 @@ +/** + * 发版后旧标签页懒加载 chunk 失效(白屏)的识别与恢复工具。 + * + * 背景:页面 React Router 的 lazy 动态 import,发版后旧 chunk 文件名被删除, + * 停留在旧标签页的用户点菜单时 import 404,抛出 + * "Failed to fetch dynamically imported module"(Vite)/ ChunkLoadError, + * 不捕获就是整页白屏。 + */ + +/** sessionStorage 标记:最近已经为 chunk 失效自动刷新过一次(带时间戳,10min 有效) */ +const RELOAD_FLAG_KEY = "chunk_error_reloaded_at" +/** 标记有效期:超过后允许再次自动刷新,避免用户手动正常刷新后标记永久残留 */ +const RELOAD_FLAG_TTL_MS = 10 * 60 * 1000 + +/** + * Storage 在 Safari 无痕模式 / 禁用 Cookie 的浏览器 / 严格 iframe 策略下 + * 访问可能抛异常;此处统一容错,拿不到存储就降级为"无标记",绝不能让 + * 错误边界本身因读存储而崩溃。 + */ +const safeStorage = { + getItem: (key: string): string | null => { + try { + return sessionStorage.getItem(key) + } catch { + return null + } + }, + setItem: (key: string, value: string): void => { + try { + sessionStorage.setItem(key, value) + } catch { + /* 存储不可用时静默降级:仅丢失"已刷新"标记,不影响恢复动作 */ + } + }, + removeItem: (key: string): void => { + try { + sessionStorage.removeItem(key) + } catch { + /* ignore */ + } + }, +} + +/** 判断错误是否为懒加载 chunk 加载失败(发版 404 / 网络中断 / 动态 import 失败) */ +export const isChunkLoadError = (error: unknown): boolean => { + if (!error) return false + // Vite: Failed to fetch dynamically imported module: /assets/xxx-yyy.js + // Webpack: ChunkLoadError: Loading chunk xxx failed. + const needle = + error instanceof Error + ? `${error.name} ${error.message}` + : typeof error === "string" + ? error + : "" + return /failed to fetch dynamically imported module|chunkloaderror|loading chunk \d+ failed|error loading dynamically imported module|importing a module script failed/i.test( + needle, + ) +} + +/** 读取上次自动刷新时间戳;过期或不存在返回 null */ +export const getChunkReloadedAt = (): number | null => { + const raw = safeStorage.getItem(RELOAD_FLAG_KEY) + if (!raw) return null + const ts = Number(raw) + if (!Number.isFinite(ts)) return null + if (Date.now() - ts > RELOAD_FLAG_TTL_MS) return null + return ts +} + +/** 标记"已为 chunk 失效自动刷新过",然后刷新页面 */ +export const reloadForChunkError = (): void => { + safeStorage.setItem(RELOAD_FLAG_KEY, String(Date.now())) + window.location.reload() +} + +/** + * 硬恢复:清掉标记后回到首页(整页导航,不是当前 URL 刷新)。 + * - chunk 失效兜底:回到首页会拉取最新 index.html,彻底脱离旧 chunk 引用 + * - 非 chunk 的页面级崩溃:跳首页能绕开当前报错路由,避免"刷新-再崩"死循环 + */ +export const goHomeRecover = (): void => { + safeStorage.removeItem(RELOAD_FLAG_KEY) + window.location.href = "/" +}