Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aea012fb74 | |||
| 320aa89751 | |||
| c894163446 |
@@ -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<Props, State> {
|
||||
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 (
|
||||
<Result status="info" title="系统正在更新" subTitle="检测到新版本,正在自动刷新页面…" />
|
||||
)
|
||||
}
|
||||
|
||||
// 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用;
|
||||
// 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环
|
||||
return (
|
||||
<Result
|
||||
status="warning"
|
||||
title={isChunkError ? "系统已更新" : "页面出现异常"}
|
||||
subTitle={
|
||||
isChunkError
|
||||
? "检测到新版本,请点击下方按钮回到首页加载最新内容。"
|
||||
: "页面加载遇到问题,点击返回首页通常可以恢复,未保存的内容可能丢失。"
|
||||
}
|
||||
extra={
|
||||
<Button type="primary" onClick={goHomeRecover}>
|
||||
{isChunkError ? "刷新并返回首页" : "返回首页"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChunkErrorBoundary
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<AntApp>
|
||||
<RouterProvider router={router} />
|
||||
<ChunkErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</ChunkErrorBoundary>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -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")),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<RouteObject> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import("@/utils/chunkLoadError")>()
|
||||
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 <Button onClick={() => setBoom(true)}>boom</Button>
|
||||
}
|
||||
|
||||
const renderBoundary = (ui: React.ReactNode) =>
|
||||
render(<ChunkErrorBoundary>{ui}</ChunkErrorBoundary>)
|
||||
|
||||
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(<div>hello-child</div>)
|
||||
expect(screen.getByText("hello-child")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("首次捕获 chunk 错误 → 自动刷新(reloadForChunkError)并显示自动刷新提示", () => {
|
||||
renderBoundary(<ChunkBoomButton />)
|
||||
fireEvent.click(screen.getByText("boom"))
|
||||
expect(reloadForChunkError).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText(/正在自动刷新/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("已刷新过仍失败 → 不再自动刷新,显示手动兜底按钮", () => {
|
||||
// 模拟"本会话已经自动刷新过一次"
|
||||
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now()))
|
||||
renderBoundary(
|
||||
<Boom error={new TypeError("Failed to fetch dynamically imported module: /assets/y.js")} />,
|
||||
)
|
||||
expect(reloadForChunkError).not.toHaveBeenCalled()
|
||||
expect(screen.getByText("系统已更新")).toBeInTheDocument()
|
||||
// 点击兜底按钮 → goHomeRecover(跳首页,不刷新当前 URL)
|
||||
fireEvent.click(screen.getByText("刷新并返回首页"))
|
||||
expect(goHomeRecover).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("非 chunk 错误 → 显示通用错误页,不触发 chunk 自动刷新", () => {
|
||||
renderBoundary(<Boom error={new Error("普通业务报错")} />)
|
||||
expect(reloadForChunkError).not.toHaveBeenCalled()
|
||||
expect(screen.getByText("页面出现异常")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
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()
|
||||
})
|
||||
|
||||
it("URL 带 chunkreload 参数时视为已刷新过(storage 不可用的降级标记)", () => {
|
||||
window.history.replaceState({}, "", "/app/assets?chunkreload=1")
|
||||
expect(getChunkReloadedAt()).not.toBeNull()
|
||||
window.history.replaceState({}, "", "/")
|
||||
})
|
||||
|
||||
it("storage 完全不可用时,reloadForChunkError 走 URL 标记跳转(防无痕死循环)", () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
const getSpy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
window.history.replaceState({}, "", "/app/assets")
|
||||
// reloadForChunkError 应走 location.replace 带上 ?chunkreload=1(jsdom 不会真导航)
|
||||
expect(() => reloadForChunkError()).not.toThrow()
|
||||
setSpy.mockRestore()
|
||||
getSpy.mockRestore()
|
||||
window.history.replaceState({}, "", "/")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 发版后旧标签页懒加载 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,
|
||||
)
|
||||
}
|
||||
|
||||
/** URL 降级标记参数:storage 不可用(无痕/禁 Cookie)时靠它在刷新后保留"已刷新"状态 */
|
||||
const RELOAD_QUERY_KEY = "chunkreload"
|
||||
|
||||
/** 判断当前 URL 是否已带"为 chunk 失效刷新过"标记 */
|
||||
const urlHasReloadFlag = (): boolean => {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).has(RELOAD_QUERY_KEY)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取"已自动刷新过"状态:storage 与 URL 参数双重判定。
|
||||
* storage 在 Safari 无痕等环境可能完全不可用,此时刷新后标记丢失会陷入
|
||||
* "检测到 chunk 失效 → 刷新 → 再检测 → 再刷新"死循环;URL 参数刷新后仍在,
|
||||
* 作为降级标记兜住这种环境。
|
||||
*/
|
||||
export const getChunkReloadedAt = (): number | null => {
|
||||
if (urlHasReloadFlag()) return Date.now()
|
||||
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()))
|
||||
// 同时在 URL 上带标记:storage 写不进(无痕模式)时刷新后仍能识别已刷新过
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(RELOAD_QUERY_KEY, "1")
|
||||
window.location.replace(url.toString())
|
||||
return
|
||||
} catch {
|
||||
/* URL 构造失败则退回普通刷新 */
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* 硬恢复:清掉标记后回到首页(整页导航,不是当前 URL 刷新)。
|
||||
* - chunk 失效兜底:回到首页会拉取最新 index.html,彻底脱离旧 chunk 引用
|
||||
* - 非 chunk 的页面级崩溃:跳首页能绕开当前报错路由,避免"刷新-再崩"死循环
|
||||
*/
|
||||
export const goHomeRecover = (): void => {
|
||||
safeStorage.removeItem(RELOAD_FLAG_KEY)
|
||||
window.location.href = "/"
|
||||
}
|
||||
Reference in New Issue
Block a user