Since Metro resolves package.json exports by default, why might a React Native library's platform-specific file stop loading, and how do condition names help?
answer
- on by default since Metro 0.82
- exports target used exactly as written
- no sourceExts or platform expansion
- react-native condition from React Native's config
- missing subpath: warning, then fallback
basics
~20 sWith unstable_enablePackageExports on by default since Metro 0.82 (React Native 0.79), a subpath matched in exports resolves to its exact target, without .ios/.android or extension expansion. Libraries must route platforms through conditions: React Native asserts react-native, and Metro always asserts default plus import or require.
solid answer
~40 sMetro 0.82, shipped with React Native 0.79, turned `resolver.unstable_enablePackageExports` on by default. When a package has an `exports` field and the imported subpath matches it, Metro uses the **exact target file** and skips `sourceExts` and platform-extension expansion, and `exports` also beats `react-native`, `browser` and `main`. A library that relied on `Thing.ios.js` sitting beside an exported `Thing.js` now gets `Thing.js` on iOS. Platform choice has to move into **condition names**: Metro always asserts `default` plus `import` or `require` by syntax, `@react-native/metro-config` adds `react-native` via `unstable_conditionNames`, and `unstable_conditionsByPlatform` adds per-platform names (default `browser` for web). Keys are matched in the package's order, not the config's. If a subpath is missing from `exports`, Metro warns and falls back to the old resolution. As a stopgap, an app can set `unstable_enablePackageExports: false`.
go deeper
Know that libraries can declare entry points with exports and that Metro reads it by default in current React Native.
Explain which conditions Metro asserts, where react-native comes from, and that package order decides which key wins.
Diagnose a library that silently lost its iOS or Android file after an upgrade and choose between upstream fix, targeted resolver case and the global flag.
Weigh a global opt-out against per-library workarounds when many dependencies lag, and set a deadline for removing the opt-out.
## What changed The `exports` field in `package.json` lets a package declare its public entry points and choose files by **condition**. Metro supported it as an opt-in from 0.76 and made it the **default in Metro 0.82**, which shipped with **React Native 0.79**. The option is still named `resolver.unstable_enablePackageExports` and still defaults to `true` in Metro 0.87. ## How Metro treats a matched subpath When the imported package has `exports` and the subpath matches: 1. `exports` is consulted **first**, ahead of `react-native`, `browser` and `main`, and even ahead of a file on disk at the same subpath. 2. Condition names select the target. 3. The target path is used **exactly**: Metro does not append `sourceExts` and does not try `.ios`, `.android` or `.native` variants. 4. Asset targets still get density variants such as `@2x`. If the subpath is **not** in `exports`, Metro logs an encapsulation warning and falls back to the old file-based resolution, a lenient choice where Node would throw. ## Why a platform file stops loading Consider a library that ships `lib/Picker.js` and `lib/Picker.ios.js` and adds `"exports": { "./Picker": "./lib/Picker.js" }`. Before 0.79, `import Picker from 'lib/Picker'` on iOS found `Picker.ios.js`. With exports resolution, the subpath matches and Metro returns `lib/Picker.js` on every platform. Nothing errors; iOS just silently gets the generic implementation. ## Conditions are the replacement mechanism Metro asserts a set of condition names and picks the first key in the package's `exports` object that is in the set: | Source of the condition | Value | |---|---| | Always | `default` | | By import syntax | `import` for `import`, `require` for `require()` | | `resolver.unstable_conditionNames` | Metro default `[]`; `@react-native/metro-config` sets `['react-native']` | | `resolver.unstable_conditionsByPlatform` | Default `{ web: ['browser'] }`; Expo also maps `ios` and `android` to `react-native` | So a library can write `"./Picker": { "react-native": "./lib/Picker.native.js", "default": "./lib/Picker.web.js" }`. Order matters: keys are tried in the order the **package** lists them, so `default` must come last. ## A library that works on every target ```json { "name": "picker-lib", "exports": { "./Picker": { "react-native": "./lib/Picker.native.js", "browser": "./lib/Picker.web.js", "default": "./lib/Picker.js" } } } ``` Walk the resolution for each target under React Native's defaults: - **iOS or Android**: asserted conditions include `react-native`, the first key, so `Picker.native.js` loads. - **Web**: under `@react-native/metro-config`, `react-native` is asserted globally and `browser` is added for web, so the first key, `react-native`, still wins and web gets the native file. Under `expo/metro-config`, `react-native` is asserted only for native platforms, so web matches `browser` and loads `Picker.web.js`. - **Anything else**: `default` catches it. If iOS and Android genuinely need different files, a condition alone is not enough, because the `react-native` condition is shared by both. The library can export separate subpaths, keep a platform switch inside the native file, or configure per-platform conditions, which requires apps to set `unstable_conditionsByPlatform` accordingly. ## Diagnosing and working around it - **Symptom**: after upgrading to React Native 0.79 or later, a library behaves like its web or generic build, or a deep import prints a "not listed in the exports" warning. - **Check**: read the library's `exports` and whether it lists a `react-native` condition for the affected subpath. - **Fix upstream**: the library adds conditions or exports every previously supported subpath. - **Stopgap**: set `resolver.unstable_enablePackageExports = false` in the app's Metro config and plan to remove it; this restores the old algorithm for all packages. - **Targeted**: a `resolveRequest` special case for the one broken import, delegating everything else to `context.resolveRequest`. ## Why not just list more conditions globally Adding `import` or `require` to `unstable_conditionNames` asserts it for every import regardless of syntax, which the Metro docs warn against. Custom conditions are fine, but every entry changes resolution for every package that happens to use that key.
- What does Metro do when an app imports a library subpath that its exports field does not list?It logs a warning that the module is not listed in the package's exports and falls back to the old file-based resolution, so the import still works. Node would throw in the same situation. The Metro docs recommend fixing these warnings because a strict mode is planned.
- Why is setting unstable_enablePackageExports to false only a stopgap?It restores the old algorithm for every package, not just the broken one, so libraries that rely on conditions such as `react-native` lose them. It also depends on a flag the Metro docs say will be removed. A targeted `resolveRequest` case or an upstream fix is the durable answer.
saying these in an interview costs you the question
- Thinks Metro throws on an import missing from exports
- Expects .ios files next to an exports target to still be picked
- Believes condition names are tried in the order of the Metro config
- Adds require or import to unstable_conditionNames globally
- Dates the exports default to React Native 0.80 or later