Skip to content

Commit d41db90

Browse files
amroaltahfacebook-github-bot
authored andcommitted
Fix jest-preset resolution under pnpm and Yarn pnpm-mode (#58598)
Summary: Fixes #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/<id>/node_modules/...`), and Yarn pnpm-mode (`.store/<flat>-npm-<version>-<hash>/package/...` (or `<flat>-virtual-<hash>/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-<scope>/*` 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
1 parent 3718f62 commit d41db90

4 files changed

Lines changed: 403 additions & 3 deletions

File tree

packages/jest-preset/jest-preset.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,60 @@
1212

1313
const path = require('node:path');
1414

15+
// Package directories whose sources must be transformed rather than ignored.
16+
const RN = '(jest-)?react-native';
17+
const RN_SCOPE = '@react-native(-community)?';
18+
19+
// Yarn pnpm-mode names every store entry `<flat-name>-<protocol>-<hash>`,
20+
// where `<protocol>` is how the package was resolved. `npm` is the only one
21+
// that also carries the version. A package declaring peer dependencies
22+
// resolves as `virtual` regardless of where it came from. Observed under Yarn
23+
// 4.18: `react-native-npm-1000.0.0-30881e83e6`, `react-native-file-d30a842e27`,
24+
// `is-odd-patch-cfbe751c88`. Listing the protocols explicitly rather than
25+
// accepting any word keeps `react-native-reanimated-npm-1.0.0-<hash>` from
26+
// matching with `reanimated` in the protocol position.
27+
const STORE_PROTOCOL = 'virtual|patch|file|portal|link|exec|workspace';
28+
const STORE_SUFFIX = `(-npm-[^\\/]+|-(?:${STORE_PROTOCOL})-[0-9a-f]+)`;
29+
30+
// Locations, one per install layout, where a react-native package directory
31+
// may legitimately sit. Each is an alternative of a single negative lookahead
32+
// applied after `node_modules/`, so anything not listed here stays ignored.
33+
//
34+
// The prefixes are anchored instead of allowing arbitrary leading segments.
35+
// Without that anchoring, a scoped third-party package whose unscoped name is
36+
// exactly `react-native` (`@sentry/react-native`, `@notifee/react-native`) or
37+
// a directory literally named `react-native` nested inside an unrelated
38+
// package would match and be transformed.
39+
const TRANSFORMED_PACKAGE_LAYOUTS = [
40+
// Classic `node_modules/react-native/...`, and pnpm's
41+
// `node_modules/.pnpm/<id>/node_modules/react-native/...` — the same shape
42+
// behind an optional store prefix. The trailing `[\/]` is required: without
43+
// it the package name would also match a longer one it merely prefixes, so
44+
// `react-native-reanimated` would be transformed. Yarn's `-virtual-<hash>`
45+
// entries are deliberately not accepted here — they only ever appear under
46+
// `.store/`, which the next alternative handles.
47+
`(\\.pnpm/([^\\/]+/)?node_modules/)?(${RN}|${RN_SCOPE})[\\/]`,
48+
49+
// Yarn pnpm-mode's content store:
50+
// `node_modules/.store/<flat>-<protocol>-<hash>/package/...`, where `<flat>`
51+
// is the package name with its scope slash flattened to a dash.
52+
//
53+
// The scope segment `(?:-[^-\/]+)*` must keep `-` out of its inner class.
54+
// Allowing it there lets the segment consume dashes itself, which gives a
55+
// dash-separated name exponentially many possible partitions and makes any
56+
// near-miss path under `.store/@react-native-...` backtrack catastrophically.
57+
// Jest tests this pattern against every candidate file path, so a single
58+
// such path would hang the run.
59+
//
60+
// Flattening erases the scope boundary here, so a third-party
61+
// `@react-native-<scope>/*` package (e.g.
62+
// `@react-native-async-storage/async-storage`) is indistinguishable from a
63+
// genuine `@react-native/*` one and is transformed too. Transforming is the
64+
// safe direction — missing one would ship untransformed sources — and the
65+
// cost is performance only, confined to Yarn pnpm-mode.
66+
`\\.store/(${RN}${STORE_SUFFIX}|${RN_SCOPE}(?:-[^-\\/]+)*${STORE_SUFFIX})/package/`,
67+
];
68+
1569
module.exports = {
1670
haste: {
1771
defaultPlatform: 'ios',
@@ -29,12 +83,15 @@ module.exports = {
2983
},
3084
resolver: require.resolve('./jest/resolver.js'),
3185
transform: {
32-
'^.+\\.(js|ts|tsx)$': 'babel-jest',
86+
// Resolve from the preset's own scope so strict-isolation installs
87+
// (pnpm / Yarn pnpm-mode) find the transformer without relying on
88+
// hoisting or a consumer devDependency.
89+
'^.+\\.(js|ts|tsx)$': require.resolve('babel-jest'),
3390
'^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$':
3491
require.resolve('./jest/assetFileTransformer.js'),
3592
},
3693
transformIgnorePatterns: [
37-
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)',
94+
`node_modules/(?!${TRANSFORMED_PACKAGE_LAYOUTS.join('|')})`,
3895
],
3996
setupFiles: [require.resolve('./jest/setup.js')],
4097
testEnvironment: require.resolve('./jest/react-native-env.js'),
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
* @format
9+
*/
10+
11+
import {spawnSync} from 'node:child_process';
12+
import fs from 'node:fs';
13+
import {createRequire} from 'node:module';
14+
import os from 'node:os';
15+
import path from 'node:path';
16+
17+
const RN_ISSUE = 'https://github.com/react/react-native/issues/56641';
18+
19+
test(`isolated preset loads when the consumer provides react-native (${RN_ISSUE})`, () => {
20+
const presetDir = path.resolve(__dirname, '..', '..');
21+
const presetRequire = createRequire(path.join(presetDir, 'package.json'));
22+
const scratch = fs.mkdtempSync(
23+
path.join(os.tmpdir(), 'rn-jest-preset-56641-'),
24+
);
25+
try {
26+
const isoDir = path.join(scratch, 'isolated-preset');
27+
const consumerDir = path.join(scratch, 'consumer');
28+
fs.mkdirSync(consumerDir, {recursive: true});
29+
30+
// Mimic pnpm/Yarn pnpm-mode isolation: copy the preset so bare
31+
// specifiers resolve from the copy, which sees only what a package
32+
// manager would install there. Exclude node_modules: an open-source
33+
// Yarn install can create a per-package one, and if it contained
34+
// react-native or babel-jest the copy would inherit it and the test
35+
// would pass when it should fail.
36+
fs.cpSync(presetDir, isoDir, {
37+
recursive: true,
38+
filter: src => !src.split(path.sep).includes('node_modules'),
39+
});
40+
41+
// Mirror a package-manager install of declared dependencies into the
42+
// isolated copy. This intentionally provides nothing beyond what the
43+
// manifest declares: every dependency, peer, and optional peer is
44+
// linked from the repo, so `require.resolve` from the copy sees exactly
45+
// the declared surface (notably `babel-jest`, needed by `jest-preset.js`
46+
// itself, as well as `react-native` when declared).
47+
const pkg = JSON.parse(
48+
fs.readFileSync(path.join(presetDir, 'package.json'), 'utf8'),
49+
);
50+
const declared = new Set([
51+
...Object.keys(pkg.dependencies ?? {}),
52+
...Object.keys(pkg.peerDependencies ?? {}),
53+
...Object.keys(pkg.optionalDependencies ?? {}),
54+
]);
55+
for (const name of declared) {
56+
const target = path.join(isoDir, 'node_modules', name);
57+
fs.mkdirSync(path.dirname(target), {recursive: true});
58+
const depDir = path.dirname(
59+
presetRequire.resolve(`${name}/package.json`),
60+
);
61+
fs.symlinkSync(depDir, target, 'dir');
62+
}
63+
64+
// A consuming project always has its own copy.
65+
const consumerRnDir = path.dirname(
66+
presetRequire.resolve('react-native/package.json'),
67+
);
68+
const consumerTarget = path.join(
69+
consumerDir,
70+
'node_modules',
71+
'react-native',
72+
);
73+
fs.mkdirSync(path.dirname(consumerTarget), {recursive: true});
74+
fs.symlinkSync(consumerRnDir, consumerTarget, 'dir');
75+
76+
// Strip resolution-affecting env so the child is genuinely isolated:
77+
// inherited lookup paths can otherwise make the copy resolve more
78+
// than the directory layout alone provides.
79+
const childEnv: {[string]: string} = {};
80+
for (const key of Object.keys(process.env)) {
81+
if (key === 'NODE_PATH' || key === 'NODE_OPTIONS') {
82+
continue;
83+
}
84+
const value = process.env[key];
85+
if (value != null) {
86+
childEnv[key] = value;
87+
}
88+
}
89+
90+
// The child probes what the isolated copy can actually resolve, prints
91+
// the outcome, then loads the preset. Both probes use the isolated
92+
// copy as the resolution scope.
93+
const isoPreset = path.join(isoDir, 'jest-preset.js');
94+
const probeScript = [
95+
`const isoDir = ${JSON.stringify(isoDir)};`,
96+
`const isoPreset = ${JSON.stringify(isoPreset)};`,
97+
`console.log('CHILD_NODE_PATH:' + (process.env.NODE_PATH ?? '(unset)'));`,
98+
`console.log('LOOKUP:' + JSON.stringify(require('module')._nodeModulePaths(isoDir)));`,
99+
`let probe;`,
100+
`try { probe = 'RESOLVED:' + require.resolve('react-native', {paths: [isoDir]}); } catch (e) { probe = 'UNREACHABLE:' + e.code + ':' + String(e.message).split('\\n')[0]; }`,
101+
`console.log('PROBE:' + probe);`,
102+
`const preset = require(isoPreset);`,
103+
`console.log('PRESET_TRANSFORM:' + preset.transform['^.+\\\\.(js|ts|tsx)$']);`,
104+
`console.log('PRESET_LOADED');`,
105+
].join('\n');
106+
const child = spawnSync(process.execPath, ['-e', probeScript], {
107+
cwd: consumerDir,
108+
encoding: 'utf8',
109+
env: childEnv,
110+
});
111+
const stdout = String(child.stdout ?? '');
112+
const childOutput =
113+
`Child output:\n${stdout}\n` +
114+
`Child stderr:\n${String(child.stderr ?? '')}`;
115+
if (child.status !== 0) {
116+
throw new Error(
117+
`Isolated preset failed to load (${RN_ISSUE}).\n${childOutput}`,
118+
);
119+
}
120+
121+
// A zero exit code alone would also be produced by a child that never
122+
// reached the preset, so assert on what it reported. `react-native` must
123+
// resolve from the isolated copy's own scope: that only happens because
124+
// the manifest declares it, which is the regression this test guards.
125+
const read = (label: string): string => {
126+
const match = stdout.match(new RegExp(`^${label}:(.*)$`, 'm'));
127+
if (match == null) {
128+
throw new Error(
129+
`Child never printed ${label} (${RN_ISSUE}).\n${childOutput}`,
130+
);
131+
}
132+
return match[1];
133+
};
134+
135+
expect(read('PROBE')).toBe(
136+
`RESOLVED:${presetRequire.resolve('react-native')}`,
137+
);
138+
// `jest-preset.js` calls `require.resolve('babel-jest')` from its own
139+
// scope; a bare specifier would only have resolved by hoisting.
140+
expect(read('PRESET_TRANSFORM')).toBe(presetRequire.resolve('babel-jest'));
141+
expect(stdout).toContain('PRESET_LOADED');
142+
} finally {
143+
fs.rmSync(scratch, {recursive: true, force: true});
144+
}
145+
});

0 commit comments

Comments
 (0)