Skip to content

Commit 9566fbc

Browse files
danpeenKyrieLii
andauthored
feat(runtime): allow errorLoadRemote hook catch remote entry resource loading error (#3474)
Co-authored-by: kyli <[email protected]>
1 parent e751ad0 commit 9566fbc

File tree

23 files changed

+1519
-59
lines changed

23 files changed

+1519
-59
lines changed

.changeset/long-forks-rule.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@module-federation/runtime-core': patch
3+
'website-new': patch
4+
---
5+
6+
feat: allow errorLoadRemote hook catch remote entry resource loading error

apps/router-demo/router-host-2000/cypress/e2e/remote-resource-error.cy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('router-remote-error in host', () => {
66
describe('Remote Resource Error render and will trigger ErrorBoundary', () => {
77
Cypress.on('uncaught:exception', () => false);
88
it('jump to remote error page', async () => {
9-
cy.get('.host-menu > li:nth-child(8)').click();
9+
cy.get('.host-menu > li:nth-child(8)').click({ force: true });
1010

1111
cy.get('[data-test-id="loading"]').should('have.length', 1);
1212
cy.get('[data-test-id="loading"]')
@@ -16,7 +16,7 @@ describe('router-remote-error in host', () => {
1616
await wait5s();
1717
getP().contains('Something went wrong');
1818
getPre().contains(
19-
'Error: The request failed three times and has now been abandoned',
19+
'The request failed three times and has now been abandoned',
2020
);
2121
});
2222
});

apps/router-demo/router-host-2000/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"@module-federation/retry-plugin": "workspace:*",
1515
"react": "^18.3.1",
1616
"react-dom": "^18.3.1",
17-
"react-router-dom": "^6.24.1"
17+
"react-router-dom": "^6.24.1",
18+
"react-error-boundary": "5.0.0"
1819
},
1920
"devDependencies": {
2021
"@module-federation/rsbuild-plugin": "workspace:*",

apps/router-demo/router-host-2000/rsbuild.config.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,21 @@ export default defineConfig({
2626
'remote-render-error':
2727
'remote-render-error@http://localhost:2004/mf-manifest.json',
2828
'remote-resource-error':
29-
'remote-resource-errorr@http://localhost:2008/not-exist-mf-manifest.json',
29+
'remote-resource-error@http://localhost:2008/not-exist-mf-manifest.json',
30+
},
31+
shared: {
32+
react: {
33+
singleton: true,
34+
},
35+
'react-dom': {
36+
singleton: true,
37+
},
3038
},
31-
shared: ['react', 'react-dom', 'antd'],
3239
runtimePlugins: [
3340
path.join(__dirname, './src/runtime-plugin/shared-strategy.ts'),
3441
path.join(__dirname, './src/runtime-plugin/retry.ts'),
42+
path.join(__dirname, './src/runtime-plugin/fallback.ts'),
3543
],
36-
// bridge: {
37-
// disableAlias: true,
38-
// },
3944
}),
4045
],
4146
});

apps/router-demo/router-host-2000/src/App.tsx

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { useRef, useEffect, ForwardRefExoticComponent } from 'react';
1+
import React, {
2+
useRef,
3+
useEffect,
4+
ForwardRefExoticComponent,
5+
Suspense,
6+
} from 'react';
27
import { Route, Routes, useLocation } from 'react-router-dom';
38
import { init, loadRemote } from '@module-federation/enhanced/runtime';
49
import { RetryPlugin } from '@module-federation/retry-plugin';
@@ -8,6 +13,19 @@ import Detail from './pages/Detail';
813
import Home from './pages/Home';
914
import './App.css';
1015
import BridgeReactPlugin from '@module-federation/bridge-react/plugin';
16+
import { ErrorBoundary } from 'react-error-boundary';
17+
import Remote1AppNew from 'remote1/app';
18+
import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';
19+
import { Spin } from 'antd';
20+
21+
const fallbackPlugin: () => FederationRuntimePlugin = function () {
22+
return {
23+
name: 'fallback-plugin',
24+
errorLoadRemote(args) {
25+
return { default: () => <div> fallback component </div> };
26+
},
27+
};
28+
};
1129

1230
init({
1331
name: 'federation_consumer',
@@ -30,6 +48,7 @@ init({
3048
// },
3149
// },
3250
// }),
51+
// fallbackPlugin(),
3352
],
3453
});
3554

@@ -54,6 +73,63 @@ const Remote1App = createRemoteComponent({
5473
loading: FallbackComp,
5574
});
5675

76+
const Remote1AppWithLoadRemote = React.lazy(
77+
() =>
78+
new Promise((resolve) => {
79+
// delay 2000ms to show suspense effects
80+
setTimeout(() => {
81+
resolve(loadRemote('remote1/app'));
82+
}, 2000);
83+
}),
84+
);
85+
86+
const LoadingFallback = () => (
87+
<div
88+
style={{
89+
padding: '50px',
90+
textAlign: 'center',
91+
background: '#f5f5f5',
92+
border: '1px solid #d9d9d9',
93+
borderRadius: '4px',
94+
marginTop: '20px',
95+
}}
96+
>
97+
<Spin size="large" />
98+
<div
99+
style={{
100+
marginTop: '16px',
101+
color: '#1677ff',
102+
fontSize: '16px',
103+
}}
104+
>
105+
Loading Remote1 App...
106+
</div>
107+
</div>
108+
);
109+
110+
const Remote1AppWithErrorBoundary = React.forwardRef<any, any>((props, ref) => (
111+
<ErrorBoundary
112+
fallback={
113+
<div
114+
style={{
115+
padding: '20px',
116+
background: '#fff2f0',
117+
border: '1px solid #ffccc7',
118+
borderRadius: '4px',
119+
color: '#cf1322',
120+
marginTop: '20px',
121+
}}
122+
>
123+
Error loading Remote1App. Please try again later.
124+
</div>
125+
}
126+
>
127+
<Suspense fallback={<LoadingFallback />}>
128+
<Remote1AppWithLoadRemote {...props} ref={ref} />
129+
</Suspense>
130+
</ErrorBoundary>
131+
));
132+
57133
const Remote2App = createRemoteComponent({
58134
loader: () => import('remote2/export-app'),
59135
export: 'provider',
@@ -145,6 +221,27 @@ const App = () => {
145221
path="/remote-resource-error/*"
146222
Component={() => <RemoteResourceErrorApp />}
147223
/>
224+
<Route
225+
path="/error-load-with-hook/*"
226+
Component={() => (
227+
<Remote1AppNew name={'Ming'} age={12} />
228+
// <React.Suspense fallback={<div> Loading Remote1App...</div>}>
229+
// <Remote1AppWithLoadRemote name={'Ming'} age={12} />
230+
// </React.Suspense>
231+
)}
232+
/>
233+
234+
<Route
235+
path="/error-load-with-error-boundary/*"
236+
Component={() => (
237+
<Remote1AppWithErrorBoundary
238+
name={'Ming'}
239+
age={12}
240+
ref={ref}
241+
basename="/remote1"
242+
/>
243+
)}
244+
/>
148245
</Routes>
149246
</div>
150247
);

apps/router-demo/router-host-2000/src/navigation.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,20 @@ function Navgation() {
133133
key: '/remote-resource-error',
134134
icon: <GroupOutlined />,
135135
},
136+
{
137+
label: <Link to="/error-load-with-hook">error-load-with-hook</Link>,
138+
key: '/error-load-with-hook',
139+
icon: <GroupOutlined />,
140+
},
141+
{
142+
label: (
143+
<Link to="/error-load-with-error-boundary">
144+
error-load-with-error-boundary
145+
</Link>
146+
),
147+
key: '/error-load-with-error-boundary',
148+
icon: <GroupOutlined />,
149+
},
136150
];
137151

138152
const onClick: MenuProps['onClick'] = (e) => {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Error Handling Strategies for Module Federation
3+
*
4+
* This module provides different strategies for handling remote module loading errors.
5+
* Choose the strategy that best fits your needs:
6+
*
7+
* 1. Lifecycle-based Strategy:
8+
* - Handles errors differently based on the lifecycle stage
9+
* - Provides backup service support for entry file errors
10+
* - More granular control over error handling
11+
*
12+
* 2. Simple Strategy:
13+
* - Single fallback component for all error types
14+
* - Consistent error presentation
15+
* - Minimal configuration required
16+
*
17+
* Example usage:
18+
* ```typescript
19+
* import { createLifecycleBasedPlugin, createSimplePlugin } from './error-handling';
20+
*
21+
* // Use lifecycle-based strategy
22+
* const plugin1 = createLifecycleBasedPlugin({
23+
* backupEntryUrl: 'http://backup-server/manifest.json',
24+
* errorMessage: 'Custom error message'
25+
* });
26+
*
27+
* // Use simple strategy
28+
* const plugin2 = createSimplePlugin({
29+
* errorMessage: 'Module failed to load'
30+
* });
31+
* ```
32+
*/
33+
34+
export * from './lifecycle-based';
35+
export * from './simple';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Lifecycle-based Error Handling Strategy
3+
*
4+
* This implementation demonstrates a more granular approach to error handling
5+
* by responding differently based on the lifecycle stage where the error occurred.
6+
*
7+
* Two main stages are handled:
8+
* 1. Component Loading (onLoad): Provides a UI fallback for component rendering failures
9+
* 2. Entry File Loading (afterResolve): Attempts to load from a backup service
10+
*/
11+
12+
import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';
13+
14+
interface LifecycleBasedConfig {
15+
backupEntryUrl?: string;
16+
errorMessage?: string;
17+
}
18+
19+
export const createLifecycleBasedPlugin = (
20+
config: LifecycleBasedConfig = {},
21+
): FederationRuntimePlugin => {
22+
const {
23+
backupEntryUrl = 'http://localhost:2002/mf-manifest.json',
24+
errorMessage = 'Module loading failed, please try again later',
25+
} = config;
26+
27+
return {
28+
name: 'lifecycle-based-fallback-plugin',
29+
async errorLoadRemote(args) {
30+
// Handle component loading errors
31+
if (args.lifecycle === 'onLoad') {
32+
const React = await import('react');
33+
34+
// Create a fallback component with error message
35+
const FallbackComponent = React.memo(() => {
36+
return React.createElement(
37+
'div',
38+
{
39+
style: {
40+
padding: '16px',
41+
border: '1px solid #ffa39e',
42+
borderRadius: '4px',
43+
backgroundColor: '#fff1f0',
44+
color: '#cf1322',
45+
},
46+
},
47+
errorMessage,
48+
);
49+
});
50+
51+
FallbackComponent.displayName = 'ErrorFallbackComponent';
52+
53+
return () => ({
54+
__esModule: true,
55+
default: FallbackComponent,
56+
});
57+
}
58+
59+
// Handle entry file loading errors
60+
if (args.lifecycle === 'afterResolve') {
61+
try {
62+
// Try to load backup service
63+
const response = await fetch(backupEntryUrl);
64+
if (!response.ok) {
65+
throw new Error(
66+
`Failed to fetch backup entry: ${response.statusText}`,
67+
);
68+
}
69+
const backupManifest = await response.json();
70+
console.info('Successfully loaded backup manifest');
71+
return backupManifest;
72+
} catch (error) {
73+
console.error('Failed to load backup manifest:', error);
74+
// If backup service also fails, return original error
75+
return args;
76+
}
77+
}
78+
79+
return args;
80+
},
81+
};
82+
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Simple Error Handling Strategy
3+
*
4+
* This implementation provides a straightforward approach to error handling
5+
* by using a single fallback component for all types of errors.
6+
*
7+
* Benefits:
8+
* - Simple to understand and implement
9+
* - Consistent error presentation
10+
* - Requires minimal configuration
11+
*
12+
* Use this when you don't need different handling strategies for different error types.
13+
*/
14+
15+
import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';
16+
17+
interface SimpleConfig {
18+
errorMessage?: string;
19+
}
20+
21+
export const createSimplePlugin = (
22+
config: SimpleConfig = {},
23+
): FederationRuntimePlugin => {
24+
const { errorMessage = 'Module loading failed, please try again later' } =
25+
config;
26+
27+
return {
28+
name: 'simple-fallback-plugin',
29+
async errorLoadRemote() {
30+
const React = await import('react');
31+
32+
// Create a fallback component with error message
33+
const FallbackComponent = React.memo(() => {
34+
return React.createElement(
35+
'div',
36+
{
37+
style: {
38+
padding: '16px',
39+
border: '1px solid #ffa39e',
40+
borderRadius: '4px',
41+
backgroundColor: '#fff1f0',
42+
color: '#cf1322',
43+
},
44+
},
45+
errorMessage,
46+
);
47+
});
48+
49+
FallbackComponent.displayName = 'ErrorFallbackComponent';
50+
51+
return () => ({
52+
__esModule: true,
53+
default: FallbackComponent,
54+
});
55+
},
56+
};
57+
};

0 commit comments

Comments
 (0)