Current Behavior
withNxMetro sets projectRoot and resolver.nodeModulesPaths to workspace-root values for every app it wraps. In a workspace where two Expo apps pin different Expo SDKs, this makes the app that does not own the hoisted copy of expo bundle the other app's SDK, and the resulting bundle contains two copies of Expo.
In my workspace:
apps/native-app — Expo SDK 54, uses the hoisted node_modules/expo
apps/native-playground — Expo SDK 57, has a nested apps/native-playground/node_modules/expo@57.0.9
Both call withNxMetro. Building and installing the SDK 57 app on an iOS simulator succeeds, but the dev client redboxes on launch before rendering anything:
Uncaught Error: Property 'document' doesn't exist
hmr.ts:165:29 const currentScript = document?.currentScript
HMRClient.setup hmr.ts:176:6
The crashing frame is not from the app's own SDK 57 copy. It is from the hoisted SDK 54 copy — the bundle contains both:
node_modules/expo/src/async-require/hmr.ts <- SDK 54, crashes
apps/native-playground/node_modules/expo/src/async-require/hmr.ts <- SDK 57, correct
In SDK 54 that document?.currentScript access sits on the shared code path, and Hermes throws a ReferenceError for an undeclared global even behind optional chaining. SDK 57 moved the access into a web-only branch (getFullBundlerUrl.ts), so the app's own copy would not have crashed.
projectRoot is the decisive setting. @expo/cli swaps React Native's HMR client for Expo's own implementation, and resolves the replacement from the project root with nodeModulesPaths deliberately emptied:
// @expo/cli/src/start/server/metro/withMetroMultiPlatform.ts
const projectRootOriginPath = path.join(config.projectRoot, 'package.json');
// ...
const projectRootContext = {
...context,
nodeModulesPaths: [],
originModulePath: projectRootOriginPath,
disableHierarchicalLookup: false,
};
// ...
const hmrModule = doReplaceStrict(
`${hostPackage}/Libraries/Utilities/HMRClient.js`,
'expo/src/async-require/hmr.ts'
);
With projectRoot forced to the workspace root, that lookup walks up from the workspace package.json and always reaches the hoisted expo, regardless of which app is being bundled. Widening nodeModulesPaths does not help, because this code path clears it.
I am aware of #35904, which made the projectRoot override conditional on usesExpoMetro and shipped in 23.1.0. I am filing this separately because that detection does not cover this case, and because the underlying problem was never described in an issue:
let usesExpoMetro = false;
try {
require.resolve('@expo/metro/metro-config');
usesExpoMetro = true;
} catch {}
That is a single process-wide probe, not a per-app one. @expo/metro is hoisted to the workspace root by the SDK 57 app, so the probe resolves from the @nx/expo plugin directory and returns true while bundling the SDK 54 app as well:
node_modules/@expo/metro present (hoisted, from the SDK 57 app)
apps/native-playground/node_modules/@expo/metro present
apps/native-app/node_modules/@expo/metro absent
So on 23.1.x the SDK 54 app would also stop receiving projectRoot: workspaceRoot — the behaviour #33836 added specifically for it. getMetroConfig() in 23.x similarly prefers @expo/metro/metro-config, which the SDK 54 app would then merge against a different Metro instance than the one its own getDefaultConfig came from.
Expected Behavior
withNxMetro should derive projectRoot and nodeModulesPaths from the app being bundled rather than from the workspace root or from a process-wide require.resolve probe, so that each app resolves its own pinned dependencies. A workspace with two Expo SDKs should produce one copy of expo per bundle — the app's own.
Keying the decision off the wrapped config works, and is per-app rather than per-process. This is the patch I am running against 22.5.1:
const appNodeModules = userConfig.projectRoot
? join(userConfig.projectRoot, 'node_modules')
: null;
const nodeModulesPaths = [
...(appNodeModules && existsSync(appNodeModules) ? [appNodeModules] : []),
join(workspaceRoot, 'node_modules'),
];
// When the app pins its own `expo`, use the app root so @expo/cli's resolver
// rewrites resolve that app's SDK. Apps relying on the hoisted `expo` keep the
// workspace root.
const ownsExpo = appNodeModules && existsSync(join(appNodeModules, 'expo'));
const nxConfig = {
projectRoot: ownsExpo ? userConfig.projectRoot : workspaceRoot,
resolver: {
resolveRequest: getResolveRequest(extensions, opts.exportsConditionNames, opts.mainFields),
nodeModulesPaths,
},
watchFolders,
};
With that applied, the SDK 57 app bundles 1651 modules with no error and the SDK 54 app is unaffected.
GitHub Repo
No response — reproducing needs a two-Expo-SDK workspace, which nx-examples does not have. I can put a minimal repro repo together if that would help.
Steps to Reproduce
- Create an Nx workspace with two Expo apps whose Metro configs both call
withNxMetro.
- Pin one app to Expo SDK 54 and let it use the hoisted
node_modules/expo.
- Pin the other app to Expo SDK 57 so it gets a nested
<app>/node_modules/expo, e.g. with pnpm and a nested package.json for that app.
- Build and run the SDK 57 app on an iOS simulator:
expo run:ios --device <udid> --port 8081. The native build and install succeed.
- Start Metro for that app and launch it. The dev client redboxes with
Property 'document' doesn't exist at hmr.ts:165 before rendering.
- Confirm the cause by fetching the bundle and grepping for the module paths — both copies of
expo/src/async-require/hmr.ts are present, and the crashing one is the hoisted SDK 54 copy:
curl -s 'http://localhost:8081/node_modules/expo-router/entry.bundle?platform=ios&dev=true&minify=false' \
| grep -n 'async-require/hmr.ts'
Nx Report
Node : 22.21.1
OS : darwin-arm64
Native Target : aarch64-macos
pnpm : 10.29.2
daemon : Available
nx : 22.5.1
@nx/js : 22.5.1
@nx/eslint : 22.5.1
@nx/workspace : 22.5.1
@nx/jest : 22.5.1
@nx/detox : 22.5.1
@nx/devkit : 22.5.1
@nx/eslint-plugin : 22.5.1
@nx/expo : 22.5.1
@nx/module-federation : 22.5.1
@nx/plugin : 22.5.1
@nx/react : 22.5.1
@nx/rollup : 22.5.1
@nx/vite : 23.0.1
@nx/vitest : 22.5.1
@nx/web : 22.5.1
typescript : 5.9.3
---------------------------------------
Registered Plugins:
@nx/js/typescript
@nx/expo/plugin
@nx/eslint/plugin
@nx/jest/plugin
---------------------------------------
Local workspace plugins:
@stubhub/generators
Failure Logs
Uncaught Error: Property 'document' doesn't exist
Source
163 |
164 | const fullBundleUrl = (() => {
> 165 | const currentScript = document?.currentScript;
| ^
166 | const bundleUrl = new URL(
167 | currentScript && 'src' in currentScript ? currentScript.src
168 | location.href
hmr.ts (165:29)
Call Stack
<anonymous> hmr.ts:165:29
HMRClient.setup hmr.ts:176:6
Both copies of Expo present in a single iOS bundle:
$ curl -s 'http://localhost:8081/node_modules/expo-router/entry.bundle?platform=ios&dev=true&minify=false' \
| grep -n 'async-require/hmr.ts'
57504:},276,[1190,277,182,280],"node_modules/expo/src/async-require/hmr.ts");
95347:...,"apps/native-playground/node_modules/expo/src/async-require/hmr.ts");
Package Manager Version
pnpm 10.29.2
Operating System
Additional Information
Related history for packages/expo/plugins/with-nx-metro.ts:
I verified availability by unpacking the published tarballs rather than going by merge dates: 22.7.6 and 22.7.8 do not contain the fix (it was not backported to 22.x), 23.0.1 and 23.0.2 do not, and 23.1.0 and 23.1.1 do.
nodeModulesPaths is still [join(workspaceRoot, 'node_modules')] on master, so even on 23.1.x an app's own node_modules is never consulted through that setting. It is not what triggers this particular crash, but it is the same workspace-root assumption.
Current Behavior
withNxMetrosetsprojectRootandresolver.nodeModulesPathsto workspace-root values for every app it wraps. In a workspace where two Expo apps pin different Expo SDKs, this makes the app that does not own the hoisted copy ofexpobundle the other app's SDK, and the resulting bundle contains two copies of Expo.In my workspace:
apps/native-app— Expo SDK 54, uses the hoistednode_modules/expoapps/native-playground— Expo SDK 57, has a nestedapps/native-playground/node_modules/expo@57.0.9Both call
withNxMetro. Building and installing the SDK 57 app on an iOS simulator succeeds, but the dev client redboxes on launch before rendering anything:The crashing frame is not from the app's own SDK 57 copy. It is from the hoisted SDK 54 copy — the bundle contains both:
In SDK 54 that
document?.currentScriptaccess sits on the shared code path, and Hermes throws aReferenceErrorfor an undeclared global even behind optional chaining. SDK 57 moved the access into a web-only branch (getFullBundlerUrl.ts), so the app's own copy would not have crashed.projectRootis the decisive setting.@expo/cliswaps React Native's HMR client for Expo's own implementation, and resolves the replacement from the project root withnodeModulesPathsdeliberately emptied:With
projectRootforced to the workspace root, that lookup walks up from the workspacepackage.jsonand always reaches the hoistedexpo, regardless of which app is being bundled. WideningnodeModulesPathsdoes not help, because this code path clears it.I am aware of #35904, which made the
projectRootoverride conditional onusesExpoMetroand shipped in 23.1.0. I am filing this separately because that detection does not cover this case, and because the underlying problem was never described in an issue:That is a single process-wide probe, not a per-app one.
@expo/metrois hoisted to the workspace root by the SDK 57 app, so the probe resolves from the@nx/expoplugin directory and returns true while bundling the SDK 54 app as well:So on 23.1.x the SDK 54 app would also stop receiving
projectRoot: workspaceRoot— the behaviour #33836 added specifically for it.getMetroConfig()in 23.x similarly prefers@expo/metro/metro-config, which the SDK 54 app would then merge against a different Metro instance than the one its owngetDefaultConfigcame from.Expected Behavior
withNxMetroshould deriveprojectRootandnodeModulesPathsfrom the app being bundled rather than from the workspace root or from a process-widerequire.resolveprobe, so that each app resolves its own pinned dependencies. A workspace with two Expo SDKs should produce one copy ofexpoper bundle — the app's own.Keying the decision off the wrapped config works, and is per-app rather than per-process. This is the patch I am running against 22.5.1:
With that applied, the SDK 57 app bundles 1651 modules with no error and the SDK 54 app is unaffected.
GitHub Repo
No response — reproducing needs a two-Expo-SDK workspace, which
nx-examplesdoes not have. I can put a minimal repro repo together if that would help.Steps to Reproduce
withNxMetro.node_modules/expo.<app>/node_modules/expo, e.g. with pnpm and a nestedpackage.jsonfor that app.expo run:ios --device <udid> --port 8081. The native build and install succeed.Property 'document' doesn't existathmr.ts:165before rendering.expo/src/async-require/hmr.tsare present, and the crashing one is the hoisted SDK 54 copy:Nx Report
Failure Logs
Both copies of Expo present in a single iOS bundle:
Package Manager Version
pnpm 10.29.2
Operating System
Additional Information
Related history for
packages/expo/plugins/with-nx-metro.ts:feat(expo): support Expo 54fix(expo): set projectRoot to workspaceRoot for Expo SDK 54+ compatibility— introduced the unconditional overridefeat(expo): support Expo SDK 56— made it conditional onusesExpoMetro, first released in 23.1.0I verified availability by unpacking the published tarballs rather than going by merge dates: 22.7.6 and 22.7.8 do not contain the fix (it was not backported to 22.x), 23.0.1 and 23.0.2 do not, and 23.1.0 and 23.1.1 do.
nodeModulesPathsis still[join(workspaceRoot, 'node_modules')]onmaster, so even on 23.1.x an app's ownnode_modulesis never consulted through that setting. It is not what triggers this particular crash, but it is the same workspace-root assumption.