From 360092978a4934b36d8a8dcb3f89985d90502c92 Mon Sep 17 00:00:00 2001 From: Hassan Khan Date: Fri, 12 Jun 2026 19:03:21 +0100 Subject: [PATCH 1/3] fix(expo): Fix `asyncRoutes` failing on Android and iOS with `Requiring unknown module` (#46870) # Why Enabling `asyncRoutes` on Android/iOS breaks every async route in development with errors like: ``` ERROR [Error: Requiring unknown module "1237". If you are sure the module exists, try restarting Metro. ...] ``` This is a regression from #46539, which made `asyncRequire` try a synchronous `importAll()` first, and only fall back to fetching the split bundle when the import _throws_. This only worked for web; on native, the catch-based fallback never runs, the split bundle is never fetched, and the route resolves to `undefined`. # How Added a web-only platform check for the behavior introduced in #46539, and restored the original behavior for native. # Test Plan - CI # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com> --- packages/expo/CHANGELOG.md | 1 + .../__tests__/asyncRequireModule.test.ts | 70 ++++++++++++++++--- .../src/async-require/asyncRequireModule.ts | 30 +++++--- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/packages/expo/CHANGELOG.md b/packages/expo/CHANGELOG.md index 197653431b52d6..dc4f908071f7b5 100644 --- a/packages/expo/CHANGELOG.md +++ b/packages/expo/CHANGELOG.md @@ -16,6 +16,7 @@ - Prevent fatal `The stream is not in a state that permits close` in `expo/fetch` when native delivers `didComplete`/`didFailWithError` after the consumer has already canceled the body stream. ([#44909](https://github.com/expo/expo/pull/44909) by [@safaiyeh](https://github.com/safaiyeh)) - Adopted the UIKit scene-based life cycle on iOS so apps built with the iOS 27 SDK launch correctly. ([#46733](https://github.com/expo/expo/pull/46733) by [@alanjhughes](https://github.com/alanjhughes)) - [iOS] Mark `ExpoAppSceneDelegate` as unavailable in extensions. ([#46799](https://github.com/expo/expo/pull/46799) by [@jakex7](https://github.com/jakex7)) +- Fix `asyncRoutes` failing on Android and iOS with `Requiring unknown module` ([#46870](https://github.com/expo/expo/pull/46870) by [@hassankhan](https://github.com/hassankhan)) ### 💡 Others diff --git a/packages/expo/src/async-require/__tests__/asyncRequireModule.test.ts b/packages/expo/src/async-require/__tests__/asyncRequireModule.test.ts index 4d333167776da3..dc837c933d743c 100644 --- a/packages/expo/src/async-require/__tests__/asyncRequireModule.test.ts +++ b/packages/expo/src/async-require/__tests__/asyncRequireModule.test.ts @@ -15,6 +15,7 @@ describe('asyncRequireModule', () => { let mockImportAll: jest.Mock; let mockRequire: any; let asyncRequire: any; + const originalExpoOs = process.env.EXPO_OS; beforeEach(() => { mockImportAll = jest.fn((id: number, _moduleName?: string) => ({ @@ -64,16 +65,27 @@ describe('asyncRequireModule', () => { function asyncRequireImpl(moduleID, paths, moduleName) { var importAll = function() { return require.importAll(moduleID, moduleName); }; - try { - // Try importing first to skip bundle loading when the bundle is already preloaded. - return importAll(); - } catch (error) { - var maybeLoadBundlePromise = maybeLoadBundle(moduleID, paths); - if (maybeLoadBundlePromise != null) { - return maybeLoadBundlePromise.then(importAll); + + // On web, importing synchronously first prevents double-loading preloaded scripts + if (process.env.EXPO_OS === 'web') { + try { + return importAll(); + } catch (error) { + var maybeLoadBundlePromise = maybeLoadBundle(moduleID, paths); + if (maybeLoadBundlePromise != null) { + return maybeLoadBundlePromise.then(importAll); + } + throw error; } - throw error; } + + // On native, requiring a missing module reports a fatal error instead of + // throwing, so the split bundle must be loaded before importing + var maybeLoadBundlePromise = maybeLoadBundle(moduleID, paths); + if (maybeLoadBundlePromise != null) { + return maybeLoadBundlePromise.then(importAll); + } + return importAll(); } function asyncRequire(moduleID, paths, moduleName) { @@ -113,6 +125,7 @@ describe('asyncRequireModule', () => { afterEach(() => { delete (globalThis as any).__loadBundleAsync; delete (globalThis as any).__METRO_GLOBAL_PREFIX__; + process.env.EXPO_OS = originalExpoOs; }); it('calls importAll with moduleID and moduleName when no bundle loading needed', async () => { @@ -129,7 +142,8 @@ describe('asyncRequireModule', () => { expect(result).toEqual({ default: 'module-42' }); }); - it('falls back to bundle load when importAll throws, then retries with moduleName', async () => { + it('falls back to bundle load on web when importAll throws, then retries with moduleName', async () => { + process.env.EXPO_OS = 'web'; mockImportAll .mockImplementationOnce(() => { throw new Error('Module not loaded'); @@ -158,7 +172,8 @@ describe('asyncRequireModule', () => { expect(result).toEqual({ default: 'module-42' }); }); - it('does not load the bundle when importAll succeeds (preloaded bundle case)', async () => { + it('does not load the bundle on web when importAll succeeds (preloaded bundle case)', async () => { + process.env.EXPO_OS = 'web'; (globalThis as any).__loadBundleAsync = jest.fn(() => Promise.resolve()); const paths = { '42': '/bundles/my-module.bundle' }; @@ -169,7 +184,8 @@ describe('asyncRequireModule', () => { expect(result).toEqual({ default: 'module-42' }); }); - it('re-throws the import error when no bundle path is configured', () => { + it('re-throws the import error on web when no bundle path is configured', () => { + process.env.EXPO_OS = 'web'; mockImportAll.mockImplementationOnce(() => { throw new Error('Module not loaded'); }); @@ -177,6 +193,37 @@ describe('asyncRequireModule', () => { expect(() => asyncRequire(42, null, 'my-module')).toThrow('Module not loaded'); }); + it('loads the bundle on native before requiring split modules', async () => { + process.env.EXPO_OS = 'ios'; + let bundleLoaded = false; + mockImportAll.mockImplementation((id: number) => + bundleLoaded ? { default: `module-${id}` } : undefined + ); + (globalThis as any).__loadBundleAsync = jest.fn(() => { + bundleLoaded = true; + return Promise.resolve(); + }); + + const paths = { '42': '/bundles/my-module.bundle' }; + const result = await asyncRequire(42, paths, 'my-module'); + + expect((globalThis as any).__loadBundleAsync).toHaveBeenCalledWith('/bundles/my-module.bundle'); + expect(mockImportAll).toHaveBeenCalledTimes(1); + expect(mockImportAll).toHaveBeenCalledWith(42, 'my-module'); + expect(result).toEqual({ default: 'module-42' }); + }); + + it('imports synchronously on native when the module is inlined (no split bundle path)', () => { + process.env.EXPO_OS = 'ios'; + (globalThis as any).__loadBundleAsync = jest.fn(() => Promise.resolve()); + + const ret = asyncRequire(42, null, 'my-module'); + + expect((globalThis as any).__loadBundleAsync).not.toHaveBeenCalled(); + expect(mockImportAll).toHaveBeenCalledWith(42, 'my-module'); + expect(ret._result).toEqual({ default: 'module-42' }); + }); + describe('thenable return value', () => { it('exposes a synchronous _result when no bundle load was needed', () => { const ret = asyncRequire(42, null, 'my-module'); @@ -186,6 +233,7 @@ describe('asyncRequireModule', () => { }); it('exposes a Promise _result when a bundle load was needed', async () => { + process.env.EXPO_OS = 'web'; mockImportAll .mockImplementationOnce(() => { throw new Error('Module not loaded'); diff --git a/packages/expo/src/async-require/asyncRequireModule.ts b/packages/expo/src/async-require/asyncRequireModule.ts index 1abb7170aac808..7c183bdadae9a4 100644 --- a/packages/expo/src/async-require/asyncRequireModule.ts +++ b/packages/expo/src/async-require/asyncRequireModule.ts @@ -68,16 +68,30 @@ function asyncRequireImpl( moduleName?: string ): Promise | T { const importAll = () => (require as unknown as MetroRequire).importAll(moduleID, moduleName); - try { - // Try importing first to prevent double-loading script when the page already preloaded it - return importAll(); - } catch (error) { - const maybeLoadBundlePromise = maybeLoadBundle(moduleID, paths); - if (maybeLoadBundlePromise != null) { - return maybeLoadBundlePromise.then(importAll); + + // NOTE(@hassankhan): We need to come back and improve this, ideally we shouldn't need to have a + // separate conditional specifically for web + // On web, split chunks may already be preloaded via `