Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(runtime): allow errorLoadRemote hook catch remote entry resource loading error #3474

Merged
merged 19 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/long-forks-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@module-federation/runtime-core': patch
'website-new': patch
---

feat: allow errorLoadRemote hook catch remote entry resource loading error
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('router-remote-error in host', () => {
describe('Remote Resource Error render and will trigger ErrorBoundary', () => {
Cypress.on('uncaught:exception', () => false);
it('jump to remote error page', async () => {
cy.get('.host-menu > li:nth-child(8)').click();
cy.get('.host-menu > li:nth-child(8)').click({ force: true });

cy.get('[data-test-id="loading"]').should('have.length', 1);
cy.get('[data-test-id="loading"]')
Expand All @@ -16,7 +16,7 @@ describe('router-remote-error in host', () => {
await wait5s();
getP().contains('Something went wrong');
getPre().contains(
'Error: The request failed three times and has now been abandoned',
'The request failed three times and has now been abandoned',
);
});
});
Expand Down
3 changes: 2 additions & 1 deletion apps/router-demo/router-host-2000/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"@module-federation/retry-plugin": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1"
"react-router-dom": "^6.24.1",
"react-error-boundary": "5.0.0"
},
"devDependencies": {
"@module-federation/rsbuild-plugin": "workspace:*",
Expand Down
15 changes: 10 additions & 5 deletions apps/router-demo/router-host-2000/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,21 @@ export default defineConfig({
'remote-render-error':
'remote-render-error@http://localhost:2004/mf-manifest.json',
'remote-resource-error':
'remote-resource-errorr@http://localhost:2008/not-exist-mf-manifest.json',
'remote-resource-error@http://localhost:2008/not-exist-mf-manifest.json',
},
shared: {
react: {
singleton: true,
},
'react-dom': {
singleton: true,
},
},
shared: ['react', 'react-dom', 'antd'],
runtimePlugins: [
path.join(__dirname, './src/runtime-plugin/shared-strategy.ts'),
path.join(__dirname, './src/runtime-plugin/retry.ts'),
path.join(__dirname, './src/runtime-plugin/fallback.ts'),
],
// bridge: {
// disableAlias: true,
// },
}),
],
});
99 changes: 98 additions & 1 deletion apps/router-demo/router-host-2000/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { useRef, useEffect, ForwardRefExoticComponent } from 'react';
import React, {
useRef,
useEffect,
ForwardRefExoticComponent,
Suspense,
} from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import { init, loadRemote } from '@module-federation/enhanced/runtime';
import { RetryPlugin } from '@module-federation/retry-plugin';
Expand All @@ -8,6 +13,19 @@ import Detail from './pages/Detail';
import Home from './pages/Home';
import './App.css';
import BridgeReactPlugin from '@module-federation/bridge-react/plugin';
import { ErrorBoundary } from 'react-error-boundary';
import Remote1AppNew from 'remote1/app';
import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';
import { Spin } from 'antd';

const fallbackPlugin: () => FederationRuntimePlugin = function () {
return {
name: 'fallback-plugin',
errorLoadRemote(args) {
return { default: () => <div> fallback component </div> };
},
};
};

init({
name: 'federation_consumer',
Expand All @@ -30,6 +48,7 @@ init({
// },
// },
// }),
// fallbackPlugin(),
],
});

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

const Remote1AppWithLoadRemote = React.lazy(
() =>
new Promise((resolve) => {
// delay 2000ms to show suspense effects
setTimeout(() => {
resolve(loadRemote('remote1/app'));
}, 2000);
}),
);

const LoadingFallback = () => (
<div
style={{
padding: '50px',
textAlign: 'center',
background: '#f5f5f5',
border: '1px solid #d9d9d9',
borderRadius: '4px',
marginTop: '20px',
}}
>
<Spin size="large" />
<div
style={{
marginTop: '16px',
color: '#1677ff',
fontSize: '16px',
}}
>
Loading Remote1 App...
</div>
</div>
);

const Remote1AppWithErrorBoundary = React.forwardRef<any, any>((props, ref) => (
<ErrorBoundary
fallback={
<div
style={{
padding: '20px',
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: '4px',
color: '#cf1322',
marginTop: '20px',
}}
>
Error loading Remote1App. Please try again later.
</div>
}
>
<Suspense fallback={<LoadingFallback />}>
<Remote1AppWithLoadRemote {...props} ref={ref} />
</Suspense>
</ErrorBoundary>
));

const Remote2App = createRemoteComponent({
loader: () => import('remote2/export-app'),
export: 'provider',
Expand Down Expand Up @@ -145,6 +221,27 @@ const App = () => {
path="/remote-resource-error/*"
Component={() => <RemoteResourceErrorApp />}
/>
<Route
path="/error-load-with-hook/*"
Component={() => (
<Remote1AppNew name={'Ming'} age={12} />
// <React.Suspense fallback={<div> Loading Remote1App...</div>}>
// <Remote1AppWithLoadRemote name={'Ming'} age={12} />
// </React.Suspense>
)}
/>

<Route
path="/error-load-with-error-boundary/*"
Component={() => (
<Remote1AppWithErrorBoundary
name={'Ming'}
age={12}
ref={ref}
basename="/remote1"
/>
)}
/>
</Routes>
</div>
);
Expand Down
14 changes: 14 additions & 0 deletions apps/router-demo/router-host-2000/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ function Navgation() {
key: '/remote-resource-error',
icon: <GroupOutlined />,
},
{
label: <Link to="/error-load-with-hook">error-load-with-hook</Link>,
key: '/error-load-with-hook',
icon: <GroupOutlined />,
},
{
label: (
<Link to="/error-load-with-error-boundary">
error-load-with-error-boundary
</Link>
),
key: '/error-load-with-error-boundary',
icon: <GroupOutlined />,
},
];

const onClick: MenuProps['onClick'] = (e) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Error Handling Strategies for Module Federation
*
* This module provides different strategies for handling remote module loading errors.
* Choose the strategy that best fits your needs:
*
* 1. Lifecycle-based Strategy:
* - Handles errors differently based on the lifecycle stage
* - Provides backup service support for entry file errors
* - More granular control over error handling
*
* 2. Simple Strategy:
* - Single fallback component for all error types
* - Consistent error presentation
* - Minimal configuration required
*
* Example usage:
* ```typescript
* import { createLifecycleBasedPlugin, createSimplePlugin } from './error-handling';
*
* // Use lifecycle-based strategy
* const plugin1 = createLifecycleBasedPlugin({
* backupEntryUrl: 'http://backup-server/manifest.json',
* errorMessage: 'Custom error message'
* });
*
* // Use simple strategy
* const plugin2 = createSimplePlugin({
* errorMessage: 'Module failed to load'
* });
* ```
*/

export * from './lifecycle-based';
export * from './simple';
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Lifecycle-based Error Handling Strategy
*
* This implementation demonstrates a more granular approach to error handling
* by responding differently based on the lifecycle stage where the error occurred.
*
* Two main stages are handled:
* 1. Component Loading (onLoad): Provides a UI fallback for component rendering failures
* 2. Entry File Loading (afterResolve): Attempts to load from a backup service
*/

import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';

interface LifecycleBasedConfig {
backupEntryUrl?: string;
errorMessage?: string;
}

export const createLifecycleBasedPlugin = (
config: LifecycleBasedConfig = {},
): FederationRuntimePlugin => {
const {
backupEntryUrl = 'http://localhost:2002/mf-manifest.json',
errorMessage = 'Module loading failed, please try again later',
} = config;

return {
name: 'lifecycle-based-fallback-plugin',
async errorLoadRemote(args) {
// Handle component loading errors
if (args.lifecycle === 'onLoad') {
const React = await import('react');

// Create a fallback component with error message
const FallbackComponent = React.memo(() => {
return React.createElement(
'div',
{
style: {
padding: '16px',
border: '1px solid #ffa39e',
borderRadius: '4px',
backgroundColor: '#fff1f0',
color: '#cf1322',
},
},
errorMessage,
);
});

FallbackComponent.displayName = 'ErrorFallbackComponent';

return () => ({
__esModule: true,
default: FallbackComponent,
});
}

// Handle entry file loading errors
if (args.lifecycle === 'afterResolve') {
try {
// Try to load backup service
const response = await fetch(backupEntryUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch backup entry: ${response.statusText}`,
);
}
const backupManifest = await response.json();
console.info('Successfully loaded backup manifest');
return backupManifest;
} catch (error) {
console.error('Failed to load backup manifest:', error);
// If backup service also fails, return original error
return args;
}
}

return args;
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Simple Error Handling Strategy
*
* This implementation provides a straightforward approach to error handling
* by using a single fallback component for all types of errors.
*
* Benefits:
* - Simple to understand and implement
* - Consistent error presentation
* - Requires minimal configuration
*
* Use this when you don't need different handling strategies for different error types.
*/

import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';

interface SimpleConfig {
errorMessage?: string;
}

export const createSimplePlugin = (
config: SimpleConfig = {},
): FederationRuntimePlugin => {
const { errorMessage = 'Module loading failed, please try again later' } =
config;

return {
name: 'simple-fallback-plugin',
async errorLoadRemote() {
const React = await import('react');

// Create a fallback component with error message
const FallbackComponent = React.memo(() => {
return React.createElement(
'div',
{
style: {
padding: '16px',
border: '1px solid #ffa39e',
borderRadius: '4px',
backgroundColor: '#fff1f0',
color: '#cf1322',
},
},
errorMessage,
);
});

FallbackComponent.displayName = 'ErrorFallbackComponent';

return () => ({
__esModule: true,
default: FallbackComponent,
});
},
};
};
Loading
Loading