Skip to content

Commit 216d3e2

Browse files
huntiefacebook-github-bot
authored andcommitted
Implement package exports subpath patterns (experimental)
Summary: - Implement full support for package exports [subpath patterns](https://nodejs.org/docs/latest-v19.x/api/packages.html#subpath-patterns). - Add additional test cases. - Mark tests describing out-of-spec behaviour as `[nonstrict]`. Changelog: **[Experimental]** Add resolution of package exports subpath patterns Reviewed By: robhogan Differential Revision: D42889399 fbshipit-source-id: f9372a1df269b9d57f839667d795aa93876ff040
1 parent a929d35 commit 216d3e2

File tree

2 files changed

+145
-4
lines changed

2 files changed

+145
-4
lines changed

packages/metro-resolver/src/PackageExportsResolve.js

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,34 @@ function matchSubpathFromExports(
8888

8989
const exportMap = reduceExportsField(exportsField, conditionNames);
9090

91-
return exportMap[subpath];
91+
let match = exportMap[subpath];
92+
93+
// Attempt to match after expanding any subpath pattern keys
94+
if (match == null) {
95+
// Gather keys which are subpath patterns in descending order of specificity
96+
const expansionKeys = Object.keys(exportMap)
97+
.filter(key => key.includes('*'))
98+
.sort(key => key.split('*')[0].length)
99+
.reverse();
100+
101+
for (const key of expansionKeys) {
102+
const value = exportMap[key];
103+
104+
// Skip invalid values (must include a single '*' or be `null`)
105+
if (typeof value === 'string' && value.split('*').length !== 2) {
106+
break;
107+
}
108+
109+
const patternMatch = matchSubpathPattern(key, subpath);
110+
111+
if (patternMatch != null) {
112+
match = value == null ? null : value.replace('*', patternMatch);
113+
break;
114+
}
115+
}
116+
}
117+
118+
return match;
92119
}
93120

94121
type FlattenedExportMap = $ReadOnly<{[subpath: string]: string | null}>;
@@ -190,3 +217,25 @@ function reduceConditionalExport(
190217

191218
return reducedValue;
192219
}
220+
221+
/**
222+
* If a subpath pattern expands to the passed subpath, return the subpath match
223+
* (value to substitute for '*'). Otherwise, return `null`.
224+
*
225+
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#subpath-patterns.
226+
*/
227+
function matchSubpathPattern(
228+
subpathPattern: string,
229+
subpath: string,
230+
): string | null {
231+
const [patternBase, patternTrailer] = subpathPattern.split('*');
232+
233+
if (subpath.startsWith(patternBase) && subpath.endsWith(patternTrailer)) {
234+
return subpath.substring(
235+
patternBase.length,
236+
subpath.length - patternTrailer.length,
237+
);
238+
}
239+
240+
return null;
241+
}

packages/metro-resolver/src/__tests__/package-exports-test.js

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
import Resolver from '../index';
1313
import {createPackageAccessors, createResolutionContext} from './utils';
1414

15+
// Tests validating Package Exports resolution behaviour. See RFC0534:
16+
// https://github.com/react-native-community/discussions-and-proposals/blob/master/proposals/0534-metro-package-exports-support.md
17+
//
18+
// '[nonstrict]' tests describe behaviour that is out-of-spec, but which Metro
19+
// supports at feature launch for backwards compatibility. A future strict mode
20+
// for exports will disable these features.
21+
1522
describe('with package exports resolution disabled', () => {
1623
test('should ignore "exports" field for main entry point', () => {
1724
const context = {
@@ -115,7 +122,7 @@ describe('with package exports resolution enabled', () => {
115122
});
116123
});
117124

118-
test('should fall back to "main" field resolution when file does not exist', () => {
125+
test('[nonstrict] should fall back to "main" field resolution when file does not exist', () => {
119126
const context = {
120127
...baseContext,
121128
...createPackageAccessors({
@@ -134,7 +141,7 @@ describe('with package exports resolution enabled', () => {
134141
// file missing message
135142
});
136143

137-
test('should fall back to "main" field resolution when "exports" is an invalid subpath', () => {
144+
test('[nonstrict] should fall back to "main" field resolution when "exports" is an invalid subpath', () => {
138145
const context = {
139146
...baseContext,
140147
...createPackageAccessors({
@@ -238,7 +245,7 @@ describe('with package exports resolution enabled', () => {
238245
});
239246

240247
describe('package encapsulation', () => {
241-
test('should fall back to "browser" spec resolution and log inaccessible import warning', () => {
248+
test('[nonstrict] should fall back to "browser" spec resolution and log inaccessible import warning', () => {
242249
expect(
243250
Resolver.resolve(baseContext, 'test-pkg/private/bar', null),
244251
).toEqual({
@@ -269,6 +276,91 @@ describe('with package exports resolution enabled', () => {
269276
});
270277
});
271278

279+
describe('subpath patterns', () => {
280+
const baseContext = {
281+
...createResolutionContext({
282+
'/root/src/main.js': '',
283+
'/root/node_modules/test-pkg/package.json': JSON.stringify({
284+
name: 'test-pkg',
285+
main: 'index.js',
286+
exports: {
287+
'./features/*.js': './src/features/*.js',
288+
'./features/bar/*.js': {
289+
'react-native': null,
290+
},
291+
'./assets/*': './assets/*',
292+
},
293+
}),
294+
'/root/node_modules/test-pkg/src/features/foo.js': '',
295+
'/root/node_modules/test-pkg/src/features/foo.js.js': '',
296+
'/root/node_modules/test-pkg/src/features/bar/Bar.js': '',
297+
'/root/node_modules/test-pkg/src/features/baz.native.js': '',
298+
'/root/node_modules/test-pkg/assets/Logo.js': '',
299+
}),
300+
originModulePath: '/root/src/main.js',
301+
unstable_enablePackageExports: true,
302+
};
303+
304+
test('should resolve subpath patterns in "exports" matching import specifier', () => {
305+
for (const [importSpecifier, filePath] of [
306+
[
307+
'test-pkg/features/foo.js',
308+
'/root/node_modules/test-pkg/src/features/foo.js',
309+
],
310+
// Valid: Subpath patterns allow the match to be any substring between
311+
// the pattern base and pattern trailer
312+
[
313+
'test-pkg/features/foo.js.js',
314+
'/root/node_modules/test-pkg/src/features/foo.js.js',
315+
],
316+
[
317+
'test-pkg/features/bar/Bar.js',
318+
'/root/node_modules/test-pkg/src/features/bar/Bar.js',
319+
],
320+
]) {
321+
expect(Resolver.resolve(baseContext, importSpecifier, null)).toEqual({
322+
type: 'sourceFile',
323+
filePath,
324+
});
325+
}
326+
327+
expect(() =>
328+
Resolver.resolve(baseContext, 'test-pkg/features/foo', null),
329+
).toThrowError();
330+
expect(() =>
331+
Resolver.resolve(baseContext, 'test-pkg/features/baz.js', null),
332+
).toThrowError();
333+
});
334+
335+
test('should use most specific pattern base', () => {
336+
const context = {
337+
...baseContext,
338+
unstable_conditionNames: ['react-native'],
339+
};
340+
341+
// TODO(T145206395): Improve this error trace
342+
expect(() =>
343+
Resolver.resolve(context, 'test-pkg/features/bar/Bar.js', null),
344+
).toThrowErrorMatchingInlineSnapshot(`
345+
"Module does not exist in the Haste module map or in these directories:
346+
/root/src/node_modules
347+
/root/node_modules
348+
/node_modules
349+
"
350+
`);
351+
});
352+
353+
test('[nonstrict] should fall back to "browser" spec resolution and log inaccessible import warning', () => {
354+
expect(
355+
Resolver.resolve(baseContext, 'test-pkg/assets/Logo.js', null),
356+
).toEqual({
357+
type: 'sourceFile',
358+
filePath: '/root/node_modules/test-pkg/assets/Logo.js',
359+
});
360+
// TODO(T142200031): Assert inaccessible import warning is logged
361+
});
362+
});
363+
272364
describe('conditional exports', () => {
273365
const baseContext = {
274366
...createResolutionContext({

0 commit comments

Comments
 (0)