From 7cfe4fbb23d14dfa23d95d953af69aa5144a6fc8 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Thu, 13 Aug 2026 19:42:41 +0700 Subject: [PATCH] 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. --- packages/react-native/package.json | 4 + .../__tests__/check-packages-test.js | 182 ++++++++++++++++++ scripts/shared/monorepoUtils.js | 8 + 3 files changed, 194 insertions(+) diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 505616414b1b..272623ac6e3a 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -59,6 +59,10 @@ "types": null, "default": "./src/unstable-internals-do-not-use.js" }, + "./src/private/featureflags/ReactNativeFeatureFlags": { + "types": null, + "default": "./src/private/featureflags/ReactNativeFeatureFlags.js" + }, "./src/fb_internal/*": "./src/fb_internal/*", "./package.json": "./package.json" }, diff --git a/scripts/monorepo-tests/__tests__/check-packages-test.js b/scripts/monorepo-tests/__tests__/check-packages-test.js index 2c558bba4fe1..23efbdddd66f 100644 --- a/scripts/monorepo-tests/__tests__/check-packages-test.js +++ b/scripts/monorepo-tests/__tests__/check-packages-test.js @@ -8,12 +8,15 @@ * @format */ +import type {PackageExportsTarget} from '../../shared/monorepoUtils'; + import {PRIVATE_DIR, REPO_ROOT} from '../../shared/consts'; import { getPackages, getReactNativePackage, getWorkspaceRoot, } from '../../shared/monorepoUtils'; +import fs from 'node:fs'; import path from 'node:path'; import {globSync} from 'tinyglobby'; @@ -75,6 +78,185 @@ describe('package manifests', () => { }); }); +// Files matching these patterns are excluded from every published package via +// the package.json "files" field, so their imports are never resolved by a +// consuming app's bundler. +const UNPUBLISHED_FILE_PATTERNS = [ + '**/node_modules/**', + '**/__docs__/**', + '**/__fixtures__/**', + '**/__flowtests__/**', + '**/__mocks__/**', + '**/__tests__/**', + '**/__typetests__/**', + // Excluded from the react-native package's "files" field. + 'src/private/testing/**', + // Vendored third-party bundles, which never import react-native. + '**/third-party/**', +]; + +/** + * Matches `import`/`export ... from ''` declarations, capturing the + * Flow `type`/`typeof` marker when present. The body cannot span a `;`, which + * keeps each match within a single statement. + */ +const IMPORT_DECL_REGEX = + /\b(?:import|export)\s+(type\s+|typeof\s+)?[^;]*?\bfrom\s*'(react-native\/[^']+)'/g; + +const REQUIRE_CALL_REGEX = /\brequire\(\s*'(react-native\/[^']+)'\s*\)/g; + +/** + * Returns the `react-native/...` subpaths a module imports *at runtime*. + * + * Flow `import type`/`import typeof` declarations are excluded: Babel erases + * them, so they never reach a bundler's resolver. + */ +function findRuntimeReactNativeImports(source: string): Array { + const specifiers = []; + + for (const match of source.matchAll(IMPORT_DECL_REGEX)) { + if (match[1] == null) { + specifiers.push(match[2]); + } + } + for (const match of source.matchAll(REQUIRE_CALL_REGEX)) { + specifiers.push(match[1]); + } + + return specifiers; +} + +/** + * Selects a target under the conditions a bundler applies at runtime. Any + * condition we don't set (e.g. "types") is skipped, and an explicit `null` + * target means "not exported". + */ +function selectRuntimeTarget(target: PackageExportsTarget): string | null { + if (target == null) { + return null; + } + if (typeof target === 'string') { + return target; + } + for (const condition of Object.keys(target)) { + if (condition === 'default' || condition === 'require') { + return selectRuntimeTarget(target[condition]); + } + } + return null; +} + +/** + * Resolves a subpath against a package "exports" map, implementing the subset + * of Node's PACKAGE_EXPORTS_RESOLVE algorithm that react-native's map uses: + * exact keys, single-`*` patterns, and conditional targets. + * + * Returns the target path relative to the package root, or null when the + * subpath is not exported. + */ +function resolveExportsSubpath( + exportsMap: Record, + subpath: string, +): string | null { + if (Object.hasOwn(exportsMap, subpath)) { + return selectRuntimeTarget(exportsMap[subpath]); + } + + // Node picks the pattern with the longest prefix before `*`, then the + // longest suffix after it. + let bestKey = null; + let bestCapture = null; + + for (const key of Object.keys(exportsMap)) { + const starIndex = key.indexOf('*'); + if (starIndex === -1) { + continue; + } + const prefix = key.slice(0, starIndex); + const suffix = key.slice(starIndex + 1); + if ( + !subpath.startsWith(prefix) || + !subpath.endsWith(suffix) || + // `*` must capture at least one character. + subpath.length <= prefix.length + suffix.length + ) { + continue; + } + if ( + bestKey == null || + prefix.length > bestKey.indexOf('*') || + (prefix.length === bestKey.indexOf('*') && + suffix.length > bestKey.length - bestKey.indexOf('*') - 1) + ) { + bestKey = key; + bestCapture = subpath.slice( + prefix.length, + subpath.length - suffix.length, + ); + } + } + + if (bestKey == null || bestCapture == null) { + return null; + } + + const target = selectRuntimeTarget(exportsMap[bestKey]); + return target == null ? null : target.replaceAll('*', bestCapture); +} + +describe('package exports', () => { + // Regression test for https://github.com/react/react-native/issues/57933, + // where @react-native/virtualized-lists imported a react-native subpath that + // had been dropped from the "exports" map. Metro only warns and falls back + // to file-based resolution, so nothing in CI failed. + // + // "exports" is resolved here rather than via `require.resolve`, because both + // Jest's resolver (packages/jest-preset/jest/resolver.js) and Jest's patched + // Node module resolution ignore the "exports" field entirely. + test('published packages must only deep import exported react-native subpaths', async () => { + const {path: reactNativePath, packageJson} = await getReactNativePackage(); + const exportsMap = packageJson.exports; + if (exportsMap == null) { + throw new Error('The react-native package must declare "exports".'); + } + const packages = await getPackages({includeReactNative: true}); + const violations: Array = []; + + for (const name of Object.keys(packages)) { + const packagePath = packages[name].path; + const files = globSync('**/*.js', { + cwd: packagePath, + ignore: UNPUBLISHED_FILE_PATTERNS, + }); + + for (const file of files) { + const source = fs.readFileSync(path.join(packagePath, file), 'utf8'); + + for (const specifier of findRuntimeReactNativeImports(source)) { + const subpath = '.' + specifier.slice('react-native'.length); + const target = resolveExportsSubpath(exportsMap, subpath); + + if (target == null) { + violations.push( + `${name}: ${file} imports '${specifier}', which is not listed in react-native's "exports"`, + ); + } else if ( + // Meta-internal sources are not present in an OSS checkout. + !target.startsWith('./src/fb_internal/') && + !fs.existsSync(path.join(reactNativePath, target)) + ) { + violations.push( + `${name}: ${file} imports '${specifier}', which "exports" maps to the missing file '${target}'`, + ); + } + } + } + } + + expect(violations).toEqual([]); + }); +}); + describe('package file structure', () => { test('packages must not contain .npmignore files', () => { // Publishing must be controlled via the package.json "files" field, which is diff --git a/scripts/shared/monorepoUtils.js b/scripts/shared/monorepoUtils.js index fb0a2521e3a4..ba3a42e7d68c 100644 --- a/scripts/shared/monorepoUtils.js +++ b/scripts/shared/monorepoUtils.js @@ -16,11 +16,19 @@ const {globSync} = require('tinyglobby'); const WORKSPACES_CONFIG = '{packages,private}/*'; /*:: +// An "exports" target: a file path, `null` (not exported), or a nested map of +// export conditions. +export type PackageExportsTarget = + | string + | null + | Record; + export type PackageJson = { name: string, version: string, dependencies?: Record, devDependencies?: Record, + exports?: Record, files?: ReadonlyArray, license?: string, main?: string,