From 39c5dcab73e35921fbc3973390d2194754f9a6c7 Mon Sep 17 00:00:00 2001 From: afc163 Date: Wed, 6 May 2026 18:55:29 +0800 Subject: [PATCH] refactor: simplify offline handling and fix OfflineBanner not showing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. OfflineBanner: use `useModel('network')` instead of duplicating online/offline listener logic, fix `title` → `message` prop (Alert in banner mode uses `message`, not `title`) 2. ErrorBoundary: extract shared renderErrorFallback(), split into ErrorBoundaryClass (for ProLayout's ComponentClass prop) and ErrorBoundary (FC wrapper using useModel('network')). When isOnline prop is provided, class skips own event listeners; when unset, falls back to navigator.onLine tracking. 3. requestErrorConfig: consolidate duplicate offline check — check navigator.onLine once before the error.request branch instead of duplicating the same intl message in two branches. 4. ProLayout now uses ErrorBoundaryClass directly (satisfies the ComponentClass type requirement). Co-Authored-By: Claude Opus 4.7 --- src/app.tsx | 3 +- src/components/ErrorBoundary/index.tsx | 176 ++++++++++++++++--------- src/components/OfflineBanner/index.tsx | 20 +-- src/components/index.ts | 2 +- src/requestErrorConfig.ts | 18 +-- 5 files changed, 121 insertions(+), 98 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index de16ba25..ca1a645b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -14,6 +14,7 @@ import { AvatarDropdown, DocLink, ErrorBoundary, + ErrorBoundaryClass, Footer, LangDropdown, OfflineBanner, @@ -143,7 +144,7 @@ export const layout: RunTimeLayoutConfig = ({ : [], // Replace ProLayout's default ErrorBoundary with our offline-aware version, // so chunk load errors show friendly messages instead of "Something went wrong." - ErrorBoundary, + ErrorBoundary: ErrorBoundaryClass, menuHeaderRender: undefined, // 自定义 403 页面 // unAccessible:
unAccessible
, diff --git a/src/components/ErrorBoundary/index.tsx b/src/components/ErrorBoundary/index.tsx index d87b68e8..19096a7f 100644 --- a/src/components/ErrorBoundary/index.tsx +++ b/src/components/ErrorBoundary/index.tsx @@ -1,4 +1,4 @@ -import { getIntl } from '@umijs/max'; +import { getIntl, useModel } from '@umijs/max'; import { Button, Result } from 'antd'; import React from 'react'; @@ -10,42 +10,118 @@ function isChunkLoadError(error: Error): boolean { ); } +function getSubTitleId(isChunkError: boolean, isOffline: boolean): string { + if (!isChunkError) return 'app.error.render.description'; + return isOffline + ? 'app.error.chunk.description.offline' + : 'app.error.chunk.description.online'; +} + +function renderErrorFallback( + error: Error, + isOnline: boolean, + onRetry: () => void, +) { + const intl = getIntl(); + const isOffline = !isOnline; + const isChunkError = isChunkLoadError(error); + + return ( + + {intl.formatMessage({ + id: 'app.error.retry', + defaultMessage: 'Refresh', + })} + , + , + ]} + /> + ); +} + +interface ErrorBoundaryProps { + children: React.ReactNode; + /** When provided, skips internal online/offline listeners and uses this value. */ + isOnline?: boolean; +} + interface ErrorBoundaryState { hasError: boolean; error: Error | null; - isOnline: boolean; } -export default class ErrorBoundary extends React.Component< - { children: React.ReactNode }, +/** + * Class-based error boundary with offline-aware error messages. + * Accepts optional `isOnline` prop; falls back to own navigator.onLine tracking when unset. + */ +export class ErrorBoundaryClass extends React.Component< + ErrorBoundaryProps, ErrorBoundaryState > { - state: ErrorBoundaryState = { - hasError: false, - error: null, - isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true, - }; + state: ErrorBoundaryState = { hasError: false, error: null }; + private ownOnline = + typeof navigator !== 'undefined' ? navigator.onLine : true; static getDerivedStateFromError(error: Error): Partial { return { hasError: true, error }; } componentDidMount() { - window.addEventListener('online', this.handleOnline); - window.addEventListener('offline', this.handleOffline); + if (this.props.isOnline === undefined) { + window.addEventListener('online', this.handleOnline); + window.addEventListener('offline', this.handleOffline); + } + } + + componentDidUpdate(prev: ErrorBoundaryProps) { + // Auto-reload on recovery when isOnline prop transitions to true + if ( + this.props.isOnline === true && + prev.isOnline === false && + this.state.hasError + ) { + window.location.reload(); + } } componentWillUnmount() { - window.removeEventListener('online', this.handleOnline); - window.removeEventListener('offline', this.handleOffline); + if (this.props.isOnline === undefined) { + window.removeEventListener('online', this.handleOnline); + window.removeEventListener('offline', this.handleOffline); + } } handleOnline = () => { - this.setState({ isOnline: true }); + this.ownOnline = true; if (this.state.hasError) window.location.reload(); }; - handleOffline = () => this.setState({ isOnline: false }); + handleOffline = () => { + this.ownOnline = false; + }; componentDidCatch(error: Error, info: React.ErrorInfo) { console.error('[ErrorBoundary]', error, info.componentStack); @@ -59,55 +135,29 @@ export default class ErrorBoundary extends React.Component< } }; - render() { - if (!this.state.hasError || !this.state.error) { - return this.props.children; - } + getIsOnline(): boolean { + return this.props.isOnline ?? this.ownOnline; + } - const { error } = this.state; - const intl = getIntl(); - const isOffline = !this.state.isOnline; - const isChunkError = isChunkLoadError(error); - - const subTitleId = isChunkError - ? isOffline - ? 'app.error.chunk.description.offline' - : 'app.error.chunk.description.online' - : 'app.error.render.description'; - - return ( - - {intl.formatMessage({ - id: 'app.error.retry', - defaultMessage: 'Refresh', - })} - , - , - ]} - /> + render() { + if (!this.state.hasError || !this.state.error) return this.props.children; + return renderErrorFallback( + this.state.error, + this.getIsOnline(), + this.handleRetry, ); } } + +/** Functional wrapper providing network state via useModel('network'). */ +const ErrorBoundary: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { isOnline } = useModel('network'); + + return ( + {children} + ); +}; + +export default ErrorBoundary; diff --git a/src/components/OfflineBanner/index.tsx b/src/components/OfflineBanner/index.tsx index 8548d97e..66ed1eec 100644 --- a/src/components/OfflineBanner/index.tsx +++ b/src/components/OfflineBanner/index.tsx @@ -1,22 +1,8 @@ -import { getIntl } from '@umijs/max'; +import { getIntl, useModel } from '@umijs/max'; import { Alert } from 'antd'; -import { useEffect, useState } from 'react'; const OfflineBanner: React.FC = () => { - const [isOnline, setIsOnline] = useState( - typeof navigator !== 'undefined' ? navigator.onLine : true, - ); - - useEffect(() => { - const handleOnline = () => setIsOnline(true); - const handleOffline = () => setIsOnline(false); - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - }; - }, []); + const { isOnline } = useModel('network'); if (isOnline) return null; @@ -25,7 +11,7 @@ const OfflineBanner: React.FC = () => { type="warning" banner closable={false} - title={getIntl().formatMessage({ + message={getIntl().formatMessage({ id: 'app.network.offline', defaultMessage: 'You are currently offline. Some features may be unavailable.', diff --git a/src/components/index.ts b/src/components/index.ts index ff7d559b..62095eaa 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,7 +17,7 @@ export { default as AvatarList } from './AvatarList'; /** * 通用组件 */ -export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as ErrorBoundary, ErrorBoundaryClass } from './ErrorBoundary'; export { default as OfflineBanner } from './OfflineBanner'; export { default as StandardFormRow } from './StandardFormRow'; export { default as TagSelect } from './TagSelect'; diff --git a/src/requestErrorConfig.ts b/src/requestErrorConfig.ts index e081bba8..e1736b97 100644 --- a/src/requestErrorConfig.ts +++ b/src/requestErrorConfig.ts @@ -74,21 +74,6 @@ export const errorConfig: RequestConfig = { // Axios 的错误 // 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围 message.error(`Response status:${error.response.status}`); - } else if (error.request) { - // 请求已经成功发起,但没有收到响应 - // \`error.request\` 在浏览器中是 XMLHttpRequest 的实例, - // 而在node.js中是 http.ClientRequest 的实例 - if (typeof navigator !== 'undefined' && !navigator.onLine) { - message.error( - getIntl().formatMessage({ - id: 'app.request.offline', - defaultMessage: - 'Network unavailable. Please check your connection and try again.', - }), - ); - } else { - message.error('None response! Please retry.'); - } } else if (typeof navigator !== 'undefined' && !navigator.onLine) { message.error( getIntl().formatMessage({ @@ -97,8 +82,9 @@ export const errorConfig: RequestConfig = { 'Network unavailable. Please check your connection and try again.', }), ); + } else if (error.request) { + message.error('None response! Please retry.'); } else { - // 发送请求时出了点问题 message.error('Request error, please retry.'); } },