diff --git a/packages/react-native/src/react-private-interface.js b/packages/react-native/src/react-private-interface.js index 6e965490ebc9..2d81f4634b08 100644 --- a/packages/react-native/src/react-private-interface.js +++ b/packages/react-native/src/react-private-interface.js @@ -48,6 +48,7 @@ import type {DangerouslyImpreciseStyleProp} from '../Libraries/StyleSheet/StyleS import typeof deepFreezeAndThrowOnMutationInDev from '../Libraries/Utilities/deepFreezeAndThrowOnMutationInDev'; import typeof deepDiffer from '../Libraries/Utilities/differ/deepDiffer'; import typeof Platform from '../Libraries/Utilities/Platform'; +import typeof * as ReactNativeFeatureFlags from './private/featureflags/ReactNativeFeatureFlags'; import typeof dispatchNativeEvent from './private/renderer/events/dispatchNativeEvent'; import typeof CustomEvent from './private/webapis/dom/events/CustomEvent'; @@ -69,6 +70,9 @@ module.exports = { get RCTEventEmitter(): RCTEventEmitter { return require('../Libraries/EventEmitter/RCTEventEmitter').default; }, + get ReactNativeFeatureFlags(): ReactNativeFeatureFlags { + return require('./private/featureflags/ReactNativeFeatureFlags'); + }, get ReactNativeViewConfigRegistry(): ReactNativeViewConfigRegistry { return require('../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'); }, diff --git a/packages/react-native/src/react-private-interface.js.flow b/packages/react-native/src/react-private-interface.js.flow index b30331d119f8..6df0e5e43b85 100644 --- a/packages/react-native/src/react-private-interface.js.flow +++ b/packages/react-native/src/react-private-interface.js.flow @@ -23,6 +23,7 @@ export {default as BatchedBridge} from '../Libraries/BatchedBridge/BatchedBridge export {default as ExceptionsManager} from '../Libraries/Core/ExceptionsManager'; export {default as Platform} from '../Libraries/Utilities/Platform'; export {default as RCTEventEmitter} from '../Libraries/EventEmitter/RCTEventEmitter'; +export * as ReactNativeFeatureFlags from './private/featureflags/ReactNativeFeatureFlags'; export * as ReactNativeViewConfigRegistry from '../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; export {default as TextInputState} from '../Libraries/Components/TextInput/TextInputState'; export {default as UIManager} from '../Libraries/ReactNative/UIManager'; diff --git a/packages/virtualized-lists/Lists/VirtualizeUtils.js b/packages/virtualized-lists/Lists/VirtualizeUtils.js index 598eb2e7c9b0..4e5c6877fc77 100644 --- a/packages/virtualized-lists/Lists/VirtualizeUtils.js +++ b/packages/virtualized-lists/Lists/VirtualizeUtils.js @@ -13,7 +13,7 @@ import type ListMetricsAggregator from './ListMetricsAggregator'; import type {CellMetricProps} from './ListMetricsAggregator'; -import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; +import {ReactNativeFeatureFlags} from 'react-native/react-private-interface'; /** * Used to find the indices of the frames that overlap the given offsets. Useful for finding the diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 8545ba4c6ff8..3fd58cc7370d 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -64,7 +64,7 @@ import { View, findNodeHandle, } from 'react-native'; -import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; +import {ReactNativeFeatureFlags} from 'react-native/react-private-interface'; export type {ListRenderItemInfo, ListRenderItem, Separators}; diff --git a/scripts/monorepo-tests/__tests__/check-packages-test.js b/scripts/monorepo-tests/__tests__/check-packages-test.js index 2c558bba4fe1..5147276f27e1 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 private react-native + // subpath that was not in 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,