diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 973cd9f0d3..b3166b4541 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -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, diff --git a/packages/metro-resolver/src/__tests__/package-imports-test.js b/packages/metro-resolver/src/__tests__/package-imports-test.js index a506904de7..9c88ebbd20 100644 --- a/packages/metro-resolver/src/__tests__/package-imports-test.js +++ b/packages/metro-resolver/src/__tests__/package-imports-test.js @@ -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); + }, + ); }); diff --git a/packages/metro-resolver/src/utils/reduceExportsLikeMap.js b/packages/metro-resolver/src/utils/reduceExportsLikeMap.js index b85e8105da..b420be94b6 100644 --- a/packages/metro-resolver/src/utils/reduceExportsLikeMap.js +++ b/packages/metro-resolver/src/utils/reduceExportsLikeMap.js @@ -19,6 +19,8 @@ import type { NormalizedExportsLikeMap, } from '../types'; +const NO_MATCH: symbol = Symbol('no-match'); + export function reduceExportsLikeMap( exportsLikeMap: NormalizedExportsLikeMap, conditionNames: ReadonlySet, @@ -27,14 +29,15 @@ export function reduceExportsLikeMap( const result = new Map(); 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()) { @@ -53,7 +56,7 @@ 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. @@ -61,30 +64,34 @@ export function reduceExportsLikeMap( function reduceConditionalExport( subpathValue: Values, conditionNames: ReadonlySet, -): 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; }