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); + }); +});