Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/calm-chunks-reject.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@callstack/repack': patch
---

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.
10 changes: 8 additions & 2 deletions apps/tester-federation-v2/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@
"bundleIdentifier": "com.tester.federationV2"
},
"resources": {
"android": [],
"ios": []
"android": [
"build/host-app/android/output-local/index.android.bundle",
"build/host-app/android/output-local/res"
],
"ios": [
"build/host-app/ios/output-local/main.jsbundle",
"build/host-app/ios/output-local/assets"
]
}
}
33 changes: 29 additions & 4 deletions apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';

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

class ErrorBoundary extends React.Component<
React.PropsWithChildren,
{ hasError: boolean }
> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

render() {
if (this.state.hasError) {
return (
<View style={styles.container}>
<Text>Failed to load Mini App</Text>
</View>
);
}

return this.props.children;
}
}

const FallbackComponent = () => (
<View style={styles.container}>
<ActivityIndicator color="rgba(56, 30, 114, 1)" size="large" />
Expand All @@ -11,9 +34,11 @@ const FallbackComponent = () => (

const MiniAppScreen = () => {
return (
<React.Suspense fallback={<FallbackComponent />}>
<MiniAppNavigator />
</React.Suspense>
<ErrorBoundary>
<React.Suspense fallback={<FallbackComponent />}>
<MiniAppNavigator />
</React.Suspense>
</ErrorBoundary>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ module.exports = function () {
var inGuard = false;
var originalWebpackRequire = __webpack_require__;

function isChunkLoadError(error: unknown) {
Comment thread
whydidoo marked this conversation as resolved.
return (
typeof error === 'object' &&
error !== null &&
(error as { name?: string }).name === 'ChunkLoadError'
);
}

// wrap __webpack_require__ calls to forward errors to global.ErrorUtils
// aligned with `guardedLoadModule` behaviour in Metro
// https://github.com/facebook/metro/blob/a4cb0b0e483748ef9f1c760cb60c57e3a84c1afd/packages/metro-runtime/src/polyfills/require.js#L329
Expand All @@ -16,6 +24,16 @@ module.exports = function () {
try {
exports = originalWebpackRequire(moduleId);
} catch (e) {
// Webpack and Rspack reject dynamic imports with ChunkLoadError when
// loading the requested chunk fails. Module Federation can surface the
// same transport error through a synthetic module factory, which makes
// it pass through this guard. Let it propagate back to the import
// promise so callers such as React.lazy can handle the rejection.
if (isChunkLoadError(e)) {
inGuard = false;
throw e;
}

// exposed as global early on, part of `@react-native/js-polyfills` error-guard
// https://github.com/facebook/react-native/blob/4dac99cf6d308e804efc098b37f5c24c1eb611cf/packages/polyfills/error-guard.js#L121
$globalObject$.ErrorUtils.reportFatalError(e);
Expand Down
147 changes: 147 additions & 0 deletions tests/integration/src/plugins/RepackTargetPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { createContext, runInContext } from 'node:vm';
import { plugins } from '@callstack/repack';
import type { Configuration } from '@rspack/core';
import { describe, expect, it } from 'vitest';
import {
compile,
createCompiler,
createVirtualModulePlugin,
} from '../helpers.js';

class ForceModuleFactoriesPlugin {
apply(compiler: any) {
compiler.hooks.compilation.tap(
'ForceModuleFactoriesPlugin',
(compilation: any) => {
compilation.hooks.additionalTreeRuntimeRequirements.tap(
'ForceModuleFactoriesPlugin',
(_chunk: unknown, runtimeRequirements: Set<string>) => {
runtimeRequirements.add(compiler.webpack.RuntimeGlobals.require);
runtimeRequirements.add(
compiler.webpack.RuntimeGlobals.moduleFactories
);
}
);
}
);
}
}

async function compileRuntime(
virtualModules: Record<string, string>,
entry = './index.js'
) {
const virtualPlugin = await createVirtualModulePlugin(virtualModules);
const compiler = await createCompiler({
context: __dirname,
mode: 'development',
devtool: false,
entry,
output: {
path: '/out',
filename: 'main.js',
},
plugins: [
virtualPlugin,
new ForceModuleFactoriesPlugin(),
new plugins.RepackTargetPlugin(),
],
} satisfies Configuration);

return compile(compiler);
}

function executeBundle(code: string) {
const fatalErrors: unknown[] = [];
const context = createContext({
ErrorUtils: {
reportFatalError(error: unknown) {
fatalErrors.push(error);
},
},
});

runInContext(code, context);

return { context, fatalErrors };
}

describe('RepackTargetPlugin guarded require', () => {
it('reports an uncaught startup module error as fatal', async () => {
const { code } = await compileRuntime(
{
'./index.cjs': 'throw new Error("startup module failed");',
},
'./index.cjs'
);

const { fatalErrors } = executeBundle(code);

expect(fatalErrors).toHaveLength(1);
expect(fatalErrors[0]).toMatchObject({
name: 'Error',
message: 'startup module failed',
});
});

it('preserves optional require and regular module error behavior', async () => {
const { code } = await compileRuntime(
{
'./index.cjs': `
try {
require('./optional.cjs');
} catch (error) {
globalThis.optionalRequireError = error.message;
}

globalThis.requireRegularModuleLater = function () {
return require('./regular.cjs');
};
`,
'./optional.cjs': 'throw new Error("optional module failed");',
'./regular.cjs': 'throw new Error("regular module failed");',
},
'./index.cjs'
);

const { context, fatalErrors } = executeBundle(code);

expect(context.optionalRequireError).toBe('optional module failed');
expect(fatalErrors).toHaveLength(0);

expect(context.requireRegularModuleLater()).toBeUndefined();
expect(fatalErrors).toHaveLength(1);
expect(fatalErrors[0]).toMatchObject({
name: 'Error',
message: 'regular module failed',
});
});

it('propagates ChunkLoadError to an asynchronous caller', async () => {
const { code } = await compileRuntime(
{
'./index.cjs': `
globalThis.importChunkLater = function () {
return Promise.resolve().then(function () {
return require('./chunk.cjs');
});
};
`,
'./chunk.cjs': `
var error = new Error('Loading chunk test failed');
error.name = 'ChunkLoadError';
throw error;
`,
},
'./index.cjs'
);

const { context, fatalErrors } = executeBundle(code);

await expect(context.importChunkLater()).rejects.toMatchObject({
name: 'ChunkLoadError',
message: 'Loading chunk test failed',
});
expect(fatalErrors).toHaveLength(0);
});
});
Loading