From c89416344671fc8a991e91beaeb8151969711256 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 6 Sep 2026 13:21:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=8F=91=E7=89=88=E5=90=8E=E6=87=92?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=20chunk=20=E5=A4=B1=E6=95=88=E7=99=BD?= =?UTF-8?q?=E5=B1=8F=E2=80=94=E2=80=94ErrorBoundary=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=B7=E6=96=B0=20+=20lazy=20=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:所有页面 React Router lazy 动态 import,发版后旧 chunk 文件名删除, 停留旧标签页的用户点菜单时 import 404(Failed to fetch dynamically imported module / ChunkLoadError),无兜底导致整页白屏。 改动: - utils/chunkLoadError.ts:识别 chunk 加载错误(Vite/webpack/字符串多形态); sessionStorage 标记最近 10min 内是否已为 chunk 失效自动刷新过(防死循环) - components/common/ChunkErrorBoundary.tsx:全局错误边界包裹 RouterProvider; chunk 错误首次捕获自动整页刷新(刷新后 no-cache 的 index.html 拿到新 chunk 引用自愈),已刷新过仍失败则显示「系统已更新,请点击刷新」兜底按钮; 其他错误显示通用异常页,不再整页白屏 - router/lazyRoute.ts:路由 lazy 包一层自动重试(最多 2 次,300/800ms), 网络抖动无感恢复;chunk 404 重试耗尽抛出交给 Boundary;非 chunk 错误立即抛 - appRoutes.tsx:28 个路由 lazy 统一替换为 lazyRoute - nginx 缓存策略经核实已满足要求:index.html no-cache、/assets/* immutable 1y 单测 16 例:chunk 错误识别多形态、reload 标记生命周期、Boundary 首次自动刷新/ 二次手动兜底/非 chunk 通用页、lazyRoute 重试成功/耗尽抛出/非 chunk 不重试。 --- .../components/common/ChunkErrorBoundary.tsx | 81 ++++++++++ apps/web/src/main.tsx | 5 +- apps/web/src/router/appRoutes.tsx | 141 ++++-------------- apps/web/src/router/lazyRoute.ts | 37 +++++ .../components/ChunkErrorBoundary.test.tsx | 81 ++++++++++ apps/web/src/test/router/lazyRoute.test.ts | 44 ++++++ .../web/src/test/utils/chunkLoadError.test.ts | 66 ++++++++ apps/web/src/utils/chunkLoadError.ts | 51 +++++++ 8 files changed, 393 insertions(+), 113 deletions(-) create mode 100644 apps/web/src/components/common/ChunkErrorBoundary.tsx create mode 100644 apps/web/src/router/lazyRoute.ts create mode 100644 apps/web/src/test/components/ChunkErrorBoundary.test.tsx create mode 100644 apps/web/src/test/router/lazyRoute.test.ts create mode 100644 apps/web/src/test/utils/chunkLoadError.test.ts create mode 100644 apps/web/src/utils/chunkLoadError.ts diff --git a/apps/web/src/components/common/ChunkErrorBoundary.tsx b/apps/web/src/components/common/ChunkErrorBoundary.tsx new file mode 100644 index 000000000..88bc68909 --- /dev/null +++ b/apps/web/src/components/common/ChunkErrorBoundary.tsx @@ -0,0 +1,81 @@ +/** + * 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏。 + * + * 捕获到 ChunkLoadError / Failed to fetch dynamically imported module: + * 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取, + * 拿到新 chunk 引用,白屏自愈) + * 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新" + * 兜底界面,由用户手动点击 + * + * 其他非 chunk 错误:显示通用错误页("应用出现异常"+ 刷新按钮),避免整页白屏无反馈。 + */ +import React from "react" +import { Button, Result } from "antd" +import { + getChunkReloadedAt, + hardReload, + 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 ( + + ) + } + + return ( + + 刷新页面 + + } + /> + ) + } +} + +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..a43271eb7 --- /dev/null +++ b/apps/web/src/router/lazyRoute.ts @@ -0,0 +1,37 @@ +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() + 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..fe0d3d606 --- /dev/null +++ b/apps/web/src/test/components/ChunkErrorBoundary.test.tsx @@ -0,0 +1,81 @@ +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(), + hardReload: vi.fn(), + } +}) +const { reloadForChunkError, hardReload } = 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() + const btn = screen.getByText("刷新页面") + expect(btn).toBeInTheDocument() + // 点击兜底按钮 → hardReload + fireEvent.click(btn) + expect(hardReload).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..5a3f2e7ba --- /dev/null +++ b/apps/web/src/test/utils/chunkLoadError.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { + getChunkReloadedAt, + hardReload, + 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("hardReload 清掉标记", () => { + reloadForChunkError() + expect(getChunkReloadedAt()).not.toBeNull() + expect(() => hardReload()).not.toThrow() + expect(sessionStorage.getItem("chunk_error_reloaded_at")).toBeNull() + }) +}) diff --git a/apps/web/src/utils/chunkLoadError.ts b/apps/web/src/utils/chunkLoadError.ts new file mode 100644 index 000000000..391e86c40 --- /dev/null +++ b/apps/web/src/utils/chunkLoadError.ts @@ -0,0 +1,51 @@ +/** + * 发版后旧标签页懒加载 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 + +/** 判断错误是否为懒加载 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 = sessionStorage.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 => { + sessionStorage.setItem(RELOAD_FLAG_KEY, String(Date.now())) + window.location.reload() +} + +/** 手动刷新(兜底按钮):清掉标记后整页刷新,回到全新加载 */ +export const hardReload = (): void => { + sessionStorage.removeItem(RELOAD_FLAG_KEY) + window.location.reload() +} -- 2.54.0 From 320aa897519a92f9a4c54c973637db15eb7c9a5c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 6 Sep 2026 13:28:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=8C=89=20AI=20Review=20=E6=84=8F?= =?UTF-8?q?=E8=A7=81=E5=8A=A0=E5=9B=BA=E2=80=94=E2=80=94=E9=9D=9Echunk?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=B7=B3=E9=A6=96=E9=A1=B5=E9=98=B2=E6=AD=BB?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=20+=20storage=E5=BC=82=E5=B8=B8=E9=98=B2?= =?UTF-8?q?=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 手动兜底由 hardReload(刷新当前URL) 改为 goHomeRecover(跳首页整页导航): chunk 失效时脱离旧 chunk 引用;业务崩溃时绕开报错路由,避免刷新-再崩死循环 - chunkLoadError 的 sessionStorage 访问全部包 try-catch(Safari 无痕/禁用 Cookie 时访问存储会抛异常,错误边界本身不能因此崩溃),降级为无标记 - lazyRoute 增加 mod.default 校验,缺导出时抛明确错误 --- .../components/common/ChunkErrorBoundary.tsx | 18 +++++--- apps/web/src/router/lazyRoute.ts | 3 ++ .../components/ChunkErrorBoundary.test.tsx | 12 +++-- .../web/src/test/utils/chunkLoadError.test.ts | 20 +++++++-- apps/web/src/utils/chunkLoadError.ts | 45 ++++++++++++++++--- 5 files changed, 75 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/common/ChunkErrorBoundary.tsx b/apps/web/src/components/common/ChunkErrorBoundary.tsx index 88bc68909..5403751c6 100644 --- a/apps/web/src/components/common/ChunkErrorBoundary.tsx +++ b/apps/web/src/components/common/ChunkErrorBoundary.tsx @@ -1,5 +1,6 @@ /** - * 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏。 + * 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏, + * 同时兜住页面级渲染崩溃,避免任何未捕获错误导致整页白屏无反馈。 * * 捕获到 ChunkLoadError / Failed to fetch dynamically imported module: * 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取, @@ -7,13 +8,14 @@ * 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新" * 兜底界面,由用户手动点击 * - * 其他非 chunk 错误:显示通用错误页("应用出现异常"+ 刷新按钮),避免整页白屏无反馈。 + * 其他非 chunk 错误:显示通用错误页 + "返回首页"按钮(跳首页而非刷新当前 URL, + * 避免刷新后再次命中同一路由崩溃形成死循环)。 */ import React from "react" import { Button, Result } from "antd" import { getChunkReloadedAt, - hardReload, + goHomeRecover, isChunkLoadError, reloadForChunkError, } from "@/utils/chunkLoadError" @@ -59,18 +61,20 @@ class ChunkErrorBoundary extends React.Component { ) } + // 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用; + // 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环 return ( - 刷新页面 + } /> diff --git a/apps/web/src/router/lazyRoute.ts b/apps/web/src/router/lazyRoute.ts index a43271eb7..2a1970935 100644 --- a/apps/web/src/router/lazyRoute.ts +++ b/apps/web/src/router/lazyRoute.ts @@ -22,6 +22,9 @@ export const lazyRoute = ( 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 diff --git a/apps/web/src/test/components/ChunkErrorBoundary.test.tsx b/apps/web/src/test/components/ChunkErrorBoundary.test.tsx index fe0d3d606..c049dcb95 100644 --- a/apps/web/src/test/components/ChunkErrorBoundary.test.tsx +++ b/apps/web/src/test/components/ChunkErrorBoundary.test.tsx @@ -11,10 +11,10 @@ vi.mock("@/utils/chunkLoadError", async (importOriginal) => { return { ...actual, reloadForChunkError: vi.fn(), - hardReload: vi.fn(), + goHomeRecover: vi.fn(), } }) -const { reloadForChunkError, hardReload } = vi.mocked(chunkUtils) +const { reloadForChunkError, goHomeRecover } = vi.mocked(chunkUtils) /** 渲染时直接抛错的子组件 */ const Boom: React.FC<{ error: Error }> = ({ error }) => { @@ -66,11 +66,9 @@ describe("ChunkErrorBoundary", () => { ) expect(reloadForChunkError).not.toHaveBeenCalled() expect(screen.getByText("系统已更新")).toBeInTheDocument() - const btn = screen.getByText("刷新页面") - expect(btn).toBeInTheDocument() - // 点击兜底按钮 → hardReload - fireEvent.click(btn) - expect(hardReload).toHaveBeenCalledTimes(1) + // 点击兜底按钮 → goHomeRecover(跳首页,不刷新当前 URL) + fireEvent.click(screen.getByText("刷新并返回首页")) + expect(goHomeRecover).toHaveBeenCalledTimes(1) }) it("非 chunk 错误 → 显示通用错误页,不触发 chunk 自动刷新", () => { diff --git a/apps/web/src/test/utils/chunkLoadError.test.ts b/apps/web/src/test/utils/chunkLoadError.test.ts index 5a3f2e7ba..b7a5b9997 100644 --- a/apps/web/src/test/utils/chunkLoadError.test.ts +++ b/apps/web/src/test/utils/chunkLoadError.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" import { getChunkReloadedAt, - hardReload, + goHomeRecover, isChunkLoadError, reloadForChunkError, } from "@/utils/chunkLoadError" @@ -57,10 +57,24 @@ describe("reload 标记", () => { expect(getChunkReloadedAt()).toBeNull() }) - it("hardReload 清掉标记", () => { + it("goHomeRecover 清掉标记", () => { reloadForChunkError() expect(getChunkReloadedAt()).not.toBeNull() - expect(() => hardReload()).not.toThrow() + 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 index 391e86c40..7dce61a66 100644 --- a/apps/web/src/utils/chunkLoadError.ts +++ b/apps/web/src/utils/chunkLoadError.ts @@ -12,6 +12,35 @@ 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 @@ -30,7 +59,7 @@ export const isChunkLoadError = (error: unknown): boolean => { /** 读取上次自动刷新时间戳;过期或不存在返回 null */ export const getChunkReloadedAt = (): number | null => { - const raw = sessionStorage.getItem(RELOAD_FLAG_KEY) + const raw = safeStorage.getItem(RELOAD_FLAG_KEY) if (!raw) return null const ts = Number(raw) if (!Number.isFinite(ts)) return null @@ -40,12 +69,16 @@ export const getChunkReloadedAt = (): number | null => { /** 标记"已为 chunk 失效自动刷新过",然后刷新页面 */ export const reloadForChunkError = (): void => { - sessionStorage.setItem(RELOAD_FLAG_KEY, String(Date.now())) + safeStorage.setItem(RELOAD_FLAG_KEY, String(Date.now())) window.location.reload() } -/** 手动刷新(兜底按钮):清掉标记后整页刷新,回到全新加载 */ -export const hardReload = (): void => { - sessionStorage.removeItem(RELOAD_FLAG_KEY) - window.location.reload() +/** + * 硬恢复:清掉标记后回到首页(整页导航,不是当前 URL 刷新)。 + * - chunk 失效兜底:回到首页会拉取最新 index.html,彻底脱离旧 chunk 引用 + * - 非 chunk 的页面级崩溃:跳首页能绕开当前报错路由,避免"刷新-再崩"死循环 + */ +export const goHomeRecover = (): void => { + safeStorage.removeItem(RELOAD_FLAG_KEY) + window.location.href = "/" } -- 2.54.0