Skip to content

Latest commit

 

History

History
490 lines (334 loc) · 28.7 KB

0534-metro-package-exports-support.md

File metadata and controls

490 lines (334 loc) · 28.7 KB
title author date
Package exports support in Metro
Alex Hunt <alexeh@meta.com>
2022-10-17

RFC0534: Package exports support in Metro

Summary

To improve React Native’s compatibility with the npm package ecosystem, we intend to add support for the "exports" field in Metro. This feature was introduced in Node.js 12.7.0 and is described in the Node.js spec under Package entry points.

Basic example

The "exports" field is an expanded way for npm packages to define entry points within their package.json manifest. It is a modern alternative to "main" and the recommended method for exposing files in new packages.

{
  "name": "my-package",
  "exports": {
    ".": "./lib/index.js",
    "./utils/*": "./utils/*.js",
    "./utils/*.js": "./utils/*.js",
    "./package.json": "./package.json"
  }
}

https://nodejs.org/docs/latest-v18.x/api/packages.html#package-entry-points

With "exports" support enabled, Metro will need to resolve paths appropriately based on what is defined by each package.

In this first iteration, we intend for "exports" paths to be handled inclusively on top of current resolution behaviour. In a future proposal, package maintainers will be able to opt into strict handling of "exports".

import MyPackage from 'my-package';
// Loads ./lib/index.js

import foo from 'my-package/private/foo';
// Loads ./private/foo.js (Inaccessible under strict mode)

In addition to this example, "exports" introduces further behaviours which are discussed later in this proposal.

Motivation

Developers using React Native should be able to use current JavaScript standards, conventions and packages to author their apps.

React Native developers expect a seamless experience when consuming packages from npm. Supporting "exports" within Metro has been a specific pain point (facebook/metro#670), with this feature having been adopted by other bundlers and a growing proportion of the JavaScript ecosystem.

We intend to initially roll out lenient and backwards compatible support for "exports" features. We posit that compatibility with "exports" packages is the most immediate unit of benefit we can provide to app developers, while keeping the surface area and upgrade guidance minimal for package maintainers.

The expected outcomes are that:

  1. React Native projects will work with more npm packages out-of-the-box. Packages using "exports" will have associated non-breaking behaviours respected under Metro (described in the next section).
  2. React Native for Web projects will work with more npm packages out-of-the-box due to better-aligned resolution of the "browser" and "react-native" runtimes under "exports".
  3. Package maintainers will have less ambiguity and more control over which entry points are provided to React Native projects under "exports".

At a later date, we will revisit strict mode in a separate proposal (initially drafted in earlier versions of this doc: #534).

Priorities within this proposal

The Metro team intends to implement all features of "exports" described in this proposal. However, due to the size of the spec we are considering features in two priority levels.

  • P0 - Core features: The robust subset of features that we must support in the spec for a minimum implementation of "exports".
    • This work will be scheduled ahead of P1 features unless it makes sense not to during implementation.
    • App developers may wish to opt in to experimental "exports" support upon completion of these features.
  • P1 - Not targeted in first iteration:
    • Attached to features which are more challenging to implement, or that were launched in later revisions to the Node.js spec.
    • These may still be done, otherwise will be fast-follow.
    • This work may be up for grabs by interested external contributors.

All features discussed are P0 unless otherwise stated.

Detailed design

We intend to support all features of "exports" up to Node 18.10. For now, we are aware of but are not committing to support for "imports".

We are beginning in a state where "exports" is in general use and we cannot guarantee that references to these packages within existing React Native projects are compliant with the "exports" spec. Therefore Metro needs to carefully design around this.

The following features and considerations are detailed in this section:

The following features will be implemented without any anticipated behaviour differences or special notes (links go to Node.js spec):

Package encapsulation

This is the primary breaking change introduced by "exports".

When the "exports" field is defined, all subpaths of the package are encapsulated and no longer available to importers.
https://nodejs.org/docs/latest-v18.x/api/packages.html#main-entry-point-export

"exports": {
  // Paths outside of this list are inaccessible to importers
  ".": "./lib/index.js",
  "./package.json": "./package.json"
}

Proposed: Metro will ignore this constraint (reserved for a future strict mode). We will log a warning when an inaccessible import is accessed.

Subpath exports

This relates to the ability for packages to define subpath aliases that point to an underlying file.
https://nodejs.org/docs/latest-v18.x/api/packages.html#subpath-exports

"exports": {
  ".": "./index.js",
  "./submodule.js": "./src/submodule.js"
}

One edge case is a path conflict, for instance if the file tree for this package looked like this:

├── src
│   └── submodule.js
└── submodule.js

Proposed:

  • Breaking: Metro will consider all exact "exports" subpaths when present. In the case of a path conflict, Metro will prioritise the file specified by "exports".
    • If a subpath is specified in "exports" but fails to resolve (i.e. file doesn't exist), Metro will log a warning before falling back to filesystem resolution.

Illustration of subpath resolution for the above conflict example:

Imported path Current behaviour Proposed behaviour
'pkg/submodule.js' ./submodule.js ./src/submodule.js
'pkg/src/submodule.js' ./src/submodule.js ./src/submodule.js
Prioritises entries in "exports" (spec compliant)

Subpath patterns

Subpath exports may use patterns to match several files. These can expose all contents of a directory, or include a pattern trailer (e.g. file extension).
https://nodejs.org/docs/latest-v18.x/api/packages.html#subpath-patterns

"exports": {
  ".": "./index.js",
  "./utils/*": "./utils/*.js"
  "./utils/*.js": "./utils/*.js"
}

Subpath patterns are strictly a string replacement syntax. Therefore * acts as equivalently to a ** file glob:

import x from 'pkg/utils/x.js';
// Loads ./node_modules/pkg/src/utils/x.js

import y from 'pkg/utils/y/y.js';
// Loads ./node_modules/pkg/src/utils/y/y.js

Multiple *s cannot be matched in patterns (source):

"./*/*.js": "./*.js" // Invalid subpath pattern means entry is ignored

Proposed:

  • P1 (not targeted in first iteration): Metro will resolve subpath patterns.

This feature has a lower prioritisation due to possible implementation complexity and because the introduction of pattern trailers changed its behaviour more recently. The Node.js spec recommends explicitly listed imports for small packages (meaning we anticipate any fixes towards this will be weighted towards large packages).

For packages with a small number of exports or imports, we recommend explicitly listing each exports subpath entry.
https://nodejs.org/docs/latest-v18.x/api/packages.html#subpath-patterns

Exact path specifiers

Package authors should provide either extensioned (import 'pkg/subpath.js') [...] or extensionless (import 'pkg/subpath') [...] subpaths [...] This ensures that there is only one subpath for each exported module so that all dependents import the same consistent specifier.
https://nodejs.org/docs/latest-v18.x/api/packages.html#extensions-in-subpaths

This detail conflicts with React Native's platform-specific extensions. Metro's existing resolution behaviour around extensions is as follows:

// For extensions in `resolver.sourceExts`

import FooComponent from './FooComponent';
// Tries .[platform].js, .native.js, .js (+ TypeScript variants)

import BarComponent from './BarComponent.js';
// Tries suffixes following the extensioned filename,
// e.g. .js.[platform].js, .js.native.js, .js.js

// For extensions in `watcher.additionalExts`

import BazComponent from './BazComponent.mjs';
// Tries exact extension only
  • As noted in Subpath patterns, the "exports" spec allows subpaths to use pattern trailers (*) strictly for substitution. "exports" values must also be file paths with extensions. Therefore packages cannot express subpaths where the import specifier maps to a file with an expanded extension.

    "exports": {
      "./subpath": "./subpath.js",
      "./subpath.js": "./subpath.js",
      "./subpath*": "./subpath*", // Insufficient to expand extension
    }
  • The second example (e.g. .js.js) may be seen as unintuitive, given the emergence of extensioned JS imports. We have the opportunity to omit this logic when resolving "exports".

Proposed:

  • Breaking: Under "exports", Metro will not resolve platform-specific extensions for listed package entry points.
    • When resolving any import specifier:
      • If the package defines "exports" and the exact import specifier is matched, the package-defined path mapping will be used with no further transformation.
      • If there is no match in "exports", Metro will look for files which match the import specifier, trying all extension variants (existing resolution logic).
  • With this decision, we will have narrowed support for platform-specific extensions in packages. We will communicate to React Native package authors that alternative patterns should be used.
    • We have no near-term plans to drop platform-specific extensions for packages not using "exports", or in app code.
  • We will not take a strong stance on using extensionless or extensioned imports. The former may provide more flexibility for React Native package authors to change extensions in future without impacting consuming apps.

Note: We may yet (unplanned) independently make the platform-specific extensions feature work with extensioned specifiers, at which point this could be opened back up on "exports" entry points.

Illustrated

"exports": {
  // Node.js recommends that packages list extensionless specifiers 
  // for compatibility
  "./FooComponent": "./src/FooComponent.js",
  "./FooComponent.js": "./src/FooComponent.js",
}

Import specifiers listed in "exports" will be used when matched. Alternative paths will only be tried when there is no match in "exports".

import FooComponent from 'pkg/FooComponent';
// (Metro will not expand this specifier using sourceExts)
// Reads from "exports":
//   pkg/src/FooComponent.js

import FooComponent from 'pkg/FooComponent.js';
// Reads from "exports":
//   pkg/src/FooComponent.js

import FooComponent from 'pkg/src/FooComponent';
// No match in "exports" (Metro will print warning)
// Tries files if present:
//   pkg/src/FooComponent.[platform].js
//   pkg/src/FooComponent.native.js
//   pkg/src/FooComponent.js

The last example given is not expected to enable backwards compatibility, but may serendipitously capture pre-existing imports in apps. In a future strict mode, paths outside "exports" will not be considered.

Workarounds

We will recommend that packages which rely on platform-specific extensions being available to consuming apps do not migrate to "exports" or update these entry points to handle platform internally.

One replacement pattern (besides Platform.select()) is a wrapper module:

└── src
    ├── FooComponent.js # Listed in "exports"
    ├── FooComponentImpl.android.js
    ├── FooComponentImpl.ios.js
    └── FooComponentImpl.js
// pkg/src/FooComponent.js
export * from './FooComponentImpl'; // Will resolve platform exts

Conditional exports will be the recommended solution for replacing current .native.js entry points where aiming to identify React Native projects.

Conditional exports

"exports" introduces a universal way for npm packages to specify alternative modules targeting specific platforms or environments.
https://nodejs.org/docs/latest-v18.x/api/packages.html#conditional-exports

Conditions are provided via an object structure and can be nested. In this proposal, string identifiers for a conditional export are referred to as a condition name, e.g. "node", "default".

"exports": {
  ".": "./index.js",
  "./feature": {
    "node": "./feature-node.js",
    "default": "./feature.js"
  }
}

The Node.js spec documents two groups of condition names, which cut across multiple concepts.

Proposed:

  • Metro will implement resolution of all conditional exports within the core spec, and a subset of community defined conditions (described in the next two sections).
  • We do not intend to use conditional exports as an alternative method to target the current React Native concept of platforms (e.g. "android", "ios", "web").
    • Special handling will be implemented for "browser".

Our stance on keeping to the existing methods of targeting platforms is motivated by consistency of this feature between app and package developers, and not introducing additional concepts to the community.

Conditional exports: "import" and "require"

"import" and "require" are listed among the core condition names supported by Node.js, intended to match modules depending on the syntax used to reference the module — either import/import() or require() (and variants) to select between CommonJS or ECMAScript modules.

For React Native projects, we have historically implemented no distinction between these module types, with packages assuming Babel-style module interop. Modules are converted on the fly via @babel/plugin-transform-modules-commonjs, and Metro treats import and require() equivalently.

Proposed: Metro will provide no specific handling for "import" and "require" conditions. This support could come in future if we decide to move our module handling more tightly to the Node spec or there is a clear use case.

Condition name Node.js Metro (proposed)
"node" Always matched -
"node-addons" Matched when export requires native addons -
"default" Always matched Always matched
"import" Matched when loaded via import or import() Always matched
"require" Matched when loaded via require() Always matched

Conditional exports: Community definitions and "browser"

Analysing the Community Conditions Definitions documented in the Node.js spec and introduced previously:

  • We note that "browser", representing any web browser environment, is an established use case that will make sense to support in Metro — enabling packages to work seamlessly in React Native web apps.
  • We see a space and community need for a "react-native" condition to be available under "exports", replacing the existing "react-native" top-level field.

Improving current behaviour

Metro supports equivalent behaviour currently with the top-level "browser" field spec and our custom top-level "react-native" field.

A pain point for developers has been the inability for packages to specify priority when using both of these fields, where the "react-native" entry point has historically been prioritised instead of "browser" in a react-native-web project. This (we believe) went against the general assumption of community package authors (in addition to React app developers) who intuitively expect "browser" to be used on web (example).

As we translate these conditions to "exports", this can be improved due to the order-sensitivity of conditional exports — which moves control of which conditions are matched to package authors.

Proposed:

  • We will implement the "browser" condition name. This will not be implicitly preferred over "react-native", but will be read in the order specified by the package (per Node spec).
    • Metro will provide a built-in overridable default for this behaviour (see next section).
  • We will introduce a "react-native" condition name representing all React Native projects (native and web).
    • Metro will match this condition when configured for React Native (see next section).
  • Metro will not provide an implementation for other community conditions.
Condition name Description Node.js Webpack Metro (proposed)
"browser" Any web browser environment - Matched when platform === 'web' Matched when platform === 'web'
"react-native" [New] Matched by the React Native framework (all platforms) - Matched for React Native projects (when configured) (and/or "TBD" — source) Matched for React Native projects (when configured)
All other (unimplemented) Various supported, e.g. "deno", "worker", "electron" (unimplemented)
"default" Makes no assumption about JS environment Always matched Always matched Always matched

Under this model, "react-native" and "browser" will continue to overlap. However, because earlier conditional exports entries have higher priority, package maintainers will have more fine-grained control over what is selected for their package — e.g. enabling matching of native platforms only when listed after "browser".

Note: Packages intending to support both native and web environments but which want to expose alternative "exports" targeting React Native APIs should consider continuing to use the "default" condition and use a subpath export instead: e.g. import { reactNativeExport } from 'some-pkg/react-native'.

Conditional exports: User conditions and configuration

User conditions relates to the ability to set custom condition names within a project, which may be matched by target packages.

Prior art: Webpack's resolve.conditionNames and Node.js' --conditions CLI argument.

Proposed:

  • We will provide a new config option allowing app developers to define additional condition names which statically extend the default conditions matched.
  • We will provide a new config option to assert certain condition names based on context, e.g. platform.
    • A default implementation matching "browser" when platform === 'web' will be applied by metro-config.
// metro.config.js
module.exports = {
  // ...
  resolver: {
    // The exports field condition names to assert globally
    // (default: ['react-native'])
    conditionNames: ['react-native', 'production'],

    // The set of additional condition names to dynamically
    // assert by platform
    conditionsByPlatform: {
      web: ['browser'],
    },
  },
};

The exact name and shape of these options may change during implementation. Naming will ideally be broad enough to cover any future support for "imports".

In addition, resolver.resolveRequest will continue to provide an escape hatch from Metro's handling of conditional exports, should an app need to override imports from a given package.

Asset resolutions

In addition to source files, Metro supports the concept of asset files. In addition to being bundled separately, asset files may have different resolution suffixes (configured via resolver.assetResolutions), for example ./img/check.png may resolve a set of files that includes ./img/check@2x.png, selected depending on target device.

Since this concept is not expressible using the existing "exports" spec (see Subpath patterns), it makes sense to add Metro-specific functionality to continue supporting this feature.

We support existing config options that can be provided to customise how asset resolution behaves. These should remain load-bearing whether a file is resolved via "exports" or by filesystem resolution:

Proposed:

  • Handle asset resolutions by calling isAssetFile on the subpath mapped to by "exports". If this returns true, then expand asset resolutions by calling resolveAsset against this path.
  • As with source files, if a subpath is not matched in "exports" and falls back to legacy resolution, a package encapsulation warning will be logged (using the base file name).

Additional consideration: Jest

Because Metro's implementation of "exports" will not be strict, package resolution behaviour will be misaligned between Metro and Jest (where support was added in Jest 28). In projects consuming several exports-enabled dependencies, it is likely that misalignments will lead to confusing test failures.

Ideally, we can make use of the custom React Native test env introduced in facebook/react-native#34971 to align major differences.

Proposed:

  • Update customExportConditions in React Native's project template to include "react-native".
  • P1 (not targeted in first iteration): Align non-strict resolution in Jest via available configuration in React Native's Jest environment (potentially exposing a relevant integration point in metro-resolver).

Adoption strategy

Metro rollout

We plan to deliver "exports" functionality to app developers via a major release of Metro once we consider P0 functionality to be stable. Before this point, we will ship an experimental Metro config option (likely, resolver.experimentalPackageExports) allowing app and package developers to preview functionality.

We expect the breaking changes to be a manageable upgrade for app developers, impacting imports from the subset of packages which define "exports" and the subset of those with different pre-existing assumptions about "exports" handling in React Native.

  • Removal of platform-specific extension handling when a subpath is present in "exports":
    • Medium risk: Will impact cross-platform libraries where React Native is targeted via .native.js or where the top-level "react-native" field is expected to override "exports".
    • Low risk: Will impact libraries using extensions to target individual platforms (.[platform].js). We assume the majority of packages which make use of this are React Native libraries, which are less likely to have migrated to "exports".
  • Low risk: Edge case conflicts between an aliased subpath defined in "exports" and a filesystem path.
  • Low risk: Order-sensitivity of "browser" and "react-native" conditions under "exports" — may affect the subset of developers using react-native-web with Metro (non-default) or standalone web projects with Metro.

We will seek feedback from app developers who choose to preview "exports" functionality and aim to gather data about frequently-used packages before general rollout.

Package adoption

We will recommend that packages should start adopting "exports" at the point of the next major React Native release that includes "exports"-ready Metro.

Package authors will then be able to drive this migration, reaching app developers in either minor or breaking package releases. While consuming projects with Metro will use non-strict handling we will recommend that most packages define a spec-ready list of exports rather than exporting all entry points.

We anticipate this adoption will be gradual. We will maintain long-term support for packages that don't switch to "exports", for instance React Native-only libraries which do not want to drop platform-specific extensions.

How we teach this

Adding "react-native" to the Node.js docs

As previously described, we will seek to include "react-native" in the list of Community Conditions Definitions in the Node.js docs, increasing its discoverability for package authors across the npm ecosystem.

Update: Submitted as nodejs/node#45367.

Warnings in Metro Server

A visible place we can indicate incompatibilities with the "exports" spec between apps and packages is in Metro Server logs. This will inform app developers and help them towards updating their app code or nudging package authors. It is anticipated there will be a low occurrence of these edge cases in a typical React Native project.

  • Warning: 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.

Updating package.json boilerplate in template projects

We will update the following templates/tools used to scaffold React Native libraries to use "exports" over the "main" field:

Metro/React Native website blog post

On completion, we will publish a new article on the availability of "exports" in React Native. This will include (or be wholly focused around) examples and recommendations to on updating packages to use "exports" as either a non-breaking or breaking release. The article may preview the future intended behaviour of opt-in strict handling, enabling package encapsulation and spec-compliant resolution.

Unresolved questions

Use cases for custom condition names

We are asking for input on the usefulness of this feature and use cases for apps, e.g. static "development" and "production" conditions.

If there is a clear case that projects will benefit from dynamic configuration of matched condition names (e.g. per platform or from other resolve-time info), this will inform config design.