Skip to content

Commit 1e47cb5

Browse files
huntiefacebook-github-bot
authored andcommitted
Add compatibility with legacy Node.js "exports" array fallback
Summary: Adds compatibility for reading Package Exports mappings given as an array value (edge case, and distinct from D44338043). Specifically, Metro now accommodates the fallback array format for Node.js 13.0 =< 13.7, as used by packages such as `babel/runtime`. This format defines a string fallback when specifying a conditional exports mapping for a subpath (or root value), which wasn't implemented in Node.js 13.0. Metro will use the first valid value. ```js { ... "exports": { "./helpers/interopRequireDefault": [ { "node": "./helpers/interopRequireDefault.js", "import": "./helpers/esm/interopRequireDefault.js", "default": "./helpers/interopRequireDefault.js" }, "./helpers/interopRequireDefault.js" ], ... } } ``` babel/babel#12877 This also adds validation to reject nested array formats (another non-standard pattern, and an extreme edge case), which will fall back to file-based resolution and log a warning (see new test cases). Changelog: **[Experimental]** Add compatibility with legacy Node.js "exports" array formats Reviewed By: robhogan Differential Revision: D43871634 fbshipit-source-id: 22f66c1f0a3f0e0b05716b2d0603e9a8003f3ac6
1 parent f321cff commit 1e47cb5

File tree

3 files changed

+274
-28
lines changed

3 files changed

+274
-28
lines changed

packages/metro-resolver/src/PackageExportsResolve.js

+68-26
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import type {
1313
ExportMap,
14+
ExportMapWithFallbacks,
1415
ExportsField,
1516
FileResolution,
1617
ResolutionContext,
@@ -54,15 +55,15 @@ export function resolvePackageTargetFromExports(
5455
exportsField: ExportsField,
5556
platform: string | null,
5657
): FileResolution {
57-
const raiseConfigError = (reason: string) => {
58-
throw new InvalidPackageConfigurationError({
58+
const createConfigError = (reason: string) => {
59+
return new InvalidPackageConfigurationError({
5960
reason,
6061
packagePath,
6162
});
6263
};
6364

6465
const subpath = getExportsSubpath(packagePath, modulePath);
65-
const exportMap = normalizeExportsField(exportsField, raiseConfigError);
66+
const exportMap = normalizeExportsField(exportsField, createConfigError);
6667

6768
if (!isSubpathDefinedInExports(exportMap, subpath)) {
6869
throw new PackagePathNotExportedError(
@@ -76,14 +77,14 @@ export function resolvePackageTargetFromExports(
7677
subpath,
7778
exportMap,
7879
platform,
79-
raiseConfigError,
80+
createConfigError,
8081
);
8182

8283
if (target != null) {
8384
const invalidSegmentInTarget = findInvalidPathSegment(target.slice(2));
8485

8586
if (invalidSegmentInTarget != null) {
86-
raiseConfigError(
87+
throw createConfigError(
8788
`The target for "${subpath}" defined in "exports" is "${target}", ` +
8889
'however this value is an invalid subpath or subpath pattern ' +
8990
`because it includes "${invalidSegmentInTarget}".`,
@@ -117,7 +118,7 @@ export function resolvePackageTargetFromExports(
117118
return {type: 'sourceFile', filePath};
118119
}
119120

120-
raiseConfigError(
121+
throw createConfigError(
121122
`The resolution for "${modulePath}" defined in "exports" is ${filePath}, ` +
122123
'however this file does not exist.',
123124
);
@@ -142,45 +143,86 @@ function getExportsSubpath(packagePath: string, modulePath: string): string {
142143

143144
/**
144145
* Normalise an "exports"-like field by parsing string shorthand and conditions
145-
* shorthand at root.
146+
* shorthand at root, and flattening any legacy Node.js <13.7 array values.
146147
*
147148
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#exports-sugar.
148149
*/
149150
function normalizeExportsField(
150151
exportsField: ExportsField,
151-
raiseConfigError: (reason: string) => void,
152+
createConfigError: (reason: string) => Error,
152153
): ExportMap {
153-
if (typeof exportsField === 'string') {
154-
return {'.': exportsField};
155-
}
154+
let rootValue;
156155

157156
if (Array.isArray(exportsField)) {
158-
return exportsField.reduce(
159-
(result, subpath) => ({
160-
...result,
161-
[subpath]: subpath,
162-
}),
163-
{},
157+
// If an array of strings, expand as subpath mapping (legacy root shorthand)
158+
if (exportsField.every(value => typeof value === 'string')) {
159+
// $FlowIssue[incompatible-call] exportsField is refined to `string[]`
160+
return exportsField.reduce(
161+
(result: ExportMap, subpath: string) => ({
162+
...result,
163+
[subpath]: subpath,
164+
}),
165+
{},
166+
);
167+
}
168+
169+
// Otherwise, should be a condition map and fallback string (Node.js <13.7)
170+
rootValue = exportsField[0];
171+
} else {
172+
rootValue = exportsField;
173+
}
174+
175+
if (rootValue == null || Array.isArray(rootValue)) {
176+
throw createConfigError(
177+
'Could not parse non-standard array value at root of "exports" field.',
164178
);
165179
}
166180

167-
const firstLevelKeys = Object.keys(exportsField);
181+
if (typeof rootValue === 'string') {
182+
return {'.': rootValue};
183+
}
184+
185+
const firstLevelKeys = Object.keys(rootValue);
168186
const subpathKeys = firstLevelKeys.filter(subpathOrCondition =>
169187
subpathOrCondition.startsWith('.'),
170188
);
171189

172190
if (subpathKeys.length === firstLevelKeys.length) {
173-
return exportsField;
191+
return flattenLegacySubpathValues(rootValue, createConfigError);
174192
}
175193

176194
if (subpathKeys.length !== 0) {
177-
raiseConfigError(
195+
throw createConfigError(
178196
'The "exports" field cannot have keys which are both subpaths and ' +
179197
'condition names at the same level.',
180198
);
181199
}
182200

183-
return {'.': exportsField};
201+
return {'.': flattenLegacySubpathValues(rootValue, createConfigError)};
202+
}
203+
204+
/**
205+
* Flatten legacy Node.js <13.7 array subpath values in an exports mapping.
206+
*/
207+
function flattenLegacySubpathValues(
208+
exportMap: ExportMap | ExportMapWithFallbacks,
209+
createConfigError: (reason: string) => Error,
210+
): ExportMap {
211+
return Object.keys(exportMap).reduce((result: ExportMap, subpath: string) => {
212+
const value = exportMap[subpath];
213+
214+
// We do not support empty or nested arrays (non-standard)
215+
if (Array.isArray(value) && (!value.length || Array.isArray(value[0]))) {
216+
throw createConfigError(
217+
'Could not parse non-standard array value in "exports" field.',
218+
);
219+
}
220+
221+
return {
222+
...result,
223+
[subpath]: Array.isArray(value) ? value[0] : value,
224+
};
225+
}, {});
184226
}
185227

186228
/**
@@ -224,7 +266,7 @@ function matchSubpathFromExports(
224266
subpath: string,
225267
exportMap: ExportMap,
226268
platform: string | null,
227-
raiseConfigError: (reason: string) => void,
269+
createConfigError: (reason: string) => Error,
228270
): $ReadOnly<{
229271
target: string | null,
230272
patternMatch: string | null,
@@ -240,7 +282,7 @@ function matchSubpathFromExports(
240282
const exportMapAfterConditions = reduceExportMap(
241283
exportMap,
242284
conditionNames,
243-
raiseConfigError,
285+
createConfigError,
244286
);
245287

246288
let target = exportMapAfterConditions[subpath];
@@ -283,7 +325,7 @@ type FlattenedExportMap = $ReadOnly<{[subpath: string]: string | null}>;
283325
function reduceExportMap(
284326
exportMap: ExportMap,
285327
conditionNames: $ReadOnlySet<string>,
286-
raiseConfigError: (reason: string) => void,
328+
createConfigError: (reason: string) => Error,
287329
): FlattenedExportMap {
288330
const result: {[subpath: string]: string | null} = {};
289331

@@ -306,7 +348,7 @@ function reduceExportMap(
306348
);
307349

308350
if (invalidValues.length) {
309-
raiseConfigError(
351+
throw createConfigError(
310352
'One or more mappings for subpaths defined in "exports" are invalid. ' +
311353
'All values must begin with "./".',
312354
);
@@ -325,7 +367,7 @@ function reduceExportMap(
325367
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#conditional-exports.
326368
*/
327369
function reduceConditionalExport(
328-
subpathValue: ExportMap | string | null,
370+
subpathValue: $Values<ExportMap>,
329371
conditionNames: $ReadOnlySet<string>,
330372
): string | null | 'no-match' {
331373
let reducedValue = subpathValue;

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

+188
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,20 @@ describe('with package exports resolution enabled', () => {
308308
`);
309309
});
310310

311+
test('should use "exports" for bare specifiers within the same package', () => {
312+
const context = {
313+
...baseContext,
314+
originModulePath: '/root/node_modules/test-pkg/lib/foo.js',
315+
};
316+
317+
expect(Resolver.resolve(context, 'test-pkg/metadata.json', null)).toEqual(
318+
{
319+
type: 'sourceFile',
320+
filePath: '/root/node_modules/test-pkg/metadata.min.json',
321+
},
322+
);
323+
});
324+
311325
test('should not use "exports" for internal relative imports within a package', () => {
312326
const context = {
313327
...baseContext,
@@ -849,4 +863,178 @@ describe('with package exports resolution enabled', () => {
849863
);
850864
});
851865
});
866+
867+
describe('compatibility with non-standard "exports" array formats', () => {
868+
// Node.js versions >=13.0.0, <13.7.0 support the `exports` field but not
869+
// conditional exports. Used by packages such as @babel/runtime.
870+
// See https://github.com/babel/babel/pull/12877
871+
describe('early Node.js 13 versions', () => {
872+
test('should use first value when subpath is an array including a condition mapping', () => {
873+
const context = {
874+
...createResolutionContext({
875+
'/root/src/main.js': '',
876+
'/root/node_modules/@babel/runtime/package.json': JSON.stringify({
877+
exports: {
878+
'./helpers/typeof': [
879+
{
880+
node: './helpers/typeof.js',
881+
import: './helpers/esm/typeof.js',
882+
default: './helpers/typeof.js',
883+
},
884+
'./helpers/typeof.js',
885+
],
886+
'./helpers/interopRequireDefault': [
887+
{
888+
node: './helpers/interopRequireDefault.js',
889+
import: './helpers/esm/interopRequireDefault.js',
890+
default: './helpers/interopRequireDefault.js',
891+
},
892+
'./helpers/interopRequireDefault.js',
893+
],
894+
},
895+
}),
896+
'/root/node_modules/@babel/runtime/helpers/interopRequireDefault.js':
897+
'',
898+
'/root/node_modules/@babel/runtime/helpers/esm/interopRequireDefault.js':
899+
'',
900+
'/root/node_modules/@babel/runtime/helpers/esm/typeof.js': '',
901+
}),
902+
originModulePath: '/root/src/main.js',
903+
unstable_conditionNames: [],
904+
unstable_enablePackageExports: true,
905+
};
906+
907+
expect(
908+
Resolver.resolve(
909+
context,
910+
'@babel/runtime/helpers/interopRequireDefault',
911+
null,
912+
),
913+
).toEqual({
914+
type: 'sourceFile',
915+
filePath:
916+
'/root/node_modules/@babel/runtime/helpers/interopRequireDefault.js',
917+
});
918+
expect(
919+
Resolver.resolve(
920+
{...context, unstable_conditionNames: ['import']},
921+
'@babel/runtime/helpers/interopRequireDefault',
922+
null,
923+
),
924+
).toEqual({
925+
type: 'sourceFile',
926+
filePath:
927+
'/root/node_modules/@babel/runtime/helpers/esm/interopRequireDefault.js',
928+
});
929+
// Check internal self-reference case explicitly!
930+
expect(
931+
Resolver.resolve(
932+
{
933+
...context,
934+
originModulePath:
935+
'/root/node_modules/@babel/runtime/helpers/esm/interopRequireDefault.js',
936+
unstable_conditionNames: ['import'],
937+
},
938+
'@babel/runtime/helpers/typeof',
939+
null,
940+
),
941+
).toEqual({
942+
type: 'sourceFile',
943+
filePath: '/root/node_modules/@babel/runtime/helpers/esm/typeof.js',
944+
});
945+
});
946+
947+
test('should use first value when subpath (root shorthand) is an array including a condition mapping', () => {
948+
const context = {
949+
...createResolutionContext({
950+
'/root/src/main.js': '',
951+
'/root/node_modules/test-pkg/package.json': JSON.stringify({
952+
exports: [
953+
{browser: './index-browser.js', default: 'index.js'},
954+
'./index-alt.js',
955+
],
956+
}),
957+
'/root/node_modules/test-pkg/index.js': '',
958+
'/root/node_modules/test-pkg/index-browser.js': '',
959+
}),
960+
originModulePath: '/root/src/main.js',
961+
unstable_conditionNames: ['browser'],
962+
unstable_enablePackageExports: true,
963+
};
964+
965+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
966+
type: 'sourceFile',
967+
filePath: '/root/node_modules/test-pkg/index-browser.js',
968+
});
969+
});
970+
});
971+
972+
// Array as order-preserving data structure for other environments.
973+
// See https://github.com/nodejs/node/issues/37777#issuecomment-804164719
974+
describe('[unsupported] exotic nested arrays', () => {
975+
test('should fall back and log warning for nested array at root', () => {
976+
const logWarning = jest.fn();
977+
const context = {
978+
...createResolutionContext({
979+
'/root/src/main.js': '',
980+
'/root/node_modules/test-pkg/package.json': JSON.stringify({
981+
main: './index.js',
982+
exports: [
983+
[{import: 'index.mjs'}],
984+
[{require: 'index.cjs'}],
985+
['index.cjs'],
986+
],
987+
}),
988+
'/root/node_modules/test-pkg/index.js': '',
989+
}),
990+
originModulePath: '/root/src/main.js',
991+
unstable_enablePackageExports: true,
992+
unstable_logWarning: logWarning,
993+
};
994+
995+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
996+
type: 'sourceFile',
997+
filePath: '/root/node_modules/test-pkg/index.js',
998+
});
999+
expect(logWarning).toHaveBeenCalledTimes(1);
1000+
expect(logWarning.mock.calls[0][0]).toMatchInlineSnapshot(`
1001+
"The package /root/node_modules/test-pkg contains an invalid package.json configuration. Consider raising this issue with the package maintainer(s).
1002+
Reason: Could not parse non-standard array value at root of \\"exports\\" field. Falling back to file-based resolution."
1003+
`);
1004+
});
1005+
1006+
test('should fall back and log warning for nested array at subpath', () => {
1007+
const logWarning = jest.fn();
1008+
const context = {
1009+
...createResolutionContext({
1010+
'/root/src/main.js': '',
1011+
'/root/node_modules/test-pkg/package.json': JSON.stringify({
1012+
main: './index.js',
1013+
exports: {
1014+
'.': [
1015+
[{import: 'index.mjs'}],
1016+
[{require: 'index.cjs'}],
1017+
['index.cjs'],
1018+
],
1019+
},
1020+
}),
1021+
'/root/node_modules/test-pkg/index.js': '',
1022+
}),
1023+
originModulePath: '/root/src/main.js',
1024+
unstable_enablePackageExports: true,
1025+
unstable_logWarning: logWarning,
1026+
};
1027+
1028+
expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({
1029+
type: 'sourceFile',
1030+
filePath: '/root/node_modules/test-pkg/index.js',
1031+
});
1032+
expect(logWarning).toHaveBeenCalledTimes(1);
1033+
expect(logWarning.mock.calls[0][0]).toMatchInlineSnapshot(`
1034+
"The package /root/node_modules/test-pkg contains an invalid package.json configuration. Consider raising this issue with the package maintainer(s).
1035+
Reason: Could not parse non-standard array value in \\"exports\\" field. Falling back to file-based resolution."
1036+
`);
1037+
});
1038+
});
1039+
});
8521040
});

0 commit comments

Comments
 (0)