|
8 | 8 | * @format |
9 | 9 | */ |
10 | 10 |
|
| 11 | +import type {PackageExportsTarget} from '../../shared/monorepoUtils'; |
| 12 | + |
11 | 13 | import {PRIVATE_DIR, REPO_ROOT} from '../../shared/consts'; |
12 | 14 | import { |
13 | 15 | getPackages, |
14 | 16 | getReactNativePackage, |
15 | 17 | getWorkspaceRoot, |
16 | 18 | } from '../../shared/monorepoUtils'; |
| 19 | +import fs from 'node:fs'; |
17 | 20 | import path from 'node:path'; |
18 | 21 | import {globSync} from 'tinyglobby'; |
19 | 22 |
|
@@ -75,6 +78,185 @@ describe('package manifests', () => { |
75 | 78 | }); |
76 | 79 | }); |
77 | 80 |
|
| 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 | + |
78 | 260 | describe('package file structure', () => { |
79 | 261 | test('packages must not contain .npmignore files', () => { |
80 | 262 | // Publishing must be controlled via the package.json "files" field, which is |
|
0 commit comments