Skip to content

Commit 4d7ab38

Browse files
huntiefacebook-github-bot
authored andcommitted
Implement package exports entry point resolution (experimental)
Summary: Add the base implementation for Package Exports resolution, and configure for main package entry point resolution when `resolver.unstable_enablePackageExports` is configured. Changelog: **[Experimental]** Add package entry point resolution from "exports" field Reviewed By: robhogan Differential Revision: D42889398 fbshipit-source-id: 436a0c22380ceaeeb28f54ce6c46bbffdb385cc5
1 parent 1ba4720 commit 4d7ab38

6 files changed

Lines changed: 360 additions & 18 deletions

File tree

packages/metro-resolver/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"cleanup-release": "test ! -e build && mv src build && mv src.real src"
1313
},
1414
"dependencies": {
15-
"absolute-path": "^0.0.0"
15+
"absolute-path": "^0.0.0",
16+
"invariant": "^2.2.4"
1617
},
1718
"license": "MIT",
1819
"engines": {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
import type {ExportMap, PackageInfo, ResolutionContext} from './types';
13+
14+
import invariant from 'invariant';
15+
16+
export type PackageExportsResolutionContext = $ReadOnly<{
17+
unstable_conditionNames: ResolutionContext['unstable_conditionNames'],
18+
unstable_conditionsByPlatform: ResolutionContext['unstable_conditionsByPlatform'],
19+
...
20+
}>;
21+
22+
/**
23+
* Resolve the main entry point subpath for a package.
24+
*
25+
* Implements modern package resolution behaviour based on the [Package Entry
26+
* Points spec](https://nodejs.org/docs/latest-v19.x/api/packages.html#package-entry-points).
27+
*/
28+
export function getPackageEntryPointFromExports(
29+
context: PackageExportsResolutionContext,
30+
packageInfo: PackageInfo,
31+
platform: string | null,
32+
): ?string {
33+
return matchSubpathFromExports('.', context, packageInfo, platform);
34+
}
35+
36+
/**
37+
* Get the mapped replacement for the given subpath.
38+
*
39+
* Implements modern package resolution behaviour based on the [Package Entry
40+
* Points spec](https://nodejs.org/docs/latest-v19.x/api/packages.html#package-entry-points).
41+
*/
42+
export function matchSubpathFromExports(
43+
/**
44+
* The package-relative subpath (beginning with '.') to match against either
45+
* an exact subpath key or subpath pattern key in "exports".
46+
*/
47+
subpath: string,
48+
context: PackageExportsResolutionContext,
49+
{packageJson}: PackageInfo,
50+
platform: string | null,
51+
): ?string {
52+
const {exports: exportsField} = packageJson;
53+
54+
if (exportsField == null) {
55+
return null;
56+
}
57+
58+
const conditionNames = new Set([
59+
'default',
60+
...context.unstable_conditionNames,
61+
...(platform != null
62+
? context.unstable_conditionsByPlatform[platform] ?? []
63+
: []),
64+
]);
65+
66+
let exportMap: FlattenedExportMap;
67+
68+
try {
69+
exportMap = reduceExportsField(exportsField, conditionNames);
70+
} catch (e) {
71+
// TODO(T143882479): Log a warning if the "exports" field cannot be parsed
72+
// NOTE: Under strict mode, this should throw an InvalidPackageConfigurationError
73+
return null;
74+
}
75+
76+
return exportMap[subpath];
77+
}
78+
79+
type FlattenedExportMap = $ReadOnly<{[subpath: string]: string | null}>;
80+
81+
/**
82+
* Reduce an "exports"-like field to a flat subpath mapping after resolving
83+
* shorthand syntax and conditional exports.
84+
*
85+
* @throws Will throw invariant violation if structure or configuration
86+
* specified by `exportsField` is invalid.
87+
*/
88+
function reduceExportsField(
89+
exportsField: ExportMap | string,
90+
conditionNames: $ReadOnlySet<string>,
91+
): FlattenedExportMap {
92+
if (typeof exportsField === 'string') {
93+
return {'.': exportsField};
94+
}
95+
96+
const firstLevelKeys = Object.keys(exportsField);
97+
const subpathKeys = firstLevelKeys.filter(subpathOrCondition =>
98+
subpathOrCondition.startsWith('.'),
99+
);
100+
101+
invariant(
102+
subpathKeys.length === 0 || subpathKeys.length === firstLevelKeys.length,
103+
'"exports" object cannot have keys mapping both subpaths and conditions ' +
104+
'at the same level',
105+
);
106+
107+
let exportMap = exportsField;
108+
109+
// Normalise conditions shorthand at root
110+
if (subpathKeys.length === 0) {
111+
exportMap = {'.': exportsField};
112+
}
113+
114+
const result: {[subpath: string]: string | null} = {};
115+
116+
for (const subpath in exportMap) {
117+
const subpathValue = reduceConditionalExport(
118+
exportMap[subpath],
119+
conditionNames,
120+
);
121+
122+
// If a subpath has no resolution for the passed `conditionNames`, do not
123+
// include it in the result. (This includes only explicit `null` values,
124+
// which may conditionally hide higher-specificity subpath patterns.)
125+
if (subpathValue !== 'no-match') {
126+
result[subpath] = subpathValue;
127+
}
128+
}
129+
130+
return result;
131+
}
132+
133+
/**
134+
* Reduce an "exports"-like subpath value after asserting the passed
135+
* `conditionNames` in any nested conditions.
136+
*
137+
* Returns `'no-match'` in the case that none of the asserted `conditionNames`
138+
* are matched.
139+
*
140+
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#conditional-exports.
141+
*/
142+
function reduceConditionalExport(
143+
subpathValue: ExportMap | string | null,
144+
conditionNames: $ReadOnlySet<string>,
145+
): string | null | 'no-match' {
146+
let reducedValue = subpathValue;
147+
148+
while (reducedValue != null && typeof reducedValue !== 'string') {
149+
let match: typeof subpathValue | 'no-match' = 'no-match';
150+
151+
for (const conditionName in reducedValue) {
152+
if (conditionNames.has(conditionName)) {
153+
match = reducedValue[conditionName];
154+
break;
155+
}
156+
}
157+
158+
reducedValue = match;
159+
}
160+
161+
return reducedValue;
162+
}

packages/metro-resolver/src/PackageResolve.js

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,56 @@
99
* @oncall react_native
1010
*/
1111

12-
import type {PackageJson, ResolutionContext} from './types';
12+
import type {PackageExportsResolutionContext} from './PackageExportsResolve';
13+
import type {PackageInfo, PackageJson, ResolutionContext} from './types';
1314

1415
import path from 'path';
16+
import {getPackageEntryPointFromExports} from './PackageExportsResolve';
1517
import toPosixPath from './utils/toPosixPath';
1618

1719
/**
18-
* Resolve the main entry point for a package.
20+
* Resolve the main entry point subpath for a package.
1921
*
20-
* Implements legacy (non-exports) package resolution behaviour based on the
21-
* ["browser" field spec](https://github.com/defunctzombie/package-browser-field-spec).
22+
* When `context.unstable_enablePackageExports` is `true`, attempts to resolve
23+
* using the "exports" field if present, based on the [Package Entry Points spec
24+
* ](https://nodejs.org/docs/latest-v19.x/api/packages.html#package-entry-points).
25+
*
26+
* If resolution via "exports" does not return a match, or
27+
* `context.unstable_enablePackageExports` is `false`, will fall back to legacy
28+
* (non-exports) package resolution behaviour based on the ["browser" field spec
29+
* ](https://github.com/defunctzombie/package-browser-field-spec).
2230
*/
2331
export function getPackageEntryPoint(
24-
pkg: PackageJson,
25-
mainFields: $ReadOnlyArray<string>,
32+
context: $ReadOnly<{
33+
...PackageExportsResolutionContext,
34+
doesFileExist: ResolutionContext['doesFileExist'],
35+
mainFields: ResolutionContext['mainFields'],
36+
unstable_enablePackageExports: ResolutionContext['unstable_enablePackageExports'],
37+
...
38+
}>,
39+
packageInfo: PackageInfo,
40+
platform: string | null,
2641
): string {
42+
const {doesFileExist, mainFields, unstable_enablePackageExports} = context;
43+
const pkg = packageInfo.packageJson;
44+
45+
if (unstable_enablePackageExports) {
46+
const packageExportsEntryPoint = getPackageEntryPointFromExports(
47+
context,
48+
packageInfo,
49+
platform,
50+
);
51+
52+
// TODO(T142200031): Log an invalid package warning if entry point missing
53+
// TODO(T142200031): Log a package encapsulation warning on fall-through
54+
if (
55+
packageExportsEntryPoint != null &&
56+
doesFileExist(path.join(packageInfo.rootPath, packageExportsEntryPoint))
57+
) {
58+
return packageExportsEntryPoint;
59+
}
60+
}
61+
2762
let main = 'index';
2863

2964
for (const name of mainFields) {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
import Resolver from '../index';
13+
import {createResolutionContext} from './utils';
14+
15+
describe('with package exports resolution disabled', () => {
16+
test('should ignore "exports" field', () => {
17+
const context = {
18+
...createResolutionContext({
19+
'/root/src/main.js': '',
20+
'/root/node_modules/test-pkg/package.json': JSON.stringify({
21+
main: 'index.js',
22+
exports: 'index-exports.js',
23+
}),
24+
'/root/node_modules/test-pkg/index.js': '',
25+
'/root/node_modules/test-pkg/index-exports.js': '',
26+
}),
27+
originModulePath: '/root/src/main.js',
28+
unstable_enablePackageExports: false,
29+
};
30+
31+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
32+
type: 'sourceFile',
33+
filePath: '/root/node_modules/test-pkg/index.js',
34+
});
35+
});
36+
});
37+
38+
describe('with package exports resolution enabled', () => {
39+
describe('main entry point', () => {
40+
const baseContext = {
41+
...createResolutionContext({
42+
'/root/src/main.js': '',
43+
'/root/node_modules/test-pkg/package.json': '',
44+
'/root/node_modules/test-pkg/index.js': '',
45+
'/root/node_modules/test-pkg/index-main.js': '',
46+
}),
47+
originModulePath: '/root/src/main.js',
48+
unstable_enablePackageExports: true,
49+
};
50+
51+
test('should resolve package using "exports" field', () => {
52+
const context = {
53+
...baseContext,
54+
getPackage: () => ({
55+
main: 'index-main.js',
56+
exports: {
57+
'.': 'index.js',
58+
},
59+
}),
60+
};
61+
62+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
63+
type: 'sourceFile',
64+
filePath: '/root/node_modules/test-pkg/index.js',
65+
});
66+
});
67+
68+
test('should resolve package using "exports" field (shorthand)', () => {
69+
const context = {
70+
...baseContext,
71+
getPackage: () => ({
72+
main: 'index-main.js',
73+
exports: 'index.js',
74+
}),
75+
};
76+
77+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
78+
type: 'sourceFile',
79+
filePath: '/root/node_modules/test-pkg/index.js',
80+
});
81+
});
82+
});
83+
84+
describe('conditional exports', () => {
85+
describe('main entry point', () => {
86+
const baseContext = {
87+
...createResolutionContext({
88+
'/root/src/main.js': '',
89+
'/root/node_modules/test-pkg/package.json': '',
90+
'/root/node_modules/test-pkg/index.js': '',
91+
'/root/node_modules/test-pkg/index-browser.js': '',
92+
}),
93+
originModulePath: '/root/src/main.js',
94+
unstable_enablePackageExports: true,
95+
};
96+
97+
test('should resolve main entry point using conditional exports', () => {
98+
const context = {
99+
...baseContext,
100+
unstable_conditionNames: ['browser', 'import', 'require'],
101+
getPackage: () => ({
102+
main: 'index-main.js',
103+
exports: {
104+
'.': {
105+
browser: './index-browser.js',
106+
default: './index.js',
107+
},
108+
},
109+
}),
110+
};
111+
112+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
113+
type: 'sourceFile',
114+
filePath: '/root/node_modules/test-pkg/index-browser.js',
115+
});
116+
});
117+
118+
test('should resolve main entry point when root keys are a condition mapping (shorthand)', () => {
119+
const context = {
120+
...baseContext,
121+
unstable_conditionNames: ['browser', 'import', 'require'],
122+
getPackage: () => ({
123+
main: 'index-main.js',
124+
exports: {
125+
browser: './index-browser.js',
126+
default: './index.js',
127+
},
128+
}),
129+
};
130+
131+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
132+
type: 'sourceFile',
133+
filePath: '/root/node_modules/test-pkg/index-browser.js',
134+
});
135+
});
136+
});
137+
});
138+
});

packages/metro-resolver/src/resolve.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,13 @@ function resolvePackage(
280280
packageJsonPath: string,
281281
platform: string | null,
282282
): Resolution {
283-
const packagePath = path.dirname(path.resolve(packageJsonPath));
283+
const packageInfo = {
284+
rootPath: path.dirname(path.resolve(packageJsonPath)),
285+
packageJson: context.getPackage(packageJsonPath) ?? {},
286+
};
284287
const mainPrefixPath = path.join(
285-
packagePath,
286-
getPackageEntryPoint(
287-
context.getPackage(packageJsonPath) ?? {},
288-
context.mainFields,
289-
),
288+
packageInfo.rootPath,
289+
getPackageEntryPoint(context, packageInfo, platform),
290290
);
291291
const dirPath = path.dirname(mainPrefixPath);
292292
const prefixName = path.basename(mainPrefixPath);

0 commit comments

Comments
 (0)