From 5f86bb614d8f0fa9915c627af585e67174c8c08d Mon Sep 17 00:00:00 2001 From: maximilliangrand <214999687+maximilliangrand@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:44:44 +0200 Subject: [PATCH 1/3] Fix parent fallback for unmatched nested package conditions --- .../src/__tests__/package-exports-test.js | 72 +++++++++++++++++++ .../src/__tests__/package-imports-test.js | 70 ++++++++++++++++++ .../src/utils/reduceExportsLikeMap.js | 64 +++++++++-------- 3 files changed, 177 insertions(+), 29 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 973cd9f0d3..ffe5c4f4c9 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -848,6 +848,78 @@ describe('with package exports resolution enabled', () => { }); }); + test.each([ + ['unmatched condition', {browser: './lib/foo-browser.js'}], + ['empty condition object', {}], + [ + 'nested unmatched condition', + {require: {browser: './lib/foo-browser.js'}}, + ], + ])('should continue parent conditions after %s', (_, nestedConditions) => { + for (const fallback of [ + {require: './lib/foo.js'}, + {default: './lib/foo.js'}, + ]) { + 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..6457d3bf43 100644 --- a/packages/metro-resolver/src/__tests__/package-imports-test.js +++ b/packages/metro-resolver/src/__tests__/package-imports-test.js @@ -167,4 +167,74 @@ describe('import subpath conditional imports resolution', () => { filePath: p('/root/node_modules/test-pkg/lib/foo-react-native.mjs'), }); }); + + test.each([ + ['unmatched condition', {browser: './lib/foo-browser.js'}], + ['empty condition object', {}], + [ + 'nested unmatched condition', + {require: {browser: './lib/foo-browser.js'}}, + ], + ])('should continue parent conditions after %s', (_, nestedConditions) => { + for (const fallback of [ + {require: './lib/foo.js'}, + {default: './lib/foo.js'}, + ]) { + 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..be942db1d6 100644 --- a/packages/metro-resolver/src/utils/reduceExportsLikeMap.js +++ b/packages/metro-resolver/src/utils/reduceExportsLikeMap.js @@ -27,14 +27,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); - } + // Preserve unmatched subpaths so they still take precedence over less + // specific patterns, just like explicit null targets. + result.set(subpath, subpathValue ?? null); } for (const value of result.values()) { @@ -53,7 +54,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 `undefined` 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 +62,35 @@ 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 | void { + 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. + // eslint-disable-next-line lint/strictly-null + if (resolved !== undefined) { + return resolved; } } - - reducedValue = match; } - return reducedValue; + return undefined; } From 44c961e9fc770501b3f0fc9b988ed4c168e9ae9c Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:41:47 +0100 Subject: [PATCH 2/3] Use a symbol sentinel for unmatched export conditions Metro treats `null` and `undefined` as equivalent, so signalling an unmatched nested condition with `undefined` (as distinct from an explicit `null` exclusion) needed a `lint/strictly-null` suppression. Return a module-level `NO_MATCH` symbol instead. Unlike a string sentinel, it can't collide with a literal target in `package.json`. At the top level, any non-string result is stored as `null`, so an exact key with no matching condition is still retained and takes precedence over subpath patterns. Changelog: [Internal] --- .../src/utils/reduceExportsLikeMap.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/metro-resolver/src/utils/reduceExportsLikeMap.js b/packages/metro-resolver/src/utils/reduceExportsLikeMap.js index be942db1d6..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, @@ -33,9 +35,9 @@ export function reduceExportsLikeMap( createConfigError, ); - // Preserve unmatched subpaths so they still take precedence over less - // specific patterns, just like explicit null targets. - result.set(subpath, subpathValue ?? null); + // 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()) { @@ -54,7 +56,7 @@ export function reduceExportsLikeMap( * Reduce an "exports"-like subpath value after asserting the passed * `conditionNames` in any nested conditions. * - * Returns `undefined` 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. @@ -63,7 +65,7 @@ function reduceConditionalExport( subpathValue: Values, conditionNames: ReadonlySet, createConfigError: (reason: string) => Error, -): string | null | void { +): string | null | symbol { if (subpathValue == null || typeof subpathValue === 'string') { return subpathValue; } @@ -85,12 +87,11 @@ function reduceConditionalExport( createConfigError, ); // Null excludes this entry; only an unmatched condition falls through. - // eslint-disable-next-line lint/strictly-null - if (resolved !== undefined) { + if (resolved !== NO_MATCH) { return resolved; } } } - return undefined; + return NO_MATCH; } From 4075c0618aab94cc967fd7c80e9cbeedab902389 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:53:44 +0100 Subject: [PATCH 3/3] Split parent fallback tests into separate test.each rows --- .../src/__tests__/package-exports-test.js | 30 ++++++++++++++----- .../src/__tests__/package-imports-test.js | 30 ++++++++++++++----- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index ffe5c4f4c9..b3166b4541 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -849,17 +849,31 @@ describe('with package exports resolution enabled', () => { }); test.each([ - ['unmatched condition', {browser: './lib/foo-browser.js'}], - ['empty condition object', {}], + [ + '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'}}, ], - ])('should continue parent conditions after %s', (_, nestedConditions) => { - for (const fallback of [ - {require: './lib/foo.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, @@ -882,8 +896,8 @@ describe('with package exports resolution enabled', () => { filePath: p('/root/node_modules/test-pkg/lib/foo.js'), }); expect(logWarning).not.toHaveBeenCalled(); - } - }); + }, + ); test.each([ ['null', null, 'no match was resolved'], diff --git a/packages/metro-resolver/src/__tests__/package-imports-test.js b/packages/metro-resolver/src/__tests__/package-imports-test.js index 6457d3bf43..9c88ebbd20 100644 --- a/packages/metro-resolver/src/__tests__/package-imports-test.js +++ b/packages/metro-resolver/src/__tests__/package-imports-test.js @@ -169,17 +169,31 @@ describe('import subpath conditional imports resolution', () => { }); test.each([ - ['unmatched condition', {browser: './lib/foo-browser.js'}], - ['empty condition object', {}], + [ + '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'}}, ], - ])('should continue parent conditions after %s', (_, nestedConditions) => { - for (const fallback of [ - {require: './lib/foo.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, @@ -202,8 +216,8 @@ describe('import subpath conditional imports resolution', () => { filePath: p('/root/node_modules/test-pkg/lib/foo.js'), }); expect(logWarning).not.toHaveBeenCalled(); - } - }); + }, + ); test.each([ ['null', null, 'no match was resolved'],