Skip to content

Commit 9f3278c

Browse files
authored
fix: let error boundaries handle chunk loading failures (#1440)
1 parent c17c212 commit 9f3278c

5 files changed

Lines changed: 207 additions & 6 deletions

File tree

‎.changeset/calm-chunks-reject.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@callstack/repack': patch
3+
---
4+
5+
Let `ChunkLoadError` propagate through the guarded `__webpack_require__` instead of reporting it as fatal, so a failed dynamic import (including a missing Module Federation exposed chunk) rejects the import promise and can be handled by a React Error Boundary. Other remote loading failures, such as an unreachable remote entry, are not affected by this change.

‎apps/tester-federation-v2/app.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@
2525
"bundleIdentifier": "com.tester.federationV2"
2626
},
2727
"resources": {
28-
"android": [],
29-
"ios": []
28+
"android": [
29+
"build/host-app/android/output-local/index.android.bundle",
30+
"build/host-app/android/output-local/res"
31+
],
32+
"ios": [
33+
"build/host-app/ios/output-local/main.jsbundle",
34+
"build/host-app/ios/output-local/assets"
35+
]
3036
}
3137
}

‎apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,31 @@
11
import React from 'react';
2-
import { ActivityIndicator, StyleSheet, View } from 'react-native';
2+
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
33

44
const MiniAppNavigator = React.lazy(() => import('MiniApp/MiniAppNavigator'));
55

6+
class ErrorBoundary extends React.Component<
7+
React.PropsWithChildren,
8+
{ hasError: boolean }
9+
> {
10+
state = { hasError: false };
11+
12+
static getDerivedStateFromError() {
13+
return { hasError: true };
14+
}
15+
16+
render() {
17+
if (this.state.hasError) {
18+
return (
19+
<View style={styles.container}>
20+
<Text>Failed to load Mini App</Text>
21+
</View>
22+
);
23+
}
24+
25+
return this.props.children;
26+
}
27+
}
28+
629
const FallbackComponent = () => (
730
<View style={styles.container}>
831
<ActivityIndicator color="rgba(56, 30, 114, 1)" size="large" />
@@ -11,9 +34,11 @@ const FallbackComponent = () => (
1134

1235
const MiniAppScreen = () => {
1336
return (
14-
<React.Suspense fallback={<FallbackComponent />}>
15-
<MiniAppNavigator />
16-
</React.Suspense>
37+
<ErrorBoundary>
38+
<React.Suspense fallback={<FallbackComponent />}>
39+
<MiniAppNavigator />
40+
</React.Suspense>
41+
</ErrorBoundary>
1742
);
1843
};
1944

‎packages/repack/src/plugins/RepackTargetPlugin/implementation/guardedRequire.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ module.exports = function () {
66
var inGuard = false;
77
var originalWebpackRequire = __webpack_require__;
88

9+
function isChunkLoadError(error: unknown) {
10+
return (
11+
typeof error === 'object' &&
12+
error !== null &&
13+
(error as { name?: string }).name === 'ChunkLoadError'
14+
);
15+
}
16+
917
// wrap __webpack_require__ calls to forward errors to global.ErrorUtils
1018
// aligned with `guardedLoadModule` behaviour in Metro
1119
// https://github.com/facebook/metro/blob/a4cb0b0e483748ef9f1c760cb60c57e3a84c1afd/packages/metro-runtime/src/polyfills/require.js#L329
@@ -16,6 +24,16 @@ module.exports = function () {
1624
try {
1725
exports = originalWebpackRequire(moduleId);
1826
} catch (e) {
27+
// Webpack and Rspack reject dynamic imports with ChunkLoadError when
28+
// loading the requested chunk fails. Module Federation can surface the
29+
// same transport error through a synthetic module factory, which makes
30+
// it pass through this guard. Let it propagate back to the import
31+
// promise so callers such as React.lazy can handle the rejection.
32+
if (isChunkLoadError(e)) {
33+
inGuard = false;
34+
throw e;
35+
}
36+
1937
// exposed as global early on, part of `@react-native/js-polyfills` error-guard
2038
// https://github.com/facebook/react-native/blob/4dac99cf6d308e804efc098b37f5c24c1eb611cf/packages/polyfills/error-guard.js#L121
2139
$globalObject$.ErrorUtils.reportFatalError(e);
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { createContext, runInContext } from 'node:vm';
2+
import { plugins } from '@callstack/repack';
3+
import type { Configuration } from '@rspack/core';
4+
import { describe, expect, it } from 'vitest';
5+
import {
6+
compile,
7+
createCompiler,
8+
createVirtualModulePlugin,
9+
} from '../helpers.js';
10+
11+
class ForceModuleFactoriesPlugin {
12+
apply(compiler: any) {
13+
compiler.hooks.compilation.tap(
14+
'ForceModuleFactoriesPlugin',
15+
(compilation: any) => {
16+
compilation.hooks.additionalTreeRuntimeRequirements.tap(
17+
'ForceModuleFactoriesPlugin',
18+
(_chunk: unknown, runtimeRequirements: Set<string>) => {
19+
runtimeRequirements.add(compiler.webpack.RuntimeGlobals.require);
20+
runtimeRequirements.add(
21+
compiler.webpack.RuntimeGlobals.moduleFactories
22+
);
23+
}
24+
);
25+
}
26+
);
27+
}
28+
}
29+
30+
async function compileRuntime(
31+
virtualModules: Record<string, string>,
32+
entry = './index.js'
33+
) {
34+
const virtualPlugin = await createVirtualModulePlugin(virtualModules);
35+
const compiler = await createCompiler({
36+
context: __dirname,
37+
mode: 'development',
38+
devtool: false,
39+
entry,
40+
output: {
41+
path: '/out',
42+
filename: 'main.js',
43+
},
44+
plugins: [
45+
virtualPlugin,
46+
new ForceModuleFactoriesPlugin(),
47+
new plugins.RepackTargetPlugin(),
48+
],
49+
} satisfies Configuration);
50+
51+
return compile(compiler);
52+
}
53+
54+
function executeBundle(code: string) {
55+
const fatalErrors: unknown[] = [];
56+
const context = createContext({
57+
ErrorUtils: {
58+
reportFatalError(error: unknown) {
59+
fatalErrors.push(error);
60+
},
61+
},
62+
});
63+
64+
runInContext(code, context);
65+
66+
return { context, fatalErrors };
67+
}
68+
69+
describe('RepackTargetPlugin guarded require', () => {
70+
it('reports an uncaught startup module error as fatal', async () => {
71+
const { code } = await compileRuntime(
72+
{
73+
'./index.cjs': 'throw new Error("startup module failed");',
74+
},
75+
'./index.cjs'
76+
);
77+
78+
const { fatalErrors } = executeBundle(code);
79+
80+
expect(fatalErrors).toHaveLength(1);
81+
expect(fatalErrors[0]).toMatchObject({
82+
name: 'Error',
83+
message: 'startup module failed',
84+
});
85+
});
86+
87+
it('preserves optional require and regular module error behavior', async () => {
88+
const { code } = await compileRuntime(
89+
{
90+
'./index.cjs': `
91+
try {
92+
require('./optional.cjs');
93+
} catch (error) {
94+
globalThis.optionalRequireError = error.message;
95+
}
96+
97+
globalThis.requireRegularModuleLater = function () {
98+
return require('./regular.cjs');
99+
};
100+
`,
101+
'./optional.cjs': 'throw new Error("optional module failed");',
102+
'./regular.cjs': 'throw new Error("regular module failed");',
103+
},
104+
'./index.cjs'
105+
);
106+
107+
const { context, fatalErrors } = executeBundle(code);
108+
109+
expect(context.optionalRequireError).toBe('optional module failed');
110+
expect(fatalErrors).toHaveLength(0);
111+
112+
expect(context.requireRegularModuleLater()).toBeUndefined();
113+
expect(fatalErrors).toHaveLength(1);
114+
expect(fatalErrors[0]).toMatchObject({
115+
name: 'Error',
116+
message: 'regular module failed',
117+
});
118+
});
119+
120+
it('propagates ChunkLoadError to an asynchronous caller', async () => {
121+
const { code } = await compileRuntime(
122+
{
123+
'./index.cjs': `
124+
globalThis.importChunkLater = function () {
125+
return Promise.resolve().then(function () {
126+
return require('./chunk.cjs');
127+
});
128+
};
129+
`,
130+
'./chunk.cjs': `
131+
var error = new Error('Loading chunk test failed');
132+
error.name = 'ChunkLoadError';
133+
throw error;
134+
`,
135+
},
136+
'./index.cjs'
137+
);
138+
139+
const { context, fatalErrors } = executeBundle(code);
140+
141+
await expect(context.importChunkLater()).rejects.toMatchObject({
142+
name: 'ChunkLoadError',
143+
message: 'Loading chunk test failed',
144+
});
145+
expect(fatalErrors).toHaveLength(0);
146+
});
147+
});

0 commit comments

Comments
 (0)