Skip to content

Commit 55f6749

Browse files
amroaltahfacebook-github-bot
authored andcommitted
Fix jest-preset resolution under pnpm and Yarn pnpm-mode
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 55f6749

4 files changed

Lines changed: 374 additions & 3 deletions

File tree

packages/jest-preset/jest-preset.js

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,54 @@
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 appends `-virtual-<hash>` to an entry for a package that
20+
// declares peer dependencies, and `-npm-<version>-<hash>` to a plain one.
21+
const VIRTUAL = '-virtual-[0-9a-f]+';
22+
const STORE_SUFFIX = `(-npm-[^\\/]+|${VIRTUAL})`;
23+
24+
// Locations, one per install layout, where a react-native package directory
25+
// may legitimately sit. Each is an alternative of a single negative lookahead
26+
// applied after `node_modules/`, so anything not listed here stays ignored.
27+
//
28+
// The prefixes are anchored instead of allowing arbitrary leading segments.
29+
// Without that anchoring, a scoped third-party package whose unscoped name is
30+
// exactly `react-native` (`@sentry/react-native`, `@notifee/react-native`) or
31+
// a directory literally named `react-native` nested inside an unrelated
32+
// package would match and be transformed.
33+
const TRANSFORMED_PACKAGE_LAYOUTS = [
34+
// Classic `node_modules/react-native/...`, and pnpm's
35+
// `node_modules/.pnpm/<id>/node_modules/react-native/...` — the same shape
36+
// behind an optional store prefix. The trailing `[\/]` is required: without
37+
// it the package name would also match a longer one it merely prefixes, so
38+
// `react-native-reanimated` would be transformed. Yarn's `-virtual-<hash>`
39+
// entries are deliberately not accepted here — they only ever appear under
40+
// `.store/`, which the next alternative handles.
41+
`(\\.pnpm/([^\\/]+/)?node_modules/)?(${RN}|${RN_SCOPE})[\\/]`,
42+
43+
// Yarn pnpm-mode's content store:
44+
// `node_modules/.store/<flat>-npm-<version>-<hash>/package/...`, where
45+
// `<flat>` is the package name with its scope slash flattened to a dash.
46+
//
47+
// The scope segment `(?:-[^-\/]+)*` must keep `-` out of its inner class.
48+
// Allowing it there lets the segment consume dashes itself, which gives a
49+
// dash-separated name exponentially many possible partitions and makes any
50+
// near-miss path under `.store/@react-native-...` backtrack catastrophically.
51+
// Jest tests this pattern against every candidate file path, so a single
52+
// such path would hang the run.
53+
//
54+
// Flattening erases the scope boundary here, so a third-party
55+
// `@react-native-<scope>/*` package (e.g.
56+
// `@react-native-async-storage/async-storage`) is indistinguishable from a
57+
// genuine `@react-native/*` one and is transformed too. Transforming is the
58+
// safe direction — missing one would ship untransformed sources — and the
59+
// cost is performance only, confined to Yarn pnpm-mode.
60+
`\\.store/(${RN}${STORE_SUFFIX}|${RN_SCOPE}(?:-[^-\\/]+)*${STORE_SUFFIX})/package/`,
61+
];
62+
1563
module.exports = {
1664
haste: {
1765
defaultPlatform: 'ios',
@@ -29,12 +77,15 @@ module.exports = {
2977
},
3078
resolver: require.resolve('./jest/resolver.js'),
3179
transform: {
32-
'^.+\\.(js|ts|tsx)$': 'babel-jest',
80+
// Resolve from the preset's own scope so strict-isolation installs
81+
// (pnpm / Yarn pnpm-mode) find the transformer without relying on
82+
// hoisting or a consumer devDependency.
83+
'^.+\\.(js|ts|tsx)$': require.resolve('babel-jest'),
3384
'^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$':
3485
require.resolve('./jest/assetFileTransformer.js'),
3586
},
3687
transformIgnorePatterns: [
37-
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)',
88+
`node_modules/(?!${TRANSFORMED_PACKAGE_LAYOUTS.join('|')})`,
3889
],
3990
setupFiles: [require.resolve('./jest/setup.js')],
4091
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+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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+
* @format
8+
* @noflow
9+
*/
10+
11+
import fs from 'node:fs';
12+
import {createRequire} from 'node:module';
13+
import path from 'node:path';
14+
15+
const RN_ISSUE = 'https://github.com/react/react-native/issues/56641';
16+
17+
const presetDir = path.resolve(__dirname, '..', '..');
18+
const presetRequire = createRequire(path.join(presetDir, 'package.json'));
19+
20+
describe(`preset transform survives strict isolation (${RN_ISSUE})`, () => {
21+
const preset = require('../../jest-preset');
22+
23+
test('JS transformer resolves from the preset scope', () => {
24+
const jsTransform = preset.transform['^.+\\.(js|ts|tsx)$'];
25+
// A bare 'babel-jest' specifier only resolves by hoisting or a consumer
26+
// devDependency. Under pnpm / Yarn pnpm-mode the consumer scope does not
27+
// see the preset's dependencies, so the transformer must resolve from
28+
// the preset's own scope.
29+
expect(jsTransform).toBe(presetRequire.resolve('babel-jest'));
30+
expect(fs.existsSync(jsTransform)).toBe(true);
31+
});
32+
33+
test('transformIgnorePatterns covers pnpm/Yarn layouts without widening', () => {
34+
const re = new RegExp(preset.transformIgnorePatterns[0]);
35+
// [path, shouldBeIgnored]. pnpm (.pnpm/<id>/node_modules) and Yarn
36+
// pnpm-mode (.store/<flat>-npm-<version>-<hash>/package, with a scoped
37+
// package's slash flattened to a dash) layouts must transform preset and
38+
// react-native sources; everything else must stay ignored exactly as
39+
// before — in particular scoped third-party packages whose unscoped name
40+
// is exactly react-native, and nested directories literally named
41+
// react-native inside unrelated packages.
42+
const cases: Array<[string, boolean]> = [
43+
['/app/node_modules/@react-native/jest-preset/jest/setup.js', false],
44+
['/app/node_modules/react-native/Libraries/AppState/AppState.js', false],
45+
['/app/node_modules/lodash/lodash.js', true],
46+
['/app/node_modules/react-native-reanimated/lib/index.js', true],
47+
['/app/node_modules/react-native-svg/lib/index.js', true],
48+
[
49+
'/app/node_modules/@react-native-async-storage/async-storage/lib/index.js',
50+
true,
51+
],
52+
['/app/node_modules/react-native-virtualized-view/lib/index.js', true],
53+
['/app/node_modules/react-native-virtual-joystick/lib/index.js', true],
54+
['/app/node_modules/react-native-virtual-keyboard/lib/index.js', true],
55+
['/app/node_modules/react-native-virtual-list/lib/index.js', true],
56+
// `-virtual-<hash>` is a Yarn *store* convention, so it must not be
57+
// honoured in a classic layout: these are ordinary third-party packages
58+
// that happen to end in a hex-looking segment, and a hex `[0-9a-f]+`
59+
// matches short words like `beef`, `dead` and `cafe`.
60+
[
61+
'/app/node_modules/react-native-reanimated-virtual-beef/lib/index.js',
62+
true,
63+
],
64+
['/app/node_modules/react-native-virtual-dead/index.js', true],
65+
['/app/node_modules/react-native-svg-virtual-cafe/index.js', true],
66+
// A path ending at the package directory itself, with no trailing
67+
// separator, is not a source file and must not be transformed.
68+
['/app/node_modules/react-native', true],
69+
['/app/node_modules/@sentry/react-native/lib/index.js', true],
70+
['/app/node_modules/@notifee/react-native/lib/index.js', true],
71+
['/app/node_modules/some-pkg/react-native/patch.js', true],
72+
[
73+
'/tmp/x/node_modules/.pnpm/@react-native+jest-preset@file+preset_abc/node_modules/@react-native/jest-preset/jest/setup.js',
74+
false,
75+
],
76+
[
77+
'/tmp/x/node_modules/.pnpm/react-native@1000.0.0/node_modules/react-native/Libraries/AppState/AppState.js',
78+
false,
79+
],
80+
[
81+
'/tmp/x/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js',
82+
true,
83+
],
84+
[
85+
'/tmp/x/node_modules/.pnpm/react-native-reanimated@1.0.0/node_modules/react-native-reanimated/lib/index.js',
86+
true,
87+
],
88+
[
89+
'/tmp/x/node_modules/.pnpm/react-native-svg@1.0.0/node_modules/react-native-svg/lib/index.js',
90+
true,
91+
],
92+
[
93+
'/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',
94+
true,
95+
],
96+
[
97+
'/tmp/x/node_modules/.pnpm/@sentry+react-native@6.1.0/node_modules/@sentry/react-native/lib/index.js',
98+
true,
99+
],
100+
[
101+
'/tmp/x/node_modules/.pnpm/some-pkg@1.0.0/node_modules/some-pkg/react-native/patch.js',
102+
true,
103+
],
104+
[
105+
'/tmp/x/node_modules/.store/react-native-npm-1000.0.0-abc123def4/package/Libraries/AppState/AppState.js',
106+
false,
107+
],
108+
[
109+
'/tmp/x/node_modules/.store/@react-native-jest-preset-npm-0.87.1-abc123def4/package/jest/mock.js',
110+
false,
111+
],
112+
[
113+
'/tmp/x/node_modules/.store/@react-native-community-cli-npm-15.0.0-abc123def4/package/build/index.js',
114+
false,
115+
],
116+
[
117+
'/tmp/x/node_modules/.store/lodash-npm-4.17.21-abc123def4/package/lodash.js',
118+
true,
119+
],
120+
[
121+
'/tmp/x/node_modules/.store/react-native-reanimated-npm-1.0.0-abc123def4/package/lib/index.js',
122+
true,
123+
],
124+
[
125+
'/tmp/x/node_modules/.store/react-native-virtualized-view-npm-1.0.0-abc123def4/package/lib/index.js',
126+
true,
127+
],
128+
[
129+
'/tmp/x/node_modules/.store/@sentry-react-native-npm-6.1.0-abc123def4/package/lib/index.js',
130+
true,
131+
],
132+
[
133+
'/tmp/x/node_modules/.store/@notifee-react-native-npm-9.1.0-abc123def4/package/lib/index.js',
134+
true,
135+
],
136+
// Yarn emits -virtual-<hash> entries (no version) for packages that
137+
// declare peer dependencies — both store forms must behave the same.
138+
[
139+
'/tmp/x/node_modules/.store/@react-native-jest-preset-virtual-1fd1f8fd8f/package/jest/setup.js',
140+
false,
141+
],
142+
[
143+
'/tmp/x/node_modules/.store/react-native-virtual-abc123def4/package/Libraries/AppState/AppState.js',
144+
false,
145+
],
146+
[
147+
'/tmp/x/node_modules/.store/@sentry-react-native-virtual-abc123def4/package/lib/index.js',
148+
true,
149+
],
150+
[
151+
'/tmp/x/node_modules/.store/react-native-reanimated-virtual-abc123def4/package/lib/index.js',
152+
true,
153+
],
154+
[
155+
'/tmp/x/node_modules/.store/@react-native-async-storage-async-storage-virtual-abc123def4/package/lib/index.js',
156+
false,
157+
],
158+
// Flattening erases the scope boundary, so a third-party
159+
// @react-native-<scope>/* package is indistinguishable from a genuine
160+
// @react-native/* one here; transforming is the safe direction (a miss
161+
// would ship untransformed sources), at performance-only cost.
162+
[
163+
'/tmp/x/node_modules/.store/@react-native-async-storage-async-storage-npm-1.0.0-abc123def4/package/lib/index.js',
164+
false,
165+
],
166+
['/tmp/x/packages/app/__tests__/App.test.js', false],
167+
];
168+
for (const [file, shouldIgnore] of cases) {
169+
expect(re.test(file)).toBe(shouldIgnore);
170+
}
171+
});
172+
});

0 commit comments

Comments
 (0)