Skip to main content

Package Exports Support in React Native

· 9 min read
Alex Hunt

With the release of React Native 0.72, Metro — our JavaScript build tool — now includes beta support for the package.json "exports" field. When enabled, it adds the following functionality:

In this post we'll cover how Package Exports works, and what these changes mean for you as a React Native app developer or package maintainer.

What is Package Exports?

Introduced in Node.js 12.7.0, Package Exports is the modern approach for npm packages to specify entry points — the mapping of package subpaths which can be externally imported and which file(s) they should resolve to.

Supporting "exports" improves how React Native projects will work with the wider JavaScript ecosystem (used in ~16.6k packages today), and gives package authors a standardised feature set for multiplatform packages to target React Native.

"exports" can be used alongside, or instead of, "main" in a package.json file.

{
"name": "@storybook/addon-actions",
"main": "./dist/index.js",
...
"exports": {
".": {
"node": "./dist/index.js",
"import": "./dist/index.mjs",
"default": "./dist/index.js"
},
"./preview": {
"import": "./dist/preview.mjs",
"default": "./dist/preview.js"
},
...
"./package.json": "./package.json"
}
}

Here's some app code consuming the above package by importing different subpaths of @storybook/addon-actions.

import {action} from '@storybook/addon-actions';
// -> '@storybook/addon-actions/dist/index.js'

import {action} from '@storybook/addon-actions/preview';
// -> '@storybook/addon-actions/dist/preview.js'

import helpers from '@storybook/addon-actions/src/preset/addArgsHelpers';
// Inaccessible - not listed in "exports"!

The headlining features of Package Exports are:

  • Package encapsulation: Only subpaths defined in "exports" can be imported from outside the package — giving packages control over their public API.
  • Subpath aliases: Packages can define custom subpaths which map to a different file location (including via subpath patterns) — allowing relocation of files while preserving the public API.
  • Conditional exports: A subpath may resolve to a different underlying file depending on environment. For example, to target "node", "browser", or "react-native" runtimes — replacing the "browser" field spec.
note

The full capabilities for "exports" are detailed in the Node.js Package Entry Points spec.

Since these features overlap with existing React Native concepts (such as platform-specific extensions), and since "exports" had been live in the npm ecosystem for some time, we reached out to the React Native community to make sure our implementation would meet developers' needs (PR, final RFC).

For app developers

Package Exports can be enabled today, in beta.

  • Imports against packages that depend on Package Exports features (such as Firebase and Storybook) should now work as designed.
  • React Native for Web projects using Metro will now be able to use the "browser" conditional export, removing the need for workarounds.

Enabling Package Exports brings a few edge-case breaking changes that may affect specific projects, and which you can test today.

In a future React Native release, Package Exports will be enabled by default. In a chicken-and-egg situation, React Native apps were previously a holdout for some packages to migrate to "exports" — or used our "react-native" root field escape hatch. Supporting these features in Metro will allow the ecosystem to move forward.

Enabling Package Exports (beta)

Package Exports can be enabled in your app's metro.config.js file via the resolver.unstable_enablePackageExports option.

const config = {
// ...
resolver: {
unstable_enablePackageExports: true,
},
};

Metro exposes two further resolver options which configure how conditional exports behave:

  • unstable_conditionNames — The set of condition names to assert when resolving conditional exports. By default, we match ['require', 'import', 'react-native'].
  • unstable_conditionsByPlatform — The additional condition names to assert when resolving for a given platform target. By default, this matches 'browser' when the platform is 'web'.
tip

Remember to use the React Native Jest preset! Jest includes support for Package Exports by default. In tests, you can override which customExportConditions are resolved using the testEnvironmentOptions option.

If you are using TypeScript, resolution behaviour can be matched by setting moduleResolution: 'bundler' and resolvePackageJsonImports: false within your project's tsconfig.json.

Validating changes in your project

For existing projects, we recommend that early adopters follow these steps to see if resolution changes occur after enabling unstable_enablePackageExports. This is a one-time process. It's likely that there will be no changes at all, but we'd like developers to opt in with certainty.

💡 Validating changes in your project
note

If you are not using Yarn, substitute yarn for npx (or the relevant tool used in your project).

  1. Get all resolved dependencies (before changes):

    # Replace index.js with your entry file if needed, such as App.js
    yarn metro get-dependencies index.js --platform android --output before.txt
    • Expo CLI: Run npx expo customize metro.config.js if your project doesn't have a metro.config.js file yet.
    • For full coverage, substitute --platform android for the other platforms in use by your app (e.g. ios, web).
  2. Enable resolver.unstable_enablePackageExports in metro.config.js.

  3. Get all resolved dependencies (after changes):

    yarn metro get-dependencies index.js --platform android --output after.txt
  4. Compare!

    diff before.txt after.txt

Breaking changes

We decided on an implementation of Package Exports in Metro that is spec-compliant (necessitating some breaking changes), but backwards compatible otherwise (helping apps with existing imports to migrate gradually).

The key breaking change is that when "exports" is provided by a package, it will be consulted first (before any other package.json fields) — and a matched subpath target will be used directly.

For more details, please see all breaking changes in the Metro docs.

Package encapsulation is lenient

When Metro encounters a subpath that isn't listed in "exports", it will fall back to legacy resolution. This is a compatibility feature intended to reduce user friction for previously allowable imports in existing React Native projects.

Instead of throwing an error, Metro will log a warning.

warn: You have imported the module "foo/private/fn.js" which is not listed in
the "exports" of "foo". Consider updating your call site or asking the package
maintainer(s) to expose this API.
note

We plan to implement a strict mode for package encapsulation in future, to align with Node's default behaviour. Therefore, we recommend that all developers address these warnings if raised by users.

For package maintainers (preview)

info

Per our rollout plan, Package Exports will be enabled for most projects in the next React Native release (0.73) later this year.

We have no plans to remove support for the "main" field and other current package resolution features any time soon.

Package Exports provides the ability to restrict access to your package's internals, and more predictable capabilities for libraries to target React Native and React Native for Web.

If you are using "exports" today

If your package uses "exports" alongside the current "react-native" root field, please bear in mind the breaking changes for users above. For users enabling this feature in Metro, "exports" will now be considered first during module resolution.

In practice, we anticipate the main change for users will be the enforcement (via warnings) of any inaccessible subpaths in their apps, from respecting "exports" package encapsulation.

Migrating to "exports"

Adding an "exports" field to your package is entirely optional. Existing package resolution features will behave identically for packages which don't use "exports" — and we have no plans to remove this behaviour.

We believe that the new features of "exports" provide a compelling feature set for React Native package maintainers.

  • Tighten your package API: This is a great time to review the module API of your package, which can now be formally defined via exported subpath aliases. This prevents users from accessing internal APIs, reducing surface area for bugs.
  • Conditional exports: If your package targets React Native for Web (i.e. "react-native" and "browser"), we now give packages control of the resolution order of these conditions (see next heading).

If you decide to introduce "exports", we recommend making this as a breaking change. We've prepared a migration guide in the Metro docs which includes how to replace features such as platform-specific extensions.

note

Please do not rely on the lenient behaviours of Metro's implementation. While Metro is backwards-compatible, packages should follow how "exports" is documented in the spec and strictly implemented by other tools.

The new "react-native" condition

We've introduced "react-native" as a community condition (for use with conditional exports). This represents React Native, the framework, sitting alongside other recognised runtimes such as "node" and "deno" (RFC).

Community Conditions Definitions — "react-native"

Will be matched by the React Native framework (all platforms). To target React Native for Web, "browser" should be specified before this condition.

This replaces the previous "react-native" root field. The priority order for how this was previously resolved was determined by projects, which created ambiguity when using React Native for Web. Under "exports", packages concretely define the resolution order for conditional entry points — removing this ambiguity.

  "exports": {
"browser": "./dist/index-browser.js",
"react-native": "./dist/index-react-native.js",
"default": "./dist/index.js"
}
note

We chose not to introduce "android" and "ios" conditions, due to the prevalence of other existing platform selection methods, and the complexity of how this behaviour might work across frameworks. Please use the Platform.select() API instead.

The future: Stable "exports", enabled by default

In the next React Native release, we are aiming to remove the unstable_ prefix for this feature (having addressed planned performance work and any bugs) and will enable Package Exports resolution by default.

With "exports" enabled for everyone, we can begin taking the React Native community forward — for example, React Native's core packages could be updated to better separate public and internal modules.

Rollout plan for Package Exports support

Thanks

Thanks to members of the React Native community that gave feedback on the RFC: @SimenB, @tido64, @byCedric, @thymikee.

Huge thanks to @motiz88 and @robhogan at Meta for supporting the development of this feature.