From 1a441832fd62fd46067059e316cf09719654538e Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 28 Jul 2026 10:39:01 +0200 Subject: [PATCH 1/3] fix(cli): resolve react-native script subpaths through the exports map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `spm` and `codegen` command wrappers reach into the react-native package with require.resolve('react-native/...'). Those specifiers go through react-native's `exports` map, which — unlike legacy CJS resolution — neither appends extensions nor falls back to a directory's index.js. Both specifiers relied on exactly that, so each threw MODULE_NOT_FOUND as soon as the command ran: npx react-native spm ... -> Cannot find module '.../node_modules/react-native/scripts/setup-apple-spm' npx react-native codegen -> same, for '.../scripts/codegen/generate-artifacts-executor' (a directory) Point them at the real files: `setup-apple-spm.js` and `generate-artifacts-executor/index.js`. Adds a regression test that scans the command wrappers for react-native/* specifiers and resolves each one in a real node process — Jest's moduleNameMapper remaps react-native to the source tree and bypasses the exports map, so an in-band resolve passes even when production fails. Co-Authored-By: Claude Opus 5 (1M context) --- .../reactNativeSubpathResolution-test.js | 70 +++++++++++++++++++ .../src/commands/codegen.js | 2 +- .../community-cli-plugin/src/commands/spm.js | 2 +- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js diff --git a/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js b/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js new file mode 100644 index 00000000000..9f25d30c4dd --- /dev/null +++ b/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js @@ -0,0 +1,70 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const COMMANDS_DIR = path.join(__dirname, '..'); +const REPO_ROOT = path.resolve(__dirname, '../../../../..'); + +/** + * The command wrappers reach into the `react-native` package with + * `require.resolve('react-native/...')`. Those specifiers go through + * react-native's `exports` map, which — unlike legacy CJS resolution — neither + * appends extensions nor falls back to a directory's `index.js`. A specifier + * missing either therefore throws, but only at runtime when the command is + * invoked. + * + * Resolution must be checked in a real `node` process: Jest's `moduleNameMapper` + * remaps `react-native` to the source tree and bypasses the `exports` map + * entirely, so resolving in-band here would pass even when production fails. + */ +function collectReactNativeSpecifiers(): Array<{file: string, spec: string}> { + const found = []; + for (const entry of fs.readdirSync(COMMANDS_DIR, {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith('.js')) { + continue; + } + const source = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); + for (const match of source.matchAll(/'(react-native\/[^']+)'/g)) { + found.push({file: entry.name, spec: match[1]}); + } + } + return found; +} + +function resolvesInNode(spec: string): boolean { + try { + execFileSync( + process.execPath, + ['-e', `require.resolve(${JSON.stringify(spec)})`], + {cwd: REPO_ROOT, stdio: 'ignore'}, + ); + return true; + } catch { + return false; + } +} + +describe('react-native subpath specifiers used by the command wrappers', () => { + const specifiers = collectReactNativeSpecifiers(); + + test('at least one specifier is covered by this test', () => { + expect(specifiers.length).toBeGreaterThan(0); + }); + + test.each(specifiers)( + "$file resolves $spec through react-native's exports map", + ({spec}) => { + expect(resolvesInNode(spec)).toBe(true); + }, + ); +}); diff --git a/packages/community-cli-plugin/src/commands/codegen.js b/packages/community-cli-plugin/src/commands/codegen.js index cb158a1e8bb..75fa43b34d1 100644 --- a/packages/community-cli-plugin/src/commands/codegen.js +++ b/packages/community-cli-plugin/src/commands/codegen.js @@ -47,7 +47,7 @@ const codegenCommand: Command = { args: CodegenCommandArgs, ): void => { const generateArtifactsExecutor = require.resolve( - 'react-native/scripts/codegen/generate-artifacts-executor', + 'react-native/scripts/codegen/generate-artifacts-executor/index.js', {paths: [config.root]}, ); // $FlowFixMe[unsupported-syntax] dynamic require of a resolved path diff --git a/packages/community-cli-plugin/src/commands/spm.js b/packages/community-cli-plugin/src/commands/spm.js index 181ad4ff812..df8d3c0ce77 100644 --- a/packages/community-cli-plugin/src/commands/spm.js +++ b/packages/community-cli-plugin/src/commands/spm.js @@ -107,7 +107,7 @@ const spmCommand: Command = { } } const setupAppleSpm = require.resolve( - 'react-native/scripts/setup-apple-spm', + 'react-native/scripts/setup-apple-spm.js', {paths: [config.root]}, ); // $FlowFixMe[unsupported-syntax] dynamic require of a resolved path From 08e62f9818afb724f4b66f4fe0adc8ae8b3a2a6e Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 28 Jul 2026 10:48:26 +0200 Subject: [PATCH 2/3] Fix Flow errors in the subpath-resolution regression test readdirSync's withFileTypes overload types Dirent.name as `string | Buffer`, which flow-check rejected at three call sites. Use the plain readdirSync overload (Array) with a statSync isFile check, and switch the requires to the node: protocol per the lint rule. Co-Authored-By: Claude Opus 5 (1M context) --- .../reactNativeSubpathResolution-test.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js b/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js index 9f25d30c4dd..92f0b45a725 100644 --- a/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js +++ b/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js @@ -8,9 +8,9 @@ * @format */ -const {execFileSync} = require('child_process'); -const fs = require('fs'); -const path = require('path'); +const {execFileSync} = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); const COMMANDS_DIR = path.join(__dirname, '..'); const REPO_ROOT = path.resolve(__dirname, '../../../../..'); @@ -29,13 +29,17 @@ const REPO_ROOT = path.resolve(__dirname, '../../../../..'); */ function collectReactNativeSpecifiers(): Array<{file: string, spec: string}> { const found = []; - for (const entry of fs.readdirSync(COMMANDS_DIR, {withFileTypes: true})) { - if (!entry.isFile() || !entry.name.endsWith('.js')) { + for (const name of fs.readdirSync(COMMANDS_DIR)) { + if (!name.endsWith('.js')) { continue; } - const source = fs.readFileSync(path.join(COMMANDS_DIR, entry.name), 'utf8'); + const filePath = path.join(COMMANDS_DIR, name); + if (!fs.statSync(filePath).isFile()) { + continue; + } + const source = fs.readFileSync(filePath, 'utf8'); for (const match of source.matchAll(/'(react-native\/[^']+)'/g)) { - found.push({file: entry.name, spec: match[1]}); + found.push({file: name, spec: match[1]}); } } return found; From 32ee36fda2f4c01c0ae51fb638f119648e1cb031 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 28 Jul 2026 11:07:34 +0200 Subject: [PATCH 3/3] Trim the subpath-resolution test Drop the helper functions and the long explanatory comment; the scan and the subprocess check (the part Jest's moduleNameMapper would otherwise defeat) collapse into two statements. 70 lines -> 43. Co-Authored-By: Claude Opus 5 (1M context) --- .../reactNativeSubpathResolution-test.js | 75 ++++++------------- 1 file changed, 22 insertions(+), 53 deletions(-) diff --git a/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js b/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js index 92f0b45a725..a7808616608 100644 --- a/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js +++ b/packages/community-cli-plugin/src/commands/__tests__/reactNativeSubpathResolution-test.js @@ -15,60 +15,29 @@ const path = require('node:path'); const COMMANDS_DIR = path.join(__dirname, '..'); const REPO_ROOT = path.resolve(__dirname, '../../../../..'); -/** - * The command wrappers reach into the `react-native` package with - * `require.resolve('react-native/...')`. Those specifiers go through - * react-native's `exports` map, which — unlike legacy CJS resolution — neither - * appends extensions nor falls back to a directory's `index.js`. A specifier - * missing either therefore throws, but only at runtime when the command is - * invoked. - * - * Resolution must be checked in a real `node` process: Jest's `moduleNameMapper` - * remaps `react-native` to the source tree and bypasses the `exports` map - * entirely, so resolving in-band here would pass even when production fails. - */ -function collectReactNativeSpecifiers(): Array<{file: string, spec: string}> { - const found = []; - for (const name of fs.readdirSync(COMMANDS_DIR)) { - if (!name.endsWith('.js')) { - continue; - } - const filePath = path.join(COMMANDS_DIR, name); - if (!fs.statSync(filePath).isFile()) { - continue; - } - const source = fs.readFileSync(filePath, 'utf8'); - for (const match of source.matchAll(/'(react-native\/[^']+)'/g)) { - found.push({file: name, spec: match[1]}); - } - } - return found; -} - -function resolvesInNode(spec: string): boolean { - try { - execFileSync( - process.execPath, - ['-e', `require.resolve(${JSON.stringify(spec)})`], - {cwd: REPO_ROOT, stdio: 'ignore'}, - ); - return true; - } catch { - return false; - } -} - -describe('react-native subpath specifiers used by the command wrappers', () => { - const specifiers = collectReactNativeSpecifiers(); +// The wrappers load scripts as `react-native/`, resolved through +// react-native's `exports` map: no extension guessing, no index.js fallback. +// Must run in a real node process — Jest's moduleNameMapper bypasses `exports` +// and would pass either way. +const specifiers = fs + .readdirSync(COMMANDS_DIR) + .filter(name => name.endsWith('.js')) + .flatMap(name => + [ + ...fs + .readFileSync(path.join(COMMANDS_DIR, name), 'utf8') + .matchAll(/'(react-native\/[^']+)'/g), + ].map(match => ({file: name, spec: match[1]})), + ); - test('at least one specifier is covered by this test', () => { - expect(specifiers.length).toBeGreaterThan(0); - }); +test('found specifiers to check', () => { + expect(specifiers.length).toBeGreaterThan(0); +}); - test.each(specifiers)( - "$file resolves $spec through react-native's exports map", - ({spec}) => { - expect(resolvesInNode(spec)).toBe(true); - }, +test.each(specifiers)('$file resolves $spec', ({spec}) => { + execFileSync( + process.execPath, + ['-e', `require.resolve(${JSON.stringify(spec)})`], + {cwd: REPO_ROOT, stdio: 'ignore'}, ); });