Skip to content

Commit 7cfe4fb

Browse files
committed
Export the ReactNativeFeatureFlags subpath used by virtualized-lists
#57484 removed the "./src/*" exports mapping from the react-native package, on the basis that these import paths were never used externally. @react-native/virtualized-lists is a separately published package and a direct dependency of react-native, and it imports 'react-native/src/private/featureflags/ReactNativeFeatureFlags' at runtime from VirtualizedList.js and VirtualizeUtils.js. As a result, any app rendering FlatList makes Metro emit a package-exports violation warning and fall back to file-based resolution. Re-export that single subpath, keeping "types": null so it stays hidden from TypeScript as intended by #57277. Also add a monorepo test that resolves every react-native subpath imported at runtime by a published package against the "exports" map, so a mapping cannot be dropped again without CI failing.
1 parent 551d12a commit 7cfe4fb

3 files changed

Lines changed: 194 additions & 0 deletions

File tree

packages/react-native/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
"types": null,
6060
"default": "./src/unstable-internals-do-not-use.js"
6161
},
62+
"./src/private/featureflags/ReactNativeFeatureFlags": {
63+
"types": null,
64+
"default": "./src/private/featureflags/ReactNativeFeatureFlags.js"
65+
},
6266
"./src/fb_internal/*": "./src/fb_internal/*",
6367
"./package.json": "./package.json"
6468
},

scripts/monorepo-tests/__tests__/check-packages-test.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
* @format
99
*/
1010

11+
import type {PackageExportsTarget} from '../../shared/monorepoUtils';
12+
1113
import {PRIVATE_DIR, REPO_ROOT} from '../../shared/consts';
1214
import {
1315
getPackages,
1416
getReactNativePackage,
1517
getWorkspaceRoot,
1618
} from '../../shared/monorepoUtils';
19+
import fs from 'node:fs';
1720
import path from 'node:path';
1821
import {globSync} from 'tinyglobby';
1922

@@ -75,6 +78,185 @@ describe('package manifests', () => {
7578
});
7679
});
7780

81+
// Files matching these patterns are excluded from every published package via
82+
// the package.json "files" field, so their imports are never resolved by a
83+
// consuming app's bundler.
84+
const UNPUBLISHED_FILE_PATTERNS = [
85+
'**/node_modules/**',
86+
'**/__docs__/**',
87+
'**/__fixtures__/**',
88+
'**/__flowtests__/**',
89+
'**/__mocks__/**',
90+
'**/__tests__/**',
91+
'**/__typetests__/**',
92+
// Excluded from the react-native package's "files" field.
93+
'src/private/testing/**',
94+
// Vendored third-party bundles, which never import react-native.
95+
'**/third-party/**',
96+
];
97+
98+
/**
99+
* Matches `import`/`export ... from '<specifier>'` declarations, capturing the
100+
* Flow `type`/`typeof` marker when present. The body cannot span a `;`, which
101+
* keeps each match within a single statement.
102+
*/
103+
const IMPORT_DECL_REGEX =
104+
/\b(?:import|export)\s+(type\s+|typeof\s+)?[^;]*?\bfrom\s*'(react-native\/[^']+)'/g;
105+
106+
const REQUIRE_CALL_REGEX = /\brequire\(\s*'(react-native\/[^']+)'\s*\)/g;
107+
108+
/**
109+
* Returns the `react-native/...` subpaths a module imports *at runtime*.
110+
*
111+
* Flow `import type`/`import typeof` declarations are excluded: Babel erases
112+
* them, so they never reach a bundler's resolver.
113+
*/
114+
function findRuntimeReactNativeImports(source: string): Array<string> {
115+
const specifiers = [];
116+
117+
for (const match of source.matchAll(IMPORT_DECL_REGEX)) {
118+
if (match[1] == null) {
119+
specifiers.push(match[2]);
120+
}
121+
}
122+
for (const match of source.matchAll(REQUIRE_CALL_REGEX)) {
123+
specifiers.push(match[1]);
124+
}
125+
126+
return specifiers;
127+
}
128+
129+
/**
130+
* Selects a target under the conditions a bundler applies at runtime. Any
131+
* condition we don't set (e.g. "types") is skipped, and an explicit `null`
132+
* target means "not exported".
133+
*/
134+
function selectRuntimeTarget(target: PackageExportsTarget): string | null {
135+
if (target == null) {
136+
return null;
137+
}
138+
if (typeof target === 'string') {
139+
return target;
140+
}
141+
for (const condition of Object.keys(target)) {
142+
if (condition === 'default' || condition === 'require') {
143+
return selectRuntimeTarget(target[condition]);
144+
}
145+
}
146+
return null;
147+
}
148+
149+
/**
150+
* Resolves a subpath against a package "exports" map, implementing the subset
151+
* of Node's PACKAGE_EXPORTS_RESOLVE algorithm that react-native's map uses:
152+
* exact keys, single-`*` patterns, and conditional targets.
153+
*
154+
* Returns the target path relative to the package root, or null when the
155+
* subpath is not exported.
156+
*/
157+
function resolveExportsSubpath(
158+
exportsMap: Record<string, PackageExportsTarget>,
159+
subpath: string,
160+
): string | null {
161+
if (Object.hasOwn(exportsMap, subpath)) {
162+
return selectRuntimeTarget(exportsMap[subpath]);
163+
}
164+
165+
// Node picks the pattern with the longest prefix before `*`, then the
166+
// longest suffix after it.
167+
let bestKey = null;
168+
let bestCapture = null;
169+
170+
for (const key of Object.keys(exportsMap)) {
171+
const starIndex = key.indexOf('*');
172+
if (starIndex === -1) {
173+
continue;
174+
}
175+
const prefix = key.slice(0, starIndex);
176+
const suffix = key.slice(starIndex + 1);
177+
if (
178+
!subpath.startsWith(prefix) ||
179+
!subpath.endsWith(suffix) ||
180+
// `*` must capture at least one character.
181+
subpath.length <= prefix.length + suffix.length
182+
) {
183+
continue;
184+
}
185+
if (
186+
bestKey == null ||
187+
prefix.length > bestKey.indexOf('*') ||
188+
(prefix.length === bestKey.indexOf('*') &&
189+
suffix.length > bestKey.length - bestKey.indexOf('*') - 1)
190+
) {
191+
bestKey = key;
192+
bestCapture = subpath.slice(
193+
prefix.length,
194+
subpath.length - suffix.length,
195+
);
196+
}
197+
}
198+
199+
if (bestKey == null || bestCapture == null) {
200+
return null;
201+
}
202+
203+
const target = selectRuntimeTarget(exportsMap[bestKey]);
204+
return target == null ? null : target.replaceAll('*', bestCapture);
205+
}
206+
207+
describe('package exports', () => {
208+
// Regression test for https://github.com/react/react-native/issues/57933,
209+
// where @react-native/virtualized-lists imported a react-native subpath that
210+
// had been dropped from the "exports" map. Metro only warns and falls back
211+
// to file-based resolution, so nothing in CI failed.
212+
//
213+
// "exports" is resolved here rather than via `require.resolve`, because both
214+
// Jest's resolver (packages/jest-preset/jest/resolver.js) and Jest's patched
215+
// Node module resolution ignore the "exports" field entirely.
216+
test('published packages must only deep import exported react-native subpaths', async () => {
217+
const {path: reactNativePath, packageJson} = await getReactNativePackage();
218+
const exportsMap = packageJson.exports;
219+
if (exportsMap == null) {
220+
throw new Error('The react-native package must declare "exports".');
221+
}
222+
const packages = await getPackages({includeReactNative: true});
223+
const violations: Array<string> = [];
224+
225+
for (const name of Object.keys(packages)) {
226+
const packagePath = packages[name].path;
227+
const files = globSync('**/*.js', {
228+
cwd: packagePath,
229+
ignore: UNPUBLISHED_FILE_PATTERNS,
230+
});
231+
232+
for (const file of files) {
233+
const source = fs.readFileSync(path.join(packagePath, file), 'utf8');
234+
235+
for (const specifier of findRuntimeReactNativeImports(source)) {
236+
const subpath = '.' + specifier.slice('react-native'.length);
237+
const target = resolveExportsSubpath(exportsMap, subpath);
238+
239+
if (target == null) {
240+
violations.push(
241+
`${name}: ${file} imports '${specifier}', which is not listed in react-native's "exports"`,
242+
);
243+
} else if (
244+
// Meta-internal sources are not present in an OSS checkout.
245+
!target.startsWith('./src/fb_internal/') &&
246+
!fs.existsSync(path.join(reactNativePath, target))
247+
) {
248+
violations.push(
249+
`${name}: ${file} imports '${specifier}', which "exports" maps to the missing file '${target}'`,
250+
);
251+
}
252+
}
253+
}
254+
}
255+
256+
expect(violations).toEqual([]);
257+
});
258+
});
259+
78260
describe('package file structure', () => {
79261
test('packages must not contain .npmignore files', () => {
80262
// Publishing must be controlled via the package.json "files" field, which is

scripts/shared/monorepoUtils.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@ const {globSync} = require('tinyglobby');
1616
const WORKSPACES_CONFIG = '{packages,private}/*';
1717

1818
/*::
19+
// An "exports" target: a file path, `null` (not exported), or a nested map of
20+
// export conditions.
21+
export type PackageExportsTarget =
22+
| string
23+
| null
24+
| Record<string, PackageExportsTarget>;
25+
1926
export type PackageJson = {
2027
name: string,
2128
version: string,
2229
dependencies?: Record<string, string>,
2330
devDependencies?: Record<string, string>,
31+
exports?: Record<string, PackageExportsTarget>,
2432
files?: ReadonlyArray<string>,
2533
license?: string,
2634
main?: string,

0 commit comments

Comments
 (0)