diff --git a/.changeset/calm-chunks-reject.md b/.changeset/calm-chunks-reject.md
new file mode 100644
index 000000000..aeaf614a1
--- /dev/null
+++ b/.changeset/calm-chunks-reject.md
@@ -0,0 +1,5 @@
+---
+'@callstack/repack': patch
+---
+
+Allow chunk loading failures to reject dynamic imports without bypassing React Error Boundaries.
diff --git a/apps/tester-federation-v2/app.json b/apps/tester-federation-v2/app.json
index 7d155773f..0178aab12 100644
--- a/apps/tester-federation-v2/app.json
+++ b/apps/tester-federation-v2/app.json
@@ -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"
+ ]
}
}
diff --git a/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx b/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx
index fa9032b27..af41cfd97 100644
--- a/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx
+++ b/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx
@@ -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 (
+
+ Failed to load Mini App
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
const FallbackComponent = () => (
@@ -11,9 +34,11 @@ const FallbackComponent = () => (
const MiniAppScreen = () => {
return (
- }>
-
-
+
+ }>
+
+
+
);
};
diff --git a/packages/repack/src/plugins/RepackTargetPlugin/implementation/guardedRequire.ts b/packages/repack/src/plugins/RepackTargetPlugin/implementation/guardedRequire.ts
index dd7834586..a806d03cd 100644
--- a/packages/repack/src/plugins/RepackTargetPlugin/implementation/guardedRequire.ts
+++ b/packages/repack/src/plugins/RepackTargetPlugin/implementation/guardedRequire.ts
@@ -6,6 +6,14 @@ module.exports = function () {
var inGuard = false;
var originalWebpackRequire = __webpack_require__;
+ function isChunkLoadError(error: unknown) {
+ 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
@@ -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);
diff --git a/tests/integration/src/plugins/RepackTargetPlugin.test.ts b/tests/integration/src/plugins/RepackTargetPlugin.test.ts
new file mode 100644
index 000000000..eb858ec21
--- /dev/null
+++ b/tests/integration/src/plugins/RepackTargetPlugin.test.ts
@@ -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) => {
+ runtimeRequirements.add(compiler.webpack.RuntimeGlobals.require);
+ runtimeRequirements.add(
+ compiler.webpack.RuntimeGlobals.moduleFactories
+ );
+ }
+ );
+ }
+ );
+ }
+}
+
+async function compileRuntime(
+ virtualModules: Record,
+ 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);
+ });
+});