Browse Source

refactor: simplify offline handling and fix OfflineBanner not showing

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 <noreply@anthropic.com>
pull/11756/head
afc163 3 weeks ago
parent
commit
39c5dcab73
  1. 3
      src/app.tsx
  2. 176
      src/components/ErrorBoundary/index.tsx
  3. 20
      src/components/OfflineBanner/index.tsx
  4. 2
      src/components/index.ts
  5. 18
      src/requestErrorConfig.ts

3
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: <div>unAccessible</div>,

176
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 (
<Result
status="error"
title={intl.formatMessage({
id: isChunkError ? 'app.error.chunk.title' : 'app.error.render.title',
defaultMessage: isChunkError
? 'Failed to load page'
: 'Something went wrong',
})}
subTitle={intl.formatMessage({
id: getSubTitleId(isChunkError, isOffline),
defaultMessage:
isChunkError && isOffline
? 'Your network connection has been lost. Please check your connection and refresh.'
: isChunkError
? 'Page resources failed to load. Please refresh and try again.'
: 'Sorry, an error occurred on this page. Please refresh or go back to the home page.',
})}
extra={[
<Button type="primary" key="retry" onClick={onRetry}>
{intl.formatMessage({
id: 'app.error.retry',
defaultMessage: 'Refresh',
})}
</Button>,
<Button href="/" key="home">
{intl.formatMessage({
id: 'app.error.home',
defaultMessage: 'Back Home',
})}
</Button>,
]}
/>
);
}
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<ErrorBoundaryState> {
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 (
<Result
status="error"
title={intl.formatMessage({
id: isChunkError ? 'app.error.chunk.title' : 'app.error.render.title',
defaultMessage: isChunkError
? 'Failed to load page'
: 'Something went wrong',
})}
subTitle={intl.formatMessage({
id: subTitleId,
defaultMessage:
isChunkError && isOffline
? 'Your network connection has been lost. Please check your connection and refresh.'
: isChunkError
? 'Page resources failed to load. Please refresh and try again.'
: 'Sorry, an error occurred on this page. Please refresh or go back to the home page.',
})}
extra={[
<Button type="primary" key="retry" onClick={this.handleRetry}>
{intl.formatMessage({
id: 'app.error.retry',
defaultMessage: 'Refresh',
})}
</Button>,
<Button href="/" key="home">
{intl.formatMessage({
id: 'app.error.home',
defaultMessage: 'Back Home',
})}
</Button>,
]}
/>
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 (
<ErrorBoundaryClass isOnline={isOnline}>{children}</ErrorBoundaryClass>
);
};
export default ErrorBoundary;

20
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.',

2
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';

18
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.');
}
},

Loading…
Cancel
Save