fix(worker): 修复寄卖页分片加载失败导致的白屏

- 新增全局/页面级错误边界,页面渲染异常不再整页白屏
- 监听 vite:preloadError,部署后旧分片加载失败自动刷新一次
- 修复长会话 setTimeout 2^31 溢出导致立即登出
- 寄卖页资料接口失败给出重试态,不再误报未开通
This commit is contained in:
2026-11-13 16:19:17 +08:00
parent dd0c56c265
commit d8793db593
9 changed files with 389 additions and 9 deletions
@@ -0,0 +1,107 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button, Result, Space, Typography } from 'antd'
import { isChunkLoadError, shouldAutoReloadChunkFailure } from '@/utils/chunk-load-error'
import { isWorkerSite } from '@/utils/site'
type AppErrorBoundaryProps = {
children: ReactNode
/** inline:只占内容区,保留侧边栏/导航;page:整页错误页。 */
variant?: 'page' | 'inline'
/** 路由变化时自动清除错误,避免一个页面崩溃后整个会话都停在错误页。 */
resetKey?: string
}
type AppErrorBoundaryState = {
error: Error | null
}
/**
* 全局兜底错误边界。
*
* 没有它时,任意一个页面在渲染期抛错(典型场景:部署后点击尚未加载过的路由,
* 旧的 lazy chunk 已被新镜像删除,`import()` 直接失败)会让 React 卸载整棵树,
* 用户看到的就是没有任何提示的纯白页面。这里统一转成可操作的错误页,并对
* 分片加载失败自动刷新一次拿新版本。
*/
export default class AppErrorBoundary extends Component<
AppErrorBoundaryProps,
AppErrorBoundaryState
> {
state: AppErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: unknown): AppErrorBoundaryState {
return { error: error instanceof Error ? error : new Error(String(error)) }
}
componentDidCatch(error: unknown, info: ErrorInfo) {
if (isChunkLoadError(error) && typeof window !== 'undefined') {
// 旧版本页面引用了已删除的分片:刷新一次即可拿到新 index.html 与新分片。
// 防抖窗口内再次失败则不再刷新,落到下面错误页,避免无限刷新。
if (shouldAutoReloadChunkFailure(window.sessionStorage)) {
window.location.reload()
return
}
}
console.error('[AppErrorBoundary] 页面渲染失败', error, info.componentStack)
}
componentDidUpdate(prevProps: AppErrorBoundaryProps) {
if (this.state.error && prevProps.resetKey !== this.props.resetKey) {
this.setState({ error: null })
}
}
render() {
const { error } = this.state
if (!error) return this.props.children
const chunkFailed = isChunkLoadError(error)
const inline = this.props.variant === 'inline'
const homePath = isWorkerSite() ? '/hall' : '/'
return (
<div
data-testid="app-error-boundary"
style={
inline
? { padding: '32px 16px' }
: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
background: '#f5f7fb',
}
}
>
<Result
status="error"
title={chunkFailed ? '页面资源已更新' : '页面加载失败'}
subTitle={
chunkFailed
? '检测到页面版本已更新或网络中断,刷新后即可继续使用。'
: '页面遇到异常,已停止渲染以免影响后续操作。'
}
extra={
<Space direction="vertical" size={12} align="center">
<Space wrap>
<Button type="primary" onClick={() => window.location.reload()}>
重新加载
</Button>
<Button onClick={() => window.location.assign(homePath)}>返回首页</Button>
</Space>
{chunkFailed ? null : (
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 520 }}>
错误信息:{error.message.slice(0, 200)}
</Typography.Text>
)}
</Space>
}
/>
</div>
)
}
}
+20 -3
View File
@@ -67,6 +67,11 @@ import {
shouldPrependAdminWorkOrder,
} from '@/utils/admin-realtime-work-orders'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
import {
hasRemainingTimerDelay,
MAX_TIMER_DELAY_MS,
resolveTimerChunkDelay,
} from '@/utils/session-expiry-timer'
import dayjs from '@/lib/dayjs'
const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed'
@@ -132,9 +137,21 @@ export default function AdminLayout() {
useEffect(() => {
const delay = getAdminSessionExpiryDelay(expiresAt)
if (delay === null) return
const timer = window.setTimeout(() => {
expireAdminSession()
}, delay)
// 同 WorkerLayout:超过 2^31-1 毫秒的延迟会溢出成立即触发,长会话必须分段等待。
let remaining = delay
let timer = 0
const armTimer = () => {
timer = window.setTimeout(() => {
if (hasRemainingTimerDelay(remaining)) {
remaining -= MAX_TIMER_DELAY_MS
armTimer()
return
}
expireAdminSession()
}, resolveTimerChunkDelay(remaining))
}
armTimer()
return () => window.clearTimeout(timer)
}, [expiresAt])
+29 -5
View File
@@ -35,9 +35,15 @@ import {
isWorkerSessionStorageKey,
} from '@/utils/worker-auth'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
import AppErrorBoundary from '@/components/common/AppErrorBoundary'
import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner'
import WorkerAnnouncementPopup from '@/components/worker/WorkerAnnouncementPopup'
import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech'
import {
hasRemainingTimerDelay,
MAX_TIMER_DELAY_MS,
resolveTimerChunkDelay,
} from '@/utils/session-expiry-timer'
const menuItems: MenuProps['items'] = [
{ key: '/hall', icon: <ShoppingOutlined />, label: '抢单大厅' },
@@ -140,9 +146,21 @@ export default function WorkerLayout() {
const delay = getWorkerSessionExpiryDelay(sessionExpiresAt)
if (delay === null) return
const timer = window.setTimeout(() => {
expireWorkerSession()
}, delay)
// setTimeout 的延迟上限是 2^31-1 毫秒(约 24.8 天),超出会溢出成立即触发,
// 会话有效期较长时必须分段等待,否则一进页面就会被误判为过期。
let remaining = delay
let timer = 0
const armTimer = () => {
timer = window.setTimeout(() => {
if (hasRemainingTimerDelay(remaining)) {
remaining -= MAX_TIMER_DELAY_MS
armTimer()
return
}
expireWorkerSession()
}, resolveTimerChunkDelay(remaining))
}
armTimer()
return () => window.clearTimeout(timer)
}, [sessionExpiresAt])
@@ -215,7 +233,10 @@ export default function WorkerLayout() {
<main className="worker-mobile-content">
<WorkerAnnouncementBanner />
<WorkerAnnouncementPopup />
<Outlet />
{/* 页面级错误边界:单个页面崩溃时保留底部导航,用户可以直接切走,不用整页刷新。 */}
<AppErrorBoundary variant="inline" resetKey={`${location.pathname}${location.search}`}>
<Outlet />
</AppErrorBoundary>
</main>
<nav className="worker-mobile-tabbar" aria-label="移动端底部导航">
@@ -310,7 +331,10 @@ export default function WorkerLayout() {
<Layout.Content className="admin-content">
<WorkerAnnouncementBanner />
<WorkerAnnouncementPopup />
<Outlet />
{/* 页面级错误边界:侧边栏保持可用,避免一个页面报错后整个工作台白屏。 */}
<AppErrorBoundary variant="inline" resetKey={`${location.pathname}${location.search}`}>
<Outlet />
</AppErrorBoundary>
</Layout.Content>
</Layout>
</Layout>
+16 -1
View File
@@ -9,8 +9,21 @@ import 'antd/dist/reset.css'
import '@/lib/dayjs'
import App from './App'
import AppErrorBoundary from '@/components/common/AppErrorBoundary'
import { shouldAutoReloadChunkFailure } from '@/utils/chunk-load-error'
import './styles/main.css'
/**
* 部署后旧页面再去加载已被替换的懒加载 chunk 时会直接失败。
* Vite 会为这种失败派发 `vite:preloadError`,在这里刷新一次即可拿到新版本;
* 连续失败(刷新后仍是旧资源/断网)则交给错误边界展示可操作提示,避免无限刷新。
*/
window.addEventListener('vite:preloadError', () => {
if (shouldAutoReloadChunkFailure(window.sessionStorage)) {
window.location.reload()
}
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -56,7 +69,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
>
<AntdApp>
<QueryClientProvider client={queryClient}>
<App />
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</QueryClientProvider>
</AntdApp>
</ConfigProvider>
@@ -846,6 +846,31 @@ export default function WorkerConsignmentPage() {
<div style={{ padding: '48px 16px', textAlign: 'center' }}>
<Spin />
</div>
) : profileQuery.isError ? (
// 资料接口失败(历史上生产出现过 15s 语句超时)不能误显示成「未开通寄卖」,
// 这里给出明确错误和重试入口,避免用户以为是自己没有权限。
<div style={{ padding: '36px 16px', textAlign: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div style={{ maxWidth: 420, margin: '0 auto' }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>
寄卖资料加载失败
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 20 }}>
网络波动或服务繁忙时可能出现,请点击下方按钮重试;多次失败请联系客服。
</Typography.Paragraph>
<Button
type="primary"
loading={profileQuery.isFetching}
onClick={() => void profileQuery.refetch()}
>
重新加载
</Button>
</div>
}
/>
</div>
) : !canConsign ? (
<div style={{ padding: '36px 16px', textAlign: 'center' }}>
<Empty
@@ -0,0 +1,71 @@
/**
* 部署后旧 chunk 会被新镜像替换:正在运行的旧页面再去 `import()` 已删除的分片时,
* 浏览器只会抛一个 `Failed to fetch dynamically imported module`,React 拿不到任何内容。
* 这类错误只需要刷新一次页面即可恢复,其余错误交给错误边界展示。
*/
const CHUNK_RELOAD_STORAGE_KEY = 'order-site-chunk-reload-at'
/** 自动刷新防抖窗口:窗口内再次失败就展示错误页,避免无限刷新。 */
export const CHUNK_RELOAD_GUARD_MS = 30_000
const CHUNK_ERROR_HINTS = [
'failed to fetch dynamically imported module',
'error loading dynamically imported module',
'importing a module script failed',
'chunkloaderror',
'loading chunk',
'load failed',
]
/** 判断错误是否来自「动态 import 的分片加载失败」。 */
export function isChunkLoadError(error: unknown): boolean {
if (error === null || error === undefined) return false
const message = readErrorMessage(error).toLowerCase()
return CHUNK_ERROR_HINTS.some((hint) => message.includes(hint))
}
/** 兼容 Error 实例,以及 webpack/监控上报里常见的 { name, message } 普通对象。 */
function readErrorMessage(error: unknown): string {
if (error instanceof Error) return `${error.name}: ${error.message}`
if (typeof error === 'object') {
const record = error as { name?: unknown; message?: unknown }
const name = String(record.name || '')
const message = String(record.message || '')
if (name || message) return `${name}: ${message}`
}
return String(error)
}
/**
* 判断本次分片失败是否允许自动刷新;允许时同时记录本次尝试时间。
*
* 用 sessionStorage 而不是 localStorage:刷新只针对当前标签页里的旧版本页面,
* 新开的标签页本来就拿到新 index.html,不需要继承这个窗口。
*/
export function shouldAutoReloadChunkFailure(
storage: Pick<Storage, 'getItem' | 'setItem'> | null | undefined,
now = Date.now(),
): boolean {
if (!storage) return false
try {
const lastAttemptAt = Number(storage.getItem(CHUNK_RELOAD_STORAGE_KEY) || 0)
if (
Number.isFinite(lastAttemptAt) &&
lastAttemptAt > 0 &&
now - lastAttemptAt < CHUNK_RELOAD_GUARD_MS
) {
return false
}
storage.setItem(CHUNK_RELOAD_STORAGE_KEY, String(now))
return true
} catch {
// 隐私模式/存储被禁用时宁可展示错误页,也不要冒无限刷新的风险。
return false
}
}
@@ -0,0 +1,16 @@
/**
* 浏览器 `setTimeout` 的延迟会被转成 32 位有符号整数,超过 2^31-1 毫秒会溢出成负数,
* 表现是「定时器立刻触发」。会话空闲有效期较长时(例如 30 天)必须分段等待。
*/
export const MAX_TIMER_DELAY_MS = 2_147_483_647
/** 返回本次 `setTimeout` 可安全使用的延迟:非正数按 0 处理,超过上限的截断。 */
export function resolveTimerChunkDelay(remainingMs: number): number {
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return 0
return Math.min(remainingMs, MAX_TIMER_DELAY_MS)
}
/** 本次定时器触发后是否还需要继续等待剩余时间(说明原延迟超过单次上限)。 */
export function hasRemainingTimerDelay(remainingMs: number): boolean {
return Number.isFinite(remainingMs) && remainingMs > MAX_TIMER_DELAY_MS
}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
CHUNK_RELOAD_GUARD_MS,
isChunkLoadError,
shouldAutoReloadChunkFailure,
} from '../src/utils/chunk-load-error.ts'
function createStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial))
return {
getItem(key: string) {
return values.get(key) ?? null
},
setItem(key: string, value: string) {
values.set(key, String(value))
},
}
}
test('识别浏览器动态 import 失败文案', () => {
assert.equal(
isChunkLoadError(
new TypeError(
'Failed to fetch dynamically imported module: https://worker.khhao.com/assets/WorkerConsignmentPage-abc.js',
),
),
true,
)
assert.equal(isChunkLoadError(new Error('Importing a module script failed.')), true)
assert.equal(isChunkLoadError(new Error('Load failed')), true)
assert.equal(
isChunkLoadError({ name: 'ChunkLoadError', message: 'Loading chunk 12 failed' }),
true,
)
})
test('不把普通业务错误当成分片失败', () => {
assert.equal(isChunkLoadError(new Error('接口请求失败')), false)
assert.equal(isChunkLoadError(null), false)
assert.equal(isChunkLoadError(undefined), false)
})
test('首次分片失败允许自动刷新,窗口内再次失败不再刷新', () => {
const storage = createStorage()
const now = 1_700_000_000_000
assert.equal(shouldAutoReloadChunkFailure(storage, now), true)
assert.equal(shouldAutoReloadChunkFailure(storage, now + 1_000), false)
assert.equal(shouldAutoReloadChunkFailure(storage, now + CHUNK_RELOAD_GUARD_MS - 1), false)
})
test('超过防抖窗口后允许再次自动刷新', () => {
const storage = createStorage()
const now = 1_700_000_000_000
assert.equal(shouldAutoReloadChunkFailure(storage, now), true)
assert.equal(shouldAutoReloadChunkFailure(storage, now + CHUNK_RELOAD_GUARD_MS), true)
})
test('存储不可用时不做自动刷新,避免无限循环', () => {
const storage = {
getItem() {
throw new Error('denied')
},
setItem() {
throw new Error('denied')
},
}
assert.equal(shouldAutoReloadChunkFailure(storage), false)
assert.equal(shouldAutoReloadChunkFailure(null), false)
})
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
MAX_TIMER_DELAY_MS,
hasRemainingTimerDelay,
resolveTimerChunkDelay,
} from '../src/utils/session-expiry-timer.ts'
const DAY_MS = 24 * 60 * 60 * 1000
test('7 天会话有效期不需要切段', () => {
assert.equal(resolveTimerChunkDelay(7 * DAY_MS), 7 * DAY_MS)
assert.equal(hasRemainingTimerDelay(7 * DAY_MS), false)
})
test('30 天会话有效期超过 32 位上限时切段,避免 setTimeout 溢出后立即触发', () => {
const thirtyDays = 30 * DAY_MS
assert.ok(thirtyDays > MAX_TIMER_DELAY_MS)
assert.equal(resolveTimerChunkDelay(thirtyDays), MAX_TIMER_DELAY_MS)
assert.equal(hasRemainingTimerDelay(thirtyDays), true)
})
test('刚好等于上限时不切段', () => {
assert.equal(resolveTimerChunkDelay(MAX_TIMER_DELAY_MS), MAX_TIMER_DELAY_MS)
assert.equal(hasRemainingTimerDelay(MAX_TIMER_DELAY_MS), false)
})
test('已过期/非法延迟按 0 处理', () => {
assert.equal(resolveTimerChunkDelay(0), 0)
assert.equal(resolveTimerChunkDelay(-1), 0)
assert.equal(resolveTimerChunkDelay(Number.NaN), 0)
assert.equal(hasRemainingTimerDelay(Number.NaN), false)
})