From 216d3e234c14a7c16b9561b2682e60d2e2936114 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 15 Feb 2023 08:44:28 -0800 Subject: [PATCH] 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 --- .../src/PackageExportsResolve.js | 51 +++++++++- .../src/__tests__/package-exports-test.js | 98 ++++++++++++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/packages/metro-resolver/src/PackageExportsResolve.js b/packages/metro-resolver/src/PackageExportsResolve.js index 97147c3acf..9fce6b9232 100644 --- a/packages/metro-resolver/src/PackageExportsResolve.js +++ b/packages/metro-resolver/src/PackageExportsResolve.js @@ -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}>; @@ -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; +} diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 99484cbaf2..9972dad10a 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -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 = { @@ -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({ @@ -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({ @@ -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({ @@ -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({