fix(worker): 修复寄卖页分片加载失败导致的白屏
- 新增全局/页面级错误边界,页面渲染异常不再整页白屏 - 监听 vite:preloadError,部署后旧分片加载失败自动刷新一次 - 修复长会话 setTimeout 2^31 溢出导致立即登出 - 寄卖页资料接口失败给出重试态,不再误报未开通
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user