Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/metro-resolver/src/__tests__/package-exports-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,92 @@ describe('with package exports resolution enabled', () => {
});
});

test.each([
[
'unmatched condition',
{require: './lib/foo.js'},
{browser: './lib/foo-browser.js'},
],
['empty condition object', {require: './lib/foo.js'}, {}],
[
'nested unmatched condition',
{require: './lib/foo.js'},
{require: {browser: './lib/foo-browser.js'}},
],
[
'unmatched condition',
{default: './lib/foo.js'},
{browser: './lib/foo-browser.js'},
],
['empty condition object', {default: './lib/foo.js'}, {}],
[
'nested unmatched condition',
{default: './lib/foo.js'},
{require: {browser: './lib/foo-browser.js'}},
],
])(
'should continue parent conditions after %s to %p',
(_, fallback, nestedConditions) => {
const logWarning = jest.fn();
const context = {
...baseContext,
...createPackageAccessors({
[p('/root/node_modules/test-pkg/package.json')]: {
exports: {
'./foo.js': {
'react-native': nestedConditions,
...fallback,
},
},
},
}),
unstable_conditionNames: ['react-native'],
unstable_logWarning: logWarning,
};

expect(Resolver.resolve(context, 'test-pkg/foo.js', null)).toEqual({
type: 'sourceFile',
filePath: p('/root/node_modules/test-pkg/lib/foo.js'),
});
expect(logWarning).not.toHaveBeenCalled();
},
);

test.each([
['null', null, 'no match was resolved'],
['invalid', 'invalid-target', 'invalid package.json configuration'],
['numeric', 42, 'invalid package.json configuration'],
['boolean', false, 'invalid package.json configuration'],
['unsupported empty array', [], 'no match was resolved'],
['unsupported nested array', ['./lib/foo.js'], 'no match was resolved'],
])(
'should not continue parent conditions after a %s target',
(_, target, warning) => {
const logWarning = jest.fn();
const context = {
...baseContext,
...createPackageAccessors({
[p('/root/node_modules/test-pkg/package.json')]: JSON.stringify({
exports: {
'./foo.js': {
'react-native': {require: target},
default: './lib/foo.js',
},
},
}),
}),
unstable_conditionNames: ['react-native'],
unstable_logWarning: logWarning,
};

expect(() =>
Resolver.resolve(context, 'test-pkg/foo.js', null),
).toThrow();
expect(logWarning).toHaveBeenCalledTimes(1);
expect(logWarning.mock.calls[0][0]).toContain(warning);
},
);

test('should throw FailedToResolvePathError when no conditions are matched', () => {
const context = {
...baseContext,
Expand Down
84 changes: 84 additions & 0 deletions packages/metro-resolver/src/__tests__/package-imports-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,88 @@ describe('import subpath conditional imports resolution', () => {
filePath: p('/root/node_modules/test-pkg/lib/foo-react-native.mjs'),
});
});

test.each([
[
'unmatched condition',
{require: './lib/foo.js'},
{browser: './lib/foo-browser.js'},
],
['empty condition object', {require: './lib/foo.js'}, {}],
[
'nested unmatched condition',
{require: './lib/foo.js'},
{require: {browser: './lib/foo-browser.js'}},
],
[
'unmatched condition',
{default: './lib/foo.js'},
{browser: './lib/foo-browser.js'},
],
['empty condition object', {default: './lib/foo.js'}, {}],
[
'nested unmatched condition',
{default: './lib/foo.js'},
{require: {browser: './lib/foo-browser.js'}},
],
])(
'should continue parent conditions after %s to %p',
(_, fallback, nestedConditions) => {
const logWarning = jest.fn();
const context = {
...baseContext,
...createPackageAccessors({
[p('/root/node_modules/test-pkg/package.json')]: {
imports: {
'#foo': {
'react-native': nestedConditions,
...fallback,
},
},
},
}),
unstable_conditionNames: ['react-native'],
unstable_logWarning: logWarning,
};

expect(Resolver.resolve(context, '#foo', null)).toEqual({
type: 'sourceFile',
filePath: p('/root/node_modules/test-pkg/lib/foo.js'),
});
expect(logWarning).not.toHaveBeenCalled();
},
);

test.each([
['null', null, 'no match was resolved'],
['invalid', 'invalid-target', 'invalid package.json configuration'],
['numeric', 42, 'invalid package.json configuration'],
['boolean', false, 'invalid package.json configuration'],
['unsupported empty array', [], 'no match was resolved'],
['unsupported nested array', ['./lib/foo.js'], 'no match was resolved'],
])(
'should not continue parent conditions after a %s target',
(_, target, warning) => {
const logWarning = jest.fn();
const context = {
...baseContext,
...createPackageAccessors({
[p('/root/node_modules/test-pkg/package.json')]: JSON.stringify({
imports: {
'#foo': {
'react-native': {require: target},
default: './lib/foo.js',
},
},
}),
}),
unstable_conditionNames: ['react-native'],
unstable_logWarning: logWarning,
};

expect(() => Resolver.resolve(context, '#foo', null)).toThrow();
expect(logWarning).toHaveBeenCalledTimes(1);
expect(logWarning.mock.calls[0][0]).toContain(warning);
},
);
});
65 changes: 36 additions & 29 deletions packages/metro-resolver/src/utils/reduceExportsLikeMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type {
NormalizedExportsLikeMap,
} from '../types';

const NO_MATCH: symbol = Symbol('no-match');

export function reduceExportsLikeMap(
exportsLikeMap: NormalizedExportsLikeMap,
conditionNames: ReadonlySet<string>,
Expand All @@ -27,14 +29,15 @@ export function reduceExportsLikeMap(
const result = new Map<string, string | null>();

for (const [subpath, value] of exportsLikeMap) {
const subpathValue = reduceConditionalExport(value, conditionNames);
const subpathValue = reduceConditionalExport(
value,
conditionNames,
createConfigError,
);

// If a subpath has no resolution for the passed `conditionNames`, do not
// include it in the result. (This includes only explicit `null` values,
// which may conditionally hide higher-specificity subpath patterns.)
if (subpathValue !== 'no-match') {
result.set(subpath, subpathValue);
}
// An exact key takes precedence over subpath patterns even when no
// condition matches, so retain it as an explicit null.
result.set(subpath, typeof subpathValue === 'string' ? subpathValue : null);
}

for (const value of result.values()) {
Expand All @@ -53,38 +56,42 @@ export function reduceExportsLikeMap(
* Reduce an "exports"-like subpath value after asserting the passed
* `conditionNames` in any nested conditions.
*
* Returns `'no-match'` in the case that none of the asserted `conditionNames`
* Returns `NO_MATCH` in the case that none of the asserted `conditionNames`
* are matched.
*
* See https://nodejs.org/docs/latest-v19.x/api/packages.html#conditional-exports.
*/
function reduceConditionalExport(
subpathValue: Values<ExportsLikeMap>,
conditionNames: ReadonlySet<string>,
): string | null | 'no-match' {
let reducedValue = subpathValue;

while (reducedValue != null && typeof reducedValue !== 'string') {
let match: typeof subpathValue | 'no-match';

// when conditions are present and default is not specified
// the default condition is implicitly set to null, to allow
// for restricting access to unexported internals of a package.
if ('default' in reducedValue) {
match = 'no-match';
} else {
match = null;
}
createConfigError: (reason: string) => Error,
): string | null | symbol {
if (subpathValue == null || typeof subpathValue === 'string') {
return subpathValue;
}
if (typeof subpathValue !== 'object') {
throw createConfigError(
'A conditional target must be a string, an object, or null.',
);
}
// Legacy arrays are normalized earlier; nested arrays remain unsupported.
if (Array.isArray(subpathValue)) {
return null;
}

for (const conditionName in reducedValue) {
if (conditionNames.has(conditionName)) {
match = reducedValue[conditionName];
break;
for (const conditionName in subpathValue) {
if (conditionNames.has(conditionName)) {
const resolved = reduceConditionalExport(
subpathValue[conditionName],
conditionNames,
createConfigError,
);
// Null excludes this entry; only an unmatched condition falls through.
if (resolved !== NO_MATCH) {
return resolved;
}
}

reducedValue = match;
}

return reducedValue;
return NO_MATCH;
}
Loading