Skip to content

Commit

Permalink
Implement package exports subpath patterns (experimental)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
huntie authored and facebook-github-bot committed Feb 15, 2023
1 parent a929d35 commit 216d3e2
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 4 deletions.
51 changes: 50 additions & 1 deletion packages/metro-resolver/src/PackageExportsResolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,34 @@ function matchSubpathFromExports(

const exportMap = reduceExportsField(exportsField, conditionNames);

return exportMap[subpath];
let match = exportMap[subpath];

// Attempt to match after expanding any subpath pattern keys
if (match == null) {
// Gather keys which are subpath patterns in descending order of specificity
const expansionKeys = Object.keys(exportMap)
.filter(key => key.includes('*'))
.sort(key => key.split('*')[0].length)
.reverse();

for (const key of expansionKeys) {
const value = exportMap[key];

// Skip invalid values (must include a single '*' or be `null`)
if (typeof value === 'string' && value.split('*').length !== 2) {
break;
}

const patternMatch = matchSubpathPattern(key, subpath);

if (patternMatch != null) {
match = value == null ? null : value.replace('*', patternMatch);
break;
}
}
}

return match;
}

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

return reducedValue;
}

/**
* If a subpath pattern expands to the passed subpath, return the subpath match
* (value to substitute for '*'). Otherwise, return `null`.
*
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#subpath-patterns.
*/
function matchSubpathPattern(
subpathPattern: string,
subpath: string,
): string | null {
const [patternBase, patternTrailer] = subpathPattern.split('*');

if (subpath.startsWith(patternBase) && subpath.endsWith(patternTrailer)) {
return subpath.substring(
patternBase.length,
subpath.length - patternTrailer.length,
);
}

return null;
}
98 changes: 95 additions & 3 deletions packages/metro-resolver/src/__tests__/package-exports-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
import Resolver from '../index';
import {createPackageAccessors, createResolutionContext} from './utils';

// Tests validating Package Exports resolution behaviour. See RFC0534:
// https://github.com/react-native-community/discussions-and-proposals/blob/master/proposals/0534-metro-package-exports-support.md
//
// '[nonstrict]' tests describe behaviour that is out-of-spec, but which Metro
// supports at feature launch for backwards compatibility. A future strict mode
// for exports will disable these features.

describe('with package exports resolution disabled', () => {
test('should ignore "exports" field for main entry point', () => {
const context = {
Expand Down Expand Up @@ -115,7 +122,7 @@ describe('with package exports resolution enabled', () => {
});
});

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

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

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

describe('subpath patterns', () => {
const baseContext = {
...createResolutionContext({
'/root/src/main.js': '',
'/root/node_modules/test-pkg/package.json': JSON.stringify({
name: 'test-pkg',
main: 'index.js',
exports: {
'./features/*.js': './src/features/*.js',
'./features/bar/*.js': {
'react-native': null,
},
'./assets/*': './assets/*',
},
}),
'/root/node_modules/test-pkg/src/features/foo.js': '',
'/root/node_modules/test-pkg/src/features/foo.js.js': '',
'/root/node_modules/test-pkg/src/features/bar/Bar.js': '',
'/root/node_modules/test-pkg/src/features/baz.native.js': '',
'/root/node_modules/test-pkg/assets/Logo.js': '',
}),
originModulePath: '/root/src/main.js',
unstable_enablePackageExports: true,
};

test('should resolve subpath patterns in "exports" matching import specifier', () => {
for (const [importSpecifier, filePath] of [
[
'test-pkg/features/foo.js',
'/root/node_modules/test-pkg/src/features/foo.js',
],
// Valid: Subpath patterns allow the match to be any substring between
// the pattern base and pattern trailer
[
'test-pkg/features/foo.js.js',
'/root/node_modules/test-pkg/src/features/foo.js.js',
],
[
'test-pkg/features/bar/Bar.js',
'/root/node_modules/test-pkg/src/features/bar/Bar.js',
],
]) {
expect(Resolver.resolve(baseContext, importSpecifier, null)).toEqual({
type: 'sourceFile',
filePath,
});
}

expect(() =>
Resolver.resolve(baseContext, 'test-pkg/features/foo', null),
).toThrowError();
expect(() =>
Resolver.resolve(baseContext, 'test-pkg/features/baz.js', null),
).toThrowError();
});

test('should use most specific pattern base', () => {
const context = {
...baseContext,
unstable_conditionNames: ['react-native'],
};

// TODO(T145206395): Improve this error trace
expect(() =>
Resolver.resolve(context, 'test-pkg/features/bar/Bar.js', null),
).toThrowErrorMatchingInlineSnapshot(`
"Module does not exist in the Haste module map or in these directories:
/root/src/node_modules
/root/node_modules
/node_modules
"
`);
});

test('[nonstrict] should fall back to "browser" spec resolution and log inaccessible import warning', () => {
expect(
Resolver.resolve(baseContext, 'test-pkg/assets/Logo.js', null),
).toEqual({
type: 'sourceFile',
filePath: '/root/node_modules/test-pkg/assets/Logo.js',
});
// TODO(T142200031): Assert inaccessible import warning is logged
});
});

describe('conditional exports', () => {
const baseContext = {
...createResolutionContext({
Expand Down

0 comments on commit 216d3e2

Please sign in to comment.