From d41db90c5b558f2e764537b8490fbdcc16642f04 Mon Sep 17 00:00:00 2001 From: Amro Altahtamouni Date: Fri, 18 Sep 2026 11:58:04 -0700 Subject: [PATCH] Fix jest-preset resolution under pnpm and Yarn pnpm-mode (#58598) Summary: Fixes https://github.com/facebook/react-native/issues/56641 The preset failed in two ways under strict-isolation installs (pnpm and Yarn pnpm-mode): `react-native` was not a declared dependency so `jest-preset.js` could not resolve it, and the `transformIgnorePatterns` rule only matched classic `node_modules` layouts so preset sources shipped untransformed. - Declare `react-native` as a peer dependency so the installer links it into the preset's scope. - Move `babel/core` from dependencies to peerDependencies: `babel-jest` peer-depends on it, so it must be provided, but as a direct dependency a strict installer gives the preset its own copy and the consumer's `babel.config.js` presets would then load under a different `babel/core` instance than the consumer's own. A peer keeps one copy, matching how `react` and `react-native` are already declared. `babel/runtime` stays a direct dependency: the preset's sources are compiled with `babel/plugin-transform-runtime` helpers enabled, so the transformed `jest/setup.js` requires `babel/runtime/helpers/*` from the preset's own scope at Jest runtime (verified: removing it makes the pnpm harness fail with `Cannot find module 'babel/runtime/helpers/interopRequireDefault'`). - Resolve the `babel-jest` transformer from the preset's own scope via `require.resolve('babel-jest')` instead of the bare specifier. - Match `react-native` sources in `transformIgnorePatterns` at each layout's anchored location - classic `node_modules`, pnpm (`.pnpm//node_modules/...`), and Yarn pnpm-mode (`.store/-npm--/package/...` (or `-virtual-/package/...` for packages declaring peer dependencies, verified against real Yarn installs)) - so strict-isolation layouts still transform preset and `react-native` sources. The prefixes are anchored rather than permitting arbitrary depth, so scoped third-party packages whose unscoped name is exactly `react-native` (`sentry/react-native`, `notifee/react-native`), nested directories literally named `react-native`, and real `-suffix` packages all stay ignored exactly as before. - Write the `.store` scoped-package segment as `(?:-[^-\/]+)*` rather than `(-[^\/]+)*`. The inner class in the original form could itself consume `-`, so a dash-separated name had exponentially many ways to be partitioned and any near-miss path under `node_modules/.store/react-native-...` forced catastrophic backtracking. Jest evaluates `transformIgnorePatterns` against every candidate file path, so one pathological path could hang a run. Restricting the segment to non-dash characters makes the partition unique and the match linear, with no change to which paths are ignored. Known limitation: under Yarn pnpm-mode's `.store` layout, a scoped package's slash flattens to a dash, erasing the scope boundary - so third-party `react-native-/*` packages (e.g. `react-native-async-storage/async-storage`) are indistinguishable from genuine `react-native/*` ones and are also transformed there. Transforming is the safe direction (a miss would ship untransformed sources); the impact is performance-only and confined to Yarn pnpm-mode. Changelog: [General][Fixed] - Fix `react-native/jest-preset` failing to resolve `react-native` and to transform preset sources under pnpm and Yarn pnpm-mode installs Differential Revision: D119701713 --- packages/jest-preset/jest-preset.js | 61 +++++- .../__tests__/preset-react-native-dep-test.js | 145 +++++++++++++ .../preset-transform-isolation-test.js | 195 ++++++++++++++++++ packages/jest-preset/package.json | 5 +- 4 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js create mode 100644 packages/jest-preset/jest/__tests__/preset-transform-isolation-test.js diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index 720a73efdca4..8f0384594b1a 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -12,6 +12,60 @@ const path = require('node:path'); +// Package directories whose sources must be transformed rather than ignored. +const RN = '(jest-)?react-native'; +const RN_SCOPE = '@react-native(-community)?'; + +// Yarn pnpm-mode names every store entry `--`, +// where `` is how the package was resolved. `npm` is the only one +// that also carries the version. A package declaring peer dependencies +// resolves as `virtual` regardless of where it came from. Observed under Yarn +// 4.18: `react-native-npm-1000.0.0-30881e83e6`, `react-native-file-d30a842e27`, +// `is-odd-patch-cfbe751c88`. Listing the protocols explicitly rather than +// accepting any word keeps `react-native-reanimated-npm-1.0.0-` from +// matching with `reanimated` in the protocol position. +const STORE_PROTOCOL = 'virtual|patch|file|portal|link|exec|workspace'; +const STORE_SUFFIX = `(-npm-[^\\/]+|-(?:${STORE_PROTOCOL})-[0-9a-f]+)`; + +// Locations, one per install layout, where a react-native package directory +// may legitimately sit. Each is an alternative of a single negative lookahead +// applied after `node_modules/`, so anything not listed here stays ignored. +// +// The prefixes are anchored instead of allowing arbitrary leading segments. +// Without that anchoring, a scoped third-party package whose unscoped name is +// exactly `react-native` (`@sentry/react-native`, `@notifee/react-native`) or +// a directory literally named `react-native` nested inside an unrelated +// package would match and be transformed. +const TRANSFORMED_PACKAGE_LAYOUTS = [ + // Classic `node_modules/react-native/...`, and pnpm's + // `node_modules/.pnpm//node_modules/react-native/...` — the same shape + // behind an optional store prefix. The trailing `[\/]` is required: without + // it the package name would also match a longer one it merely prefixes, so + // `react-native-reanimated` would be transformed. Yarn's `-virtual-` + // entries are deliberately not accepted here — they only ever appear under + // `.store/`, which the next alternative handles. + `(\\.pnpm/([^\\/]+/)?node_modules/)?(${RN}|${RN_SCOPE})[\\/]`, + + // Yarn pnpm-mode's content store: + // `node_modules/.store/--/package/...`, where `` + // is the package name with its scope slash flattened to a dash. + // + // The scope segment `(?:-[^-\/]+)*` must keep `-` out of its inner class. + // Allowing it there lets the segment consume dashes itself, which gives a + // dash-separated name exponentially many possible partitions and makes any + // near-miss path under `.store/@react-native-...` backtrack catastrophically. + // Jest tests this pattern against every candidate file path, so a single + // such path would hang the run. + // + // Flattening erases the scope boundary here, so a third-party + // `@react-native-/*` package (e.g. + // `@react-native-async-storage/async-storage`) is indistinguishable from a + // genuine `@react-native/*` one and is transformed too. Transforming is the + // safe direction — missing one would ship untransformed sources — and the + // cost is performance only, confined to Yarn pnpm-mode. + `\\.store/(${RN}${STORE_SUFFIX}|${RN_SCOPE}(?:-[^-\\/]+)*${STORE_SUFFIX})/package/`, +]; + module.exports = { haste: { defaultPlatform: 'ios', @@ -29,12 +83,15 @@ module.exports = { }, resolver: require.resolve('./jest/resolver.js'), transform: { - '^.+\\.(js|ts|tsx)$': 'babel-jest', + // Resolve from the preset's own scope so strict-isolation installs + // (pnpm / Yarn pnpm-mode) find the transformer without relying on + // hoisting or a consumer devDependency. + '^.+\\.(js|ts|tsx)$': require.resolve('babel-jest'), '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': require.resolve('./jest/assetFileTransformer.js'), }, transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)', + `node_modules/(?!${TRANSFORMED_PACKAGE_LAYOUTS.join('|')})`, ], setupFiles: [require.resolve('./jest/setup.js')], testEnvironment: require.resolve('./jest/react-native-env.js'), diff --git a/packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js b/packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js new file mode 100644 index 000000000000..a9fa11c22ad4 --- /dev/null +++ b/packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js @@ -0,0 +1,145 @@ +/** + * 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 + * @format + */ + +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +const RN_ISSUE = 'https://github.com/react/react-native/issues/56641'; + +test(`isolated preset loads when the consumer provides react-native (${RN_ISSUE})`, () => { + const presetDir = path.resolve(__dirname, '..', '..'); + const presetRequire = createRequire(path.join(presetDir, 'package.json')); + const scratch = fs.mkdtempSync( + path.join(os.tmpdir(), 'rn-jest-preset-56641-'), + ); + try { + const isoDir = path.join(scratch, 'isolated-preset'); + const consumerDir = path.join(scratch, 'consumer'); + fs.mkdirSync(consumerDir, {recursive: true}); + + // Mimic pnpm/Yarn pnpm-mode isolation: copy the preset so bare + // specifiers resolve from the copy, which sees only what a package + // manager would install there. Exclude node_modules: an open-source + // Yarn install can create a per-package one, and if it contained + // react-native or babel-jest the copy would inherit it and the test + // would pass when it should fail. + fs.cpSync(presetDir, isoDir, { + recursive: true, + filter: src => !src.split(path.sep).includes('node_modules'), + }); + + // Mirror a package-manager install of declared dependencies into the + // isolated copy. This intentionally provides nothing beyond what the + // manifest declares: every dependency, peer, and optional peer is + // linked from the repo, so `require.resolve` from the copy sees exactly + // the declared surface (notably `babel-jest`, needed by `jest-preset.js` + // itself, as well as `react-native` when declared). + const pkg = JSON.parse( + fs.readFileSync(path.join(presetDir, 'package.json'), 'utf8'), + ); + const declared = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.peerDependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]); + for (const name of declared) { + const target = path.join(isoDir, 'node_modules', name); + fs.mkdirSync(path.dirname(target), {recursive: true}); + const depDir = path.dirname( + presetRequire.resolve(`${name}/package.json`), + ); + fs.symlinkSync(depDir, target, 'dir'); + } + + // A consuming project always has its own copy. + const consumerRnDir = path.dirname( + presetRequire.resolve('react-native/package.json'), + ); + const consumerTarget = path.join( + consumerDir, + 'node_modules', + 'react-native', + ); + fs.mkdirSync(path.dirname(consumerTarget), {recursive: true}); + fs.symlinkSync(consumerRnDir, consumerTarget, 'dir'); + + // Strip resolution-affecting env so the child is genuinely isolated: + // inherited lookup paths can otherwise make the copy resolve more + // than the directory layout alone provides. + const childEnv: {[string]: string} = {}; + for (const key of Object.keys(process.env)) { + if (key === 'NODE_PATH' || key === 'NODE_OPTIONS') { + continue; + } + const value = process.env[key]; + if (value != null) { + childEnv[key] = value; + } + } + + // The child probes what the isolated copy can actually resolve, prints + // the outcome, then loads the preset. Both probes use the isolated + // copy as the resolution scope. + const isoPreset = path.join(isoDir, 'jest-preset.js'); + const probeScript = [ + `const isoDir = ${JSON.stringify(isoDir)};`, + `const isoPreset = ${JSON.stringify(isoPreset)};`, + `console.log('CHILD_NODE_PATH:' + (process.env.NODE_PATH ?? '(unset)'));`, + `console.log('LOOKUP:' + JSON.stringify(require('module')._nodeModulePaths(isoDir)));`, + `let probe;`, + `try { probe = 'RESOLVED:' + require.resolve('react-native', {paths: [isoDir]}); } catch (e) { probe = 'UNREACHABLE:' + e.code + ':' + String(e.message).split('\\n')[0]; }`, + `console.log('PROBE:' + probe);`, + `const preset = require(isoPreset);`, + `console.log('PRESET_TRANSFORM:' + preset.transform['^.+\\\\.(js|ts|tsx)$']);`, + `console.log('PRESET_LOADED');`, + ].join('\n'); + const child = spawnSync(process.execPath, ['-e', probeScript], { + cwd: consumerDir, + encoding: 'utf8', + env: childEnv, + }); + const stdout = String(child.stdout ?? ''); + const childOutput = + `Child output:\n${stdout}\n` + + `Child stderr:\n${String(child.stderr ?? '')}`; + if (child.status !== 0) { + throw new Error( + `Isolated preset failed to load (${RN_ISSUE}).\n${childOutput}`, + ); + } + + // A zero exit code alone would also be produced by a child that never + // reached the preset, so assert on what it reported. `react-native` must + // resolve from the isolated copy's own scope: that only happens because + // the manifest declares it, which is the regression this test guards. + const read = (label: string): string => { + const match = stdout.match(new RegExp(`^${label}:(.*)$`, 'm')); + if (match == null) { + throw new Error( + `Child never printed ${label} (${RN_ISSUE}).\n${childOutput}`, + ); + } + return match[1]; + }; + + expect(read('PROBE')).toBe( + `RESOLVED:${presetRequire.resolve('react-native')}`, + ); + // `jest-preset.js` calls `require.resolve('babel-jest')` from its own + // scope; a bare specifier would only have resolved by hoisting. + expect(read('PRESET_TRANSFORM')).toBe(presetRequire.resolve('babel-jest')); + expect(stdout).toContain('PRESET_LOADED'); + } finally { + fs.rmSync(scratch, {recursive: true, force: true}); + } +}); diff --git a/packages/jest-preset/jest/__tests__/preset-transform-isolation-test.js b/packages/jest-preset/jest/__tests__/preset-transform-isolation-test.js new file mode 100644 index 000000000000..7a31e24124eb --- /dev/null +++ b/packages/jest-preset/jest/__tests__/preset-transform-isolation-test.js @@ -0,0 +1,195 @@ +/** + * 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. + * + * @format + * @noflow + */ + +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; + +const RN_ISSUE = 'https://github.com/react/react-native/issues/56641'; + +const presetDir = path.resolve(__dirname, '..', '..'); +const presetRequire = createRequire(path.join(presetDir, 'package.json')); + +describe(`preset transform survives strict isolation (${RN_ISSUE})`, () => { + const preset = require('../../jest-preset'); + + test('JS transformer resolves from the preset scope', () => { + const jsTransform = preset.transform['^.+\\.(js|ts|tsx)$']; + // A bare 'babel-jest' specifier only resolves by hoisting or a consumer + // devDependency. Under pnpm / Yarn pnpm-mode the consumer scope does not + // see the preset's dependencies, so the transformer must resolve from + // the preset's own scope. + expect(jsTransform).toBe(presetRequire.resolve('babel-jest')); + expect(fs.existsSync(jsTransform)).toBe(true); + }); + + test('transformIgnorePatterns covers pnpm/Yarn layouts without widening', () => { + const re = new RegExp(preset.transformIgnorePatterns[0]); + // [path, shouldBeIgnored]. pnpm (.pnpm//node_modules) and Yarn + // pnpm-mode (.store/-npm--/package, with a scoped + // package's slash flattened to a dash) layouts must transform preset and + // react-native sources; everything else must stay ignored exactly as + // before — in particular scoped third-party packages whose unscoped name + // is exactly react-native, and nested directories literally named + // react-native inside unrelated packages. + const cases: Array<[string, boolean]> = [ + ['/app/node_modules/@react-native/jest-preset/jest/setup.js', false], + ['/app/node_modules/react-native/Libraries/AppState/AppState.js', false], + ['/app/node_modules/lodash/lodash.js', true], + ['/app/node_modules/react-native-reanimated/lib/index.js', true], + ['/app/node_modules/react-native-svg/lib/index.js', true], + [ + '/app/node_modules/@react-native-async-storage/async-storage/lib/index.js', + true, + ], + ['/app/node_modules/react-native-virtualized-view/lib/index.js', true], + ['/app/node_modules/react-native-virtual-joystick/lib/index.js', true], + ['/app/node_modules/react-native-virtual-keyboard/lib/index.js', true], + ['/app/node_modules/react-native-virtual-list/lib/index.js', true], + // `-virtual-` is a Yarn *store* convention, so it must not be + // honoured in a classic layout: these are ordinary third-party packages + // that happen to end in a hex-looking segment, and a hex `[0-9a-f]+` + // matches short words like `beef`, `dead` and `cafe`. + [ + '/app/node_modules/react-native-reanimated-virtual-beef/lib/index.js', + true, + ], + ['/app/node_modules/react-native-virtual-dead/index.js', true], + ['/app/node_modules/react-native-svg-virtual-cafe/index.js', true], + // A path ending at the package directory itself, with no trailing + // separator, is not a source file and must not be transformed. + ['/app/node_modules/react-native', true], + ['/app/node_modules/@sentry/react-native/lib/index.js', true], + ['/app/node_modules/@notifee/react-native/lib/index.js', true], + ['/app/node_modules/some-pkg/react-native/patch.js', true], + [ + '/tmp/x/node_modules/.pnpm/@react-native+jest-preset@file+preset_abc/node_modules/@react-native/jest-preset/jest/setup.js', + false, + ], + [ + '/tmp/x/node_modules/.pnpm/react-native@1000.0.0/node_modules/react-native/Libraries/AppState/AppState.js', + false, + ], + [ + '/tmp/x/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/react-native-reanimated@1.0.0/node_modules/react-native-reanimated/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/react-native-svg@1.0.0/node_modules/react-native-svg/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/@react-native-async-storage+async-storage@1.0.0/node_modules/@react-native-async-storage/async-storage/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/@sentry+react-native@6.1.0/node_modules/@sentry/react-native/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/some-pkg@1.0.0/node_modules/some-pkg/react-native/patch.js', + true, + ], + [ + '/tmp/x/node_modules/.store/react-native-npm-1000.0.0-abc123def4/package/Libraries/AppState/AppState.js', + false, + ], + [ + '/tmp/x/node_modules/.store/@react-native-jest-preset-npm-0.87.1-abc123def4/package/jest/mock.js', + false, + ], + [ + '/tmp/x/node_modules/.store/@react-native-community-cli-npm-15.0.0-abc123def4/package/build/index.js', + false, + ], + [ + '/tmp/x/node_modules/.store/lodash-npm-4.17.21-abc123def4/package/lodash.js', + true, + ], + [ + '/tmp/x/node_modules/.store/react-native-reanimated-npm-1.0.0-abc123def4/package/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.store/react-native-virtualized-view-npm-1.0.0-abc123def4/package/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.store/@sentry-react-native-npm-6.1.0-abc123def4/package/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.store/@notifee-react-native-npm-9.1.0-abc123def4/package/lib/index.js', + true, + ], + // Yarn emits -virtual- entries (no version) for packages that + // declare peer dependencies — both store forms must behave the same. + [ + '/tmp/x/node_modules/.store/@react-native-jest-preset-virtual-1fd1f8fd8f/package/jest/setup.js', + false, + ], + [ + '/tmp/x/node_modules/.store/react-native-virtual-abc123def4/package/Libraries/AppState/AppState.js', + false, + ], + [ + '/tmp/x/node_modules/.store/@sentry-react-native-virtual-abc123def4/package/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.store/react-native-reanimated-virtual-abc123def4/package/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.store/@react-native-async-storage-async-storage-virtual-abc123def4/package/lib/index.js', + false, + ], + // Flattening erases the scope boundary, so a third-party + // @react-native-/* package is indistinguishable from a genuine + // @react-native/* one here; transforming is the safe direction (a miss + // would ship untransformed sources), at performance-only cost. + [ + '/tmp/x/node_modules/.store/@react-native-async-storage-async-storage-npm-1.0.0-abc123def4/package/lib/index.js', + false, + ], + // A store entry records the protocol the package was resolved through, + // not just `npm`/`virtual`. These paths are verbatim from a Yarn 4.18 + // pnpm-mode install, and must transform like any other react-native + // source — `yarn patch react-native` is a common thing to do. + [ + '/tmp/x/node_modules/.store/react-native-file-d30a842e27/package/Libraries/AppState/AppState.js', + false, + ], + [ + '/tmp/x/node_modules/.store/react-native-patch-cfbe751c88/package/Libraries/AppState/AppState.js', + false, + ], + [ + '/tmp/x/node_modules/.store/@react-native-jest-preset-file-508aaadafa/package/jest/setup.js', + false, + ], + // The protocol list is explicit so that a package whose name merely + // continues past `react-native` cannot put its own name in the protocol + // position and be transformed. + [ + '/tmp/x/node_modules/.store/react-native-filedep-file-d9baa55f6d/package/index.js', + true, + ], + ['/tmp/x/packages/app/__tests__/App.test.js', false], + ]; + for (const [file, shouldIgnore] of cases) { + expect(re.test(file)).toBe(shouldIgnore); + } + }); +}); diff --git a/packages/jest-preset/package.json b/packages/jest-preset/package.json index bc3f36a83450..5af767029b2b 100644 --- a/packages/jest-preset/package.json +++ b/packages/jest-preset/package.json @@ -28,6 +28,7 @@ "!**/__tests__/**" ], "dependencies": { + "@babel/runtime": "^7.25.0", "@jest/create-cache-key-function": "^29.7.0", "@react-native/js-polyfills": "0.87.0-main", "babel-jest": "^29.7.0", @@ -35,6 +36,8 @@ "regenerator-runtime": "^0.13.2" }, "peerDependencies": { - "react": "^19.2.3" + "@babel/core": "^7.25.2", + "react": "^19.2.3", + "react-native": "1000.0.0" } }