From f7366deea88f8ca2e441baf0d5479f7422b31178 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 04:25:08 -0500 Subject: [PATCH 1/4] feat: sync the 0.87 public API and Metro feature foundation Curate thirteen upstream source changes for setup-env, strict TypeScript APIs, public entry points and Metro 0.87 without stable release version bumps or the broader 0.88 cutoff. Preserve macOS public type contracts through generated default exports, including watch-mode translation and aliased API hashes. Independent review verified source coverage, package conditions, strict/legacy consumer checks and focused transform tests. --- .flowconfig | 2 + .github/workflows/test-all.yml | 4 +- .yarnrc.yml | 10 +- jest.config.js | 3 + package.json | 12 +- packages/community-cli-plugin/package.json | 6 +- .../src/utils/loadMetroConfig.js | 25 +- .../__tests__/no-deep-imports-test.js | 30 +- .../no-deep-imports.js | 44 +- packages/eslint-plugin-react-native/utils.js | 7 + packages/jest-preset/README.md | 6 - packages/jest-preset/jest-preset.js | 2 + packages/jest-preset/jest/setup.js | 1 + packages/metro-config/package.json | 4 +- packages/metro-config/src/index.flow.js | 6 +- .../plugin-warn-on-deep-imports-test.js | 14 - .../src/plugin-warn-on-deep-imports.js | 17 +- .../Components/TextInput/TextInput.flow.js | 2 +- .../Components/View/ViewPropTypes.js | 9 + .../Libraries/Core/InitializeCore.js | 10 +- .../Libraries/Core/setUpGlobals.js | 13 +- .../Libraries/Core/setUpNavigator.js | 7 +- .../ReactNativePrivateInterface.js | 121 +-- .../ReactNativePrivateInterface.js.flow | 36 +- packages/react-native/ReactNativeApi.d.ts | 909 +++++++++++------- packages/react-native/__typetests__/index.tsx | 17 - .../__typetests__/macos-public-api.ts | 160 +++ .../react-native/__typetests__/tsconfig.json | 10 +- .../__typetests__/tsconfig.legacy.json | 15 + .../tsconfig.macos-public-api.json | 12 + packages/react-native/index.js | 18 +- packages/react-native/index.js.flow | 12 +- packages/react-native/jest-preset.js | 26 - packages/react-native/package.json | 40 +- packages/react-native/rn-get-polyfills.js | 13 - packages/react-native/src/asset-registry.js | 2 +- ...ultReactNativeEnvironment-Globals-itest.js | 69 ++ .../src/react-private-interface.js | 145 +++ .../src/react-private-interface.js.flow | 48 + packages/react-native/src/setup-env.js | 22 + packages/react-native/src/types/macos.d.ts | 100 ++ packages/react-native/src/types/macos.js | 23 + .../src/unstable-internals-do-not-use.d.ts | 214 +++++ .../src/unstable-internals-do-not-use.js | 76 ++ packages/react-native/types/tsconfig.json | 20 - .../IntegrationTests/IntegrationTestsApp.js | 3 +- packages/typescript-config/README.md | 15 +- packages/typescript-config/package.json | 3 +- .../typescript-config/tsconfig.strict.json | 8 - packages/virtualized-lists/package.json | 4 +- private/core-cli-utils/package.json | 2 +- private/helloworld/jest.config.js | 2 +- private/helloworld/package.json | 1 + private/react-native-fantom/package.json | 4 +- .../__tests__/translateSourceFile-test.js | 35 + .../js-api/build-types/buildApiSnapshot.js | 9 + .../js-api/build-types/buildGeneratedTypes.js | 24 +- .../__tests__/aliasedExports-test.js | 89 ++ .../__tests__/organizeDeclarations-test.js | 11 + .../typescript/organizeDeclarations.js | 27 +- .../typescript/versionExportedApis.js | 9 +- .../js-api/build-types/translateSourceFile.js | 11 + scripts/run-ci-javascript-tests.js | 2 +- yarn.lock | 846 ++++------------ 64 files changed, 2048 insertions(+), 1399 deletions(-) create mode 100644 packages/react-native/__typetests__/macos-public-api.ts rename scripts/js-api/build-types/templates/tsconfig.test.json => packages/react-native/__typetests__/tsconfig.json (52%) create mode 100644 packages/react-native/__typetests__/tsconfig.legacy.json create mode 100644 packages/react-native/__typetests__/tsconfig.macos-public-api.json delete mode 100644 packages/react-native/jest-preset.js delete mode 100644 packages/react-native/rn-get-polyfills.js create mode 100644 packages/react-native/src/react-private-interface.js create mode 100644 packages/react-native/src/react-private-interface.js.flow create mode 100644 packages/react-native/src/setup-env.js create mode 100644 packages/react-native/src/types/macos.d.ts create mode 100644 packages/react-native/src/types/macos.js create mode 100644 packages/react-native/src/unstable-internals-do-not-use.d.ts create mode 100644 packages/react-native/src/unstable-internals-do-not-use.js delete mode 100644 packages/react-native/types/tsconfig.json delete mode 100644 packages/typescript-config/tsconfig.strict.json create mode 100644 scripts/js-api/build-types/__tests__/translateSourceFile-test.js create mode 100644 scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js diff --git a/.flowconfig b/.flowconfig index 907a4d5bb985..afad0a2f882c 100644 --- a/.flowconfig +++ b/.flowconfig @@ -56,6 +56,8 @@ experimental.multi_platform.extensions=.android munge_underscores=true module.name_mapper='^react-native$' -> '/packages/react-native/index.js' +module.name_mapper='^react-native/react-private-interface$' -> '/packages/react-native/src/react-private-interface.js' +module.name_mapper='^react-native/setup-env$' -> '/packages/react-native/src/setup-env.js' module.name_mapper='^react-native/\(.*\)$' -> '/packages/react-native/\1' module.name_mapper='^@react-native/dev-middleware$' -> '/packages/dev-middleware' ; [macOS Resolve workspace self-references (incl. @react-native/fantom) in yarn pnpm mode diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 422a90c712ae..1f49896cbf36 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -471,9 +471,9 @@ jobs: - name: Flow shell: bash run: yarn flow-check - - name: TypeScript + - name: TypeScript (legacy deep imports / manual types) shell: bash - run: yarn test-typescript + run: yarn test-typescript-legacy test_js: runs-on: ubuntu-latest diff --git a/.yarnrc.yml b/.yarnrc.yml index 74c435d96534..8c7e958fdef5 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -21,16 +21,16 @@ packageExtensions: '@babel/plugin-syntax-flow': '^7.25.0' '@react-native/core-cli-utils@*': dependencies: - 'metro': '^0.83.3' + 'metro': '^0.87.0' '@react-native/metro-babel-transformer@*': dependencies: - 'metro-babel-transformer': '^0.83.3' + 'metro-babel-transformer': '^0.87.0' '@react-native/compatibility-check@*': dependencies: 'invariant': '^2.2.4' '@react-native/tester@*': dependencies: - 'metro-config': '^0.83.3' + 'metro-config': '^0.87.0' '@react-native/eslint-plugin-monorepo@*': dependencies: 'hermes-estree': '0.29.1' @@ -39,8 +39,8 @@ packageExtensions: 'istanbul-lib-coverage': '^3.2.0' 'jest-docblock': '^29.7.0' 'jest-message-util': '^29.7.0' - 'metro': '^0.84.3' - 'metro-config': '^0.84.3' + 'metro': '^0.87.0' + 'metro-config': '^0.87.0' 'source-map': '^0.6.1' '@typescript-eslint/type-utils@*': dependencies: diff --git a/jest.config.js b/jest.config.js index f881ea65f144..84fbdce8a36d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -51,6 +51,9 @@ module.exports = { }, // [macOS moduleNameMapper: { + // `resolver.js` strips `exports`; match this before the broad fork mapper. + '^react-native/setup-env$': + '/packages/react-native/src/setup-env.js', // These mappers allow out-of-tree platforms tests to resolve RN imports '^react-native/(.*)': '/packages/react-native/$1', '^react-native$': '/packages/react-native/index.js', diff --git a/package.json b/package.json index 88e1f61326d9..9abf30c43ed0 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "test-release-local-clean": "node ./scripts/release-testing/test-release-local-clean.js", "test-release-local": "node ./scripts/release-testing/test-release-local.js", "test-ios": "./scripts/objc-test.sh test", - "test-typescript": "tsc -p packages/react-native/types/tsconfig.json", - "test-generated-typescript": "tsc -p packages/react-native/types_generated/tsconfig.test.json", + "test-typescript-legacy": "tsc -p packages/react-native/__typetests__/tsconfig.legacy.json", + "test-generated-typescript": "tsc -p packages/react-native/__typetests__/tsconfig.json && tsc -p packages/react-native/__typetests__/tsconfig.macos-public-api.json", "test": "jest", "fantom": "./scripts/fantom.sh", "fantom-cli": "./scripts/fantom-cli.sh", @@ -128,11 +128,11 @@ "markdownlint-cli2": "^0.22.0", "markdownlint-rule-relative-links": "^3.0.0", "memfs": "^4.38.2", - "metro": "^0.84.3", - "metro-babel-register": "^0.86.0", - "metro-config": "^0.84.3", + "metro": "^0.87.0", + "metro-babel-register": "^0.87.0", + "metro-config": "^0.87.0", "metro-memory-fs": "^0.82.5", - "metro-transform-plugins": "^0.86.0", + "metro-transform-plugins": "^0.87.0", "micromatch": "^4.0.4", "node-fetch": "^2.2.0", "nullthrows": "^1.1.1", diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index 6d15fc021ba6..09a1228d7add 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -36,12 +36,12 @@ "chalk": "^4.0.0", "debug": "^4.4.0", "invariant": "^2.2.4", - "metro": "^0.86.0", - "metro-config": "^0.86.0", + "metro": "^0.87.0", + "metro-config": "^0.87.0", "semver": "^7.1.3" }, "devDependencies": { - "metro-resolver": "^0.86.0" + "metro-resolver": "^0.87.0" }, "peerDependencies": { "@react-native-community/cli": "*", diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index 678dbbab2f7d..12b816926347 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -14,7 +14,6 @@ import type {InputConfigT} from 'metro-config'; import {CLIError} from './errors'; import {reactNativePlatformResolver} from './metroPlatformResolver'; import {loadConfig, resolveConfig} from 'metro'; -import path from 'path'; const debug = require('debug')('ReactNative:CommunityCliPlugin'); @@ -25,7 +24,6 @@ export type {Config}; export type ConfigLoadingContext = Readonly<{ root: Config['root'], - reactNativePath: Config['reactNativePath'], platforms: Config['platforms'], ... }>; @@ -61,16 +59,13 @@ function getCommunityCliDefaultConfig( return { resolver, serializer: { - // We can include multiple copies of InitializeCore here because metro will + // We can include multiple copies of setup-env here because Metro will // only add ones that are already part of the bundle getModulesRunBeforeMainModule: () => [ - require.resolve( - path.join(ctx.reactNativePath, 'Libraries/Core/InitializeCore'), - {paths: [ctx.root]}, - ), + resolveSetupEnv(ctx.root), // [macOS] ...outOfTreePlatforms.map(platform => require.resolve( - `${ctx.platforms[platform].npmPackageName}/Libraries/Core/InitializeCore`, + `${ctx.platforms[platform].npmPackageName}/setup-env`, {paths: [ctx.root]}, ), ), @@ -79,6 +74,20 @@ function getCommunityCliDefaultConfig( }; } +// [macOS] Main workspaces have only the fork; release apps can have both. +function resolveSetupEnv(projectRoot: string): string { + try { + return require.resolve('react-native/setup-env', {paths: [projectRoot]}); + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + throw error; + } + return require.resolve('react-native-macos/setup-env', { + paths: [projectRoot], + }); + } +} + /** * Load Metro config. * diff --git a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js index d28b33f45835..bf14e917f7bf 100644 --- a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js +++ b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js @@ -29,10 +29,10 @@ eslintTester.run('../no-deep-imports', rule, { "import Foo from 'react-native-foo';", "import Foo from 'react-native-foo/Foo';", "import Foo from 'react/native/Foo';", - "import 'react-native/Libraries/Core/InitializeCore';", - "require('react-native/Libraries/Core/InitializeCore');", "import Foo from 'react-native/src/fb_internal/Foo'", "require('react-native/src/fb_internal/Foo')", + "import 'react-native/setup-env';", + "require('react-native/setup-env');", ], invalid: [ { @@ -125,5 +125,31 @@ eslintTester.run('../no-deep-imports', rule, { ], output: null, }, + { + code: "import 'react-native/Libraries/Core/InitializeCore';", + errors: [ + { + messageId: 'useReplacementSource', + data: { + importPath: 'react-native/Libraries/Core/InitializeCore', + replacementSource: 'react-native/setup-env', + }, + }, + ], + output: "import 'react-native/setup-env';", + }, + { + code: "require('react-native/Libraries/Core/InitializeCore');", + errors: [ + { + messageId: 'useReplacementSource', + data: { + importPath: 'react-native/Libraries/Core/InitializeCore', + replacementSource: 'react-native/setup-env', + }, + }, + ], + output: "require('react-native/setup-env');", + }, ], }); diff --git a/packages/eslint-plugin-react-native/no-deep-imports.js b/packages/eslint-plugin-react-native/no-deep-imports.js index 6446c70ba6ac..e84a1f24d45b 100644 --- a/packages/eslint-plugin-react-native/no-deep-imports.js +++ b/packages/eslint-plugin-react-native/no-deep-imports.js @@ -21,6 +21,8 @@ module.exports = { messages: { deepImport: "'{{importPath}}' React Native deep imports are deprecated. Please use the top level import instead.", + useReplacementSource: + "'{{importPath}}' is deprecated. Please import '{{replacementSource}}' instead.", }, schema: [], fixable: 'code', @@ -31,12 +33,14 @@ module.exports = { ImportDeclaration(node) { if ( !isDeepReactNativeImport(node.source) || - isInitializeCoreImport(node.source) || isSecondaryEntryPoint(node.source) || isFbInternalImport(node.source) ) { return; } + if (reportReplacementSource(node.source)) { + return; + } if (isDefaultImport(node)) { const reactNativeSource = node.source.value.slice( 'react-native/'.length, @@ -88,13 +92,16 @@ module.exports = { CallExpression(node) { if ( !isDeepRequire(node) || - isInitializeCoreImport(node.arguments[0]) || isSecondaryEntryPoint(node.arguments[0]) || isFbInternalImport(node.arguments[0]) ) { return; } + if (reportReplacementSource(node.arguments[0])) { + return; + } + const parent = node.parent; const importPath = node.arguments[0].value; @@ -123,6 +130,26 @@ module.exports = { }, }; + function reportReplacementSource(source) { + const reactNativeSource = source.value.slice('react-native/'.length); + const mapping = publicAPIMapping[reactNativeSource]; + if (!mapping || !mapping.replacementSource) { + return false; + } + context.report({ + node: source, + messageId: 'useReplacementSource', + data: { + importPath: source.value, + replacementSource: mapping.replacementSource, + }, + fix(fixer) { + return fixer.replaceText(source, `'${mapping.replacementSource}'`); + }, + }); + return true; + } + function getStandardReport(source) { return { node: source, @@ -167,20 +194,15 @@ module.exports = { return parts.length > 1 && parts[0] === 'react-native'; } - function isInitializeCoreImport(source) { - if (source.type !== 'Literal' || typeof source.value !== 'string') { - return false; - } - - return source.value === 'react-native/Libraries/Core/InitializeCore'; - } - function isSecondaryEntryPoint(source) { if (source.type !== 'Literal' || typeof source.value !== 'string') { return false; } - return source.value === 'react-native/asset-registry'; + return ( + source.value === 'react-native/asset-registry' || + source.value === 'react-native/setup-env' + ); } function isFbInternalImport(source) { diff --git a/packages/eslint-plugin-react-native/utils.js b/packages/eslint-plugin-react-native/utils.js index 98d1e90ca0b0..728cc26d6ab3 100644 --- a/packages/eslint-plugin-react-native/utils.js +++ b/packages/eslint-plugin-react-native/utils.js @@ -37,6 +37,13 @@ const publicAPIMapping = { default: 'experimental_LayoutConformance', types: ['LayoutConformanceProps'], }, + 'Libraries/Core/InitializeCore': { + // `InitializeCore` has no public named export; the deep import must be + // swapped for the `react-native/setup-env` entry point entirely. + default: null, + types: null, + replacementSource: 'react-native/setup-env', + }, 'Libraries/Lists/FlatList': { default: 'FlatList', types: ['FlatListProps'], diff --git a/packages/jest-preset/README.md b/packages/jest-preset/README.md index de420553b828..c53da96056d0 100644 --- a/packages/jest-preset/README.md +++ b/packages/jest-preset/README.md @@ -36,9 +36,3 @@ module.exports = { ``` You can further customize your Jest configuration by specifying other options. See [Jest's `jest.config.js` documentation](https://jestjs.io/docs/configuration) to learn more. - -### Migration Note - -This Jest preset used to be part of the core `react-native` package and accessible at `react-native/jest-preset.js`. As long as `@react-native/jest-preset` is installed, `react-native/jest-preset.js` will be aliased to this package and continue to work but is deprecated. - -Follow the installation instructions above to migrate to `@react-native/jest-preset` and change `preset: 'react-native'` to `preset: '@react-native/jest-preset` to migrate. diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index 643e860e8a21..fb819c04c701 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -18,6 +18,8 @@ module.exports = { platforms: ['android', 'ios', 'macos', 'native'], // [macOS] }, moduleNameMapper: { + // Resolve secondary entry points before the broad fork mapper. // [macOS] + '^react-native/setup-env$': `${path.dirname(require.resolve('react-native-macos'))}/src/setup-env.js`, // [macOS] '^react-native($|/.*)': `${path.dirname( require.resolve('react-native-macos'), // [macOS] )}/$1`, diff --git a/packages/jest-preset/jest/setup.js b/packages/jest-preset/jest/setup.js index 29f86698a129..483f14cad8f3 100644 --- a/packages/jest-preset/jest/setup.js +++ b/packages/jest-preset/jest/setup.js @@ -133,6 +133,7 @@ mock( 'm#react-native/Libraries/Core/InitializeCore', 'm#./mocks/InitializeCore', ); +mock('m#react-native/setup-env', 'm#./mocks/InitializeCore'); mock('m#react-native/Libraries/Core/NativeExceptionsManager'); mock('m#react-native/Libraries/Image/Image', 'm#./mocks/Image'); mock( diff --git a/packages/metro-config/package.json b/packages/metro-config/package.json index a98e14e6c91b..c67db840623e 100644 --- a/packages/metro-config/package.json +++ b/packages/metro-config/package.json @@ -37,8 +37,8 @@ "dependencies": { "@react-native/js-polyfills": "workspace:*", "@react-native/metro-babel-transformer": "workspace:*", - "metro-config": "^0.86.0", - "metro-runtime": "^0.86.0" + "metro-config": "^0.87.0", + "metro-runtime": "^0.87.0" }, "private": true } diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index 03a9aed5ace4..f02b498d5ed1 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -59,9 +59,11 @@ export function getDefaultConfig(projectRoot: string): ConfigT { unstable_conditionNames: ['react-native'], }, serializer: { - // Note: This option is overridden in cli-plugin-metro (getOverrideConfig) + // NOTE: Overridden in community-cli-plugin getModulesRunBeforeMainModule: () => [ - require.resolve('react-native/Libraries/Core/InitializeCore'), + require.resolve('react-native-macos/setup-env', { + paths: [projectRoot], + }), // [macOS] ], getPolyfills: () => require('@react-native/js-polyfills')(), isThirdPartyModule({path: modulePath}: Readonly<{path: string, ...}>) { diff --git a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js index 820fc9c59eae..66f247d7112a 100644 --- a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js +++ b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js @@ -82,17 +82,3 @@ test('import from other package', () => { `"import { foo } from 'react-native-foo';"`, ); }); - -test('import react-native/Libraries/Core/InitializeCore', () => { - const code = ` - import 'react-native/Libraries/Core/InitializeCore'; - require('react-native/Libraries/Core/InitializeCore'); - export * from 'react-native/Libraries/Core/InitializeCore'; - `; - - expect(transform(code, [rnDeepImportsWarningPlugin])).toMatchInlineSnapshot(` - "import 'react-native/Libraries/Core/InitializeCore'; - require('react-native/Libraries/Core/InitializeCore'); - export * from 'react-native/Libraries/Core/InitializeCore';" - `); -}); diff --git a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js index 98f317586fa4..5e98ed59ad74 100644 --- a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js +++ b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js @@ -38,10 +38,6 @@ function isDeepReactNativeImport(source) { return parts.length > 1 && parts[0] === 'react-native'; } -function isInitializeCoreImport(source) { - return source === 'react-native/Libraries/Core/InitializeCore'; -} - function withLocation(node, loc) { if (!node.loc) { return {...node, loc}; @@ -55,7 +51,7 @@ module.exports = ({types: t}) => ({ ImportDeclaration(path, state) { const source = path.node.source.value; - if (isDeepReactNativeImport(source) && !isInitializeCoreImport(source)) { + if (isDeepReactNativeImport(source)) { const loc = path.node.loc; state.import.push({source, loc}); } @@ -71,10 +67,7 @@ module.exports = ({types: t}) => ({ ) { const source = args[0].node.type === 'StringLiteral' ? args[0].node.value : ''; - if ( - isDeepReactNativeImport(source) && - !isInitializeCoreImport(source) - ) { + if (isDeepReactNativeImport(source)) { const loc = path.node.loc; state.require.push({source, loc}); } @@ -83,11 +76,7 @@ module.exports = ({types: t}) => ({ ExportNamedDeclaration(path, state) { const source = path.node.source; - if ( - source && - isDeepReactNativeImport(source.value) && - !isInitializeCoreImport(source) - ) { + if (source && isDeepReactNativeImport(source.value)) { const loc = path.node.loc; state.export.push({source: source.value, loc}); } diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index ee12d8a8431a..83447ac43d17 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -452,7 +452,7 @@ export type TextInputIOSProps = Readonly<{ }>; // [macOS -type TextInputMacOSProps = Readonly<{ +export type TextInputMacOSProps = Readonly<{ /** * If `true`, clears the text field synchronously before `onSubmitEditing` is emitted. * diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 0d8b7bdc48c6..c82903cc5feb 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js @@ -584,6 +584,15 @@ type MacOSViewProps = Readonly<{ */ inverted?: ?boolean, }>; + +// Public macOS subset, including events shared with other platforms. +export type ViewPropsMacOS = Readonly<{ + ...MacOSViewProps, + ...MouseEventProps, + ...KeyboardEventProps, + onAuxClick?: ?(event: PointerEvent) => void, + onAuxClickCapture?: ?(event: PointerEvent) => void, +}>; // macOS] type ViewBaseProps = Readonly<{ diff --git a/packages/react-native/Libraries/Core/InitializeCore.js b/packages/react-native/Libraries/Core/InitializeCore.js index c3ba83318376..a53aedfe8991 100644 --- a/packages/react-native/Libraries/Core/InitializeCore.js +++ b/packages/react-native/Libraries/Core/InitializeCore.js @@ -22,8 +22,16 @@ * 1. Require system. * 2. Bridged modules. * + * @deprecated Since 0.87. Use `'react-native/setup-env'` instead. */ 'use strict'; -require('../../src/private/setup/setUpDefaultReactNativeEnvironment').default(); +// NOTE: This delegates to the `'react-native/setup-env'` entry point (rather +// than calling `setUpDefaultReactNativeEnvironment` directly) so that +// `src/setup-env.js` is pulled into the module graph. Metro's +// `getModulesRunBeforeMainModule` only runs modules that are already part of +// the bundle, and `InitializeCore` is a guaranteed graph entry (via +// `ReactNativePrivateInitializeCore`). This keeps `'react-native/setup-env'` +// reachable so it runs before the main module. +require('../../src/setup-env'); diff --git a/packages/react-native/Libraries/Core/setUpGlobals.js b/packages/react-native/Libraries/Core/setUpGlobals.js index 74d8786131dd..0418fc0246ad 100644 --- a/packages/react-native/Libraries/Core/setUpGlobals.js +++ b/packages/react-native/Libraries/Core/setUpGlobals.js @@ -15,14 +15,19 @@ * Sets up global variables for React Native. * You can use this module directly, or just require InitializeCore. */ -if (global.window === undefined) { +if (globalThis.window === undefined) { // $FlowExpectedError[cannot-write] The global isn't writable anywhere but here, where we define it. - global.window = global; + Object.defineProperty(globalThis, 'window', { + value: globalThis, + configurable: false, + enumerable: true, + writable: false, + }); } -if (global.self === undefined) { +if (globalThis.self === undefined) { // $FlowExpectedError[cannot-write] The global isn't writable anywhere but here, where we define it. - global.self = global; + globalThis.self = globalThis; } // Set up process diff --git a/packages/react-native/Libraries/Core/setUpNavigator.js b/packages/react-native/Libraries/Core/setUpNavigator.js index bcf2cbd597b5..1917abd5202c 100644 --- a/packages/react-native/Libraries/Core/setUpNavigator.js +++ b/packages/react-native/Libraries/Core/setUpNavigator.js @@ -15,7 +15,12 @@ const {polyfillObjectProperty} = require('../Utilities/PolyfillFunctions'); const navigator = global.navigator; if (navigator === undefined) { // $FlowExpectedError[cannot-write] The global isn't writable anywhere but here, where we define it. - global.navigator = {product: 'ReactNative'}; + Object.defineProperty(global, 'navigator', { + value: {product: 'ReactNative'}, + configurable: true, + enumerable: true, + writable: false, + }); } else { // see https://github.com/facebook/react-native/issues/10881 polyfillObjectProperty(navigator, 'product', () => 'ReactNative'); diff --git a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js index 190d559a7117..2cf1eca02e95 100644 --- a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js +++ b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js @@ -8,124 +8,13 @@ * @format */ -import typeof dispatchNativeEvent from '../../src/private/renderer/events/dispatchNativeEvent'; -import typeof CustomEvent from '../../src/private/webapis/dom/events/CustomEvent'; -import typeof BatchedBridge from '../BatchedBridge/BatchedBridge'; -import typeof legacySendAccessibilityEvent from '../Components/AccessibilityInfo/legacySendAccessibilityEvent'; -import typeof TextInputState from '../Components/TextInput/TextInputState'; -import typeof ExceptionsManager from '../Core/ExceptionsManager'; -import typeof RawEventEmitter from '../Core/RawEventEmitter'; -import typeof ReactFiberErrorDialog from '../Core/ReactFiberErrorDialog'; -import typeof RCTEventEmitter from '../EventEmitter/RCTEventEmitter'; -import typeof { - createPublicInstance, - createPublicRootInstance, - createPublicTextInstance, - getInternalInstanceHandleFromPublicInstance, - getNativeTagFromPublicInstance, - getNodeFromPublicInstance, -} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; -import typeof { - create as createAttributePayload, - diff as diffAttributePayloads, -} from '../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; -import typeof UIManager from '../ReactNative/UIManager'; -import typeof * as ReactNativeViewConfigRegistry from '../Renderer/shims/ReactNativeViewConfigRegistry'; -import typeof flattenStyle from '../StyleSheet/flattenStyle'; -import type {DangerouslyImpreciseStyleProp} from '../StyleSheet/StyleSheet'; -import typeof deepFreezeAndThrowOnMutationInDev from '../Utilities/deepFreezeAndThrowOnMutationInDev'; -import typeof deepDiffer from '../Utilities/differ/deepDiffer'; -import typeof Platform from '../Utilities/Platform'; +import typeof {createPublicTextInstance} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; export type {PublicRootInstance} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; export type PublicTextInstance = ReturnType; -// flowlint unsafe-getters-setters:off +/** + * @deprecated Since 0.88. Use 'react-native/react-private-interface' instead. + */ // eslint-disable-next-line @react-native/monorepo/no-commonjs-exports -module.exports = { - get BatchedBridge(): BatchedBridge { - return require('../BatchedBridge/BatchedBridge').default; - }, - get ExceptionsManager(): ExceptionsManager { - return require('../Core/ExceptionsManager').default; - }, - get Platform(): Platform { - return require('../Utilities/Platform').default; - }, - get RCTEventEmitter(): RCTEventEmitter { - return require('../EventEmitter/RCTEventEmitter').default; - }, - get ReactNativeViewConfigRegistry(): ReactNativeViewConfigRegistry { - return require('../Renderer/shims/ReactNativeViewConfigRegistry'); - }, - get TextInputState(): TextInputState { - return require('../Components/TextInput/TextInputState').default; - }, - get UIManager(): UIManager { - return require('../ReactNative/UIManager').default; - }, - // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` - get deepDiffer(): deepDiffer { - return require('../Utilities/differ/deepDiffer').default; - }, - get deepFreezeAndThrowOnMutationInDev(): deepFreezeAndThrowOnMutationInDev< - {...} | Array, - > { - return require('../Utilities/deepFreezeAndThrowOnMutationInDev').default; - }, - // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` - get flattenStyle(): flattenStyle { - // $FlowFixMe[underconstrained-implicit-instantiation] - // $FlowFixMe[incompatible-type] - return require('../StyleSheet/flattenStyle').default; - }, - get ReactFiberErrorDialog(): ReactFiberErrorDialog { - return require('../Core/ReactFiberErrorDialog').default; - }, - get legacySendAccessibilityEvent(): legacySendAccessibilityEvent { - return require('../Components/AccessibilityInfo/legacySendAccessibilityEvent') - .default; - }, - get RawEventEmitter(): RawEventEmitter { - return require('../Core/RawEventEmitter').default; - }, - get CustomEvent(): CustomEvent { - return require('../../src/private/webapis/dom/events/CustomEvent').default; - }, - get createAttributePayload(): createAttributePayload { - return require('../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') - .create; - }, - get diffAttributePayloads(): diffAttributePayloads { - return require('../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') - .diff; - }, - get createPublicRootInstance(): createPublicRootInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .createPublicRootInstance; - }, - get createPublicInstance(): createPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .createPublicInstance; - }, - get createPublicTextInstance(): createPublicTextInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .createPublicTextInstance; - }, - get getNativeTagFromPublicInstance(): getNativeTagFromPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .getNativeTagFromPublicInstance; - }, - get getNodeFromPublicInstance(): getNodeFromPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .getNodeFromPublicInstance; - }, - get getInternalInstanceHandleFromPublicInstance(): getInternalInstanceHandleFromPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .getInternalInstanceHandleFromPublicInstance; - }, - get dispatchNativeEvent(): dispatchNativeEvent { - return require('../../src/private/renderer/events/dispatchNativeEvent') - .default; - }, -}; +module.exports = require('../../src/react-private-interface'); diff --git a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow index da6972e835df..c9b1e0e77c8b 100644 --- a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow +++ b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow @@ -8,34 +8,8 @@ * @format */ -import typeof {createPublicTextInstance as createPublicTextInstanceT} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; - -export type {PublicRootInstance} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; -export type PublicTextInstance = ReturnType; - -export {default as BatchedBridge} from '../BatchedBridge/BatchedBridge'; -export {default as ExceptionsManager} from '../Core/ExceptionsManager'; -export {default as Platform} from '../Utilities/Platform'; -export {default as RCTEventEmitter} from '../EventEmitter/RCTEventEmitter'; -export * as ReactNativeViewConfigRegistry from '../Renderer/shims/ReactNativeViewConfigRegistry'; -export {default as TextInputState} from '../Components/TextInput/TextInputState'; -export {default as UIManager} from '../ReactNative/UIManager'; -export {default as deepDiffer} from '../Utilities/differ/deepDiffer'; -export {default as deepFreezeAndThrowOnMutationInDev} from '../Utilities/deepFreezeAndThrowOnMutationInDev'; -export {default as flattenStyle} from '../StyleSheet/flattenStyle'; -export {default as ReactFiberErrorDialog} from '../Core/ReactFiberErrorDialog'; -export {default as legacySendAccessibilityEvent} from '../Components/AccessibilityInfo/legacySendAccessibilityEvent'; -export {default as RawEventEmitter} from '../Core/RawEventEmitter'; -export {default as CustomEvent} from '../../src/private/webapis/dom/events/CustomEvent'; -export { - create as createAttributePayload, - diff as diffAttributePayloads, -} from '../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; -export { - createPublicRootInstance, - createPublicInstance, - createPublicTextInstance, - getNativeTagFromPublicInstance, - getNodeFromPublicInstance, - getInternalInstanceHandleFromPublicInstance, -} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +/** + * @deprecated Since 0.88. Use 'react-native/react-private-interface' instead. + */ +export type * from '../../src/react-private-interface'; +export * from '../../src/react-private-interface'; diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 1cf2055b1644..ffeb4fae3124 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<8ce0ca3b145c5d282f4acfd2e92ce78d>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -27,7 +27,7 @@ import { ListRenderItemInfo } from "@react-native-macos/virtualized-lists" import { ListViewToken } from "@react-native-macos/virtualized-lists" import type { PublicRootInstance as PublicRootInstance_2 } from "react-native" import type { PublicTextInstance as PublicTextInstance_2 } from "react-native" -import * as React from "react" +import * as React_2 from "react" import { ScrollToLocationParamsType } from "@react-native-macos/virtualized-lists" import { SectionBase } from "@react-native-macos/virtualized-lists" import type { SectionData as SectionListData } from "@react-native-macos/virtualized-lists" @@ -37,15 +37,15 @@ import VirtualizedLists from "@react-native-macos/virtualized-lists" import { VirtualizedSectionListProps } from "@react-native-macos/virtualized-lists" declare const $$AnimatedFlatList: ( props: Omit>, "ref"> & { - ref?: React.Ref> + ref?: React_2.Ref> }, -) => React.ReactNode +) => React_2.ReactNode declare const $$AnimatedImage: AnimatedComponentType< - React.JSX.LibraryManagedAttributes< + React_2.JSX.LibraryManagedAttributes< typeof Image, - React.ComponentProps + React_2.ComponentProps >, - React.ComponentRef + React_2.ComponentRef > declare const $$AnimatedImplementation: { add: typeof addImpl @@ -77,16 +77,16 @@ declare const $$AnimatedImplementation: { declare const $$AnimatedScrollView: typeof AnimatedScrollView_default declare const $$AnimatedSectionList: ( props: Omit>, "ref"> & { - ref?: React.Ref> + ref?: React_2.Ref> }, -) => React.ReactNode +) => React_2.ReactNode declare const $$AnimatedText: AnimatedComponentType< TextProps, - React.ComponentRef + React_2.ComponentRef > declare const $$AnimatedView: AnimatedComponentType< ViewProps, - React.ComponentRef + React_2.ComponentRef > declare const $$flattenStyle: typeof flattenStyle_default declare const $$NativeDeviceInfo: typeof NativeDeviceInfo_default @@ -143,9 +143,9 @@ declare const ActionSheetIOS_default: { declare const ActivityIndicator: typeof ActivityIndicator_default declare const ActivityIndicator_default: ( props: ActivityIndicatorProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const add: typeof $$AnimatedImplementation.add declare const addImpl: ( a: AnimatedNode_default | number, @@ -167,9 +167,9 @@ declare const BackHandler_default: TBackHandler declare const Button: typeof Button_default declare const Button_default: ( props: ButtonProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const Clipboard: { getString(): Promise setString(content: string): void @@ -178,7 +178,7 @@ declare const codegenNativeCommands: typeof codegenNativeCommands_default declare const codegenNativeComponent: typeof codegenNativeComponent_default declare const ColorWithSystemEffectMacOS: ( color: ColorValue, - effect: SystemEffectMacOS, + effect: SystemEffectMacOS_2, ) => ColorValue declare const compose: typeof composeStyles_default declare const create: ( @@ -258,7 +258,7 @@ declare const I18nManager: { } declare const Image: ImageType declare const InputAccessoryView: typeof InputAccessoryView_default -declare const InputAccessoryView_default: React.ComponentType +declare const InputAccessoryView_default: React_2.ComponentType declare const Keyboard: typeof Keyboard_default declare const Keyboard_default: KeyboardImpl declare const LayoutAnimation: typeof LayoutAnimation_default @@ -327,21 +327,21 @@ declare const Presets: { } declare const Pressable: ( props: PressableProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const processColor: typeof processColor_default declare const ProgressBarAndroid: typeof ProgressBarAndroid_default declare let ProgressBarAndroid_default: ( props: Omit_2< Omit_2, keyof { - ref?: React.Ref + ref?: React_2.Ref } > & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const RCTNativeAppEventEmitter_default: typeof DeviceEventEmitter declare const RCTNetworking_default: { abortRequest(requestId: number): void @@ -372,20 +372,20 @@ declare const requireNativeComponent: typeof requireNativeComponent_default declare const requireNativeComponent_default: ( uiViewClassName: string, ) => HostComponent -declare const RootTagContext: React.Context +declare const RootTagContext: React_2.Context declare const SafeAreaView: typeof SafeAreaView_default declare const SafeAreaView_default: ( props: ViewProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const ScrollView: typeof ScrollViewWrapper & ScrollViewComponentStatics -declare const ScrollViewContext_default: React.Context +declare const ScrollViewContext_default: React_2.Context declare const ScrollViewWrapper: ( props: ScrollViewProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const sequence: typeof $$AnimatedImplementation.sequence declare const sequenceImpl: ( animations: Array, @@ -414,16 +414,6 @@ declare const staggerImpl: ( time: number, animations: Array, ) => CompositeAnimation -declare const States: { - ERROR: "ERROR" - NOT_RESPONDER: "NOT_RESPONDER" - RESPONDER_ACTIVE_LONG_PRESS_IN: "RESPONDER_ACTIVE_LONG_PRESS_IN" - RESPONDER_ACTIVE_LONG_PRESS_OUT: "RESPONDER_ACTIVE_LONG_PRESS_OUT" - RESPONDER_ACTIVE_PRESS_IN: "RESPONDER_ACTIVE_PRESS_IN" - RESPONDER_ACTIVE_PRESS_OUT: "RESPONDER_ACTIVE_PRESS_OUT" - RESPONDER_INACTIVE_PRESS_IN: "RESPONDER_INACTIVE_PRESS_IN" - RESPONDER_INACTIVE_PRESS_OUT: "RESPONDER_INACTIVE_PRESS_OUT" -} declare const subtract: typeof $$AnimatedImplementation.subtract declare const subtractImpl: ( a: AnimatedNode_default | number, @@ -432,15 +422,15 @@ declare const subtractImpl: ( declare const Switch: typeof Switch_default declare const Switch_default: ( props: SwitchProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode -declare const Text: typeof TextImpl_default +) => React_2.ReactNode +declare const Text_2: typeof TextImpl_default declare const TextImpl_default: ( props: TextProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const TextInput: TextInputType declare const timing: typeof $$AnimatedImplementation.timing declare const timingImpl: ( @@ -467,45 +457,17 @@ declare const ToastAndroid_default: { yOffset: number, ) => void } -declare const Touchable: typeof TouchableImpl_default declare const Touchable_default: ( props: TouchableOpacityProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare const TouchableHighlight: typeof TouchableHighlight_default declare const TouchableHighlight_default: ( props: Readonly> & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode -declare const TouchableImpl_default: { - Mixin: typeof TouchableMixinImpl - renderDebugView: ($$PARAM_0$$: { - color: ColorValue - hitSlop?: EdgeInsetsProp - }) => null | React.ReactNode -} -declare const TouchableMixinImpl: { - withoutDefaultFocusAndBlur: {} - componentDidMount: () => void - componentWillUnmount: () => void - touchableGetInitialState: () => { - touchable: { - responderID: GestureResponderEvent["currentTarget"] | undefined - touchState: TouchableState | undefined - } - } - touchableHandleBlur: (e: BlurEvent) => void - touchableHandleFocus: (e: FocusEvent) => void - touchableHandleResponderGrant: (e: GestureResponderEvent) => void - touchableHandleResponderMove: (e: GestureResponderEvent) => void - touchableHandleResponderRelease: (e: GestureResponderEvent) => void - touchableHandleResponderTerminate: (e: GestureResponderEvent) => void - touchableHandleResponderTerminationRequest: () => any - touchableHandleStartShouldSetResponder: () => any - touchableLongPressCancelsPress: () => boolean -} +) => React_2.ReactNode declare const TouchableOpacity: typeof Touchable_default declare const UIManager: typeof UIManager_default declare const UIManager_default: UIManagerJSInterface @@ -999,14 +961,14 @@ declare type AbsoluteFillStyle = { } declare type AbstractImageAndroid = ( props: ImageProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare type AbstractImageIOS = ( props: ImageProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare type AccessibilityActionEvent = NativeSyntheticEvent<{ readonly actionName: string }> @@ -1386,9 +1348,9 @@ declare type AnimatedColorConfig = Readonly< > declare type AnimatedComponentType = ( props: Omit, "ref"> & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare class AnimatedDiffClamp_default extends AnimatedWithChildren_default { constructor( a: AnimatedNode_default, @@ -1476,7 +1438,9 @@ declare type AnimatedProps = LooseOmit< "ref" > & PassThroughProps -declare type AnimatedScrollViewInstance = React.ComponentRef +declare type AnimatedScrollViewInstance = React_2.ComponentRef< + typeof ScrollView +> declare class AnimatedSubtraction_default extends AnimatedWithChildren_default { constructor( a: AnimatedNode_default | number, @@ -1662,6 +1626,10 @@ declare interface ArrayLike_2 extends Iterable { [indexer: number]: T readonly length: number } +declare interface ArrayLike_3 extends Iterable { + [indexer: number]: T + readonly length: number +} declare type AssetDestPathResolver = "android" | "generic" declare type AssetRegistry = typeof AssetRegistry declare type attachNativeEvent = typeof attachNativeEvent @@ -1698,7 +1666,7 @@ declare function beginAsyncEvent(eventName: EventName, args?: EventArgs): number declare function beginEvent(eventName: EventName, args?: EventArgs): void declare class Blob_default { close(): void - constructor(parts?: Array, options?: BlobOptions) + constructor(parts?: Array, options?: BlobOptions_2) set data(data: BlobData | null | undefined) get data(): BlobData get size(): number @@ -1713,7 +1681,7 @@ declare type BlobData = { size: number type?: string } -declare type BlobOptions = { +declare type BlobOptions_2 = { lastModified: number type: string } @@ -1734,7 +1702,7 @@ declare type Builtin = ( ...$$REST$$: ReadonlyArray ) => Date | Error | RegExp | unknown declare type Button = typeof Button -declare type ButtonInstance = React.ComponentRef +declare type ButtonInstance = React_2.ComponentRef declare interface ButtonProps { readonly accessibilityActions?: ReadonlyArray readonly accessibilityHint?: string @@ -1764,7 +1732,7 @@ declare interface ButtonProps { readonly nextFocusUp?: number readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onBlur?: (e: BlurEvent) => void - readonly onFocus?: (e: FocusEvent) => void + readonly onFocus?: (e: FocusEvent_2) => void readonly testID?: string readonly title: string readonly tooltip?: string @@ -1802,11 +1770,11 @@ declare type ColorSchemeName = "dark" | "light" declare type ColorSchemeOverride = "auto" | "dark" | "light" | "unspecified" declare type ColorValue = ____ColorValue_Internal declare type ColorWithSystemEffectMacOS = typeof ColorWithSystemEffectMacOS -declare type ComponentProvider = () => React.ComponentType +declare type ComponentProvider = () => React_2.ComponentType declare type ComponentProviderInstrumentationHook = ( component_: ComponentProvider, scopedPerformanceLogger: IPerformanceLogger, -) => React.ComponentType +) => React_2.ComponentType declare type compose = typeof compose declare function composeStyles_default( style1: null | U | undefined, @@ -1827,17 +1795,17 @@ declare function counterEvent(eventName: EventName, value: number): void declare type create = typeof create declare type createAnimatedComponent = typeof createAnimatedComponent declare function createAnimatedComponent_default< - TInstance extends React.ComponentType, + TInstance extends React_2.ComponentType, >( Component: TInstance, ): AnimatedComponentType< Readonly< - React.JSX.LibraryManagedAttributes< + React_2.JSX.LibraryManagedAttributes< TInstance, - React.ComponentProps + React_2.ComponentProps > >, - React.ComponentRef + React_2.ComponentRef > declare function createLayoutAnimation( duration: number, @@ -1906,11 +1874,16 @@ declare type DataDetectorTypesType = | "spelling" | "trackingNumber" | "transitInformation" -declare type DataTransfer = { +declare type DataTransfer_2 = { readonly files: ReadonlyArray readonly items: ReadonlyArray readonly types: ReadonlyArray } +declare interface DataTransfer_3 { + files: ReadonlyArray + items: ReadonlyArray + types: ReadonlyArray +} declare type DataTransferFile = { readonly height?: number readonly name: string @@ -1919,10 +1892,22 @@ declare type DataTransferFile = { readonly uri: string readonly width?: number } +declare interface DataTransferFile_2 { + height?: number + name: string + size?: number + type: null | string | undefined + uri: string + width?: number +} declare type DataTransferItem = { readonly kind: string readonly type: string | undefined } +declare interface DataTransferItem_2 { + kind: string + type: null | string | undefined +} declare type decay = typeof decay declare type DecayAnimationConfig = Readonly< AnimationConfig & { @@ -2009,7 +1994,7 @@ declare type DisplayModeType = symbol & { } declare type divide = typeof divide declare class DOMRect_default extends DOMRectReadOnly_default { - static fromRect(rect?: DOMRectInit | null | undefined): DOMRect_default + static fromRect(rect?: DOMRectInit_2 | null | undefined): DOMRect_default get height(): number set height(height: null | number | undefined) get width(): number @@ -2019,7 +2004,7 @@ declare class DOMRect_default extends DOMRectReadOnly_default { get y(): number set y(y: null | number | undefined) } -declare interface DOMRectInit { +declare interface DOMRectInit_2 { height?: number width?: number x?: number @@ -2034,7 +2019,7 @@ declare class DOMRectReadOnly_default { height: null | number | undefined, ) static fromRect( - rect?: DOMRectInit | null | undefined, + rect?: DOMRectInit_2 | null | undefined, ): DOMRectReadOnly_default get height(): number get left(): number @@ -2055,10 +2040,15 @@ declare class DOMRectReadOnly_default { get y(): number } declare type Double = number -declare type DragEvent = NativeSyntheticEvent<{ +declare interface DragEvent_2 extends MouseEvent_3 { + nativeEvent: NativeMouseEvent_2 & { + dataTransfer?: DataTransfer_3 + } +} +declare type DragEvent_3 = NativeSyntheticEvent<{ readonly clientX: number readonly clientY: number - readonly dataTransfer?: DataTransfer + readonly dataTransfer?: DataTransfer_2 readonly pageX: number readonly pageY: number readonly timestamp: number @@ -2067,7 +2057,7 @@ declare type DraggedType = "fileUrl" | "image" | "string" declare type DraggedTypesType = DraggedType | ReadonlyArray declare type DrawerLayoutAndroid = typeof DrawerLayoutAndroid declare class DrawerLayoutAndroid_default - extends React.Component + extends React_2.Component implements DrawerLayoutAndroidMethods { blur(): void @@ -2081,7 +2071,7 @@ declare class DrawerLayoutAndroid_default onFail?: () => void, ): void openDrawer(): void - render(): React.ReactNode + render(): React_2.ReactNode setNativeProps(nativeProps: Object): void } declare type DrawerLayoutAndroidInstance = DrawerLayoutAndroidMethods @@ -2111,7 +2101,7 @@ declare type DrawerLayoutAndroidProps = Readonly< onDrawerSlide?: (event: DrawerSlideEvent) => unknown onDrawerStateChanged?: (state: DrawerStates) => unknown statusBarBackgroundColor?: ColorValue - renderNavigationView: () => React.JSX.Element + renderNavigationView: () => React_2.JSX.Element } > declare type DrawerLayoutAndroidState = { @@ -2172,7 +2162,7 @@ declare type EnterKeyHintTypeOptions = | EnterKeyHintTypeAndroid | EnterKeyHintTypeIOS declare type ErrorHandler = (error: unknown, isFatal: boolean) => void -declare type ErrorUtils = { +declare type ErrorUtils_2 = { applyWithGuard, TOut>( fun: Fn, context?: unknown, @@ -2291,7 +2281,7 @@ declare type FilterFunction = sepia: number | string } declare type findNodeHandle = typeof findNodeHandle -declare class FlatList extends React.PureComponent< +declare class FlatList extends React_2.PureComponent< FlatListProps > { componentDidUpdate(prevProps: FlatListProps): void @@ -2301,7 +2291,7 @@ declare class FlatList extends React.PureComponent< getScrollableNode(): any getScrollResponder(): null | ScrollResponderType | undefined recordInteraction(): void - render(): React.ReactNode + render(): React_2.ReactNode scrollToEnd( params?: | null @@ -2375,12 +2365,12 @@ declare type Float = number declare type Fn, Return> = ( ...$$REST$$: Args ) => Return -declare type FocusEvent = NativeSyntheticEvent +declare type FocusEvent_2 = NativeSyntheticEvent declare type FocusEventProps = { readonly onBlur?: (event: BlurEvent) => void readonly onBlurCapture?: (event: BlurEvent) => void - readonly onFocus?: (event: FocusEvent) => void - readonly onFocusCapture?: (event: FocusEvent) => void + readonly onFocus?: (event: FocusEvent_2) => void + readonly onFocusCapture?: (event: FocusEvent_2) => void } declare type FontVariant = ____FontVariant_Internal declare type forkEvent = typeof forkEvent @@ -2389,23 +2379,23 @@ declare function forkEventImpl( listener: Function, ): AnimatedEvent | Function declare class FormData_default { - append(key: string, value: FormDataValue): void + append(key: string, value: FormDataValue_2): void constructor() - getAll(key: string): Array - getParts(): Array + getAll(key: string): Array + getParts(): Array } -declare type FormDataPart = +declare type FormDataPart_2 = | { - headers: Headers + headers: Headers_2 name?: string type?: string uri: string } | { - headers: Headers + headers: Headers_2 string: string } -declare type FormDataValue = +declare type FormDataValue_2 = | string | { name?: string @@ -2462,7 +2452,7 @@ declare function getSections(): Runnables declare function getWithFallback_DEPRECATED( name: string, viewConfigProvider: () => PartialViewConfig, -): React.ComponentType +): React_2.ComponentType declare type hairlineWidth = typeof hairlineWidth declare type HandledKeyEvent = { readonly altKey?: boolean @@ -2471,20 +2461,35 @@ declare type HandledKeyEvent = { readonly metaKey?: boolean readonly shiftKey?: boolean } +declare type HandledKeyEvent_2 = { + altKey?: boolean + ctrlKey?: boolean + key: string + metaKey?: boolean + shiftKey?: boolean +} declare interface HardwareBackPressEvent { readonly timeStamp: number readonly type: string } -declare type Headers = { +declare type Headers_2 = { [name: string]: string } declare type HeadlessTask = (taskData: any) => Promise declare type HostComponent = ( props: Omit & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare type HostInstance = ReactNativeElement_default +declare type HostInstance_3 = ReactNativeElement +declare interface HTMLCollection extends Iterable, ArrayLike_3 { + [index: number]: T + readonly length: number + item(index: number): null | T + namedItem(name: string): null | T + [Symbol.iterator](): Iterator +} declare class HTMLCollection_default implements Iterable, ArrayLike_2 { [index: number]: T readonly length: number @@ -2531,15 +2536,15 @@ declare interface ILogBox { } declare type Image = typeof Image declare type ImageAndroid = AbstractImageAndroid & ImageComponentStaticsAndroid -declare class ImageBackground extends React.Component { - render(): React.ReactNode +declare class ImageBackground extends React_2.Component { + render(): React_2.ReactNode setNativeProps(props: {}): void } declare type ImageBackgroundInstance = ImageBackground declare interface ImageBackgroundProps extends Readonly> { - readonly children?: React.ReactNode - readonly imageRef?: React.Ref> + readonly children?: React_2.ReactNode + readonly imageRef?: React_2.Ref> readonly imageStyle?: ImageStyleProp readonly style?: ViewStyleProp } @@ -2716,11 +2721,11 @@ declare type IndeterminateProgressBarAndroidStyleAttrProp = { | "SmallInverse" } declare type IndicatorSize = "large" | "small" | number -declare type InnerViewInstance = React.ComponentRef +declare type InnerViewInstance = React_2.ComponentRef declare type InputAccessoryView = typeof InputAccessoryView declare interface InputAccessoryViewProps { readonly backgroundColor?: ColorValue - readonly children: React.ReactNode + readonly children: React_2.ReactNode readonly nativeID?: string readonly style?: ViewStyleProp } @@ -2744,9 +2749,9 @@ declare type InternalInstanceHandle = symbol & { } declare type InternalTextInput = ( props: TextInputProps & { - ref?: React.Ref + ref?: React_2.Ref }, -) => React.ReactNode +) => React_2.ReactNode declare type InterpolationConfigSupportedOutputType = | NativeColorValue | number @@ -2834,12 +2839,12 @@ declare interface IPerformanceLogger { } declare function isEnabled(): boolean declare type Keyboard = typeof Keyboard -declare class KeyboardAvoidingView extends React.Component< +declare class KeyboardAvoidingView extends React_2.Component< KeyboardAvoidingViewProps, KeyboardAvoidingViewState > { viewRef: { - current: null | React.ComponentRef + current: null | React_2.ComponentRef } componentDidMount(): void componentDidUpdate( @@ -2848,7 +2853,7 @@ declare class KeyboardAvoidingView extends React.Component< ): void componentWillUnmount(): void constructor(props: KeyboardAvoidingViewProps) - render(): React.ReactNode + render(): React_2.ReactNode } declare type KeyboardAvoidingViewInstance = KeyboardAvoidingView declare interface KeyboardAvoidingViewProps extends Readonly { @@ -2860,14 +2865,14 @@ declare interface KeyboardAvoidingViewProps extends Readonly { declare type KeyboardAvoidingViewState = { bottom: number } -declare type KeyboardEvent = AndroidKeyboardEvent | IOSKeyboardEvent +declare type KeyboardEvent_2 = AndroidKeyboardEvent | IOSKeyboardEvent declare type KeyboardEventDefinitions = { - keyboardDidChangeFrame: [KeyboardEvent] - keyboardDidHide: [KeyboardEvent] - keyboardDidShow: [KeyboardEvent] - keyboardWillChangeFrame: [KeyboardEvent] - keyboardWillHide: [KeyboardEvent] - keyboardWillShow: [KeyboardEvent] + keyboardDidChangeFrame: [KeyboardEvent_2] + keyboardDidHide: [KeyboardEvent_2] + keyboardDidShow: [KeyboardEvent_2] + keyboardWillChangeFrame: [KeyboardEvent_2] + keyboardWillHide: [KeyboardEvent_2] + keyboardWillShow: [KeyboardEvent_2] } declare type KeyboardEventEasing = | "easeIn" @@ -2890,6 +2895,20 @@ declare type KeyboardEventPayload = { readonly repeat?: boolean readonly shiftKey: boolean } +declare interface KeyboardEventPayload_2 { + altKey: boolean + capsLockKey?: boolean + code?: string + ctrlKey: boolean + functionKey?: boolean + helpKey?: boolean + isComposing?: boolean + key: string + metaKey: boolean + numericPadKey?: boolean + repeat?: boolean + shiftKey: boolean +} declare type KeyboardEventProps = { readonly keyDownEvents?: ReadonlyArray readonly keyUpEvents?: ReadonlyArray @@ -2912,7 +2931,7 @@ declare class KeyboardImpl { removeAllListeners( eventType: K | null | undefined, ): void - scheduleLayoutAnimation(event: KeyboardEvent): void + scheduleLayoutAnimation(event: KeyboardEvent_2): void } declare type KeyboardMetrics = { readonly height: number @@ -2941,6 +2960,7 @@ declare type KeyboardTypeOptions = | KeyboardTypeAndroid | KeyboardTypeIOS declare type KeyDownEvent = NativeSyntheticEvent +declare type KeyDownEvent_2 = NativeSyntheticEvent_2 declare type KeyEvent = KeyDownEvent declare type KeyEventProps = { readonly onKeyDown?: (event: KeyDownEvent) => void @@ -2950,6 +2970,7 @@ declare type KeyEventProps = { } declare type KeysOfUnion = T extends any ? keyof T : never declare type KeyUpEvent = NativeSyntheticEvent +declare type KeyUpEvent_2 = NativeSyntheticEvent_2 declare type LayoutAnimation = typeof LayoutAnimation declare type LayoutAnimationAnim = { readonly delay?: number @@ -2987,7 +3008,7 @@ declare type LayoutChangeEvent = NativeSyntheticEvent<{ readonly layout: LayoutRectangle }> declare type LayoutConformanceProps = { - readonly children: React.ReactNode + readonly children: React_2.ReactNode readonly mode: "compatibility" | "strict" } declare type LayoutRectangle = { @@ -3076,9 +3097,9 @@ declare type MacOSViewProps = { readonly inverted?: boolean readonly mouseDownCanMoveWindow?: boolean readonly tooltip?: string - readonly onDragEnter?: (event: DragEvent) => void - readonly onDragLeave?: (event: DragEvent) => void - readonly onDrop?: (event: DragEvent) => void + readonly onDragEnter?: (event: DragEvent_3) => void + readonly onDragLeave?: (event: DragEvent_3) => void + readonly onDrop?: (event: DragEvent_3) => void } declare type Mapping = | AnimatedValue_default @@ -3095,12 +3116,24 @@ declare type MeasureInWindowOnSuccessCallback = ( width: number, height: number, ) => void +declare type MeasureInWindowOnSuccessCallback_2 = ( + x: number, + y: number, + width: number, + height: number, +) => void declare type MeasureLayoutOnSuccessCallback = ( left: number, top: number, width: number, height: number, ) => void +declare type MeasureLayoutOnSuccessCallback_2 = ( + left: number, + top: number, + width: number, + height: number, +) => void declare type MeasureOnSuccessCallback = ( x: number, y: number, @@ -3109,6 +3142,14 @@ declare type MeasureOnSuccessCallback = ( pageX: number, pageY: number, ) => void +declare type MeasureOnSuccessCallback_2 = ( + x: number, + y: number, + width: number, + height: number, + pageX: number, + pageY: number, +) => void declare type MergeUnion = { [K in KeysOfUnion]?: ValueOfUnion } declare type Message = { readonly content: string @@ -3121,7 +3162,7 @@ declare type Modal = typeof Modal declare interface ModalBaseProps { animationType?: "fade" | "none" | "slide" backdropColor?: ColorValue - modalRef?: React.Ref + modalRef?: React_2.Ref onRequestClose?: DirectEventHandler onShow?: DirectEventHandler transparent?: boolean @@ -3155,7 +3196,7 @@ declare type ModalPropsIOS = { > } declare type ModalRefProps = { - readonly ref?: React.Ref + readonly ref?: React_2.Ref } declare type ModeChangeEvent = Readonly< Omit & { @@ -3166,17 +3207,19 @@ declare type ModeChangeEvent = Readonly< > declare type Module = {} declare type modulo = typeof modulo -declare type MouseEvent = NativeSyntheticEvent<{ +declare type MouseEvent_2 = NativeSyntheticEvent<{ readonly clientX: number readonly clientY: number readonly pageX: number readonly pageY: number readonly timestamp: number }> +declare interface MouseEvent_3 + extends NativeSyntheticEvent_2 {} declare type MouseEventProps = { - readonly onDoubleClick?: (event: MouseEvent) => void - readonly onMouseEnter?: (event: MouseEvent) => void - readonly onMouseLeave?: (event: MouseEvent) => void + readonly onDoubleClick?: (event: MouseEvent_2) => void + readonly onMouseEnter?: (event: MouseEvent_2) => void + readonly onMouseLeave?: (event: MouseEvent_2) => void } declare type multiply = typeof multiply declare type NativeAppEventEmitter = typeof NativeAppEventEmitter @@ -3218,6 +3261,7 @@ declare class NativeEventEmitter< ): void } declare type NativeEventSubscription = EventSubscription +declare type NativeKeyEvent = KeyboardEventPayload_2 declare type NativeMeasureInWindowOnSuccessCallback = ( x: number, y: number, @@ -3238,6 +3282,18 @@ declare type NativeMeasureOnSuccessCallback = ( pageX: number, pageY: number, ) => void +declare interface NativeMethods { + blur(): void + focus(): void + measure(callback: MeasureOnSuccessCallback_2): void + measureInWindow(callback: MeasureInWindowOnSuccessCallback_2): void + measureLayout( + relativeToNativeComponentRef: HostInstance_3 | number, + onSuccess: MeasureLayoutOnSuccessCallback_2, + onFail?: () => void, + ): void + setNativeProps(nativeProps: object): void +} declare type NativeModeChangeEvent = { readonly mode: Int32 readonly targetRect: { @@ -3277,6 +3333,26 @@ declare interface NativeMouseEvent extends NativeUIEvent { readonly x: number readonly y: number } +declare interface NativeMouseEvent_2 extends NativeUIEvent_2 { + readonly altKey: boolean + readonly button: number + readonly buttons: number + readonly clientX: number + readonly clientY: number + readonly ctrlKey: boolean + readonly metaKey: boolean + // offset is proposed: https://drafts.csswg.org/cssom-view/#extensions-to-the-mouseevent-interface + readonly offsetX: number + readonly offsetY: number + readonly pageX: number + readonly pageY: number + readonly relatedTarget: HostInstance_3 | null | number + readonly screenX: number + readonly screenY: number + readonly shiftKey: boolean + readonly x: number + readonly y: number +} declare interface NativePointerEvent extends NativeMouseEvent { readonly height: number readonly isPrimary: boolean @@ -3339,6 +3415,8 @@ declare type NativeSyntheticEvent = { readonly preventDefault: () => void readonly stopPropagation: () => void } +declare interface NativeSyntheticEvent_2 + extends React_2.BaseSyntheticEvent {} declare type NativeTouchEvent = { readonly altKey?: boolean readonly button?: number @@ -3359,10 +3437,26 @@ declare type NativeTouchEvent = { declare interface NativeUIEvent { readonly detail: number } +declare interface NativeUIEvent_2 { + readonly detail: number +} declare type Networking = typeof Networking declare type Node = symbol & { __Node__: string } +declare interface NodeList extends Iterable, ArrayLike_3 { + [index: number]: T + readonly length: number + entries(): Iterator<[number, T]> + forEach( + callbackFn: (value: T, index: number, array: NodeList) => unknown, + thisArg?: ThisType, + ): void + item(index: number): null | T + keys(): Iterator + values(): Iterator + [Symbol.iterator](): Iterator +} declare class NodeList_default implements Iterable, ArrayLike_2 { [index: number]: T readonly length: number @@ -3498,10 +3592,27 @@ declare type PassThroughProps = { } declare type PasswordRules = string declare type PastedTypesType = PasteType | ReadonlyArray -declare type PasteEvent = NativeSyntheticEvent<{ - readonly dataTransfer: DataTransfer +declare type PasteEvent = NativeSyntheticEvent_2<{ + dataTransfer: { + files: { + height: number + size: number + type: string + uri: string + width: number + }[] + items: { + kind: string + type: string + }[] + types: string[] + } +}> +declare type PasteEvent_2 = NativeSyntheticEvent<{ + readonly dataTransfer: DataTransfer_2 }> declare type PasteType = "fileUrl" | "image" | "string" +declare type PasteType_2 = "fileUrl" | "image" | "string" declare type Permission = PermissionsType[keyof PermissionsType] declare type PermissionsAndroid = typeof PermissionsAndroid declare class PermissionsAndroidImpl { @@ -3600,32 +3711,32 @@ declare type PlatformType = | MacOSPlatform | WebPlatform | WindowsPlatform -declare type PointerEvent = NativeSyntheticEvent +declare type PointerEvent_2 = NativeSyntheticEvent declare type PointerEventProps = { - readonly onAuxClick?: (event: PointerEvent) => void - readonly onAuxClickCapture?: (event: PointerEvent) => void - readonly onClick?: (event: PointerEvent) => void - readonly onClickCapture?: (event: PointerEvent) => void - readonly onGotPointerCapture?: (e: PointerEvent) => void - readonly onGotPointerCaptureCapture?: (e: PointerEvent) => void - readonly onLostPointerCapture?: (e: PointerEvent) => void - readonly onLostPointerCaptureCapture?: (e: PointerEvent) => void - readonly onPointerCancel?: (e: PointerEvent) => void - readonly onPointerCancelCapture?: (e: PointerEvent) => void - readonly onPointerDown?: (e: PointerEvent) => void - readonly onPointerDownCapture?: (e: PointerEvent) => void - readonly onPointerEnter?: (event: PointerEvent) => void - readonly onPointerEnterCapture?: (event: PointerEvent) => void - readonly onPointerLeave?: (event: PointerEvent) => void - readonly onPointerLeaveCapture?: (event: PointerEvent) => void - readonly onPointerMove?: (event: PointerEvent) => void - readonly onPointerMoveCapture?: (event: PointerEvent) => void - readonly onPointerOut?: (e: PointerEvent) => void - readonly onPointerOutCapture?: (e: PointerEvent) => void - readonly onPointerOver?: (e: PointerEvent) => void - readonly onPointerOverCapture?: (e: PointerEvent) => void - readonly onPointerUp?: (e: PointerEvent) => void - readonly onPointerUpCapture?: (e: PointerEvent) => void + readonly onAuxClick?: (event: PointerEvent_2) => void + readonly onAuxClickCapture?: (event: PointerEvent_2) => void + readonly onClick?: (event: PointerEvent_2) => void + readonly onClickCapture?: (event: PointerEvent_2) => void + readonly onGotPointerCapture?: (e: PointerEvent_2) => void + readonly onGotPointerCaptureCapture?: (e: PointerEvent_2) => void + readonly onLostPointerCapture?: (e: PointerEvent_2) => void + readonly onLostPointerCaptureCapture?: (e: PointerEvent_2) => void + readonly onPointerCancel?: (e: PointerEvent_2) => void + readonly onPointerCancelCapture?: (e: PointerEvent_2) => void + readonly onPointerDown?: (e: PointerEvent_2) => void + readonly onPointerDownCapture?: (e: PointerEvent_2) => void + readonly onPointerEnter?: (event: PointerEvent_2) => void + readonly onPointerEnterCapture?: (event: PointerEvent_2) => void + readonly onPointerLeave?: (event: PointerEvent_2) => void + readonly onPointerLeaveCapture?: (event: PointerEvent_2) => void + readonly onPointerMove?: (event: PointerEvent_2) => void + readonly onPointerMoveCapture?: (event: PointerEvent_2) => void + readonly onPointerOut?: (e: PointerEvent_2) => void + readonly onPointerOutCapture?: (e: PointerEvent_2) => void + readonly onPointerOver?: (e: PointerEvent_2) => void + readonly onPointerOverCapture?: (e: PointerEvent_2) => void + readonly onPointerUp?: (e: PointerEvent_2) => void + readonly onPointerUpCapture?: (e: PointerEvent_2) => void } declare type PointProp = { readonly x: number @@ -3666,9 +3777,9 @@ declare type PressabilityConfig = { readonly hitSlop?: RectOrSize readonly minPressDuration?: number readonly onBlur?: (event: BlurEvent) => void - readonly onFocus?: (event: FocusEvent) => void - readonly onHoverIn?: (event: MouseEvent) => unknown - readonly onHoverOut?: (event: MouseEvent) => unknown + readonly onFocus?: (event: FocusEvent_2) => void + readonly onHoverIn?: (event: MouseEvent_2) => unknown + readonly onHoverOut?: (event: MouseEvent_2) => unknown readonly onKeyDown?: (event: KeyDownEvent) => void readonly onKeyUp?: (event: KeyUpEvent) => void readonly onLongPress?: (event: GestureResponderEvent) => unknown @@ -3681,13 +3792,13 @@ declare type PressabilityConfig = { declare type PressabilityEventHandlers = { readonly onBlur: (event: BlurEvent) => void readonly onClick: (event: GestureResponderEvent) => void - readonly onFocus: (event: FocusEvent) => void + readonly onFocus: (event: FocusEvent_2) => void readonly onKeyDown?: (event: KeyDownEvent) => void readonly onKeyUp?: (event: KeyUpEvent) => void - readonly onMouseEnter?: (event: MouseEvent) => void - readonly onMouseLeave?: (event: MouseEvent) => void - readonly onPointerEnter?: (event: PointerEvent) => void - readonly onPointerLeave?: (event: PointerEvent) => void + readonly onMouseEnter?: (event: MouseEvent_2) => void + readonly onMouseLeave?: (event: MouseEvent_2) => void + readonly onPointerEnter?: (event: PointerEvent_2) => void + readonly onPointerLeave?: (event: PointerEvent_2) => void readonly onResponderGrant: (event: GestureResponderEvent) => boolean | void readonly onResponderMove: (event: GestureResponderEvent) => void readonly onResponderRelease: (event: GestureResponderEvent) => void @@ -3711,8 +3822,8 @@ declare type PressableBaseProps = { readonly blockNativeResponder?: boolean readonly cancelable?: boolean readonly children?: - | ((state: PressableStateCallbackType) => React.ReactNode) - | React.ReactNode + | ((state: PressableStateCallbackType) => React_2.ReactNode) + | React_2.ReactNode readonly delayHoverIn?: number readonly delayHoverOut?: number readonly delayLongPress?: number @@ -3724,9 +3835,9 @@ declare type PressableBaseProps = { readonly keyUpEvents?: Array readonly mouseDownCanMoveWindow?: boolean readonly onBlur?: (event: BlurEvent) => void - readonly onFocus?: (event: FocusEvent) => void - readonly onHoverIn?: (event: MouseEvent) => unknown - readonly onHoverOut?: (event: MouseEvent) => unknown + readonly onFocus?: (event: FocusEvent_2) => void + readonly onHoverIn?: (event: MouseEvent_2) => unknown + readonly onHoverOut?: (event: MouseEvent_2) => unknown readonly onKeyDown?: (event: KeyDownEvent) => void readonly onKeyUp?: (event: KeyUpEvent) => void readonly onLayout?: (event: LayoutChangeEvent) => unknown @@ -3745,9 +3856,9 @@ declare type PressableBaseProps = { readonly tooltip?: string readonly validKeysDown?: ReadonlyArray readonly validKeysUp?: ReadonlyArray - readonly onDragEnter?: (event: DragEvent) => void - readonly onDragLeave?: (event: DragEvent) => void - readonly onDrop?: (event: DragEvent) => void + readonly onDragEnter?: (event: DragEvent_3) => void + readonly onDragLeave?: (event: DragEvent_3) => void + readonly onDrop?: (event: DragEvent_3) => void } declare type PressableInstance = HostInstance declare interface PressableProps @@ -3938,11 +4049,15 @@ declare type ReactFabricType = { command: string, args: Array, ): void - findHostInstance_DEPRECATED( - componentOrHandle: (number | React.ComponentRef) | undefined, + findHostInstance_DEPRECATED( + componentOrHandle: + | (number | React_2.ComponentRef) + | undefined, ): HostInstance_2 | undefined - findNodeHandle( - componentOrHandle: (number | React.ComponentRef) | undefined, + findNodeHandle( + componentOrHandle: + | (number | React_2.ComponentRef) + | undefined, ): number | undefined getNodeFromInternalInstanceHandle( internalInstanceHandle: InternalInstanceHandle, @@ -3953,15 +4068,26 @@ declare type ReactFabricType = { getPublicInstanceFromRootTag(rootTag: number): null | PublicRootInstance_2 isChildPublicInstance(parent: HostInstance_2, child: HostInstance_2): boolean render( - element: React.JSX.Element, + element: React_2.JSX.Element, containerTag: number, callback: (() => void) | undefined, concurrentRoot: boolean | undefined, options: RenderRootOptions | undefined, - ): React.ComponentRef | undefined + ): React_2.ComponentRef | undefined sendAccessibilityEvent(handle: HostInstance_2, eventType: string): void unmountComponentAtNode(containerTag: number): void } +declare interface ReactNativeDocument extends ReadOnlyNode { + get childElementCount(): number + get children(): HTMLCollection + get documentElement(): ReactNativeElement + get firstElementChild(): null | ReadOnlyElement + getElementById(id: string): null | ReadOnlyElement + get lastElementChild(): null | ReadOnlyElement + get nodeName(): string + get nodeType(): number + get nodeValue(): null +} declare class ReactNativeDocument_default extends ReadOnlyNode_default { get childElementCount(): number get children(): HTMLCollection_default @@ -3981,6 +4107,16 @@ declare class ReactNativeDocument_default extends ReadOnlyNode_default { declare type ReactNativeDocumentInstanceHandle = symbol & { __ReactNativeDocumentInstanceHandle__: string } +declare interface ReactNativeElement extends ReadOnlyElement, NativeMethods { + blur(): void + focus(): void + get offsetHeight(): number + get offsetLeft(): number + get offsetParent(): null | ReadOnlyElement + get offsetTop(): number + get offsetWidth(): number + setNativeProps(nativeProps: {}): void +} declare class ReactNativeElement_default extends ReadOnlyElement_default { blur(): void protected constructor() @@ -4015,6 +4151,33 @@ declare class ReadOnlyCharacterData_default extends ReadOnlyNode_default { substringData(offset: number, count: number): string get textContent(): string } +declare interface ReadOnlyElement extends ReadOnlyNode { + get childElementCount(): number + get children(): HTMLCollection + get clientHeight(): number + get clientLeft(): number + get clientTop(): number + get clientWidth(): number + get firstElementChild(): null | ReadOnlyElement + getBoundingClientRect(): DOMRect + hasPointerCapture(pointerId: number): boolean + get id(): string + get lastElementChild(): null | ReadOnlyElement + get nextElementSibling(): null | ReadOnlyElement + get nodeName(): string + get nodeType(): number + get nodeValue(): null | string + set nodeValue(value: string) + get previousElementSibling(): null | ReadOnlyElement + releasePointerCapture(pointerId: number): void + get scrollHeight(): number + get scrollLeft(): number + get scrollTop(): number + get scrollWidth(): number + setPointerCapture(pointerId: number): void + get tagName(): string + get textContent(): string +} declare class ReadOnlyElement_default extends ReadOnlyNode_default { get childElementCount(): number get children(): HTMLCollection_default @@ -4042,6 +4205,25 @@ declare class ReadOnlyElement_default extends ReadOnlyNode_default { get tagName(): string get textContent(): string } +declare interface ReadOnlyNode { + get childNodes(): NodeList + compareDocumentPosition(otherNode: ReadOnlyNode): number + contains(otherNode: ReadOnlyNode): boolean + get firstChild(): null | ReadOnlyNode + getRootNode(): ReadOnlyNode + hasChildNodes(): boolean + get isConnected(): boolean + get lastChild(): null | ReadOnlyNode + get nextSibling(): null | ReadOnlyNode + get nodeName(): string + get nodeType(): number + get nodeValue(): null | string + get ownerDocument(): null | ReactNativeDocument + get parentElement(): null | ReadOnlyElement + get parentNode(): null | ReadOnlyNode + get previousSibling(): null | ReadOnlyNode + get textContent(): string +} declare class ReadOnlyNode_default extends ReadOnlyNodeBase { static ATTRIBUTE_NODE: number static CDATA_SECTION_NODE: number @@ -4091,10 +4273,10 @@ declare type Rect = { readonly top?: number } declare type RectOrSize = number | Rect -declare class RefreshControl extends React.Component { +declare class RefreshControl extends React_2.Component { componentDidMount(): void componentDidUpdate(prevProps: RefreshControlProps): void - render(): React.ReactNode + render(): React_2.ReactNode } declare type RefreshControlBaseProps = { readonly onRefresh?: () => Promise | void @@ -4154,7 +4336,7 @@ declare type RenderRootOptions = { error: unknown, errorInfo: { readonly componentStack?: string - readonly errorBoundary?: React.Component + readonly errorBoundary?: React_2.Component }, ) => void onDefaultTransitionIndicator?: () => (() => void) | void @@ -4333,14 +4515,14 @@ declare type ScrollEvent = NativeSyntheticEvent declare type ScrollResponderType = ScrollViewImperativeMethods declare type ScrollView = typeof ScrollView declare type ScrollViewBaseProps = { - readonly children?: React.ReactNode + readonly children?: React_2.ReactNode readonly contentContainerStyle?: ViewStyleProp readonly contentOffset?: PointProp readonly decelerationRate?: DecelerationRateType readonly disableIntervalMomentum?: boolean readonly hasOverlayStyleIndicator?: boolean readonly horizontal?: boolean - readonly innerViewRef?: React.Ref + readonly innerViewRef?: React_2.Ref readonly inverted?: boolean readonly invertStickyHeaders?: boolean readonly keyboardDismissMode?: "interactive" | "none" | "on-drag" @@ -4355,11 +4537,11 @@ declare type ScrollViewBaseProps = { readonly onScrollBeginDrag?: (event: ScrollEvent) => void readonly onScrollEndDrag?: (event: ScrollEvent) => void readonly pagingEnabled?: boolean - readonly refreshControl?: React.JSX.Element + readonly refreshControl?: React_2.JSX.Element readonly removeClippedSubviews?: boolean readonly scrollEnabled?: boolean readonly scrollEventThrottle?: number - readonly scrollViewRef?: React.Ref + readonly scrollViewRef?: React_2.Ref readonly showsVerticalScrollIndicator?: boolean readonly snapToAlignment?: "center" | "end" | "start" readonly snapToEnd?: boolean @@ -4373,10 +4555,10 @@ declare type ScrollViewBaseProps = { contentWidth: number, contentHeight: number, ) => void - readonly onKeyboardDidHide?: (event: KeyboardEvent) => void - readonly onKeyboardDidShow?: (event: KeyboardEvent) => void - readonly onKeyboardWillHide?: (event: KeyboardEvent) => void - readonly onKeyboardWillShow?: (event: KeyboardEvent) => void + readonly onKeyboardDidHide?: (event: KeyboardEvent_2) => void + readonly onKeyboardDidShow?: (event: KeyboardEvent_2) => void + readonly onKeyboardWillHide?: (event: KeyboardEvent_2) => void + readonly onKeyboardWillShow?: (event: KeyboardEvent_2) => void } declare type ScrollViewComponentStatics = { readonly Context: typeof $$ScrollViewContext @@ -4471,7 +4653,7 @@ declare interface ScrollViewScrollToOptions { y?: number } declare type ScrollViewStickyHeaderProps = { - readonly children?: React.ReactNode + readonly children?: React_2.ReactNode readonly hiddenOnScroll?: boolean readonly inverted: boolean | undefined readonly nativeID?: string @@ -4483,13 +4665,13 @@ declare type ScrollViewStickyHeaderProps = { declare class SectionList< ItemT = any, SectionT = DefaultSectionT, -> extends React.PureComponent> { +> extends React_2.PureComponent> { props: SectionListProps flashScrollIndicators(): void getScrollableNode(): any getScrollResponder(): null | ScrollResponderType | undefined recordInteraction(): void - render(): React.ReactNode + render(): React_2.ReactNode scrollToLocation(params: ScrollToLocationParamsType): void setNativeProps(props: Object): void } @@ -4516,7 +4698,7 @@ declare interface SectionListProps > {} declare type SectionListRenderItem = ( info: SectionListRenderItemInfo, -) => null | React.ReactNode +) => null | React_2.ReactNode declare type SectionListRenderItemInfo< ItemT, SectionT = DefaultSectionT, @@ -4556,6 +4738,11 @@ declare type SettingChangeEvent = NativeSyntheticEvent<{ readonly grammarCheckEnabled: boolean readonly spellCheckEnabled: boolean }> +declare type SettingChangeEvent_2 = NativeSyntheticEvent_2<{ + autoCorrectEnabled: boolean + grammarCheckEnabled: boolean + spellCheckEnabled: boolean +}> declare type Settings = typeof Settings declare function setWrapperComponentProvider( provider: WrapperComponentProvider, @@ -4767,14 +4954,14 @@ declare function startHeadlessTask( taskKey: string, data: any, ): void -declare class StatusBar extends React.Component { +declare class StatusBar extends React_2.Component { static currentHeight: null | number | undefined componentDidMount(): void componentDidUpdate(): void componentWillUnmount(): void static popStackEntry(entry: StackProps): void static pushStackEntry(props: StatusBarProps): StackProps - render(): React.ReactNode + render(): React_2.ReactNode static replaceStackEntry(entry: StackProps, props: StatusBarProps): StackProps static setBarStyle(style: StatusBarStyle, animated?: boolean): void static setHidden(hidden: boolean, animation?: StatusBarAnimation): void @@ -4802,11 +4989,11 @@ declare type StatusBarStyle = keyof { } declare type StickyHeaderComponentType = ( props: ScrollViewStickyHeaderProps & { - ref?: React.Ref<{ + ref?: React_2.Ref<{ readonly setNextHeaderY: ($$PARAM_0$$: number) => void }> }, -) => React.ReactNode +) => React_2.ReactNode declare type StyleProp = | "" | false @@ -4834,6 +5021,14 @@ declare type SubmitKeyEvent = { readonly metaKey?: boolean readonly shiftKey?: boolean } +declare type SubmitKeyEvent_2 = { + altKey?: boolean + ctrlKey?: boolean + functionKey?: boolean + key: string + metaKey?: boolean + shiftKey?: boolean +} declare type subtract = typeof subtract declare type Switch = typeof Switch declare type SwitchChangeEvent = NativeSyntheticEvent @@ -4867,6 +5062,12 @@ declare type SystemEffectMacOS = | "none" | "pressed" | "rollover" +declare type SystemEffectMacOS_2 = + | "deepPressed" + | "disabled" + | "none" + | "pressed" + | "rollover" declare namespace Systrace { export { isEnabled, @@ -4899,11 +5100,11 @@ declare type TBackHandler = { } exitApp(): void } -declare type Text = typeof Text +declare type Text_2 = typeof Text_2 declare type TextBaseProps = { readonly allowFontScaling?: boolean readonly android_hyphenationFrequency?: "full" | "none" | "normal" - readonly children?: React.ReactNode + readonly children?: React_2.ReactNode readonly ellipsizeMode?: "clip" | "head" | "middle" | "tail" readonly id?: string readonly maxFontSizeMultiplier?: number @@ -5079,7 +5280,7 @@ declare type TextInputBaseProps = { readonly defaultValue?: string readonly editable?: boolean readonly enterKeyHint?: EnterKeyHintTypeOptions - readonly forwardedRef?: React.Ref + readonly forwardedRef?: React_2.Ref readonly inputMode?: InputModeOptions readonly keyboardType?: KeyboardTypeOptions readonly maxFontSizeMultiplier?: number @@ -5150,7 +5351,7 @@ declare type TextInputEndEditingEventData = Readonly< text: string } > -declare type TextInputFocusEvent = FocusEvent +declare type TextInputFocusEvent = FocusEvent_2 declare type TextInputInstance = _TextInputInstance declare type TextInputIOSProps = { readonly clearButtonMode?: @@ -5195,7 +5396,19 @@ declare type TextInputKeyPressEventData = Readonly< target?: number } > -declare type TextInputMacOSProps = { +declare interface TextInputMacOSProps { + clearTextOnSubmit?: boolean + grammarCheck?: boolean + hideVerticalScrollIndicator?: boolean + onAutoCorrectChange?: (event: SettingChangeEvent_2) => void + onGrammarCheckChange?: (event: SettingChangeEvent_2) => void + onPaste?: (event: PasteEvent) => void + onSpellCheckChange?: (event: SettingChangeEvent_2) => void + pastedTypes?: PasteType_2 | PasteType_2[] + submitKeyEvents?: SubmitKeyEvent_2[] + tooltip?: string +} +declare type TextInputMacOSProps_2 = { readonly clearTextOnSubmit?: boolean readonly grammarCheck?: boolean readonly hideVerticalScrollIndicator?: boolean @@ -5209,7 +5422,7 @@ declare type TextInputMacOSProps = { readonly pastedTypes?: PastedTypesType readonly submitKeyEvents?: ReadonlyArray readonly tooltip?: string - readonly onPaste?: (event: PasteEvent) => void + readonly onPaste?: (event: PasteEvent_2) => void } declare interface TextInputProps extends Readonly< @@ -5217,7 +5430,7 @@ declare interface TextInputProps TextInputIOSProps & TextInputAndroidProps & TextInputBaseProps & - TextInputMacOSProps + TextInputMacOSProps_2 > {} declare type TextInputSelectionChangeEvent = NativeSyntheticEvent @@ -5250,9 +5463,9 @@ declare type TextLayoutLine = Readonly< } > declare type TextPointerEventProps = { - readonly onPointerEnter?: (event: PointerEvent) => void - readonly onPointerLeave?: (event: PointerEvent) => void - readonly onPointerMove?: (event: PointerEvent) => void + readonly onPointerEnter?: (event: PointerEvent_2) => void + readonly onPointerLeave?: (event: PointerEvent_2) => void + readonly onPointerMove?: (event: PointerEvent_2) => void } declare interface TextProps extends Readonly< @@ -5322,11 +5535,10 @@ declare type TimingAnimationConfig = Readonly< } > declare type ToastAndroid = typeof ToastAndroid -declare type Touchable = typeof Touchable declare type TouchableHighlight = typeof TouchableHighlight declare type TouchableHighlightBaseProps = { readonly activeOpacity?: number - readonly hostRef?: React.Ref + readonly hostRef?: React_2.Ref readonly onHideUnderlay?: () => void readonly onShowUnderlay?: () => void readonly style?: ViewStyleProp @@ -5341,7 +5553,7 @@ declare interface TouchableHighlightProps IOSProps & TouchableHighlightBaseProps > {} -declare class TouchableNativeFeedback extends React.Component< +declare class TouchableNativeFeedback extends React_2.Component< TouchableNativeFeedbackProps, TouchableNativeFeedbackState > { @@ -5375,7 +5587,7 @@ declare class TouchableNativeFeedback extends React.Component< prevState: TouchableNativeFeedbackState, ): void componentWillUnmount(): void - render(): React.ReactNode + render(): React_2.ReactNode } declare type TouchableNativeFeedbackInstance = TouchableNativeFeedback declare type TouchableNativeFeedbackProps = Readonly< @@ -5412,7 +5624,7 @@ declare type TouchableNativeFeedbackTVProps = { declare type TouchableOpacity = typeof TouchableOpacity declare type TouchableOpacityBaseProps = { readonly activeOpacity?: number - readonly hostRef?: React.Ref + readonly hostRef?: React_2.Ref readonly style?: Animated.WithAnimatedValue } declare type TouchableOpacityInstance = HostInstance @@ -5429,25 +5641,16 @@ declare type TouchableOpacityTVProps = { readonly nextFocusRight?: number readonly nextFocusUp?: number } -declare type TouchableState = - | typeof States.ERROR - | typeof States.NOT_RESPONDER - | typeof States.RESPONDER_ACTIVE_LONG_PRESS_IN - | typeof States.RESPONDER_ACTIVE_LONG_PRESS_OUT - | typeof States.RESPONDER_ACTIVE_PRESS_IN - | typeof States.RESPONDER_ACTIVE_PRESS_OUT - | typeof States.RESPONDER_INACTIVE_PRESS_IN - | typeof States.RESPONDER_INACTIVE_PRESS_OUT declare function TouchableWithoutFeedback( props: TouchableWithoutFeedbackProps, -): React.ReactNode +): React_2.ReactNode declare interface TouchableWithoutFeedbackProps extends Readonly< TouchableWithoutFeedbackPropsAndroid & TouchableWithoutFeedbackPropsIOS & AccessibilityProps > { - readonly children?: React.ReactNode + readonly children?: React_2.ReactNode readonly delayLongPress?: number readonly delayPressIn?: number readonly delayPressOut?: number @@ -5463,7 +5666,7 @@ declare interface TouchableWithoutFeedbackProps readonly nativeID?: string readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onBlur?: (event: BlurEvent) => void - readonly onFocus?: (event: FocusEvent) => void + readonly onFocus?: (event: FocusEvent_2) => void readonly onLayout?: (event: LayoutChangeEvent) => unknown readonly onLongPress?: (event: GestureResponderEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown @@ -5482,11 +5685,11 @@ declare type TouchableWithoutFeedbackPropsIOS = { draggedTypes?: DraggedTypesType enableFocusRing?: boolean tooltip?: string - onDragEnter?: (event: DragEvent) => void - onDragLeave?: (event: DragEvent) => void - onDrop?: (event: DragEvent) => void - onMouseEnter?: (event: MouseEvent) => void - onMouseLeave?: (event: MouseEvent) => void + onDragEnter?: (event: DragEvent_3) => void + onDragLeave?: (event: DragEvent_3) => void + onDrop?: (event: DragEvent_3) => void + onMouseEnter?: (event: MouseEvent_2) => void + onMouseLeave?: (event: MouseEvent_2) => void } declare type TouchEventProps = { readonly onTouchCancel?: (e: GestureResponderEvent) => void @@ -5568,11 +5771,11 @@ declare type Vibration = typeof Vibration declare type View = typeof View declare function View_default( props: ViewProps & { - ref?: React.Ref + ref?: React_2.Ref }, -): React.ReactNode +): React_2.ReactNode declare type ViewBaseProps = { - readonly children?: React.ReactNode + readonly children?: React_2.ReactNode readonly collapsable?: boolean readonly collapsableChildren?: boolean readonly hitSlop?: EdgeInsetsOrSizeProp @@ -5648,14 +5851,37 @@ declare type ViewPropsAndroid = { declare type ViewPropsIOS = { readonly shouldRasterizeIOS?: boolean } +declare interface ViewPropsMacOS { + acceptsFirstMouse?: boolean + allowsVibrancy?: boolean + draggedTypes?: PasteType_2 | PasteType_2[] + enableFocusRing?: boolean + keyDownEvents?: HandledKeyEvent_2[] + keyUpEvents?: HandledKeyEvent_2[] + mouseDownCanMoveWindow?: boolean + // Match ViewPropTypes.d.ts, which uses the global PointerEvent here. + onAuxClick?: (event: PointerEvent) => void + onAuxClickCapture?: (event: PointerEvent) => void + onDoubleClick?: (event: MouseEvent_3) => void + onDragEnter?: (event: DragEvent_2) => void + onDragLeave?: (event: DragEvent_2) => void + onDrop?: (event: DragEvent_2) => void + onKeyDown?: (event: KeyDownEvent_2) => void + onKeyUp?: (event: KeyUpEvent_2) => void + onMouseEnter?: (event: MouseEvent_3) => void + onMouseLeave?: (event: MouseEvent_3) => void + passthroughAllKeyEvents?: boolean + validKeysDown?: readonly (HandledKeyEvent_2 | string)[] + validKeysUp?: readonly (HandledKeyEvent_2 | string)[] +} declare type ViewStyle = ____ViewStyle_Internal declare type ViewStyleProp = ____ViewStyleProp_Internal declare type VirtualizedList = typeof VirtualizedList -declare type VirtualizedListInstance = React.ComponentRef +declare type VirtualizedListInstance = React_2.ComponentRef declare type VirtualizedListType = typeof VirtualizedLists.VirtualizedList declare type VirtualizedSectionList = typeof VirtualizedSectionList declare type VirtualizedSectionListInstance = - React.ComponentRef + React_2.ComponentRef declare type VirtualizedSectionListType = typeof VirtualizedLists.VirtualizedSectionList declare enum VirtualViewMode { @@ -5752,10 +5978,10 @@ declare type WithDefault< > = null | Type | undefined declare function Wrapper_default( $$PARAM_0$$: ModalRefProps & ModalProps, -): React.ReactNode +): React_2.ReactNode declare type WrapperComponentProvider = ( appParameters: Object, -) => React.ComponentType +) => React_2.ComponentType export { AccessibilityActionEvent, // a0d4daa0 AccessibilityActionInfo, // db47a917 @@ -5766,18 +5992,18 @@ export { AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // 493900fa + ActivityIndicator, // c5864b68 ActivityIndicatorInstance, // a82dd4e7 - ActivityIndicatorProps, // 4227530f + ActivityIndicatorProps, // dd6f004c Alert, // 24958ab5 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 AlertOptions, // 39b16cfa AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // be5ea01d - AppConfig, // 35c0ca70 - AppRegistry, // 5ee0a63b + Animated, // e7241a74 + AppConfig, // e4e0157b + AppRegistry, // 981198cc AppState, // 12012be5 AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -5789,16 +6015,16 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // 4ba4f941 BoxShadowValue, // b679703f - Button, // 505b6cc1 - ButtonInstance, // 7c607915 - ButtonProps, // 29cfadcc + Button, // 8e70b924 + ButtonInstance, // 5983c48f + ButtonProps, // 69a911f2 Clipboard, // 41addb89 CodegenTypes, // ab4986cc ColorSchemeName, // 6615edd6 ColorValue, // 98989a8f ColorWithSystemEffectMacOS, // 5644e8fe - ComponentProvider, // bf78f91b - ComponentProviderInstrumentationHook, // 68823a13 + ComponentProvider, // ce579627 + ComponentProviderInstrumentationHook, // 9e47ae75 CursorValue, // 2c77888f DevMenu, // 21b8b7a9 DevSettings, // e55b91dc @@ -5810,9 +6036,10 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // 699ed56a + DragEvent_2 as DragEvent, // e161690c + DrawerLayoutAndroid, // 80ee3edb DrawerLayoutAndroidInstance, // c0694352 - DrawerLayoutAndroidProps, // a8e2c17a + DrawerLayoutAndroidProps, // 5884eb5e DrawerSlideEvent, // c4ab8fba DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -5825,35 +6052,35 @@ export { EdgeInsetsValue, // bd44afe6 EmitterSubscription, // de50c359 EnterKeyHintTypeOptions, // 8314de78 - ErrorUtils, // 38fb909e + ErrorUtils_2 as ErrorUtils, // 5b32fdc8 EventEmitter, // 2a2ea995 EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // baec8ab3 - FlatListInstance, // f892502d + FlatList, // df6431a9 + FlatListInstance, // e06eece7 FlatListProps, // fb158bc8 - FocusEvent, // 850f1517 + FocusEvent_2 as FocusEvent, // 30b0dd35 FontVariant, // 7c7558bb GestureResponderEvent, // 3bd4697e GestureResponderHandlers, // 3f4b3d6f - HostComponent, // a611806a + HostComponent, // 74c10001 HostInstance, // f78dcaf8 I18nManager, // f9870e00 IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // a802d222 - ImageBackground, // f92847c7 - ImageBackgroundInstance, // 8a827b32 - ImageBackgroundProps, // f44ac281 + Image, // 2a05118e + ImageBackground, // 43405e02 + ImageBackgroundInstance, // 4b6f798d + ImageBackgroundProps, // fdf47df6 ImageErrorEvent, // 978933f4 ImageInstance, // 9a100753 ImageLoadEvent, // 77f0b718 ImageProgressEventIOS, // 445331a4 - ImageProps, // c61ffdb6 + ImageProps, // cea58952 ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // 68fb3ba8 + ImagePropsBase, // 13761fda ImagePropsIOS, // 4a080668 ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 @@ -5863,20 +6090,20 @@ export { ImageSourcePropType, // bfb5e5c6 ImageStyle, // ccf7055c ImageURISource, // 016eb083 - InputAccessoryView, // 35edf5de - InputAccessoryViewProps, // ea39d6b6 + InputAccessoryView, // 29eefece + InputAccessoryViewProps, // 2aa4ba24 InputModeOptions, // 4e8581b9 Insets, // e7fe432a KeyDownEvent, // cbe0db49 KeyEvent, // 2a18a436 KeyUpEvent, // ef64af4c - Keyboard, // 49414c97 - KeyboardAvoidingView, // 39ef8a97 - KeyboardAvoidingViewInstance, // 00a5c52c - KeyboardAvoidingViewProps, // b5f6093a - KeyboardEvent, // c3f895d4 + Keyboard, // 12be6986 + KeyboardAvoidingView, // 84c12855 + KeyboardAvoidingViewInstance, // 5fcd1820 + KeyboardAvoidingViewProps, // a5e139b7 + KeyboardEvent_2 as KeyboardEvent, // da6563cf KeyboardEventEasing, // af4091c8 - KeyboardEventName, // 59299ad6 + KeyboardEventName, // 5564dd77 KeyboardMetrics, // 7997b799 KeyboardTypeOptions, // ae44efe7 LayoutAnimation, // 54110cf7 @@ -5887,7 +6114,7 @@ export { LayoutAnimationType, // 2da0a29b LayoutAnimationTypes, // 081b3bde LayoutChangeEvent, // 98960b70 - LayoutConformanceProps, // 055f03b8 + LayoutConformanceProps, // 4727f030 LayoutRectangle, // 6601b294 Linking, // 9a6a174d ListRenderItem, @@ -5898,19 +6125,20 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // 3e0b762f - ModalBaseProps, // ac3931ad + Modal, // eee9076c + ModalBaseProps, // 0de93395 ModalInstance, // d466ce77 - ModalProps, // bcbc89bb + ModalProps, // e855ab04 ModalPropsAndroid, // 515fb173 ModalPropsIOS, // 0e13cfcc ModeChangeEvent, // f64bf69d - MouseEvent, // d1f889fd + MouseEvent_2 as MouseEvent, // 8bc148bf NativeAppEventEmitter, // 08d4c47d NativeColorValue, // d2094c29 - NativeComponentRegistry, // c8fc2fb5 + NativeComponentRegistry, // 7b7d3974 NativeEventEmitter, // 27f97c1a NativeEventSubscription, // de3942e7 + NativeKeyEvent, // 7852683b NativeModules, // 4597cd36 NativeMouseEvent, // 16c856a5 NativePointerEvent, // 64ff2151 @@ -5925,6 +6153,7 @@ export { PanResponderCallbacks, // 4ee3d101 PanResponderGestureState, // 54baf558 PanResponderInstance, // b3afacbe + PasteEvent, // c030c607 Permission, // 08f1c82f PermissionStatus, // 4b7de97b PermissionsAndroid, // 8a0bc8d8 @@ -5934,18 +6163,18 @@ export { PlatformOSType, // 0a17561e PlatformSelectSpec, // 09ed7758 PointValue, // 69db075f - PointerEvent, // ff599afe - PressabilityConfig, // 2374a593 - PressabilityEventHandlers, // 9b800ac5 - Pressable, // 53bb82ea + PointerEvent_2 as PointerEvent, // f47675d8 + PressabilityConfig, // 8df202e3 + PressabilityEventHandlers, // e7987b00 + Pressable, // 1ba7d4c7 PressableAndroidRippleConfig, // ee32eaca PressableInstance, // eebfe911 - PressableProps, // b4227cfb + PressableProps, // 6f3e9ea7 PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // e8b7eaf6 + ProgressBarAndroid, // debc6d5f ProgressBarAndroidInstance, // ab545ef1 - ProgressBarAndroidProps, // b3103b9c + ProgressBarAndroidProps, // eba62243 PublicRootInstance, // 8040afd7 PublicTextInstance, // 6937c7bf PushNotificationEventName, // 84e7e150 @@ -5953,9 +6182,9 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 5ddff7bf - RefreshControlInstance, // 561b5344 - RefreshControlProps, // cc88e35f + RefreshControl, // 83571873 + RefreshControlInstance, // 92a44a91 + RefreshControlProps, // 28551311 RefreshControlPropsAndroid, // 99f64c97 RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d @@ -5963,29 +6192,29 @@ export { ReturnKeyTypeOptions, // afd47ba3 Role, // af7b889d RootTag, // 3cd10504 - RootTagContext, // cc287c11 + RootTagContext, // 5d8416fc RootViewStyleProvider, // 6a15dcfc Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 5b0aef39 + SafeAreaView, // 1d65b30b SafeAreaViewInstance, // 21dba39c ScaledSize, // 07e417c7 ScrollEvent, // 10f01ee3 - ScrollResponderType, // 02f30132 + ScrollResponderType, // 51719b64 ScrollToLocationParamsType, - ScrollView, // fcdcd27c - ScrollViewImperativeMethods, // 63083e1a - ScrollViewInstance, // 4bdb26a9 - ScrollViewProps, // 11071735 + ScrollView, // 8d4a580b + ScrollViewImperativeMethods, // efff3f67 + ScrollViewInstance, // 7421bd9f + ScrollViewProps, // a23b9a17 ScrollViewPropsAndroid, // 44210553 ScrollViewPropsIOS, // 19a1147f ScrollViewScrollToOptions, // 3313411e SectionBase, - SectionList, // 58abd791 + SectionList, // d8ed4ae2 SectionListData, - SectionListInstance, // 5faca954 - SectionListProps, // 6061d1b8 - SectionListRenderItem, // c118edfb + SectionListInstance, // f0d79d5d + SectionListProps, // a06f752e + SectionListRenderItem, // de6c0c34 SectionListRenderItemInfo, // bd038703 Separators, Settings, // 2be0c61e @@ -5995,84 +6224,86 @@ export { ShareActionSheetIOSOptions, // eff574f5 ShareContent, // 7c627896 ShareOptions, // 800c3a4e - StatusBar, // 81b4e92f + StatusBar, // abc3fd90 StatusBarAnimation, // 7fd047e6 - StatusBarInstance, // 4e03048c + StatusBarInstance, // 31445769 StatusBarProps, // bfedeab1 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a StyleSheet, // 3145ad8d SubmitBehavior, // c4ddf490 - Switch, // 3dac27a2 + Switch, // 7b1086fa SwitchChangeEvent, // 899635b1 SwitchInstance, // 3c50eec5 - SwitchProps, // 9f9ae665 + SwitchProps, // 64074d79 + SystemEffectMacOS, // d026159b Systrace, // 626d178c TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // c0e9e9ee + Text_2 as Text, // af6f8150 TextContentType, // 239b3ecc - TextInput, // 0632b08e + TextInput, // 3ad5d822 TextInputAndroidProps, // 7109938a TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 TextInputContentSizeChangeEvent, // a27cd32a TextInputEndEditingEvent, // e690b56b - TextInputFocusEvent, // 19851a88 + TextInputFocusEvent, // 51668a1e TextInputIOSProps, // e8905f3e TextInputInstance, // 9d5cf2e6 TextInputKeyPressEvent, // 546c5d07 - TextInputProps, // 070786dd + TextInputMacOSProps, // 4a7a4b02 + TextInputProps, // 2bf4840f TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 TextLayoutEvent, // 3f54186f - TextProps, // 7e0b961b + TextProps, // 96b3535c TextStyle, // 694c5ffb ToastAndroid, // 88a8969a - Touchable, // e8712e89 - TouchableHighlight, // 2ce6532d + TouchableHighlight, // d2d261ca TouchableHighlightInstance, // b510c0eb - TouchableHighlightProps, // d786ab40 - TouchableNativeFeedback, // ab2adc08 - TouchableNativeFeedbackInstance, // 06a9ced1 - TouchableNativeFeedbackProps, // 3ef45dab - TouchableOpacity, // 0f6c7488 + TouchableHighlightProps, // 9abae613 + TouchableNativeFeedback, // 205c3d42 + TouchableNativeFeedbackInstance, // 284d8dc6 + TouchableNativeFeedbackProps, // e2bb1c8e + TouchableOpacity, // 13b593b5 TouchableOpacityInstance, // b186055b - TouchableOpacityProps, // d0aee298 - TouchableWithoutFeedback, // 30af9ea1 - TouchableWithoutFeedbackProps, // b6a23a82 + TouchableOpacityProps, // 69b78d84 + TouchableWithoutFeedback, // 5647bbf2 + TouchableWithoutFeedbackProps, // b36f07bb TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 UIManager, // a1a7cc01 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 578096b7 + View, // 467d00f2 ViewInstance, // ffde5573 - ViewProps, // 8abb0217 + ViewProps, // 2a62eb65 ViewPropsAndroid, // ca64ec97 ViewPropsIOS, // 58ee19bf + ViewPropsMacOS, // a39358a0 ViewStyle, // 2fc81116 VirtualViewMode, // 6be59722 VirtualizedList, // d4e15eaa - VirtualizedListInstance, // dcba9753 + VirtualizedListInstance, // 09171ad2 VirtualizedListProps, VirtualizedSectionList, // 486a3a9d - VirtualizedSectionListInstance, // b8db2acd + VirtualizedSectionListInstance, // 93c6cbad VirtualizedSectionListProps, - WrapperComponentProvider, // 4b8c7962 + WrapperComponentProvider, // 994bf953 codegenNativeCommands, // 628a7c0a - codegenNativeComponent, // 32a1bca6 + codegenNativeComponent, // cf7eb9a8 findNodeHandle, // 93f80214 processColor, // 6e877698 registerCallableModule, // 839c8cfe - requireNativeComponent, // aa36a6dd + requireNativeComponent, // f133b4a3 useAnimatedColor, // e3511f81 useAnimatedValue, // b18adb63 useAnimatedValueXY, // c7ee2332 useColorScheme, // d585efdb - usePressability, // c504c51d + usePressability, // a33fa4bf useWindowDimensions, // bb4b683f } diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 1df236c3bc71..54ea8b7694ba 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -124,7 +124,6 @@ import { // @ts-ignore SectionListData, ToastAndroid, - Touchable, LayoutAnimation, processColor, experimental_LayoutConformance as LayoutConformance, @@ -487,22 +486,6 @@ class Welcome extends React.Component< export default Welcome; -// TouchableTest -function TouchableTest() { - function basicUsage() { - return Touchable.renderDebugView({ - color: 'mediumspringgreen', - hitSlop: {bottom: 5, top: 5}, - }); - } - - function defaultHitSlop() { - return Touchable.renderDebugView({ - color: 'red', - }); - } -} - export class TouchableHighlightTest extends React.Component { buttonRef = React.createRef>(); diff --git a/packages/react-native/__typetests__/macos-public-api.ts b/packages/react-native/__typetests__/macos-public-api.ts new file mode 100644 index 000000000000..7c3f1a7e5f03 --- /dev/null +++ b/packages/react-native/__typetests__/macos-public-api.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +// Use the actual package export conditions, without the react-native test alias. +import type { + SystemEffectMacOS, + TextInputMacOSProps, + ViewPropsMacOS, + NativeKeyEvent, + DragEvent, + PasteEvent, +} from 'react-native-macos'; +import type {SystemEffectMacOS as ManualSystemEffectMacOS} from '../Libraries/StyleSheet/PlatformColorValueTypesMacOS'; +import type { + TextInputMacOSProps as ManualTextInputMacOSProps, + PasteEvent as ManualPasteEvent, +} from '../Libraries/Components/TextInput/TextInput'; +import type {ViewPropsMacOS as ManualViewPropsMacOS} from '../Libraries/Components/View/ViewPropTypes'; +import type { + NativeKeyEvent as ManualNativeKeyEvent, + DragEvent as ManualDragEvent, +} from '../Libraries/Types/CoreEventTypes'; + +type Assert = T; +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type OptionalKeys = { + [K in keyof T]-?: {} extends Pick ? K : never; +}[keyof T]; + +type SystemEffectsMatch = Assert< + Equal +>; +type KeyFieldsMatch = Assert< + Equal +>; +type OptionalKeyFieldsMatch = Assert< + Equal, OptionalKeys> +>; +type TextInputFieldsMatch = Assert< + Equal +>; +type ViewFieldsMatch = Assert< + Equal +>; +type OptionalTextInputFieldsMatch = Assert< + Equal, keyof TextInputMacOSProps> +>; +type OptionalViewFieldsMatch = Assert< + Equal, keyof ViewPropsMacOS> +>; + +// Check both assignment directions, including callback parameters and arrays. +declare let key: NativeKeyEvent; +declare let manualKey: ManualNativeKeyEvent; +key = manualKey; +manualKey = key; +declare let input: TextInputMacOSProps; +declare let manualInput: ManualTextInputMacOSProps; +input = manualInput; +manualInput = input; +declare let view: ViewPropsMacOS; +declare let manualView: ManualViewPropsMacOS; +view = manualView; +manualView = view; +declare let drag: DragEvent; +declare let manualDrag: ManualDragEvent; +drag = manualDrag; +manualDrag = drag; +declare let paste: PasteEvent; +declare let manualPaste: ManualPasteEvent; +paste = manualPaste; +manualPaste = paste; + +const minimalKey: NativeKeyEvent = { + key: 'Enter', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, +}; +const optionalKeyFields: NativeKeyEvent = { + ...minimalKey, + code: undefined, + repeat: undefined, + isComposing: undefined, + capsLockKey: undefined, + numericPadKey: undefined, + helpKey: undefined, + functionKey: undefined, +}; +// @ts-expect-error The modifier booleans remain required. +const missingModifiers: NativeKeyEvent = {key: 'Enter'}; +// @ts-expect-error Unknown system effects are not part of the public API. +const invalidEffect: SystemEffectMacOS = 'highlighted'; + +const pastePayload: PasteEvent['nativeEvent'] = { + dataTransfer: { + files: [ + { + height: 10, + width: 20, + size: 30, + type: 'image/png', + uri: 'file:///image.png', + }, + ], + items: [{kind: 'file', type: 'image/png'}], + types: ['image/png'], + }, +}; +// The legacy paste arrays are mutable and do not require a file name. +pastePayload.dataTransfer.files.push(pastePayload.dataTransfer.files[0]); +// @ts-expect-error Paste image dimensions remain required. +pastePayload.dataTransfer.files.push({ + size: 30, + type: 'image/png', + uri: 'file:///image.png', +}); + +const transfer: NonNullable = { + files: [{name: 'file', type: null, uri: 'file:///file', size: undefined}], + items: [{kind: 'file', type: undefined}], + types: [null, undefined, 'image/png'], +}; +// @ts-expect-error Drag files remain read-only as an array. +transfer.files.push({name: 'file', type: null, uri: 'file:///file'}); +const optionalTransfer: Pick = {}; +const undefinedTransfer: Pick = { + dataTransfer: undefined, +}; +// @ts-expect-error Drag payloads retain the full native mouse contract. +const incompleteDrag: DragEvent['nativeEvent'] = { + clientX: 0, + clientY: 0, + pageX: 0, + pageY: 0, +}; + +const emptyInput: TextInputMacOSProps = {}; +const emptyView: ViewPropsMacOS = {}; +const explicitUndefinedInput: TextInputMacOSProps = { + onPaste: undefined, + tooltip: undefined, +}; +const explicitUndefinedView: ViewPropsMacOS = { + onDrop: undefined, + keyDownEvents: undefined, +}; +// The legacy arrays remain mutable. +input.submitKeyEvents?.push({key: 'Enter'}); +view.keyDownEvents?.push({key: 'Enter'}); diff --git a/scripts/js-api/build-types/templates/tsconfig.test.json b/packages/react-native/__typetests__/tsconfig.json similarity index 52% rename from scripts/js-api/build-types/templates/tsconfig.test.json rename to packages/react-native/__typetests__/tsconfig.json index 7af1dd074cb8..99148db543df 100644 --- a/scripts/js-api/build-types/templates/tsconfig.test.json +++ b/packages/react-native/__typetests__/tsconfig.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "module": "esnext", "lib": ["es2020"], @@ -9,9 +10,12 @@ "jsx": "react", "noEmit": true, "forceConsistentCasingInFileNames": true, - "paths": {"react-native": ["."]}, "moduleResolution": "bundler", - "customConditions": ["react-native-strict-api"] + // [macOS] Resolve upstream test imports to the fork's generated API. + "paths": { + "react-native": ["../types_generated/index.d.ts"] + } }, - "include": ["**/*.d.ts", "../__typetests__/**/*"] + "include": ["**/*"], + "exclude": ["macos-public-api.ts"] } diff --git a/packages/react-native/__typetests__/tsconfig.legacy.json b/packages/react-native/__typetests__/tsconfig.legacy.json new file mode 100644 index 000000000000..0f310fcd60a3 --- /dev/null +++ b/packages/react-native/__typetests__/tsconfig.legacy.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + // Legacy variant: Opts into the `"react-native-legacy-deep-imports"` export + // condition so `react-native` and `react-native/Libraries/*` resolve to the + // hand-written types in `types/`. + "extends": "./tsconfig.json", + "compilerOptions": { + "customConditions": ["react-native-legacy-deep-imports"], + // [macOS] Match the legacy variant to the fork's hand-written types. + "paths": { + "react-native": ["../types/index.d.ts"], + "react-native/*": ["../*"] + } + } +} diff --git a/packages/react-native/__typetests__/tsconfig.macos-public-api.json b/packages/react-native/__typetests__/tsconfig.macos-public-api.json new file mode 100644 index 000000000000..1086bd9c6910 --- /dev/null +++ b/packages/react-native/__typetests__/tsconfig.macos-public-api.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "paths": {}, + "customConditions": [] + }, + "include": ["macos-public-api.ts"], + "exclude": [] +} diff --git a/packages/react-native/index.js b/packages/react-native/index.js index a2f8a7e3cc47..a681bd122cb7 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -149,9 +149,6 @@ module.exports = { get TextInput() { return require('./Libraries/Components/TextInput/TextInput').default; }, - get Touchable() { - return require('./Libraries/Components/Touchable/Touchable').default; - }, get TouchableHighlight() { return require('./Libraries/Components/Touchable/TouchableHighlight') .default; @@ -423,6 +420,21 @@ module.exports = { // #endregion } as ReactNativePublicAPI; +// `Touchable` has been removed from the public API types, but remains +// re-exported at runtime here because of a hanging `react-native-svg` call +// site (fbsource). +// TODO(huntie): Remove this re-export once `react-native-svg` is updated. +/* $FlowFixMe[prop-missing] This is intentional: `Touchable` is a value-only + * re-export that is absent from the public API types. */ +/* $FlowFixMe[invalid-export] This is intentional: `Touchable` is a value-only + * re-export that is absent from the public API types. */ +Object.defineProperty(module.exports, 'Touchable', { + configurable: true, + get() { + return require('./Libraries/Components/Touchable/Touchable').default; + }, +}); + if (__DEV__) { /* $FlowFixMe[prop-missing] This is intentional: Flow will error when * attempting to access InteractionManager. */ diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 615b92991929..93e4f39ca4d2 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -175,8 +175,6 @@ export type { } from './Libraries/Components/TextInput/TextInput'; export {default as TextInput} from './Libraries/Components/TextInput/TextInput'; -export {default as Touchable} from './Libraries/Components/Touchable/Touchable'; - export type { TouchableHighlightInstance, TouchableHighlightProps, @@ -468,6 +466,16 @@ export {default as Vibration} from './Libraries/Vibration/Vibration'; // #endregion // #region APIs (types only) +// [macOS] Preserve the public manual type signatures in the generated API. +export type { + SystemEffectMacOS, + TextInputMacOSProps, + ViewPropsMacOS, + NativeKeyEvent, + DragEvent, + PasteEvent, +} from './src/types/macos'; + export type { BlurEvent, FocusEvent, diff --git a/packages/react-native/jest-preset.js b/packages/react-native/jest-preset.js deleted file mode 100644 index 5251fcdbd0d2..000000000000 --- a/packages/react-native/jest-preset.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @noflow - * @format - */ - -'use strict'; - -try { - module.exports = require('@react-native/jest-preset'); -} catch (error) { - if (error.code === 'MODULE_NOT_FOUND') { - throw new Error( - `The React Native Jest preset has moved to a separate package. -To migrate, please install "@react-native/jest-preset" and update your -jest.config.js to reference: - preset: '@react-native/jest-preset'`, - ); - } else { - throw error; - } -} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index b414e79587d5..c3a7cc0d3309 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -28,33 +28,36 @@ "react-native-macos": "cli.js" }, "main": "./index.js", - "types": "types", "exports": { ".": { - "react-native-strict-api": "./types_generated/index.d.ts", - "types": "./types/index.d.ts", + "react-native-legacy-deep-imports": "./types/index.d.ts", + "types": "./types_generated/index.d.ts", "default": "./index.js" }, "./Libraries/*": { - "react-native-strict-api": null, - "types": "./Libraries/*.d.ts", + "react-native-legacy-deep-imports": "./Libraries/*.d.ts", + "types": null, "default": "./Libraries/*.js" }, "./Libraries/*.js": { - "react-native-strict-api": null, + "types": null, "default": "./Libraries/*.js" }, "./scripts/*": "./scripts/*", - "./src/*": { - "types": null, - "default": "./src/*.js" - }, "./asset-registry": { "types": null, "default": "./src/asset-registry.js" }, - "./jest-preset": "./jest-preset.js", - "./rn-get-polyfills": "./rn-get-polyfills.js", + "./react-private-interface": { + "types": null, + "default": "./src/react-private-interface.js" + }, + "./setup-env": "./src/setup-env.js", + "./unstable-internals-do-not-use": { + "react-native-unstable-internals": "./src/unstable-internals-do-not-use.d.ts", + "types": null, + "default": "./src/unstable-internals-do-not-use.js" + }, "./src/fb_internal/*": "./src/fb_internal/*", "./package.json": "./package.json" }, @@ -70,7 +73,6 @@ "gradle/libs.versions.toml", "index.js", "index.js.flow", - "jest-preset.js", "Libraries", "local-cli", "LICENSE", @@ -92,7 +94,6 @@ "ReactApple", "ReactCommon", "README.md", - "rn-get-polyfills.js", "scripts/replace-rncore-version.js", "scripts/bundle.js", "scripts/cocoapods/**", @@ -140,14 +141,10 @@ "featureflags": "node ./scripts/featureflags/index.js" }, "peerDependencies": { - "@react-native/jest-preset": "workspace:*", "@types/react": "^19.1.1", "react": "^19.2.3" }, "peerDependenciesMeta": { - "@react-native/jest-preset": { - "optional": true - }, "@types/react": { "optional": true } @@ -158,7 +155,6 @@ "@react-native/codegen": "workspace:*", "@react-native/community-cli-plugin": "workspace:*", "@react-native/gradle-plugin": "workspace:*", - "@react-native/js-polyfills": "workspace:*", "@react-native/normalize-colors": "workspace:*", "anser": "^1.4.9", "ansi-regex": "^5.0.0", @@ -170,9 +166,9 @@ "hermes-compiler": "250829098.0.13", "invariant": "^2.2.4", "memoize-one": "^5.0.0", - "metro": "^0.86.0", - "metro-runtime": "^0.86.0", - "metro-source-map": "^0.86.0", + "metro": "^0.87.0", + "metro-runtime": "^0.87.0", + "metro-source-map": "^0.87.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", diff --git a/packages/react-native/rn-get-polyfills.js b/packages/react-native/rn-get-polyfills.js deleted file mode 100644 index bf0d0428de37..000000000000 --- a/packages/react-native/rn-get-polyfills.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -'use strict'; - -module.exports = require('@react-native/js-polyfills'); diff --git a/packages/react-native/src/asset-registry.js b/packages/react-native/src/asset-registry.js index e98f442e7694..6d3a4cdf34b4 100644 --- a/packages/react-native/src/asset-registry.js +++ b/packages/react-native/src/asset-registry.js @@ -11,7 +11,7 @@ 'use strict'; // ---------------------------------------------------------------------------- -// Secondary react-native/asset-registry entry point. +// react-native/asset-registry // // This is an untyped secondary entry point intended to be referenced from // Metro's `transformer.assetRegistryPath` config option. This entry point may diff --git a/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js b/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js index c319ddbdc061..2172cb72b6a4 100644 --- a/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js +++ b/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js @@ -14,6 +14,25 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; declare var PerformanceObserverEntryList: unknown; declare var EventCounts: unknown; +function expectGlobalDescriptorToMatch( + name: string, + expected: { + enumerable: boolean, + configurable: boolean, + writable: boolean, + }, +) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + if (descriptor == null) { + throw new Error(`Expected globalThis.${name} to be defined`); + } + expect({ + enumerable: descriptor.enumerable, + configurable: descriptor.configurable, + writable: descriptor.writable, + }).toEqual(expected); +} + describe('setUpDefaultReactNativeEnvironment (globals)', () => { describe('global object', () => { it('should be exposed as globalThis, global, window and self', () => { @@ -23,6 +42,56 @@ describe('setUpDefaultReactNativeEnvironment (globals)', () => { }); }); + describe('property descriptors', () => { + it('should define window as enumerable but not configurable or writable', () => { + expectGlobalDescriptorToMatch('window', { + enumerable: true, + configurable: false, + writable: false, + }); + }); + + it('should define self as configurable, enumerable and writable', () => { + expectGlobalDescriptorToMatch('self', { + enumerable: true, + configurable: true, + writable: true, + }); + }); + + it('should define navigator as configurable and enumerable but not writable', () => { + expectGlobalDescriptorToMatch('navigator', { + enumerable: true, + configurable: true, + writable: false, + }); + }); + + it('should define Infinity as not configurable, writable or enumerable', () => { + expectGlobalDescriptorToMatch('Infinity', { + enumerable: false, + configurable: false, + writable: false, + }); + }); + + it('should define NaN as not configurable, writable or enumerable', () => { + expectGlobalDescriptorToMatch('NaN', { + enumerable: false, + configurable: false, + writable: false, + }); + }); + + it('should define undefined as not configurable, writable or enumerable', () => { + expectGlobalDescriptorToMatch('undefined', { + enumerable: false, + configurable: false, + writable: false, + }); + }); + }); + describe('environment', () => { it('should provide process.env.NODE_ENV', () => { expect(process.env.NODE_ENV).toBe('development'); diff --git a/packages/react-native/src/react-private-interface.js b/packages/react-native/src/react-private-interface.js new file mode 100644 index 000000000000..6e965490ebc9 --- /dev/null +++ b/packages/react-native/src/react-private-interface.js @@ -0,0 +1,145 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +// ---------------------------------------------------------------------------- +// react-native/react-private-interface +// +// This is a private entry point allowing React to require React Native +// internals (previously, Libaries/ReactNativePrivateInterface.js). +// +// These APIs should ONLY be used by first party React internals and are not +// part of our public API. +// +// IMPORTANT: Keep this file in sync with react-private-interface.js.flow. +// ---------------------------------------------------------------------------- + +import typeof BatchedBridge from '../Libraries/BatchedBridge/BatchedBridge'; +import typeof legacySendAccessibilityEvent from '../Libraries/Components/AccessibilityInfo/legacySendAccessibilityEvent'; +import typeof TextInputState from '../Libraries/Components/TextInput/TextInputState'; +import typeof ExceptionsManager from '../Libraries/Core/ExceptionsManager'; +import typeof RawEventEmitter from '../Libraries/Core/RawEventEmitter'; +import typeof ReactFiberErrorDialog from '../Libraries/Core/ReactFiberErrorDialog'; +import typeof RCTEventEmitter from '../Libraries/EventEmitter/RCTEventEmitter'; +import typeof { + createPublicInstance, + createPublicRootInstance, + createPublicTextInstance, + getInternalInstanceHandleFromPublicInstance, + getNativeTagFromPublicInstance, + getNodeFromPublicInstance, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +import typeof { + create as createAttributePayload, + diff as diffAttributePayloads, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; +import typeof UIManager from '../Libraries/ReactNative/UIManager'; +import typeof * as ReactNativeViewConfigRegistry from '../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; +import typeof flattenStyle from '../Libraries/StyleSheet/flattenStyle'; +import type {DangerouslyImpreciseStyleProp} from '../Libraries/StyleSheet/StyleSheet'; +import typeof deepFreezeAndThrowOnMutationInDev from '../Libraries/Utilities/deepFreezeAndThrowOnMutationInDev'; +import typeof deepDiffer from '../Libraries/Utilities/differ/deepDiffer'; +import typeof Platform from '../Libraries/Utilities/Platform'; +import typeof dispatchNativeEvent from './private/renderer/events/dispatchNativeEvent'; +import typeof CustomEvent from './private/webapis/dom/events/CustomEvent'; + +export type {PublicRootInstance} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +export type PublicTextInstance = ReturnType; + +// flowlint unsafe-getters-setters:off +// eslint-disable-next-line @react-native/monorepo/no-commonjs-exports +module.exports = { + get BatchedBridge(): BatchedBridge { + return require('../Libraries/BatchedBridge/BatchedBridge').default; + }, + get ExceptionsManager(): ExceptionsManager { + return require('../Libraries/Core/ExceptionsManager').default; + }, + get Platform(): Platform { + return require('../Libraries/Utilities/Platform').default; + }, + get RCTEventEmitter(): RCTEventEmitter { + return require('../Libraries/EventEmitter/RCTEventEmitter').default; + }, + get ReactNativeViewConfigRegistry(): ReactNativeViewConfigRegistry { + return require('../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'); + }, + get TextInputState(): TextInputState { + return require('../Libraries/Components/TextInput/TextInputState').default; + }, + get UIManager(): UIManager { + return require('../Libraries/ReactNative/UIManager').default; + }, + // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` + get deepDiffer(): deepDiffer { + return require('../Libraries/Utilities/differ/deepDiffer').default; + }, + get deepFreezeAndThrowOnMutationInDev(): deepFreezeAndThrowOnMutationInDev< + {...} | Array, + > { + return require('../Libraries/Utilities/deepFreezeAndThrowOnMutationInDev') + .default; + }, + // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` + get flattenStyle(): flattenStyle { + // $FlowFixMe[underconstrained-implicit-instantiation] + // $FlowFixMe[incompatible-type] + return require('../Libraries/StyleSheet/flattenStyle').default; + }, + get ReactFiberErrorDialog(): ReactFiberErrorDialog { + return require('../Libraries/Core/ReactFiberErrorDialog').default; + }, + get legacySendAccessibilityEvent(): legacySendAccessibilityEvent { + return require('../Libraries/Components/AccessibilityInfo/legacySendAccessibilityEvent') + .default; + }, + get RawEventEmitter(): RawEventEmitter { + return require('../Libraries/Core/RawEventEmitter').default; + }, + get CustomEvent(): CustomEvent { + return require('./private/webapis/dom/events/CustomEvent').default; + }, + get createAttributePayload(): createAttributePayload { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') + .create; + }, + get diffAttributePayloads(): diffAttributePayloads { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') + .diff; + }, + get createPublicRootInstance(): createPublicRootInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .createPublicRootInstance; + }, + get createPublicInstance(): createPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .createPublicInstance; + }, + get createPublicTextInstance(): createPublicTextInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .createPublicTextInstance; + }, + get getNativeTagFromPublicInstance(): getNativeTagFromPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .getNativeTagFromPublicInstance; + }, + get getNodeFromPublicInstance(): getNodeFromPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .getNodeFromPublicInstance; + }, + get getInternalInstanceHandleFromPublicInstance(): getInternalInstanceHandleFromPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .getInternalInstanceHandleFromPublicInstance; + }, + get dispatchNativeEvent(): dispatchNativeEvent { + return require('./private/renderer/events/dispatchNativeEvent').default; + }, +}; diff --git a/packages/react-native/src/react-private-interface.js.flow b/packages/react-native/src/react-private-interface.js.flow new file mode 100644 index 000000000000..b30331d119f8 --- /dev/null +++ b/packages/react-native/src/react-private-interface.js.flow @@ -0,0 +1,48 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +// ---------------------------------------------------------------------------- +// Types entry point for react-native/react-private-interface +// +// IMPORTANT: Keep this file in sync with react-private-interface.js. +// ---------------------------------------------------------------------------- + +import typeof {createPublicTextInstance as createPublicTextInstanceT} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; + +export type {PublicRootInstance} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +export type PublicTextInstance = ReturnType; + +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 ReactNativeViewConfigRegistry from '../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; +export {default as TextInputState} from '../Libraries/Components/TextInput/TextInputState'; +export {default as UIManager} from '../Libraries/ReactNative/UIManager'; +export {default as deepDiffer} from '../Libraries/Utilities/differ/deepDiffer'; +export {default as deepFreezeAndThrowOnMutationInDev} from '../Libraries/Utilities/deepFreezeAndThrowOnMutationInDev'; +export {default as flattenStyle} from '../Libraries/StyleSheet/flattenStyle'; +export {default as ReactFiberErrorDialog} from '../Libraries/Core/ReactFiberErrorDialog'; +export {default as legacySendAccessibilityEvent} from '../Libraries/Components/AccessibilityInfo/legacySendAccessibilityEvent'; +export {default as RawEventEmitter} from '../Libraries/Core/RawEventEmitter'; +export {default as CustomEvent} from './private/webapis/dom/events/CustomEvent'; +export { + create as createAttributePayload, + diff as diffAttributePayloads, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; +export { + createPublicRootInstance, + createPublicInstance, + createPublicTextInstance, + getNativeTagFromPublicInstance, + getNodeFromPublicInstance, + getInternalInstanceHandleFromPublicInstance, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +export {default as dispatchNativeEvent} from './private/renderer/events/dispatchNativeEvent'; diff --git a/packages/react-native/src/setup-env.js b/packages/react-native/src/setup-env.js new file mode 100644 index 000000000000..fbe7f9178b31 --- /dev/null +++ b/packages/react-native/src/setup-env.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; +'use client'; + +// ---------------------------------------------------------------------------- +// react-native/setup-env +// +// Side-effectful module that sets up the core React Native JavaScript +// environment. This includes global timers (`setTimeout` etc), the global +// `console` object, and hooks for printing stack traces with source maps. +// ---------------------------------------------------------------------------- + +require('./private/setup/setUpDefaultReactNativeEnvironment').default(); diff --git a/packages/react-native/src/types/macos.d.ts b/packages/react-native/src/types/macos.d.ts new file mode 100644 index 000000000000..0229c107ec58 --- /dev/null +++ b/packages/react-native/src/types/macos.d.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +// [macOS] Preserve the public TypeScript contracts during Strict API adoption. +// Match the manual declarations without importing the legacy component graph. +/// +import type { + DragEvent, + HandledKeyEvent, + KeyDownEvent, + KeyUpEvent, + MouseEvent, + NativeSyntheticEvent, +} from '../../Libraries/Types/CoreEventTypes'; + +export type { + DragEvent, + NativeKeyEvent, +} from '../../Libraries/Types/CoreEventTypes'; + +export type SystemEffectMacOS = + | 'none' + | 'pressed' + | 'deepPressed' + | 'disabled' + | 'rollover'; + +export type PasteEvent = NativeSyntheticEvent<{ + dataTransfer: { + files: { + height: number; + size: number; + type: string; + uri: string; + width: number; + }[]; + items: {kind: string; type: string}[]; + types: string[]; + }; +}>; + +type SettingChangeEvent = NativeSyntheticEvent<{ + autoCorrectEnabled: boolean; + spellCheckEnabled: boolean; + grammarCheckEnabled: boolean; +}>; + +type SubmitKeyEvent = { + key: string; + altKey?: boolean | undefined; + ctrlKey?: boolean | undefined; + metaKey?: boolean | undefined; + shiftKey?: boolean | undefined; + functionKey?: boolean | undefined; +}; + +type PasteType = 'fileUrl' | 'image' | 'string'; + +export interface TextInputMacOSProps { + clearTextOnSubmit?: boolean | undefined; + grammarCheck?: boolean | undefined; + hideVerticalScrollIndicator?: boolean | undefined; + onPaste?: ((event: PasteEvent) => void) | undefined; + onAutoCorrectChange?: ((event: SettingChangeEvent) => void) | undefined; + onSpellCheckChange?: ((event: SettingChangeEvent) => void) | undefined; + onGrammarCheckChange?: ((event: SettingChangeEvent) => void) | undefined; + pastedTypes?: PasteType | PasteType[] | undefined; + submitKeyEvents?: SubmitKeyEvent[] | undefined; + tooltip?: string | undefined; +} + +export interface ViewPropsMacOS { + acceptsFirstMouse?: boolean | undefined; + allowsVibrancy?: boolean | undefined; + mouseDownCanMoveWindow?: boolean | undefined; + enableFocusRing?: boolean | undefined; + onMouseEnter?: ((event: MouseEvent) => void) | undefined; + onMouseLeave?: ((event: MouseEvent) => void) | undefined; + onDoubleClick?: ((event: MouseEvent) => void) | undefined; + // Match ViewPropTypes.d.ts, which uses the global PointerEvent here. + onAuxClick?: ((event: PointerEvent) => void) | undefined; + onAuxClickCapture?: ((event: PointerEvent) => void) | undefined; + onDragEnter?: ((event: DragEvent) => void) | undefined; + onDragLeave?: ((event: DragEvent) => void) | undefined; + onDrop?: ((event: DragEvent) => void) | undefined; + onKeyDown?: ((event: KeyDownEvent) => void) | undefined; + onKeyUp?: ((event: KeyUpEvent) => void) | undefined; + keyDownEvents?: HandledKeyEvent[] | undefined; + keyUpEvents?: HandledKeyEvent[] | undefined; + validKeysDown?: readonly (string | HandledKeyEvent)[] | undefined; + validKeysUp?: readonly (string | HandledKeyEvent)[] | undefined; + passthroughAllKeyEvents?: boolean | undefined; + draggedTypes?: PasteType | PasteType[] | undefined; +} diff --git a/packages/react-native/src/types/macos.js b/packages/react-native/src/types/macos.js new file mode 100644 index 000000000000..08add09c7692 --- /dev/null +++ b/packages/react-native/src/types/macos.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +// [macOS] Flow consumers use the runtime source definitions. The companion +// macos.d.ts preserves the existing TypeScript signatures, including event +// wrappers and mutable arrays that differ from the internal Flow contracts. +export type {SystemEffectMacOS} from '../../Libraries/StyleSheet/PlatformColorValueTypesMacOS'; +export type { + PasteEvent, + TextInputMacOSProps, +} from '../../Libraries/Components/TextInput/TextInput.flow'; +export type { + DragEvent, + KeyboardEventPayload as NativeKeyEvent, +} from '../../Libraries/Types/CoreEventTypes'; +export type {ViewPropsMacOS} from '../../Libraries/Components/View/ViewPropTypes'; diff --git a/packages/react-native/src/unstable-internals-do-not-use.d.ts b/packages/react-native/src/unstable-internals-do-not-use.d.ts new file mode 100644 index 000000000000..6c1f3bf54c40 --- /dev/null +++ b/packages/react-native/src/unstable-internals-do-not-use.d.ts @@ -0,0 +1,214 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +// ---------------------------------------------------------------------------- +// Types entry point for react-native/unstable-internals-do-not-use. +// +// IMPORTANT: Keep this file in sync with unstable-internals-do-not-use.js. +// ---------------------------------------------------------------------------- + +import type * as React from 'react'; + +// #region AppContainer + +interface AppContainerProps { + children?: React.ReactNode | undefined; + rootTag: number; + initialProps?: object | undefined; + WrapperComponent?: React.ComponentType | null | undefined; + rootViewStyle?: unknown | undefined; + internal_excludeLogBox?: boolean | undefined; + internal_excludeInspector?: boolean | undefined; +} + +/** Root component that wraps and mounts a React Native app tree. */ +export const AppContainer: React.ComponentType; + +// #endregion +// #region AssetSourceResolver + +interface ResolvedAssetSource { + readonly __packager_asset: boolean; + readonly width: number | null | undefined; + readonly height: number | null | undefined; + readonly uri: string; + readonly scale: number; +} + +/** Resolves a packager asset descriptor to a loadable source for the current platform. */ +export class AssetSourceResolver { + serverUrl: string | null | undefined; + jsbundleUrl: string | null | undefined; + asset: unknown; + constructor( + serverUrl: string | null | undefined, + jsbundleUrl: string | null | undefined, + asset: unknown, + ); + isLoadedFromServer(): boolean; + isLoadedFromFileSystem(): boolean; + defaultAsset(): ResolvedAssetSource; + getAssetUsingResolver(resolver: 'android' | 'generic'): ResolvedAssetSource; + assetServerURL(): ResolvedAssetSource; + scaledAssetPath(): ResolvedAssetSource; + scaledAssetURLNearBundle(): ResolvedAssetSource; + resourceIdentifierWithoutScale(): ResolvedAssetSource; + drawableFolderInBundle(): ResolvedAssetSource; + fromSource(source: string): ResolvedAssetSource; + static pickScale(scales: number[], deviceScale?: number): number; +} + +// #endregion +// #region customDirectEventTypes + +/** Registry mapping custom direct (non-bubbling) event names to their registration names. */ +export const customDirectEventTypes: { + [eventName: string]: Readonly<{ + registrationName: string; + }>; +}; + +// #endregion +// #region DevLoadingView + +/** Dev-only overlay banner showing bundle load, refresh, and error status. */ +export const DevLoadingView: { + showMessage( + message: string, + type: 'load' | 'refresh' | 'error', + options?: {dismissButton?: boolean | undefined}, + ): void; + hide(): void; +}; + +// #endregion +// #region getDevServer + +interface DevServerInfo { + url: string; + fullBundleUrl: string | null; + bundleLoadedFromServer: boolean; +} + +/** Returns information about the running dev server. */ +export function getDevServer(): DevServerInfo; + +// #endregion +// #region HMRClient + +/** Client that receives Fast Refresh updates and applies them at runtime. */ +export class HMRClient { + enable(): void; + disable(): void; + registerBundle(requestUrl: string): void; + log( + level: + | 'trace' + | 'info' + | 'warn' + | 'error' + | 'log' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'debug', + data: ReadonlyArray, + ): void; + setup( + platform: string, + bundleEntry: string, + host: string, + port: number | string, + isEnabled: boolean, + scheme?: string, + ): void; +} + +// #endregion +// #region NativeExceptionsManager + +interface StackFrame { + column: number | null; + file: string | null; + lineNumber: number | null; + methodName: string; + collapse?: boolean | undefined; +} + +interface ExceptionData { + message: string; + originalMessage: string | null; + name: string | null; + componentStack: string | null; + stack: StackFrame[]; + id: number; + isFatal: boolean; + extraData?: object | undefined; +} + +/** Reports JS exceptions to native and manages RedBox. */ +export const NativeExceptionsManager: { + reportFatalException( + message: string, + stack: StackFrame[], + exceptionId: number, + ): void; + reportSoftException( + message: string, + stack: StackFrame[], + exceptionId: number, + ): void; + dismissRedbox(): void; + reportException(data: ExceptionData): void; +}; + +// #endregion +// #region NativeRedBox + +interface NativeRedBoxSpec { + setExtraData(extraData: object, forIdentifier: string): void; + dismiss(): void; +} + +/** Native module for the RedBox error overlay; null when unavailable. */ +export const NativeRedBox: NativeRedBoxSpec | null; + +// #endregion +// #region NativeSourceCode + +interface SourceCodeConstants { + scriptURL: string; +} + +/** Native module exposing source-code constants such as the bundle scriptURL. */ +export const NativeSourceCode: { + getConstants(): SourceCodeConstants; +}; + +// #endregion +// #region PressabilityDebugView + +type Rect = Readonly<{ + bottom?: number | null | undefined; + left?: number | null | undefined; + right?: number | null | undefined; + top?: number | null | undefined; +}>; + +type RectOrSize = Rect | number; + +interface PressabilityDebugViewProps { + color: unknown; + hitSlop: RectOrSize | null | undefined; +} + +/** Debug overlay that visualizes press targets when enabled via the Inspector. */ +export const PressabilityDebugView: React.ComponentType; + +// #endregion diff --git a/packages/react-native/src/unstable-internals-do-not-use.js b/packages/react-native/src/unstable-internals-do-not-use.js new file mode 100644 index 000000000000..20cb19edaec0 --- /dev/null +++ b/packages/react-native/src/unstable-internals-do-not-use.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; +'use client'; + +// ---------------------------------------------------------------------------- +// react-native/unstable-internals-do-not-use +// +// UNSTABLE WITH NO SEMVER GUARANTEES. +// SHOULD NOT BE DEPENDED ON BY NEW CODE. +// +// This is a secondary entry point for frameworks and libraries that depend on +// specific React Native internals, serving as a compatibility bridge. +// +// Consuming codebases must opt in via tsconfig.json: +// "customConditions": ["react-native-unstable-internals"] +// +// Having this entry point: +// - Maintains a known list of which React Native internals are in use. +// - Enables us to relocate supporting files more freely. +// - Gives us time to decide on the future of these APIs (independent from +// removal of the Strict API opt out). +// +// The long term future of these exports is to formalize/delete them where +// appropriate, and collapse this entry point. +// +// Replaces RFC0985 +// https://github.com/react-native-community/discussions-and-proposals/pull/985 +// where we reviewed internal APIs used in the ecosystem. +// +// IMPORTANT: Keep this file in sync with unstable-internals-do-not-use.d.ts. +// ---------------------------------------------------------------------------- + +// eslint-disable-next-line @react-native/monorepo/no-commonjs-exports +module.exports = { + get AppContainer() { + return require('../Libraries/ReactNative/AppContainer').default; + }, + get AssetSourceResolver() { + return require('../Libraries/Image/AssetSourceResolver').default; + }, + get customDirectEventTypes() { + return require('../Libraries/Renderer/shims/ReactNativeViewConfigRegistry') + .customDirectEventTypes; + }, + get DevLoadingView() { + return require('../Libraries/Utilities/DevLoadingView').default; + }, + get getDevServer() { + return require('../Libraries/Core/Devtools/getDevServer').default; + }, + get HMRClient() { + return require('../Libraries/Utilities/HMRClient').default; + }, + get NativeExceptionsManager() { + return require('../Libraries/Core/NativeExceptionsManager').default; + }, + get NativeRedBox() { + return require('../Libraries/NativeModules/specs/NativeRedBox').default; + }, + get NativeSourceCode() { + return require('../Libraries/NativeModules/specs/NativeSourceCode').default; + }, + get PressabilityDebugView() { + return require('../Libraries/Pressability/PressabilityDebug') + .PressabilityDebugView; + }, +}; diff --git a/packages/react-native/types/tsconfig.json b/packages/react-native/types/tsconfig.json deleted file mode 100644 index 71a77f17accd..000000000000 --- a/packages/react-native/types/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "lib": ["es6"], - "strict": false, - "noImplicitAny": true, - "noImplicitThis": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "types": [], - "jsx": "react", - "noEmit": true, - "forceConsistentCasingInFileNames": true, - "paths": { - "react-native": ["."], - "react-native/*": ["../*"] - } - }, - "include": ["**/*.d.ts", "../__typetests__/**/*"] -} diff --git a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js index c1f9cf2c760a..1cbc4335348f 100644 --- a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js +++ b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js @@ -10,7 +10,8 @@ 'use strict'; -require('react-native/Libraries/Core/InitializeCore'); +require('react-native/setup-env'); + const React = require('react'); const ReactNative = require('react-native'); diff --git a/packages/typescript-config/README.md b/packages/typescript-config/README.md index d58ee3b56455..bf53f0c39a6a 100644 --- a/packages/typescript-config/README.md +++ b/packages/typescript-config/README.md @@ -9,25 +9,18 @@ This package provides the default `tsconfig.json` used by newly built React Nati This template is customized for specific versions of React Native, and should be updated in sync with the rest of your app. -## Strict TypeScript API +## Opting out of the Strict TypeScript API (default in 0.87) -To opt into the new [strict TypeScript API](https://reactnative.dev/blog/2025/06/12/moving-towards-a-stable-javascript-api#strict-typescript-api-opt-in) you can extend from `@react-native/typescript-config/strict` +To opt out of the new [Strict TypeScript API](https://reactnative.dev/docs/strict-typescript-api) you can extend from `@react-native/typescript-config/strict` -```jsonc -{ - "extends": "@react-native/typescript-config/strict", - // ... -} -``` - -or alternatively add the `customConditions` yourself: +If your app still needs access to deep `'react-native/Libraries/*'` imports (deprecated in 0.80), you can opt out via `customConditions` in your `tsconfig.json`: ```jsonc { "extends": "@react-native/typescript-config", "compilerOptions": { // ... - "customConditions": ["react-native-strict-api", "react-native"] + "customConditions": ["react-native", "react-native-legacy-deep-imports"] } } ``` diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 72bb2e53f490..b80428ab5cb1 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -16,8 +16,7 @@ ], "bugs": "https://github.com/react/react-native/issues", "exports": { - ".": "./tsconfig.json", - "./strict": "./tsconfig.strict.json" + ".": "./tsconfig.json" }, "private": true } diff --git a/packages/typescript-config/tsconfig.strict.json b/packages/typescript-config/tsconfig.strict.json deleted file mode 100644 index c060fa2a4191..000000000000 --- a/packages/typescript-config/tsconfig.strict.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "display": "React Native (Strict)", - "compilerOptions": { - "customConditions": ["react-native-strict-api", "react-native"] - } -} diff --git a/packages/virtualized-lists/package.json b/packages/virtualized-lists/package.json index 01d6519152d6..6bd5842abd6b 100644 --- a/packages/virtualized-lists/package.json +++ b/packages/virtualized-lists/package.json @@ -21,8 +21,8 @@ }, "exports": { ".": { - "react-native-strict-api": "./types_generated/index.d.ts", - "types": "./index.d.ts", + "react-native-legacy-deep-imports": "./index.d.ts", + "types": "./types_generated/index.d.ts", "default": "./index.js" }, "./*": { diff --git a/private/core-cli-utils/package.json b/private/core-cli-utils/package.json index b8227e1a4f5b..1e4a4886ff1d 100644 --- a/private/core-cli-utils/package.json +++ b/private/core-cli-utils/package.json @@ -11,7 +11,7 @@ "./version.js": "./src/public/version.js" }, "dependencies": { - "metro-babel-register": "^0.86.0" + "metro-babel-register": "^0.87.0" }, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" diff --git a/private/helloworld/jest.config.js b/private/helloworld/jest.config.js index 26ef92b73cee..3b10bdf375da 100644 --- a/private/helloworld/jest.config.js +++ b/private/helloworld/jest.config.js @@ -9,5 +9,5 @@ */ module.exports = { - preset: 'react-native', + preset: '@react-native/jest-preset', }; diff --git a/private/helloworld/package.json b/private/helloworld/package.json index 0287cd2c8a5b..4c3934f27c20 100644 --- a/private/helloworld/package.json +++ b/private/helloworld/package.json @@ -22,6 +22,7 @@ "@react-native/babel-preset": "0.87.0-main", "@react-native/core-cli-utils": "*", "@react-native/eslint-config": "0.87.0-main", + "@react-native/jest-preset": "0.87.0-main", "@react-native/metro-config": "0.87.0-main", "@react-native/typescript-config": "0.87.0-main", "@types/jest": "^29.5.14", diff --git a/private/react-native-fantom/package.json b/private/react-native-fantom/package.json index 7f6dcd09f49b..0ceb712e7eda 100644 --- a/private/react-native-fantom/package.json +++ b/private/react-native-fantom/package.json @@ -11,8 +11,8 @@ "babel-plugin-istanbul": "^7.0.0", "hermes-parser": "0.36.1", "istanbul-lib-coverage": "^3.2.0", - "metro": "^0.84.3", - "metro-babel-transformer": "^0.84.3" + "metro": "^0.87.0", + "metro-babel-transformer": "^0.87.0" }, "peerDependencies": { "jest": "^29.7.0", diff --git a/scripts/js-api/build-types/__tests__/translateSourceFile-test.js b/scripts/js-api/build-types/__tests__/translateSourceFile-test.js new file mode 100644 index 000000000000..3dab0efe81b7 --- /dev/null +++ b/scripts/js-api/build-types/__tests__/translateSourceFile-test.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const {REACT_NATIVE_PACKAGE_DIR} = require('../../../shared/consts'); +const translateSourceFile = require('../translateSourceFile'); +const {promises: fs} = require('fs'); +const path = require('path'); + +test('direct translation preserves the macOS shim used by full and watch builds', async () => { + const file = path.join(REACT_NATIVE_PACKAGE_DIR, 'src/types/macos.js'); + const source = await fs.readFile(file, 'utf8'); + for (const input of [source, source + '\n// Watch-mode source update\n']) { + const {result, dependencies} = await translateSourceFile(input, file); + expect(result).toBe('export type * from "../../../src/types/macos";\n'); + expect(dependencies).toContain( + path.join(REACT_NATIVE_PACKAGE_DIR, 'Libraries/Types/CoreEventTypes.js'), + ); + } +}); + +test('the shim rule does not replace other macos.js modules', async () => { + const {result} = await translateSourceFile( + 'export type Example = {value: string};', + path.join(REACT_NATIVE_PACKAGE_DIR, 'src/other/macos.js'), + ); + expect(result).toContain('value: string'); + expect(result).not.toContain('../../../src/types/macos'); +}); diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index e60d756c57a8..c2dbc9b31869 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -206,6 +206,15 @@ async function preparePackagesInTempDir( }), ); + // [macOS] The public compatibility shim lives outside types_generated. + // Resolve it from the source tree when API Extractor runs in its temp tree. + await fs.writeFile( + path.join(tempDirectory, 'react-native/src/types/macos.d.ts'), + `export type * from ${JSON.stringify( + path.join(REACT_NATIVE_PACKAGE_DIR, 'src/types/macos'), + )};\n`, + ); + const typeDefs = globSync('**/*.d.ts', { cwd: tempDirectory, onlyFiles: true, diff --git a/scripts/js-api/build-types/buildGeneratedTypes.js b/scripts/js-api/build-types/buildGeneratedTypes.js index 1408b1625b50..3ef71abc2d13 100644 --- a/scripts/js-api/build-types/buildGeneratedTypes.js +++ b/scripts/js-api/build-types/buildGeneratedTypes.js @@ -55,26 +55,10 @@ async function buildGeneratedTypes(): Promise> { } } - await Promise.all([ - fs.copyFile( - path.join(__dirname, 'templates', 'tsconfig.json'), - path.join( - PACKAGES_DIR, - 'react-native', - TYPES_OUTPUT_DIR, - 'tsconfig.json', - ), - ), - fs.copyFile( - path.join(__dirname, 'templates', 'tsconfig.test.json'), - path.join( - PACKAGES_DIR, - 'react-native', - TYPES_OUTPUT_DIR, - 'tsconfig.test.json', - ), - ), - ]); + await fs.copyFile( + path.join(__dirname, 'templates', 'tsconfig.json'), + path.join(PACKAGES_DIR, 'react-native', TYPES_OUTPUT_DIR, 'tsconfig.json'), + ); if (allErrors.length > 0) { console.error( diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js new file mode 100644 index 000000000000..c77c99bb3fea --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js @@ -0,0 +1,89 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const applyBabelTransformsSeq = require('../../../utils/applyBabelTransformsSeq'); +const organizeDeclarations = require('../organizeDeclarations'); +const versionExportedApis = require('../versionExportedApis'); +const {parse} = require('@babel/parser'); + +async function exportsAfterPipeline(code: string) { + const result = await applyBabelTransformsSeq(code, [ + organizeDeclarations, + versionExportedApis(true), + ]); + const ast = parse(result, {sourceType: 'module', plugins: ['typescript']}); + return ast.program.body + .filter(node => node.type === 'ExportNamedDeclaration') + .flatMap(node => node.specifiers) + .map(specifier => ({ + local: specifier.local.name, + public: specifier.exported.name, + comment: specifier.trailingComments?.[0]?.value, + })); +} + +describe('organizeDeclarations and versionExportedApis pipeline', () => { + test.each([ + ['type', 'type Local = {value: T};'], + [ + 'runtime function', + 'declare function Local(value: T): T;', + ], + [ + 'runtime value and type', + 'declare const Local: (value: T) => T; type Local = typeof Local;', + ], + ])( + 'hashes an aliased %s from its local generic declaration', + async (_, declaration) => { + const source = ` + type Base = {id: string}; + ${declaration} + export {Local as Public}; + `; + const [original] = await exportsAfterPipeline(source); + expect(original.local).toBe('Local'); + expect(original.public).toBe('Public'); + expect(original.comment).toMatch(/^ [a-f0-9]{8}, Deps: \[Base\]/); + + const [changed] = await exportsAfterPipeline( + source.replace('id: string', 'id: number'), + ); + expect(changed.comment).not.toBe(original.comment); + expect(changed.public).toBe('Public'); + + const [unaliased] = await exportsAfterPipeline( + source.replace('Local as Public', 'Local'), + ); + expect(unaliased.comment).toBe(original.comment); + }, + ); + + test('does not hash an unrelated declaration with the public alias name', async () => { + const source = ` + type Base = {id: string}; + type Public = boolean; + type Local = {value: T}; + export {Local as Public, Local as SecondPublic}; + `; + const original = await exportsAfterPipeline(source); + expect(original.map(item => item.public)).toEqual([ + 'Public', + 'SecondPublic', + ]); + expect(original[0].comment).toMatch(/^ [a-f0-9]{8}, Deps: \[Base\]/); + expect(original[1].comment).toBe(original[0].comment); + expect( + await exportsAfterPipeline( + source.replace('Public = boolean', 'Public = number'), + ), + ).toEqual(original); + }); +}); diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/organizeDeclarations-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/organizeDeclarations-test.js index ae9d9b07e701..148b8e508506 100644 --- a/scripts/js-api/build-types/transforms/typescript/__tests__/organizeDeclarations-test.js +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/organizeDeclarations-test.js @@ -22,6 +22,17 @@ async function translate(code: string): Promise { } describe('organizeDeclarations', () => { + test('preserves public aliases for names disambiguated by API Extractor', async () => { + const result = await translate(` + declare interface DragEvent_2 { nativeEvent: {} } + declare const Text_2: unknown; + export { Text_2 as Text, DragEvent_2 as DragEvent }; + `); + expect(result).toContain( + 'export { DragEvent_2 as DragEvent, Text_2 as Text };', + ); + }); + test('should sort declarations and move exports into single export block', async () => { const code = await fs.readFile( path.join(__dirname, '../__fixtures__/organizeDeclarations.d.ts'), diff --git a/scripts/js-api/build-types/transforms/typescript/organizeDeclarations.js b/scripts/js-api/build-types/transforms/typescript/organizeDeclarations.js index 17b65908b9bf..a5134405f421 100644 --- a/scripts/js-api/build-types/transforms/typescript/organizeDeclarations.js +++ b/scripts/js-api/build-types/transforms/typescript/organizeDeclarations.js @@ -22,7 +22,7 @@ import * as t from '@babel/types'; const visitor: PluginObj = { visitor: { Program(path) { - const exportedIdentifiers: Set = new Set(); + const exportedIdentifiers: Map = new Map(); // Collect exported identifiers path.get('body').forEach(nodePath => { @@ -40,14 +40,20 @@ const visitor: PluginObj = { t.isTSEnumDeclaration(declaration) ) { if (declaration.id && declaration.id.name != null) { - exportedIdentifiers.add(declaration.id.name); + exportedIdentifiers.set( + declaration.id.name, + declaration.id.name, + ); } else if ( t.isVariableDeclaration(declaration) && declaration.declarations.length > 0 ) { declaration.declarations.forEach(declarator => { if (t.isIdentifier(declarator.id)) { - exportedIdentifiers.add(declarator.id.name); + exportedIdentifiers.set( + declarator.id.name, + declarator.id.name, + ); } }); } @@ -60,7 +66,13 @@ const visitor: PluginObj = { } else if (nodePath.node.specifiers) { nodePath.node.specifiers.forEach(specifier => { if (specifier.type === 'ExportSpecifier') { - exportedIdentifiers.add(specifier.local.name); + // Preserve public names when API Extractor disambiguates a + // declaration from a global or another imported type. // [macOS] + const exported = specifier.exported; + exportedIdentifiers.set( + t.isIdentifier(exported) ? exported.name : exported.value, + specifier.local.name, + ); } }); nodePath.remove(); // Remove export statement @@ -77,12 +89,15 @@ const visitor: PluginObj = { // Move all exports into single `export {}` block if (exportedIdentifiers.size > 0) { - const sortedIdentifiers = Array.from(exportedIdentifiers).sort(); + const sortedIdentifiers = Array.from(exportedIdentifiers.keys()).sort(); const exportStatement = t.exportNamedDeclaration( // $FlowFixMe[incompatible-type] null, sortedIdentifiers.map(name => - t.exportSpecifier(t.identifier(name), t.identifier(name)), + t.exportSpecifier( + t.identifier(exportedIdentifiers.get(name) ?? name), + t.identifier(name), + ), ), ); path.pushContainer('body', exportStatement); diff --git a/scripts/js-api/build-types/transforms/typescript/versionExportedApis.js b/scripts/js-api/build-types/transforms/typescript/versionExportedApis.js index 5b257e4beb39..99b05e920356 100644 --- a/scripts/js-api/build-types/transforms/typescript/versionExportedApis.js +++ b/scripts/js-api/build-types/transforms/typescript/versionExportedApis.js @@ -232,9 +232,12 @@ function createVersionExportedApis( nodePath.node.specifiers != null ) { const specifiers = nodePath.node.specifiers.map(specifier => { - // $FlowFixMe[incompatible-type] nodePath is refined above - // $FlowFixMe[incompatible-use] - const name: string = specifier.exported.name; + if (!t.isExportSpecifier(specifier)) { + return specifier; + } + // [macOS] Hash the local declaration, retaining the public alias + // on the export specifier (e.g. DragEvent_2 as DragEvent). + const name: string = specifier.local.name; if (declarations.has(name)) { const hash = generateTypeHash(name); let comment = ` ${hash}`; diff --git a/scripts/js-api/build-types/translateSourceFile.js b/scripts/js-api/build-types/translateSourceFile.js index 44bfc2e7f07a..94ec10e915b7 100644 --- a/scripts/js-api/build-types/translateSourceFile.js +++ b/scripts/js-api/build-types/translateSourceFile.js @@ -12,10 +12,12 @@ import type {PluginObj} from '@babel/core'; import type {ParseResult} from 'hermes-transform/dist/transform/parse'; import type {TransformASTResult} from 'hermes-transform/dist/transform/transformAST'; +const {REACT_NATIVE_PACKAGE_DIR} = require('../../shared/consts'); const getDependencies = require('./resolution/getDependencies'); const applyBabelTransformsSeq = require('./utils/applyBabelTransformsSeq'); const translate = require('flow-api-translator'); const {parse, print} = require('hermes-transform'); +const path = require('path'); type PreTransformFn = ParseResult => Promise; @@ -70,6 +72,15 @@ async function translateSourceFile( filePath, ); + // [macOS] Full builds and watch updates must preserve the same public shim. + // Keep the Flow dependencies above so watch mode still tracks source changes. + if (filePath === path.join(REACT_NATIVE_PACKAGE_DIR, 'src/types/macos.js')) { + return { + result: 'export type * from "../../../src/types/macos";\n', + dependencies, + }; + } + // Translate to TypeScript defs const tsDefResult = await translate.translateFlowDefToTSDef( flowDefResult, diff --git a/scripts/run-ci-javascript-tests.js b/scripts/run-ci-javascript-tests.js index 7992b46fbcce..aed03f7f812a 100644 --- a/scripts/run-ci-javascript-tests.js +++ b/scripts/run-ci-javascript-tests.js @@ -81,7 +81,7 @@ try { ); describe('Test: TypeScript tests'); - execAndLog(`${YARN_BINARY} run test-typescript`); + execAndLog(`${YARN_BINARY} run test-typescript-legacy`); } catch (e) { if (e instanceof ExecError) { console.error(e.message); diff --git a/yarn.lock b/yarn.lock index 0a6470cf98b9..1c6ef91c9577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -44,7 +44,7 @@ __metadata: languageName: node linkType: hard -"@babel/compat-data@npm:^7.28.6, @babel/compat-data@npm:^7.29.7": +"@babel/compat-data@npm:^7.29.7": version: 7.29.7 resolution: "@babel/compat-data@npm:7.29.7" checksum: 10c0/47913f05e08a45a1c9df38c02b4b49e391005085b489432647a1abe112e5d9c75e3be8ea5972b7f6da4ec5d1339922ceb9ea02b8a25d4ed1cb8636e5261f344e @@ -145,7 +145,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.28.6, @babel/helper-compilation-targets@npm:^7.29.7": +"@babel/helper-compilation-targets@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-compilation-targets@npm:7.29.7" dependencies: @@ -857,7 +857,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-arrow-functions@npm:^7.24.7, @babel/plugin-transform-arrow-functions@npm:^7.29.7": +"@babel/plugin-transform-arrow-functions@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-arrow-functions@npm:7.29.7" dependencies: @@ -967,7 +967,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-computed-properties@npm:^7.24.7, @babel/plugin-transform-computed-properties@npm:^7.29.7": +"@babel/plugin-transform-computed-properties@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-computed-properties@npm:7.29.7" dependencies: @@ -1094,7 +1094,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-function-name@npm:^7.25.1, @babel/plugin-transform-function-name@npm:^7.29.7": +"@babel/plugin-transform-function-name@npm:^7.25.1": version: 7.29.7 resolution: "@babel/plugin-transform-function-name@npm:7.29.7" dependencies: @@ -1131,7 +1131,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-literals@npm:^7.25.2, @babel/plugin-transform-literals@npm:^7.29.7": +"@babel/plugin-transform-literals@npm:^7.25.2": version: 7.29.7 resolution: "@babel/plugin-transform-literals@npm:7.29.7" dependencies: @@ -1153,7 +1153,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-logical-assignment-operators@npm:^7.24.7, @babel/plugin-transform-logical-assignment-operators@npm:^7.29.7": +"@babel/plugin-transform-logical-assignment-operators@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.29.7" dependencies: @@ -1270,7 +1270,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-numeric-separator@npm:^7.24.7, @babel/plugin-transform-numeric-separator@npm:^7.29.7": +"@babel/plugin-transform-numeric-separator@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-numeric-separator@npm:7.29.7" dependencies: @@ -1340,7 +1340,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-parameters@npm:^7.24.7, @babel/plugin-transform-parameters@npm:^7.29.7": +"@babel/plugin-transform-parameters@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-parameters@npm:7.29.7" dependencies: @@ -1362,7 +1362,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-private-methods@npm:^7.24.4, @babel/plugin-transform-private-methods@npm:^7.29.7": +"@babel/plugin-transform-private-methods@npm:^7.24.4": version: 7.29.7 resolution: "@babel/plugin-transform-private-methods@npm:7.29.7" dependencies: @@ -1509,7 +1509,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-shorthand-properties@npm:^7.24.7, @babel/plugin-transform-shorthand-properties@npm:^7.29.7": +"@babel/plugin-transform-shorthand-properties@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-shorthand-properties@npm:7.29.7" dependencies: @@ -1531,7 +1531,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-spread@npm:^7.24.7, @babel/plugin-transform-spread@npm:^7.29.7": +"@babel/plugin-transform-spread@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-spread@npm:7.29.7" dependencies: @@ -1555,7 +1555,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-sticky-regex@npm:^7.24.7, @babel/plugin-transform-sticky-regex@npm:^7.29.7": +"@babel/plugin-transform-sticky-regex@npm:^7.24.7": version: 7.29.7 resolution: "@babel/plugin-transform-sticky-regex@npm:7.29.7" dependencies: @@ -1796,13 +1796,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.25.2, @babel/runtime@npm:^7.5.5": - version: 7.29.7 - resolution: "@babel/runtime@npm:7.29.7" - checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e - languageName: node - linkType: hard - "@babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.8.4": version: 7.26.9 resolution: "@babel/runtime@npm:7.26.9" @@ -1812,6 +1805,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.25.2, @babel/runtime@npm:^7.5.5": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + languageName: node + linkType: hard + "@babel/template@npm:^7.25.0, @babel/template@npm:^7.29.7": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" @@ -1834,7 +1834,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.16.0, @babel/traverse@npm:^7.25.2, @babel/traverse@npm:^7.29.7": +"@babel/traverse@npm:^7.25.2, @babel/traverse@npm:^7.29.7": version: 7.29.7 resolution: "@babel/traverse@npm:7.29.7" dependencies: @@ -3461,11 +3461,11 @@ __metadata: markdownlint-cli2: "npm:^0.22.0" markdownlint-rule-relative-links: "npm:^3.0.0" memfs: "npm:^4.38.2" - metro: "npm:^0.84.3" - metro-babel-register: "npm:^0.86.0" - metro-config: "npm:^0.84.3" + metro: "npm:^0.87.0" + metro-babel-register: "npm:^0.87.0" + metro-config: "npm:^0.87.0" metro-memory-fs: "npm:^0.82.5" - metro-transform-plugins: "npm:^0.86.0" + metro-transform-plugins: "npm:^0.87.0" micromatch: "npm:^4.0.4" node-fetch: "npm:^2.2.0" nullthrows: "npm:^1.1.1" @@ -3643,9 +3643,9 @@ __metadata: chalk: "npm:^4.0.0" debug: "npm:^4.4.0" invariant: "npm:^2.2.4" - metro: "npm:^0.86.0" - metro-config: "npm:^0.86.0" - metro-resolver: "npm:^0.86.0" + metro: "npm:^0.87.0" + metro-config: "npm:^0.87.0" + metro-resolver: "npm:^0.87.0" semver: "npm:^7.1.3" peerDependencies: "@react-native-community/cli": "*" @@ -3672,7 +3672,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-native/core-cli-utils@workspace:private/core-cli-utils" dependencies: - metro-babel-register: "npm:^0.86.0" + metro-babel-register: "npm:^0.87.0" languageName: unknown linkType: soft @@ -3779,8 +3779,8 @@ __metadata: babel-plugin-istanbul: "npm:^7.0.0" hermes-parser: "npm:0.36.1" istanbul-lib-coverage: "npm:^3.2.0" - metro: "npm:^0.84.3" - metro-babel-transformer: "npm:^0.84.3" + metro: "npm:^0.87.0" + metro-babel-transformer: "npm:^0.87.0" peerDependencies: jest: ^29.7.0 jest-snapshot: ^29.7.0 @@ -3833,8 +3833,8 @@ __metadata: dependencies: "@react-native/js-polyfills": "workspace:*" "@react-native/metro-babel-transformer": "workspace:*" - metro-config: "npm:^0.86.0" - metro-runtime: "npm:^0.86.0" + metro-config: "npm:^0.87.0" + metro-runtime: "npm:^0.87.0" languageName: unknown linkType: soft @@ -5113,7 +5113,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -5677,7 +5677,7 @@ __metadata: languageName: node linkType: hard -"bl@npm:^4.0.3, bl@npm:^4.1.0": +"bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" dependencies: @@ -5904,7 +5904,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:^4.0.0, chalk@npm:^4.1.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -6054,7 +6054,7 @@ __metadata: languageName: node linkType: hard -"cli-cursor@npm:3.1.0, cli-cursor@npm:^3.1.0": +"cli-cursor@npm:^3.1.0": version: 3.1.0 resolution: "cli-cursor@npm:3.1.0" dependencies: @@ -6750,7 +6750,7 @@ __metadata: languageName: node linkType: hard -"end-of-stream@npm:^1.4.1, end-of-stream@npm:^1.4.4": +"end-of-stream@npm:^1.4.4": version: 1.4.5 resolution: "end-of-stream@npm:1.4.5" dependencies: @@ -7715,7 +7715,7 @@ __metadata: languageName: node linkType: hard -"figures@npm:3.2.0, figures@npm:^3.0.0": +"figures@npm:^3.0.0": version: 3.2.0 resolution: "figures@npm:3.2.0" dependencies: @@ -8353,7 +8353,7 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.3, hasown@npm:^2.0.4": +"hasown@npm:^2.0.3": version: 2.0.4 resolution: "hasown@npm:2.0.4" dependencies: @@ -8394,13 +8394,6 @@ __metadata: languageName: node linkType: hard -"hermes-estree@npm:0.35.0": - version: 0.35.0 - resolution: "hermes-estree@npm:0.35.0" - checksum: 10c0/a88c9dc63b8b3679b1aeb43e72e977597096c1bd7d59978c952f1d6df6d1a517c4a817c70b1b701854996b485adfa66c2fc7f80871029a7f0c04306f6717b59a - languageName: node - linkType: hard - "hermes-estree@npm:0.36.0": version: 0.36.0 resolution: "hermes-estree@npm:0.36.0" @@ -8424,15 +8417,6 @@ __metadata: languageName: node linkType: hard -"hermes-parser@npm:0.35.0": - version: 0.35.0 - resolution: "hermes-parser@npm:0.35.0" - dependencies: - hermes-estree: "npm:0.35.0" - checksum: 10c0/49d98093a2094758db5b536627c6cf5146b140f66e63143acf471c62f1d3fd8bd6ae10a33f2372f72e3653deda5d4615c6dae89d01248849440916209901fc4a - languageName: node - linkType: hard - "hermes-parser@npm:0.36.1": version: 0.36.1 resolution: "hermes-parser@npm:0.36.1" @@ -8589,7 +8573,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.0.4, ignore@npm:^5.0.5, ignore@npm:^5.2.0": +"ignore@npm:^5.0.5, ignore@npm:^5.2.0": version: 5.3.2 resolution: "ignore@npm:5.3.2" checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 @@ -8660,7 +8644,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -8922,7 +8906,7 @@ __metadata: languageName: node linkType: hard -"is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": +"is-docker@npm:^2.0.0": version: 2.2.1 resolution: "is-docker@npm:2.2.1" bin: @@ -9880,27 +9864,27 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^3.10.0, js-yaml@npm:^3.6.1": - version: 3.15.0 - resolution: "js-yaml@npm:3.15.0" +"js-yaml@npm:^3.13.1": + version: 3.14.1 + resolution: "js-yaml@npm:3.14.1" dependencies: argparse: "npm:^1.0.7" esprima: "npm:^4.0.0" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/ca966bd354ac5b1b7a4694ebdba46526796aa3a6a99529fa540af2abf85918bd155a50ccc0166b413130a00622999973754458ec01e7095bc902177bfdbd5b64 + checksum: 10c0/6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b languageName: node linkType: hard -"js-yaml@npm:^3.13.1": - version: 3.14.1 - resolution: "js-yaml@npm:3.14.1" +"js-yaml@npm:^3.6.1": + version: 3.15.0 + resolution: "js-yaml@npm:3.15.0" dependencies: argparse: "npm:^1.0.7" esprima: "npm:^4.0.0" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b + checksum: 10c0/ca966bd354ac5b1b7a4694ebdba46526796aa3a6a99529fa540af2abf85918bd155a50ccc0166b413130a00622999973754458ec01e7095bc902177bfdbd5b64 languageName: node linkType: hard @@ -9933,7 +9917,7 @@ __metadata: languageName: node linkType: hard -"jsesc@npm:^3.0.2, jsesc@npm:~3.1.0": +"jsesc@npm:^3.0.2": version: 3.1.0 resolution: "jsesc@npm:3.1.0" bin: @@ -9986,7 +9970,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.2.2, json5@npm:^2.2.3": +"json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -10313,7 +10297,7 @@ __metadata: languageName: node linkType: hard -"log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0": +"log-symbols@npm:^4.1.0": version: 4.1.0 resolution: "log-symbols@npm:4.1.0" dependencies: @@ -10589,9 +10573,9 @@ __metadata: languageName: node linkType: hard -"metro-babel-register@npm:^0.86.0": - version: 0.86.0 - resolution: "metro-babel-register@npm:0.86.0" +"metro-babel-register@npm:^0.87.0": + version: 0.87.0 + resolution: "metro-babel-register@npm:0.87.0" dependencies: "@babel/core": "npm:^7.25.2" "@babel/plugin-proposal-export-namespace-from": "npm:^7.18.9" @@ -10604,195 +10588,73 @@ __metadata: babel-plugin-transform-flow-enums: "npm:^0.0.2" escape-string-regexp: "npm:^1.0.5" flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/7e87de19295f047f57996e9e2880fe48c6c6999445d83c5699bbae9e3e42e07c1a467ca8bc6480852cb7bb66275061a8508f621544e9f226a62599a74743e914 + checksum: 10c0/f0bd1b2c7590551bc0eeb15f2168aed0df7a96e3e17915315aab835b45e6d95900fd2ef709c248d888d9810c0db62e0651c00652ca3969b74eb22a04476287ee languageName: node linkType: hard -"metro-babel-transformer@npm:0.83.7, metro-babel-transformer@npm:^0.83.3": - version: 0.83.7 - resolution: "metro-babel-transformer@npm:0.83.7" - dependencies: - "@babel/core": "npm:^7.25.2" - flow-enums-runtime: "npm:^0.0.6" - hermes-parser: "npm:0.35.0" - metro-cache-key: "npm:0.83.7" - nullthrows: "npm:^1.1.1" - checksum: 10c0/c50c2fe9fa3d3fbe382d8a482ebcc77d7dba0ef4673cee4904a55b9ea28dea4b86d3b80e9b9ac0cd2b094b9ecd0368c953bba0f1df1cfd36c3a27c666352fd86 - languageName: node - linkType: hard - -"metro-babel-transformer@npm:0.84.4, metro-babel-transformer@npm:^0.84.3": - version: 0.84.4 - resolution: "metro-babel-transformer@npm:0.84.4" - dependencies: - "@babel/core": "npm:^7.25.2" - flow-enums-runtime: "npm:^0.0.6" - hermes-parser: "npm:0.35.0" - metro-cache-key: "npm:0.84.4" - nullthrows: "npm:^1.1.1" - checksum: 10c0/d1ac996666334bc1cfe9d399cbf4cd747b675f6f8f758c2317eebcc52bd76046ed864ddb7b270efeb8cf337940a61fb03912e5c859b7cbc54687c2f5c41a9d2a - languageName: node - linkType: hard - -"metro-babel-transformer@npm:0.86.0": - version: 0.86.0 - resolution: "metro-babel-transformer@npm:0.86.0" +"metro-babel-transformer@npm:0.87.0, metro-babel-transformer@npm:^0.87.0": + version: 0.87.0 + resolution: "metro-babel-transformer@npm:0.87.0" dependencies: "@babel/core": "npm:^7.25.2" flow-enums-runtime: "npm:^0.0.6" hermes-parser: "npm:0.36.1" - metro-cache-key: "npm:0.86.0" + metro-cache-key: "npm:0.87.0" nullthrows: "npm:^1.1.1" - checksum: 10c0/aad72165855852de62468db74ae622dea4122bf6e9c1378e6ca150407fc23361dd84f53f658d18e00ec2389db8b3477126a9e38491f02aa5ee62a3eee3ae24a9 - languageName: node - linkType: hard - -"metro-cache-key@npm:0.83.7": - version: 0.83.7 - resolution: "metro-cache-key@npm:0.83.7" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/a2f20b86b173cd2be710552a49e6389a2c9ef994ac35873616bd4e5c34588beb8c93279bcf525295ca73b6d206ab0d9942f35cbd4a76e642d359bdab48924d7a + checksum: 10c0/0a84ff061f0692f69738fd33d8e0571594c249cb03cd461c381a7dcea7c820ceefe84ee36a8aa57764aa64ddc25846bc1227769d4222bb2c18bac42982811b98 languageName: node linkType: hard -"metro-cache-key@npm:0.84.4": - version: 0.84.4 - resolution: "metro-cache-key@npm:0.84.4" +"metro-cache-key@npm:0.87.0": + version: 0.87.0 + resolution: "metro-cache-key@npm:0.87.0" dependencies: flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/a82ab6367f11886d960cc8fa1f3aa54f6529fe30c16059c141c3e789084c50838fdd7e1a5528534cd9c11a74c63aa5c6a7461dbfa50e8c449b6141eaf2fd05e0 + checksum: 10c0/f75b20b43cd99f684d215c57b195611e04d2d2a9442ad4df17fa2a8929ec7f31a428331881ab950c25e585c65946dcda3c7377d58905c2c7b600a5b66286ec2d languageName: node linkType: hard -"metro-cache-key@npm:0.86.0": - version: 0.86.0 - resolution: "metro-cache-key@npm:0.86.0" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/a27ab420f3a4a10923fcb8f3436b8a68e214a6d60ee84ee9ebe5a1ddffb02e85342136f74772ed96dd87450cc7fefb825b942296eb1d2f10b0df44ed24d6cf5c - languageName: node - linkType: hard - -"metro-cache@npm:0.83.7": - version: 0.83.7 - resolution: "metro-cache@npm:0.83.7" +"metro-cache@npm:0.87.0": + version: 0.87.0 + resolution: "metro-cache@npm:0.87.0" dependencies: exponential-backoff: "npm:^3.1.1" flow-enums-runtime: "npm:^0.0.6" https-proxy-agent: "npm:^7.0.5" - metro-core: "npm:0.83.7" - checksum: 10c0/81ae4b2d0d389d25b816bb2d59eb8baed5e1f03a727aa9bcecadb5547e949525f90af888c956fbd5ff4a732cf4dc48b6d903bab648d23dc1830135d820040825 + metro-core: "npm:0.87.0" + checksum: 10c0/150404598aa6ab7d2a98243214191009d698f841e8126dffe6de83d3861869bd7e4ab527ede6fdf6c738db9fff94f691998e9213fffe38cbc72dca8cd80a8f43 languageName: node linkType: hard -"metro-cache@npm:0.84.4": - version: 0.84.4 - resolution: "metro-cache@npm:0.84.4" - dependencies: - exponential-backoff: "npm:^3.1.1" - flow-enums-runtime: "npm:^0.0.6" - https-proxy-agent: "npm:^7.0.5" - metro-core: "npm:0.84.4" - checksum: 10c0/3bf7f3a1f85b4f1af05f4b2c71c78e56fd3262d967ee43f02e9ff6820254063af33a70b6549e3dc5e993a6a0b9df92e9279632ad9a8b1cde2577342f93df45eb - languageName: node - linkType: hard - -"metro-cache@npm:0.86.0": - version: 0.86.0 - resolution: "metro-cache@npm:0.86.0" - dependencies: - exponential-backoff: "npm:^3.1.1" - flow-enums-runtime: "npm:^0.0.6" - https-proxy-agent: "npm:^7.0.5" - metro-core: "npm:0.86.0" - checksum: 10c0/6ffc71a2bbb760c9179a5c696c5a6c4c3e613e509bd6682774141433040432ad8d69400afe3ef6dfbd89999f3dfe57a2aa7479e5ef6e5fca28099b36a8582e95 - languageName: node - linkType: hard - -"metro-config@npm:0.83.7, metro-config@npm:^0.83.3": - version: 0.83.7 - resolution: "metro-config@npm:0.83.7" - dependencies: - connect: "npm:^3.6.5" - flow-enums-runtime: "npm:^0.0.6" - jest-validate: "npm:^29.7.0" - metro: "npm:0.83.7" - metro-cache: "npm:0.83.7" - metro-core: "npm:0.83.7" - metro-runtime: "npm:0.83.7" - yaml: "npm:^2.6.1" - checksum: 10c0/04f9ae008a4972d8399ed90dc03286afefabc2fc362fef8b8f362ec9c09f2edfe3f0f9e0e5aebf42de8b253f202629cc8e86ef0b07e205adf257cd609ac386fb - languageName: node - linkType: hard - -"metro-config@npm:0.84.4, metro-config@npm:^0.84.0, metro-config@npm:^0.84.3": - version: 0.84.4 - resolution: "metro-config@npm:0.84.4" - dependencies: - connect: "npm:^3.6.5" - flow-enums-runtime: "npm:^0.0.6" - jest-validate: "npm:^29.7.0" - metro: "npm:0.84.4" - metro-cache: "npm:0.84.4" - metro-core: "npm:0.84.4" - metro-runtime: "npm:0.84.4" - yaml: "npm:^2.6.1" - checksum: 10c0/f8aaf7d8cff9b486353b62f4746b0a70f99749bd4061f5ae847524aaedcd9c5a34bf176cbbe12fb33e771e8ed3c1496654b2578fa5ba8b9e4f856f0589744d98 - languageName: node - linkType: hard - -"metro-config@npm:0.86.0, metro-config@npm:^0.86.0": - version: 0.86.0 - resolution: "metro-config@npm:0.86.0" +"metro-config@npm:0.87.0, metro-config@npm:^0.87.0": + version: 0.87.0 + resolution: "metro-config@npm:0.87.0" dependencies: connect: "npm:^3.6.5" flow-enums-runtime: "npm:^0.0.6" jest-validate: "npm:^29.7.0" - metro: "npm:0.86.0" - metro-cache: "npm:0.86.0" - metro-core: "npm:0.86.0" - metro-runtime: "npm:0.86.0" - checksum: 10c0/40ac5f4bc32365087e0b185e2938414f2df9fca2b121c7d6fed60b3884667dab6afc24a15a7b1968011cb2b301f5ecda5a8248a62699e391369b03797c5f6018 - languageName: node - linkType: hard - -"metro-core@npm:0.83.7": - version: 0.83.7 - resolution: "metro-core@npm:0.83.7" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - lodash.throttle: "npm:^4.1.1" - metro-resolver: "npm:0.83.7" - checksum: 10c0/cd267b2516ccce0cc152d12ff1f08073abec0a9b210779357a43f1a279acb3c3be0ff625de030da9a110aea45b70e36f14e13b846cb4fee8419fae67db2a53b4 + metro: "npm:0.87.0" + metro-cache: "npm:0.87.0" + metro-core: "npm:0.87.0" + metro-runtime: "npm:0.87.0" + checksum: 10c0/a3c5a131f875bf8a52cf9ccb528e0343abb501159a18c7985de218ce188768c91f66ce4348e5707eb885bcf09948219de76ece03498179ba4db5aeadf3ddf7ea languageName: node linkType: hard -"metro-core@npm:0.84.4": - version: 0.84.4 - resolution: "metro-core@npm:0.84.4" +"metro-core@npm:0.87.0": + version: 0.87.0 + resolution: "metro-core@npm:0.87.0" dependencies: flow-enums-runtime: "npm:^0.0.6" lodash.throttle: "npm:^4.1.1" - metro-resolver: "npm:0.84.4" - checksum: 10c0/19d859de16b5e082c9c31bed981c579a4e6d31a626c7829b725df9ae0ffb755d0ef7809ba9f8adf22d3921f5ffdd931ed77b21b95ca2ea17895f0c99b3cab831 + metro-resolver: "npm:0.87.0" + checksum: 10c0/3ddefabf34f98e757a8b95768982dc0eb11f15eed001a9063d0144529fd85bcf22b7c33b770636aec3e1ba5b50219143486b87dd2e54b04e4d5b027ae02642b0 languageName: node linkType: hard -"metro-core@npm:0.86.0": - version: 0.86.0 - resolution: "metro-core@npm:0.86.0" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - lodash.throttle: "npm:^4.1.1" - metro-resolver: "npm:0.86.0" - checksum: 10c0/3486a534224bff2ea647f98c5dd60e1e60d247dda59fccdbb50ac98e156364d13e12dc7a614735506e180b6d7252ad8c1c4737b3adf56c80d3900cccad430288 - languageName: node - linkType: hard - -"metro-file-map@npm:0.83.7": - version: 0.83.7 - resolution: "metro-file-map@npm:0.83.7" +"metro-file-map@npm:0.87.0": + version: 0.87.0 + resolution: "metro-file-map@npm:0.87.0" dependencies: debug: "npm:^4.4.0" fb-watchman: "npm:^2.0.0" @@ -10803,41 +10665,7 @@ __metadata: micromatch: "npm:^4.0.4" nullthrows: "npm:^1.1.1" walker: "npm:^1.0.7" - checksum: 10c0/467729e9f607552504d9e1b9a8d81e5ea5a27f979f19a40360215773b38e1a3b563789047f06a5502cf5983aae922b3c52f5a7e0be8edc0b0bb747556e7d5af8 - languageName: node - linkType: hard - -"metro-file-map@npm:0.84.4": - version: 0.84.4 - resolution: "metro-file-map@npm:0.84.4" - dependencies: - debug: "npm:^4.4.0" - fb-watchman: "npm:^2.0.0" - flow-enums-runtime: "npm:^0.0.6" - graceful-fs: "npm:^4.2.4" - invariant: "npm:^2.2.4" - jest-worker: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - nullthrows: "npm:^1.1.1" - walker: "npm:^1.0.7" - checksum: 10c0/09ca829570d1d6dc5beb0534da8a7f2bfcae5415b0974fd5f58b4a05da95dbafdd47f7dc8dedeb11b6562ee9a92c4d918466d02a05cda6e1eaf2c400cbbe6fb4 - languageName: node - linkType: hard - -"metro-file-map@npm:0.86.0": - version: 0.86.0 - resolution: "metro-file-map@npm:0.86.0" - dependencies: - debug: "npm:^4.4.0" - fb-watchman: "npm:^2.0.0" - flow-enums-runtime: "npm:^0.0.6" - graceful-fs: "npm:^4.2.4" - invariant: "npm:^2.2.4" - jest-worker: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - nullthrows: "npm:^1.1.1" - walker: "npm:^1.0.7" - checksum: 10c0/ff2978479dc615a88a7d5b90f1754e9b861f09c539426bf6878eff3b85ceba23fadebdcaf87c9e2f5c5b384e0074053895fa24c51bbdeaf20d6f0c27ba7e8292 + checksum: 10c0/49b9d859c3a7d6da277699a04c55db8a77f404cebec0cacfa7916664ebec607d8e57f8120e2ba28a4aec615d4af33fd5a3cdb164dc80d78b1d9b722f2351aadc languageName: node linkType: hard @@ -10850,209 +10678,71 @@ __metadata: languageName: node linkType: hard -"metro-minify-terser@npm:0.83.7": - version: 0.83.7 - resolution: "metro-minify-terser@npm:0.83.7" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - terser: "npm:^5.15.0" - checksum: 10c0/bf59f9b5a51c3cfff313f64daab78673fa17d3c9d0ec34823d295cd454daa146daa23706f4cae36104bc41f10ad7b5fa2802246891a6f5eb4d0736ef891c9c7a - languageName: node - linkType: hard - -"metro-minify-terser@npm:0.84.4": - version: 0.84.4 - resolution: "metro-minify-terser@npm:0.84.4" +"metro-minify-terser@npm:0.87.0": + version: 0.87.0 + resolution: "metro-minify-terser@npm:0.87.0" dependencies: flow-enums-runtime: "npm:^0.0.6" terser: "npm:^5.15.0" - checksum: 10c0/c9b36c2adb8254c38bdedad9da8bf2b7fae7f45cbd883e590430a5fc9cad808af24dd08a9420925e15733dab886528ad553e3eeb3faffc53d3ad80e7e03e5f6d - languageName: node - linkType: hard - -"metro-minify-terser@npm:0.86.0": - version: 0.86.0 - resolution: "metro-minify-terser@npm:0.86.0" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - terser: "npm:^5.15.0" - checksum: 10c0/a1340cfac82666d36df836ec4874c0d7a353cb2148780ca17556649c38b7e4b43fdc7ebc4c96d49412ab817d3f3ee8e2e9e38b6276faca67590b98a0bf8cb46f - languageName: node - linkType: hard - -"metro-resolver@npm:0.83.7": - version: 0.83.7 - resolution: "metro-resolver@npm:0.83.7" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/be5ba43302362f3918ef4a1f91f96d63c04469cc9fed076a2846785f60f7f53193278a36d249b92a4678fcf95f7a6e31da87a13df82ddc384f010c80fd3b074d - languageName: node - linkType: hard - -"metro-resolver@npm:0.84.4": - version: 0.84.4 - resolution: "metro-resolver@npm:0.84.4" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/468334270598222e15cbee32af51a3b5e1f4fa6869794955b95d1134b28a58594e8e3879e841ccf00bbb5cd86c689a4481714d6c6a464931987d5333d2c55f80 + checksum: 10c0/db0cff372672b9de914be0d0c6440873df88ccbc38ed5e299cf534bc806c2cc0caa005098f0c498cc2d3e51e4f3f6e05f7671b9c48c873cf62709557cc07689d languageName: node linkType: hard -"metro-resolver@npm:0.86.0, metro-resolver@npm:^0.86.0": - version: 0.86.0 - resolution: "metro-resolver@npm:0.86.0" +"metro-resolver@npm:0.87.0, metro-resolver@npm:^0.87.0": + version: 0.87.0 + resolution: "metro-resolver@npm:0.87.0" dependencies: flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/2c20f103dfc7109790ec80c94c579bc478bc2773a1325d210e10fbc03101057609a789ea9f5a391672eacd0f7f309a6dc85af99974caca20e6f30e512c09056e + checksum: 10c0/e2283f83d14794948eb0153a3f9d8b39519be25d21f5d482f27972c47702d58ed340f49c6ab87a175e3a340c53878eff49b4764cffdd508f86bce30079914c58 languageName: node linkType: hard -"metro-runtime@npm:0.83.7": - version: 0.83.7 - resolution: "metro-runtime@npm:0.83.7" +"metro-runtime@npm:0.87.0, metro-runtime@npm:^0.87.0": + version: 0.87.0 + resolution: "metro-runtime@npm:0.87.0" dependencies: "@babel/runtime": "npm:^7.25.0" flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/b9feb9cffdf4df086cea91d803529d706c1a212d0c6b9d138ef4985905cb9bd22f9861a77061364a397917b772d2ef722e465a5b198d6405f574481dfa35a9a7 + checksum: 10c0/6811ee32a759dbccaa3241d98ec26f3e26fd5e4a1cdd5929c98df94fd46329c20f026759da6ca055cb068f8a57dfc7054b6cfc7bb9fb7b3280144055d2f8d67f languageName: node linkType: hard -"metro-runtime@npm:0.84.4": - version: 0.84.4 - resolution: "metro-runtime@npm:0.84.4" - dependencies: - "@babel/runtime": "npm:^7.25.0" - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/e2b2e819027940c6bbd081e5650238d52b6c6d78561cd486b8c10cd1e7fce0213c66fa7f885e37ad5377fcd5726b1c9e473fba6de13938cdf2c966e82968c05f - languageName: node - linkType: hard - -"metro-runtime@npm:0.86.0, metro-runtime@npm:^0.86.0": - version: 0.86.0 - resolution: "metro-runtime@npm:0.86.0" - dependencies: - "@babel/runtime": "npm:^7.25.0" - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/db2a3bb1cfc3b90f86fe7af0a3bdae4d6e166e8b10bd8e81172f133496ec7b3bd6c6ea3d7e04c2d89ce5ac08a142a26c3902d9208c993a80ef834a01ead605ef - languageName: node - linkType: hard - -"metro-source-map@npm:0.83.7": - version: 0.83.7 - resolution: "metro-source-map@npm:0.83.7" +"metro-source-map@npm:0.87.0, metro-source-map@npm:^0.87.0": + version: 0.87.0 + resolution: "metro-source-map@npm:0.87.0" dependencies: "@babel/traverse": "npm:^7.29.0" "@babel/types": "npm:^7.29.0" flow-enums-runtime: "npm:^0.0.6" invariant: "npm:^2.2.4" - metro-symbolicate: "npm:0.83.7" - nullthrows: "npm:^1.1.1" - ob1: "npm:0.83.7" - source-map: "npm:^0.5.6" - vlq: "npm:^1.0.0" - checksum: 10c0/6c424f6258a2128bb4ae6e7591fb7ed1f79b042978397c24a1e35d8bdfcd8960655b3cdd0c4d8ff90f3055ce5b00eca5cb61b327720e7029b3f334fd8ae1776d - languageName: node - linkType: hard - -"metro-source-map@npm:0.84.4": - version: 0.84.4 - resolution: "metro-source-map@npm:0.84.4" - dependencies: - "@babel/traverse": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - flow-enums-runtime: "npm:^0.0.6" - invariant: "npm:^2.2.4" - metro-symbolicate: "npm:0.84.4" - nullthrows: "npm:^1.1.1" - ob1: "npm:0.84.4" - source-map: "npm:^0.5.6" - vlq: "npm:^1.0.0" - checksum: 10c0/39df4524022e07aa4b4d09dd874a9509eb9e2e1e491e80a35099020347ab6be2407851b026452296aad314b0eb7ecf14f9b6bab96bd7c31d47d8b1eb30279aaf - languageName: node - linkType: hard - -"metro-source-map@npm:0.86.0, metro-source-map@npm:^0.86.0": - version: 0.86.0 - resolution: "metro-source-map@npm:0.86.0" - dependencies: - "@babel/traverse": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - flow-enums-runtime: "npm:^0.0.6" - invariant: "npm:^2.2.4" - metro-symbolicate: "npm:0.86.0" - nullthrows: "npm:^1.1.1" - ob1: "npm:0.86.0" - source-map: "npm:^0.5.6" - vlq: "npm:^1.0.0" - checksum: 10c0/9aab7af3ed85fca5292a62a65ee51f9df2f0141af93298660fff3da8733db8ea7530d051ee85a431c4e5c3565617259393418cb3ea9d0cd44a01b9d92823f18f - languageName: node - linkType: hard - -"metro-symbolicate@npm:0.83.7": - version: 0.83.7 - resolution: "metro-symbolicate@npm:0.83.7" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - invariant: "npm:^2.2.4" - metro-source-map: "npm:0.83.7" + metro-symbolicate: "npm:0.87.0" nullthrows: "npm:^1.1.1" + ob1: "npm:0.87.0" source-map: "npm:^0.5.6" vlq: "npm:^1.0.0" - bin: - metro-symbolicate: src/index.js - checksum: 10c0/4968a89d9bdd040e0cb6328852f14a8555e912875a1661e639566879e548fbfb849134b8f55076f875c062d202c713be1c9f47b4e68c627a4f8b367d7e208213 + checksum: 10c0/8074cf0bb4ea7f1027924155b57a0e1205ed9381611abebddd1d7e5a92d0c231cb5c6dd65964711a3a2a2757486b478869d971246158b28dd95232daaef18eff languageName: node linkType: hard -"metro-symbolicate@npm:0.84.4": - version: 0.84.4 - resolution: "metro-symbolicate@npm:0.84.4" +"metro-symbolicate@npm:0.87.0": + version: 0.87.0 + resolution: "metro-symbolicate@npm:0.87.0" dependencies: flow-enums-runtime: "npm:^0.0.6" invariant: "npm:^2.2.4" - metro-source-map: "npm:0.84.4" + metro-source-map: "npm:0.87.0" nullthrows: "npm:^1.1.1" source-map: "npm:^0.5.6" vlq: "npm:^1.0.0" bin: metro-symbolicate: src/index.js - checksum: 10c0/416a9ef694150a8ec708187743b74ab67e0b4fec39c64610b3771b584830117670a62acb9aa824f84a44efbb1cfec07aaf943d1aaf349d977eecf7c72bd8c0bf - languageName: node - linkType: hard - -"metro-symbolicate@npm:0.86.0": - version: 0.86.0 - resolution: "metro-symbolicate@npm:0.86.0" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - invariant: "npm:^2.2.4" - metro-source-map: "npm:0.86.0" - nullthrows: "npm:^1.1.1" - source-map: "npm:^0.5.6" - vlq: "npm:^1.0.0" - bin: - metro-symbolicate: src/index.js - checksum: 10c0/a9dfac0db1af9ab1a0c6f1f495725930263917ec40c172e14f5ee09c4f5ba0ed7c0ec7fc4450d159b251f22a3ca7bcf78e5aab869b205616405205d727627f4e - languageName: node - linkType: hard - -"metro-transform-plugins@npm:0.83.7": - version: 0.83.7 - resolution: "metro-transform-plugins@npm:0.83.7" - dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/generator": "npm:^7.29.1" - "@babel/template": "npm:^7.28.6" - "@babel/traverse": "npm:^7.29.0" - flow-enums-runtime: "npm:^0.0.6" - nullthrows: "npm:^1.1.1" - checksum: 10c0/2fce17ba1013e96278bd6800141ff050af0331a60c4c0dbc24f83c6b524806b6ff4089b36e3268c2315375211d16e875d644b7cbf2b23d43dc050047ec019556 + checksum: 10c0/aacc80d9b46cefedd4078fd2fea0212978a570aa7f2eba6042b33d288858be1e5150d71320dd420bcc0d357c46237edb1b7312639693d288bf6d67fa502c4203 languageName: node linkType: hard -"metro-transform-plugins@npm:0.84.4": - version: 0.84.4 - resolution: "metro-transform-plugins@npm:0.84.4" +"metro-transform-plugins@npm:0.87.0, metro-transform-plugins@npm:^0.87.0": + version: 0.87.0 + resolution: "metro-transform-plugins@npm:0.87.0" dependencies: "@babel/core": "npm:^7.25.2" "@babel/generator": "npm:^7.29.1" @@ -11060,188 +10750,34 @@ __metadata: "@babel/traverse": "npm:^7.29.0" flow-enums-runtime: "npm:^0.0.6" nullthrows: "npm:^1.1.1" - checksum: 10c0/7edb0c0d3655e9f5f5fb8bd8221ec297394b8730c959a3245ea81e50da8177ad7782f21696201a0dcb922281efd919e9548d5b819d8338e52d4b130f06333123 + checksum: 10c0/66e622b701e3fcb3e7034b7ac008c34351381d27e76c712d332814fde05eb6f1d98268bce16b62933a591b81b123e26cdd536c9744c8fbf122c087e7275c4e83 languageName: node linkType: hard -"metro-transform-plugins@npm:0.86.0, metro-transform-plugins@npm:^0.86.0": - version: 0.86.0 - resolution: "metro-transform-plugins@npm:0.86.0" - dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/generator": "npm:^7.29.1" - "@babel/template": "npm:^7.28.6" - "@babel/traverse": "npm:^7.29.0" - flow-enums-runtime: "npm:^0.0.6" - nullthrows: "npm:^1.1.1" - checksum: 10c0/89d3f803f1b4ab7e805e17c816091401b74a2a1c13b3c68f66601aab6770b379991f8cda2b81a3ddb73a7b1aad0f5cc3242114ad3e50b6e5df34888a5f5d7ed4 - languageName: node - linkType: hard - -"metro-transform-worker@npm:0.83.7": - version: 0.83.7 - resolution: "metro-transform-worker@npm:0.83.7" +"metro-transform-worker@npm:0.87.0": + version: 0.87.0 + resolution: "metro-transform-worker@npm:0.87.0" dependencies: "@babel/core": "npm:^7.25.2" "@babel/generator": "npm:^7.29.1" "@babel/parser": "npm:^7.29.0" "@babel/types": "npm:^7.29.0" flow-enums-runtime: "npm:^0.0.6" - metro: "npm:0.83.7" - metro-babel-transformer: "npm:0.83.7" - metro-cache: "npm:0.83.7" - metro-cache-key: "npm:0.83.7" - metro-minify-terser: "npm:0.83.7" - metro-source-map: "npm:0.83.7" - metro-transform-plugins: "npm:0.83.7" + metro: "npm:0.87.0" + metro-babel-transformer: "npm:0.87.0" + metro-cache: "npm:0.87.0" + metro-cache-key: "npm:0.87.0" + metro-minify-terser: "npm:0.87.0" + metro-source-map: "npm:0.87.0" + metro-transform-plugins: "npm:0.87.0" nullthrows: "npm:^1.1.1" - checksum: 10c0/a84e3bae6679826059d66bc9438f00292d93569bdefb4193bcbe4c9a72f5423d88d8d3da94389f95674674118af1ee233ac8a2b5b8279ff568c8becf4c609f1c + checksum: 10c0/2fc3d49874bc9f76a055e14e0a4c5e6c5659efdae148a275ef13a261a013975114c2aacdb5f42815da0be01196ecfcb3a722b6691752edda69110b655b8522ba languageName: node linkType: hard -"metro-transform-worker@npm:0.84.4": - version: 0.84.4 - resolution: "metro-transform-worker@npm:0.84.4" - dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/generator": "npm:^7.29.1" - "@babel/parser": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - flow-enums-runtime: "npm:^0.0.6" - metro: "npm:0.84.4" - metro-babel-transformer: "npm:0.84.4" - metro-cache: "npm:0.84.4" - metro-cache-key: "npm:0.84.4" - metro-minify-terser: "npm:0.84.4" - metro-source-map: "npm:0.84.4" - metro-transform-plugins: "npm:0.84.4" - nullthrows: "npm:^1.1.1" - checksum: 10c0/95924f9bcaf6df931bba2783f440d8fab29909bdde8cecdcc3bc7603e7de71e51728a34288f045694b616c94216d1fc683493b8a470e074c9c8a7f220aa9f9b5 - languageName: node - linkType: hard - -"metro-transform-worker@npm:0.86.0": - version: 0.86.0 - resolution: "metro-transform-worker@npm:0.86.0" - dependencies: - "@babel/core": "npm:^7.25.2" - "@babel/generator": "npm:^7.29.1" - "@babel/parser": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - flow-enums-runtime: "npm:^0.0.6" - metro: "npm:0.86.0" - metro-babel-transformer: "npm:0.86.0" - metro-cache: "npm:0.86.0" - metro-cache-key: "npm:0.86.0" - metro-minify-terser: "npm:0.86.0" - metro-source-map: "npm:0.86.0" - metro-transform-plugins: "npm:0.86.0" - nullthrows: "npm:^1.1.1" - checksum: 10c0/e8d66c361361ef592c659443723b57e67afb86fdf84748ab1750a77f12e74fee215af7e414eced1996383e69d9d80517f5d9ccb6e6a861f529273790f6b84c4c - languageName: node - linkType: hard - -"metro@npm:0.83.7, metro@npm:^0.83.3": - version: 0.83.7 - resolution: "metro@npm:0.83.7" - dependencies: - "@babel/code-frame": "npm:^7.29.0" - "@babel/core": "npm:^7.25.2" - "@babel/generator": "npm:^7.29.1" - "@babel/parser": "npm:^7.29.0" - "@babel/template": "npm:^7.28.6" - "@babel/traverse": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - accepts: "npm:^2.0.0" - ci-info: "npm:^2.0.0" - connect: "npm:^3.6.5" - debug: "npm:^4.4.0" - error-stack-parser: "npm:^2.0.6" - flow-enums-runtime: "npm:^0.0.6" - graceful-fs: "npm:^4.2.4" - hermes-parser: "npm:0.35.0" - image-size: "npm:^1.0.2" - invariant: "npm:^2.2.4" - jest-worker: "npm:^29.7.0" - jsc-safe-url: "npm:^0.2.2" - lodash.throttle: "npm:^4.1.1" - metro-babel-transformer: "npm:0.83.7" - metro-cache: "npm:0.83.7" - metro-cache-key: "npm:0.83.7" - metro-config: "npm:0.83.7" - metro-core: "npm:0.83.7" - metro-file-map: "npm:0.83.7" - metro-resolver: "npm:0.83.7" - metro-runtime: "npm:0.83.7" - metro-source-map: "npm:0.83.7" - metro-symbolicate: "npm:0.83.7" - metro-transform-plugins: "npm:0.83.7" - metro-transform-worker: "npm:0.83.7" - mime-types: "npm:^3.0.1" - nullthrows: "npm:^1.1.1" - serialize-error: "npm:^2.1.0" - source-map: "npm:^0.5.6" - throat: "npm:^5.0.0" - ws: "npm:^7.5.10" - yargs: "npm:^17.6.2" - bin: - metro: src/cli.js - checksum: 10c0/f759631778ce2e8cd7e1338ec886ffa0e3d4cd8456d5a506c57e5212206519435a345bcac6334dac22b3adf188b34854688007ddb1669c65afbadf0a0eef431a - languageName: node - linkType: hard - -"metro@npm:0.84.4, metro@npm:^0.84.3": - version: 0.84.4 - resolution: "metro@npm:0.84.4" - dependencies: - "@babel/code-frame": "npm:^7.29.0" - "@babel/core": "npm:^7.25.2" - "@babel/generator": "npm:^7.29.1" - "@babel/parser": "npm:^7.29.0" - "@babel/template": "npm:^7.28.6" - "@babel/traverse": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - accepts: "npm:^2.0.0" - ci-info: "npm:^2.0.0" - connect: "npm:^3.6.5" - debug: "npm:^4.4.0" - error-stack-parser: "npm:^2.0.6" - flow-enums-runtime: "npm:^0.0.6" - graceful-fs: "npm:^4.2.4" - hermes-parser: "npm:0.35.0" - image-size: "npm:^1.0.2" - invariant: "npm:^2.2.4" - jest-worker: "npm:^29.7.0" - jsc-safe-url: "npm:^0.2.2" - lodash.throttle: "npm:^4.1.1" - metro-babel-transformer: "npm:0.84.4" - metro-cache: "npm:0.84.4" - metro-cache-key: "npm:0.84.4" - metro-config: "npm:0.84.4" - metro-core: "npm:0.84.4" - metro-file-map: "npm:0.84.4" - metro-resolver: "npm:0.84.4" - metro-runtime: "npm:0.84.4" - metro-source-map: "npm:0.84.4" - metro-symbolicate: "npm:0.84.4" - metro-transform-plugins: "npm:0.84.4" - metro-transform-worker: "npm:0.84.4" - mime-types: "npm:^3.0.1" - nullthrows: "npm:^1.1.1" - serialize-error: "npm:^2.1.0" - source-map: "npm:^0.5.6" - throat: "npm:^5.0.0" - ws: "npm:^7.5.10" - yargs: "npm:^17.6.2" - bin: - metro: src/cli.js - checksum: 10c0/ff92915119db29cd855274f3789d391cba83c50cb92e22d1e9b8c729e7f6d39495e32540a22ca4c6591eea6a847ade49fcfa5faab01b2300227e3f1fc7df359c - languageName: node - linkType: hard - -"metro@npm:0.86.0, metro@npm:^0.86.0": - version: 0.86.0 - resolution: "metro@npm:0.86.0" +"metro@npm:0.87.0, metro@npm:^0.87.0": + version: 0.87.0 + resolution: "metro@npm:0.87.0" dependencies: "@babel/code-frame": "npm:^7.29.0" "@babel/core": "npm:^7.25.2" @@ -11263,18 +10799,18 @@ __metadata: jest-worker: "npm:^29.7.0" jsc-safe-url: "npm:^0.2.2" lodash.throttle: "npm:^4.1.1" - metro-babel-transformer: "npm:0.86.0" - metro-cache: "npm:0.86.0" - metro-cache-key: "npm:0.86.0" - metro-config: "npm:0.86.0" - metro-core: "npm:0.86.0" - metro-file-map: "npm:0.86.0" - metro-resolver: "npm:0.86.0" - metro-runtime: "npm:0.86.0" - metro-source-map: "npm:0.86.0" - metro-symbolicate: "npm:0.86.0" - metro-transform-plugins: "npm:0.86.0" - metro-transform-worker: "npm:0.86.0" + metro-babel-transformer: "npm:0.87.0" + metro-cache: "npm:0.87.0" + metro-cache-key: "npm:0.87.0" + metro-config: "npm:0.87.0" + metro-core: "npm:0.87.0" + metro-file-map: "npm:0.87.0" + metro-resolver: "npm:0.87.0" + metro-runtime: "npm:0.87.0" + metro-source-map: "npm:0.87.0" + metro-symbolicate: "npm:0.87.0" + metro-transform-plugins: "npm:0.87.0" + metro-transform-worker: "npm:0.87.0" mime-types: "npm:^3.0.1" nullthrows: "npm:^1.1.1" serialize-error: "npm:^2.1.0" @@ -11284,7 +10820,7 @@ __metadata: yargs: "npm:^17.6.2" bin: metro: src/cli.js - checksum: 10c0/ac46525304525398e22290bfe6e9caa5be6237335e9e87ea25eef6472e1486f542536957cae37d88287ffa466d1051d1b2347918173c5ef47d70de0c05441cf4 + checksum: 10c0/e7aa8b6968c45b4a68dfdf7f6d68e40be0ec888facd8d1bd304a2655ecec245533e9fbf525bd41bf01dd32a327c55a4fd81c229cd589d5796431a663777b0017 languageName: node linkType: hard @@ -11614,15 +11150,6 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.35, mime-types@npm:~2.1.34": - version: 2.1.35 - resolution: "mime-types@npm:2.1.35" - dependencies: - mime-db: "npm:1.52.0" - checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 - languageName: node - linkType: hard - "mime-types@npm:^3.0.0, mime-types@npm:^3.0.1": version: 3.0.2 resolution: "mime-types@npm:3.0.2" @@ -11632,6 +11159,15 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:~2.1.34": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + "mime@npm:1.6.0": version: 1.6.0 resolution: "mime@npm:1.6.0" @@ -11666,15 +11202,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:9.0.9, minimatch@npm:^9.0.4": - version: 9.0.9 - resolution: "minimatch@npm:9.0.9" - dependencies: - brace-expansion: "npm:^2.0.2" - checksum: 10c0/0b6a58530dbb00361745aa6c8cffaba4c90f551afe7c734830bd95fd88ebf469dd7355a027824ea1d09e37181cfeb0a797fb17df60c15ac174303ac110eb7e86 - languageName: node - linkType: hard - "minimatch@npm:^10.0.1, minimatch@npm:^10.2.2": version: 10.2.5 resolution: "minimatch@npm:10.2.5" @@ -11684,6 +11211,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^9.0.4": + version: 9.0.9 + resolution: "minimatch@npm:9.0.9" + dependencies: + brace-expansion: "npm:^2.0.2" + checksum: 10c0/0b6a58530dbb00361745aa6c8cffaba4c90f551afe7c734830bd95fd88ebf469dd7355a027824ea1d09e37181cfeb0a797fb17df60c15ac174303ac110eb7e86 + languageName: node + linkType: hard + "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -11976,30 +11512,12 @@ __metadata: languageName: node linkType: hard -"ob1@npm:0.83.7": - version: 0.83.7 - resolution: "ob1@npm:0.83.7" +"ob1@npm:0.87.0": + version: 0.87.0 + resolution: "ob1@npm:0.87.0" dependencies: flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/b009d7a79b7ccf8db520acdc713a544a1ec379ae804438328080715c66944aa871551b8d7b1cf7cfb89cbe53bbab29f3345097c455259d7b7fe76b0891860187 - languageName: node - linkType: hard - -"ob1@npm:0.84.4": - version: 0.84.4 - resolution: "ob1@npm:0.84.4" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/8bf3a3bdc2b27f1b1b60569c31ff2d9d829025f9a1ce7388b5e810242e48672c8d6b24e5972d6e30aef4d84f6894d12b13d0c6c418460d031da1972b96920bba - languageName: node - linkType: hard - -"ob1@npm:0.86.0": - version: 0.86.0 - resolution: "ob1@npm:0.86.0" - dependencies: - flow-enums-runtime: "npm:^0.0.6" - checksum: 10c0/45cddc7a2295be99a9fc9e04c6a622ccd7b1ff83781e27a47475800523d5ca48db4689fea135e268ba941d669323885507a2b24b8a4dcd56e9e8b5f3bca7942f + checksum: 10c0/917478964d9d0876fb0f1d07232ba02115589505972c98468780a174fc61b15ef8ffe322667836df385271db7b4beaf7916cd837b559776b601e3e6a46ca56c3 languageName: node linkType: hard @@ -12342,7 +11860,7 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": +"parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" dependencies: @@ -12814,7 +12332,6 @@ __metadata: "@react-native/codegen": "workspace:*" "@react-native/community-cli-plugin": "workspace:*" "@react-native/gradle-plugin": "workspace:*" - "@react-native/js-polyfills": "workspace:*" "@react-native/normalize-colors": "workspace:*" anser: "npm:^1.4.9" ansi-regex: "npm:^5.0.0" @@ -12826,9 +12343,9 @@ __metadata: hermes-compiler: "npm:250829098.0.13" invariant: "npm:^2.2.4" memoize-one: "npm:^5.0.0" - metro: "npm:^0.86.0" - metro-runtime: "npm:^0.86.0" - metro-source-map: "npm:^0.86.0" + metro: "npm:^0.87.0" + metro-runtime: "npm:^0.87.0" + metro-source-map: "npm:^0.87.0" nullthrows: "npm:^1.1.1" pretty-format: "npm:^29.7.0" promise: "npm:^8.3.0" @@ -12843,12 +12360,9 @@ __metadata: ws: "npm:^7.5.10" yargs: "npm:^17.6.2" peerDependencies: - "@react-native/jest-preset": "workspace:*" "@types/react": ^19.1.1 react: ^19.2.3 peerDependenciesMeta: - "@react-native/jest-preset": - optional: true "@types/react": optional: true bin: @@ -13140,7 +12654,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.19.0, resolve@npm:^1.22.11": +"resolve@npm:^1.19.0": version: 1.22.12 resolution: "resolve@npm:1.22.12" dependencies: @@ -13180,7 +12694,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.11#optional!builtin": +"resolve@patch:resolve@npm%3A^1.19.0#optional!builtin": version: 1.22.12 resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" dependencies: @@ -14335,7 +13849,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -14847,7 +14361,7 @@ __metadata: languageName: node linkType: hard -"wcwidth@npm:^1.0.0, wcwidth@npm:^1.0.1": +"wcwidth@npm:^1.0.1": version: 1.0.1 resolution: "wcwidth@npm:1.0.1" dependencies: @@ -15180,13 +14694,6 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:21.1.1, yargs-parser@npm:^21.1.1": - version: 21.1.1 - resolution: "yargs-parser@npm:21.1.1" - checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 - languageName: node - linkType: hard - "yargs-parser@npm:^18.1.2": version: 18.1.3 resolution: "yargs-parser@npm:18.1.3" @@ -15204,6 +14711,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + "yargs-parser@npm:^22.0.0": version: 22.0.0 resolution: "yargs-parser@npm:22.0.0" From 62e4b68a12e468d3caebbe27acfbc6ad2eb9c8d1 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 05:09:47 -0500 Subject: [PATCH 2/4] feat: sync Apple header frameworks and SwiftPM foundations Curate ten upstream feature changes without stable release version bumps or the broader 0.88 cutoff. Preserve macOS header selection, resources and facade platforms. Give prebuilt RCTUIKit one physical header owner through a dependency-only facade. Independent review verified source coverage, generated provider checks and focused feature tests; later compatibility groups and native artifacts remain separate validation steps. --- .github/actions/test-ios-rntester/action.yml | 43 +- .github/workflows/prebuild-ios-core.yml | 37 +- .../workflows/prebuild-ios-dependencies.yml | 7 +- .github/workflows/test-all.yml | 6 + package.json | 1 + packages/react-native-test-library/.gitignore | 11 + .../apple/TestLibraryApple.h | 11 + .../apple/TestLibraryApple.mm | 27 + .../apple/TestLibraryApple.podspec | 29 + .../apple/__tests__/TestLibraryAppleTests.cpp | 12 + .../apple/index.d.ts | 10 + .../react-native-test-library/apple/index.js | 28 + .../apple/package.json | 31 + .../apple/react-native.config.js | 22 + .../common/TestLibraryCommon.h | 15 + .../common/TestLibraryCommon.mm | 24 + .../common/TestLibraryCommon.podspec | 23 + .../common/index.d.ts | 14 + .../react-native-test-library/common/index.js | 34 + .../common/package.json | 24 + .../common/react-native.config.js | 19 + .../Libraries/ReactNative/AppRegistry.flow.js | 1 + .../Libraries/ReactNative/AppRegistryImpl.js | 5 +- .../Libraries/Utilities/SceneTracker.js | 42 - .../Utilities/__tests__/SceneTracker-itest.js | 28 - .../react-native/React-Core-prebuilt.podspec | 62 +- packages/react-native/React-Core.podspec | 11 +- .../React/I18n/RCTLocalizedString.mm | 40 +- packages/react-native/ReactNativeApi.d.ts | 7 +- packages/react-native/package.json | 4 + packages/react-native/react-native.config.js | 81 + .../cocoapods/__docs__/prebuilt-deps.md | 78 + .../__tests__/rncore-rctuikit-facade-test.rb | 161 ++ .../scripts/cocoapods/__tests__/spm-test.rb | 137 + .../react-native/scripts/cocoapods/fabric.rb | 2 +- .../react-native/scripts/cocoapods/rncore.rb | 113 +- .../scripts/cocoapods/rncore_facades.rb | 236 ++ .../scripts/cocoapods/rndependencies.rb | 67 +- .../scripts/cocoapods/rndeps_facades.rb | 193 ++ .../react-native/scripts/cocoapods/spm.rb | 89 +- .../templates/Package.swift.spm-template | 97 + .../scripts/ios-prebuild/__docs__/README.md | 159 +- .../ios-prebuild/__docs__/headers-rules.md | 388 +++ .../__tests__/framework-resources-test.js | 396 +++ .../__tests__/headers-compose-test.js | 90 + .../__tests__/headers-inventory-test.js | 142 ++ .../__tests__/headers-spec-test.js | 323 +++ .../__tests__/headers-xcframework-test.js | 113 + .../ios-prebuild/framework-resources.js | 306 +++ .../scripts/ios-prebuild/headers-compose.js | 414 +++ .../scripts/ios-prebuild/headers-config.js | 6 + .../headers-include-baseline.json | 29 + .../scripts/ios-prebuild/headers-inventory.js | 641 +++++ .../scripts/ios-prebuild/headers-spec.js | 520 ++++ .../scripts/ios-prebuild/headers-verify.js | 499 ++++ .../ios-prebuild/headers-xcframework.js | 282 +++ .../ios-prebuild/reactNativeDependencies.js | 37 + .../ios-prebuild/templates/React-umbrella.h | 246 -- .../templates/React_RCTAppDelegate-umbrella.h | 30 - .../ios-prebuild/templates/module.modulemap | 10 - .../scripts/ios-prebuild/types.js | 18 - .../react-native/scripts/ios-prebuild/vfs.js | 282 --- .../scripts/ios-prebuild/xcframework.js | 325 +-- .../scripts/react-native-xcode.sh | 20 + .../react-native/scripts/react_native_pods.rb | 83 +- .../scripts/replace-rncore-version.js | 59 +- .../react-native/scripts/setup-apple-spm.js | 1165 +++++++++ .../spm/__doc__/rfc-spm-xcframework.md | 707 ++++++ .../spm/__doc__/spm-autolinking-plugins.md | 244 ++ .../spm/__doc__/spm-header-paths-contract.md | 97 + .../spm/__doc__/spm-plugins-assessment.md | 128 + .../scripts/spm/__doc__/spm-scripts.md | 451 ++++ .../__tests__/__fixtures__/plain-app.pbxproj | 171 ++ .../spm/__tests__/autolinking-plugins-test.js | 343 +++ .../__tests__/download-spm-artifacts-test.js | 788 ++++++ .../__tests__/expand-spm-dependencies-test.js | 393 +++ .../flavored-frameworks-plist-dep-test.js | 102 + .../spm/__tests__/flavored-frameworks-test.js | 439 ++++ .../generate-spm-autolinking-config-test.js | 259 ++ .../generate-spm-autolinking-test.js | 1420 +++++++++++ .../__tests__/generate-spm-package-test.js | 282 +++ .../__tests__/generate-spm-xcodeproj-test.js | 228 ++ .../__tests__/inject-spm-xcodeproj-test.js | 444 ++++ .../spm/__tests__/read-podspec-test.js | 487 ++++ .../__tests__/remove-spm-injection-test.js | 395 +++ .../__tests__/scaffold-package-swift-test.js | 1140 +++++++++ .../spm/__tests__/setup-apple-spm-test.js | 292 +++ .../scripts/spm/__tests__/spm-pbxproj-test.js | 516 ++++ .../scripts/spm/__tests__/spm-utils-test.js | 621 +++++ .../spm/__tests__/swift-tools-version-test.js | 97 + .../__tests__/sync-spm-autolinking-test.js | 118 + .../scripts/spm/autolinking-plugins.js | 331 +++ .../scripts/spm/download-spm-artifacts.js | 1409 +++++++++++ .../scripts/spm/expand-spm-dependencies.js | 216 ++ .../scripts/spm/flavored-frameworks.js | 1008 ++++++++ .../spm/generate-spm-autolinking-config.js | 161 ++ .../scripts/spm/generate-spm-autolinking.js | 1888 ++++++++++++++ .../scripts/spm/generate-spm-package.js | 302 +++ .../scripts/spm/generate-spm-xcodeproj.js | 2224 +++++++++++++++++ .../react-native/scripts/spm/read-podspec.js | 695 ++++++ .../scripts/spm/scaffold-package-swift.js | 1215 +++++++++ .../react-native/scripts/spm/spm-pbxproj.js | 654 +++++ .../react-native/scripts/spm/spm-types.js | 517 ++++ .../react-native/scripts/spm/spm-utils.js | 648 +++++ .../scripts/spm/sync-spm-autolinking.js | 160 ++ .../utils/replace_hermes_version.js | 22 +- .../ReactNativeDependencies.podspec | 4 +- packages/rn-tester/RNTester/AppDelegate.h | 9 + .../TestLibrary/TestLibraryExample.ios.js | 102 + .../rn-tester/js/utils/RNTesterList.ios.js | 4 + packages/rn-tester/metro.config.js | 2 + packages/rn-tester/package.json | 4 +- packages/rn-tester/react-native.config.js | 28 + .../virtualized-lists/Lists/FillRateHelper.js | 2 +- .../ios-prebuild/compose-framework.js | 24 + .../releases/ios-prebuild/configuration.js | 18 +- .../SocketRocket/PrivacyInfo.xcprivacy | 12 + yarn.lock | 21 + 118 files changed, 29117 insertions(+), 1190 deletions(-) create mode 100644 packages/react-native-test-library/.gitignore create mode 100644 packages/react-native-test-library/apple/TestLibraryApple.h create mode 100644 packages/react-native-test-library/apple/TestLibraryApple.mm create mode 100644 packages/react-native-test-library/apple/TestLibraryApple.podspec create mode 100644 packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp create mode 100644 packages/react-native-test-library/apple/index.d.ts create mode 100644 packages/react-native-test-library/apple/index.js create mode 100644 packages/react-native-test-library/apple/package.json create mode 100644 packages/react-native-test-library/apple/react-native.config.js create mode 100644 packages/react-native-test-library/common/TestLibraryCommon.h create mode 100644 packages/react-native-test-library/common/TestLibraryCommon.mm create mode 100644 packages/react-native-test-library/common/TestLibraryCommon.podspec create mode 100644 packages/react-native-test-library/common/index.d.ts create mode 100644 packages/react-native-test-library/common/index.js create mode 100644 packages/react-native-test-library/common/package.json create mode 100644 packages/react-native-test-library/common/react-native.config.js delete mode 100644 packages/react-native/Libraries/Utilities/SceneTracker.js delete mode 100644 packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js create mode 100644 packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md create mode 100644 packages/react-native/scripts/cocoapods/__tests__/rncore-rctuikit-facade-test.rb create mode 100644 packages/react-native/scripts/cocoapods/__tests__/spm-test.rb create mode 100644 packages/react-native/scripts/cocoapods/rncore_facades.rb create mode 100644 packages/react-native/scripts/cocoapods/rndeps_facades.rb create mode 100644 packages/react-native/scripts/codegen/templates/Package.swift.spm-template create mode 100644 packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/framework-resources.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-compose.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-include-baseline.json create mode 100644 packages/react-native/scripts/ios-prebuild/headers-inventory.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-spec.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-verify.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-xcframework.js delete mode 100644 packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h delete mode 100644 packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h delete mode 100644 packages/react-native/scripts/ios-prebuild/templates/module.modulemap delete mode 100644 packages/react-native/scripts/ios-prebuild/vfs.js create mode 100644 packages/react-native/scripts/setup-apple-spm.js create mode 100644 packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-scripts.md create mode 100644 packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj create mode 100644 packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/read-podspec-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/spm-utils-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js create mode 100644 packages/react-native/scripts/spm/autolinking-plugins.js create mode 100644 packages/react-native/scripts/spm/download-spm-artifacts.js create mode 100644 packages/react-native/scripts/spm/expand-spm-dependencies.js create mode 100644 packages/react-native/scripts/spm/flavored-frameworks.js create mode 100644 packages/react-native/scripts/spm/generate-spm-autolinking-config.js create mode 100644 packages/react-native/scripts/spm/generate-spm-autolinking.js create mode 100644 packages/react-native/scripts/spm/generate-spm-package.js create mode 100644 packages/react-native/scripts/spm/generate-spm-xcodeproj.js create mode 100644 packages/react-native/scripts/spm/read-podspec.js create mode 100644 packages/react-native/scripts/spm/scaffold-package-swift.js create mode 100644 packages/react-native/scripts/spm/spm-pbxproj.js create mode 100644 packages/react-native/scripts/spm/spm-types.js create mode 100644 packages/react-native/scripts/spm/spm-utils.js create mode 100644 packages/react-native/scripts/spm/sync-spm-autolinking.js create mode 100644 packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js create mode 100644 scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy diff --git a/.github/actions/test-ios-rntester/action.yml b/.github/actions/test-ios-rntester/action.yml index fbb2d7474c38..2b62f554daa1 100644 --- a/.github/actions/test-ios-rntester/action.yml +++ b/.github/actions/test-ios-rntester/action.yml @@ -15,9 +15,18 @@ inputs: required: false default: false use-frameworks: - description: Whether we have to build with Dynamic Frameworks. If this is set to true, it builds from source + description: Whether we have to build with Dynamic Frameworks. required: false default: false + use-prebuilds: + description: >- + Whether to consume the prebuilt ReactCore/ReactNativeDependencies + artifacts. 'auto' (default) keeps the historical coupling: prebuilds for + static, source for dynamic frameworks. Pass 'true' with + use-frameworks:true for the prebuilt + dynamic-frameworks lane (the + config of the 2026-07-03 SocketRocket dual-copy regression). + required: false + default: auto runs: using: composite @@ -41,24 +50,44 @@ runs: - name: Prepare IOS Tests if: ${{ inputs.run-unit-tests == 'true' }} uses: ./.github/actions/prepare-ios-tests + - name: Resolve prebuilds mode + id: prebuilds + shell: bash + run: | + if [[ "${{ inputs.use-prebuilds }}" == "auto" ]]; then + # Historical coupling: prebuilds for static, source for dynamic frameworks. + if [[ "${{ inputs.use-frameworks }}" == "true" ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + elif [[ "${{ inputs.use-prebuilds }}" == "true" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + elif [[ "${{ inputs.use-prebuilds }}" == "false" ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + # Don't silently treat a typo as 'disabled' — surface it. + echo "::warning::Unexpected use-prebuilds value '${{ inputs.use-prebuilds }}' (expected auto/true/false); treating as disabled." + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi - name: Download ReactNativeDependencies - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/third-party/ - name: Print third-party folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/third-party - name: Download React Native Prebuilds - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactCore${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/ReactCore - name: Print ReactCore folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/ReactCore - name: Install CocoaPods dependencies @@ -66,8 +95,8 @@ runs: run: | if [[ ${{ inputs.use-frameworks }} == "true" ]]; then export USE_FRAMEWORKS=dynamic - else - # If use-frameworks is false, let's use prebuilds + fi + if [[ "${{ steps.prebuilds.outputs.enabled }}" == "true" ]]; then export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz" export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ inputs.flavor }}.xcframework.tar.gz" fi diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index ad47de482fef..db271c4e246d 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -133,7 +133,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/.build/output/xcframeworks - key: v2-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} - name: Setup node.js if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/setup-node @@ -157,6 +157,24 @@ jobs: pattern: prebuild-ios-core-headers-${{ matrix.flavor }}-* path: packages/react-native/.build/headers merge-multiple: true + - name: Download ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + path: /tmp/third-party/ + - name: Extract ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + shell: bash + run: | + # ReactNativeHeaders.xcframework is pure-RN (the deps namespaces ship + # in the ReactNativeDependenciesHeaders sidecar built by the deps + # prebuild), but the headers-verify compile gates still need the deps + # headers on their include path (folly/glog/... reached from RN's + # public headers), so the deps artifact is staged here too. + tar -xzf /tmp/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz -C /tmp/third-party/ + mkdir -p packages/react-native/third-party/ + mv /tmp/third-party/packages/react-native/third-party/ReactNativeDependencies.xcframework packages/react-native/third-party/ReactNativeDependencies.xcframework - name: Setup Keychain if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} uses: apple-actions/import-codesign-certs@v3 # https://github.com/marketplace/actions/import-code-signing-certificates @@ -173,11 +191,24 @@ jobs: run: | cd packages/react-native node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" + - name: Verify composed headers + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + run: | + # Generator-time header gate: include-health ratchet, structural + # byte-compare of module maps/umbrellas against the spec render, and + # compile smokes (React module + every namespace module + the + # privileged-consumer/Expo fixtures). Catches consumer-facing header + # regressions here instead of in downstream builds. + cd packages/react-native + node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" - name: Compress and Rename XCFramework if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | cd packages/react-native/.build/output/xcframeworks/${{matrix.flavor}} - tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework + # Ship BOTH xcframeworks: React-Core-prebuilt's prepare_command flattens + # ReactNativeHeaders.xcframework's Headers (incl. module.modulemap) into the + # pod. Omitting it leaves consumers without React-Core-prebuilt/Headers/module.modulemap. + tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework ReactNativeHeaders.xcframework - name: Compress and Rename dSYM if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | @@ -200,4 +231,4 @@ jobs: path: | packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.framework.dSYM.tar.gz - key: v2-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index ab9322da3dd8..a61426b0feb3 100644 --- a/.github/workflows/prebuild-ios-dependencies.yml +++ b/.github/workflows/prebuild-ios-dependencies.yml @@ -130,7 +130,7 @@ jobs: with: path: | packages/react-native/third-party/ - key: v3-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }} + key: v5-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js', 'packages/react-native/scripts/ios-prebuild/headers-xcframework.js', 'packages/react-native/scripts/ios-prebuild/headers-spec.js') }} # If cache hit, we already have our binary. We don't need to do anything. - name: Yarn Install if: steps.restore-xcframework.outputs.cache-hit != 'true' @@ -164,7 +164,8 @@ jobs: if: steps.restore-xcframework.outputs.cache-hit != 'true' run: | tar -cz -f packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz \ - packages/react-native/third-party/ReactNativeDependencies.xcframework + packages/react-native/third-party/ReactNativeDependencies.xcframework \ + packages/react-native/third-party/ReactNativeDependenciesHeaders.xcframework - name: Show Symbol folder content if: steps.restore-xcframework.outputs.cache-hit != 'true' run: ls -lR packages/react-native/third-party/Symbols @@ -192,4 +193,4 @@ jobs: path: | packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.framework.dSYM.tar.gz - key: v3-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }} + key: v5-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js', 'packages/react-native/scripts/ios-prebuild/headers-xcframework.js', 'packages/react-native/scripts/ios-prebuild/headers-spec.js') }} diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 1f49896cbf36..a65219271e15 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -155,6 +155,12 @@ jobs: uses: ./.github/actions/test-ios-rntester with: use-frameworks: ${{ matrix.frameworks }} + # Consume the prebuilt artifacts in the dynamic-frameworks cells too: + # prebuilt + use_frameworks is the config of the 2026-07-03 + # SocketRocket dual-copy regression, previously covered by no lane + # (source-built dynamic frameworks stay covered by + # test_ios_rntester_dynamic_frameworks). + use-prebuilds: true flavor: ${{ matrix.flavor }} test_e2e_ios_rntester: diff --git a/package.json b/package.json index 9abf30c43ed0..f2a65e58fde1 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "workspaces": [ "packages/*", "tools/*", + "packages/react-native-test-library/*", "private/*", "!packages/helloworld", "!packages/hermes-inspector-msggen", diff --git a/packages/react-native-test-library/.gitignore b/packages/react-native-test-library/.gitignore new file mode 100644 index 000000000000..531ed06a6fec --- /dev/null +++ b/packages/react-native-test-library/.gitignore @@ -0,0 +1,11 @@ +# Generated SPM artifacts (written by setup-ios-spm.js's earlier in-place +# layout). The current autolinker emits these under the consumer app's +# build/generated/autolinking/ tree instead, so any copy that lands here is +# stale and should not be committed. +Package.swift +Package.resolved +include/ + +# SwiftPM caches +.build/ +.swiftpm/ diff --git a/packages/react-native-test-library/apple/TestLibraryApple.h b/packages/react-native-test-library/apple/TestLibraryApple.h new file mode 100644 index 000000000000..fe19590fdc6d --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface TestLibraryApple : NSObject +@end diff --git a/packages/react-native-test-library/apple/TestLibraryApple.mm b/packages/react-native-test-library/apple/TestLibraryApple.mm new file mode 100644 index 000000000000..2fa7f1fcefb1 --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.mm @@ -0,0 +1,27 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "TestLibraryApple.h" + +// Synth library products are emitted as .library(type: .dynamic, ...), so SPM +// wraps each autolinked dep as a Foo.framework under PackageFrameworks/. That +// gives angle-bracket imports the standard resolution path, +// matching how most React Native libraries already organize their headers. +#import + +@implementation TestLibraryApple + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD( + echo : (NSString *)message resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + NSString *prefix = [TestLibraryCommon defaultPrefix]; + resolve([NSString stringWithFormat:@"%@apple: %@", prefix, message]); +} + +@end diff --git a/packages/react-native-test-library/apple/TestLibraryApple.podspec b/packages/react-native-test-library/apple/TestLibraryApple.podspec new file mode 100644 index 000000000000..02cef6573b26 --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.podspec @@ -0,0 +1,29 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "TestLibraryApple" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://github.com/facebook/react-native" + s.license = "MIT" + s.platforms = min_supported_versions + s.author = "Meta Platforms, Inc. and its affiliates" + s.source = { :git => "https://github.com/facebook/react-native.git", :tag => "#{s.version}" } + s.source_files = "*.{h,m,mm,swift}" + s.requires_arc = true + + # TestLibraryApple.mm imports . + # CocoaPods resolves it leniently through the shared Public headers dir, but the + # dependency edge must be declared for SwiftPM (the scaffolder wires sibling + # packages from podspec dependencies). + s.dependency "TestLibraryCommon" + + install_modules_dependencies(s) +end diff --git a/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp b/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp new file mode 100644 index 000000000000..a9c5b63cca3d --- /dev/null +++ b/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp @@ -0,0 +1,12 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +static_assert( + false, + "TestLibraryAppleTests.cpp must not be compiled by the SPM autolinker"); diff --git a/packages/react-native-test-library/apple/index.d.ts b/packages/react-native-test-library/apple/index.d.ts new file mode 100644 index 000000000000..9c911b2d99b7 --- /dev/null +++ b/packages/react-native-test-library/apple/index.d.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Greeting} from '../common'; + +export function greet(g: Greeting): Promise; diff --git a/packages/react-native-test-library/apple/index.js b/packages/react-native-test-library/apple/index.js new file mode 100644 index 000000000000..028d0e9450fa --- /dev/null +++ b/packages/react-native-test-library/apple/index.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {Greeting} from '../common'; + +import {formatGreeting} from '../common'; +import {NativeModules, Platform} from 'react-native'; + +export function greet(g: Greeting): Promise { + const TestLibraryApple = NativeModules.TestLibraryApple; + if (TestLibraryApple == null) { + return Promise.reject( + new Error( + `react-native-test-library-apple: native module unavailable on ${Platform.OS}. This package is iOS-only; install a platform-specific sibling (e.g. react-native-test-library-android) for cross-platform coverage.`, + ), + ); + } + return TestLibraryApple.echo(formatGreeting(g)); +} diff --git a/packages/react-native-test-library/apple/package.json b/packages/react-native-test-library/apple/package.json new file mode 100644 index 000000000000..0709a59f93f3 --- /dev/null +++ b/packages/react-native-test-library/apple/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-native-test-library-apple", + "version": "0.87.0-main", + "description": "Apple platform implementation for the React Native autolinking fixture. Depends on react-native-test-library-common; used to validate iOS/macOS autolinking discovery and transitive native dependency resolution.", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "files": [ + "index.js", + "index.d.ts", + "react-native.config.js", + "TestLibraryApple.podspec", + "TestLibraryApple.h", + "TestLibraryApple.mm" + ], + "keywords": [ + "react-native", + "fixture", + "autolinking", + "ios", + "macos" + ], + "dependencies": { + "react-native-test-library-common": "workspace:*" + }, + "peerDependencies": { + "react": "*", + "react-native": "1000.0.0" + } +} diff --git a/packages/react-native-test-library/apple/react-native.config.js b/packages/react-native-test-library/apple/react-native.config.js new file mode 100644 index 000000000000..982ed353de95 --- /dev/null +++ b/packages/react-native-test-library/apple/react-native.config.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +module.exports = { + dependency: { + platforms: { + ios: {}, + }, + }, + spm: { + dependencies: ['react-native-test-library-common'], + }, +}; diff --git a/packages/react-native-test-library/common/TestLibraryCommon.h b/packages/react-native-test-library/common/TestLibraryCommon.h new file mode 100644 index 000000000000..a84f31084b51 --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface TestLibraryCommon : NSObject + +/** Shared prefix used by other test-library packages that depend on common. */ ++ (NSString *)defaultPrefix; + +@end diff --git a/packages/react-native-test-library/common/TestLibraryCommon.mm b/packages/react-native-test-library/common/TestLibraryCommon.mm new file mode 100644 index 000000000000..19ced66cde8c --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.mm @@ -0,0 +1,24 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "TestLibraryCommon.h" + +@implementation TestLibraryCommon + +RCT_EXPORT_MODULE() + ++ (NSString *)defaultPrefix +{ + return @"[common] "; +} + +RCT_EXPORT_METHOD(version : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + resolve(@"common@0.87.0-main"); +} + +@end diff --git a/packages/react-native-test-library/common/TestLibraryCommon.podspec b/packages/react-native-test-library/common/TestLibraryCommon.podspec new file mode 100644 index 000000000000..4218183fb657 --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.podspec @@ -0,0 +1,23 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "TestLibraryCommon" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://github.com/facebook/react-native" + s.license = "MIT" + s.platforms = min_supported_versions + s.author = "Meta Platforms, Inc. and its affiliates" + s.source = { :git => "https://github.com/facebook/react-native.git", :tag => "#{s.version}" } + s.source_files = "*.{h,m,mm,swift}" + s.requires_arc = true + + install_modules_dependencies(s) +end diff --git a/packages/react-native-test-library/common/index.d.ts b/packages/react-native-test-library/common/index.d.ts new file mode 100644 index 000000000000..c765deb0688f --- /dev/null +++ b/packages/react-native-test-library/common/index.d.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export type Greeting = Readonly<{ + name: string; + language: string; +}>; + +export function formatGreeting(g: Greeting): string; +export function getVersion(): Promise; diff --git a/packages/react-native-test-library/common/index.js b/packages/react-native-test-library/common/index.js new file mode 100644 index 000000000000..71eeefcf1ba2 --- /dev/null +++ b/packages/react-native-test-library/common/index.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import {NativeModules, Platform} from 'react-native'; + +export type Greeting = Readonly<{ + name: string, + language: string, +}>; + +export function formatGreeting(g: Greeting): string { + return `[${g.language}] Hello, ${g.name}!`; +} + +export function getVersion(): Promise { + const TestLibraryCommon = NativeModules.TestLibraryCommon; + if (TestLibraryCommon == null) { + return Promise.reject( + new Error( + `react-native-test-library-common: native module unavailable on ${Platform.OS}.`, + ), + ); + } + return TestLibraryCommon.version(); +} diff --git a/packages/react-native-test-library/common/package.json b/packages/react-native-test-library/common/package.json new file mode 100644 index 000000000000..0a911df6a303 --- /dev/null +++ b/packages/react-native-test-library/common/package.json @@ -0,0 +1,24 @@ +{ + "name": "react-native-test-library-common", + "version": "0.87.0-main", + "description": "Shared JS utilities consumed by react-native-test-library-apple. Used as a fixture for validating autolinking discovery and transitive native dependency resolution in the React Native monorepo.", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "files": [ + "index.js", + "index.d.ts", + "react-native.config.js", + "TestLibraryCommon.podspec", + "TestLibraryCommon.h", + "TestLibraryCommon.mm" + ], + "keywords": [ + "react-native", + "fixture", + "autolinking", + "ios", + "macos" + ] +} diff --git a/packages/react-native-test-library/common/react-native.config.js b/packages/react-native-test-library/common/react-native.config.js new file mode 100644 index 000000000000..3de9a9829be7 --- /dev/null +++ b/packages/react-native-test-library/common/react-native.config.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +module.exports = { + dependency: { + platforms: { + ios: {}, + }, + }, +}; diff --git a/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js b/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js index c6f5ee3fb988..94de6edde6ec 100644 --- a/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js +++ b/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js @@ -44,5 +44,6 @@ export type Registry = { }; export type WrapperComponentProvider = ( appParameters: Object, + appKey?: string, ) => React.ComponentType; export type RootViewStyleProvider = (appParameters: Object) => ViewStyleProp; diff --git a/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js b/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js index 92db1cd98ea2..33e0c84f3ba1 100644 --- a/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js +++ b/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js @@ -22,7 +22,6 @@ import type { WrapperComponentProvider, } from './AppRegistry.flow'; -import SceneTracker from '../Utilities/SceneTracker'; import DeprecatedPerformanceLoggerStub from './DeprecatedPerformanceLoggerStub'; import {coerceDisplayMode} from './DisplayMode'; import HeadlessJsTaskError from './HeadlessJsTaskError'; @@ -105,7 +104,8 @@ export function registerComponent( initialProps: appParameters.initialProps, rootTag: appParameters.rootTag, WrapperComponent: - wrapperComponentProvider && wrapperComponentProvider(appParameters), + wrapperComponentProvider && + wrapperComponentProvider(appParameters, appKey), rootViewStyle: rootViewStyleProvider && rootViewStyleProvider(appParameters), isLogBox: appKey === 'LogBox', @@ -212,7 +212,6 @@ export function runApplication( "* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called.", ); - SceneTracker.setActiveScene({name: appKey}); runnables[appKey](appParameters, coerceDisplayMode(displayMode)); } diff --git a/packages/react-native/Libraries/Utilities/SceneTracker.js b/packages/react-native/Libraries/Utilities/SceneTracker.js deleted file mode 100644 index 2cedf5df6f07..000000000000 --- a/packages/react-native/Libraries/Utilities/SceneTracker.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict - * @format - */ - -'use strict'; - -export type Scene = {name: string, [string]: unknown, ...}; - -let _listeners: Array<(scene: Scene) => void> = []; - -let _activeScene: Scene = {name: 'default'}; - -const SceneTracker = { - setActiveScene(scene: Scene) { - _activeScene = scene; - _listeners.forEach(listener => listener(_activeScene)); - }, - - getActiveScene(): Scene { - return _activeScene; - }, - - addActiveSceneChangedListener(callback: (scene: Scene) => void): { - remove: () => void, - ... - } { - _listeners.push(callback); - return { - remove: () => { - _listeners = _listeners.filter(listener => callback !== listener); - }, - }; - }, -}; - -export default SceneTracker; diff --git a/packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js b/packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js deleted file mode 100644 index 392f8fad5c32..000000000000 --- a/packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; - -const SceneTracker = require('../SceneTracker').default; - -describe('setActiveScene', function () { - it('can handle multiple listeners and unsubscribe', function () { - const listeners = [jest.fn(), jest.fn(), jest.fn()]; - const subscriptions = listeners.map(listener => - SceneTracker.addActiveSceneChangedListener(listener), - ); - subscriptions[1].remove(); - const newScene = {name: 'scene1'}; - SceneTracker.setActiveScene(newScene); - expect(listeners[0]).toBeCalledWith(newScene); - expect(listeners[1]).not.toBeCalled(); - expect(listeners[2]).toBeCalledWith(newScene); - }); -}); diff --git a/packages/react-native/React-Core-prebuilt.podspec b/packages/react-native/React-Core-prebuilt.podspec index 98aa6a09b1df..bc838fafef3a 100644 --- a/packages/react-native/React-Core-prebuilt.podspec +++ b/packages/react-native/React-Core-prebuilt.podspec @@ -17,36 +17,64 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.platforms = min_supported_versions s.source = source + + # We vend two xcframeworks that ship together in the prebuilt tarball: + # - React.xcframework: the compiled core. Its per-slice React.framework carries + # every header + the framework module map, so `#import ` + # and `@import React;` resolve through FRAMEWORK_SEARCH_PATHS automatically. + # - ReactNativeHeaders.xcframework: headers-only, PURE-RN. Carries every other + # RN namespace (, , ...). Its headers are flattened into + # a top-level Headers/ (see prepare_command) and exposed via the standard pod + # header search path. The third-party deps namespaces (folly/glog/boost/...) + # are NOT here — the ReactNativeDependencies pod serves them from its own + # artifact (see scripts/cocoapods/__docs__/prebuilt-deps.md), wired through + # add_rn_third_party_dependencies below. ( is supplied by the + # hermes-engine pod here; it is folded into ReactNativeHeaders only on the + # SwiftPM consumer side.) + # There is no clang VFS overlay. s.vendored_frameworks = "React.xcframework" s.preserve_paths = '**/*.*' - s.header_mappings_dir = 'React.xcframework/Headers' - s.source_files = 'React.xcframework/Headers/**/*.{h,hpp}' - - s.module_name = 'React' - s.module_map = 'React.xcframework/Modules/module.modulemap' - s.public_header_files = 'React.xcframework/Headers/**/*.h' + s.header_mappings_dir = 'Headers' + s.source_files = 'Headers/**/*.{h,hpp}' + s.public_header_files = 'Headers/**/*.h' add_rn_third_party_dependencies(s) - # We need to make sure that the React.xcframework is copied correctly - in the downloaded tarball - # the root directory is the framework, but when using it we need to have it in a subdirectory - # called React.xcframework, so we need to move the contents of the tarball into that directory. - # This is done in the prepare_command. - # We need to make sure that the headers are copied to the right place - local tar.gz has a different structure - # than the one from the maven repo + # The downloaded tarball ships React.xcframework and ReactNativeHeaders.xcframework + # at its root. We make sure React.xcframework is in its own subdirectory (the Maven + # tarball lays the framework contents at the root; the local tar.gz has a different + # structure) and flatten ReactNativeHeaders' headers into a top-level Headers/ dir + # so CocoaPods exposes them on the header search path. s.prepare_command = <<~'CMD' CURRENT_PATH=$(pwd) XCFRAMEWORK_PATH="${CURRENT_PATH}/React.xcframework" - # Check if XCFRAMEWORK_PATH is empty - if [ -z "$XCFRAMEWORK_PATH" ]; then - echo "ERROR: XCFRAMEWORK_PATH is empty." - exit 0 + # Flatten ReactNativeHeaders' headers (identical across slices) into Headers/ + # BEFORE we sweep stray root entries into React.xcframework. Fail closed: + # a tarball without ReactNativeHeaders.xcframework (an artifact published + # before the headers-spec layout, or a truncated download) would otherwise + # yield a green install with an empty Headers/ and every or + # include failing much later, far from the cause. + mkdir -p Headers + RNH_XCFRAMEWORK_PATH=$(find "$CURRENT_PATH" -type d -name "ReactNativeHeaders.xcframework" | head -n 1) + if [ -z "$RNH_XCFRAMEWORK_PATH" ]; then + echo "[React-Core-prebuilt] ERROR: ReactNativeHeaders.xcframework not found in the prebuilt tarball." >&2 + echo "The artifact predates the headers-spec layout or is incomplete; use a matching react-native version." >&2 + exit 1 + fi + RNH_HEADERS_PATH=$(find "$RNH_XCFRAMEWORK_PATH" -type d -name "Headers" | head -n 1) + if [ -z "$RNH_HEADERS_PATH" ]; then + echo "[React-Core-prebuilt] ERROR: no Headers directory inside $RNH_XCFRAMEWORK_PATH." >&2 + exit 1 fi + cp -R "$RNH_HEADERS_PATH/." Headers + rm -rf "$RNH_XCFRAMEWORK_PATH" mkdir -p "${XCFRAMEWORK_PATH}" - find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 ! -name "$(basename "$XCFRAMEWORK_PATH")" -exec mv {} "$XCFRAMEWORK_PATH" \; + find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 \ + ! -name "$(basename "$XCFRAMEWORK_PATH")" ! -name "Headers" \ + -exec mv {} "$XCFRAMEWORK_PATH" \; CMD # If we are passing a local tarball, we don't want to switch between Debug and Release diff --git a/packages/react-native/React-Core.podspec b/packages/react-native/React-Core.podspec index 69ca3473e49e..72c8564aa8b2 100644 --- a/packages/react-native/React-Core.podspec +++ b/packages/react-native/React-Core.podspec @@ -51,7 +51,6 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.platforms = min_supported_versions s.source = source - s.resource_bundle = { "RCTI18nStrings" => ["React/I18n/strings/*.lproj"]} s.compiler_flags = js_engine_flags() s.header_dir = "React" s.weak_framework = "JavaScriptCore" @@ -123,7 +122,15 @@ Pod::Spec.new do |s| s.dependency "React-hermes" end - s.resource_bundles = {'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy'} + # Both bundles in one declaration: a second `resource_bundle(s) =` would replace + # (not merge) the first. RCTI18nStrings holds React-Core's localized strings + # (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest. + # (Prebuilt/SwiftPM get both from inside React.xcframework instead — see + # scripts/ios-prebuild/framework-resources.js — but source builds ship them here.) + s.resource_bundles = { + 'RCTI18nStrings' => ['React/I18n/strings/*.lproj'], + 'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy', + } add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern') diff --git a/packages/react-native/React/I18n/RCTLocalizedString.mm b/packages/react-native/React/I18n/RCTLocalizedString.mm index 6a09613b77a0..df87ea02ea5a 100644 --- a/packages/react-native/React/I18n/RCTLocalizedString.mm +++ b/packages/react-native/React/I18n/RCTLocalizedString.mm @@ -7,8 +7,45 @@ #import "RCTLocalizedString.h" +#import + #if !defined(WITH_FBI18N) || !(WITH_FBI18N) +// Anchors resource lookups to the bundle that contains this code: React.framework +// when React Native is consumed prebuilt / via SwiftPM, or the app's main bundle +// for static source builds. +@interface RCTI18nStringsAnchor : NSObject +@end +@implementation RCTI18nStringsAnchor +@end + +// Resolves RCTI18nStrings.bundle wherever it ships: the code's own bundle first +// (prebuilt/SwiftPM embed it inside React.framework), then the app's main bundle +// (source builds copy it there via the podspec resource_bundles). Returns nil +// when absent, so the caller falls back to the untranslated default value. +static NSBundle *RCTI18nStringsBundle(void) +{ + NSBundle *codeBundle = [NSBundle bundleForClass:[RCTI18nStringsAnchor class]]; + NSURL *url = [codeBundle URLForResource:@"RCTI18nStrings" withExtension:@"bundle"]; + if (url != nil) { + return [NSBundle bundleWithURL:url]; + } + NSString *mainPath = [[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" ofType:@"bundle"]; + if (mainPath != nil) { + return [NSBundle bundleWithPath:mainPath]; + } +#if RCT_DEV + // Missing resources are otherwise silent (every lookup falls back to the + // untranslated default and the privacy manifest quietly drops out of the + // app's aggregated privacy report). Called once — the caller caches. + RCTLogWarn( + @"RCTI18nStrings.bundle not found in React.framework or the app bundle. Localized strings will use their " + @"untranslated defaults, and React's PrivacyInfo.xcprivacy may be missing from the app's privacy report. " + @"When consuming the prebuilt React.framework, verify it is embedded into the app with its resources intact."); +#endif + return nil; +} + extern "C" { static NSString *FBTStringByConvertingIntegerToBase64(uint64_t number) @@ -33,8 +70,7 @@ NSString *RCTLocalizedStringFromKey(uint64_t key, NSString *defaultValue) { - static NSBundle *bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" - ofType:@"bundle"]]; + static NSBundle *bundle = RCTI18nStringsBundle(); if (bundle == nil) { return defaultValue; } else { diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index ffeb4fae3124..fe41f971f4b0 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<8ce0ca3b145c5d282f4acfd2e92ce78d>> + * @generated SignedSource<<42fdaff3d087e2a1d276298b69718f30>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5981,6 +5981,7 @@ declare function Wrapper_default( ): React_2.ReactNode declare type WrapperComponentProvider = ( appParameters: Object, + appKey?: string, ) => React_2.ComponentType export { AccessibilityActionEvent, // a0d4daa0 @@ -6003,7 +6004,7 @@ export { AndroidKeyboardEvent, // e03becc8 Animated, // e7241a74 AppConfig, // e4e0157b - AppRegistry, // 981198cc + AppRegistry, // b1f0909a AppState, // 12012be5 AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -6293,7 +6294,7 @@ export { VirtualizedSectionList, // 486a3a9d VirtualizedSectionListInstance, // 93c6cbad VirtualizedSectionListProps, - WrapperComponentProvider, // 994bf953 + WrapperComponentProvider, // 147416ab codegenNativeCommands, // 628a7c0a codegenNativeComponent, // cf7eb9a8 findNodeHandle, // 93f80214 diff --git a/packages/react-native/package.json b/packages/react-native/package.json index c3a7cc0d3309..fe3db74bffb0 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -114,6 +114,8 @@ "scripts/react_native_pods_utils/script_phases.sh", "scripts/react_native_pods.rb", "scripts/react-native-xcode.sh", + "scripts/setup-apple-spm.js", + "scripts/spm/**", "scripts/xcode/ccache-clang.sh", "scripts/xcode/ccache-clang++.sh", "scripts/xcode/ccache.conf", @@ -169,7 +171,9 @@ "metro": "^0.87.0", "metro-runtime": "^0.87.0", "metro-source-map": "^0.87.0", + "micromatch": "^4.0.4", "nullthrows": "^1.1.1", + "plist": "^3.1.0", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", diff --git a/packages/react-native/react-native.config.js b/packages/react-native/react-native.config.js index 1cbc83a7019e..17b48efd225e 100644 --- a/packages/react-native/react-native.config.js +++ b/packages/react-native/react-native.config.js @@ -145,6 +145,87 @@ const codegenCommand /*: Command */ = { commands.push(codegenCommand); +const spmCommand /*: Command */ = { + name: 'spm [action]', + description: + 'Set up or maintain Swift Package Manager support for the iOS/macOS app. ' + + 'Actions: add, update, deinit, scaffold. With no action: add (or update ' + + 'if SPM is already set up).', + options: [ + { + name: '--version ', + description: + 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json.', + }, + { + name: '--yes', + description: 'Skip the dirty-pbxproj confirmation prompt.', + }, + { + name: '--xcodeproj ', + description: + '[add] Path to the .xcodeproj to inject SPM packages into ' + + '(disambiguates when several exist).', + }, + { + name: '--productName ', + description: + '[add] App target to inject into (disambiguates when several exist).', + }, + { + name: '--deintegrate', + description: + '[add] Run `pod deintegrate` and strip React Native from the Podfile ' + + 'before injecting (CocoaPods → SwiftPM migration).', + }, + { + name: '--artifacts ', + description: + '[advanced] Local artifact root containing complete debug/ and release/ slots.', + }, + { + name: '--download ', + description: + '[advanced] Artifact download policy: auto (default), skip, or force.', + }, + { + name: '--skipCodegen', + description: '[advanced] Skip the react-native codegen step.', + }, + ], + func: async (argv, _config, args) => { + const passthrough /*: Array */ = []; + if (argv[0] != null) { + passthrough.push(argv[0]); + } + const stringOpts /*: Array<[string, string]> */ = [ + ['version', '--version'], + ['productName', '--product-name'], + ['xcodeproj', '--xcodeproj'], + ['artifacts', '--artifacts'], + ['download', '--download'], + ]; + for (const [key, flag] of stringOpts) { + if (args[key] != null) { + passthrough.push(flag, String(args[key])); + } + } + const boolOpts /*: Array<[string, string]> */ = [ + ['skipCodegen', '--skip-codegen'], + ['deintegrate', '--deintegrate'], + ['yes', '--yes'], + ]; + for (const [key, flag] of boolOpts) { + if (args[key]) { + passthrough.push(flag); + } + } + await require('./scripts/setup-apple-spm').main(passthrough); + }, +}; + +commands.push(spmCommand); + const config = { commands, platforms: {} /*:: as {[string]: Readonly<{ diff --git a/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md new file mode 100644 index 000000000000..5d4970f00d6a --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md @@ -0,0 +1,78 @@ +# Prebuilt ReactNativeDependencies — self-serving headers + deps facades + +How the third-party C/C++ deps (`RCT-Folly`, `glog`, `boost`, +`DoubleConversion`, `fmt`, `fast_float`, `SocketRocket`) are served when +`ReactNativeDependenciesUtils.build_react_native_deps_from_source()` is false +(prebuilt-deps mode). Source-deps mode is unaffected by everything below. + +## Pod-served headers, CocoaPods only (`rndependencies.rb`) + +In prebuilt-deps mode the `ReactNativeDependencies` POD (CocoaPods) is the +single authority for the third-party deps: compiled code lives in its +xcframework binary, and the artifact's own +`Headers/{folly,glog,boost,fmt,double-conversion,fast_float,SocketRocket}` are +flattened into the pod's `Headers/` by the podspec's `prepare_command`. +Consumers resolve bare `` / `` via CocoaPods +public-header linkage from `s.dependency "ReactNativeDependencies"`, plus +`HEADER_SEARCH_PATHS` entries pointing at +`$(PODS_ROOT)/ReactNativeDependencies/Headers`: per-podspec via +`add_rn_third_party_dependencies`, and globally (aggregate + every pod target) +via `ReactNativeDependenciesUtils.configure_aggregate_xcconfig` at post-install +— ReactNativeHeaders is pure-RN, so this is the only global home of the deps +namespaces. The real source pods are neither depended on nor searched. + +For SPM, the deps XCFRAMEWORK itself cannot serve headers: it is framework-type +without `HeadersPath`, and its root `Headers/` is invisible to SPM binaryTargets +(verified 2026-07-04 — `HeadersPath` is rejected on framework entries). The deps +prebuild therefore emits a headers-only LIBRARY-type sidecar, +`ReactNativeDependenciesHeaders.xcframework` (same recipe as ReactNativeHeaders: +stub archives + per-slice `Headers/`), which SPM auto-serves with zero flags. +The sidecar ships inside the deps tarball and as a standalone artifact. + +## Why SocketRocket is vended here + +React-Core compiled from source (source-core + prebuilt-deps mix) imports +`` (`RCTReconnectingWebSocket.m`), and in +prebuilt-deps mode there is NO real SocketRocket pod in the graph — the artifact +is the sole supplier. This does not reintroduce the 2026-07-03 dual-copy +regression: that bug relocated SocketRocket copies onto every pod's search path +(via ReactNativeHeaders → React-Core-prebuilt) while a REAL SocketRocket pod +coexisted. Here there is exactly one physical copy and no coexisting pod. + +## Deps facades (`rndeps_facades.rb`, declared in `react_native_pods.rb`) + +The real source pods are only declared in the deps-from-source branch, so in +prebuilt-deps mode a community podspec's hardcoded `s.dependency "RCT-Folly"` / +`"RCT-Folly/Fabric"` / `"glog"` would resolve from the CocoaPods trunk and +compile from source next to the prebuilt binary. `RNDepsFacades` generates +dependency-only facade podspecs (`build/rndeps-facades//`), installed as +LOCAL pods (`:path`, so Podfile-local resolution beats trunk, nothing fetched): +no sources, no headers, single dependency on `ReactNativeDependencies`. +Versions + subspecs are DERIVED from the real podspecs in +`third-party-podspecs/` (RCT-Folly keeps `/Default` + `/Fabric`, +`default_subspecs = ["Default"]`). SocketRocket has no local podspec — its +facade version is SYNTHESIZED from +`Helpers::Constants::socket_rocket_config[:version]`, fail-closed if absent. +`:modular_headers` is intentionally dropped on facade declarations: a +dependency-only placeholder builds no module; consumers get modules from +`ReactNativeDependencies`. + +## Mode × supplier table + +| core × deps | real 3P pods in graph | SocketRocket headers supplier | +| ------------------- | ----------------------------------------------- | ------------------------------- | +| source + source | yes (`react_native_pods.rb` deps-source branch) | real pod | +| source + prebuilt | no | RNDeps artifact (sole supplier) | +| prebuilt + source | yes | real pod | +| prebuilt + prebuilt | no | RNDeps artifact | + +## SocketRocket privacy manifest + +Upstream SocketRocket ships NO privacy manifest, and the deps artifact +historically carried bundles only for boost/folly/glog. Fixed alongside this +work: the deps prebuild (`scripts/releases/ios-prebuild/configuration.js`) now +embeds `ReactNativeDependencies_SocketRocket.bundle/PrivacyInfo.xcprivacy`, +sourced from an RN-authored manifest at +`scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy` +(accurate-empty: SocketRocket uses no Required Reason APIs). Facades remain +resource-free by design. diff --git a/packages/react-native/scripts/cocoapods/__tests__/rncore-rctuikit-facade-test.rb b/packages/react-native/scripts/cocoapods/__tests__/rncore-rctuikit-facade-test.rb new file mode 100644 index 000000000000..48fa1b60c898 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/rncore-rctuikit-facade-test.rb @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require 'test/unit' +require 'cocoapods' +require 'tmpdir' +require 'open3' +require_relative '../../react_native_pods' + +class RNCoreRCTUIKitFacadeTests < Test::Unit::TestCase + RN_ROOT = File.expand_path('../../..', __dir__) + UIKIT_ROOT = File.join(RN_ROOT, 'ReactApple/Libraries/RCTUIKit') + + def setup + @tmp = Dir.mktmpdir('rncore-rctuikit-') + @previous_source = ReactNativeCoreUtils.class_variable_get(:@@build_from_source) + @previous_frameworks = ENV['USE_FRAMEWORKS'] + @previous_tarball = ENV['RCT_TESTONLY_RNCORE_TARBALL_PATH'] + ENV['USE_FRAMEWORKS'] = 'dynamic' + # A real local file makes podspec evaluation network-free. No download runs. + ENV['RCT_TESTONLY_RNCORE_TARBALL_PATH'] = File.join(@tmp, 'local.tar.gz') + File.write(ENV['RCT_TESTONLY_RNCORE_TARBALL_PATH'], '') + end + + def teardown + ReactNativeCoreUtils.class_variable_set(:@@build_from_source, @previous_source) + ENV['USE_FRAMEWORKS'] = @previous_frameworks + ENV['RCT_TESTONLY_RNCORE_TARBALL_PATH'] = @previous_tarball + FileUtils.rm_rf(@tmp) + end + + def pod(name, **options) + @declaration = [name, options] + end + + def selected_spec(source_mode) + ReactNativeCoreUtils.class_variable_set(:@@build_from_source, source_mode) + unless source_mode + RNCoreFacades.generate(RN_ROOT, @tmp, 'unused', min_supported_versions) + end + # Execute the actual declaration from use_react_native!, without pod install's + # network/codegen setup. This catches accidentally restoring a plain `pod`. + prefix = RN_ROOT + declaration = File.readlines(File.join(RN_ROOT, 'scripts/react_native_pods.rb')) + .find { |line| line.match?(/^\s+(?:rncore_pod|pod) 'React-RCTUIKit',/) } + assert_not_nil(declaration) + eval(declaration, binding) + name, options = @declaration + assert_equal('React-RCTUIKit', name) + dir = File.expand_path(options[:path], @tmp) + [Pod::Specification.from_file(Dir.glob(File.join(dir, '*.podspec{,.json}')).fetch(0)), dir] + end + + def accessor(spec, root) + Pod::Sandbox::FileAccessor.new(Pathname.new(root), spec.consumer(:osx)) + end + + def dynamic_target(spec, files) + definition = Pod::Podfile.new do + target 'Consumer' do + platform :osx, min_macos_version_supported + use_frameworks! :linkage => :dynamic + end + end.target_definitions.fetch('Consumer') + Pod::PodTarget.new( + Pod::Sandbox.new(File.join(@tmp, 'Pods')), Pod::BuildType.dynamic_framework, + {'Debug' => :debug, 'Release' => :release}, ['arm64'], + Pod::Platform.new(:osx, min_macos_version_supported), [spec], [definition], [files] + ) + end + + def test_prebuilt_facade_has_only_the_prebuilt_dependency_and_no_second_header_provider + spec, facade_dir = selected_spec(false) + assert_equal(JSON.parse(File.read(File.join(RN_ROOT, 'package.json')))['version'], spec.version.to_s) + assert_equal(['React-Core-prebuilt'], spec.dependencies.map(&:name)) + assert_equal(min_macos_version_supported, spec.deployment_target(:osx)) + facade_files = accessor(spec, facade_dir) + assert_empty(facade_files.source_files) + assert_empty(facade_files.public_headers) + assert_nil(spec.consumer(:osx).module_map) + assert_false(dynamic_target(spec, facade_files).should_build?) + + # Stage the actual spec-driven RCTUIKit headers, then run the real prebuilt + # pod's prepare command and FileAccessor. Only xcodebuild/binary work is omitted. + artifact = File.join(@tmp, 'React-Core-prebuilt') + FileUtils.mkdir_p(artifact) + script = <<~'JS' + const fs = require('fs'), path = require('path'); + const [root, out] = process.argv.slice(1); + const {computeSpecPlan} = require(path.join(root, 'scripts/ios-prebuild/headers-compose')); + const {renderNamespaceModuleMap} = require(path.join(root, 'scripts/ios-prebuild/headers-spec')); + const plan = computeSpecPlan(root); + const headers = path.join(out, 'ReactNativeHeaders.xcframework', 'macos-arm64', 'Headers'); + fs.mkdirSync(headers, {recursive:true}); + for (const entry of plan.reactNativeHeaders.filter(e => e.relPath.startsWith('RCTUIKit/'))) { + if (entry.redirectTo) throw Error('RCTUIKit must own canonical content'); + const dest = path.join(headers, entry.relPath); + fs.mkdirSync(path.dirname(dest), {recursive:true}); + fs.copyFileSync(path.join(root,entry.source), dest); + } + fs.writeFileSync(path.join(headers, 'module.modulemap'), renderNamespaceModuleMap({RCTUIKit:plan.namespaceModules.RCTUIKit})); + const fw = path.join(out, 'React.xcframework', 'macos-arm64', 'React.framework', 'Headers'); + fs.mkdirSync(fw, {recursive:true}); + for (const name of ['RCTUIKit.h','RCTPlatformDisplayLink.h']) { + const entry = plan.react.find(e => e.relPath === name); + if (!entry) throw Error('Missing React compatibility header: '+name); + fs.copyFileSync(path.join(root,entry.source),path.join(fw,name)); + } + JS + stdout, stderr, status = Open3.capture3('node', '-e', script, RN_ROOT, artifact) + assert_predicate(status, :success?, stdout + stderr) + prebuilt = Pod::Specification.from_file(File.join(RN_ROOT, 'React-Core-prebuilt.podspec')) + stdout, stderr, status = Open3.capture3('bash', '-c', prebuilt.prepare_command, :chdir => artifact) + assert_predicate(status, :success?, stdout + stderr) + prebuilt_files = accessor(prebuilt, artifact) + canonical = prebuilt_files.public_headers.select { |h| h.to_s.include?('/Headers/RCTUIKit/') } + expected = Dir.glob(File.join(UIKIT_ROOT, '*.h')).map { |h| File.basename(h) }.sort + assert_equal(18, expected.length) + assert_equal(expected, canonical.map { |h| h.basename.to_s }.sort) + providers = [facade_files, prebuilt_files].flat_map(&:public_headers) + expected.each do |name| + assert_equal(1, providers.count { |h| h.basename.to_s == name }, "one provider for #{name}") + end + assert_match(/module RCTUIKit\s*\{/, File.read(File.join(artifact, 'Headers/module.modulemap'))) + {'RCTUIKit.h' => 'RCTUIKit/RCTUIKit.h', 'RCTPlatformDisplayLink.h' => 'RCTUIKit/RCTPlatformDisplayLink.h'}.each do |name, include_path| + compat = File.read(File.join(artifact, 'React.xcframework/macos-arm64/React.framework/Headers', name)) + assert_include(compat, "#import <#{include_path}>") + end + assert_false(Dir.exist?(File.join(artifact, 'ReactNativeHeaders.xcframework'))) + end + + def test_source_mode_keeps_the_original_module_headers_and_implementation + spec, source_dir = selected_spec(true) + assert_equal(UIKIT_ROOT, source_dir) + assert_equal('RCTUIKit', spec.module_name) + assert_equal('RCTUIKit', spec.consumer(:osx).header_dir) + assert_not_include(spec.dependencies.map(&:name), 'React-Core-prebuilt') + files = accessor(spec, source_dir) + assert_equal(18, files.public_headers.length) + implementations = files.source_files.select { |f| f.extname == '.m' } + assert_equal(14, implementations.length) + assert_true(dynamic_target(spec, files).should_build?) + end + + def test_swiftpm_react_product_includes_rctuikit_implementation_target + stdout, stderr, status = Open3.capture3('swift', 'package', 'dump-package', '--package-path', RN_ROOT) + assert_predicate(status, :success?, stderr) + manifest = JSON.parse(stdout) + product = manifest['products'].find { |p| p['name'] == 'React' } + assert_equal(['dynamic'], product.fetch('type').fetch('library')) + assert_include(product.fetch('targets'), 'RCTUIKit') + target = manifest['targets'].find { |t| t['name'] == 'RCTUIKit' } + assert_equal('regular', target['type']) + assert_equal('ReactApple/Libraries/RCTUIKit', target['path']) + assert_nil(target['sources']) # SwiftPM discovers the implementation files. + assert_equal(['README.md'], target['exclude']) + assert_equal(14, Dir.glob(File.join(RN_ROOT, target['path'], '*.m')).length) + end +end diff --git a/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb b/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb new file mode 100644 index 000000000000..96d0e6b370d2 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb @@ -0,0 +1,137 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "test/unit" +require "fileutils" +require "cocoapods" +require_relative "../spm.rb" + +# These tests exercise the real `Pod::Project` UUID machinery (via the +# `xcodeproj`/`cocoapods` gems) because the bug they guard against is emergent +# from how `Pod::Project` hands out UUIDs, and cannot be observed against a mock. +class SPMTests < Test::Unit::TestCase + PodSpecStub = Struct.new(:name) + InstallerStub = Struct.new(:pods_project, :aggregate_targets) + + POD_NAME = "ReactNativeEnrichedMarkdown" + TMP_DIR = File.join(Dir.tmpdir, "rn-spm-test") + + def setup + FileUtils.rm_rf(TMP_DIR) + FileUtils.mkdir_p(TMP_DIR) + end + + def teardown + FileUtils.rm_rf(TMP_DIR) + end + + def build_project(num_pods) + path = File.join(TMP_DIR, "Pods.xcodeproj") + FileUtils.mkdir_p(path) + project = Pod::Project.new(path) + num_pods.times { |i| project.new_target(:static_library, "Pod#{i}", :ios) } + project.new_target(:static_library, POD_NAME, :ios) + project.save + project + end + + def inject_spm(project, aggregates = []) + manager = SPMManager.new + manager.dependency( + PodSpecStub.new(POD_NAME), + url: "https://github.com/software-mansion-labs/RaTeX.git", + requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.1.0" }, + products: ["RaTeX"] + ) + manager.apply_on_post_install(InstallerStub.new(project, aggregates)) + project.save + end + + # Simulates the state after an on-disk reload / incremental `pod install`: + # existing objects keep their counter-based UUIDs, but the generator counters + # reset to zero. This is what makes `Pod::Project#generate_uuid` hand back a + # UUID (`00000000`) that already belongs to the root object. + def simulate_reload(project) + project.instance_variable_set(:@generated_uuids, []) + project.instance_variable_set(:@available_uuids, []) + end + + def assert_loadable_project(path) + reopened = nil + assert_nothing_raised("Pods project must reload cleanly after SPM injection") do + reopened = Xcodeproj::Project.open(path) + end + root_uuid = reopened.root_object.uuid + assert( + reopened.objects_by_uuid[root_uuid].is_a?(Xcodeproj::Project::Object::PBXProject), + "rootObject UUID must resolve to a PBXProject" + ) + package_uuids = reopened.root_object.package_references.map(&:uuid) + assert( + package_uuids.none? { |uuid| uuid == root_uuid }, + "injected package reference must not collide with the root object UUID" + ) + reopened + end + + def test_spm_injection_on_freshly_generated_project_reloads_cleanly + project = build_project(88) + inject_spm(project) + assert_loadable_project(project.path) + end + + def test_spm_injection_after_project_reload_does_not_collide_with_root_object + project = build_project(88) + simulate_reload(project) + inject_spm(project) + assert_loadable_project(project.path) + end + + def test_injected_uuids_are_unique_across_all_objects + project = build_project(88) + simulate_reload(project) + inject_spm(project) + reopened = assert_loadable_project(project.path) + uuids = reopened.objects.map(&:uuid) + assert_equal(uuids.length, uuids.uniq.length, "all object UUIDs must be unique") + end + + def test_static_pod_uses_shared_products_and_rewrites_saved_aggregate_config + project = build_project(1) + config = Xcodeproj::Config.new( + "OTHER_CFLAGS" => "$(inherited) -fmodule-map-file=${PODS_CONFIGURATION_BUILD_DIR}/#{POD_NAME}/#{POD_NAME}.modulemap", + "OTHER_SWIFT_FLAGS" => "$(inherited) -Xcc -fmodule-map-file=${PODS_CONFIGURATION_BUILD_DIR}/#{POD_NAME}/#{POD_NAME}.modulemap" + ) + aggregate = Struct.new(:xcconfigs, :output) do + def xcconfig_path(name) + Pathname.new(output) + end + end.new({"Debug" => config}, File.join(TMP_DIR, "aggregate.xcconfig")) + inject_spm(project, [aggregate]) + target = project.targets.find { |t| t.name == POD_NAME } + target.build_configurations.each do |build_config| + assert_equal('${PODS_CONFIGURATION_BUILD_DIR}', target.build_settings(build_config.name)['CONFIGURATION_BUILD_DIR']) + end + %w[OTHER_CFLAGS OTHER_SWIFT_FLAGS].each do |key| + assert_include(config.attributes[key], "${PODS_CONFIGURATION_BUILD_DIR}/#{POD_NAME}.modulemap") + assert_not_include(config.attributes[key], "/#{POD_NAME}/#{POD_NAME}.modulemap") + end + assert_include(File.read(aggregate.output), "${PODS_CONFIGURATION_BUILD_DIR}/#{POD_NAME}.modulemap") + end + + def test_dynamic_framework_keeps_its_build_directory + project = build_project(1) + target = project.targets.find { |t| t.name == POD_NAME } + target.product_type = 'com.apple.product-type.framework' + target.build_configurations.each do |config| + target.build_settings(config.name)['CONFIGURATION_BUILD_DIR'] = 'original-products' + end + inject_spm(project) + target.build_configurations.each do |config| + assert_equal('original-products', target.build_settings(config.name)['CONFIGURATION_BUILD_DIR']) + assert_include(target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'], '${SYMROOT}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/') + end + end +end diff --git a/packages/react-native/scripts/cocoapods/fabric.rb b/packages/react-native/scripts/cocoapods/fabric.rb index b6c8264a3546..ccb10438940b 100644 --- a/packages/react-native/scripts/cocoapods/fabric.rb +++ b/packages/react-native/scripts/cocoapods/fabric.rb @@ -11,7 +11,7 @@ def setup_fabric!(react_native_path: "../node_modules/react-native") pod 'React-Fabric', :path => "#{react_native_path}/ReactCommon" pod 'React-FabricComponents', :path => "#{react_native_path}/ReactCommon" pod 'React-graphics', :path => "#{react_native_path}/ReactCommon/react/renderer/graphics" - pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true + rncore_pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true pod 'React-ImageManager', :path => "#{react_native_path}/ReactCommon/react/renderer/imagemanager/platform/ios" pod 'React-FabricImage', :path => "#{react_native_path}/ReactCommon" end diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 252588c98432..00b9327871e4 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -11,30 +11,16 @@ ### Adds ReactNativeCore-prebuilt as a dependency to the given podspec if we're not ### building ReactNativeCore from source (then this function does nothing). +### +### `` resolves through the vendored React.framework; every other namespace +### (``, ``, ``, ...) resolves through the flattened +### ReactNativeHeaders headers that React-Core-prebuilt exposes. The header search path +### and the ReactNativeHeaders module-map activation are NOT added here: they are applied +### post-install by configure_aggregate_xcconfig, which covers aggregate, third-party AND +### these pods from a single injection site. No clang VFS overlay. def add_rncore_dependency(s) if !ReactNativeCoreUtils.build_rncore_from_source() - # Add the dependency s.dependency "React-Core-prebuilt" - - current_pod_target_xcconfig = s.to_hash["pod_target_xcconfig"] || {} - current_pod_target_xcconfig = current_pod_target_xcconfig.to_h unless current_pod_target_xcconfig.is_a?(Hash) - - # Add VFS overlay flags for both Objective-C and Swift - # The VFS overlay file is pre-resolved at pod install time for each platform slice. - # We reference it directly in the xcframework using the React-VFS.yaml file that - # is written to the React-Core-prebuilt folder during setup_vfs_overlay. - # See scripts/ios-prebuild/__docs__/README.md for more details on VFS overlays. - vfs_overlay_flag = "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - current_pod_target_xcconfig["OTHER_CFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CFLAGS"] += " #{vfs_overlay_flag}" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] += " #{vfs_overlay_flag}" - # For Swift, we need to use -Xcc to pass flags to the underlying Clang compiler - # Both the flag and its argument need separate -Xcc prefixes - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] += " -Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - - s.pod_target_xcconfig = current_pod_target_xcconfig end end @@ -521,71 +507,36 @@ def self.get_nightly_npm_version() return latest_nightly end - # Processes the VFS overlay file from the React.xcframework to resolve the ${ROOT_PATH} placeholder. - # This method should be called from react_native_post_install after pod install completes. + # Single post-install injection site for the prebuilt header resolution. Adds the + # ReactNativeHeaders search path + module-map activation to the aggregate (main app) + # target AND every pod target — RN core pods, third-party pods alike. (add_rncore_dependency + # only declares the React-Core-prebuilt dependency; it no longer touches xcconfigs.) # - # The VFS overlay file maps header import paths to their actual locations within the xcframework. - # Since the xcframework contains platform-specific slices, we generate a resolved VFS file for each - # slice and also create a default VFS file that can be used immediately (before script phases run). - def self.process_vfs_overlay() - return if @@build_from_source - - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - xcframework_path = File.join(prebuilt_path, "React.xcframework") - vfs_template_path = File.join(xcframework_path, "React-VFS-template.yaml") - - unless File.exist?(vfs_template_path) - rncore_log("VFS overlay template not found at #{vfs_template_path}", :error) - exit 1 - end - - rncore_log("Processing VFS overlay file...") - - # Read the template content - vfs_template_content = File.read(vfs_template_path) - - # Write the VFS file - use the top-level xcframework path - # so that ${ROOT_PATH}/Headers points to the xcframework's Headers folder - resolved_vfs_content = vfs_template_content.gsub('${ROOT_PATH}', xcframework_path) - resolved_vfs_path = File.join(prebuilt_path, "React-VFS.yaml") - File.write(resolved_vfs_path, resolved_vfs_content) - rncore_log(" Created VFS overlay at #{resolved_vfs_path}") - - rncore_log("VFS overlay setup complete") - end - - # Configures the xcconfig files for aggregate (main app) targets to enable VFS overlay for React Native Core. - # This is needed because the main app target does not go through podspec processing, - # so it won't get the VFS overlay flags from add_rncore_dependency. + # `` resolves through the vendored React.framework; this adds the search + # path to the flattened ReactNativeHeaders headers (every other RN namespace — + # the third-party deps namespaces are served by the ReactNativeDependencies pod, + # see ReactNativeDependenciesUtils.configure_aggregate_xcconfig). There is + # no clang VFS overlay. # # Parameters: # - installer: The CocoaPods installer object def self.configure_aggregate_xcconfig(installer) return if @@build_from_source - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - vfs_overlay_path = File.join(prebuilt_path, "React-VFS.yaml") - - unless File.exist?(vfs_overlay_path) - rncore_log("VFS overlay not found at #{vfs_overlay_path}, skipping prebuilt xcconfig configuration", :error) - exit 1 - end - rncore_log("Configuring xcconfig for prebuilt React Native Core...") - vfs_overlay_flag = " -ivfsoverlay \"#{vfs_overlay_path}\"" - swift_vfs_overlay_flag = " -Xcc -ivfsoverlay -Xcc \"#{vfs_overlay_path}\"" + headers_search_path = " \"$(PODS_ROOT)/React-Core-prebuilt/Headers\"" - # Add flags to aggregate target xcconfigs (these are used by the main app target) + # Add the header search path to aggregate target xcconfigs (used by the main app target) installer.aggregate_targets.each do |aggregate_target| aggregate_target.xcconfigs.each do |config_name, config_file| - add_vfs_overlay_flags(config_file.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(config_file.attributes, headers_search_path) xcconfig_path = aggregate_target.xcconfig_path(config_name) config_file.save_as(xcconfig_path) end end - # Add flags to ALL pod targets (for third-party pods that don't call add_rncore_dependency) + # Add the header search path to ALL pod targets (for third-party pods that don't call add_rncore_dependency) installer.pod_targets.each do |pod_target| pod_target.build_settings.each do |config_name, build_settings| xcconfig_path = pod_target.xcconfig_path(config_name) @@ -593,11 +544,11 @@ def self.configure_aggregate_xcconfig(installer) xcconfig = Xcodeproj::Config.new(xcconfig_path) - # Check if VFS overlay is already present - other_cflags = xcconfig.attributes["OTHER_CFLAGS"] || "" - next if other_cflags.include?("ivfsoverlay") + # Skip if the prebuilt header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("React-Core-prebuilt/Headers") - add_vfs_overlay_flags(xcconfig.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(xcconfig.attributes, headers_search_path) xcconfig.save_as(xcconfig_path) end end @@ -605,12 +556,18 @@ def self.configure_aggregate_xcconfig(installer) rncore_log("Prebuilt xcconfig configuration complete") end - # Helper method to add VFS overlay flags to an xcconfig attributes map - def self.add_vfs_overlay_flags(attributes, vfs_overlay_flag, swift_vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", swift_vfs_overlay_flag) + # Helper method to add the prebuilt ReactNativeHeaders header search path to an xcconfig attributes map + def self.add_prebuilt_header_search_paths(attributes, headers_search_path) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "HEADER_SEARCH_PATHS", headers_search_path) # Suppress incomplete umbrella warnings for the prebuilt frameworks (it is expected, as our umbrella headers do not include all headers) ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc -Wno-incomplete-umbrella") + # Activate the ReactNativeHeaders module map so the relocated namespaces + # (`yoga`, `RCTDeprecation`, `ReactNativeHeaders_react`, ...) are modular — + # otherwise the React framework's clang explicit-module precompile trips + # -Wnon-modular-include-in-framework-module on `` / ``. + # Quoted so a $(PODS_ROOT) containing spaces stays a single clang argument. + module_map_flag = " \"-fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap\"" + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", module_map_flag) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc" + module_map_flag) end end diff --git a/packages/react-native/scripts/cocoapods/rncore_facades.rb b/packages/react-native/scripts/cocoapods/rncore_facades.rb new file mode 100644 index 000000000000..6a332aacfdea --- /dev/null +++ b/packages/react-native/scripts/cocoapods/rncore_facades.rb @@ -0,0 +1,236 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require 'json' +require 'fileutils' + +# Facade podspecs for the prebuilt React Native Core path. +# +# In prebuilt mode the compiled code AND headers for the React core pods live +# entirely inside React.xcframework + React-Core-prebuilt (which flattens the +# ReactNativeHeaders namespaces into its Headers/). Re-installing the SOURCE +# podspecs in that mode is what makes them ship duplicate headers that shadow the +# prebuilt artifact (via HEADER_SEARCH_PATHS, CocoaPods .hmap header maps, and +# the all-product-headers VFS overlay) and break the React framework's clang +# explicit-module precompile. +# +# Instead we install dependency-only FACADE podspecs for those names: they ship +# no source files and (with one narrow exception, see FACADE_REEXPOSED_HEADERS) +# no headers, so CocoaPods makes them PBXAggregateTarget +# placeholders (should_build? == false) and nothing is laid down to shadow. Each +# facade depends on React-Core-prebuilt so its consumers transitively pick up the +# prebuilt framework + headers. The pod NAMES still resolve, so ReactCodegen, +# third-party modules, and RN's own podspec graph keep resolving `React-Core`, +# `Yoga`, `React-Core/Default`, etc. +# +# MAINTENANCE MODEL: the set of facaded pods is explicit (FACADE_PODS) so the +# prebuilt rollout can be staged, but each facade's VERSION and SUBSPECS are +# DERIVED from the real podspec at `pod install` time (Pod::Specification.from_file). +# That removes the drift risk that would otherwise bite third-party libraries: +# if React adds/renames `React-Core/`, the facade exposes it +# automatically — nobody has to hand-maintain a parallel subspec list. +# +# This is staged: phase 1 facades a small set and KEEPS the existing +# podspec_sources / add_rncore_dependency / configure_aggregate_xcconfig / +# -fmodule-map-file machinery. The set is expanded until the cold prebuilt +# build passes; the distributed prebuilt helpers are only deleted afterwards. +module RNCoreFacades + # pod name => podspec path (relative to the react-native package root). + # These are the React-core pods whose code + headers are fully provided by + # the prebuilt React.xcframework / React-Core-prebuilt. Start small; expand as + # the cold build surfaces more shadowing pods. (NOTE: not every caller of + # add_rncore_dependency belongs here — e.g. ReactCodegen depends on the + # prebuilt but still builds its own generated sources, so it is NOT a facade.) + FACADE_PODS = { + "React-Core" => "React-Core.podspec", + "React-RCTFabric" => "React/React-RCTFabric.podspec", + "React-RCTRuntime" => "React/Runtime/React-RCTRuntime.podspec", + # [macOS] React's SwiftPM product already links the RCTUIKit target. + # Its canonical module/headers live only in ReactNativeHeaders; the + # React compatibility headers forward there rather than re-vending it. + "React-RCTUIKit" => "ReactApple/Libraries/RCTUIKit/React-RCTUIKit.podspec", + "Yoga" => "ReactCommon/yoga/Yoga.podspec", + "RCTDeprecation" => "ReactApple/Libraries/RCTFoundation/RCTDeprecation/RCTDeprecation.podspec", + "FBLazyVector" => "Libraries/FBLazyVector/FBLazyVector.podspec", + "RCTRequired" => "Libraries/Required/RCTRequired.podspec", + } + + # A facade ships NO headers by default — the prebuilt React.framework / + # React-Core-prebuilt own them. NARROW EXCEPTION: a few headers live ONLY + # inside React.framework/Headers (served angle-only as , e.g. + # because they reach unguarded C++ and are excluded from the framework module + # map) and are NOT in the flattened React-Core-prebuilt/Headers. A quoted + # `#import ".h"` therefore has no resolution target in prebuilt mode. + # + # Community Fabric modules quote-import RCTFabricComponentsPlugins.h (47x in a + # full app: slider, maps, pager-view, keyboard-controller, ...). In source it + # was vended by React-RCTFabric at header_dir "React", which put it in + # dependents' CocoaPods header maps so the bare quoted name resolved. The + # facade dropped it. We re-vend JUST that header here so dependents' header + # maps carry it again, exactly as the source pod did. + # + # Re-exposing a SINGLE header (not the whole React/ namespace) does not put + # / on -I, so it does NOT reintroduce the + # -Wnon-modular-include-in-framework-module shadowing the modular prebuilt + # layout exists to eliminate. The header is core-only (matches source: it + # only ever declared RN's built-in components; third-party Fabric components + # register via the codegen RCTThirdPartyComponentsProvider, shipped by the + # non-facaded ReactCodegen pod). + # + # pod name => { "header_dir" => , "globs" => [] } + FACADE_REEXPOSED_HEADERS = { + "React-RCTFabric" => { + "header_dir" => "React", + "globs" => ["Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h"], + }, + } + + # Sub-directory (relative to the install root) that holds the generated facades. + FACADE_RELDIR = File.join("build", "rncore-facades") + + @@install_root = nil + + # True when `name` should be installed as a facade instead of its source podspec. + def self.facade?(name) + FACADE_PODS.key?(name) + end + + # Generates the facade podspecs and returns the base directory holding them. + # Each facade gets its OWN sub-directory containing a single + # `.podspec.json`, so it can be installed as a LOCAL pod via + # `:path => `. `:path` (PathSource) uses the spec in place and never + # downloads `spec.source` — unlike `:podspec` (PodspecSource), which is an + # *external* source whose `root_spec.source` CocoaPods would actually fetch + # (i.e. git-clone react-native for every empty facade). Idempotent; safe to + # call once per `pod install`. + # + # `react_native_path` locates the real podspecs we mirror. version + subspecs + + # default_subspecs are DERIVED from the real spec so the facade stays + # graph-equivalent to the source pod (resources are NOT carried — they live in + # the prebuilt artifact; see the note in the loop). A facaded pod whose real + # podspec can't be read is a hard error (see load_real_spec) — silently shipping + # an empty facade would hide exactly the drift this guards against. + def self.generate(react_native_path, install_root, version, platforms) + @@install_root = install_root.to_s + abs_base = File.join(@@install_root, FACADE_RELDIR) + FileUtils.mkdir_p(abs_base) + FACADE_PODS.each do |name, podspec_rel_path| + podspec_path = File.join(react_native_path.to_s, podspec_rel_path) + podspec_dir = File.dirname(podspec_path) + real = load_real_spec(podspec_path, name) + dir = File.join(abs_base, name) + FileUtils.mkdir_p(dir) + + spec = { + "name" => name, + "version" => real.version.to_s, + "summary" => "Prebuilt facade for #{name} (code + headers live in React-Core-prebuilt).", + "homepage" => "https://reactnative.dev/", + "license" => "MIT", + "authors" => "Meta Platforms, Inc. and its affiliates", + "platforms" => platforms, # [macOS] Use the fork's shared Apple platform policy. + # Required podspec attribute, but never fetched: the pod is installed + # as a LOCAL pod (`:path => `), which uses this spec in place and + # ships no source_files. Placeholder only. + "source" => { "git" => "https://github.com/facebook/react-native.git" }, + "dependencies" => { "React-Core-prebuilt" => [] }, + } + + # NOTE: the facade carries NO resources. The pods' non-code resources + # (e.g. the privacy manifest) are embedded directly in the prebuilt + # React.xcframework by the ios-prebuild compose (see ios-prebuild/framework-resources.js), + # so they reach both CocoaPods-prebuilt and SwiftPM from the artifact — + # the facade only needs to declare the React-Core-prebuilt dependency. + + # Re-vend the narrow set of angle-only framework headers that community + # modules quote-import (see FACADE_REEXPOSED_HEADERS). The header is + # COPIED into the facade dir (a self-contained snapshot — robust, no + # `..` globs reaching out of the pod) and exposed as a public header. + # It's header-only (a `.h` has nothing to compile, so the facade stays + # a placeholder), but CocoaPods lays it into + # Pods/Headers/Public/// and dependents' .hmap, + # restoring quoted resolution exactly as the source pod did. + reexposed = FACADE_REEXPOSED_HEADERS[name] + if reexposed + copied = copy_reexposed_headers(reexposed["globs"], podspec_dir, dir, name) + unless copied.empty? + spec["source_files"] = copied + spec["public_header_files"] = copied + spec["header_dir"] = reexposed["header_dir"] if reexposed["header_dir"] + end + end + + # Preserve default_subspec so a bare `pod ''` resolves to the SAME + # subspec graph as the source pod (without it CocoaPods pulls every + # subspec, which is not graph-equivalent). + defaults = Array(real.default_subspecs) + spec["default_subspecs"] = defaults unless defaults.empty? + + subspecs = derive_subspecs(real) + unless subspecs.empty? + spec["subspecs"] = subspecs.map do |ss| + { "name" => ss, "dependencies" => { "React-Core-prebuilt" => [] } } + end + end + + File.write(File.join(dir, "#{name}.podspec.json"), JSON.pretty_generate(spec)) + end + abs_base + end + + # Facade dir for ``, RELATIVE to the install root — pass to `pod :path =>`. + # Relative (not absolute) so the path CocoaPods records in Podfile.lock is + # portable rather than machine-specific. + def self.facade_path(name) + File.join(FACADE_RELDIR, name) + end + + # Loads the real podspec so we can mirror its structure. A facaded pod MUST have + # a readable real podspec — if it's missing or unparseable we raise rather than + # ship an empty facade, since that would silently drop subspecs (the very drift + # this mechanism exists to prevent). + def self.load_real_spec(path, name) + unless File.exist?(path) + raise "[RNCoreFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \ + "Update FACADE_PODS in rncore_facades.rb if the podspec moved." + end + Pod::Specification.from_file(path) + rescue => e + raise "[RNCoreFacades] Failed to read real podspec for facaded pod '#{name}' at #{path}: #{e.message}" + end + private_class_method :load_real_spec + + # Library (non-test, non-app) subspec names of the real spec, so third-party + # libs depending on `/` keep resolving. Derived, never hand-listed. + def self.derive_subspecs(real) + real.subspecs + .reject { |ss| ss.test_specification? || (ss.respond_to?(:app_specification?) && ss.app_specification?) } + .map(&:base_name) + end + private_class_method :derive_subspecs + + # Copy the re-exposed header(s) into the facade dir (flat) and return the + # facade-relative source_files entries. `globs` are resolved against the real + # podspec dir. A glob that matches nothing is a hard error: the whole point is + # to keep a quoted-import header resolvable, so silently shipping a facade + # without it would reintroduce the exact "file not found" we're fixing. + def self.copy_reexposed_headers(globs, podspec_dir, facade_dir, name) + copied = [] + Array(globs).each do |g| + matches = Dir.glob(File.expand_path(g, podspec_dir)) + if matches.empty? + raise "[RNCoreFacades] Re-exposed header glob '#{g}' for facade '#{name}' " \ + "matched no files under #{podspec_dir}. Update FACADE_REEXPOSED_HEADERS." + end + matches.each do |src| + base = File.basename(src) + FileUtils.cp(src, File.join(facade_dir, base)) + copied << base + end + end + copied.uniq + end + private_class_method :copy_reexposed_headers +end diff --git a/packages/react-native/scripts/cocoapods/rndependencies.rb b/packages/react-native/scripts/cocoapods/rndependencies.rb index 1c7fac1a6cb7..a88fa6e483f9 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -6,6 +6,7 @@ require "json" require 'net/http' require 'rexml/document' +require 'shellwords' require_relative './utils.rb' @@ -36,7 +37,7 @@ def add_rn_third_party_dependencies(s) header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] if header_search_paths.is_a?(String) - header_search_paths = header_search_paths.split(" ") + header_search_paths = Shellwords.shellsplit(header_search_paths) end header_search_paths << "$(PODS_ROOT)/glog" @@ -47,10 +48,24 @@ def add_rn_third_party_dependencies(s) header_search_paths << "$(PODS_ROOT)/SocketRocket" header_search_paths << "$(PODS_ROOT)/RCT-Folly" - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths + # uniq so a second call on the same spec can't duplicate entries. + current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq else + # Prebuilt-deps mode: this pod SELF-SERVES the third-party headers from its + # own xcframework (incl. SocketRocket - sole supplier in this mode). See + # scripts/cocoapods/__docs__/prebuilt-deps.md for the full contract. s.dependency "ReactNativeDependencies" - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] ||= [] << "$(PODS_ROOT)/ReactNativeDependencies" + + header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] + if header_search_paths.is_a?(String) + header_search_paths = Shellwords.shellsplit(header_search_paths) + end + # Artifact headers are flattened into the pod-local Headers/ by the podspec + # prepare_command (see __docs__/prebuilt-deps.md). + header_search_paths << "$(PODS_ROOT)/ReactNativeDependencies/Headers" + + # uniq so a second call on the same spec can't duplicate entries. + current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq end s.pod_target_xcconfig = current_pod_target_xcconfig @@ -139,6 +154,52 @@ def self.abort_if_use_local_rndeps_with_no_file() end end + # Single post-install injection site for the prebuilt deps header resolution. + # Adds the flattened ReactNativeDependencies/Headers search path to the + # aggregate (main app) target AND every pod target, mirroring + # ReactNativeCoreUtils.configure_aggregate_xcconfig. ReactNativeHeaders is + # pure-RN, so this path is the single global home of the third-party + # namespaces (folly/glog/boost/fmt/double-conversion/fast_float/ + # SocketRocket): pods that never call add_rn_third_party_dependencies (nor + # depend on a facade) still compile RN headers that textually reach + # . No module-map activation needed — the deps headers are + # served textually; modules come from the ReactNativeDependencies pod. + def self.configure_aggregate_xcconfig(installer) + return if @@build_from_source + + rndeps_log("Configuring xcconfig for prebuilt React Native Dependencies...") + headers_search_path = " \"$(PODS_ROOT)/ReactNativeDependencies/Headers\"" + + # Add the header search path to aggregate target xcconfigs (used by the main app target) + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.xcconfigs.each do |config_name, config_file| + ReactNativePodsUtils.add_flag_to_map_with_inheritance(config_file.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig_path = aggregate_target.xcconfig_path(config_name) + config_file.save_as(xcconfig_path) + end + end + + # Add the header search path to ALL pod targets (for pods that don't go + # through add_rn_third_party_dependencies) + installer.pod_targets.each do |pod_target| + pod_target.build_settings.each do |config_name, build_settings| + xcconfig_path = pod_target.xcconfig_path(config_name) + next unless File.exist?(xcconfig_path) + + xcconfig = Xcodeproj::Config.new(xcconfig_path) + + # Skip if the deps header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("ReactNativeDependencies/Headers") + + ReactNativePodsUtils.add_flag_to_map_with_inheritance(xcconfig.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig.save_as(xcconfig_path) + end + end + + rndeps_log("Prebuilt deps xcconfig configuration complete") + end + def self.podspec_source_download_prebuild_release_tarball() # Warn if @@react_native_path is not set if @@react_native_path == "" diff --git a/packages/react-native/scripts/cocoapods/rndeps_facades.rb b/packages/react-native/scripts/cocoapods/rndeps_facades.rb new file mode 100644 index 000000000000..dc89ddc28e88 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/rndeps_facades.rb @@ -0,0 +1,193 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require 'json' +require 'fileutils' +# Self-contained against require ordering: this module reads +# Helpers::Constants.socket_rocket_config. react_native_pods.rb normally loads +# helpers.rb first, but requiring it here (idempotent) removes that implicit +# dependency. The defined? guard at the use site stays as a backstop. +require_relative './helpers' + +# Dependency-only facade podspecs for the third-party deps in prebuilt-deps +# mode (deps-side analogue of RNCoreFacades). Design + rationale: +# scripts/cocoapods/__docs__/prebuilt-deps.md +module RNDepsFacades + # The name of the umbrella prebuilt-deps pod every facade depends on. Its pod + # self-serves the third-party headers + carries the binary (see + # rndependencies.rb, ReactNativeDependencies.podspec). + DEPS_POD = "ReactNativeDependencies" + + # pod name => podspec path (relative to the react-native package root), or + # :synthesized for a pod with no local podspec (SocketRocket). Version + + # subspecs + default_subspecs are DERIVED from the real podspec where one + # exists; synthesized entries derive their version from a constant. + FACADE_PODS = { + "RCT-Folly" => "third-party-podspecs/RCT-Folly.podspec", + "glog" => "third-party-podspecs/glog.podspec", + "boost" => "third-party-podspecs/boost.podspec", + "DoubleConversion" => "third-party-podspecs/DoubleConversion.podspec", + "fmt" => "third-party-podspecs/fmt.podspec", + "fast_float" => "third-party-podspecs/fast_float.podspec", + "SocketRocket" => :synthesized, + } + + # Sub-directory (relative to the install root) that holds the generated + # deps facades. Kept separate from RNCoreFacades' `build/rncore-facades` so + # the two families never collide. + FACADE_RELDIR = File.join("build", "rndeps-facades") + + # Generates the facade podspecs and returns the base directory holding them. + # Each facade gets its OWN sub-directory containing a single + # `.podspec.json`, so it can be installed as a LOCAL pod via + # `:path => ` (PathSource uses the spec in place and never downloads + # `spec.source`). Idempotent; safe to call once per `pod install`. + # + # `react_native_path` locates the real third-party podspecs we mirror. + # version + subspecs + default_subspecs are DERIVED from the real spec (or, + # for SocketRocket, synthesized from the socket_rocket_config version) so the + # facade matches the source pod's spec/subspec SHAPE. It is not fully + # graph-equivalent: every derived subspec depends only on + # ReactNativeDependencies, so intra-pod subspec deps (e.g. RCT-Folly/Fabric + # -> RCT-Folly/Default) are not reproduced — harmless here because the deps + # are all declared explicitly in react_native_pods.rb. NO source_files and + # NO headers are emitted — the ReactNativeDependencies pod supplies both. A + # facaded pod whose real podspec can't be read is a hard error (see + # load_real_spec) — silently shipping an empty facade would hide drift. + def self.generate(react_native_path, install_root, platforms) + abs_base = File.join(install_root.to_s, FACADE_RELDIR) + FileUtils.mkdir_p(abs_base) + FACADE_PODS.each do |name, podspec_rel_path| + dir = File.join(abs_base, name) + FileUtils.mkdir_p(dir) + + if podspec_rel_path == :synthesized + spec = synthesized_spec(name, platforms) + else + podspec_path = File.join(react_native_path.to_s, podspec_rel_path) + real = load_real_spec(podspec_path, name) + spec = derived_spec(name, real, platforms) + end + + File.write(File.join(dir, "#{name}.podspec.json"), JSON.pretty_generate(spec)) + end + abs_base + end + + # Facade dir for ``, RELATIVE to the install root — pass to `pod :path =>`. + # Relative (not absolute) so the path CocoaPods records in Podfile.lock is + # portable rather than machine-specific. + def self.facade_path(name) + File.join(FACADE_RELDIR, name) + end + + # Base spec skeleton shared by derived + synthesized facades: dependency-only, + # no source_files, no headers. Depends solely on ReactNativeDependencies. + def self.base_spec(name, version, platforms) + { + "name" => name, + "version" => version, + "summary" => "Prebuilt facade for #{name} (code + headers live in #{DEPS_POD}).", + "homepage" => "https://reactnative.dev/", + "license" => "MIT", + "authors" => "Meta Platforms, Inc. and its affiliates", + "platforms" => platforms, # [macOS] Share the caller's Apple platform policy. + # Required podspec attribute, but never fetched: installed as a LOCAL + # pod (`:path => `), which uses this spec in place and ships no + # source_files. Placeholder only. + "source" => { "git" => "https://github.com/facebook/react-native.git" }, + "dependencies" => { DEPS_POD => [] }, + } + end + private_class_method :base_spec + + # Facade derived from a real third-party podspec: version + subspecs + + # default_subspecs mirror the real spec so a bare `pod ''` and any + # `pod '/'` resolve to the SAME graph (e.g. RCT-Folly's + # bare + /Default + /Fabric). Each subspec is also dependency-only and + # depends on ReactNativeDependencies. + # + # NOTE: resources (e.g. RCT-Folly's PrivacyInfo.xcprivacy) are intentionally + # NOT carried. In prebuilt-deps mode the third-party code — and its privacy + # manifest — is embedded in the ReactNativeDependencies artifact; the facade + # only needs to declare the dependency (see the design note in the PR). + def self.derived_spec(name, real, platforms) + spec = base_spec(name, real.version.to_s, platforms) + + defaults = Array(real.default_subspecs) + spec["default_subspecs"] = defaults unless defaults.empty? + + subspecs = derive_subspecs(real) + unless subspecs.empty? + spec["subspecs"] = subspecs.map do |ss| + { "name" => ss, "dependencies" => { DEPS_POD => [] } } + end + end + + spec + end + private_class_method :derived_spec + + # Facade synthesized for a pod with NO local podspec (SocketRocket, a trunk + # pod). Version comes from Helpers::Constants::socket_rocket_config; no + # subspecs. A missing/blank constant is a hard error rather than a silent + # versionless facade — a bare `pod 'SocketRocket'` in the source path is + # `"~> #{socket_rocket_config[:version]}"`, so the facade MUST carry a version + # that satisfies that constraint. + def self.synthesized_spec(name, platforms) + version = synthesized_version(name) + base_spec(name, version, platforms) + end + private_class_method :synthesized_spec + + # Resolves the synthesized version for a no-podspec facade. Fail-closed on a + # missing constant/version. Only SocketRocket is synthesized today. + def self.synthesized_version(name) + case name + when "SocketRocket" + unless defined?(Helpers::Constants) && Helpers::Constants.respond_to?(:socket_rocket_config) + raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \ + "Helpers::Constants.socket_rocket_config is unavailable." + end + version = Helpers::Constants.socket_rocket_config[:version] + if version.nil? || version.to_s.strip.empty? + raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \ + "socket_rocket_config[:version] is missing or empty." + end + version.to_s + else + raise "[RNDepsFacades] No synthesized version rule for facaded pod '#{name}'. " \ + "Add one to synthesized_version or give it a real podspec in FACADE_PODS." + end + end + private_class_method :synthesized_version + + # Loads the real podspec so we can mirror its structure. A facaded pod with a + # declared podspec path MUST have a readable real podspec — if it's missing or + # unparseable we raise rather than ship an empty facade (which would silently + # drop subspecs / the version, the very drift this mechanism prevents). + def self.load_real_spec(path, name) + unless File.exist?(path) + raise "[RNDepsFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \ + "Update FACADE_PODS in rndeps_facades.rb if the podspec moved." + end + begin + Pod::Specification.from_file(path) + rescue => e + raise "[RNDepsFacades] Failed to read real podspec for facaded pod '#{name}' at #{path}: #{e.message}" + end + end + private_class_method :load_real_spec + + # Library (non-test, non-app) subspec names of the real spec, so third-party + # libs depending on `/` (e.g. `RCT-Folly/Fabric`) keep + # resolving. Derived, never hand-listed. + def self.derive_subspecs(real) + real.subspecs + .reject { |ss| ss.test_specification? || (ss.respond_to?(:app_specification?) && ss.app_specification?) } + .map(&:base_name) + end + private_class_method :derive_subspecs +end diff --git a/packages/react-native/scripts/cocoapods/spm.rb b/packages/react-native/scripts/cocoapods/spm.rb index 0995dfbfc972..f49230270d1f 100644 --- a/packages/react-native/scripts/cocoapods/spm.rb +++ b/packages/react-native/scripts/cocoapods/spm.rb @@ -19,28 +19,46 @@ def apply_on_post_install(installer) log 'Cleaning old SPM dependencies from Pods project' clean_spm_dependencies_from_target(project, @dependencies_by_pod) log 'Adding SPM dependencies to Pods project' + flattened_pod_names = [] @dependencies_by_pod.each do |pod_name, dependencies| + target = project.targets.find { |t| t.name == pod_name} dependencies.each do |spm_spec| log "Adding SPM dependency on product #{spm_spec[:products]}" add_spm_to_target( project, - project.targets.find { |t| t.name == pod_name}, + target, spm_spec[:url], spm_spec[:requirement], spm_spec[:products] ) - log " Adding workaround for Swift package not found issue" - target = project.targets.find { |t| t.name == pod_name} - target.build_configurations.each do |config| - target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'] ||= ['$(inherited)'] - search_path = '${SYMROOT}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/' - unless target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'].include?(search_path) - target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'].push(search_path) + if target.product_type == 'com.apple.product-type.library.static' + # Xcode emits a package C-target's module map both into the shared + # products dir and into this pod's CONFIGURATION_BUILD_DIR, and both + # copies are unavoidably visible (the package swiftmodule serializes + # the products-root path) — clang hard-errors with "redefinition of + # module" on Xcode 26.3. Building the pod straight into the products + # root makes both paths the same file, and puts the package + # swiftmodule on the pod's default search path. + log " Building #{pod_name} into the shared products dir to avoid duplicate module maps" + target.build_configurations.each do |config| + target.build_settings(config.name)['CONFIGURATION_BUILD_DIR'] = '${PODS_CONFIGURATION_BUILD_DIR}' + end + flattened_pod_names << pod_name unless flattened_pod_names.include?(pod_name) + else + log " Adding workaround for Swift package not found issue" + target.build_configurations.each do |config| + target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'] ||= ['$(inherited)'] + search_path = '${SYMROOT}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/' + unless target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'].include?(search_path) + target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'].push(search_path) + end end end end end + rewrite_aggregate_modulemap_references(installer, flattened_pod_names) unless flattened_pod_names.empty? + unless @dependencies_by_pod.empty? log_warning "If you're using Xcode 15 or earlier you might need to close and reopen the Xcode workspace" unless ENV["USE_FRAMEWORKS"] == "dynamic" @@ -53,6 +71,55 @@ def apply_on_post_install(installer) private + # Flattening a pod's build dir moves its generated modulemap from + # "/.modulemap" to ".modulemap"; the aggregate xcconfigs + # reference the old path via -fmodule-map-file. Must mutate the in-memory + # Config attributes — later post_install steps (NewArchitectureHelper) re-save + # the same Config objects and would clobber a plain file edit. + def rewrite_aggregate_modulemap_references(installer, pod_names) + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.xcconfigs.each do |config_name, config_file| + %w[OTHER_CFLAGS OTHER_SWIFT_FLAGS].each do |key| + value = config_file.attributes[key] + next unless value + + updated_value = pod_names.reduce(value) do |acc, pod_name| + acc.gsub( + "${PODS_CONFIGURATION_BUILD_DIR}/#{pod_name}/#{pod_name}.modulemap", + "${PODS_CONFIGURATION_BUILD_DIR}/#{pod_name}.modulemap" + ) + end + config_file.attributes[key] = updated_value if updated_value != value + end + + config_file.save_as(aggregate_target.xcconfig_path(config_name)) + end + end + end + + # Creates a new object in the project with a UUID guaranteed not to collide + # with any UUID already present in the project. + # + # `Pod::Project` overrides `generate_available_uuid_list` with a fast, + # counter-based scheme (`0`) that deliberately skips + # collision checks, on the assumption that the whole Pods project is generated + # in a single pass. That assumption does not hold here: we run in a + # `post_install` hook, and the generator's counter can be out of sync with the + # UUIDs already assigned to existing objects (e.g. when the project has been + # reloaded from disk during an incremental install, the counter restarts at 0 + # while the root object still occupies `00000000`). Using `project.new` + # directly can therefore hand back a UUID that is already in use and overwrite + # an existing object (notably the root `PBXProject`), producing a Pods project + # Xcode refuses to load. We keep the deterministic scheme but probe forward + # until we find a UUID that is actually free. + def new_object(project, klass) + uuid = project.generate_uuid + uuid = project.generate_uuid while project.objects_by_uuid.key?(uuid) + object = klass.new(project, uuid) + object.initialize_defaults + object + end + def log(msg) ::Pod::UI.puts "[SPM] #{msg}" end @@ -76,7 +143,7 @@ def add_spm_to_target(project, target, url, requirement, products) pkg_class = Xcodeproj::Project::Object::XCLocalSwiftPackageReference pkg = project.root_object.package_references.find { |p| p.class == pkg_class && p.relative_path == url } if !pkg - pkg = project.new(pkg_class) + pkg = new_object(project, pkg_class) pkg.relative_path = url log(" Adding local package to workspace: #{pkg.inspect}") project.root_object.package_references << pkg @@ -85,7 +152,7 @@ def add_spm_to_target(project, target, url, requirement, products) pkg_class = Xcodeproj::Project::Object::XCRemoteSwiftPackageReference pkg = project.root_object.package_references.find { |p| p.class == pkg_class && p.repositoryURL == url } if !pkg - pkg = project.new(pkg_class) + pkg = new_object(project, pkg_class) pkg.repositoryURL = url pkg.requirement = requirement log(" Adding remote package to workspace: #{pkg.inspect}") @@ -101,7 +168,7 @@ def add_spm_to_target(project, target, url, requirement, products) next if ref log(" Adding product dependency #{product_name} to #{target.name}") - ref = project.new(ref_class) + ref = new_object(project, ref_class) ref.package = pkg ref.product_name = product_name target.package_product_dependencies << ref diff --git a/packages/react-native/scripts/codegen/templates/Package.swift.spm-template b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template new file mode 100644 index 000000000000..97cdb78fd54f --- /dev/null +++ b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template @@ -0,0 +1,97 @@ +// swift-tools-version: 6.0 +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// AUTO-GENERATED by scripts/setup-apple-spm.js – do not edit manually. +// This SPM-specific template replaces the CocoaPods codegen template +// with xcframework configuration built in. + +import PackageDescription + +// React headers need NO search paths: the React module comes from the +// invariant ReactHeaders Clang target, +// every other RN namespace from the ReactNativeHeaders binaryTarget +// (pure-RN), the third-party deps namespaces (folly/glog/boost/...) from the +// ReactNativeDependenciesHeaders sidecar, and the per-app generated headers +// from the ReactAppHeaders target below — all auto-served by SPM through +// product/target dependencies. +let headersDep: [Target.Dependency] = [ + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + "ReactAppHeaders", +] + +let package = Package( + name: "React-GeneratedCode", + platforms: [.iOS(.v15), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)], + products: [ + .library( + name: "ReactCodegen", + targets: ["ReactCodegen"]), + .library( + name: "ReactAppDependencyProvider", + targets: ["ReactAppDependencyProvider"]), + .library( + name: "ReactAppHeaders", + targets: ["ReactAppHeaders"]), + ], + dependencies: [ + .package(name: "ReactNative", path: "../../xcframeworks"), + ], + targets: [ + .target( + name: "ReactCodegen", + dependencies: headersDep, + path: "ReactCodegen", + exclude: ["ReactCodegen.podspec"], + publicHeadersPath: ".", + cSettings: [.headerSearchPath("headers")], + // DEBUG/NDEBUG match the prebuilt React.framework's config-gated C++ + // ABI: NDEBUG (Release) strips DebugStringConvertible's vtable and + // shifts the ShadowNode layout that codegen'd Props inherit, so this + // target must compile with the same NDEBUG state or the Release link + // fails (undefined "vtable for DebugStringConvertible"). Mirrors + // packages/react-native/Package.swift. + cxxSettings: [ + .headerSearchPath("headers"), + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ], + linkerSettings: [ + .linkedFramework("Foundation") + ] + ), + .target( + name: "ReactAppDependencyProvider", + dependencies: ["ReactCodegen"] + headersDep, + path: "ReactAppDependencyProvider", + exclude: ["ReactAppDependencyProvider.podspec"], + publicHeadersPath: ".", + cSettings: [.headerSearchPath(".."), .headerSearchPath("headers")], + cxxSettings: [ + .headerSearchPath(".."), + .headerSearchPath("headers"), + // Match the prebuilt React.framework NDEBUG-gated C++ ABI (see above). + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ], + linkerSettings: [ + .linkedFramework("Foundation") + ] + ), + // The per-app generated-headers farm (built by spm sync), vended as a + // normal SPM headers target. + .target( + name: "ReactAppHeaders", + path: "ReactAppHeaders", + publicHeadersPath: "." + ), + ], + // React Native headers require C++20 (concepts, std::optional, etc.) + cxxLanguageStandard: .cxx20 +) diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 4d2786314714..c411934c1742 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -111,123 +111,48 @@ The build process uses specific `xcodebuild` flags: - Build times vary depending on the target platform and configuration - XCFrameworks support multiple architectures in a single bundle -## Known Issues - -The generated XCFrameworks currently use CocoaPods-style header structures -rather than standard framework header conventions. This may cause modularity -issues when: - -- Consuming the XCFrameworks in projects that expect standard framework headers -- Building dependent frameworks that rely on proper module boundaries -- Integrating with Swift Package Manager projects expecting modular headers - -## VFS Overlay System - -The prebuilt XCFrameworks use Clang's Virtual File System (VFS) overlay -mechanism to enable header imports without modifying the actual header file -structure. This is necessary because React Native's headers are organized -differently than standard framework conventions. - -### Overview - -The VFS overlay creates a virtual mapping between the import paths used in code -(e.g., `#import `) and the actual physical -locations of headers within the XCFramework. This allows the prebuilt frameworks -to work seamlessly while maintaining the original import syntax. - -### Build-Time VFS Generation (`vfs.js`) - -The `vfs.js` script creates a VFS overlay template during the prebuild process: - -1. **Header Collection** (`headers.js`): Scans all podspec files in the React - Native package to discover header files and their target import paths. - -2. **VFS Structure Building**: The `buildVFSStructure()` function creates a - hierarchical directory tree representation from the header mappings. Clang's - VFS overlay requires directories to contain their children in a tree - structure. - -3. **YAML Generation**: The `generateVFSOverlayYAML()` function converts the VFS - structure into Clang's expected YAML format. - -4. **Template Creation**: The generated overlay uses `${ROOT_PATH}` as a - placeholder for the actual installation path. This template is included in - the XCFramework as `React-VFS-template.yaml`. - -#### Key Functions - -- `createVFSOverlay(rootFolder)`: Main entry point that generates the complete - VFS overlay YAML string -- `createVFSOverlayContents(rootFolder)`: Creates the VFS overlay object - structure -- `buildVFSStructure(mappings)`: Builds the hierarchical directory tree from - flat mappings -- `resolveVFSOverlay(vfsTemplate, rootPath)`: Replaces `${ROOT_PATH}` with the - actual path - -### Runtime VFS Processing (CocoaPods) - -When consuming prebuilt frameworks via CocoaPods, the VFS overlay is processed -at pod install time by `rncore.rb`: - -#### `process_vfs_overlay()` - -Called during `react_native_post_install`, this method: - -1. Reads the `React-VFS-template.yaml` from the XCFramework -2. Resolves the `${ROOT_PATH}` placeholder with the actual XCFramework path -3. Writes the resolved overlay to - `$(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml` - -#### `add_rncore_dependency(s)` - -Adds VFS overlay compiler flags to podspecs that depend on React Native: - -```ruby -# For C/C++ compilation -OTHER_CFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -OTHER_CPLUSPLUSFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - -# For Swift compilation (flags passed to underlying Clang) -OTHER_SWIFT_FLAGS += "-Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -``` - -#### `configure_aggregate_xcconfig(installer)` - -Configures VFS overlay flags for: - -- **Aggregate targets**: Main app targets that don't go through podspec - processing -- **All pod targets**: Third-party pods that don't explicitly call - `add_rncore_dependency` - -This ensures all compilation units in the project can resolve React Native -headers through the VFS overlay. - -### VFS Overlay Format - -The VFS overlay uses Clang's hierarchical YAML format: - -```yaml -version: 0 -case-sensitive: false -roots: - - name: '${ROOT_PATH}/Headers' - type: 'directory' - contents: - - name: 'react' - type: 'directory' - contents: - - name: 'renderer' - type: 'directory' - contents: - - name: 'Size.h' - type: 'file' - external-contents: '${ROOT_PATH}/Headers/React/react/renderer/Size.h' -``` - -The structure maps virtual paths (what the compiler sees) to physical paths -(where the files actually exist in the XCFramework). +## Header Resolution (headers-spec layout) + +The prebuilt XCFrameworks ship a **headers-spec layout** so that header imports +resolve through plain header/framework search paths — there is **no clang VFS +overlay**. The layout contract is defined and validated in code: + +- `headers-spec.js`: the executable layout contract (rules R1–R8) — which + namespaces are hoisted, which carry module maps, and how collisions are + rejected. +- `headers-inventory.js`: scans the source tree to build the live header + inventory that feeds the spec. +- `headers-compose.js`: emits the layout. `emitReactFrameworkHeaders()` writes + the `React/` and bare-aliased headers into every slice's + `React.framework/Headers`, and `buildReactNativeHeadersXcframework()` + assembles the headers-only `ReactNativeHeaders.xcframework` carrying every + other namespace (incl. `react/`) plus the third-party dependency namespaces + (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, `fast_float`). The + Hermes public headers (``) are folded in only on the SwiftPM + consumer side (`ensureHeadersLayout`); the published prebuild artifact does + not yet carry them (TODO in `xcframework.js`). + +### Artifacts + +The prebuild (`xcframework.js`) always produces: + +- `React.xcframework` — the compiled React core. Each slice's `React.framework` + carries the headers-spec layout (every `` header + the framework + module map), which is what both CocoaPods and SwiftPM consume. +- `ReactNativeHeaders.xcframework` — headers-only; carries every other + namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the + `React-Core-prebuilt` pod (headers flattened onto the header search path). + +### CocoaPods consumption + +The `React-Core-prebuilt` pod vends `React.xcframework` (so `` and +`@import React;` resolve through the framework module via +`FRAMEWORK_SEARCH_PATHS`) and flattens `ReactNativeHeaders.xcframework`'s +headers into a top-level `Headers/` exposed on the pod header search path (so +``, ``, `` resolve). `rncore.rb` adds the +`HEADER_SEARCH_PATHS` entry to `React-Core-prebuilt/Headers` for podspec, +aggregate (main app), and third-party pod targets. No `-ivfsoverlay` flags are +added. ## Integrating in your project with Cocoapods diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md new file mode 100644 index 000000000000..3c4de65e246a --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md @@ -0,0 +1,388 @@ +# How the prebuilt header layout is created — rules and rationale + +This documents the header-generation system introduced by the VFS-overlay +removal PR (#57285): how every header that ships in the prebuilt artifacts is +discovered, classified, placed, and made modular. The system lives in three +scripts under `scripts/ios-prebuild/`: + +| Script | Role | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `headers-inventory.js` | **Discover + classify** every shipped header (the facts) | +| `headers-spec.js` | **The rules** (R1–R11) — turns the inventory into a layout plan + module maps | +| `headers-compose.js` | **Emit** — projects the plan into `React.xcframework` and `ReactNativeHeaders.xcframework` | +| `headers-verify.js` | **Gate** — generator-time verification: include-health ratchet, structural byte-compare, consumer-shaped compile smokes (runs in the prebuild CI compose job) | + +One principle drives the whole design: **content authority = the source files, +layout authority = the spec.** No header is ever edited; only _where it goes_ +and _which module owns it_ is computed. + +## Why this exists (what replaced the VFS overlay) + +Previously a clang VFS overlay synthesized a virtual header tree at build time +so includes like ``, ``, `` +would resolve against the prebuilt artifact. That approach broke down under +clang **explicit modules** (the overlay shadowed the framework's own module), +made code-signature stability awkward, and required consumer-side machinery. + +The replacement is purely physical: two artifacts whose on-disk layout makes +every include form resolve through standard mechanisms — +`FRAMEWORK_SEARCH_PATHS` for `` and a plain header search path for +everything else. No overlay, no include rewriting, no consumer flags. + +## The pipeline + +```text +podspecs ──► headers-inventory.js ──► inventory (facts per header) + │ + ▼ + headers-spec.js (rules R1–R10) + │ plan: what goes where + module maps + ▼ + headers-compose.js (emission) + │ │ + ▼ ▼ + React.xcframework ReactNativeHeaders.xcframework + (binary + React/ ns) (headers-only, all other ns) +``` + +## Stage 1 — Inventory: discover and classify (headers-inventory.js) + +### Discovery + +Headers are enumerated through the **same podspec-driven discovery the prebuild +itself uses** (`headers.js`), so the inventory cannot drift from the shipped +set. Each header gets: + +- a **natural path** — the include path consumers actually write + (`React/RCTBridge.h`, `react/renderer/core/ShadowNode.h`, `yoga/Yoga.h`). This + is the canonical identity; the podspec `header_dir` supplies the namespace, + and header_dir-less pods get their pod name as prefix. +- one or more **identities** — the physical source file plus the pod-namespaced + path. `React_RCTAppDelegate` headers get a **second, synthetic bare-root + identity** (`RCTAppDelegate.h` with no prefix) because app templates + historically wrote `#import `. + +### Language classification + +`scanHeader()` does a guard-aware scan of each header's text: + +- Tracks a `#if/#ifdef __cplusplus` **stack**, so C++ constructs and includes + that only exist under a C++ guard don't taint the ObjC surface + (`cxxGuarded: true` edges). +- ObjC markers: `@interface/@protocol/@implementation/@class/@end`, + `NS_ASSUME_NONNULL_BEGIN`. +- C++ markers: `namespace`, `template <`, `extern "C++"`, `enum class`, + `constexpr`, `using namespace/alias` — plus one non-obvious heuristic: a **C++ + default member initializer inside a struct/class aggregate** + (`struct X { CGFloat size = NAN; };`), which is illegal in C/ObjC but has no + keyword the line scan would catch. + +Result: `lang ∈ {objc, objcxx, cxx, c}`. + +### Include classification + +Every `#include/#import` is resolved and classified: `internal` (another shipped +header — quoted includes are resolved against the source dir and mapped back to +a natural path), `thirdParty` (folly/glog/boost/fmt/ +double-conversion/fast_float), `hermes`, `system` (Apple SDK), `std`, +`metaInternal` (FB-internal, never resolvable in OSS), `otherPlatform` +(android/jni), `notShipped` (our namespace but not in the shipped set — a +flattening mismatch), `unresolved`. + +### The bucket — "can this header live in a clang module?" + +The key output. A **fixpoint over unguarded include edges** propagates two facts +through the internal include graph: _does this header transitively reach C++?_ +and _which third-party libs does it reach?_ — considering only edges an ObjC +consumer would actually follow (cxx-guarded edges are skipped). + +| Bucket | Meaning | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `objc-modular-candidate` | Pure ObjC surface; reaches no C++ and no third-party lib unguarded → **can** be a module member | +| `objc-blocked` | ObjC header, but transitively reaches C++/third-party (e.g. Fabric headers importing ``) → cannot be a clean module member | +| `objcxx` | Mixed ObjC++ in the header itself | +| `cxx` | Pure C++ (also anything `.hpp`) | + +Only `objc-modular-candidate` headers can enter module maps; everything else +stays **textual** (resolved by search path at the consumer's use site — exactly +the semantics the old VFS overlay provided). + +## Stage 2 — The rules (headers-spec.js) + +The spec's docblock is the contract; each rule exists because of a concrete +failure mode: + +**R1 — `React.framework/Headers` root = the `React/` namespace, hoisted.** The +framework _name_ supplies the `React/` prefix, so `#import ` +resolves verbatim through `FRAMEWORK_SEARCH_PATHS`. Bare root aliases (R6) also +live here. The lowercase `react/` namespace is deliberately **not** here: +resolving `` through `React.framework` requires case-folding +`react.framework` → `React.framework`, which only works on case-insensitive +filesystems. The header-search-path route (R2) is exact everywhere. + +**R2 — every other namespace ships in ONE headers-only xcframework, +`ReactNativeHeaders`.** Namespace dirs at its `Headers/` root: `react/`, +`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … **including the +third-party deps namespaces** (folly/glog/boost/fmt/double-conversion/ +fast_float, copied out of the ReactNativeDependencies artifact — which thereby +becomes binary-only). SPM/Xcode auto-serve a binaryTarget's `Headers/` on the +consumer's search path, so everything resolves with zero flags. + +**R3 — NO include rewriting, anywhere.** Shipped headers are byte-identical to +the repo. If a header's includes don't work in the packaged layout, the fix is +in the _source header_ or the _rules_ — never a packaging-time patch. This is +what keeps source builds and prebuilt builds semantically identical. + +**R4 — the React framework module map is an umbrella over the _safe_ ObjC +surface.** A header enters the umbrella iff `isUmbrellaSafe`: + +```text +bucket == objc-modular-candidate +∧ React/-namespace +∧ no '+' in the filename (category headers, e.g. +Private — see R9) +∧ no C extern-inline definition +``` + +The extern-inline exclusion is empirical: a C99 `extern inline` definition emits +a **strong symbol in every importing translation unit** → duplicate symbols at +link (found via `RCTTextInputNativeCommands.h`). In practice this yields ~225 +umbrella headers out of ~317 in the framework. + +**R5 — every ReactNativeHeaders namespace with modular candidates gets a plain +(non-framework) module** declaring exactly those candidates. Why: a framework +module (React) may not _textually_ include non-modular headers under +`-Wnon-modular-include-in-framework-module` — so when a React.framework header +imports ``, yoga's header must itself belong to a module (found +empirically via yoga + RCTDeprecation). Details: + +- Module _names_ are internal to clang's module graph — consumers never + `@import yoga`; they `#import ` and clang maps the header to its + owning module. +- The `react` namespace's module is renamed `ReactNativeHeaders_react` so it can + never alias the `React` framework module on a case-insensitive filesystem. + (Header paths are unchanged; only the module name differs.) +- Namespaces whose name isn't a valid module identifier (`jsinspector-modern`, + `double-conversion`) are exempt — they currently have no modular candidates; + the verifier asserts that stays true. + +**R6 — bare includes migrate to ``.** Bare root aliases +(`RCTAppDelegate.h` etc.) are physically placed at the framework Headers root, +so `` works; the bare angle form +(`#import `) has no framework spelling and is the one +accepted, measured ecosystem migration (~4 lines total). + +**R7 — sign AFTER composing.** The code signature pins the header manifest; +composing after signing would invalidate it. + +**R8 — collisions are hard errors.** Two different source files may never +project to the same destination path. `computeSpecPlan` throws; the artifact is +not produced. + +**R9 — private React headers, exposed in the default `React` module via a +curated allowlist.** Privileged framework consumers (Expo) import headers the +public umbrella excludes. Rather than a `React.Private` submodule (which would +force a Swift `import React.Private` in consumers) they are appended to the +module map, split by bucket: + +```text +framework module React { + umbrella header "React-umbrella.h" + header "RCTBridge+Private.h" // objc-modular-candidate → real member + textual header "RCTComponentViewFactory.h" // objc-blocked → textual only + textual header "RCTMountingManager.h" // (…6 Fabric headers total) + ... + export * + module * { export * } +} +``` + +An `objc-blocked` header **must** be `textual` — a real member would re-trip the +non-modular-include error that got it excluded from the umbrella in the first +place. Textual works because its C++ includes resolve at the consumer's use +site. "Private" is by convention (naming), not enforcement: a single binary +artifact cannot hard-gate an app from headers a framework legitimately needs. +Validation **fails closed**: an allowlisted header missing from the inventory, +or a `modular` entry whose bucket drifted, aborts the build with a targeted +message. + +**R10 — per-namespace umbrella headers, derived, for namespaces consumers +probe.** Expo's `RCTAppDelegateUmbrella.h` does +`__has_include()` — a +CocoaPods-era artifact filename. The flattened layout ships the individual +headers but no umbrella, so the probe silently failed. The fix emits +`/-umbrella.h` for each namespace in `UMBRELLA_NAMESPACES` (currently +just `React_RCTAppDelegate`), with content **derived from the namespace's +modular header set** — never hand-listed, so it can't drift (it correctly omits +e.g. the since-removed `RCTArchConfiguratorProtocol.h`). The umbrella is also +added to that namespace's R5 module so importing it stays modular. Fails closed +if the namespace loses all modular headers. + +**R11 — one source file, one content location (redirect shims).** Some sources +ship under several spellings: `React/X.h` plus a legacy pod-namespace form +(`CoreModules/X.h`, `RCTAnimation/X.h`, `RCTImage/X.h`, …), or a bare root alias +plus `React_RCTAppDelegate/X.h` — 116 sources at the time of writing. Under the +VFS overlay every spelling mapped to _one physical file_, so `#import`-once and +module ownership were coherent. A flattened layout that _copies_ the content +breaks both: any `-fmodules` consumer touching two spellings — even +transitively, e.g. importing legacy `` whose header pulls a +modular `` — hits **redefinition errors** (found by the headers gate +on its first run). The rule: the **module-owned spelling keeps the content** +(the `React/` form when it exists — umbrella/module-React owner or canonical +textual home; else the R5-module namespaced form), and every other spelling is +emitted as a one-line redirect shim (`#import `). Shims that are +namespace-module members are fine: they import the owning module, so +declarations stay single-owned. + +## Stage 3 — Emission (headers-compose.js) + +`computeSpecPlan(rnRoot)` = inventory → plan, throwing on R8 collisions. Then: + +### `emitReactFrameworkHeaders` (per slice of React.xcframework) + +1. Stage all R1 entries (byte-copies of the source files) + the generated + `React-umbrella.h`. +2. For each slice (`ios-arm64`, `ios-arm64_x86_64-simulator`, …): replace + `React.framework/Headers` with the stage, replace `Modules/` with the + generated module map (R4 + R9). +3. (Stacked PR #57305 adds resource embedding here — `PrivacyInfo.xcprivacy` and + `RCTI18nStrings.bundle` per slice — orthogonal to headers.) + +### `buildReactNativeHeadersXcframework` + +1. Stage all R2 entries, then copy the six deps namespaces from + `third-party/ReactNativeDependencies.xcframework/Headers`. A declared deps + namespace that is missing is a **hard error** — previously a warn-and-ship, + which once produced a silently deps-less artifact (1.6 MB instead of 11 MB). +2. Optionally fold in the `hermes/` public headers (consumer-side compose path). +3. Write the R10 umbrella files, then the R5 module map. +4. Compile a stub static archive per slice (headers-only artifacts still need a + library for `xcodebuild -create-xcframework`) and compose the xcframework. + +### `ensureHeadersLayout` (consumer-side) + +Applies the same emission to an already-downloaded cache slot (so any consumer +with a cached `React.xcframework` gets composed artifacts without a published +`ReactNativeHeaders`). Idempotent via a freshness marker (source realpath + +mtime + hermes presence). + +## How consumers resolve headers (why the rules are shaped this way) + +| Include form | Resolved by | Rule | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ------ | +| `#import ` | `React.framework/Headers` via FRAMEWORK_SEARCH_PATHS; modular via the umbrella | R1, R4 | +| `#import ` | same, modular via the R9 `header` entry | R9 | +| `#import ` | same, **textual** via the R9 entry | R9 | +| `#import ` (C++) | `ReactNativeHeaders/Headers` search path, textual | R2 | +| `#import ` | same, **modular** via the yoga R5 module | R2, R5 | +| `#import ` | same (deps namespaces relocated here) | R2 | +| `` | same, modular | R10 | +| `#import "RCTFabricComponentsPlugins.h"` (quoted, community Fabric pods) | CocoaPods only: re-vended by the `React-RCTFabric` facade into the pod header map (`rncore_facades.rb`, `FACADE_REEXPOSED_HEADERS`) | — | + +- **SwiftPM**: both xcframeworks are plain `.binaryTarget`s; Xcode auto-serves + `React.framework`'s `Headers/`+`Modules/` and `ReactNativeHeaders`' `Headers/` + (incl. its `module.modulemap`) to dependents. Zero flags. +- **CocoaPods**: `React-Core-prebuilt`'s `prepare_command` flattens + `ReactNativeHeaders`' Headers (incl. the module map) into the pod, and the + React-core pods are installed as dependency-only **facades** + (`rncore_facades.rb`) so no source headers shadow the artifact. + +## Fail-closed invariants (the "can't silently regress" list) + +| Guard | Trips when | Where | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| R8 collision check | two sources project to one destination | `computeSpecPlan` | +| R9 allowlist validation | private header removed/renamed, or bucket drifted | `validatePrivateReactHeaders` | +| R10 umbrella check | a probed namespace loses all modular headers | `planFromInventory` | +| R5 exemption assert | an invalid-module-identifier namespace gains a modular-candidate header | `planFromInventory` | +| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildReactNativeHeadersXcframework` | +| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildReactNativeHeadersXcframework` | +| Include-health ratchet | a shipped header gains a `notShipped`/`unresolved`/quoted-unresolvable include not in the committed baseline | `headers-verify.js` | +| Structural gate | composed module maps/umbrellas differ from the spec render; R9 headers or deps dirs absent | `headers-verify.js` | +| Compile gates | the React module, any R5 namespace module, the R10 umbrella, the R9 textual (Expo-shape) surface, or Swift `RCTBridge.moduleRegistry` fails to compile | `headers-verify.js` (CI: prebuild compose job) | +| Facade re-vend glob | `RCTFabricComponentsPlugins.h` glob matches nothing | `rncore_facades.rb` | + +`DEPS_NAMESPACES` (headers-spec.js) is the single source of truth for +third-party namespaces — the inventory's include classifier derives from it, and +compose enforces set-equality with the deps artifact in both directions. + +The unit tests (`__tests__/headers-spec-test.js`) exercise R9/R10/R11, the R5 +exemption assert, the ratchet diff, and the gate fixtures red/green. + +## Resilience against new headers (drift analysis) + +The system is **derived-by-construction for the common case** — a new ordinary +header requires zero maintenance — but its consumer-facing contracts are +**allowlist-maintained** and only fail downstream. Grouped by _when_ a change +surfaces (daily CI = prebuild compose + rn-tester prebuilt consumer lanes): + +### A. Auto-adapts — nothing to do + +- **New header in an existing pod**: podspec-glob discovery → inventory → placed + by R1/R2; joins the umbrella (R4) or its namespace module (R5) automatically + iff `isUmbrellaSafe`. +- **New pod/namespace** (valid module identifier): gets an R5 module + automatically. +- **New header in `React_RCTAppDelegate`**: joins the derived R10 umbrella. +- **New `+Category` / extern-inline / objc-blocked header**: auto-excluded from + the modular surface — the _safe_ default for app consumers. + +### B. Fails loudly at compose time (the fail-closed guards) + +R8 collisions, R9 allowlist drift (rename/removal, modular→blocked), R10 +total-loss, missing deps namespaces, facade re-vend glob. These protect the +spec's _own_ invariants and cannot silently regress. + +### C. Fails loudly at the GENERATOR — the headers gate (`headers-verify.js`) + +These classes previously failed late (consumer CI lane) or not at all; the gate +— run in the prebuild compose job — moved them to compose time: + +- **A new umbrella member that doesn't compile standalone** (its include chain + hits a `notShipped` header, or the language scanner misbucketed a C++-only + header as C): the gate's ObjC TU precompiles the `React` module — compiling + _every_ umbrella header — at generator time. +- **A broken R5 namespace module**: the gate imports one header from every + namespace module, precompiling each (previously lazy — a module rn-tester + never imported was latent). +- **A public header gaining a quoted `#import "Private.h"` of a non-shipped + header**: recorded by the inventory (`quotedNotShipped`) and caught by the + include-health ratchet against `headers-include-baseline.json` — no compile + coverage needed. +- **The privileged-consumer (Expo) contract**: the gate compiles an Expo-shaped + ObjC++ TU (every R9 textual Fabric header) and a Swift TU (`import React` + + `RCTBridge.moduleRegistry`), plus `__has_include` asserts for the R9/R10 + surfaces — a tested invariant instead of a downstream discovery. + +Proof the gate earns its keep — its FIRST run found two real shipping defects: +the dual-identity redefinitions that became R11, and an undeclared +`SocketRocket` namespace in the deps artifact (caught by the set-equality guard +the moment it was added). SocketRocket now lives in `DEPS_NAMESPACES` alongside +the other third-party deps namespaces, with a single physical home in the +ReactNativeDependenciesHeaders sidecar: relocating a second textual copy into +ReactNativeHeaders collided with the real pod's own headers under +`use_frameworks` (the duplicate-`@interface` / poisoned-module-graph Expo +regression, 2026-07-03), so the set-equality gate asserts the declared namespace +set — `DEPS_NAMESPACES` — matches the deps artifact's namespaces exactly, in +both directions. + +### D. Remaining silent gaps — allowlist maintenance (by design) + +The contracts describing what _external_ consumers need are still curated lists; +a new consumer need shows up downstream first, then becomes a one-line allowlist +addition (now protected by the gate once added): + +1. **New privileged-consumer headers**: a new `+Private.h` or Fabric-class + objc-blocked header an Expo-class consumer starts importing — + `PRIVATE_REACT_HEADERS` is manual by design. +2. **New umbrella-probe namespaces**: a consumer probing `` + for a namespace not in `UMBRELLA_NAMESPACES`. +3. **Partial R10 shrink**: a `React_RCTAppDelegate` header flipping + modular→blocked drops out of the derived umbrella (fail-closed triggers only + on _total_ loss). Mitigated: the Expo probe surface is compile-tested, so a + shrink that breaks the umbrella itself is caught. + +### Remaining recommendation (not yet implemented) + +- **Artifact snapshot metrics** (per-namespace header counts, size bounds) as a + coarse tripwire for large silent drops beyond what the structural gate + byte-compares. diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js new file mode 100644 index 000000000000..885bcb9dc08d --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js @@ -0,0 +1,396 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + buildI18nStringsBundle, + buildReactPrivacyManifest, + collectLprojDirs, + collectReactPrivacyManifestPaths, + i18nBundleInfoPlist, + mergePrivacyManifests, + readPrivacyManifest, + serializePrivacyManifest, +} = require('../framework-resources'); +const {emitReactFrameworkHeaders} = require('../headers-compose'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// react-native package root (…/scripts/ios-prebuild/__tests__ -> …) +const RN_PATH = path.resolve(__dirname, '..', '..', '..'); + +// Apple privacy manifest fixtures mirroring the real ones shipped by the pods +// baked into React.framework. +const reactCore = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + ], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, +}; + +const cxxreact = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + ], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, +}; + +describe('mergePrivacyManifests', () => { + it('returns a valid empty manifest for no inputs', () => { + expect(mergePrivacyManifests([])).toEqual({ + NSPrivacyAccessedAPITypes: [], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, + }); + }); + + it('passes a single manifest through unchanged (by value)', () => { + expect(mergePrivacyManifests([reactCore])).toEqual(reactCore); + }); + + it('unions accessed-API categories, deduping reasons per category', () => { + const merged = mergePrivacyManifests([reactCore, cxxreact]); + const byType = Object.fromEntries( + merged.NSPrivacyAccessedAPITypes.map(e => [ + e.NSPrivacyAccessedAPIType, + e.NSPrivacyAccessedAPITypeReasons, + ]), + ); + // FileTimestamp appears in both -> single entry, reason deduped. + expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(2); + expect(byType.NSPrivacyAccessedAPICategoryFileTimestamp).toEqual([ + 'C617.1', + ]); + expect(byType.NSPrivacyAccessedAPICategoryUserDefaults).toEqual(['CA92.1']); + }); + + it('unions reasons across manifests for the same category', () => { + const a = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + ], + }; + const b = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['1C8F.1'], + }, + ], + }; + const merged = mergePrivacyManifests([a, b]); + expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(1); + expect( + merged.NSPrivacyAccessedAPITypes[0].NSPrivacyAccessedAPITypeReasons.sort(), + ).toEqual(['1C8F.1', 'CA92.1']); + }); + + it('ORs NSPrivacyTracking and unions tracking domains', () => { + const a = {NSPrivacyTracking: false, NSPrivacyTrackingDomains: ['a.com']}; + const b = { + NSPrivacyTracking: true, + NSPrivacyTrackingDomains: ['a.com', 'b.com'], + }; + const merged = mergePrivacyManifests([a, b]); + expect(merged.NSPrivacyTracking).toBe(true); + expect(merged.NSPrivacyTrackingDomains.sort()).toEqual(['a.com', 'b.com']); + }); + + it('unions collected data types, deduping structurally-equal entries', () => { + const entry = { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData', + NSPrivacyCollectedDataTypeLinked: false, + }; + const merged = mergePrivacyManifests([ + {NSPrivacyCollectedDataTypes: [entry]}, + {NSPrivacyCollectedDataTypes: [{...entry}]}, + ]); + expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1); + }); + + it('dedups collected data types regardless of source key order', () => { + // Same dict, different key order (order comes from each plist) must dedup. + const merged = mergePrivacyManifests([ + { + NSPrivacyCollectedDataTypes: [ + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData', + NSPrivacyCollectedDataTypeLinked: false, + }, + ], + }, + { + NSPrivacyCollectedDataTypes: [ + { + NSPrivacyCollectedDataTypeLinked: false, + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData', + }, + ], + }, + ]); + expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1); + }); + + it('does not fabricate a reasons key when the source omitted it', () => { + const merged = mergePrivacyManifests([ + { + NSPrivacyAccessedAPITypes: [ + {NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryOther'}, + ], + }, + ]); + const entry = merged.NSPrivacyAccessedAPITypes[0]; + expect(entry.NSPrivacyAccessedAPIType).toBe( + 'NSPrivacyAccessedAPICategoryOther', + ); + expect('NSPrivacyAccessedAPITypeReasons' in entry).toBe(false); + }); +}); + +describe('serialize/read privacy-manifest round-trip', () => { + it('serialize -> readPrivacyManifest yields the same object (guards the plist.build byte shape)', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-rt-')); + const file = path.join(tmp, 'PrivacyInfo.xcprivacy'); + try { + fs.writeFileSync(file, serializePrivacyManifest(reactCore)); + expect(readPrivacyManifest(file)).toEqual(reactCore); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } + }); +}); + +describe('collectReactPrivacyManifestPaths drift gate', () => { + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-')); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + function writeManifest(rel) { + const file = path.join(tmp, rel); + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, serializePrivacyManifest(reactCore)); + return file; + } + + it('throws when a manifest under the privacy roots is not allowlisted (a new pod must be a conscious decision)', () => { + writeManifest('React/Resources/PrivacyInfo.xcprivacy'); + writeManifest('Libraries/SomeNewPod/PrivacyInfo.xcprivacy'); + expect(() => collectReactPrivacyManifestPaths(tmp)).toThrow(/SomeNewPod/); + expect(() => collectReactPrivacyManifestPaths(tmp)).toThrow( + /REACT_PRIVACY_MANIFESTS/, + ); + }); + + it('returns the found subset of allowlisted manifests (partial trees stay valid)', () => { + const file = writeManifest('React/Resources/PrivacyInfo.xcprivacy'); + expect(collectReactPrivacyManifestPaths(tmp)).toEqual([file]); + }); + + it('returns [] for a tree with no manifests', () => { + expect(collectReactPrivacyManifestPaths(tmp)).toEqual([]); + }); +}); + +describe('buildReactPrivacyManifest (against the real source tree)', () => { + it('discovers React-core PrivacyInfo.xcprivacy files (not third-party deps)', () => { + const paths = collectReactPrivacyManifestPaths(RN_PATH); + expect(paths.length).toBeGreaterThan(0); + // third-party-podspecs manifests belong to ReactNativeDependencies, not React.framework + expect(paths.some(p => p.includes('third-party-podspecs'))).toBe(false); + // React-Core's manifest is the canonical one that must be present + expect( + paths.some(p => p.endsWith('React/Resources/PrivacyInfo.xcprivacy')), + ).toBe(true); + }); + + it('merges them into one manifest covering the known React-core API usages', () => { + const merged = buildReactPrivacyManifest(RN_PATH); + expect(merged).not.toBeNull(); + const categories = (merged?.NSPrivacyAccessedAPITypes ?? []).map( + e => e.NSPrivacyAccessedAPIType, + ); + // FileTimestamp + UserDefaults are declared by React-Core; both must survive the merge. + expect(categories).toContain('NSPrivacyAccessedAPICategoryFileTimestamp'); + expect(categories).toContain('NSPrivacyAccessedAPICategoryUserDefaults'); + // No category should be duplicated after merging. + expect(new Set(categories).size).toBe(categories.length); + }); +}); + +describe('i18nBundleInfoPlist', () => { + it('is a valid resource-bundle Info.plist dict', () => { + const info = i18nBundleInfoPlist(); + expect(info.CFBundlePackageType).toBe('BNDL'); + expect(info.CFBundleName).toBe('RCTI18nStrings'); + expect(typeof info.CFBundleIdentifier).toBe('string'); + expect(info.CFBundleIdentifier.length).toBeGreaterThan(0); + expect(typeof info.CFBundleDevelopmentRegion).toBe('string'); + // Versioned so Apple validation tooling doesn't warn on a version-less bundle. + expect(info.CFBundleShortVersionString).toBeDefined(); + expect(info.CFBundleVersion).toBeDefined(); + }); +}); + +describe('collectLprojDirs (against the real source tree)', () => { + it('finds the React i18n .lproj locale dirs', () => { + const dirs = collectLprojDirs(RN_PATH); + expect(dirs.length).toBeGreaterThan(0); + expect(dirs.every(d => d.endsWith('.lproj'))).toBe(true); + // English is the canonical base locale and must be present. + expect(dirs.some(d => path.basename(d) === 'en.lproj')).toBe(true); + }); +}); + +describe('buildI18nStringsBundle', () => { + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-bundle-')); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('builds RCTI18nStrings.bundle with the .lproj dirs and an Info.plist', () => { + const out = path.join(tmp, 'RCTI18nStrings.bundle'); + const count = buildI18nStringsBundle(RN_PATH, out); + + expect(count).toBeGreaterThan(0); + expect(fs.existsSync(path.join(out, 'Info.plist'))).toBe(true); + expect(fs.existsSync(path.join(out, 'en.lproj'))).toBe(true); + // the copied locale carries its actual strings file(s) + expect(fs.readdirSync(path.join(out, 'en.lproj')).length).toBeGreaterThan( + 0, + ); + // count matches the number of .lproj dirs copied + const copied = fs.readdirSync(out).filter(e => e.endsWith('.lproj')); + expect(copied.length).toBe(count); + }); + + it('returns 0 and writes nothing when there are no .lproj dirs', () => { + const emptyRn = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-rn-')); + const out = path.join(tmp, 'RCTI18nStrings.bundle'); + const count = buildI18nStringsBundle(emptyRn, out); + expect(count).toBe(0); + expect(fs.existsSync(out)).toBe(false); + fs.rmSync(emptyRn, {recursive: true, force: true}); + }); +}); + +describe('emitReactFrameworkHeaders resource landing (integration)', () => { + let tmp; + let rnRoot; + let xcfw; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'emit-res-')); + + // Minimal fake source tree: one privacy manifest + one locale. + rnRoot = path.join(tmp, 'rn'); + const privacyDir = path.join(rnRoot, 'React', 'Resources'); + fs.mkdirSync(privacyDir, {recursive: true}); + fs.writeFileSync( + path.join(privacyDir, 'PrivacyInfo.xcprivacy'), + serializePrivacyManifest(reactCore), + ); + const lproj = path.join(rnRoot, 'React', 'I18n', 'strings', 'en.lproj'); + fs.mkdirSync(lproj, {recursive: true}); + fs.writeFileSync( + path.join(lproj, 'Localizable.strings'), + '"key" = "value";\n', + ); + + // Two-slice xcframework, each carrying an empty React.framework. + xcfw = path.join(tmp, 'React.xcframework'); + for (const slice of ['ios-arm64', 'ios-arm64_x86_64-simulator']) { + fs.mkdirSync(path.join(xcfw, slice, 'React.framework'), { + recursive: true, + }); + } + const macFramework = path.join( + xcfw, + 'macos-arm64_x86_64', + 'React.framework', + ); + fs.mkdirSync(path.join(macFramework, 'Versions', 'A', 'Resources'), { + recursive: true, + }); + fs.symlinkSync('A', path.join(macFramework, 'Versions', 'Current')); + fs.symlinkSync( + 'Versions/Current/Resources', + path.join(macFramework, 'Resources'), + ); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('lands PrivacyInfo.xcprivacy and RCTI18nStrings.bundle into EVERY slice', () => { + // Empty header plan — this test targets the non-header resources only. + emitReactFrameworkHeaders( + xcfw, + { + react: [], + umbrella: [], + privateReactHeaders: {modular: [], textual: []}, + }, + rnRoot, + ); + + for (const slice of ['ios-arm64', 'ios-arm64_x86_64-simulator']) { + const fwk = path.join(xcfw, slice, 'React.framework'); + expect(fs.existsSync(path.join(fwk, 'PrivacyInfo.xcprivacy'))).toBe(true); + const bundle = path.join(fwk, 'RCTI18nStrings.bundle'); + expect(fs.existsSync(path.join(bundle, 'Info.plist'))).toBe(true); + expect(fs.existsSync(path.join(bundle, 'en.lproj'))).toBe(true); + // The composed module map is present too (proves the slice was rewritten). + expect(fs.existsSync(path.join(fwk, 'Modules', 'module.modulemap'))).toBe( + true, + ); + } + const resources = path.join( + xcfw, + 'macos-arm64_x86_64', + 'React.framework', + 'Resources', + ); + expect(fs.existsSync(path.join(resources, 'PrivacyInfo.xcprivacy'))).toBe( + true, + ); + expect( + fs.existsSync(path.join(resources, 'RCTI18nStrings.bundle', 'en.lproj')), + ).toBe(true); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js new file mode 100644 index 000000000000..80ff6d3253b6 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const { + COMPOSE_TOOLING_FILES, + composeToolingHash, +} = require('../headers-compose'); +const fs = require('fs'); +const path = require('path'); + +describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', () => { + test('every local sibling require is covered by the hashed file list', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', 'headers-compose.js'), + 'utf8', + ); + const requireRe = /require\('\.\/([\w-]+)'\)/g; + const required = new Set(); + for (const match of source.matchAll(requireRe)) { + required.add(`${match[1]}.js`); + } + for (const name of required) { + expect(COMPOSE_TOOLING_FILES).toContain(name); + } + const listWithoutSelf = COMPOSE_TOOLING_FILES.filter( + name => name !== 'headers-compose.js', + ); + for (const name of listWithoutSelf) { + expect(required).toContain(name); + } + }); +}); + +describe('composeToolingHash', () => { + test('returns a 64-char hex sha256 digest', () => { + const hash = composeToolingHash(); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +test('header sidecar composition uses the binary macOS slice instead of iOS defaults', () => { + const emitter = require('../headers-xcframework'); + const os = require('os'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'macos-headers-compose-')); + const slices = [ + {name: 'macos', sdk: 'macosx', targets: ['arm64-apple-macosx11.0']}, + ]; + const recipe = jest + .spyOn(emitter, 'stubSlicesFromXcframework') + .mockReturnValue(slices); + const compose = jest + .spyOn(emitter, 'composeHeadersOnlyXcframework') + .mockReturnValue(path.join(root, 'ReactNativeHeaders.xcframework')); + // No compiler or xcodebuild is needed: this checks the platform handoff. + try { + const {buildReactNativeHeadersXcframework} = require('../headers-compose'); + buildReactNativeHeadersXcframework( + root, + { + reactNativeHeaders: [], + namespaceUmbrellas: [], + namespaceModules: {}, + }, + root, + false, + null, + '/binary/React.xcframework', + ); + expect(recipe).toHaveBeenCalledWith('/binary/React.xcframework'); + expect(compose).toHaveBeenCalledWith( + root, + 'ReactNativeHeaders', + expect.any(String), + slices, + ); + } finally { + recipe.mockRestore(); + compose.mockRestore(); + fs.rmSync(root, {recursive: true, force: true}); + } +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js new file mode 100644 index 000000000000..300a559e1f5b --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js @@ -0,0 +1,142 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {computeInventory, scanHeader} = require('../headers-inventory'); +const path = require('path'); + +test('fork inventory retains stable dispatcher paths and macOS headers without collisions', () => { + const inventory = computeInventory(path.resolve(__dirname, '../../..')); + expect(inventory.collisions).toEqual([]); + const byPath = new Map(inventory.headers.map(h => [h.naturalPath, h])); + const base = 'react/renderer/components/view/'; + for (const name of [ + 'HostPlatformTouch', + 'HostPlatformViewEventEmitter', + 'HostPlatformViewProps', + 'HostPlatformViewTraitsInitializer', + ]) { + expect(byPath.get(`${base}${name}.h`).identities[0].source).toBe( + `ReactCommon/${base}${name}.h`, + ); + expect(byPath.has(`${base}platform/macos/${base}${name}.h`)).toBe(true); + expect(byPath.has(`${base}platform/cxx/${base}${name}.h`)).toBe(true); + } + expect(byPath.has('React/RCTUIKit.h')).toBe(true); + expect(inventory.headers.flatMap(h => h.includes.unresolved)).not.toEqual( + expect.arrayContaining(['AppKit/AppKit.h']), + ); +}); + +describe('scanHeader include classification', () => { + test('an unguarded include is not cxx-guarded', () => { + const r = scanHeader('#import \n'); + expect(r.includes).toEqual([ + {token: 'React/RCTBridge.h', cxxGuarded: false}, + ]); + }); + + test('an include under #ifdef __cplusplus is cxx-guarded', () => { + const r = scanHeader( + '#ifdef __cplusplus\n#include \n#endif\n', + ); + expect(r.includes).toEqual([{token: 'folly/dynamic.h', cxxGuarded: true}]); + }); + + test('#else flips the __cplusplus guard', () => { + const src = [ + '#ifdef __cplusplus', + '#include ', + '#else', + '#include ', + '#endif', + '', + ].join('\n'); + expect(scanHeader(src).includes).toEqual([ + {token: 'cpp/only.h', cxxGuarded: true}, + {token: 'c/only.h', cxxGuarded: false}, + ]); + }); + + test('#elif __cplusplus enters a cxx-only region', () => { + const src = [ + '#if SOMETHING', + '#include ', + '#elif __cplusplus', + '#include ', + '#endif', + '', + ].join('\n'); + expect(scanHeader(src).includes).toEqual([ + {token: 'a.h', cxxGuarded: false}, + {token: 'b.h', cxxGuarded: true}, + ]); + }); +}); + +describe('scanHeader C++ / ObjC surface detection', () => { + test('an unguarded namespace is unguarded C++', () => { + const r = scanHeader('namespace facebook { struct X; }\n'); + expect(r.hasUnguardedCxx).toBe(true); + expect(r.hasGuardedCxx).toBe(false); + }); + + test('a namespace under __cplusplus is guarded, not unguarded', () => { + const r = scanHeader('#ifdef __cplusplus\nnamespace facebook {}\n#endif\n'); + expect(r.hasGuardedCxx).toBe(true); + expect(r.hasUnguardedCxx).toBe(false); + }); + + test('a named aggregate with a member initializer is ObjC++', () => { + const r = scanHeader('struct RCTFontProperties { CGFloat size = NAN; };\n'); + expect(r.hasUnguardedCxx).toBe(true); + }); + + test('an ANONYMOUS aggregate with a member initializer is ObjC++', () => { + // Regression: the tag name is optional, so a typedef'd anonymous struct + // carrying a C++ default member initializer is still detected as ObjC++. + const r = scanHeader('typedef struct { CGFloat x = NAN; } Foo;\n'); + expect(r.hasUnguardedCxx).toBe(true); + }); + + test('@interface marks the header as ObjC', () => { + const r = scanHeader('@interface RCTBridge : NSObject\n@end\n'); + expect(r.hasObjC).toBe(true); + }); +}); + +describe('scanHeader comment handling', () => { + test('a multi-line block comment mentioning C++ keywords does not trip the detector', () => { + const src = [ + '/*', + ' * namespace foo is documented here', + ' * template and constexpr too', + ' */', + '@interface RCTFoo', + '@end', + '', + ].join('\n'); + const r = scanHeader(src); + expect(r.hasUnguardedCxx).toBe(false); + expect(r.hasGuardedCxx).toBe(false); + expect(r.hasObjC).toBe(true); + }); + + test('an inline block comment does not trip the detector', () => { + expect(scanHeader('int x; /* namespace y */\n').hasUnguardedCxx).toBe( + false, + ); + }); + + test('a // line comment mentioning a keyword does not trip the detector', () => { + expect(scanHeader('// using namespace std;\n').hasUnguardedCxx).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js new file mode 100644 index 000000000000..f4ff450892dd --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js @@ -0,0 +1,323 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const { + DEPS_NAMESPACES, + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, +} = require('../headers-spec'); +const fs = require('fs'); + +/*:: +type TestInventoryManifest = { + headers: Array<{ + naturalPath: string, + bucket: string, + lang: string, + identities: Array<{source: string}>, + }>, +}; +*/ + +// isUmbrellaSafe reads each header's source to reject extern-inline defs. Stub +// it to empty so synthetic objc-modular-candidate headers count as umbrella-safe +// (and thus land in namespaceModules), making these tests deterministic. +const planFromInventoryForTest = (manifest /*: TestInventoryManifest */) => { + const readFileSync = jest.spyOn(fs, 'readFileSync').mockReturnValue(''); + try { + return planFromInventory(manifest); + } finally { + readFileSync.mockRestore(); + } +}; + +const entry = ( + naturalPath /*: string */, + bucket /*: string */, + source /*:: ?: string */, +) => ({ + naturalPath, + bucket, + lang: 'objc', + identities: [{source: source ?? `does/not/exist/${naturalPath}`}], +}); + +// A manifest satisfying both the R9 private-header allowlist and the R10 +// umbrella-namespace allowlist (React_RCTAppDelegate). +const validManifest = () => ({ + headers: [ + entry('React/RCTBridge+Private.h', 'objc-modular-candidate'), + entry('React/RCTComponentViewFactory.h', 'objc-blocked'), + entry('React/RCTComponentViewProtocol.h', 'objc-blocked'), + entry('React/RCTComponentViewRegistry.h', 'objc-blocked'), + entry('React/RCTMountingManager.h', 'objc-blocked'), + entry('React/RCTSurfacePresenter.h', 'objc-blocked'), + entry('React/RCTViewComponentView.h', 'objc-blocked'), + entry( + 'React_RCTAppDelegate/RCTReactNativeFactory.h', + 'objc-modular-candidate', + ), + entry( + 'React_RCTAppDelegate/RCTRootViewFactory.h', + 'objc-modular-candidate', + ), + entry('React_RCTAppDelegate/RCTAppDelegate.h', 'objc-modular-candidate'), + // Bucketed objc-modular-candidate because its only C++ include + // ( → react/timing/primitives.h) is behind + // a `#ifdef __cplusplus` guard; the inventory's umbrella-safety fixpoint + // follows unguarded edges only. It must still be kept OUT of the umbrella. + entry('React/RCTFrameTimingsObserver.h', 'objc-modular-candidate'), + ], +}); + +describe('renderReactModuleMap (R9 private headers)', () => { + test('appends modular allowlist as `header` and objc-blocked as `textual header`', () => { + const out = renderReactModuleMap({ + modular: ['RCTBridge+Private.h'], + textual: ['RCTMountingManager.h'], + }); + expect(out).toContain('umbrella header "React-umbrella.h"'); + expect(out).toContain(' header "RCTBridge+Private.h"'); + expect(out).toContain(' textual header "RCTMountingManager.h"'); + // A textual header must NOT also appear as a plain modular `header`. + expect(out).not.toMatch(/^\s*header "RCTMountingManager\.h"/m); + }); + + test('with no private headers renders just the umbrella (backwards compatible)', () => { + const out = renderReactModuleMap(); + expect(out).toContain('umbrella header "React-umbrella.h"'); + expect(out).not.toContain('textual header'); + }); +}); + +describe('planFromInventory R9 validation', () => { + test('passes for a valid allowlist and exposes privateReactHeaders', () => { + const plan = planFromInventoryForTest(validManifest()); + expect(plan.privateReactHeaders.modular).toContain('RCTBridge+Private.h'); + expect(plan.privateReactHeaders.textual).toContain('RCTMountingManager.h'); + }); + + test('throws when an allowlisted header is absent from the inventory', () => { + const m = validManifest(); + m.headers = m.headers.filter( + x => x.naturalPath !== 'React/RCTBridge+Private.h', + ); + expect(() => planFromInventoryForTest(m)).toThrow( + /RCTBridge\+Private\.h is absent/, + ); + }); + + test('throws when a modular allowlist header is no longer objc-modular-candidate', () => { + const m = validManifest(); + const h = m.headers.find( + x => x.naturalPath === 'React/RCTBridge+Private.h', + ); + if (h == null) { + throw new Error('fixture missing RCTBridge+Private.h'); + } + h.bucket = 'objc-blocked'; + expect(() => planFromInventoryForTest(m)).toThrow( + /not 'objc-modular-candidate'/, + ); + }); +}); + +describe('R10 per-namespace umbrella (React_RCTAppDelegate)', () => { + test('emits a derived umbrella for the namespace', () => { + const plan = planFromInventoryForTest(validManifest()); + const u = plan.namespaceUmbrellas.find( + x => x.relPath === 'React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h', + ); + expect(u).toBeDefined(); + if (u == null) { + return; + } + // Imports are relative to the namespace dir, derived from the live set. + expect(u.content).toContain('#import "RCTReactNativeFactory.h"'); + expect(u.content).toContain('#import "RCTRootViewFactory.h"'); + expect(u.content).toContain('#import "RCTAppDelegate.h"'); + expect(u.content).toContain('#ifdef __OBJC__'); + expect(u.content).toContain( + '#import \n#if TARGET_OS_OSX\n#import \n#else\n#import \n#endif', + ); + // No CocoaPods version boilerplate. + expect(u.content).not.toContain('FOUNDATION_EXPORT'); + }); + + test('module map lists the umbrella so the import stays modular', () => { + const plan = planFromInventoryForTest(validManifest()); + const mm = renderNamespaceModuleMap(plan.namespaceModules); + expect(mm).toContain('module React_RCTAppDelegate {'); + expect(mm).toContain( + 'header "React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h"', + ); + }); + + test('fails closed when the umbrella namespace lost its modular headers', () => { + const m = validManifest(); + m.headers = m.headers.filter( + x => !x.naturalPath.startsWith('React_RCTAppDelegate/'), + ); + expect(() => planFromInventoryForTest(m)).toThrow( + /umbrella namespace 'React_RCTAppDelegate'/, + ); + }); +}); + +describe('R4 umbrella excludes C++-guarded foreign-namespace headers', () => { + test('a guarded-C++ objc-modular-candidate header is kept out of the umbrella', () => { + const plan = planFromInventoryForTest(validManifest()); + // RCTFrameTimingsObserver.h is objc-modular-candidate for a pure-ObjC + // consumer, but reaches react/timing/primitives.h once the __cplusplus + // guard opens (Swift/C++-interop compiles the React module as ObjC++). + // primitives.h is owned by ReactNativeHeaders_react, so an umbrella entry + // would make one header live in two modules → C++ redefinition. + expect(plan.umbrella).not.toContain('React/RCTFrameTimingsObserver.h'); + }); + + test('fails closed when an excluded header is absent from the inventory', () => { + const m = validManifest(); + m.headers = m.headers.filter( + x => x.naturalPath !== 'React/RCTFrameTimingsObserver.h', + ); + expect(() => planFromInventoryForTest(m)).toThrow( + /RCTFrameTimingsObserver\.h is absent/, + ); + }); +}); + +describe('R5 invalid-identifier exemption assert (H5)', () => { + test('throws when an invalid-identifier namespace gains a modular candidate', () => { + const m = validManifest(); + m.headers.push(entry('bad-namespace/Foo.h', 'objc-modular-candidate')); + expect(() => planFromInventoryForTest(m)).toThrow( + /namespace 'bad-namespace' is not a valid module identifier/, + ); + }); + + test('invalid-identifier namespaces with only non-modular headers stay exempt', () => { + const m = validManifest(); + m.headers.push(entry('jsinspector-modern/Foo.h', 'objcxx')); + expect(() => planFromInventoryForTest(m)).not.toThrow(); + }); +}); + +describe('R11 redirect shims for dual-identity headers', () => { + test('RNH spelling of a source that also ships as React/ becomes a shim', () => { + const m = validManifest(); + m.headers.push( + entry('React/RCTClipboard.h', 'objc-modular-candidate', 'src/clip.h'), + entry( + 'CoreModules/RCTClipboard.h', + 'objc-modular-candidate', + 'src/clip.h', + ), + ); + const plan = planFromInventoryForTest(m); + const shim = plan.reactNativeHeaders.find( + e => e.naturalPath === 'CoreModules/RCTClipboard.h', + ); + expect(shim?.redirectTo).toBe('React/RCTClipboard.h'); + // The React/ owner keeps its content. + const owner = plan.react.find( + e => e.naturalPath === 'React/RCTClipboard.h', + ); + expect(owner?.redirectTo).toBeUndefined(); + // The shim stays a namespace-module member (imports the owning module). + expect(plan.namespaceModules.CoreModules).toContain( + 'CoreModules/RCTClipboard.h', + ); + }); + + test('bare root alias shims to its RNH namespaced owner', () => { + const m = validManifest(); + m.headers.push( + entry('RCTAppDelegate.h', 'objc-modular-candidate', 'src/appdelegate.h'), + ); + // Same source as the namespaced form. + const ns = m.headers.find( + x => x.naturalPath === 'React_RCTAppDelegate/RCTAppDelegate.h', + ); + if (ns == null) { + throw new Error('fixture missing namespaced RCTAppDelegate.h'); + } + ns.identities[0].source = 'src/appdelegate.h'; + const plan = planFromInventoryForTest(m); + const bare = plan.react.find(e => e.naturalPath === 'RCTAppDelegate.h'); + expect(bare?.redirectTo).toBe('React_RCTAppDelegate/RCTAppDelegate.h'); + const owner = plan.reactNativeHeaders.find( + e => e.naturalPath === 'React_RCTAppDelegate/RCTAppDelegate.h', + ); + expect(owner?.redirectTo).toBeUndefined(); + }); + + test('single-identity headers get no redirect', () => { + const plan = planFromInventoryForTest(validManifest()); + for (const e of [...plan.react, ...plan.reactNativeHeaders]) { + expect(e.redirectTo).toBeUndefined(); + } + }); +}); + +describe('DEPS_NAMESPACES (R2 — the deps sidecar namespace set)', () => { + test('includes SocketRocket: one physical home, in the sidecar', () => { + // Pre-sidecar, SocketRocket was excluded from relocation because a REAL + // pod vended it (the 2026-07-03 dual-copy regression). With the sidecar + // being the deps' single header home, it must be declared like every + // other deps namespace. + expect(DEPS_NAMESPACES).toContain('SocketRocket'); + }); + + test('plan.depsNamespaces mirrors the spec list', () => { + expect(planFromInventoryForTest(validManifest()).depsNamespaces).toEqual( + DEPS_NAMESPACES, + ); + }); +}); + +describe('headers-verify gate pieces', () => { + const { + diffAgainstBaseline, + renderObjcFixture, + renderPrivilegedFixture, + } = require('../headers-verify'); + + test('diffAgainstBaseline ratchets: new offenders fail, resolved reported', () => { + const {newOffenders, resolved} = diffAgainstBaseline( + ['a', 'c'], + ['a', 'b'], + ); + expect(newOffenders).toEqual(['c']); + expect(resolved).toEqual(['b']); + }); + + test('ObjC fixture asserts and imports the R9/R10 + module surfaces', () => { + const plan = planFromInventoryForTest(validManifest()); + const tu = renderObjcFixture(plan); + expect(tu).toContain('__has_include()'); + expect(tu).toContain( + '__has_include()', + ); + expect(tu).toContain('#import '); + expect(tu).toContain('#import '); + // One import per namespace module (fixture has React_RCTAppDelegate). + expect(tu).toMatch(/#import { + const plan = planFromInventoryForTest(validManifest()); + const tu = renderPrivilegedFixture(plan); + expect(tu).toContain('#import '); + expect(tu).toContain('#import '); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js new file mode 100644 index 000000000000..6fc781504218 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const { + buildDepsHeadersXcframework, + stubSlicesFromXcframework, +} = require('../headers-xcframework'); +const childProcess = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('buildDepsHeadersXcframework set-equality gate', () => { + let tmp /*: string */ = ''; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-headers-test-')); + }); + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + const mkHeaders = (namespaces /*: Array */) => { + const dir = path.join(tmp, 'Headers'); + fs.mkdirSync(dir, {recursive: true}); + for (const ns of namespaces) { + fs.mkdirSync(path.join(dir, ns), {recursive: true}); + } + return dir; + }; + + // Both gates throw BEFORE any staging or xcodebuild invocation, so these + // tests run without macOS tooling. + test('fails closed when a declared namespace is missing from the artifact', () => { + const headers = mkHeaders(['folly']); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []), + ).toThrow(/missing from .*Headers: glog/); + }); + + test('fails closed when the artifact ships an undeclared namespace', () => { + const headers = mkHeaders(['folly', 'brand-new-dep']); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly'], []), + ).toThrow(/undeclared in DEPS_NAMESPACES.*brand-new-dep/); + }); + + test('ignores loose files at the Headers root (directories are the namespace set)', () => { + const headers = mkHeaders(['folly']); + fs.writeFileSync(path.join(headers, 'stray.h'), ''); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []), + ).toThrow(/missing from .*Headers: glog/); // throws for glog, not stray.h + }); +}); + +describe('stubSlicesFromXcframework', () => { + // The plist shape is a pure function of plutil's JSON; mock it so the + // SupportedPlatform/Variant -> key mapping and the unknown-slice guard can be + // tested without a real xcframework or macOS tooling. + const mockPlist = (obj /*: unknown */) => + jest + .spyOn(childProcess, 'execFileSync') + .mockReturnValue(Buffer.from(JSON.stringify(obj) ?? '', 'utf8')); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('maps ios / ios-simulator slices to their stub recipes', () => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'ios', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ], + }); + const slices = stubSlicesFromXcframework('/fake.xcframework'); + expect(slices).toEqual([ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, + ]); + }); + + test('throws for an unknown slice, pointing at PLATFORM_STUB_RECIPES', () => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'watchos', SupportedArchitectures: ['arm64']}, + ], + }); + expect(() => stubSlicesFromXcframework('/fake.xcframework')).toThrow( + /no stub recipe for slice 'watchos'[\s\S]*PLATFORM_STUB_RECIPES/, + ); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/framework-resources.js b/packages/react-native/scripts/ios-prebuild/framework-resources.js new file mode 100644 index 000000000000..41e70bc1389b --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/framework-resources.js @@ -0,0 +1,306 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Non-header resources that the prebuild embeds into React.framework so the + * prebuilt artifact is self-describing for both CocoaPods-prebuilt and SwiftPM. + * + * In source builds each pod ships these via its podspec `resource_bundles`. In + * the prebuilt path the source pods aren't installed (CocoaPods facades) / not + * present (SwiftPM), so we reproduce them from the source tree at compose time: + * + * - Privacy manifest: the pods baked into React.framework each ship a + * PrivacyInfo.xcprivacy; we merge them into ONE manifest at the framework + * root, where Xcode's privacy-report aggregation picks it up (no runtime). + * - RCTI18nStrings: React-Core's localized strings (React/I18n/strings/*.lproj) + * rebuilt as RCTI18nStrings.bundle inside the framework, where the + * framework-aware RCTLocalizedString loader resolves them via bundleForClass:. + */ + +const fs = require('fs'); +const path = require('path'); +const plist = require('plist'); + +// Source roots whose pods compile into React.framework. third-party-podspecs is +// intentionally excluded — those (boost/glog/RCT-Folly) live in +// ReactNativeDependencies.xcframework and are aggregated there. +const REACT_PRIVACY_ROOTS = ['React', 'ReactCommon', 'Libraries', 'ReactApple']; + +// The privacy manifests of the pods that compile INTO React.framework — +// an EXPLICIT allowlist, not a glob: a PrivacyInfo.xcprivacy that appears +// under REACT_PRIVACY_ROOTS without being listed here fails the prebuild, +// forcing a conscious decision. A pod whose sources compile into +// React.framework belongs on this list; a pod that ships as its OWN framework +// must NOT have its manifest folded into React.framework's aggregate +// (over-declaration in the app's privacy report). +const REACT_PRIVACY_MANIFESTS = [ + path.join('React', 'Resources', 'PrivacyInfo.xcprivacy'), + path.join('ReactCommon', 'cxxreact', 'PrivacyInfo.xcprivacy'), + path.join('ReactCommon', 'react', 'timing', 'PrivacyInfo.xcprivacy'), +]; + +// Where React-Core's localized strings live, relative to the package root. +const STRINGS_REL = path.join('React', 'I18n', 'strings'); + +/*:: +type AccessedAPIType = { + NSPrivacyAccessedAPIType: string, + NSPrivacyAccessedAPITypeReasons?: Array, + ... +}; +type PrivacyManifest = { + NSPrivacyAccessedAPITypes?: Array, + NSPrivacyCollectedDataTypes?: Array, + NSPrivacyTracking?: boolean, + NSPrivacyTrackingDomains?: Array, + ... +}; +*/ + +// --------------------------------------------------------------------------- +// Privacy manifest +// --------------------------------------------------------------------------- + +// Recursively sorts object keys so structurally-equal values serialize +// identically regardless of source key order (arrays keep their order). +function canonicalize(value /*: unknown */) /*: unknown */ { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value != null && typeof value === 'object') { + const out /*: {[string]: unknown} */ = {}; + for (const k of Object.keys(value).sort()) { + out[k] = canonicalize(value[k]); + } + return out; + } + return value; +} + +/** + * Merges Apple privacy manifests into one. Pure; operates on parsed plist + * objects. Semantics: + * - NSPrivacyAccessedAPITypes: keyed by category; reasons unioned (deduped). + * - NSPrivacyCollectedDataTypes: unioned, deduped structurally. + * - NSPrivacyTrackingDomains: unioned (deduped); omitted when empty. + * - NSPrivacyTracking: logical OR. + */ +function mergePrivacyManifests( + manifests /*: Array */, +) /*: PrivacyManifest */ { + const reasonsByType /*: Map> */ = new Map(); + // Categories where at least one source actually declared the reasons key, so + // a source that omitted it doesn't get a fabricated empty-array key on the way + // out (keeps "a single manifest passes through unchanged" honest). + const typeHadReasonsKey /*: Set */ = new Set(); + const typeOrder /*: Array */ = []; + const trackingDomains /*: Set */ = new Set(); + const collected /*: Array */ = []; + const collectedSeen /*: Set */ = new Set(); + let tracking = false; + + for (const manifest of manifests) { + if (manifest == null) { + continue; + } + for (const entry of manifest.NSPrivacyAccessedAPITypes ?? []) { + const category = entry.NSPrivacyAccessedAPIType; + if (!reasonsByType.has(category)) { + reasonsByType.set(category, []); + typeOrder.push(category); + } + const entryReasons = entry.NSPrivacyAccessedAPITypeReasons; + if (entryReasons != null) { + typeHadReasonsKey.add(category); + const reasons = reasonsByType.get(category); + if (reasons != null) { + for (const reason of entryReasons) { + if (!reasons.includes(reason)) { + reasons.push(reason); + } + } + } + } + } + for (const domain of manifest.NSPrivacyTrackingDomains ?? []) { + trackingDomains.add(domain); + } + for (const dataType of manifest.NSPrivacyCollectedDataTypes ?? []) { + // Canonicalize (sort object keys recursively) before keying so two pods + // declaring the same data-type dict in different key order still dedup. + // The `?? ''` is unreachable at runtime (canonicalize of a plist dict + // never yields undefined) — it exists purely because Flow types + // JSON.stringify as `string | void`. + const key = JSON.stringify(canonicalize(dataType)) ?? ''; + if (!collectedSeen.has(key)) { + collectedSeen.add(key); + collected.push(dataType); + } + } + if (manifest.NSPrivacyTracking === true) { + tracking = true; + } + } + + const merged /*: PrivacyManifest */ = { + NSPrivacyAccessedAPITypes: typeOrder.map(category => { + const entry /*: AccessedAPIType */ = { + NSPrivacyAccessedAPIType: category, + }; + if (typeHadReasonsKey.has(category)) { + entry.NSPrivacyAccessedAPITypeReasons = + reasonsByType.get(category) ?? []; + } + return entry; + }), + NSPrivacyCollectedDataTypes: collected, + NSPrivacyTracking: tracking, + }; + if (trackingDomains.size > 0) { + merged.NSPrivacyTrackingDomains = Array.from(trackingDomains); + } + return merged; +} + +/** Parses a single `PrivacyInfo.xcprivacy` (plist) file into an object. */ +function readPrivacyManifest(filePath /*: string */) /*: PrivacyManifest */ { + // $FlowFixMe[incompatible-return] plist.parse returns a loose PlistValue. + return plist.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * Finds every `PrivacyInfo.xcprivacy` under the React-core source roots of + * `reactNativePath` (excluding third-party deps). Sorted for deterministic output. + */ +function collectReactPrivacyManifestPaths( + reactNativePath /*: string */, +) /*: Array */ { + const found /*: Array */ = []; + for (const root of REACT_PRIVACY_ROOTS) { + const dir = path.join(reactNativePath, root); + if (!fs.existsSync(dir)) { + continue; + } + for (const rel of fs.readdirSync(dir, {recursive: true})) { + if (path.basename(String(rel)) === 'PrivacyInfo.xcprivacy') { + found.push(path.join(root, String(rel))); + } + } + } + const unlisted = found.filter(rel => !REACT_PRIVACY_MANIFESTS.includes(rel)); + if (unlisted.length > 0) { + throw new Error( + 'React.framework privacy-manifest drift: found PrivacyInfo.xcprivacy ' + + 'file(s) under the React privacy roots that are not allowlisted:\n' + + unlisted.map(rel => ` ${rel}`).join('\n') + + '\nIf the owning pod compiles into React.framework, add the path to ' + + 'REACT_PRIVACY_MANIFESTS in framework-resources.js. If it ships as ' + + 'its own framework, its manifest must NOT be folded into ' + + "React.framework's aggregate — relocate it out of the privacy roots " + + 'or exclude it explicitly.', + ); + } + // A listed-but-absent manifest is legal (partial fixture trees; upstream + // deletions surface as under-declaration exactly like source builds would). + // A MOVED manifest cannot slip through: its new location is unlisted. + return found.map(rel => path.join(reactNativePath, rel)).sort(); +} + +/** + * Builds the aggregated React.framework privacy manifest from the source pods, + * or null when there are none. + */ +function buildReactPrivacyManifest( + reactNativePath /*: string */, +) /*: ?PrivacyManifest */ { + const paths = collectReactPrivacyManifestPaths(reactNativePath); + if (paths.length === 0) { + return null; + } + return mergePrivacyManifests(paths.map(readPrivacyManifest)); +} + +/** Serializes a manifest object back to a plist XML string. */ +function serializePrivacyManifest( + manifest /*: PrivacyManifest */, +) /*: string */ { + return plist.build(manifest); +} + +// --------------------------------------------------------------------------- +// RCTI18nStrings bundle +// --------------------------------------------------------------------------- + +/** The Info.plist contents that make the copied .lproj dirs load as an NSBundle. */ +function i18nBundleInfoPlist() /*: {[string]: string} */ { + return { + CFBundleDevelopmentRegion: 'en', + CFBundleIdentifier: 'org.reactnative.RCTI18nStrings', + CFBundleInfoDictionaryVersion: '6.0', + CFBundleName: 'RCTI18nStrings', + CFBundlePackageType: 'BNDL', + // Present so Apple validation tooling doesn't warn on a version-less bundle. + CFBundleShortVersionString: '1.0', + CFBundleVersion: '1', + }; +} + +/** Absolute paths of the React i18n `.lproj` locale dirs, sorted. */ +function collectLprojDirs(reactNativePath /*: string */) /*: Array */ { + const stringsDir = path.join(reactNativePath, STRINGS_REL); + if (!fs.existsSync(stringsDir)) { + return []; + } + return fs + .readdirSync(stringsDir, {withFileTypes: true}) + .filter(e => e.isDirectory() && String(e.name).endsWith('.lproj')) + .map(e => path.join(stringsDir, String(e.name))) + .sort(); +} + +/** + * Builds `RCTI18nStrings.bundle` at `outBundlePath` from the React i18n .lproj + * dirs + an Info.plist. Returns the number of locales copied (0 when there are + * none, in which case nothing is written). + */ +function buildI18nStringsBundle( + reactNativePath /*: string */, + outBundlePath /*: string */, +) /*: number */ { + const lprojDirs = collectLprojDirs(reactNativePath); + if (lprojDirs.length === 0) { + return 0; + } + fs.rmSync(outBundlePath, {recursive: true, force: true}); + fs.mkdirSync(outBundlePath, {recursive: true}); + for (const lproj of lprojDirs) { + fs.cpSync(lproj, path.join(outBundlePath, path.basename(lproj)), { + recursive: true, + }); + } + fs.writeFileSync( + path.join(outBundlePath, 'Info.plist'), + plist.build(i18nBundleInfoPlist()), + ); + return lprojDirs.length; +} + +module.exports = { + // privacy manifest + mergePrivacyManifests, + readPrivacyManifest, + collectReactPrivacyManifestPaths, + buildReactPrivacyManifest, + serializePrivacyManifest, + // RCTI18nStrings bundle + i18nBundleInfoPlist, + collectLprojDirs, + buildI18nStringsBundle, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js new file mode 100644 index 000000000000..b87f055d1c4f --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -0,0 +1,414 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers compose — emits the headers-spec layout (rules R1–R8 in + * headers-spec.js) into a React.xcframework and builds the headers-only + * ReactNativeHeaders.xcframework beside it (pure-RN: the third-party deps + * namespaces ship in the ReactNativeDependenciesHeaders sidecar instead — + * see headers-xcframework.js). The prebuild path (xcframework.js) composes + * before signing (R7); `ensureHeadersLayout()` applies the same emission to + * an already-cached artifact and builds the deps sidecar from the slot's + * deps headers. One projector, spec-driven, byte-identical output either + * way. + */ + +const { + buildI18nStringsBundle, + buildReactPrivacyManifest, + collectLprojDirs, + serializePrivacyManifest, +} = require('./framework-resources'); +const {computeInventory} = require('./headers-inventory'); +const { + DEPS_NAMESPACES, + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, + stubSlicesFromXcframework, +} = require('./headers-xcframework'); +const {execFileSync} = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// APFS clonefile (-c) is a macOS-only cp flag; plain -R elsewhere (Linux CI +// exercises these paths through the jest integration tests). +const CP_FLAGS = process.platform === 'darwin' ? '-Rc' : '-R'; + +// Hash of the compose tooling itself. Folded into the freshness marker so a +// local edit to any of these scripts (which changes the composed output) +// forces a recompose even when the cached source xcframework is untouched — +// the Info.plist mtime alone can't catch that. Stable across fresh checkouts +// (content-based, not mtime-based). sha256 to avoid weak-hash lint. +// +// This list must contain every local sibling module headers-compose.js +// requires (i.e. every `require('./*.js')` above) — the guard test in +// __tests__/headers-compose-test.js enforces that by parsing this file's +// source and diffing it against this list, so an added require without a +// matching entry here fails the test instead of silently going stale. +const COMPOSE_TOOLING_FILES /*: Array */ = [ + 'framework-resources.js', + 'headers-inventory.js', + 'headers-spec.js', + 'headers-xcframework.js', + 'headers-compose.js', +]; + +function composeToolingHash() /*: string */ { + const hash = crypto.createHash('sha256'); + for (const name of COMPOSE_TOOLING_FILES) { + hash.update(fs.readFileSync(path.join(__dirname, name))); + } + return hash.digest('hex'); +} + +/*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */ + +/** + * Computes the spec plan from the live source tree. Throws on collisions + * (R8) — a collision means the spec and the source tree disagree and the + * artifact must not be produced. + */ +function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ { + const inventory = computeInventory(rnRoot); + // R8, part 1: two distinct sources mapping to the same natural Headers/ path. + // addIdentity merges these and planFromInventory keeps only identities[0], so + // the second source would be silently dropped. Fail closed instead. + if (inventory.collisions.length > 0) { + const detail = inventory.collisions + .map(c => `${c.naturalPath} <- ${c.sources.join(', ')}`) + .join('\n '); + throw new Error( + `header-inventory natural-path collisions (R8):\n ${detail}`, + ); + } + const plan = planFromInventory(inventory, rnRoot); + // R8, part 2: two natural paths colliding on the same xcframework destination. + if (plan.collisions.length > 0) { + throw new Error( + `headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`, + ); + } + return plan; +} + +/** + * Copies spec entries (each `{relPath, source}`) into a staging dir, creating + * parent dirs. Shared by the React.framework and ReactNativeHeaders emission. + */ +function stageEntries( + stage /*: string */, + entries /*: Array */, + rnRoot /*: string */, +) /*: void */ { + for (const e of entries) { + const dest = path.join(stage, e.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + if (e.redirectTo != null) { + // R11: duplicate spelling of a source that lives elsewhere — emit a + // redirect shim so the declarations exist in exactly one file (module + // ownership and #import-once stay coherent across spellings). + fs.writeFileSync( + dest, + `// @generated by headers-compose (R11): this spelling redirects to\n` + + `// the content's single home so clang modules stay coherent.\n` + + `#import <${e.redirectTo}>\n`, + ); + } else { + fs.copyFileSync(path.join(rnRoot, e.source), dest); + } + } +} + +/** + * Emits the React.framework side of the spec (R1, R4, R6) into every slice + * of an xcframework: Headers root = React/ hoisted to root + bare aliases, + * generated umbrella + framework module map. Replaces each slice's Headers + * and Modules. The xcframework's ROOT Headers/ (the CocoaPods header surface) + * is left untouched. + */ +function emitReactFrameworkHeaders( + xcfwPath /*: string */, + plan /*: HeadersSpecPlan */, + rnRoot /*: string */, +) /*: void */ { + const stage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.react-stage-'), + ); + stageEntries(stage, plan.react, rnRoot); + fs.writeFileSync( + path.join(stage, 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + ); + + // A slice is any entry carrying a React.framework. The framework as built by + // xcodebuild -create-xcframework ships no Headers/ dir of its own — this + // emission creates it (and replaces Modules), so detect by the framework, not + // by a pre-existing Headers/. + const slices = fs + .readdirSync(xcfwPath) + .filter(d => fs.existsSync(path.join(xcfwPath, d, 'React.framework'))); + + // Aggregate the privacy manifests of the pods baked into React.framework into + // one root-level PrivacyInfo.xcprivacy, so the prebuilt artifact carries them + // for both CocoaPods-prebuilt and SwiftPM (source builds get them from the + // podspecs instead). Built once, embedded per slice. + const privacyManifest = buildReactPrivacyManifest(rnRoot); + + // Build RCTI18nStrings.bundle ONCE into a temp stage, then clone it into each + // slice below — mirrors the privacy manifest (computed once, embedded per + // slice) instead of rebuilding the bundle inside the slice loop. The stage is + // only created when there are locales to bundle. + let i18nStage = null; + let i18nBundleStage = null; + let i18nLocales = 0; + if (collectLprojDirs(rnRoot).length > 0) { + i18nStage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.i18n-stage-'), + ); + i18nBundleStage = path.join(i18nStage, 'RCTI18nStrings.bundle'); + i18nLocales = buildI18nStringsBundle(rnRoot, i18nBundleStage); + } + + for (const slice of slices) { + const fwk = path.join(xcfwPath, slice, 'React.framework'); + // [macOS] Versioned frameworks expose Headers/Modules/Resources through + // symlinks. Write resources beside the binary's versioned resource bundle. + const resources = fs.existsSync(path.join(fwk, 'Versions', 'Current')) + ? path.join(fwk, 'Versions', 'Current', 'Resources') + : fwk; + fs.mkdirSync(resources, {recursive: true}); + fs.rmSync(path.join(fwk, 'Headers'), {recursive: true, force: true}); + execFileSync('/bin/cp', [CP_FLAGS, stage, path.join(fwk, 'Headers')]); + fs.rmSync(path.join(fwk, 'Modules'), {recursive: true, force: true}); + fs.mkdirSync(path.join(fwk, 'Modules'), {recursive: true}); + fs.writeFileSync( + path.join(fwk, 'Modules', 'module.modulemap'), + renderReactModuleMap(plan.privateReactHeaders), + ); + if (privacyManifest != null) { + fs.writeFileSync( + path.join(resources, 'PrivacyInfo.xcprivacy'), + serializePrivacyManifest(privacyManifest), + ); + } + // Clone the prebuilt RCTI18nStrings.bundle so the framework-aware + // RCTLocalizedString loader resolves React-Core's strings in prebuilt/SPM. + if (i18nLocales > 0 && i18nBundleStage != null) { + const dest = path.join(resources, 'RCTI18nStrings.bundle'); + fs.rmSync(dest, {recursive: true, force: true}); + execFileSync('/bin/cp', [CP_FLAGS, i18nBundleStage, dest]); + } + } + fs.rmSync(stage, {recursive: true, force: true}); + if (i18nStage != null) { + fs.rmSync(i18nStage, {recursive: true, force: true}); + } + console.log( + `headers-compose: React.framework spec layout -> ${slices.join(', ')} ` + + `(${plan.react.length} headers, umbrella ${plan.umbrella.length}` + + `${privacyManifest != null ? ', +PrivacyInfo.xcprivacy' : ''}` + + `${i18nLocales > 0 ? `, +RCTI18nStrings.bundle (${i18nLocales} locales)` : ''})`, + ); +} + +/** + * Builds ReactNativeHeaders.xcframework (R2, R5): a headers-only LIBRARY + * xcframework (stub static archives — nothing embeds in apps) whose Headers + * root carries every non-React RN namespace, plus module.modulemap with the + * plain per-namespace modules. PURE-RN: the third-party deps namespaces ship + * in the ReactNativeDependenciesHeaders sidecar built by the deps prebuild + * (and by ensureHeadersLayout on the consumer side) — never here. SPM serves + * its Headers automatically to dependents — no flags. + */ +function buildReactNativeHeadersXcframework( + outDir /*: string */, + plan /*: HeadersSpecPlan */, + rnRoot /*: string */, + includeCatalyst /*: boolean */ = false, + // Optional dir containing a `hermes/` namespace (Hermes public headers from + // the hermes-ios tarball's destroot/include). Folded in as a textual + // namespace so `` resolves without per-library wiring. null + // when unstaged — then `` stays unavailable. + hermesHeaders /*: ?string */ = null, + // [macOS] Derive sidecar platforms from the binary whenever one is available. + binaryXcfw /*: ?string */ = null, +) /*: string */ { + // ---- stage headers ---- + const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); + stageEntries(stage, plan.reactNativeHeaders, rnRoot); + // Hermes public headers (separate source from the deps namespaces — they + // come from the hermes-ios tarball, not ReactNativeDependencies). Vend only + // the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying + // it here would double-vend. + let hermesFolded = false; + if (hermesHeaders != null) { + const src = path.join(hermesHeaders, 'hermes'); + if (fs.existsSync(src)) { + execFileSync('/bin/cp', [CP_FLAGS, src, path.join(stage, 'hermes')]); + hermesFolded = true; + } else { + console.warn( + `headers-compose: hermes headers missing at ${src} — the composed ` + + 'ReactNativeHeaders will NOT resolve ; a downstream ' + + 'build that imports Hermes headers will fail. Ensure the hermes-ios ' + + "tarball's destroot/include was staged into the slot.", + ); + } + } + // R10: per-namespace umbrella headers (e.g. React_RCTAppDelegate-umbrella.h) + // that consumers like Expo probe via __has_include. Must be staged before the + // module map references them. + for (const u of plan.namespaceUmbrellas) { + const dest = path.join(stage, u.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.writeFileSync(dest, u.content); + } + fs.writeFileSync( + path.join(stage, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + ); + + // ---- compose (stub archives + create-xcframework) ---- + const slices = + binaryXcfw != null + ? stubSlicesFromXcframework(binaryXcfw) + : includeCatalyst + ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] + : DEFAULT_STUB_SLICES; + const outXcfw = composeHeadersOnlyXcframework( + outDir, + 'ReactNativeHeaders', + stage, + slices, + ); + fs.rmSync(stage, {recursive: true, force: true}); + console.log( + `headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(${plan.reactNativeHeaders.length} RN headers, pure-RN` + + `${hermesFolded ? ' + hermes' : ''}; ` + + `${Object.keys(plan.namespaceModules).length} namespace modules)`, + ); + return outXcfw; +} + +/** + * Ensures the headers-spec layout exists at `outDir`, composed from the cache + * slot's artifacts: clones React.xcframework (APFS clonefile), strips the + * stale signature (R7 — production signs after compose), emits the spec + * layout into every slice, builds the pure-RN ReactNativeHeaders.xcframework + * from the plan, and builds the ReactNativeDependenciesHeaders sidecar from + * the slot's deps headers. + * + * Skips when the freshness marker matches the source artifact (same + * realpath + Info.plist mtime) unless `force`. Any consumer with a cache slot + * gets composed artifacts automatically — no published ReactNativeHeaders / + * ReactNativeDependenciesHeaders required. + */ +function ensureHeadersLayout( + artifactsDir /*: string */, + rnRoot /*: string */, + outDir /*: string */, + force /*: boolean */ = false, +) /*: {reactXcfw: string, headersXcfw: string, depsHeadersXcfw: string} */ { + const sourceXcfw = fs.realpathSync( + path.join(artifactsDir, 'React.xcframework'), + ); + const depsHeaders = path.join( + artifactsDir, + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + // Hermes public headers staged into the slot by download-spm-artifacts + // (the hermes-ios tarball ships them in destroot/include, which the + // xcframework extraction otherwise discards). null when absent — then + // ReactNativeHeaders composes without the hermes namespace. + const hermesHeadersDir = path.join(artifactsDir, 'hermes-headers'); + const hermesHeaders = fs.existsSync(path.join(hermesHeadersDir, 'hermes')) + ? hermesHeadersDir + : null; + const reactXcfw = path.join(outDir, 'React.xcframework'); + const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + const depsHeadersXcfw = path.join( + outDir, + 'ReactNativeDependenciesHeaders.xcframework', + ); + const markerPath = path.join(outDir, '.composed-from'); + + const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist')); + // Fold the hermes-headers presence into the marker so a slot that gains + // staged hermes headers (e.g. after a tooling upgrade re-downloads them) + // recomposes instead of reusing a hermes-less ReactNativeHeaders. The + // compose-tooling hash makes a local edit to headers-{inventory,spec,compose} + // recompose too (the source xcframework's Info.plist mtime can't detect that). + const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\ntooling:${composeToolingHash()}\n`; + if ( + !force && + fs.existsSync(reactXcfw) && + fs.existsSync(headersXcfw) && + fs.existsSync(depsHeadersXcfw) && + fs.existsSync(markerPath) && + fs.readFileSync(markerPath, 'utf8') === marker + ) { + return {reactXcfw, headersXcfw, depsHeadersXcfw}; + } + + console.log( + `headers-compose: composing layout from ${path.basename(artifactsDir)} slot...`, + ); + fs.rmSync(reactXcfw, {recursive: true, force: true}); + fs.rmSync(markerPath, {force: true}); + fs.mkdirSync(outDir, {recursive: true}); + execFileSync('/bin/cp', [CP_FLAGS, sourceXcfw, reactXcfw]); + fs.rmSync(path.join(reactXcfw, '_CodeSignature'), { + recursive: true, + force: true, + }); + + const plan = computeSpecPlan(rnRoot); + emitReactFrameworkHeaders(reactXcfw, plan, rnRoot); + buildReactNativeHeadersXcframework( + outDir, + plan, + rnRoot, + false, + hermesHeaders, + sourceXcfw, // [macOS] + ); + // [macOS] Match each sidecar to its own binary's actual platform slices. + buildDepsHeadersXcframework( + outDir, + depsHeaders, + plan.depsNamespaces, + stubSlicesFromXcframework( + path.join(artifactsDir, 'ReactNativeDependencies.xcframework'), + ), + ); + fs.writeFileSync(markerPath, marker); + return {reactXcfw, headersXcfw, depsHeadersXcfw}; +} + +module.exports = { + computeSpecPlan, + emitReactFrameworkHeaders, + buildReactNativeHeadersXcframework, + ensureHeadersLayout, + DEPS_NAMESPACES, + COMPOSE_TOOLING_FILES, + composeToolingHash, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-config.js b/packages/react-native/scripts/ios-prebuild/headers-config.js index b5534b4054ef..47de5d70bb04 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-config.js +++ b/packages/react-native/scripts/ios-prebuild/headers-config.js @@ -109,7 +109,10 @@ const PodspecExceptions /*: {[key: string]: PodSpecConfiguration} */ = { headerPatterns: [ 'react/renderer/components/view/*.h', 'react/renderer/components/view/platform/cxx/**/*.h', + 'react/renderer/components/view/platform/macos/**/*.h', // [macOS] ], + // [macOS] Stable root headers dispatch to platform-relative paths. + preservePaths: ['react/renderer/components/view/platform/**/*.h'], headerDir: 'react/renderer/components/view', }, @@ -131,6 +134,9 @@ const PodspecExceptions /*: {[key: string]: PodSpecConfiguration} */ = { excludePatterns: [ 'react/renderer/components/legacyviewmanagerinterop/tests', ], + preservePaths: [ + 'react/renderer/components/legacyviewmanagerinterop/platform/**/*.h', + ], // [macOS] Do not flatten the upstream iOS copy over the fork header. headerDir: 'react/renderer/components/legacyviewmanagerinterop', }, ], diff --git a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json new file mode 100644 index 000000000000..9221b6945ba3 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json @@ -0,0 +1,29 @@ +[ + "notShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> react/renderer/observers/intersection/IntersectionObserverManager.h", + "notShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> react/renderer/observers/mutation/MutationObserverManager.h", + "notShipped react/renderer/animated/InterpolationAnimatedNode.h -> react/renderer/animated/internal/primitives.h", + "notShipped react/renderer/animated/NativeAnimatedNodesManager.h -> react/renderer/animated/event_drivers/EventAnimationDriver.h", + "notShipped react/renderer/animated/PropsAnimatedNode.h -> react/renderer/animated/internal/primitives.h", + "notShipped react/renderer/mounting/MountingCoordinator.h -> react/renderer/mounting/stubs/stubs.h", + "notShipped react/renderer/mounting/StubViewTree.h -> react/renderer/mounting/stubs/StubView.h", + "notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubView.h", + "notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubViewTree.h", + "quotedNotShipped RCTAnimation/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped RCTAnimation/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped RCTAnimation/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped React/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped React/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped React/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped react/nativemodule/dom/NativeDOM.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/idlecallbacks/NativeIdleCallbacks.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/microtasks/NativeMicrotasks.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/viewtransition/NativeViewTransition.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"rncoreJSI.h\"", + "quotedNotShipped react/renderer/animated/AnimatedModule.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/renderer/animated/NativeAnimatedNodesManager.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped reactperflogger/FuseboxTracer.h -> \"folly/json/dynamic.h\"" +] diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js new file mode 100644 index 000000000000..b1aa586c384e --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -0,0 +1,641 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Inventory and classify every header the React xcframework ships — the input + * to the headers spec (headers-spec.js). + * + * Enumerates headers through the SAME podspec-driven discovery the prebuild + * uses (headers.js), so the inventory cannot drift from the shipped set. For + * each header it records: + * + * - both identities: the pod-namespaced layout path (`Headers//`) + * and the natural path (the include path consumers write) + * - language surface: objc | objcxx | cxx | c (with `#ifdef __cplusplus` + * guard awareness, so ObjC headers that only reach C++ behind guards are + * not misclassified) + * - a modularizability bucket (can this header live in a Clang module?) + * + * `computeInventory()` returns the classified set in-memory for the prebuild + * compose step; the CLI (`node scripts/ios-prebuild/headers-inventory.js`) + * writes the same set as a JSON manifest. Read-only: never touches the trees + * it describes. + */ + +const {getHeaderFilesFromPodspecs} = require('./headers'); +// headers-spec.js requires only fs/path, so this cannot cycle. +const {DEPS_NAMESPACES} = require('./headers-spec'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type Identity = { + pod: string, // pod folder name in Headers/ (specName with '-' -> '_') + spec: string, // (sub)spec name the header came from + namespacedPath: string, // path inside the xcframework Headers/ dir + source: string, // repo-relative path to the physical file + bareAlias?: boolean, // synthetic root-level alias (React_RCTAppDelegate rule) +}; + +type IncludeRef = { + token: string, // text between <> or "" + cxxGuarded: boolean, // true when only reachable under #ifdef __cplusplus +}; + +type HeaderEntry = { + naturalPath: string, + identities: Array, + lang: 'objc' | 'objcxx' | 'cxx' | 'c', + bucket: 'objc-modular-candidate' | 'objc-blocked' | 'objcxx' | 'cxx', + includes: { + internal: Array<{naturalPath: string, cxxGuarded: boolean}>, + thirdParty: Array<{lib: string, token: string, cxxGuarded: boolean}>, + hermes: Array, + system: Array, + std: Array<{token: string, cxxGuarded: boolean}>, + metaInternal: Array, + otherPlatform: Array, + notShipped: Array, + unresolved: Array, + // Quoted includes that do not resolve to a shipped header. Fine inside the + // framework binary's own compilation, but a consumer compiling the shipped + // header hits "file not found" (source builds mask this via pod header + // maps). Gated by the include-health ratchet (headers-verify.js). + quotedNotShipped: Array, + }, +}; +*/ +// Third-party C++ libraries that RN's public headers re-expose (Tier 3 of the +// modularization doc). Keyed by the first include-path segment. Single source +// of truth: the spec's DEPS_NAMESPACES (the ReactNativeDependenciesHeaders +// sidecar's namespace set) — a new third-party dep is declared ONCE and the +// include classifier, the sidecar emitter, and the headers gate all follow. +const THIRD_PARTY_LIBS /*: Set */ = new Set(DEPS_NAMESPACES); + +// Apple SDK / platform include roots (first path segment). Includes resolving +// here are "system": always modular or always available, never our problem. +const SDK_PREFIXES = new Set([ + 'Accelerate', + 'Accessibility', + 'AppKit', // [macOS] + 'AVFoundation', + 'AVKit', + 'CFNetwork', + 'CommonCrypto', + 'CoreFoundation', + 'CoreAudio', // [macOS] + 'CoreGraphics', + 'CoreLocation', + 'CoreMedia', + 'CoreServices', + 'CoreText', + 'CoreVideo', + 'UniformTypeIdentifiers', // [macOS] + 'Foundation', + 'ImageIO', + 'JavaScriptCore', + 'MachO', + 'Metal', + 'MetalKit', + 'MobileCoreServices', + 'Network', + 'PhotosUI', + 'QuartzCore', + 'SafariServices', + 'Security', + 'SwiftUI', + 'TargetConditionals.h', + 'UIKit', + 'UserNotifications', + 'WebKit', + 'XCTest', + 'arm', + 'dispatch', + 'libkern', + 'mach', + 'mach-o', + 'malloc', + 'objc', + 'os', + 'simd', + 'sys', +]); + +/** + * Scans a header's text line by line, tracking the preprocessor-conditional + * stack just enough to know whether a line is only compiled under + * `__cplusplus`. Returns the include list and language-marker observations. + * Heuristic by design: nested #if logic beyond __cplusplus is treated as + * "other" and ignored. + */ +function scanHeader(text /*: string */) /*: { + includes: Array, + hasObjC: boolean, + hasUnguardedCxx: boolean, + hasGuardedCxx: boolean, +} */ { + const includes /*: Array */ = []; + let hasObjC = false; + let hasUnguardedCxx = false; + let hasGuardedCxx = false; + + // Stack frames: 'cpp' (only under __cplusplus), 'notcpp', 'other'. + const stack /*: Array<'cpp' | 'notcpp' | 'other'> */ = []; + const inCxxOnly = () => stack.includes('cpp'); + + const includeRe = /^\s*#\s*(?:include|import)\s+(?:<([^>]+)>|"([^"]+)")/; + const objcRe = + /^\s*(@(interface|protocol|implementation|class\s|end)|NS_ASSUME_NONNULL_BEGIN)/; + const cxxRe = + /^\s*(namespace\s+[A-Za-z_]|template\s*<|extern\s+"C\+\+"|enum\s+class\b|constexpr\b|using\s+(namespace\s|[A-Za-z_]\w*\s*=))/; + + // Track /* ... */ block comments across lines so a documentation line inside + // a comment (e.g. `namespace`, `template <`, `constexpr`) can't trip the C++ + // detector below and needlessly shrink the umbrella. + let inBlockComment = false; + for (const rawLine of text.split('\n')) { + let line = rawLine; + if (inBlockComment) { + const end = line.indexOf('*/'); + if (end === -1) { + continue; // whole line still inside a block comment + } + line = line.slice(end + 2); + inBlockComment = false; + } + // Drop complete inline block comments, then line comments (which also + // swallow any `/*` living inside a `//` comment), then detect a block + // comment that opens and runs onto the next line. + line = line.replace(/\/\*.*?\*\//g, ''); + line = line.replace(/\/\/.*$/, ''); + const blockOpen = line.indexOf('/*'); + if (blockOpen !== -1) { + inBlockComment = true; + line = line.slice(0, blockOpen); + } + const cond = line.match(/^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$/); + if (cond) { + const [, directive, rest] = cond; + const mentionsCpp = /__cplusplus/.test(rest); + if (directive === 'ifdef' || directive === 'if') { + stack.push( + mentionsCpp && + !/!\s*defined|defined\s*\(\s*__cplusplus\s*\)\s*==\s*0/.test(rest) + ? 'cpp' + : 'other', + ); + } else if (directive === 'ifndef') { + stack.push(mentionsCpp ? 'notcpp' : 'other'); + } else if (directive === 'else') { + const top = stack.pop() ?? 'other'; + stack.push( + top === 'cpp' ? 'notcpp' : top === 'notcpp' ? 'cpp' : 'other', + ); + } else if (directive === 'elif') { + stack.pop(); + stack.push(mentionsCpp ? 'cpp' : 'other'); + } else if (directive === 'endif') { + stack.pop(); + } + continue; + } + + const inc = line.match(includeRe); + if (inc) { + includes.push({ + token: inc[1] != null ? inc[1] : `"${inc[2]}"`, + cxxGuarded: inCxxOnly(), + }); + } + if (objcRe.test(line)) { + hasObjC = true; + } + if (cxxRe.test(line)) { + if (inCxxOnly()) { + hasGuardedCxx = true; + } else { + hasUnguardedCxx = true; + } + } + } + + // C++ default member initializer inside an aggregate, e.g. + // struct RCTFontProperties { NSString *family = nil; CGFloat size = NAN; }; + // Illegal in C/ObjC, so the header is really ObjC++ and cannot compile in a + // plain ObjC module. The keyword scan above misses it (no namespace/template/ + // class keyword). Detect a `struct`/`class` body that contains a member + // declaration carrying an `=` initializer. Whole-text (not per-line) so the + // aggregate context is required, avoiding false positives on file-scope + // definitions. Unguarded by construction (definitions can't sit under a + // pure `#ifdef __cplusplus` and still be the ObjC surface). The tag name is + // optional so an anonymous `typedef struct { CGFloat x = NAN; } Foo;` is + // caught too (a named aggregate is not required for the ObjC++ surface). + const aggregateMemberInitRe = + /\b(?:struct|class)\b(?:\s+[A-Za-z_]\w*)?[^;{}]*\{[^{}]*?\b[A-Za-z_][\w\s:<>,]*\**\s+\*?[A-Za-z_]\w*\s*=\s*[^;{}]+;/s; + if (aggregateMemberInitRe.test(text)) { + hasUnguardedCxx = true; + } + + return {includes, hasObjC, hasUnguardedCxx, hasGuardedCxx}; +} + +// Meta-internal headers referenced behind RN_DISABLE_OSS_PLUGIN_HEADER (the +// FB*Plugins pattern) or fbjni/FBI18n — never resolvable in OSS, by design. +const META_INTERNAL_RE /*: RegExp */ = + /^(fbjni|FBI18n)\/|^React\/FB\w+Plugins\.h$/; +// Non-Apple platform headers (Android-only branches in shared headers). +const OTHER_PLATFORM_PREFIXES = new Set(['android', 'jni']); + +// C++ standard library headers have no slash and no extension (); +// C standard headers have no slash and a .h (). +function classifyExternal( + token /*: string */, + ownNamespaces /*: Set */, + rootFolder /*: string */, +) /*: string */ { + const first = token.split('/')[0]; + if (THIRD_PARTY_LIBS.has(first)) { + return 'thirdParty'; + } + if (first === 'hermes') { + return 'hermes'; + } + if (META_INTERNAL_RE.test(token)) { + return 'metaInternal'; + } + if (OTHER_PLATFORM_PREFIXES.has(first)) { + return 'otherPlatform'; + } + if (!token.includes('/')) { + return token.endsWith('.h') ? 'system' : 'std'; + } + if (SDK_PREFIXES.has(first)) { + return 'system'; + } + // RN's own include namespace but absent from the shipped set: either a + // genuinely unshipped header or a header_dir-flattening mismatch (headers.js + // ships /, dropping inner subdirs like mounting/stubs/). + if ( + ownNamespaces.has(first) || + fs.existsSync(path.join(rootFolder, 'ReactCommon', token)) + ) { + return 'notShipped'; + } + return 'unresolved'; +} + +function buildInventory(rootFolder /*: string */) /*: { + entries: Map, + sourceToNatural: Map>, + collisions: Array<{naturalPath: string, sources: Array}>, +} */ { + const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); + + // naturalPath -> entry skeleton; absolute source -> naturalPaths it serves. + const entries /*: Map */ = new Map(); + const sourceToNatural /*: Map> */ = new Map(); + const naturalToSources /*: Map> */ = new Map(); + + const addIdentity = ( + naturalPath /*: string */, + identity /*: Identity */, + absSource /*: string */, + ) => { + let entry = entries.get(naturalPath); + if (!entry) { + entry = { + naturalPath, + identities: [], + lang: 'c', + bucket: 'cxx', + includes: { + internal: [], + thirdParty: [], + hermes: [], + system: [], + std: [], + metaInternal: [], + otherPlatform: [], + notShipped: [], + unresolved: [], + quotedNotShipped: [], + }, + }; + entries.set(naturalPath, entry); + } + entry.identities.push(identity); + + const naturals = sourceToNatural.get(absSource) ?? []; + if (!naturals.includes(naturalPath)) { + naturals.push(naturalPath); + } + sourceToNatural.set(absSource, naturals); + + const sources = naturalToSources.get(naturalPath) ?? new Set(); + sources.add(absSource); + naturalToSources.set(naturalPath, sources); + }; + + for (const podspecPath of Object.keys(podSpecsWithHeaderFiles)) { + const headerMaps = podSpecsWithHeaderFiles[podspecPath]; + // xcframework.js and vfs.js both use the ROOT spec's name (first map) as + // the pod folder, with the same first-occurrence '-' -> '_' replacement. + const podName = headerMaps[0].specName.replace(/-/g, '_'); + + for (const headerMap of headerMaps) { + for (const header of headerMap.headers) { + // Some header patterns are written as *.{m,mm,cpp,h}; only headers ship. + if (!/\.(h|hpp)$/.test(header.source)) { + continue; + } + // Natural path = the VFS key: the podspec target, with root-level + // targets of header_dir-less pods prefixed by the pod name (vfs.js rule). + let naturalPath = header.target; + if ( + !naturalPath.includes('/') && + (!headerMap.headerDir || headerMap.headerDir === '') + ) { + naturalPath = `${podName}/${naturalPath}`; + } + const identity /*: Identity */ = { + pod: podName, + spec: headerMap.specName, + namespacedPath: path.join(podName, header.target), + source: path.relative(rootFolder, header.source), + }; + addIdentity(naturalPath, identity, header.source); + + // The merged ReactCoreHeaders tree ALSO exposes React_RCTAppDelegate + // headers bare at the root (hosts write #import ). + // Model that second identity explicitly. + if (podName === 'React_RCTAppDelegate') { + addIdentity( + path.basename(header.target), + { + ...identity, + bareAlias: true, + }, + header.source, + ); + } + } + } + } + + const collisions = []; + for (const [naturalPath, sources] of naturalToSources) { + if (sources.size > 1) { + collisions.push({ + naturalPath, + sources: Array.from(sources) + .map(s => path.relative(rootFolder, s)) + .sort(), + }); + } + } + collisions.sort((a, b) => a.naturalPath.localeCompare(b.naturalPath)); + + return {entries, sourceToNatural, collisions}; +} + +function classifyEntries( + entries /*: Map */, + sourceToNatural /*: Map> */, + rootFolder /*: string */, +) /*: void */ { + // RN's own top-level include namespaces, derived from the shipped set, so + // "in our namespace but not shipped" is detectable. + const ownNamespaces = new Set( + Array.from(entries.keys()) + .map(p => p.split('/')[0]) + .filter(p => p.includes('.') === false), + ); + + // Scan each entry's primary source once. + for (const entry of entries.values()) { + const absSource = path.join(rootFolder, entry.identities[0].source); + let text; + try { + text = fs.readFileSync(absSource, 'utf8'); + } catch { + entry.includes.unresolved.push(''); + continue; + } + const scan = scanHeader(text); + const isHpp = absSource.endsWith('.hpp'); + if (scan.hasObjC && scan.hasUnguardedCxx) { + entry.lang = 'objcxx'; + } else if (scan.hasObjC) { + entry.lang = 'objc'; + } else if (scan.hasUnguardedCxx || isHpp) { + entry.lang = 'cxx'; + } else { + entry.lang = 'c'; + } + + for (const inc of scan.includes) { + let token = inc.token; + // Quoted include: resolve against the source dir and map back to a + // natural path if the resolved file is itself a shipped header. + if (token.startsWith('"')) { + const quotedToken = token.slice(1, -1); + // [macOS] Stable dispatch headers contain inactive Android branches. + if (quotedToken.startsWith('platform/android/')) { + entry.includes.otherPlatform.push(quotedToken); + continue; + } + const resolved = path.resolve(path.dirname(absSource), quotedToken); + // [macOS] Framework-qualified quoted imports resolve through the same + // public namespace as angle imports. Text's shared platform header is + // in its parent directory before CocoaPods flattens the header map. + const naturals = + sourceToNatural.get(resolved) ?? + (entries.has(quotedToken) ? [quotedToken] : undefined) ?? + (quotedToken === 'RCTTextUIKit.h' + ? sourceToNatural.get( + path.join(rootFolder, 'Libraries/Text/RCTTextUIKit.h'), + ) + : undefined); + if (naturals && naturals.length > 0) { + entry.includes.internal.push({ + naturalPath: naturals[0], + cxxGuarded: inc.cxxGuarded, + }); + } else { + // A quoted include in a SHIPPED header that doesn't land on another + // shipped header: works in source builds (pod header maps / sibling + // files) but has no resolution target in the packaged layout when a + // consumer compiles this header. Recorded for the include-health + // ratchet rather than silently dropped. + entry.includes.quotedNotShipped.push(token); + } + continue; + } + if (entries.has(token)) { + entry.includes.internal.push({ + naturalPath: token, + cxxGuarded: inc.cxxGuarded, + }); + continue; + } + const kind = classifyExternal(token, ownNamespaces, rootFolder); + if (kind === 'thirdParty') { + entry.includes.thirdParty.push({ + lib: token.split('/')[0], + token, + cxxGuarded: inc.cxxGuarded, + }); + } else if (kind === 'hermes') { + entry.includes.hermes.push(token); + } else if (kind === 'system') { + entry.includes.system.push(token); + } else if (kind === 'std') { + entry.includes.std.push({token, cxxGuarded: inc.cxxGuarded}); + } else if (kind === 'metaInternal') { + entry.includes.metaInternal.push(token); + } else if (kind === 'otherPlatform') { + entry.includes.otherPlatform.push(token); + } else if (kind === 'notShipped') { + entry.includes.notShipped.push(token); + } else { + entry.includes.unresolved.push(token); + } + } + } + + // Fixpoint over UNGUARDED edges only: what an Obj-C (non-C++) consumer of + // this header actually pulls in. Decides modularizability of the ObjC surface. + const reachesCxx /*: Map */ = new Map(); + const reachesTp /*: Map> */ = new Map(); + for (const [naturalPath, entry] of entries) { + reachesCxx.set( + naturalPath, + entry.lang === 'cxx' || + entry.lang === 'objcxx' || + entry.includes.std.some(s => !s.cxxGuarded), + ); + reachesTp.set( + naturalPath, + new Set( + entry.includes.thirdParty.filter(t => !t.cxxGuarded).map(t => t.lib), + ), + ); + } + let changed = true; + while (changed) { + changed = false; + for (const [naturalPath, entry] of entries) { + let cxx = reachesCxx.get(naturalPath) ?? false; + const tp = reachesTp.get(naturalPath) ?? new Set(); + const beforeCxx = cxx; + const beforeTp = tp.size; + for (const dep of entry.includes.internal) { + if (dep.cxxGuarded) { + continue; + } + cxx = cxx || (reachesCxx.get(dep.naturalPath) ?? false); + for (const lib of reachesTp.get(dep.naturalPath) ?? []) { + tp.add(lib); + } + } + if (cxx !== beforeCxx || tp.size !== beforeTp) { + reachesCxx.set(naturalPath, cxx); + reachesTp.set(naturalPath, tp); + changed = true; + } + } + } + + for (const [naturalPath, entry] of entries) { + if (entry.lang === 'cxx') { + entry.bucket = 'cxx'; + } else if (entry.lang === 'objcxx') { + entry.bucket = 'objcxx'; + } else { + const cxx = reachesCxx.get(naturalPath) ?? false; + const tp = Array.from(reachesTp.get(naturalPath) ?? []).sort(); + if (!cxx && tp.length === 0) { + entry.bucket = 'objc-modular-candidate'; + } else { + entry.bucket = 'objc-blocked'; + } + } + } +} + +function main() /*: void */ { + const argv = process.argv.slice(2); + const getFlag = (name /*: string */) /*: ?string */ => { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; + }; + const rootFolder = path.resolve( + getFlag('--root') ?? path.join(__dirname, '..', '..'), + ); + const outPath = path.resolve( + getFlag('--out') ?? path.join(rootFolder, 'build', 'header-inventory.json'), + ); + + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + const headers = Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ); + + const manifest = { + formatVersion: 1, + generatedBy: 'scripts/ios-prebuild/headers-inventory.js', + root: rootFolder, + totals: {headers: headers.length}, + collisions, + headers, + }; + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + + console.log(`Wrote ${headers.length} headers to ${outPath}`); +} + +if (require.main === module) { + main(); +} + +/** + * In-memory inventory for tooling that needs the classified header set + * without going through the JSON manifest on disk (e.g. the prebuild compose + * step feeding headers-spec.planFromInventory). + */ +function computeInventory(rootFolder /*: string */) /*: { + headers: Array, + collisions: Array<{naturalPath: string, sources: Array}>, +} */ { + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + return { + headers: Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ), + // Natural-path collisions (two distinct sources mapping to the same + // Headers/ path) are silently merged by addIdentity — planFromInventory + // only keeps identities[0].source, so the second source is dropped. Surface + // them so the compose gate can fail closed (R8) instead of regressing. + collisions, + }; +} + +module.exports = { + buildInventory, + classifyEntries, + computeInventory, + scanHeader, + THIRD_PARTY_LIBS, + META_INTERNAL_RE, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js new file mode 100644 index 000000000000..dcead7e08fe0 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -0,0 +1,520 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * THE HEADERS SPEC — executable contract for the packaged header layout. + * + * One source of truth: the prebuild compose step (headers-compose.js) EMITS + * artifacts from it, and the SPM tooling derives what consumers need from it + * (nothing extra, by design). + * + * The rules: + * + * R1. React.framework/Headers ROOT serves the `React/` namespace (contents + * hoisted to root) plus the bare root aliases. The framework name supplies + * the `React/` prefix, so `` resolves verbatim through + * FRAMEWORK_SEARCH_PATHS. The `react/` (lowercase) namespace is NOT here — + * it ships in ReactNativeHeaders (R2). Resolving it through React.framework + * would require case-folding `react.framework` → `React.framework`, which + * only works on case-insensitive filesystems; the header-search-path route + * is exact and works everywhere. + * R2. Every other RN namespace (incl. `react/`) ships in ONE headers-only + * library xcframework ("ReactNativeHeaders"), namespace dirs at its + * Headers root. PURE-RN: the third-party deps namespaces (DEPS_NAMESPACES) + * are NOT here — they ship in the ReactNativeDependenciesHeaders sidecar + * built by the deps prebuild from its own artifact headers, so every + * namespace has exactly ONE physical home (the SocketRocket dual-copy + * regression class is structurally impossible). Both are served by exact + * header-search-path lookup, so resolution is filesystem-case-independent. + * R3. NO include rewriting anywhere — source headers are byte-identical to + * the repo (content authority = source files; layout authority = this + * spec). Consumers compile unchanged except bare-form angle includes + * (R6). + * R4. React.framework gets a framework module map with an umbrella over the + * ObjC modular surface: objc-modular-candidate ∧ React/-namespace ∧ no + * '+'-category header ∧ no C extern-inline definition (C99 extern inline + * emits a STRONG symbol per importing .m TU → duplicate symbols; + * RCTTextInputNativeCommands.h found empirically). + * R5. Every namespace with objc-modular-candidates gets a module declaring + * exactly those candidates (framework modules may not textually include + * non-modular framework headers; yoga + RCTDeprecation found + * empirically). Namespaces whose name is not a valid module identifier + * (e.g. jsinspector-modern) are exempt — they have no candidates today; + * planFromInventory FAILS CLOSED if that changes. `react/` is also exempt: its few + * objc-modular-candidates stay textual (as they already were inside + * React.framework) so no `react` module aliases the `React` framework + * module. + * R6. Bare root aliases are servable only as `` — bare angle forms + * (`#import `) have no framework spelling. This is the + * accepted, measured consumer migration (~4 lines ecosystem-wide). + * R7. Artifacts are code-signed AFTER header composition (signature pins the + * header manifest). + * R8. Collisions are ERRORS: two different source files may never project to + * the same destination path. + * R9. Private React headers — a curated allowlist of `` headers the + * umbrella (R4) excludes (they are `+`-suffixed and/or objc-blocked) — are + * added to the React framework module map so privileged consumers can reach + * them. The allowlist fails closed on drift (validatePrivateReactHeaders). + * R10. Per-namespace umbrella headers are emitted into ReactNativeHeaders so + * consumers that import a whole namespace (e.g. Expo) get one entry point; + * each is derived from namespaceModules (R5) so it cannot drift. + * R11. ONE source file, ONE content location. Some sources ship under several + * spellings (React/X.h + a legacy pod-namespace form like CoreModules/X.h, + * or a bare root alias + React_RCTAppDelegate/X.h). Under the VFS overlay + * all spellings mapped to one physical file, so #import-once and module + * ownership were coherent. The flattened layout would duplicate the + * declarations — any -fmodules consumer touching two spellings (even + * transitively: a legacy-spelling import whose header pulls a modular + * ) hits redefinition errors. Therefore: the MODULE-OWNED + * spelling keeps the content (the React/ form when it exists — it is the + * umbrella/module-React owner or the canonical textual home; else the + * R5-module namespaced form), and every other spelling is emitted as a + * one-line redirect shim (`#import `). Shims that are namespace- + * module members are fine: they import the owning module, so declarations + * stay single-owned. + */ + +const fs = require('fs'); +const path = require('path'); + +// Fallback root only — inventory `source` paths are relative to the root the +// inventory was computed from, so callers with a different tree (SPM tooling, +// headers-inventory --root) must pass that root to planFromInventory. +const RN_ROOT = path.join(__dirname, '..', '..'); + +/*:: +export type SpecEntry = { + relPath: string, // destination under the artifact's Headers root + source: string, // repo-relative source file + naturalPath: string, // canonical include identity (inventory key) + // R11: when set, the destination is a one-line redirect shim + // (`#import `) instead of a copy of the source file. + redirectTo?: string, +}; + +export type HeadersSpecPlan = { + // React.xcframework -> React.framework/Headers (R1) + react: Array, + // ReactNativeHeaders.xcframework -> Headers (R2); deps namespaces are + // added by the emitter from the deps artifact (not per-file here). + reactNativeHeaders: Array, + depsNamespaces: Array, + // R4: umbrella header list (React/-relative paths) + umbrella: Array, + // R5: plain modules for ReactNativeHeaders' module.modulemap + namespaceModules: {[ns: string]: Array}, + // R10: per-namespace umbrella headers emitted into ReactNativeHeaders. + namespaceUmbrellas: Array<{relPath: string, content: string}>, + // R9: private headers added to the React module map (allowlist). + privateReactHeaders: {modular: Array, textual: Array}, + collisions: Array, +}; +*/ + +// R2: the third-party deps namespaces — the exact contents of the +// ReactNativeDependenciesHeaders sidecar, and the exact set of namespace dirs +// the deps artifact's Headers/ ships. The sidecar emitter fails closed on a +// missing OR an undeclared namespace (set equality), the headers gate asserts +// these stay ABSENT from ReactNativeHeaders (one physical home per +// namespace — relocated copies collided with real pods' own headers: the +// SocketRocket duplicate-@interface / poisoned-module-graph Expo regression, +// 2026-07-03), and the include classifier (headers-inventory.js +// THIRD_PARTY_LIBS) derives from this same list. +const DEPS_NAMESPACES = [ + 'folly', + 'glog', + 'boost', + 'fmt', + 'double-conversion', + 'fast_float', + 'SocketRocket', +]; + +// R4/R5 umbrella exclusion: C extern-inline definitions. +const EXTERN_INLINE_RE /*: RegExp */ = + /\b(RCT_EXTERN\s+inline|extern\s+inline)\b/; + +const MODULE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +// R4 umbrella exclusion: ObjC headers whose ONLY C++ include is behind a +// `#ifdef __cplusplus` guard, so the inventory buckets them +// objc-modular-candidate — the reachability fixpoint (headers-inventory.js) +// follows UNGUARDED edges only, modelling a pure-ObjC consumer. But the React +// framework module is compiled as Objective-C++ by Swift/C++-interop consumers: +// the guard opens, the umbrella transitively includes the foreign C++ header, +// and that header is ALSO owned by the ReactNativeHeaders_ module. One +// physical header, two modules → C++ redefinition ("could not build module +// 'React'"). Keeping the header OUT of the umbrella leaves it shipped and +// textually importable (`#import `); its guarded C++ includes then +// resolve at the consumer's use site, exactly as PRIVATE_REACT_HEADERS.textual +// (R9) already handles the unguarded objc-blocked case. +// Found empirically: RCTFrameTimingsObserver.h (#56015) reaches +// react/timing/primitives.h via . +const UMBRELLA_CXX_GUARDED_EXCLUSIONS /*: Set */ = new Set([ + 'React/RCTFrameTimingsObserver.h', +]); + +// R9: Private React headers — a curated allowlist of `` headers that +// privileged framework consumers (e.g. Expo) import, but which the public +// umbrella (R4) excludes (they are `+`-suffixed and/or objc-blocked). They are +// already shipped in React.framework/Headers; adding them to the React module +// map keeps the existing `#import ` sites MODULAR under explicit +// modules — backwards-compatible, no consumer import (or Swift) changes. Split +// by inventory bucket: +// - modular: objc-modular-candidate (reach no C++) -> real `header`. +// - textual: objc-blocked (reach C++ via ``) -> `textual header` +// (a real member would re-trip -Wnon-modular-include; the C++ includes +// resolve at the consumer's use site, exactly as under the old VFS overlay). +// Privacy is by convention (the `+Private`/internal naming): a single binary +// artifact cannot hard-gate apps from headers a framework legitimately needs. +const PRIVATE_REACT_HEADERS /*: {modular: Array, textual: Array} */ = + { + modular: ['RCTBridge+Private.h'], + textual: [ + 'RCTComponentViewFactory.h', + 'RCTComponentViewProtocol.h', + 'RCTComponentViewRegistry.h', + 'RCTMountingManager.h', + 'RCTSurfacePresenter.h', + 'RCTViewComponentView.h', + ], + }; + +// Fail closed if an allowlisted private header drifts: it must exist in the +// inventory (else it was removed/renamed in source — e.g. RCTUIKit.h / +// RCTRootContentView.h, which need restoration, NOT this allowlist), and a +// `modular` entry must really be objc-modular-candidate (else it now reaches +// C++/third-party and must move to `textual`). +function validatePrivateReactHeaders(manifest /*: any */) /*: void */ { + const byNatural = new Map(manifest.headers.map(h => [h.naturalPath, h])); + const requireShipped = (name /*: string */) => { + const e = byNatural.get(`React/${name}`); + if (e == null) { + throw new Error( + `Private React header allowlist: React/${name} is absent from the ` + + `inventory (removed/renamed in source?). Restore the header or remove ` + + `it from PRIVATE_REACT_HEADERS.`, + ); + } + return e; + }; + for (const name of PRIVATE_REACT_HEADERS.modular) { + const e = requireShipped(name); + if (e.bucket !== 'objc-modular-candidate') { + throw new Error( + `Private React header React/${name} is bucket '${e.bucket}', not ` + + `'objc-modular-candidate' — it now reaches C++/third-party. Move it ` + + `to PRIVATE_REACT_HEADERS.textual.`, + ); + } + } + for (const name of PRIVATE_REACT_HEADERS.textual) { + requireShipped(name); + } +} + +// Fail closed if an umbrella exclusion drifts: the header must still exist in +// the inventory. If it was removed/renamed the stale exclusion would silently +// stop matching and the header would re-enter the umbrella — reintroducing the +// dual-module redefinition. Force the list to be updated instead. +function validateUmbrellaExclusions(manifest /*: any */) /*: void */ { + const naturals = new Set(manifest.headers.map(h => h.naturalPath)); + for (const np of UMBRELLA_CXX_GUARDED_EXCLUSIONS) { + if (!naturals.has(np)) { + throw new Error( + `Umbrella exclusion ${np} is absent from the inventory ` + + `(removed/renamed in source?). Update ` + + `UMBRELLA_CXX_GUARDED_EXCLUSIONS.`, + ); + } + } +} + +function isUmbrellaSafe(h /*: any */, rnRoot /*: string */) /*: boolean */ { + if ( + h.bucket !== 'objc-modular-candidate' || + h.naturalPath.includes('+') || + UMBRELLA_CXX_GUARDED_EXCLUSIONS.has(h.naturalPath) + ) { + return false; + } + try { + return !EXTERN_INLINE_RE.test( + fs.readFileSync(path.join(rnRoot, h.identities[0].source), 'utf8'), + ); + } catch { + return false; + } +} + +// R10: per-namespace umbrella headers. Some consumers (e.g. Expo's +// RCTAppDelegateUmbrella.h) probe +// `` via __has_include. +// The flattened ReactNativeHeaders layout (R2/R5) ships the individual +// namespace headers but no umbrella, so the probe fails and e.g. +// RCTReactNativeFactory / RCTRootViewFactory are never declared. Re-emit a +// per-namespace umbrella for the namespaces consumers probe — content DERIVED +// from namespaceModules (R5) so it can't drift — and add it to that +// namespace's module so the import stays modular under explicit modules. +// Targeted (not all namespaces): only those a consumer imports as +// ``. Extend as the ecosystem surfaces more. +const UMBRELLA_NAMESPACES /*: Array */ = ['React_RCTAppDelegate']; + +// Renders a per-namespace umbrella that re-imports the namespace's modular +// headers. Paths are relative to the namespace dir (where the umbrella lives), +// so the first `/` segment is stripped. +function renderNamespaceUmbrella( + ns /*: string */, + headers /*: Array */, +) /*: string */ { + const imports = headers + .map(np => `#import "${np.slice(ns.length + 1)}"`) + .join('\n'); + // [macOS] Match the framework's platform without requiring UIKit on macOS. + return `#ifdef __OBJC__\n#import \n#if TARGET_OS_OSX\n#import \n#else\n#import \n#endif\n#endif\n\n${imports}\n`; +} + +/** + * Computes the full layout plan from the header inventory manifest + * (build/header-inventory.json — regenerate with header-inventory.js). + * `rnRoot` is the tree the inventory's relative `source` paths resolve + * against; defaults to the manifest's recorded root, then to the package + * hosting this script. + */ +function planFromInventory( + manifest /*: any */, + rnRoot /*:: ?: string */, +) /*: HeadersSpecPlan */ { + const root = rnRoot ?? manifest.root ?? RN_ROOT; + validatePrivateReactHeaders(manifest); // R9: fail closed on allowlist drift + validateUmbrellaExclusions(manifest); // R4: fail closed on exclusion drift + const react /*: Array */ = []; + const reactNativeHeaders /*: Array */ = []; + const umbrella /*: Array */ = []; + const namespaceModules /*: {[string]: Array} */ = {}; + const collisions /*: Array */ = []; + const seen /*: Map */ = new Map(); + + for (const h of manifest.headers) { + const np = h.naturalPath; + const source = h.identities[0].source; + let bucketKey; + let entryList; + let relPath; + if (np.startsWith('React/')) { + relPath = np.slice(6); // R1: hoist React/ to the framework Headers root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else if (!np.includes('/')) { + relPath = np; // R1/R6: bare alias at root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else { + // R2: every other namespace (incl. react/) keeps its prefix and is + // served from ReactNativeHeaders via the header search path. + relPath = np; + bucketKey = `ReactNativeHeaders/${relPath}`; + entryList = reactNativeHeaders; + } + const prev = seen.get(bucketKey); + if (prev != null) { + if (prev !== source) { + collisions.push(`${bucketKey}: ${prev} vs ${source}`); // R8 + } + continue; + } + seen.set(bucketKey, source); + entryList.push({relPath, source, naturalPath: np}); + + // R4: React umbrella membership. + if (np.startsWith('React/') && isUmbrellaSafe(h, root)) { + umbrella.push(np); + } + // R5: namespace modules (only for ReactNativeHeaders namespaces). Every + // namespace with modular candidates gets a module so that React.framework's + // modular headers can `#import ` as a MODULAR include (otherwise + // clang's -Wnon-modular-include-in-framework-module rejects it). `react/` is + // included here too — its module is renamed in renderNamespaceModuleMap so a + // `react` module never aliases the `React` framework module on a + // case-insensitive filesystem. + if (entryList === reactNativeHeaders) { + const ns = np.split('/')[0]; + if (isUmbrellaSafe(h, root)) { + // R5 exemption assert: a namespace whose name is not a valid module + // identifier cannot get a module, so a modular-candidate header in it + // would be silently non-modular — consumers importing it from a + // framework-module context hit -Wnon-modular-include downstream. + // Fail here instead: rename the namespace or keep the header out of + // the modular surface. + if (!MODULE_IDENT_RE.test(ns)) { + throw new Error( + `R5: namespace '${ns}' is not a valid module identifier but ` + + `ships a modular-candidate header (${np}). It cannot get a ` + + `namespace module, so the header would be silently ` + + `non-modular for consumers.`, + ); + } + if (!namespaceModules[ns]) { + namespaceModules[ns] = []; + } + namespaceModules[ns].push(np); + } + } + } + + umbrella.sort(); + for (const ns of Object.keys(namespaceModules)) { + namespaceModules[ns].sort(); + } + + // R11: assign redirect shims for duplicate spellings of one source. + // Owner precedence: the React/ form (module React / canonical textual home) + // when the source ships one; else the RNH namespaced form (the R5-module + // owner — bare root aliases redirect INTO it, since content must live at + // the module-owned spelling or dual-module/dual-copy redefinitions return). + const reactBySource /*: Map */ = new Map(); + for (const e of react) { + if (e.naturalPath.startsWith('React/')) { + reactBySource.set(e.source, e.relPath); + } + } + const rnhBySource /*: Map */ = new Map(); + for (const e of reactNativeHeaders) { + if (!rnhBySource.has(e.source)) { + rnhBySource.set(e.source, e.naturalPath); + } + } + for (const e of reactNativeHeaders) { + const reactForm = reactBySource.get(e.source); + if (reactForm != null) { + e.redirectTo = `React/${reactForm}`; + } else { + // Two RNH spellings of one source with no React/ form: the first is + // the owner, later ones shim to it. + const owner = rnhBySource.get(e.source); + if (owner != null && owner !== e.naturalPath) { + e.redirectTo = owner; + } + } + } + for (const e of react) { + if (e.naturalPath.includes('/')) { + continue; // real React/ content, never a shim + } + // Bare root alias: prefer the React/ owner (three-spelling case), else + // the RNH namespaced sibling (the React_RCTAppDelegate rule). + const reactForm = reactBySource.get(e.source); + const nsForm = rnhBySource.get(e.source); + if (reactForm != null) { + e.redirectTo = `React/${reactForm}`; + } else if (nsForm != null) { + e.redirectTo = nsForm; + } + } + + // R10: fail closed if a probed umbrella namespace lost all its modular + // headers (removed/renamed) — the umbrella would silently vanish and + // re-break consumers like Expo. + const namespaceUmbrellas = UMBRELLA_NAMESPACES.map(ns => { + const headers = namespaceModules[ns]; + if (headers == null || headers.length === 0) { + throw new Error( + `R10: umbrella namespace '${ns}' has no modular headers in the ` + + `inventory (removed/renamed?). Update UMBRELLA_NAMESPACES.`, + ); + } + return { + relPath: `${ns}/${ns}-umbrella.h`, + content: renderNamespaceUmbrella(ns, headers), + }; + }); + + return { + react, + reactNativeHeaders, + depsNamespaces: DEPS_NAMESPACES, + umbrella, + namespaceModules, + namespaceUmbrellas, + privateReactHeaders: PRIVATE_REACT_HEADERS, + collisions, + }; +} + +/** + * Renders React.framework's module map (R4 + R9). The umbrella covers the + * public modular surface; the allowlisted private headers (R9) are appended as + * explicit `header` (modular) / `textual header` (objc-blocked) entries so + * `#import ` of them stays modular without polluting the umbrella. + */ +function renderReactModuleMap( + privateReactHeaders /*:: ?: {modular: Array, textual: Array} */, +) /*: string */ { + const pv = privateReactHeaders ?? {modular: [], textual: []}; + const extra = [ + ...pv.modular.map(h => ` header "${h}"`), + ...pv.textual.map(h => ` textual header "${h}"`), + ]; + const extraBlock = extra.length > 0 ? '\n' + extra.join('\n') : ''; + return `framework module React { + umbrella header "React-umbrella.h"${extraBlock} + export * + module * { export * } +} +`; +} + +/** Renders the umbrella header content (R4). */ +function renderUmbrellaHeader(umbrella /*: Array */) /*: string */ { + return umbrella.map(u => `#import <${u}>`).join('\n') + '\n'; +} + +/** + * Renders ReactNativeHeaders' module.modulemap (R5): PLAIN (non-framework) + * modules, one per namespace with modular candidates — discovered implicitly + * by clang via the auto-added header search path. Headers are referenced by + * their path relative to the Headers root (= the modulemap's directory). + */ +function renderNamespaceModuleMap( + namespaceModules /*: {[string]: Array} */, +) /*: string */ { + // The module NAME is internal to clang's module graph (consumers never + // `@import` these; they `#import ` and clang maps the header to its + // module). It only has to be unique and must not alias the `React` framework + // module on a case-insensitive filesystem — so the lowercase `react` + // namespace is given a distinct module name. Header paths are unchanged, so + // `` still resolves and is now a modular include. + const moduleNameFor = (ns /*: string */) /*: string */ => + ns === 'react' ? 'ReactNativeHeaders_react' : ns; + const blocks = []; + for (const ns of Object.keys(namespaceModules).sort()) { + const headerLines = namespaceModules[ns].map(hh => ` header "${hh}"`); + // R10: the per-namespace umbrella is itself a module member, so importing + // it stays modular (otherwise it re-trips -Wnon-modular-include inside the + // consumer's framework module). + if (UMBRELLA_NAMESPACES.includes(ns)) { + headerLines.push(` header "${ns}/${ns}-umbrella.h"`); + } + blocks.push( + `module ${moduleNameFor(ns)} {\n` + + headerLines.join('\n') + + `\n export *\n}`, + ); + } + return blocks.join('\n\n') + '\n'; +} + +module.exports = { + planFromInventory, + renderReactModuleMap, + renderUmbrellaHeader, + renderNamespaceModuleMap, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js new file mode 100644 index 000000000000..79bebc800348 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -0,0 +1,499 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers gate — verifies the composed prebuilt header layout at GENERATOR + * time, so consumer-facing regressions fail the prebuild instead of a + * downstream (rn-tester / Expo / community) build. Three stages: + * + * 1. INCLUDE HEALTH (no artifact needed): every shipped header's + * notShipped / unresolved / quotedNotShipped includes are ratcheted + * against a committed baseline (headers-include-baseline.json). A NEW + * offender fails; a resolved one is reported so the baseline can shrink. + * Update intentionally with --update-baseline. + * 2. STRUCTURAL: the composed artifact's module maps / umbrellas are + * byte-identical to what the current spec renders, and the R9 private + * headers + deps namespaces are physically present. + * 3. COMPILE (simulator SDK, syntax-only): + * a. An ObjC TU that __has_include-asserts the R9/R10 surfaces, then + * imports under -fmodules (precompiling the React + * module = compiling EVERY umbrella header + the R9 modular member), + * one header from every R5 namespace module (precompiling each), and + * the R10 per-namespace umbrella. + * b. A privileged-consumer ObjC++ TU (the Expo shape): textually imports + * every R9 textual Fabric header against ReactNativeHeaders. + * c. A Swift TU: `import React` + `RCTBridge.moduleRegistry` — the Expo + * Swift case, proving the R9 modular header is module-visible. + * + * Usage: + * node scripts/ios-prebuild/headers-verify.js [--flavor Debug|Release] + * [--artifacts ] [--skip-compile] [--update-baseline] + */ + +const {computeInventory} = require('./headers-inventory'); +const { + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/*:: import type {HeadersSpecPlan} from './headers-spec'; */ + +const RN_ROOT = path.join(__dirname, '..', '..'); +const BASELINE_PATH = path.join(__dirname, 'headers-include-baseline.json'); + +// Mirrors the folly compiler flags RN itself builds with (helpers.rb). +const FOLLY_DEFINES = [ + '-DFOLLY_MOBILE=1', + '-DFOLLY_USE_LIBCPP=1', + '-DFOLLY_CFG_NO_COROUTINES=1', + '-DFOLLY_HAVE_CLOCK_GETTIME=1', +]; +const SIM_TARGET = 'arm64-apple-ios15.0-simulator'; + +function log(msg /*: string */) { + console.log(`[headers-verify] ${msg}`); +} + +// --------------------------------------------------------------------------- +// Stage 1 — include health ratchet +// --------------------------------------------------------------------------- + +/** + * Flattens an inventory's include-health offenders to stable, sorted strings: + * " -> ". + */ +function collectIncludeHealth(inventory /*: any */) /*: Array */ { + const out = []; + for (const h of inventory.headers) { + for (const t of h.includes.notShipped) { + out.push(`notShipped ${h.naturalPath} -> ${t}`); + } + for (const t of h.includes.unresolved) { + out.push(`unresolved ${h.naturalPath} -> ${t}`); + } + for (const t of h.includes.quotedNotShipped) { + out.push(`quotedNotShipped ${h.naturalPath} -> ${t}`); + } + } + return out.sort(); +} + +/** + * Ratchet compare. New offenders (not in baseline) are failures; baseline + * entries no longer present are reported so the baseline can shrink. + */ +function diffAgainstBaseline( + current /*: Array */, + baseline /*: Array */, +) /*: {newOffenders: Array, resolved: Array} */ { + const base = new Set(baseline); + const cur = new Set(current); + return { + newOffenders: current.filter(x => !base.has(x)), + resolved: baseline.filter(x => !cur.has(x)), + }; +} + +function verifyIncludeHealth( + inventory /*: any */, + updateBaseline /*: boolean */, +) /*: void */ { + const current = collectIncludeHealth(inventory); + if (updateBaseline) { + fs.writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n'); + log(`include-health baseline updated (${current.length} entries).`); + return; + } + if (!fs.existsSync(BASELINE_PATH)) { + throw new Error( + `include-health baseline missing at ${BASELINE_PATH}. ` + + `Generate it with --update-baseline.`, + ); + } + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); + const {newOffenders, resolved} = diffAgainstBaseline(current, baseline); + if (resolved.length > 0) { + log( + `include-health: ${resolved.length} baseline entr(ies) resolved — ` + + `shrink the baseline with --update-baseline:\n ${resolved.join('\n ')}`, + ); + } + if (newOffenders.length > 0) { + throw new Error( + `include-health ratchet: ${newOffenders.length} NEW unresolvable ` + + `include(s) in shipped headers (work in source builds via pod header ` + + `maps, break the packaged layout):\n ${newOffenders.join('\n ')}\n` + + `Fix the include (ship the target header / guard it), or — only if ` + + `knowingly acceptable — add it to the baseline with --update-baseline.`, + ); + } + log(`include-health: OK (${current.length} baselined, 0 new).`); +} + +// --------------------------------------------------------------------------- +// Stage 2 — structural checks against the composed artifact +// --------------------------------------------------------------------------- + +function findSimSlice( + xcfwPath /*: string */, + probe /*: string */, +) /*: string */ { + const slices = fs + .readdirSync(xcfwPath) + .filter( + d => + d.includes('simulator') && fs.existsSync(path.join(xcfwPath, d, probe)), + ); + if (slices.length === 0) { + throw new Error(`No simulator slice with ${probe} under ${xcfwPath}`); + } + return path.join(xcfwPath, slices[0]); +} + +function assertFileEquals( + filePath /*: string */, + expected /*: string */, + what /*: string */, +) { + if (!fs.existsSync(filePath)) { + throw new Error(`${what} missing: ${filePath}`); + } + const actual = fs.readFileSync(filePath, 'utf8'); + if (actual !== expected) { + throw new Error( + `${what} does not match the current spec render: ${filePath}\n` + + `The artifact is stale or was composed from a different tree — ` + + `recompose (node scripts/ios-prebuild -c) and re-verify.`, + ); + } +} + +function verifyStructural( + plan /*: HeadersSpecPlan */, + artifactsDir /*: string */, +) /*: {reactSlice: string, rnhHeaders: string} */ { + const reactSlice = findSimSlice( + path.join(artifactsDir, 'React.xcframework'), + 'React.framework', + ); + const fwk = path.join(reactSlice, 'React.framework'); + assertFileEquals( + path.join(fwk, 'Modules', 'module.modulemap'), + renderReactModuleMap(plan.privateReactHeaders), + 'React module map', + ); + assertFileEquals( + path.join(fwk, 'Headers', 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + 'React umbrella header', + ); + for (const name of [ + ...plan.privateReactHeaders.modular, + ...plan.privateReactHeaders.textual, + ]) { + if (!fs.existsSync(path.join(fwk, 'Headers', name))) { + throw new Error(`R9 private header missing from artifact: ${name}`); + } + } + + const rnhSlice = findSimSlice( + path.join(artifactsDir, 'ReactNativeHeaders.xcframework'), + 'Headers', + ); + const rnhHeaders = path.join(rnhSlice, 'Headers'); + assertFileEquals( + path.join(rnhHeaders, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + 'ReactNativeHeaders module map', + ); + for (const u of plan.namespaceUmbrellas) { + assertFileEquals( + path.join(rnhHeaders, u.relPath), + u.content, + `R10 umbrella ${u.relPath}`, + ); + } + // ReactNativeHeaders is PURE-RN: every deps namespace must stay ABSENT. + // They ship in the ReactNativeDependenciesHeaders sidecar (one physical + // home per namespace) — relocated copies collide with the real pods' own + // headers (SocketRocket / Expo use_frameworks regression, 2026-07-03). + for (const ns of plan.depsNamespaces) { + if (fs.existsSync(path.join(rnhHeaders, ns))) { + throw new Error( + `deps namespace '${ns}' found in ReactNativeHeaders — it must NOT be ` + + `relocated (it ships in ReactNativeDependenciesHeaders; textual ` + + `copies collide with the real pods' headers).`, + ); + } + } + log('structural: OK (module maps + umbrellas byte-match the spec render).'); + return {reactSlice, rnhHeaders}; +} + +// --------------------------------------------------------------------------- +// Stage 3 — compile gates +// --------------------------------------------------------------------------- + +/** ObjC TU: __has_include asserts + React module + every namespace module. */ +function renderObjcFixture(plan /*: HeadersSpecPlan */) /*: string */ { + const lines = []; + for (const name of [ + ...plan.privateReactHeaders.modular, + ...plan.privateReactHeaders.textual, + ]) { + lines.push( + `#if !__has_include()`, + `#error "R9 private header not servable: "`, + `#endif`, + ); + } + for (const u of plan.namespaceUmbrellas) { + lines.push( + `#if !__has_include(<${u.relPath}>)`, + `#error "R10 umbrella not servable: <${u.relPath}>"`, + `#endif`, + ); + } + // Precompiles the React framework module (every umbrella header + the R9 + // modular member compile as module members). + lines.push('#import '); + for (const name of plan.privateReactHeaders.modular) { + lines.push(`#import `); + } + // One header per R5 namespace module precompiles the whole module. + for (const ns of Object.keys(plan.namespaceModules).sort()) { + lines.push(`#import <${plan.namespaceModules[ns][0]}>`); + } + for (const u of plan.namespaceUmbrellas) { + lines.push(`#import <${u.relPath}>`); + } + return lines.join('\n') + '\n'; +} + +/** Privileged-consumer ObjC++ TU (Expo shape): the R9 textual surface. */ +function renderPrivilegedFixture(plan /*: HeadersSpecPlan */) /*: string */ { + return ( + plan.privateReactHeaders.textual + .map(name => `#import `) + .join('\n') + '\n' + ); +} + +/** Swift fixture: the Expo Swift private-API case. */ +function renderSwiftFixture() /*: string */ { + return `import React +func _headersVerifyProbe(_ bridge: RCTBridge) { + // RCTBridge.moduleRegistry is declared in RCTBridge+Private.h (an R9 + // modular member) — visible through \`import React\` iff R9 holds. + _ = bridge.moduleRegistry +} +`; +} + +function xcrun(args /*: Array */, what /*: string */) { + try { + execFileSync('xcrun', args, {stdio: ['ignore', 'pipe', 'pipe']}); + } catch (e) { + const stderr = e.stderr != null ? String(e.stderr) : String(e); + throw new Error(`${what} FAILED:\n${stderr}`); + } +} + +function runCompileGates( + plan /*: HeadersSpecPlan */, + reactSlice /*: string */, + rnhHeaders /*: string */, + depsHeaders /*: string */, +) /*: void */ { + const sdk = execFileSync( + 'xcrun', + ['--sdk', 'iphonesimulator', '--show-sdk-path'], + {encoding: 'utf8'}, + ).trim(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'headers-verify-')); + try { + // ReactNativeHeaders is pure-RN, so the deps headers (folly/glog/... — + // reached textually from RN's public headers) come from the deps + // artifact's Headers dir, exactly as consumers get them from the + // ReactNativeDependencies pod / ReactNativeDependenciesHeaders sidecar. + const common = [ + '-fsyntax-only', + '-target', + SIM_TARGET, + '-isysroot', + sdk, + '-F', + reactSlice, + '-I', + rnhHeaders, + '-I', + depsHeaders, + ]; + + const objc = path.join(tmp, 'gate-modules.m'); + fs.writeFileSync(objc, renderObjcFixture(plan)); + xcrun( + [ + 'clang', + '-x', + 'objective-c', + '-fobjc-arc', + '-fmodules', + `-fmodules-cache-path=${path.join(tmp, 'mc')}`, + ...common, + objc, + ], + 'ObjC module gate (React module + namespace modules + R10 umbrella)', + ); + log( + `compile: React module + ${Object.keys(plan.namespaceModules).length} ` + + `namespace modules precompile OK.`, + ); + + const objcxx = path.join(tmp, 'gate-privileged.mm'); + fs.writeFileSync(objcxx, renderPrivilegedFixture(plan)); + xcrun( + [ + 'clang++', + '-x', + 'objective-c++', + '-std=c++20', + '-fobjc-arc', + ...FOLLY_DEFINES, + '-Wno-comma', + '-Wno-shorten-64-to-32', + ...common, + objcxx, + ], + 'Privileged-consumer gate (R9 textual Fabric headers)', + ); + log('compile: privileged-consumer (Expo-shape) ObjC++ fixture OK.'); + + const swift = path.join(tmp, 'gate-swift.swift'); + fs.writeFileSync(swift, renderSwiftFixture()); + xcrun( + [ + 'swiftc', + '-typecheck', + '-sdk', + sdk, + '-target', + SIM_TARGET, + '-module-cache-path', + path.join(tmp, 'mc-swift'), + '-F', + reactSlice, + '-I', + rnhHeaders, + '-I', + depsHeaders, + swift, + ], + 'Swift gate (import React + RCTBridge.moduleRegistry)', + ); + log('compile: Swift moduleRegistry fixture OK.'); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv /*: Array */) /*: { + flavor: string, + artifacts: ?string, + skipCompile: boolean, + updateBaseline: boolean, +} */ { + let flavor = 'Debug'; + let artifacts /*: ?string */ = null; + let skipCompile = false; + let updateBaseline = false; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--flavor') { + flavor = argv[++i]; + } else if (argv[i] === '--artifacts') { + artifacts = argv[++i]; + } else if (argv[i] === '--skip-compile') { + skipCompile = true; + } else if (argv[i] === '--update-baseline') { + updateBaseline = true; + } + } + return {flavor, artifacts, skipCompile, updateBaseline}; +} + +function main(argv /*:: ?: Array */) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + const inventory = computeInventory(RN_ROOT); + const plan = planFromInventory(inventory, RN_ROOT); + if (plan.collisions.length > 0) { + throw new Error(`R8 collisions:\n ${plan.collisions.join('\n ')}`); + } + + verifyIncludeHealth(inventory, args.updateBaseline); + if (args.updateBaseline) { + return; + } + + const artifactsDir = + args.artifacts ?? + path.join(RN_ROOT, '.build', 'output', 'xcframeworks', args.flavor); + if (!fs.existsSync(artifactsDir)) { + throw new Error( + `No composed artifacts at ${artifactsDir} — run the compose first ` + + `(node scripts/ios-prebuild -c -f ${args.flavor}).`, + ); + } + const {reactSlice, rnhHeaders} = verifyStructural(plan, artifactsDir); + + if (args.skipCompile) { + log('compile gates skipped (--skip-compile).'); + } else { + // ReactNativeHeaders is pure-RN — the compile gates additionally need the + // deps headers, served from the staged deps artifact (the same content + // the ReactNativeDependenciesHeaders sidecar ships). + const depsHeaders = path.join( + RN_ROOT, + 'third-party', + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + if (!fs.existsSync(depsHeaders)) { + throw new Error( + `deps headers missing at ${depsHeaders} — stage the ` + + `ReactNativeDependencies artifact before running the compile gates ` + + `(or pass --skip-compile).`, + ); + } + runCompileGates(plan, reactSlice, rnhHeaders, depsHeaders); + } + log('ALL GATES PASSED.'); +} + +if (require.main === module) { + main(); +} + +module.exports = { + collectIncludeHealth, + diffAgainstBaseline, + renderObjcFixture, + renderPrivilegedFixture, + main, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js new file mode 100644 index 000000000000..2cdd85487f50 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -0,0 +1,282 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers-only xcframework emitter — the shared recipe behind + * ReactNativeHeaders.xcframework (headers-compose.js) and the + * ReactNativeDependenciesHeaders.xcframework sidecar (the deps prebuild's + * compose-framework.js). A headers-only artifact is a LIBRARY-type + * xcframework: stub static archives (nothing embeds in apps) paired with a + * staged Headers dir. The per-slice Headers/ layout and the Info.plist + * `HeadersPath` key — what makes SwiftPM auto-serve the headers with zero + * flags — are produced by `xcodebuild -create-xcframework -library ... + * -headers ...` itself and are never hand-written (framework-type entries + * hard-reject `HeadersPath`; verified 2026-07-04). + * + * This module must stay dependency-light (fs/path/child_process only): the + * deps prebuild under scripts/releases requires it across the package + * boundary. + */ + +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// APFS clonefile (-c) is a macOS-only cp flag; plain -R elsewhere (Linux CI +// exercises these paths through the jest integration tests). +const CP_FLAGS = process.platform === 'darwin' ? '-Rc' : '-R'; + +/*:: +export type StubSlice = { + name: string, // human label + sdk: string, // xcrun --sdk name + targets: Array, // clang -target triples (lipo'd when > 1) +}; +*/ + +const DEFAULT_STUB_SLICES /*: Array */ = [ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, +]; + +// Mac Catalyst slice — used by the real compose (the cached-artifact +// repackage path skips it to stay fast; React.xcframework carries it). +const CATALYST_STUB_SLICE /*: StubSlice */ = { + name: 'mac-catalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], +}; + +// SupportedPlatform(+variant) from an xcframework Info.plist -> stub recipe. +// The min OS version in the triple only shapes the stub object file; slice +// identity (what create-xcframework groups by) comes from platform + variant +// + archs. +const PLATFORM_STUB_RECIPES /*: { + [key: string]: {sdk: string, os: string, suffix: string}, +} */ = { + ios: {sdk: 'iphoneos', os: 'ios15.0', suffix: ''}, + 'ios-simulator': { + sdk: 'iphonesimulator', + os: 'ios15.0', + suffix: '-simulator', + }, + 'ios-maccatalyst': {sdk: 'macosx', os: 'ios15.0', suffix: '-macabi'}, + macos: {sdk: 'macosx', os: 'macosx11.0', suffix: ''}, + tvos: {sdk: 'appletvos', os: 'tvos15.1', suffix: ''}, + 'tvos-simulator': { + sdk: 'appletvsimulator', + os: 'tvos15.1', + suffix: '-simulator', + }, + xros: {sdk: 'xros', os: 'xros1.0', suffix: ''}, + 'xros-simulator': {sdk: 'xrsimulator', os: 'xros1.0', suffix: '-simulator'}, +}; + +/** + * Derives stub slices matching an existing (binary) xcframework's slice set, + * so a headers-only sidecar resolves for every platform the binary does. + */ +function stubSlicesFromXcframework( + xcfwPath /*: string */, +) /*: Array */ { + let plist; + try { + plist = JSON.parse( + execFileSync('plutil', [ + '-convert', + 'json', + '-o', + '-', + path.join(xcfwPath, 'Info.plist'), + ]).toString(), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `headers-xcframework: failed to parse Info.plist of ${xcfwPath}: ${message}`, + ); + } + return plist.AvailableLibraries.map(lib => { + const key = + lib.SupportedPlatformVariant != null + ? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}` + : lib.SupportedPlatform; + const recipe = PLATFORM_STUB_RECIPES[key]; + if (recipe == null) { + throw new Error( + `headers-xcframework: no stub recipe for slice '${key}' of ` + + `${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`, + ); + } + return { + name: key, + sdk: recipe.sdk, + targets: lib.SupportedArchitectures.map( + a => `${a}-apple-${recipe.os}${recipe.suffix}`, + ), + }; + }); +} + +/** + * Composes `.xcframework` under `outDir` from an already-populated + * Headers stage dir: one stub static archive per slice, then + * `xcodebuild -create-xcframework` pairing every archive with the stage. + * The caller owns (and cleans) the stage dir. + */ +function composeHeadersOnlyXcframework( + outDir /*: string */, + name /*: string */, + stage /*: string */, + slices /*: Array */, +) /*: string */ { + const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); + // try/finally so an xcrun/xcodebuild failure mid-compose doesn't leave the + // .stub-work-* staging dir behind in outDir. + try { + fs.writeFileSync( + path.join(work, 'stub.c'), + `// ${name} is headers-only; this stub satisfies xcframework tooling.\n` + + `static int ${name}Stub __attribute__((unused)) = 0;\n`, + ); + const libs = slices.map(slice => { + const sdkPath = execFileSync('xcrun', [ + '--sdk', + slice.sdk, + '--show-sdk-path', + ]) + .toString() + .trim(); + const thins = slice.targets.map((t, i) => { + const obj = path.join(work, `stub-${slice.name}-${i}.o`); + execFileSync('xcrun', [ + 'clang', + '-c', + '-target', + t, + '-isysroot', + sdkPath, + path.join(work, 'stub.c'), + '-o', + obj, + ]); + const lib = path.join(work, `stub-${slice.name}-${i}.a`); + execFileSync('xcrun', ['libtool', '-static', '-o', lib, obj], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + return lib; + }); + const outLib = path.join(work, `lib${name}-${slice.name}.a`); + if (thins.length === 1) { + fs.copyFileSync(thins[0], outLib); + } else { + execFileSync('xcrun', ['lipo', '-create', ...thins, '-output', outLib]); + } + return outLib; + }); + + const outXcfw = path.join(outDir, `${name}.xcframework`); + fs.rmSync(outXcfw, {recursive: true, force: true}); + const xcframeworkArgs = ['-create-xcframework']; + for (const l of libs) { + xcframeworkArgs.push('-library', l, '-headers', stage); + } + xcframeworkArgs.push('-output', outXcfw); + execFileSync('xcodebuild', xcframeworkArgs, {stdio: 'pipe'}); + return outXcfw; + } finally { + fs.rmSync(work, {recursive: true, force: true}); + } +} + +const DEPS_HEADERS_XCFRAMEWORK_NAME = 'ReactNativeDependenciesHeaders'; + +/** + * Builds ReactNativeDependenciesHeaders.xcframework: the headers-only sidecar + * serving the third-party deps namespaces (folly/glog/boost/fmt/ + * double-conversion/fast_float/SocketRocket). The binary + * ReactNativeDependencies.xcframework is FRAMEWORK-type, so its root Headers/ + * dir is invisible to SwiftPM — this LIBRARY-type sidecar is what makes the + * deps headers auto-served, keeping ReactNativeHeaders pure-RN. + * + * Set-equality with `namespaces` (headers-spec.js DEPS_NAMESPACES) is + * enforced fail-closed in BOTH directions: a declared namespace missing from + * `depsHeaders` would ship a silently-broken sidecar; an undeclared dir means + * a new third-party dep was added without a spec decision. + */ +function buildDepsHeadersXcframework( + outDir /*: string */, + depsHeaders /*: string */, + namespaces /*: Array */, + slices /*: Array */, +) /*: string */ { + const found = fs + .readdirSync(depsHeaders, {withFileTypes: true}) + .filter(e => e.isDirectory()) + .map(e => String(e.name)); + const missing = namespaces.filter(ns => !found.includes(ns)); + const undeclared = found.filter(d => !namespaces.includes(d)); + if (missing.length > 0 || undeclared.length > 0) { + throw new Error( + `headers-xcframework: deps namespaces out of sync with the spec.\n` + + (missing.length > 0 + ? ` missing from ${depsHeaders}: ${missing.join(', ')}\n` + : '') + + (undeclared.length > 0 + ? ` undeclared in DEPS_NAMESPACES (headers-spec.js): ${undeclared.join(', ')}\n` + : '') + + `Declare new deps deliberately — the sidecar and the spec must agree.`, + ); + } + + const stage = fs.mkdtempSync(path.join(outDir, '.deps-headers-stage-')); + // try/finally so a cp/compose failure doesn't leave the .deps-headers-stage-* + // dir behind in outDir (= third-party/ for the deps path). + let outXcfw; + try { + for (const ns of namespaces) { + execFileSync('/bin/cp', [ + CP_FLAGS, + path.join(depsHeaders, ns), + path.join(stage, ns), + ]); + } + outXcfw = composeHeadersOnlyXcframework( + outDir, + DEPS_HEADERS_XCFRAMEWORK_NAME, + stage, + slices, + ); + } finally { + fs.rmSync(stage, {recursive: true, force: true}); + } + console.log( + `headers-xcframework: ${DEPS_HEADERS_XCFRAMEWORK_NAME}.xcframework ` + + `(${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(namespaces: ${namespaces.join(', ')})`, + ); + return outXcfw; +} + +module.exports = { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + DEPS_HEADERS_XCFRAMEWORK_NAME, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, + stubSlicesFromXcframework, +}; diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index 43a8c5f234b5..12c34d51319f 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -128,6 +128,36 @@ async function prepareReactNativeDependenciesArtifactsAsync( }, ); + // The headers-only ReactNativeDependenciesHeaders.xcframework sidecar ships + // alongside the binary in the same tarball — it is what serves the deps + // namespaces to SwiftPM (the binary is framework-type; its root Headers/ is + // invisible to binaryTargets). Absent only in pre-sidecar tarballs (pinned + // RN_DEP_VERSION): CocoaPods still works (the pod flattens the binary's + // root Headers/), SwiftPM consumers regain it via ensureHeadersLayout. + // + // Slice coverage: this tarball sidecar carries ALL slices the deps prebuild + // produced. The ensureHeadersLayout fallback (headers-compose.js) rebuilds it + // from DEFAULT_STUB_SLICES (ios + ios-simulator only) — sufficient for a + // SwiftPM iOS build, which is the only supported SPM target today. The two + // paths therefore agree on the iOS slices; the fallback simply omits the + // extra slices (catalyst/tvos/...) that no SPM consumer needs yet. Headers + // are slice-uniform, so a consumer never sees divergent content per slice. + const headersSidecarSource = path.join( + path.dirname(xcframeworkSource), + 'ReactNativeDependenciesHeaders.xcframework', + ); + if (fs.existsSync(headersSidecarSource)) { + execSync(`cp -R "${headersSidecarSource}" "${artifactsPath}"`, { + stdio: 'inherit', + }); + } else { + dependencyLog( + 'ReactNativeDependenciesHeaders.xcframework not present in the tarball ' + + '(pre-sidecar artifact) — continuing with the binary xcframework only.', + 'warning', + ); + } + // Delete the tarball after extraction if (!process.env.HERMES_ENGINE_TARBALL_PATH) { fs.unlinkSync(localPath); @@ -197,6 +227,13 @@ function checkExistingVersion( dependencyLog( `React Native Dependencies found on disk at: ${artifactsPath}.\nNo version file has been found. We are going to use it anyway, but there might be some unexpected behaviors.`, ); + // Honor the message above: an artifact without a version marker is a + // locally-staged one (e.g. a freshly composed deps build). Use it as-is. + // NOTE: this returns BEFORE the version.txt write below, so a + // locally-staged artifact never gains a marker — every later run re-hits + // this branch. That is intentional (don't clobber a hand-staged build), + // but downstream code must not assume version.txt exists here. + return true; } } else { dependencyLog('React Native Dependencies not found on disk'); diff --git a/packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h b/packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h deleted file mode 100644 index 53d69b27c1b8..000000000000 --- a/packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#ifdef __OBJC__ -#import -#else -#ifndef FOUNDATION_EXPORT -#if defined(__cplusplus) -#define FOUNDATION_EXPORT extern "C" -#else -#define FOUNDATION_EXPORT extern -#endif -#endif -#endif - -// RCTDefines.h defines RCT_EXTERN, RCT_EXTERN_C_BEGIN/END, RCT_EXPORT_METHOD, etc. -// Must be loaded first so its macros are visible to subsequent umbrella headers (e.g. RCTBridgeConstants.h). -// clang-format off -#import -// clang-format on -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -FOUNDATION_EXPORT double ReactVersionNumber; -FOUNDATION_EXPORT const unsigned char ReactVersionString[]; diff --git a/packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h b/packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h deleted file mode 100644 index 78627a9e2566..000000000000 --- a/packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#ifdef __OBJC__ -#import -#else -#ifndef FOUNDATION_EXPORT -#if defined(__cplusplus) -#define FOUNDATION_EXPORT extern "C" -#else -#define FOUNDATION_EXPORT extern -#endif -#endif -#endif - -#import "RCTAppDelegate.h" -#import "RCTAppSetupUtils.h" -#import "RCTDefaultReactNativeFactoryDelegate.h" -#import "RCTDependencyProvider.h" -#import "RCTJSRuntimeConfiguratorProtocol.h" -#import "RCTReactNativeFactory.h" -#import "RCTRootViewFactory.h" -#import "RCTUIConfiguratorProtocol.h" - -FOUNDATION_EXPORT double React_RCTAppDelegateVersionNumber; -FOUNDATION_EXPORT const unsigned char React_RCTAppDelegateVersionString[]; diff --git a/packages/react-native/scripts/ios-prebuild/templates/module.modulemap b/packages/react-native/scripts/ios-prebuild/templates/module.modulemap deleted file mode 100644 index 587aaaa832b6..000000000000 --- a/packages/react-native/scripts/ios-prebuild/templates/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module React { - umbrella header "React_Core/React_Core-umbrella.h" - export * -} - - -framework module React_RCTAppDelegate { - umbrella header "React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h" - export * -} diff --git a/packages/react-native/scripts/ios-prebuild/types.js b/packages/react-native/scripts/ios-prebuild/types.js index 832b05b2ee5a..ee8dd22d3ceb 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -28,24 +28,6 @@ export type Destination = export type BuildFlavor = 'Debug' | 'Release'; export type MavenSubGroup = 'hermes' | 'react'; - -export type VFSEntry = { - name: string, - type: 'file' | 'directory', - 'external-contents'?: string, - contents?: Array, -}; - -export type VFSOverlay = { - version: number, - 'case-sensitive': boolean, - roots: Array, -}; - -export type HeaderMapping = { - key: string, - path: string, -}; */ module.exports = {}; diff --git a/packages/react-native/scripts/ios-prebuild/vfs.js b/packages/react-native/scripts/ios-prebuild/vfs.js deleted file mode 100644 index 13e2cb233d5f..000000000000 --- a/packages/react-native/scripts/ios-prebuild/vfs.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -/*:: import type {HeaderMapping, VFSEntry, VFSOverlay} from './types'; */ - -const headers = require('./headers'); - -const {getHeaderFilesFromPodspecs} = headers; - -const ROOT_PATH_PLACEHOLDER = '${ROOT_PATH}'; - -/** - * Builds a hierarchical VFS directory structure from a list of header mappings. - * Clang's VFS overlay requires a tree structure where directories contain their children. - */ -function buildVFSStructure( - mappings /*: Array */, -) /*: Array */ { - // Group files by their directory structure - const dirTree /*: Map> */ = new Map(); - - for (const mapping of mappings) { - const parts = mapping.key.split('/'); - const fileName = parts[parts.length - 1]; - const dirPath = parts.slice(0, -1).join('/'); - - if (!dirTree.has(dirPath)) { - dirTree.set(dirPath, new Map()); - } - const filesMap = dirTree.get(dirPath); - if (filesMap) { - filesMap.set(fileName, mapping.path); - } - } - - // Build the root-level entries (files at root + top-level directories) - const rootDirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - const topLevel = dirPath.split('/')[0]; - if (topLevel) { - rootDirs.add(topLevel); - } - } - - const roots /*: Array */ = []; - - // Add files that live at the root (e.g. key === 'RCTAppDelegate.h') - const rootFiles = dirTree.get(''); - if (rootFiles) { - for (const [fileName, sourcePath] of Array.from( - rootFiles.entries(), - ).sort()) { - roots.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - for (const rootDir of Array.from(rootDirs).sort()) { - const dirEntry = buildDirectoryEntry(rootDir, '', dirTree); - roots.push(dirEntry); - } - - return roots; -} - -/** - * Recursively builds a directory entry for the VFS - */ -function buildDirectoryEntry( - dirName /*: string */, - parentPath /*: string */, - dirTree /*: Map> */, -) /*: VFSEntry */ { - const currentPath = parentPath ? `${parentPath}/${dirName}` : dirName; - const contents /*: Array */ = []; - - // Add files in this directory - const filesInDir = dirTree.get(currentPath); - if (filesInDir) { - for (const [fileName, sourcePath] of Array.from( - filesInDir.entries(), - ).sort()) { - contents.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - // Add subdirectories - const subdirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - if (dirPath.startsWith(currentPath + '/')) { - const remainder = dirPath.slice(currentPath.length + 1); - const nextDir = remainder.split('/')[0]; - if (nextDir) { - subdirs.add(nextDir); - } - } - } - - for (const subdir of Array.from(subdirs).sort()) { - contents.push(buildDirectoryEntry(subdir, currentPath, dirTree)); - } - - return { - name: dirName, - type: 'directory', - contents, - }; -} - -/** - * Simple YAML generator for VFS overlay structure (hierarchical format) - */ -function generateVFSOverlayYAML(overlay /*: VFSOverlay */) /*: string */ { - let yaml = ''; - - yaml += `version: ${String(overlay.version)}\n`; - yaml += `case-sensitive: ${String(overlay['case-sensitive'])}\n`; - yaml += `roots:\n`; - - for (const root of overlay.roots) { - yaml += generateEntryYAML(root, 1); - } - - return yaml; -} - -/** - * Recursively generates YAML for a VFS entry - */ -function generateEntryYAML( - entry /*: VFSEntry */, - indent /*: number */, -) /*: string */ { - const spaces = ' '.repeat(indent); - let yaml = ''; - - yaml += `${spaces}- name: '${entry.name}'\n`; - yaml += `${spaces} type: '${entry.type}'\n`; - - if (entry['external-contents']) { - yaml += `${spaces} external-contents: '${entry['external-contents']}'\n`; - } - - if (entry.contents && entry.contents.length > 0) { - yaml += `${spaces} contents:\n`; - for (const child of entry.contents) { - yaml += generateEntryYAML(child, indent + 2); - } - } - - return yaml; -} - -/** - * Creates a VFS overlay object from the header files in podspecs. - * The source paths use ${ROOT_PATH} as a placeholder for later replacement - * with the actual root path on the end user's machine. - * - * The VFS overlay wraps all header mappings under a single root at - * ${ROOT_PATH}/Headers, which matches the HEADER_SEARCH_PATHS configured - * in rncore.rb. This allows the compiler to find headers like - * by looking up ${ROOT_PATH}/Headers/yoga/style/Style.h - * which the VFS redirects to the flat location in the xcframework. - * - * @param rootFolder The root folder of the React Native package - * @returns A VFS overlay object that can be serialized to YAML - */ -function createVFSOverlayContents(rootFolder /*: string */) /*: VFSOverlay */ { - // Get header files from podspecs (disable testing since we just need the mappings) - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - const mappings /*: Array */ = []; - - // Process each podspec and its header files - Object.keys(podSpecsWithHeaderFiles).forEach(podspecPath => { - const headerMaps = podSpecsWithHeaderFiles[podspecPath]; - - // Use the first podspec spec name as the podspec name (this is the root spec) - const podSpecName = headerMaps[0].specName.replace('-', '_'); - - headerMaps.forEach(headerMap => { - headerMap.headers.forEach(header => { - // The key is just the target path (the import path) - // e.g., 'react/renderer/graphics/Size.h' for #import - let key = header.target; - - // If the podspec doesn't specify a header_dir, CocoaPods exposes public headers under - // (and umbrella headers typically use quoted imports resolved relative - // to the pod's public headers directory). To mirror that layout and avoid collisions - // between pods, prefix root-level header targets with the pod spec name. - if ( - !key.includes('/') && - (!headerMap.headerDir || headerMap.headerDir === '') - ) { - key = `${podSpecName}/${key}`; - } - - // The external-contents path is always podSpecName + header.target because - // xcframework.js copies headers to: outputHeadersPath/podSpecName/headerFile.target - // So the VFS must point to that same location. - const sourcePath = `${ROOT_PATH_PLACEHOLDER}/Headers/${podSpecName}/${header.target}`; - - mappings.push({ - key, - path: sourcePath, - }); - }); - }); - }); - - // Build the hierarchical VFS structure from mappings - const innerRoots = buildVFSStructure(mappings); - - // Wrap all roots under a single ${ROOT_PATH}/Headers root. - // This is required because Clang's VFS overlay needs absolute paths for root entries. - // The compiler will have -I${ROOT_PATH}/Headers in its include paths, so when it - // searches for , it looks for ${ROOT_PATH}/Headers/yoga/style/Style.h. - // The VFS overlay intercepts this and maps it to the actual flat location. - const wrappedRoot /*: VFSEntry */ = { - name: `${ROOT_PATH_PLACEHOLDER}/Headers`, - type: 'directory', - contents: innerRoots, - }; - - return { - version: 0, - 'case-sensitive': false, - roots: [wrappedRoot], - }; -} - -/** - * Creates a VFS overlay YAML file from the header files in podspecs. - * This is a convenience function that combines createVFSOverlayContents and - * generateVFSOverlayYAML into a single call. - * - * @param rootFolder The root folder of the React Native package - * @returns The VFS overlay as a YAML string ready to be written to a file - */ -function createVFSOverlay(rootFolder /*: string */) /*: string */ { - const overlay = createVFSOverlayContents(rootFolder); - return generateVFSOverlayYAML(overlay); -} - -/** - * Resolves a VFS overlay template by replacing the ${ROOT_PATH} placeholder - * with the actual root path. This is the equivalent of the Ruby create_vfs_overlay - * function in rncore.rb. - * - * The VFS overlay template contains ${ROOT_PATH} placeholders that need to be - * replaced with the actual path to the xcframework on the end user's machine - * (e.g., the path to React.xcframework in the Pods folder). - * - * @param vfsTemplate The VFS overlay template content (YAML string with ${ROOT_PATH} placeholders) - * @param rootPath The actual root path to substitute for ${ROOT_PATH} - * @returns The resolved VFS overlay YAML string with absolute paths - */ -function resolveVFSOverlay( - vfsTemplate /*: string */, - rootPath /*: string */, -) /*: string */ { - return vfsTemplate.split(ROOT_PATH_PLACEHOLDER).join(rootPath); -} - -module.exports = { - createVFSOverlay, - resolveVFSOverlay, -}; diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index f8065540cfab..1dcb2408a99f 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,47 +13,16 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); -const headers = require('./headers'); const utils = require('./utils'); -const vfs = require('./vfs'); const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); const {execFileSync} = childProcess; // [macOS] - -const {getHeaderFilesFromPodspecs} = headers; -const {createFolderIfNotExists, createLogger} = utils; -const {createVFSOverlay} = vfs; +const {createLogger} = utils; const frameworkLog = createLogger('XCFramework'); -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const REACT_CORE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React-umbrella.h', -); - -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React_RCTAppDelegate-umbrella.h', -); - -const RN_MODULEMAP_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'module.modulemap', -); - function buildXCFrameworks( rootFolder /*: string */, buildFolder /*: string */, @@ -71,7 +40,7 @@ function buildXCFrameworks( buildType, 'React.xcframework', ); - // Delete all target platform folders (everything but the Headers and Modules folders) + // Delete any previous output try { fs.rmSync(outputPath, {recursive: true, force: true}); } catch (error) { @@ -107,98 +76,53 @@ function buildXCFrameworks( return; } - // Use the header files from podspecs - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - // Delete header files to the output path - const outputHeadersPath = path.join(outputPath, 'Headers'); - - // Store umbrella headers keyed on podspec names - const umbrellaHeaders /*: {[key: string]: string} */ = {}; - const copiedHeaderFilesWithPodspecNames /*: {[key: string]: string[]} */ = {}; - - // Enumerate podspecs and copy headers, create umbrella headers and module map file - Object.keys(podSpecsWithHeaderFiles).forEach(podspec => { - const headerFiles = podSpecsWithHeaderFiles[podspec] - .map(h => h.headers) - .flat(); - - // Use the first podspec spec name as the podspec name (this is the root spec in the podspec file) - const podSpecName = podSpecsWithHeaderFiles[podspec][0].specName.replace( - '-', - '_', - ); - - if (headerFiles.length > 0) { - // Create a folder for the podspec in the output headers path - const podSpecTargetFolder = path.join(outputHeadersPath, podSpecName); - - // Copy each header file to the podspec folder - copiedHeaderFilesWithPodspecNames[podSpecName] = headerFiles.map( - headerFile => { - const headerFileTargetPath = path.join( - podSpecTargetFolder, - headerFile.target, - ); - createFolderIfNotExists(path.dirname(headerFileTargetPath)); - fs.copyFileSync(headerFile.source, headerFileTargetPath); - return headerFileTargetPath; - }, - ); - - // Create umbrella header file for the podspec - const umbrellaHeaderFilename = path.join( - podSpecTargetFolder, - podSpecName + '-umbrella.h', - ); - - if ( - podSpecName === 'React_Core' || - podSpecName === 'React_RCTAppDelegate' - ) { - if (podSpecName === 'React_Core') { - // Copy the React-umbrella.h file to the umbrella header filename - fs.copyFileSync( - REACT_CORE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } else { - fs.copyFileSync( - RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } - - // Store the umbrella header filename in the umbrellaHeaders object - umbrellaHeaders[podSpecName] = umbrellaHeaderFilename; - } - } - }); - - // Create the module map file using the header files in podSpecsWithHeaderFiles - const moduleMapFile = createModuleMapFile(outputPath); - if (!moduleMapFile) { - frameworkLog( - 'Failed to create module map file. The XCFramework may not work correctly. Stopping.', - 'error', - ); - return; - } + // Copy Symbols to symbols folder + copySymbols(outputPath, frameworkFolders); - // Copy header files and module map file to each platform slice in the XCFramework - copyHeaderFilesToSlices( + // Emit the headers-spec layout into every slice's React.framework and build + // the ReactNativeHeaders headers-only xcframework beside it. This is the only + // header surface consumers compile against — no root Headers/, no clang VFS + // overlay. MUST run before signing (spec R7: the signature pins the manifest). + const { + buildReactNativeHeadersXcframework, + computeSpecPlan, + emitReactFrameworkHeaders, + } = require('./headers-compose'); + const plan = computeSpecPlan(rootFolder); + emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // ReactNativeHeaders is PURE-RN — the third-party deps namespaces ship in + // the ReactNativeDependenciesHeaders sidecar built by the deps prebuild + // (scripts/releases/ios-prebuild), so the core compose no longer needs the + // deps artifact's headers. + // Fold the Hermes public headers (``) into the PUBLISHED + // ReactNativeHeaders so consumers resolve `` out of the box + // (same fold ensureHeadersLayout does consumer-side). The hermes-ios tarball + // is staged at .build/artifacts/hermes/destroot/include by the hermes prebuild + // step; pass it when its `hermes/` namespace is present, else null (then + // `` stays consumer-composed, as before). + const hermesInclude = path.resolve( + process.cwd(), + '.build', + 'artifacts', + 'hermes', + 'destroot', + 'include', + ); + const hermesHeaders = fs.existsSync(path.join(hermesInclude, 'hermes')) + ? hermesInclude + : null; + const headersXcfw = buildReactNativeHeadersXcframework( + path.dirname(outputPath), + plan, rootFolder, - outputPath, - moduleMapFile, - umbrellaHeaders, - copiedHeaderFilesWithPodspecNames, + true, // include the mac-catalyst slice in the real compose + hermesHeaders, + outputPath, // [macOS] Match the binary, including its macOS slice. ); - // Copy Symbols to symbols folder - copySymbols(outputPath, frameworkFolders); - if (identity) { signXCFramework(identity, outputPath); + signXCFramework(identity, headersXcfw); } // Tar the output folder to a .tar.gz file @@ -211,7 +135,10 @@ function buildXCFrameworks( ); frameworkLog('Creating tar file: ' + tarFilePath); try { - // [macOS + // Ship ReactNativeHeaders.xcframework alongside React.xcframework in the + // reactnative-core artifact so the React-Core-prebuilt pod can vend both + // (React.framework -> , ReactNativeHeaders -> every other + // namespace). The headers-only xcframework is a sibling of React.xcframework. execFileSync( 'tar', [ @@ -220,16 +147,44 @@ function buildXCFrameworks( '-C', path.dirname(outputPath), 'React.xcframework', + path.basename(headersXcfw), ], {stdio: 'inherit'}, ); - // macOS] } catch (error) { frameworkLog( `Error creating tar file: ${error.message}. Check if the tar command is available.`, 'warning', ); } + + // Publish ReactNativeHeaders alongside React. + const headersTarPath = path.join( + buildFolder, + 'output', + 'xcframeworks', + buildType, + 'ReactNativeHeaders.xcframework.tar.gz', + ); + frameworkLog('Creating tar file: ' + headersTarPath); + try { + execFileSync( + 'tar', + [ + '-czf', + headersTarPath, + '-C', + path.dirname(headersXcfw), + 'ReactNativeHeaders.xcframework', + ], + {stdio: 'inherit'}, + ); + } catch (error) { + frameworkLog( + `Error creating ReactNativeHeaders tar: ${error.message}`, + 'warning', + ); + } } function copySymbols( @@ -293,134 +248,6 @@ function copySymbols( }); } -// Copy header files and module map file to each platform slice in the XCFramework. -function copyHeaderFilesToSlices( - rootFolder /*:string*/, - outputPath /*:string*/, - moduleMapFile /*:string*/, - umbrellaHeaderFiles /*:{[key: string]: string}*/, - outputHeaderFiles /*: {[key: string]: string[]} */, -) { - frameworkLog('Linking modules and headers to platform folders for slice...'); - - // Enumerate all platform folders in the output path - const platformFolders = fs - .readdirSync(outputPath) - .map(folder => path.join(outputPath, folder)) - .filter(folder => { - return ( - fs.statSync(folder).isDirectory() && - !folder.endsWith('Headers') && - !folder.endsWith('Modules') - ); - }); - - platformFolders.forEach(platformFolder => { - // Link the Modules folder into the platform folder - const targetModulesFolder = path.join( - platformFolder, - 'React.Framework', - 'Modules', - ); - createFolderIfNotExists(targetModulesFolder); - - try { - fs.linkSync( - moduleMapFile, - path.join(targetModulesFolder, path.basename(moduleMapFile)), - ); - } catch (error) { - frameworkLog( - `Error copying module map file: ${error.message}. Check if the file exists at ${moduleMapFile}.`, - 'error', - ); - } - // Copy headers folder into the platform folder - const targetHeadersFolder = path.join( - platformFolder, - 'React.Framework', - 'Headers', - ); - - // Copy umbrella / header files into the platform folder - Object.keys(umbrellaHeaderFiles).forEach(podSpecName => { - const umbrellaHeaderFile = umbrellaHeaderFiles[podSpecName]; - - // Create the target folder for the umbrella header file - const targetPodSpecFolder = path.join(targetHeadersFolder, podSpecName); - createFolderIfNotExists(targetPodSpecFolder); - // Copy the umbrella header file to the target folder - try { - fs.copyFileSync( - umbrellaHeaderFile, - path.join(targetPodSpecFolder, path.basename(umbrellaHeaderFile)), - ); - } catch (error) { - frameworkLog( - `Error copying umbrella header file: ${umbrellaHeaderFile}\nError: ${error.message}. Check if the file exists.`, - 'error', - ); - } - }); - - Object.keys(outputHeaderFiles).forEach(podSpecName => { - outputHeaderFiles[podSpecName].forEach(headerFile => { - // Get the relative path from the root Headers folder to preserve directory structure - // headerFile is like /path/to/Headers/Yoga/yoga/style/Style.h - // We need to extract Yoga/yoga/style/Style.h and copy to the same structure in the slice - const rootHeadersFolder = path.join(outputPath, 'Headers'); - const relativeHeaderPath = path.relative(rootHeadersFolder, headerFile); - const targetHeaderFile = path.join( - targetHeadersFolder, - relativeHeaderPath, - ); - createFolderIfNotExists(path.dirname(targetHeaderFile)); - if (!fs.existsSync(targetHeaderFile)) { - try { - fs.copyFileSync(headerFile, targetHeaderFile); - } catch (error) { - frameworkLog( - `Error copying header file: ${error.message}. Check if the file exists.`, - 'error', - ); - } - } - }); - }); - }); - - // Create VFS overlay file at the XCFramework root (same for all platforms) - const vfsFilePath = path.join(outputPath, 'React-VFS-template.yaml'); - try { - fs.writeFileSync(vfsFilePath, createVFSOverlay(rootFolder), 'utf8'); - frameworkLog(`Created VFS overlay: ${path.basename(vfsFilePath)}`); - } catch (error) { - frameworkLog(`Error creating VFS overlay file: ${error.message}.`, 'error'); - } -} - -function createModuleMapFile(outputPath /*: string */) { - // Create/get the module map folder - const moduleMapFolder = path.join(outputPath, 'Modules'); - createFolderIfNotExists(moduleMapFolder); - - // Create the module map file - const moduleMapFile = path.join(moduleMapFolder, 'module.modulemap'); - - frameworkLog('Creating module map file: ' + moduleMapFile); - - try { - fs.copyFileSync(RN_MODULEMAP_PATH, moduleMapFile); - return moduleMapFile; - } catch (error) { - frameworkLog( - `Error creating module map file: ${error.message}. Check if the file exists.`, - 'error', - ); - return null; - } -} - function getArchsFromFramework(frameworkPath /*:string*/) { try { // [macOS diff --git a/packages/react-native/scripts/react-native-xcode.sh b/packages/react-native/scripts/react-native-xcode.sh index 6075041a63d5..83f528c32f11 100755 --- a/packages/react-native/scripts/react-native-xcode.sh +++ b/packages/react-native/scripts/react-native-xcode.sh @@ -82,6 +82,26 @@ source "$REACT_NATIVE_DIR/scripts/node-binary.sh" HERMES_ENGINE_PATH="$PODS_ROOT/hermes-engine" [ -z "$HERMES_CLI_PATH" ] && HERMES_CLI_PATH="$HERMES_ENGINE_PATH/destroot/bin/hermesc" +# SwiftPM consumers have no Pods directory to provide hermesc. When there is +# no Pods installation at all and the current HERMES_CLI_PATH does not exist +# (unset, or an injector-baked path gone stale after a package-store move), +# resolve the hermes-compiler npm package THROUGH react-native's own dependency +# graph — react-native pins the hermes-compiler version, so the compiler's +# bytecode version always matches the prebuilt hermes VM artifacts (a +# mismatched pair crashes at launch with "Wrong bytecode version"). +# STRICTLY SwiftPM-scoped: in a CocoaPods build the hermes-engine pod owns both +# the VM and hermesc, and its hermes train may legitimately differ from the npm +# hermes-compiler — falling back there would compile bundles the podded VM +# rejects. +if [[ ( -z "$PODS_ROOT" || ! -d "$PODS_ROOT" ) && ! -f "$HERMES_CLI_PATH" ]]; then + NODE_HERMESC=$(RN_DIR="$REACT_NATIVE_DIR" "$NODE_BINARY" --print \ + "require('path').join(require('path').dirname(require.resolve('hermes-compiler/package.json', {paths: [process.env.RN_DIR]})), 'hermesc', 'osx-bin', 'hermesc')" \ + 2>/dev/null || true) + if [[ -n "$NODE_HERMESC" && -f "$NODE_HERMESC" ]]; then + HERMES_CLI_PATH="$NODE_HERMESC" + fi +fi + # If hermesc is not available and USE_HERMES is not set to false, show error. if [[ $USE_HERMES != false && -f "$HERMES_ENGINE_PATH" && ! -f "$HERMES_CLI_PATH" ]]; then echo "error: Hermes is enabled but the hermesc binary could not be found at ${HERMES_CLI_PATH}." \ diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 6932c9876e84..ad86fc5f97b6 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -21,6 +21,8 @@ require_relative './cocoapods/privacy_manifest_utils.rb' require_relative './cocoapods/spm.rb' require_relative './cocoapods/rncore.rb' +require_relative './cocoapods/rncore_facades.rb' +require_relative './cocoapods/rndeps_facades.rb' # Importing to expose use_native_modules! require_relative './cocoapods/autolinking.rb' @@ -64,6 +66,28 @@ def prepare_react_native_project! ReactNativePodsUtils.create_xcode_env_if_missing end +# Declares a React core pod, choosing source vs prebuilt facade. In prebuilt +# mode, pods in the RNCoreFacades manifest are installed as dependency-only +# facades (no source/headers) so they can't shadow the prebuilt artifact; their +# code + headers come from React-Core-prebuilt. Everything else (and the whole +# source build) is unaffected. See cocoapods/rncore_facades.rb. +def rncore_pod(name, **opts) + base = name.split('/').first + if !ReactNativeCoreUtils.build_rncore_from_source() && RNCoreFacades.facade?(base) + # Install as a LOCAL pod (`:path`) from the generated facade directory, so + # CocoaPods never fetches the placeholder git source (a `:podspec` external + # source would). Both the pod and any subspec declaration point at the SAME + # directory, so CocoaPods sees one consistent source for the name (a bare + # subspec declaration would otherwise default to the spec repo and conflict). + # Preserve the caller's options (e.g. :modular_headers) but replace :path with + # the facade directory. + facade_opts = opts.reject { |k, _| k == :path } + pod name, **facade_opts, :path => RNCoreFacades.facade_path(base) + else + pod name, **opts + end +end + # Function that setup all the react native dependencies #  # Parameters @@ -135,20 +159,26 @@ def use_react_native! ( # Update ReactNativeCoreUtils so that we can easily switch between source and prebuilt ReactNativeCoreUtils.setup_rncore(prefix, react_native_version) + # In prebuilt mode, generate the facade podspecs the core pods are installed as + # (instead of their source podspecs) so they don't ship shadowing headers. + unless ReactNativeCoreUtils.build_rncore_from_source() + RNCoreFacades.generate(react_native_path, Pod::Config.instance.installation_root, react_native_version, min_supported_versions) # [macOS] + end + Pod::UI.puts "Configuring the target with the New Architecture\n" # The Pods which should be included in all projects - pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector" - pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" + rncore_pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector" + rncore_pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" pod 'RCTTypeSafety', :path => "#{prefix}/Libraries/TypeSafety", :modular_headers => true pod 'React', :path => "#{prefix}/" if !ReactNativeCoreUtils.build_rncore_from_source() pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true end - pod 'React-RCTUIKit', :path => "#{prefix}/ReactApple/Libraries/RCTUIKit" # [macOS] - pod 'React-Core', :path => "#{prefix}/" + rncore_pod 'React-RCTUIKit', :path => "#{prefix}/ReactApple/Libraries/RCTUIKit" # [macOS] + rncore_pod 'React-Core', :path => "#{prefix}/" pod 'React-CoreModules', :path => "#{prefix}/React/CoreModules" - pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime" + rncore_pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime" pod 'React-RCTAppDelegate', :path => "#{prefix}/Libraries/AppDelegate" pod 'React-RCTActionSheet', :path => "#{prefix}/Libraries/ActionSheetIOS" pod 'React-RCTAnimation', :path => "#{prefix}/Libraries/NativeAnimation" @@ -159,7 +189,7 @@ def use_react_native! ( pod 'React-RCTSettings', :path => "#{prefix}/Libraries/Settings" pod 'React-RCTText', :path => "#{prefix}/Libraries/Text" pod 'React-RCTVibration', :path => "#{prefix}/Libraries/Vibration" - pod 'React-Core/RCTWebSocket', :path => "#{prefix}/" + rncore_pod 'React-Core/RCTWebSocket', :path => "#{prefix}/" pod 'React-cxxreact', :path => "#{prefix}/ReactCommon/cxxreact" pod 'React-cxxstableapi', :path => "#{prefix}/ReactCommon/react/cxxstableapi" pod 'React-debug', :path => "#{prefix}/ReactCommon/react/debug" @@ -177,7 +207,7 @@ def use_react_native! ( pod 'React-Mapbuffer', :path => "#{prefix}/ReactCommon" pod 'React-bridging', :path => "#{prefix}/ReactCommon/react/bridging", :modular_headers => true pod 'React-jserrorhandler', :path => "#{prefix}/ReactCommon/jserrorhandler" - pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + rncore_pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation" pod 'React-RCTFBReactNativeSpec', :path => "#{prefix}/React" pod 'React-jsi', :path => "#{prefix}/ReactCommon/jsi" pod 'RCTSwiftUI', :path => "#{prefix}/ReactApple/RCTSwiftUI" @@ -210,7 +240,7 @@ def use_react_native! ( pod 'React-logger', :path => "#{prefix}/ReactCommon/logger" pod 'ReactCommon/turbomodule/core', :path => "#{prefix}/ReactCommon", :modular_headers => true pod 'React-NativeModulesApple', :path => "#{prefix}/ReactCommon/react/nativemodule/core/platform/ios", :modular_headers => true - pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true + rncore_pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true setup_fabric!(:react_native_path => prefix) setup_bridgeless!(:react_native_path => prefix, :use_hermes => hermes_enabled) @@ -227,6 +257,17 @@ def use_react_native! ( ReactNativeCoreUtils.rncore_log("Using React Native Core and React Native Dependencies prebuilt versions.") pod 'ReactNativeDependencies', :podspec => "#{prefix}/third-party-podspecs/ReactNativeDependencies.podspec", :modular_headers => true + # Facades: community pods' hardcoded s.dependency "RCT-Folly"/"glog"/... must + # resolve locally instead of from trunk. See __docs__/prebuilt-deps.md. + RNDepsFacades.generate(react_native_path, Pod::Config.instance.installation_root, min_supported_versions) # [macOS] + pod 'DoubleConversion', :path => RNDepsFacades.facade_path('DoubleConversion') + pod 'glog', :path => RNDepsFacades.facade_path('glog') + pod 'boost', :path => RNDepsFacades.facade_path('boost') + pod 'fast_float', :path => RNDepsFacades.facade_path('fast_float') + pod 'fmt', :path => RNDepsFacades.facade_path('fmt') + pod 'RCT-Folly', :path => RNDepsFacades.facade_path('RCT-Folly') + pod 'SocketRocket', :path => RNDepsFacades.facade_path('SocketRocket') + if !ReactNativeCoreUtils.build_rncore_from_source() pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true end @@ -582,18 +623,26 @@ def react_native_post_install( ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) if !ReactNativeCoreUtils.build_rncore_from_source() - # In XCode 26 we need to revert the new setting SWIFT_ENABLE_EXPLICIT_MODULES when building - # with precompiled binaries. - ReactNativePodsUtils.set_build_setting(installer, build_setting: "SWIFT_ENABLE_EXPLICIT_MODULES", value: "NO") - - # Process the VFS overlay for prebuilt React Native Core - this is done as part of the post install so - # that we can update paths based on the final location of the Pods installation. - ReactNativeCoreUtils.process_vfs_overlay() - - # Configure xcconfig for prebuilt usage (VFS overlay, header paths, cleanup redundant paths) + # The Xcode-26 SWIFT_ENABLE_EXPLICIT_MODULES=NO workaround (#53457) is removed: + # the modular ReactNativeHeaders layout + React-Core-prebuilt module-map + # activation + header-less facades let the React module precompile cleanly with + # explicit modules ON (verified cold-DD green), so the override is unnecessary. + + # Make the prebuilt React.xcframework headers resolvable from aggregate (main app) + # and third-party pod targets that don't go through add_rncore_dependency. The headers + # are served directly from the xcframework's headers-spec layout — no clang VFS overlay. ReactNativeCoreUtils.configure_aggregate_xcconfig(installer) end + if !ReactNativeDependenciesUtils.build_react_native_deps_from_source() + # Prebuilt-deps mode: make the deps artifact headers (folly/glog/...) + # resolvable from the aggregate and every pod target, mirroring the rncore + # injection above. ReactNativeHeaders is pure-RN, so the flattened + # ReactNativeDependencies/Headers dir is the single global home of the + # third-party namespaces (see scripts/cocoapods/__docs__/prebuilt-deps.md). + ReactNativeDependenciesUtils.configure_aggregate_xcconfig(installer) + end + SPM.apply_on_post_install(installer) if privacy_file_aggregation_enabled diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index 2684d3250b5d..b3d960890dfe 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -99,17 +99,19 @@ function replaceRNCoreConfiguration( throw new Error(`tar extraction failed with exit code ${result.status}`); } - // Verify extraction produced the expected xcframework structure + // Verify extraction produced the expected xcframework structure. The + // module map now lives per-slice inside React.framework, so check the + // xcframework's Info.plist instead of a root Modules/module.modulemap. const xcfwPath = path.join(tmpExtractDir, 'React.xcframework'); - const modulemapPath = path.join(xcfwPath, 'Modules', 'module.modulemap'); - if (!fs.existsSync(modulemapPath)) { + const infoPlistPath = path.join(xcfwPath, 'Info.plist'); + if (!fs.existsSync(infoPlistPath)) { throw new Error( - `Extraction verification failed: ${modulemapPath} not found`, + `Extraction verification failed: ${infoPlistPath} not found`, ); } - // Delete all directories in finalLocation - not files, since we want to - // keep the React-VFS.yaml file + // Delete only directories in finalLocation (e.g. the React.xcframework) - + // not files, so any sibling files written during pod install are preserved. const dirs = fs .readdirSync(finalLocation, {withFileTypes: true}) .filter(dirent => dirent.isDirectory()); @@ -144,6 +146,51 @@ function replaceRNCoreConfiguration( } } } + + // The podspec prepare_command flattens ReactNativeHeaders' headers into a + // top-level Headers/ dir, but it does not re-run on a config swap. Mirror + // it here: re-flatten the headers (identical across slices) and drop the + // now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers + // keeps resolving , , etc. + // + // Fail closed when the swapped-in tarball lacks ReactNativeHeaders: the + // directory purge above already deleted the previous Headers/, so + // continuing silently would leave the injected -fmodule-map-file flag + // dangling and break every include only on a config switch — + // with no pointer to the version-skewed artifact that caused it. + const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework'); + if (!fs.existsSync(rnhXcfw)) { + throw new Error( + `ReactNativeHeaders.xcframework not found in the extracted tarball at ${finalLocation}. ` + + 'The downloaded artifact predates the headers-spec layout (or is incomplete); ' + + 'use a prebuilt tarball matching this react-native version.', + ); + } + const slice = fs + .readdirSync(rnhXcfw, {withFileTypes: true}) + .find( + dirent => + dirent.isDirectory() && + fs.existsSync(path.join(rnhXcfw, dirent.name.toString(), 'Headers')), + ); + if (!slice) { + throw new Error( + `No slice with a Headers directory found inside ${rnhXcfw}.`, + ); + } + const headersDest = path.join(finalLocation, 'Headers'); + fs.rmSync(headersDest, {force: true, recursive: true}); + const cpHeaders = spawnSync( + 'cp', + ['-R', path.join(rnhXcfw, slice.name.toString(), 'Headers'), headersDest], + {stdio: 'inherit'}, + ); + if (cpHeaders.status !== 0) { + throw new Error( + `Flattening ReactNativeHeaders failed with exit code ${cpHeaders.status}`, + ); + } + fs.rmSync(rnhXcfw, {force: true, recursive: true}); } finally { // Clean up temp directory fs.rmSync(tmpDir, {force: true, recursive: true}); diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js new file mode 100644 index 000000000000..578ebc5c63da --- /dev/null +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -0,0 +1,1165 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type {CliConfigJson, SetupArgs} from './spm/spm-types'; */ + +/** + * setup-apple-spm.js – Entry point for setting up Swift Package Manager support + * in a React Native app using prebuilt XCFrameworks. + * + * Usage (from your app directory): + * node node_modules/react-native/scripts/setup-apple-spm.js [action] [options] + * (or `npx react-native spm [action]`) + * + * Actions: + * add Inject SPM packages (package refs, build + * settings, the Sync build phase) into your + * existing .xcodeproj, in place. Idempotent. + * Default on first run. `--deintegrate` first + * runs `pod deintegrate` + strips React Native + * from the Podfile (CocoaPods → SPM migration). + * update Re-run the pipeline and refresh the existing + * injection. Default once a project is injected. + * deinit The exact inverse of `add`: surgically remove + * only what `add` injected (recorded in + * .spm-injected.json) and drop the marker. + * scaffold Generate Package.swift for community deps that + * lack SPM support. + * sync / codegen / download Advanced/internal: `sync` is invoked by the + * generated Xcode build phase; `codegen` and + * `download` run a single pipeline step. + * + * Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods + * project (clean tree, stock Podfile) → `add --deintegrate`; an injected + * project → `update`; otherwise → `add` (fails loud on a CocoaPods project, + * directing you to `--deintegrate`). + * + * Options: + * --version React Native version (default: the resolved + * node_modules/react-native version). + * --yes Skip the dirty-pbxproj confirmation prompt. + * [add] --xcodeproj Which .xcodeproj to inject into (when several). + * [add] --product-name Which app target to inject into (when several). + * [add] --deintegrate Run `pod deintegrate` + strip RN from the + * Podfile before injecting. + * [advanced] --artifacts Local two-flavor artifact root; + * must contain debug/ and release/ cache slots. + * [advanced] --download Artifact policy (default: auto). + * [advanced] --skip-codegen Skip the react-native codegen step. + * + * Steps performed (add/update): + * 1. react-native codegen → build/generated/ios/ + install SPM codegen template + * 2. generate-spm-autolinking-config.js → build/generated/autolinking/autolinking.json + * 3. generate-spm-autolinking.js → build/generated/autolinking/Package.swift + * 4. download-spm-artifacts.js → cache dir (per --download policy) + * 5. generate-spm-package.js → build/xcframeworks/Package.swift + symlinks + * 6. inject SPM packages into the existing .xcodeproj (in place) + * + * The injection is committed with your project; its XCLocalSwiftPackageReference + * entries point at stable sub-package paths under build/ (xcframeworks, + * generated/autolinking, generated/ios), so adding/removing community deps + * changes those sub-packages (gitignored) and never re-injects. No app-level + * Package.swift is generated or required. + */ + +const { + main: downloadArtifacts, + resolveCacheSlotVersion, + validateArtifactsCache, +} = require('./spm/download-spm-artifacts'); +const {finalizeArtifactPublication} = require('./spm/flavored-frameworks'); +const { + MissingManifestError, + main: generateAutolinking, +} = require('./spm/generate-spm-autolinking'); +const { + generateAutolinkingConfig, +} = require('./spm/generate-spm-autolinking-config'); +const {main: generatePackage} = require('./spm/generate-spm-package'); +const {findSourcePath} = require('./spm/generate-spm-package'); +const { + cleanupDanglingJavaScriptCoreRef, + cleanupLeftoverPodsGroup, + findInjectedXcodeproj, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, +} = require('./spm/generate-spm-xcodeproj'); +const {scaffoldAll} = require('./spm/scaffold-package-swift'); +const { + RemoteVersionError, + buildPerAppHeaderTree, + defaultCacheDir, + deriveAppName, + displayPath, + findProjectRoot, + installSpmCodegenTemplate, + makeLogger, + readPackageJson, + remotePackageConfig, + runCodegenAndInstallTemplate, +} = require('./spm/spm-utils'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const yargs = require('yargs'); + +const {log, warn: logError} = makeLogger('setup-apple-spm'); + +const VALID_ACTIONS = new Set([ + 'add', + 'update', + 'deinit', + 'sync', + 'codegen', + 'download', + 'scaffold', +]); + +/*:: +type AutolinkingConfigResult = { + config: CliConfigJson, + outputPath: string, + rawJson: string, +}; +*/ + +function parseArgs(argv /*: Array */) /*: SetupArgs */ { + const parsed = yargs(argv) + .version(false) + .command('$0 [action]', 'Set up Apple SPM support') + .positional('action', { + type: 'string', + choices: Array.from(VALID_ACTIONS), + describe: + 'Action to run: add, update, deinit, or scaffold. ' + + 'Defaults to add (or update if SPM is already set up).', + }) + .option('version', { + type: 'string', + describe: + 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json', + }) + .option('yes', { + type: 'boolean', + default: false, + describe: 'Skip the dirty-pbxproj confirmation prompt', + }) + .option('xcodeproj', { + type: 'string', + describe: + '[add] Path to the .xcodeproj to inject SPM packages into (disambiguates when several exist).', + }) + .option('product-name', { + type: 'string', + describe: + '[add] App target to inject into (disambiguates when several exist).', + }) + .option('deintegrate', { + type: 'boolean', + default: false, + describe: + '[add] Run `pod deintegrate` + strip React Native from the Podfile before injecting (CocoaPods → SPM migration).', + }) + .option('artifacts', { + type: 'string', + describe: + '[advanced] Local artifact root containing complete debug/ and release/ cache slots.', + }) + .option('download', { + type: 'string', + choices: ['auto', 'skip', 'force'], + default: 'auto', + describe: + '[advanced] Artifact download policy: auto (fetch if missing), skip (never fetch), force (clear cache + refetch).', + }) + .option('skip-codegen', { + type: 'boolean', + default: false, + describe: '[advanced] Skip the react-native codegen step', + }) + .usage( + 'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.', + ) + .strictOptions() + .help() + .parseSync(); + + const positional = parsed._.map(String); + const requestedAction = parsed.action ?? positional[0] ?? null; + if (positional.length > 1) { + throw new Error( + `Expected at most one action, got: ${positional.join(', ')}`, + ); + } + if (requestedAction != null && !VALID_ACTIONS.has(requestedAction)) { + throw new Error( + `Unknown action "${requestedAction}". Expected one of: ${Array.from( + VALID_ACTIONS, + ).join(', ')}`, + ); + } + + return { + action: requestedAction, + version: parsed.version ?? null, + artifacts: parsed.artifacts ?? null, + skipCodegen: parsed['skip-codegen'], + downloadPolicy: parsed.download, + productName: parsed['product-name'] ?? null, + xcodeprojPath: parsed.xcodeproj ?? null, + deintegrate: parsed.deintegrate, + yes: parsed.yes, + }; +} + +const SPM_GITIGNORE_ENTRIES = [ + 'Package.resolved', + 'build/generated/', + 'build/xcframeworks/', + '.build/', +]; + +/** + * Ensure the project's .gitignore contains entries for SPM-generated + * directories. Called during init so that generated artifacts are not + * accidentally committed. + */ +function ensureGitignoreSpmEntries(appRoot /*: string */) { + const gitignorePath = path.join(appRoot, '.gitignore'); + let content = ''; + if (fs.existsSync(gitignorePath)) { + content = fs.readFileSync(gitignorePath, 'utf8'); + } + + const existingEntries = new Set(content.split('\n').map(l => l.trim())); + const missing = SPM_GITIGNORE_ENTRIES.filter(e => !existingEntries.has(e)); + + if (missing.length === 0) { + return; + } + + const block = [ + '', + '# SPM – auto-generated at build time (do not commit)', + ...missing, + ].join('\n'); + + // Append, ensuring we start on a fresh line + const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; + fs.writeFileSync(gitignorePath, content + separator + block + '\n', 'utf8'); + log(`Updated .gitignore with SPM entries: ${missing.join(', ')}`); +} + +/** + * Single TTY-gated Y/N prompt helper used by every interactive confirmation + * in this file. Non-TTY (CI / piped stdin) auto-confirms — every callsite + * either opted into the action explicitly or is downstream of an opt-in. + */ +function promptYesNo( + question /*: string */, + defaultYes /*: boolean */, +) /*: Promise */ { + // $FlowFixMe[prop-missing] process.stdin.isTTY not in Flow stubs + if (process.stdin.isTTY !== true) { + return Promise.resolve(true); + } + const suffix = defaultYes ? '[Y/n]' : '[y/N]'; + return new Promise(resolve => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${question} ${suffix} `, answer => { + rl.close(); + const a = answer.trim().toLowerCase(); + const yes = a === 'y' || a === 'yes'; + resolve(defaultYes ? a === '' || yes : yes); + }); + }); +} + +function resolveAction( + requestedAction /*: SetupArgs['action'] */, + appRoot /*: string */, +) /*: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' */ { + if (requestedAction != null) { + return requestedAction; + } + // Zero-arg default. Once SPM has been injected, default to `update` (regen + + // refresh). Otherwise it's a first run → `add`. Whether `add` should imply + // `--deintegrate` (fresh CocoaPods project) is decided by the safe-gate in + // main(), which only applies on this implicit path. + return findInjectedXcodeproj(appRoot) != null ? 'update' : 'add'; +} + +/** + * Detects the JS-root-vs-ios-dir mismatch that produces silently-broken + * builds for standard RN apps. The community CLI writes + * `autolinking.json` under `/build/generated/autolinking/` + * (i.e. `/ios/...`), while every SPM script anchors its + * inputs/outputs on `process.cwd()`. Running from the JS root therefore + * (a) writes outputs at `/build/...` — away from the iOS + * project, and (b) makes the autolinker miss `autolinking.json` and + * silently skip every npm native dep. The build "succeeds" but anything + * touching a native module crashes at runtime. + * + * Returns the absolute path to the redirected app root (`/ios`) + * when the redirect heuristic applies, else null. Pure: no side effects. + * The caller decides whether to auto-redirect (non-destructive actions) or + * refuse (destructive actions like `clean`). + */ +function detectStandardRnLayoutRedirect( + appRoot /*: string */, + projectRoot /*: string */, +) /*: string | null */ { + // Only relevant when cwd === projectRoot (i.e. user is at the JS root + // of their RN app). If they've already cd'd into a subdir, projectRoot + // walks up to find package.json and the two paths differ — leave alone. + if (path.resolve(appRoot) !== path.resolve(projectRoot)) { + return null; + } + // Standard RN layout has an `ios/` subdir holding the native project. + // Without it (e.g. rn-tester's flat layout), no mismatch to flag. + const iosSubdir = path.join(projectRoot, 'ios'); + try { + if (!fs.statSync(iosSubdir).isDirectory()) { + return null; + } + } catch { + return null; + } + return iosSubdir; +} + +function resolveReactNativeRoot( + autolinkingConfigResult /*: ?AutolinkingConfigResult */, + projectRoot /*: string */, +) /*: string */ { + // Prefer the React Native path resolved by the CLI config we already run for + // autolinking. Fall back to this script's package root for direct repo usage. + let reactNativeRoot = path.resolve(__dirname, '..'); + const cliConfig = autolinkingConfigResult?.config; + const cliReactNativePath = cliConfig?.reactNativePath; + const cliConfigRoot = cliConfig?.root; + if (typeof cliReactNativePath === 'string' && cliReactNativePath.length > 0) { + reactNativeRoot = path.resolve( + typeof cliConfigRoot === 'string' && cliConfigRoot.length > 0 + ? cliConfigRoot + : projectRoot, + cliReactNativePath, + ); + } + return reactNativeRoot; +} + +function determineVersion( + args /*: SetupArgs */, + reactNativeRoot /*: string */, +) /*: string */ { + let version = args.version; + if (version == null) { + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkgJson /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + ); + version = pkgJson.version; + } + return version; +} + +function runCodegenStep( + projectRoot /*: string */, + appRoot /*: string */, + reactNativeRoot /*: string */, + skipCodegen /*: boolean */, +) /*: void */ { + if (skipCodegen) { + // Output dir may already exist from a previous run; still refresh the + // SPM template so cache-slot changes propagate. + log('Skipping codegen (--skip-codegen)'); + installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + return; + } + log('Running react-native codegen...'); + try { + runCodegenAndInstallTemplate(projectRoot, appRoot, reactNativeRoot, {log}); + } catch { + logError('Codegen failed. Continuing anyway...'); + } +} + +/** + * Walks autolinking.json and writes a Package.swift into each community RN + * package that ships a podspec but no SPM manifest. Reuses the dep's + * podspec (via `pod ipc spec` when available) so the scaffolded file + * captures the dep's actual sources, header search paths, frameworks, and + * dependencies. Files carrying the scaffolder's own marker are regenerated + * when the cache slot changes (manifest-hash bump); files without the + * marker are left alone (upstream-shipped or user-managed). + * + * Runs as part of `init` / `update` / `scaffold` actions. Each invocation + * is a no-op for deps already in a clean state. + */ +// Scaffolding is an EXPLICIT, manual step (`npx react-native spm scaffold`) and +// is NEVER run automatically by init/update/sync. A missing Package.swift is a +// real gap that must surface as a hard build error (see reportMissingManifests) +// so the user fixes it deliberately: scaffold, then persist with patch-package +// (node_modules is not committed), and ideally get it fixed upstream. There is +// intentionally no prompt and no auto-restore — auto-scaffolding would hide the +// error, and a wiped scaffold SHOULD re-surface it. +async function runScaffold( + args /*: SetupArgs */, + appRoot /*: string */, + projectRoot /*: string */, + reactNativeRoot /*: string */, +) /*: Promise */ { + // Resolve the cache slot identifier so the scaffolded files carry it as + // a comment — that's how SPM's manifest hash bumps on slot transitions. + let cacheSlotLabel /*: ?string */ = null; + try { + const rawVersion = args.version ?? determineVersion(args, reactNativeRoot); + const slotVersion = await resolveCacheSlotVersion(rawVersion); + cacheSlotLabel = `${slotVersion}/dual-flavor`; + } catch { + // Without a slot label the scaffolder still works; the file just + // doesn't get the slot-bump comment. + } + + let results; + try { + results = scaffoldAll({ + appRoot, + projectRoot, + reactNativeRoot, + cacheSlotLabel, + // Always force a re-render so re-running after editing a podspec picks + // up the new content. + force: true, + }); + } catch (e) { + logError(`scaffold failed: ${e.message}.`); + process.exitCode = 1; + return; + } + + const written = results.filter(r => r.status === 'written'); + const errored = results.filter(r => r.status === 'error'); + const warned = results.filter( + r => r.status === 'written' && r.warnings && r.warnings.length > 0, + ); + + if (written.length > 0) { + log(`Scaffolded Package.swift for ${written.length} dep(s):`); + for (const r of written) { + log(` • ${r.depName}`); + } + log(''); + log( + 'node_modules is NOT committed and is wiped by `npm install`. To keep\n' + + 'these manifests, create and commit a patch with a tool like patch-package:\n' + + ' • `npx patch-package ` for each scaffolded dep, then commit the patch.\n' + + 'Also consider asking the maintainer to ship a Package.swift upstream.\n' + + 'Without a committed patch the build will hard-error again after a fresh install.', + ); + log(''); + } + + for (const r of warned) { + if (r.status !== 'written') continue; + for (const w of r.warnings) { + log(` ! ${r.depName}: ${w}`); + } + } + + for (const r of errored) { + if (r.status !== 'error') continue; + logError(` ! ${r.depName}: ${r.reason}`); + } +} + +async function ensureArtifactFlavor( + args /*: SetupArgs */, + rawVersion /*: string */, + slotVersion /*: string */, + flavor /*: 'debug' | 'release' */, +) /*: Promise */ { + if (args.artifacts != null && args.artifacts.endsWith('.xcframework')) { + throw new Error( + '--artifacts must be a directory containing debug/ and release/ slots; ' + + 'a single XCFramework cannot satisfy automatic switching', + ); + } + const resolvedArtifactsDir = + args.artifacts != null + ? path.resolve(args.artifacts, flavor) + : defaultCacheDir(slotVersion, flavor); + + if (args.downloadPolicy === 'force' && resolvedArtifactsDir != null) { + log('Clearing cached artifacts (--download force)...'); + fs.rmSync(resolvedArtifactsDir, {recursive: true, force: true}); + } + + if (args.downloadPolicy === 'skip') { + const error = validateArtifactsCache(resolvedArtifactsDir); + if (error != null) { + throw new Error( + `--download skip requires a complete ${flavor} slot: ${error}`, + ); + } + log(`Using ${flavor} artifacts (--download skip)`); + return resolvedArtifactsDir; + } + + // Validate the cache before trusting it. A bare existsSync(artifacts.json) + // check would accept a partial write from a prior failed download (e.g. + // hermes-engine 404 on a not-yet-published nightly) and silently propagate + // the gap into the xcodeproj, surfacing only as "Missing package product" + // in Xcode. validateArtifactsCache reads the JSON and confirms every + // REQUIRED_ARTIFACT has a present xcframework on disk. + const cacheError = validateArtifactsCache(resolvedArtifactsDir); + if (cacheError == null) { + log( + `${flavor} artifacts already present in ${displayPath(resolvedArtifactsDir)}`, + ); + return resolvedArtifactsDir; + } + log(`Cache incomplete (${cacheError}); re-downloading...`); + log(`Downloading ${flavor} xcframework artifacts (slot: ${slotVersion})...`); + await downloadArtifacts([ + '--version', + rawVersion, + '--flavor', + flavor, + '--output', + resolvedArtifactsDir, + ]); + const downloadedError = validateArtifactsCache(resolvedArtifactsDir); + if (downloadedError != null) { + throw new Error( + `downloaded ${flavor} slot is incomplete: ${downloadedError}`, + ); + } + return resolvedArtifactsDir; +} + +async function ensureBothArtifactFlavors( + args /*: SetupArgs */, + version /*: string */, +) /*: Promise<{debug: string, release: string}> */ { + // Resolve aliases/nightly labels exactly once so both flavors are guaranteed + // to come from the same selected artifact version. + const rawVersion = args.version ?? version; + const slotVersion = await resolveCacheSlotVersion(rawVersion); + const debug = await ensureArtifactFlavor( + args, + rawVersion, + slotVersion, + 'debug', + ); + const release = await ensureArtifactFlavor( + args, + rawVersion, + slotVersion, + 'release', + ); + return {debug, release}; +} + +function generateXcframeworksPackage( + args /*: SetupArgs */, + appRoot /*: string */, + reactNativeRoot /*: string */, + version /*: string */, + artifactDirs /*: {debug: string, release: string} */, +) { + log('Generating xcframeworks sub-package...'); + const packageArgs = [ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + '--version', + version, + ]; + packageArgs.push('--debug-artifacts-dir', artifactDirs.debug); + packageArgs.push('--release-artifacts-dir', artifactDirs.release); + generatePackage(packageArgs); +} + +// True when the chosen pbxproj is still CocoaPods-integrated (its build configs +// layer a `Pods-*.xcconfig`) — the real blocker for SPM injection. +function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { + try { + const t = fs.readFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + 'utf8', + ); + return /\bPods[-/][^\n]*\.xcconfig\b/.test(t); + } catch { + return false; + } +} + +// True when the Podfile still declares React Native integration — a latent +// landmine even after `pod deintegrate` (a future `pod install` re-breaks the +// SPM graph). Warned about (not refused) once the pbxproj itself is clean. +function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { + const podfilePath = path.join(appRoot, 'Podfile'); + if (!fs.existsSync(podfilePath)) { + return false; + } + return /use_react_native!|use_native_modules!|prepare_react_native_project!/.test( + fs.readFileSync(podfilePath, 'utf8'), + ); +} + +// True when the Podfile declares any explicit `pod '...'` (third-party pods). +function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { + const podfilePath = path.join(appRoot, 'Podfile'); + if (!fs.existsSync(podfilePath)) { + return false; + } + return /^\s*pod\s+['"]/m.test(fs.readFileSync(podfilePath, 'utf8')); +} + +// The zero-arg "fresh project" safe-gate: auto-`add --deintegrate` ONLY when it +// is provably safe — a first-run CocoaPods RN project with a stock Podfile (no +// third-party pods) whose pbxproj AND Podfile are git-tracked and clean, so the +// conversion is fully revertible. We check only the two files `deintegrate` +// mutates (not the whole tree) — a fresh app typically has a dirty +// node_modules/lockfile/patches after `npm install` + `spm scaffold`, none of +// which affect the revertibility of the CocoaPods → SwiftPM conversion. +// Otherwise false → strict `add` (which fails loud on a CocoaPods project). +function shouldAutoDeintegrate( + appRoot /*: string */, + xcodeprojPath /*: string | null */, +) /*: boolean */ { + if (xcodeprojPath == null || !pbxprojUsesCocoaPods(xcodeprojPath)) { + return false; + } + if (podfileHasThirdPartyPods(appRoot)) { + return false; + } + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (gitTrackedAndClean(appRoot, pbxprojPath) !== true) { + return false; + } + const podfilePath = path.join(appRoot, 'Podfile'); + if ( + fs.existsSync(podfilePath) && + gitTrackedAndClean(appRoot, podfilePath) !== true + ) { + return false; + } + return true; +} + +// Run `pod deintegrate` then strip React Native from the Podfile (leaving any +// non-RN pods). Requires CocoaPods on PATH (fail-loud otherwise). Flag-gated ⇒ +// no prompt ⇒ CI-safe. Does NOT touch the .xcworkspace. +function runDeintegrate(appRoot /*: string */) /*: void */ { + try { + execFileSync('pod', ['--version'], {stdio: 'ignore'}); + } catch { + logError( + '`--deintegrate` needs CocoaPods (`pod`) on PATH. Remove the React ' + + 'Native integration from your project manually, then run `spm add`.', + ); + process.exitCode = 1; + throw new Error('pod not found'); + } + log('Running `pod deintegrate`...'); + execFileSync('pod', ['deintegrate'], {cwd: appRoot, stdio: 'inherit'}); + + const podfilePath = path.join(appRoot, 'Podfile'); + if (fs.existsSync(podfilePath)) { + const orig = fs.readFileSync(podfilePath, 'utf8'); + const stripped = orig + .split('\n') + .filter( + l => + !/use_react_native!|use_native_modules!|prepare_react_native_project!/.test( + l, + ), + ) + .join('\n'); + if (stripped !== orig) { + fs.writeFileSync(podfilePath, stripped, 'utf8'); + log('Stripped React Native integration from Podfile.'); + } + } +} + +// Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place +// target (re-run) > the single .xcodeproj in appRoot. Returns an error string +// (ambiguous / none) so the caller fails loud. +function resolveInjectionTarget( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: {path: string, error?: void} | {error: string, path?: void} */ { + if (args.xcodeprojPath != null) { + const p = path.resolve(appRoot, args.xcodeprojPath); + return fs.existsSync(p) + ? {path: p} + : {error: `--xcodeproj not found: ${p}`}; + } + const injected = findInjectedXcodeproj(appRoot); + if (injected != null) { + return {path: injected}; + } + const names /*: Array */ = []; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(appRoot, {withFileTypes: true}); + } catch {} + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (name.endsWith('.xcodeproj')) { + names.push(name); + } + } + if (names.length === 0) { + return { + error: + 'no .xcodeproj found. Create an app first (e.g. `npx ' + + '@react-native-community/cli init`) or make one in Xcode, then `spm add`.', + }; + } + if (names.length > 1) { + return { + error: `multiple .xcodeproj found (${names.join(', ')}); pass --xcodeproj to pick one.`, + }; + } + return {path: path.join(appRoot, names[0])}; +} + +/** + * Inject SPM packages into the user's existing .xcodeproj, in place — the only + * xcodeproj strategy (`add` and `update` both run this; there is no + * from-scratch generation). Fails loud rather than silently retargeting. With + * `--deintegrate`, removes CocoaPods first. + */ +async function setupXcodeproj( + args /*: SetupArgs */, + appRoot /*: string */, + reactNativeRoot /*: string */, + action /*: string */, +) /*: Promise */ { + const target = resolveInjectionTarget(args, appRoot); + if (target.error != null) { + logError(`Cannot set up SPM: ${target.error}`); + process.exitCode = 1; + throw new Error(target.error); + } + const xcodeprojPath = target.path; + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + + // Snapshot the pbxproj's git state BEFORE deintegrate — `pod deintegrate` + // rewrites it (removing the Pods xcconfig layering), so checking afterward + // would always look dirty and trigger a spurious confirmation prompt. + const cleanBeforeEdits = gitTrackedAndClean(appRoot, pbxprojPath); + + if (args.deintegrate) { + runDeintegrate(appRoot); + // `pod deintegrate` strips the build integration but can leave an empty + // `Pods` group in the navigator — remove it so the converted project is + // visually clean. + if (cleanupLeftoverPodsGroup(xcodeprojPath)) { + log('Removed the leftover empty `Pods` group from the project.'); + } + } + + // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. + if (pbxprojUsesCocoaPods(xcodeprojPath)) { + logError( + `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + + '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + + 'strips React Native from the Podfile), or run `pod deintegrate` ' + + 'yourself first. Side-by-side non-RN pods are fine.', + ); + process.exitCode = 1; + throw new Error('CocoaPods-integrated project'); + } + if (podfileHasRnIntegration(appRoot)) { + log( + '\x1b[33mNote: your Podfile still declares React Native integration. ' + + 'Remove it and avoid `pod install`, or it will re-break the SPM ' + + 'package graph.\x1b[0m', + ); + } + + // No backup is made — git is the safety net. Refuse on a dirty/untracked + // pbxproj (as it was BEFORE any deintegrate edits) unless --yes, so a bad + // inject is always `git checkout`-able. + const clean = cleanBeforeEdits; + if (clean === false && !args.yes) { + const proceed = await promptYesNo( + `${path.basename(xcodeprojPath)} has uncommitted changes and no ` + + `backup is made (git is the only undo). Inject SPM packages anyway?`, + false, + ); + if (!proceed) { + log('Aborted. Commit or stash the project, then re-run `spm add`.'); + process.exitCode = 1; + throw new Error('In-place injection declined (dirty working tree)'); + } + } else if (clean === null) { + log( + `\x1b[33mNote: ${path.basename(xcodeprojPath)} is not in a git ` + + `repo — no backup is made before in-place injection.\x1b[0m`, + ); + } + + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot, + xcodeprojPath, + appName: args.productName, + // Only an EXPLICIT `--version` pins the artifacts-cache slot in the + // marker; omitting it (null) leaves any previously-recorded pin alone + // (injectSpmIntoExistingXcodeproj preserves it — see + // generate-spm-xcodeproj.js). + artifactsVersionOverride: args.version ?? null, + }); + if (result.status !== 'injected') { + logError(`SPM injection failed: ${result.reason}`); + process.exitCode = 1; + throw new Error(result.reason); + } + + // The community template has carried a dangling `JavaScriptCore.framework` + // reference since RN 0.60 (navigator-only, unlinked, meaningless under + // Hermes) — one-time migration hygiene at conversion time, like the empty + // Pods group above. `add` only; `update` stays a minimal re-sync. + if (action === 'add' && cleanupDanglingJavaScriptCoreRef(xcodeprojPath)) { + log( + "Removed the template's dangling JavaScriptCore.framework reference " + + '(unused; React Native uses Hermes).', + ); + } +} + +// True when `git status --porcelain` reports the path dirty/untracked. Returns +// null when git is unavailable or the path is outside a repo (no safety net). +function gitTrackedAndClean( + appRoot /*: string */, + targetPath /*: string */, +) /*: boolean | null */ { + try { + const out = execFileSync( + 'git', + ['status', '--porcelain', '--', targetPath], + {cwd: appRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}, + ); + return out.trim() === ''; + } catch { + return null; // not a git repo / git missing + } +} + +function logNextSteps( + projectRoot /*: string */, + appRoot /*: string */, + productName /*: string | null */, +) { + const appPkgJson = readPackageJson(projectRoot); + const rawName = + (appPkgJson != null ? appPkgJson.name : null) ?? path.basename(projectRoot); + const sourcePath = findSourcePath(appRoot, rawName); + const appDisplayName = productName ?? deriveAppName(rawName, sourcePath); + + log(''); + log('SPM setup complete!'); + log(''); + log('Next steps:'); + log(` • Open ${appDisplayName}.xcodeproj in Xcode (or \`npm run ios\`)`); + log(' • Set your Development Team in Signing & Capabilities'); + log(' • Build and run on Simulator or device'); + log(''); + log('To remove SPM later: `npx react-native spm deinit`'); +} + +async function main(argv /*:: ?: Array */) /*: Promise */ { + let appRoot = process.cwd(); + const projectRoot = findProjectRoot(appRoot); + const args = parseArgs(argv ?? process.argv.slice(2)); + + // Standard-RN-layout redirect: if invoked from the JS root and there's an + // `ios/` subdir, route the run there. Runs BEFORE resolveAction so the + // first-run heuristic checks the correct directory. + const redirectTo = detectStandardRnLayoutRedirect(appRoot, projectRoot); + if (redirectTo != null) { + const redirectAction = args.action ?? 'add'; + log( + `\x1b[33mDetected standard RN layout — running ${redirectAction} in ${displayPath(redirectTo)} ` + + `instead of ${displayPath(appRoot)}.\x1b[0m`, + ); + appRoot = redirectTo; + } + + const action = resolveAction(args.action, appRoot); + + // Zero-arg safe-gate: when `add` was resolved implicitly (no action typed) + // on a freshly-scaffolded CocoaPods project, imply `--deintegrate` so the + // common "new app → SPM" path is one command. Never on an explicit `spm add` + // (that stays strict and fails loud on CocoaPods). + if ( + action === 'add' && + args.action == null && + !args.deintegrate && + shouldAutoDeintegrate( + appRoot, + resolveInjectionTarget(args, appRoot).path ?? null, + ) + ) { + log( + 'Detected a freshly-scaffolded CocoaPods project — converting to SwiftPM ' + + '(running `pod deintegrate`). Revert with `git` or `spm deinit`.', + ); + args.deintegrate = true; + } + + log(`Running SPM ${action} in: ${displayPath(appRoot)}`); + if (projectRoot !== appRoot) { + log(`Project root (package.json): ${displayPath(projectRoot)}`); + } + + if (action === 'deinit') { + const xcodeprojPath = + args.xcodeprojPath != null + ? path.resolve(appRoot, args.xcodeprojPath) + : findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + log('No SPM injection found — nothing to remove.'); + return; + } + const result = removeSpmInjection({appRoot, xcodeprojPath}); + log( + result.status === 'removed' + ? `Removed SPM packages from ${path.basename(xcodeprojPath)}.` + : 'No SPM injection found — nothing to remove.', + ); + return; + } + + // Fail fast on a CocoaPods-integrated target BEFORE the (expensive) pipeline, + // so the user isn't made to wait through codegen + artifact download only to + // be told to re-run with --deintegrate. Skipped when --deintegrate is set + // (explicitly or via the safe-gate) — deintegration happens in setupXcodeproj. + if ((action === 'add' || action === 'update') && !args.deintegrate) { + const target = resolveInjectionTarget(args, appRoot); + if (target.path != null && pbxprojUsesCocoaPods(target.path)) { + logError( + `${path.basename(target.path)} is CocoaPods-integrated. Re-run ` + + '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + + 'strips React Native from the Podfile), or run `pod deintegrate` ' + + 'yourself first. Side-by-side non-RN pods are fine.', + ); + process.exitCode = 1; + return; + } + } + + const needsCliConfig = + action === 'add' || + action === 'update' || + action === 'sync' || + action === 'scaffold'; + let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null; + if (needsCliConfig) { + log('Generating autolinking.json (CLI config)...'); + try { + autolinkingConfigResult = generateAutolinkingConfig({projectRoot}); + log( + `Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`, + ); + } catch (e) { + logError( + `generate-spm-autolinking-config failed: ${e.message}. External native modules may not be discovered.`, + ); + } + } + const reactNativeRoot = resolveReactNativeRoot( + autolinkingConfigResult, + projectRoot, + ); + const version = determineVersion(args, reactNativeRoot); + log(`React Native version: ${version}`); + + // Resolve remote SPM mode ONCE up front. remotePackageConfig throws + // RemoteVersionError when remote mode is active but no usable RN version can + // be derived (e.g. the monorepo '1000.0.0' placeholder with no override). + // The downstream scaffold/autolinker/package steps all call it internally; + // surfacing it here gives a single, predictable failure point before any of + // them run. Exit 2 (same as a missing manifest) so the Xcode build phase + // turns it into a hard build error while staying lenient on transient sync + // failures. No-op in local mode (returns null). + try { + remotePackageConfig(appRoot); + } catch (e) { + if (e instanceof RemoteVersionError) { + logError(e.message); + process.exitCode = 2; + return; + } + throw e; + } + // The artifact cache directory is resolved later in ensureArtifacts so the + // nightly hash can be folded in for dev / nightly labels. That branch logs + // either "Downloading xcframework artifacts (slot: ...)" or + // "Artifacts already present in ...". + + if (action === 'codegen') { + runCodegenStep(projectRoot, appRoot, reactNativeRoot, false); + return; + } + + if (action === 'sync') { + const {main: runSync} = require('./spm/sync-spm-autolinking'); + try { + await runSync([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + } catch (e) { + if (e instanceof MissingManifestError) { + // The per-dep `error:` lines were already printed by the autolinker. + // Exit 2 (distinct from generic failure) so the Xcode build phase can + // turn this into a hard build error while staying lenient on transient + // sync failures. + process.exitCode = 2; + } else { + logError(`SPM sync failed: ${e.message}`); + process.exitCode = 1; + } + } + return; + } + + if (action === 'download') { + try { + await ensureBothArtifactFlavors(args, version); + } catch (e) { + logError(`Artifact setup failed: ${e.message}`); + process.exitCode = 1; + } + return; + } + + // Scaffold Package.swift for community RN packages that don't ship SPM + // support — ONLY for the explicit `scaffold` action. init/update never + // auto-scaffold: a missing manifest must surface as a hard error (the + // autolinker below throws MissingManifestError → exit 2) so the gap is + // visible and fixed deliberately (scaffold + patch-package, or upstream). + // Auto-scaffolding would silently hide that real error. + if (action === 'scaffold') { + await runScaffold(args, appRoot, projectRoot, reactNativeRoot); + } + + runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen); + log('Generating build/generated/autolinking/Package.swift...'); + try { + generateAutolinking([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + } catch (e) { + if (e instanceof MissingManifestError) { + // Per-dep `error:` lines already printed by the autolinker. This happens + // on init/update when the user declined the scaffold prompt — surface it + // as a hard failure (exit 2) directing them to scaffold. + process.exitCode = 2; + } else { + logError(`generate-spm-autolinking.js failed: ${e.message}`); + process.exitCode = 1; + } + return; + } + + const remote = remotePackageConfig(appRoot); + if (remote != null) { + log(`Remote ReactNative package: ${remote.url} @ ${remote.version}`); + } + let artifactDirs; + try { + artifactDirs = await ensureBothArtifactFlavors(args, version); + } catch (e) { + logError(`Artifact setup failed: ${e.message}`); + process.exitCode = 1; + return; + } + try { + generateXcframeworksPackage( + args, + appRoot, + reactNativeRoot, + version, + artifactDirs, + ); + } catch (e) { + logError(`generate-spm-package.js failed: ${e.message}`); + process.exitCode = 1; + return; + } + + // (Re)install the static codegen Package.swift template once build/generated/ios exists. + installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + + // Build the per-app generated-headers farm (vended as the ReactAppHeaders + // SPM target inside the codegen package). React core headers need no trees + // — they are vended by invariant SwiftPM compile products. The + // generated manifests are fully declarative (fixed-relative package paths), + // so no path-locator JSON is written. + buildPerAppHeaderTree(appRoot, {log}); + + // First-time setup only adds the gitignore entries on `add`. + if (action === 'add') { + ensureGitignoreSpmEntries(appRoot); + } + + // Xcodeproj setup: in-place injection into the existing project (the only + // strategy — no rename, no from-scratch; git is the safety net). + try { + await setupXcodeproj(args, appRoot, reactNativeRoot, action); + } catch (e) { + logError(`xcodeproj setup failed: ${e.message}`); + if (process.exitCode == null) { + process.exitCode = 1; + } + return; + } + + finalizeArtifactPublication(appRoot); + + logNextSteps(projectRoot, appRoot, args.productName); +} + +if (require.main === module) { + void main(); +} + +module.exports = { + main, + detectStandardRnLayoutRedirect, + findInjectedXcodeproj, + resolveAction, + shouldAutoDeintegrate, + ensureBothArtifactFlavors, +}; diff --git a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md new file mode 100644 index 000000000000..86116637e3f7 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md @@ -0,0 +1,707 @@ +--- +title: Swift Package Manager Support for React Native iOS +author: +- Christian Falch +date: 2026-03-17 +--- + +# RFC: Swift Package Manager Support for React Native iOS + +## Summary + +Add Swift Package Manager (SPM) as an officially supported build system for +React Native iOS apps, alongside CocoaPods. The approach uses **prebuilt +XCFrameworks** published to Maven, eliminating the need for source compilation +of React Native internals and enabling fast, reproducible builds. + +## Basic example + +### New project + +```bash +npx react-native init MyApp +cd MyApp +npx react-native spm # auto-detects first-run → init; prompts to rename legacy CocoaPods xcodeproj +npm run ios +``` + +A future CLI integration (e.g., an `--ios-build-system spm` flag on +`react-native init`) could run `react-native spm init` automatically as part +of project creation, eliminating the manual step. + +### Existing project + +```bash +cd MyApp +npx react-native spm +# Prompted: rename CocoaPods MyApp.xcodeproj → MyApp.xcodeproj.legacy? +# Accept (Y) — the SPM xcodeproj writes to the now-free MyApp.xcodeproj slot, +# `npm run ios` resolves to it unambiguously. The legacy stays on disk +# (git mv tracks the rename cleanly) for rollback via `spm clean --project`. +``` + +After initial setup, day-to-day development requires no extra commands. Adding +or removing JS dependencies that include native code is handled automatically +by a build-phase sync step (see [Auto-sync build phase](#auto-sync-build-phase)). + +## Motivation + +### Apple is moving away from CocoaPods + +SPM is Apple's endorsed dependency manager. Xcode's SPM integration improves +with every release — package resolution, build caching, and IDE features all +assume SPM as the primary workflow. CocoaPods is community-maintained and has +been officially sunsetted — the CocoaPods trunk will become permanently +read-only on **December 2, 2026**, after which no new pods or updates can be +published ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)). +Existing builds will continue to work, but the ecosystem is moving on. + +### Build speed + +Prebuilt XCFrameworks skip compilation of ~2000 C++/Objective-C files. A clean +SPM build of rn-tester compiles only app sources and codegen output. This is a +significant improvement for CI pipelines and developer iteration speed. + +### Reduced onboarding friction + +CocoaPods requires Ruby, Bundler, and a working gem environment — a frequent +source of setup issues, especially on new machines or in CI. SPM requires only +Xcode. Removing the Ruby toolchain dependency simplifies onboarding and reduces +the surface area for environment-related build failures. + +### Adoption barrier + +Many organizations mandate SPM for iOS dependencies. Teams in these +environments are currently blocked from adopting React Native, or must maintain +custom workarounds. First-class SPM support might help overcoming this barrier. + +### Compatibility + +The SPM workflow generates an `AppName.xcodeproj` that takes the same +filename slot as the legacy CocoaPods xcodeproj. On `init`, the script +prompts to rename the existing CocoaPods project to `AppName.xcodeproj.legacy` +— preserving it for rollback while letting the community CLI's +`findXcodeProject` resolve `npm run ios` to the SPM project unambiguously. +Teams can migrate at their own pace before CocoaPods trunk goes read-only +in December 2026, and `spm clean --project` reverses the migration when +needed. + +## Detailed design + +### Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Maven (artifacts) │ +│ ├── React.xcframework (~200 MB, debug) │ +│ ├── ReactNativeDependencies.xcframework │ +│ └── hermes-engine.xcframework │ +└──────────────────┬──────────────────────────────┘ + │ download + cache + ▼ +┌─────────────────────────────────────────────────┐ +│ ~/Library/Caches/com.facebook.ReactNative/ │ +│ └── spm-artifacts/{version}/{flavor}/ │ +└──────────────────┬──────────────────────────────┘ + │ symlink + ▼ +┌──────────────────────────────────────────────────┐ +│ App ios/ │ +│ ├── AppName.xcodeproj/ (committed) │ +│ │ └── .spm-managed (marker file) │ +│ ├── AppName.xcodeproj.legacy/ (committed if │ +│ │ rename was │ +│ │ accepted) │ +│ └── build/ │ +│ ├── generated/ │ +│ │ ├── autolinking/ (generated) │ +│ │ │ ├── Package.swift │ +│ │ │ ├── autolinking.json │ +│ │ │ ├── packages/ (synth wrappers) │ +│ │ │ └── libs/ (alias symlinks │ +│ │ │ for self-managed│ +│ │ │ deps; basename │ +│ │ │ = SwiftName) │ +│ │ └── ios/ (codegen) │ +│ └── xcframeworks/ (symlinks) │ +│ ├── Package.swift │ +│ ├── React.xcframework -> cache │ +│ ├── ReactNativeDependencies.xcframework │ +│ └── hermes-engine.xcframework │ +└──────────────────────────────────────────────────┘ +``` + +### Pipeline + +`react-native spm` orchestrates six steps (the underlying script is +`scripts/setup-apple-spm.js`): + +| # | Step | Script | Output | +|---|------|--------|--------| +| 1 | CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2 | Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3 | Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` + source symlinks | +| 4 | Download | `spm/download-spm-artifacts.js` | Cached xcframeworks | +| 5 | Package | `spm/generate-spm-package.js` | `build/xcframeworks/Package.swift` + symlinks | +| 6 | Xcodeproj | `spm/generate-spm-xcodeproj.js` | `AppName.xcodeproj` + `.spm-managed` marker (`init` only; create-if-missing on subsequent runs) | +| — | Sync (build-time) | `spm/sync-spm-autolinking.js` | Re-runs steps 1–5 when inputs change (downloads artifacts if missing) | + +The `init` action additionally (a) prompts to rename any existing +CocoaPods `.xcodeproj` to `.xcodeproj.legacy` before step 6, and +(b) appends SPM-specific entries to `.gitignore` +(`build/generated/`, `build/xcframeworks/`, `.build/`, `Package.resolved`). +Existing entries are not duplicated. + +### Auto-sync build phase + +After initial setup, developers shouldn't need to re-run `react-native spm` +manually when dependencies change. The generated `.xcodeproj` includes a +**Sync SPM Autolinking** pre-build phase (ordered first, before VFS overlay) +that: + +1. Checks whether xcframework artifacts are missing (`artifacts.json` or + `React.xcframework` absent). This covers fresh clones where no setup + script has been run yet. +2. Compares timestamps of `package.json`, `react-native.config.js`, and the + `node_modules` directory against `autolinked/.spm-sync-stamp`. In + monorepos where `node_modules` is hoisted, the parent directory is also + checked. +3. If any check triggers (or the stamp is missing): sources `with-environment.sh` + for node PATH, then runs `spm/sync-spm-autolinking.js` which re-executes + codegen, artifact download (if needed), autolinking, and package generation. +4. If all inputs are fresh: exits immediately (~1ms shell check). + +Failures emit `warning:` and exit 0 — the existing autolinking may still be +valid. The stamp file is written on successful sync. + +The sync step handles React Native version changes automatically: after +`npm install` pulls a new version, the `node_modules` mtime changes, the sync +step regenerates autolinking and recreates xcframework symlinks pointing to the +new version's cache directory. + +The sync step is **self-healing**: if xcframework artifacts are missing (e.g., +the local cache at `~/Library/Caches/com.facebook.ReactNative/` was deleted, +or the project was freshly cloned), it automatically downloads them before +proceeding with autolinking and package generation. This means `react-native spm` +is only strictly required for initial project scaffolding (`init`); subsequent +builds recover automatically. + +### Cleaning generated SPM state + +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does +not touch the project's `build/` or `.build/` directories. Xcode provides no +hook to run custom scripts during GUI clean actions. + +`react-native spm clean` is scoped by opt-in flags. The default removes only +generated dirs under `appRoot`: + +```bash +react-native spm clean # build/xcframeworks/, build/generated/, .build/ +react-native spm clean --project # also: delete SPM xcodeproj, restore .legacy backup +react-native spm clean --derived-data # also: this app's Xcode DerivedData entries +react-native spm clean --cache # also: cached xcframework slot for current version +react-native spm clean --all # = --project --derived-data --cache +``` + +Destructive scopes (`--project`, `--derived-data`, `--cache`, `--all`) prompt +for confirmation (bypass with `--yes`). `--project` is the reverse of the +init-time rename migration — deleting the SPM xcodeproj and restoring +`.xcodeproj.legacy` to its original filename if a backup exists. + +After a plain `clean`, run `react-native spm update` (or open the checked-in +`.xcodeproj` and build) to regenerate state. SPM package resolution is locked +for the duration of a build — if only stubs were left in place, Xcode would +resolve stubs and never pick up the real packages generated by the sync build +phase. + +### Stub packages for fresh clones + +Xcode resolves SPM packages **before** any build phase runs. On a fresh clone, +the referenced package directories (`build/xcframeworks`, `autolinked`, +`build/generated/ios`) may not exist yet, causing package resolution to fail. + +To solve this, `generate-spm-xcodeproj.js` writes **stub `Package.swift` +files** into each referenced sub-package directory that doesn't already have +one. Each stub defines the expected library products backed by a minimal +placeholder target (`.stub/Stub.swift`). This lets Xcode resolve packages +successfully even before the first build. On the first build, the auto-sync +build phase overwrites the stubs with real Package.swift files generated from +downloaded artifacts and autolinking output. + +### Caching and CI + +Xcframeworks are cached at +`~/Library/Caches/com.facebook.ReactNative/spm-artifacts/{version}/{flavor}/` +by default. The download step accepts a `--output` flag to write xcframeworks +to an explicit directory. + +For CI pipelines (GitHub Actions, CircleCI, etc.), cache the default path +keyed by the React Native version and flavor to avoid re-downloading +xcframeworks on every build. + +The Maven base URL can be overridden via the `ENTERPRISE_REPOSITORY` +environment variable for teams that mirror artifacts to an internal registry. + +**Planned:** A `RN_SPM_CACHE_DIR` environment variable to override the default +cache directory. This is not yet implemented in the current POC but is needed +for CI environments where a specific path must be persisted across builds. + +### Package graph + +The generated `.xcodeproj` references three local packages directly +via `XCLocalSwiftPackageReference` — no app-level `Package.swift` is required: + +``` +AppName.xcodeproj + ├── XCLocalSwiftPackageReference → build/xcframeworks/Package.swift + │ ├── ReactNative (product, wraps React binaryTarget) + │ ├── ReactNativeDependencies (binaryTarget) + │ └── hermes-engine (binaryTarget) + ├── XCLocalSwiftPackageReference → build/generated/ios/Package.swift + │ ├── ReactCodegen (target — codegen output) + │ └── ReactAppDependencyProvider (target) + └── XCLocalSwiftPackageReference → build/generated/autolinking/Package.swift + └── ... (targets — symlinked sources) +``` + +These three sub-package paths are **stable**: adding or removing community +deps changes the contents of `build/generated/autolinking/Package.swift` +(gitignored) but never the xcodeproj's references. That's why the +`.xcodeproj` is committed once and not regenerated on subsequent runs. + +The xcodeproj generation is **create-if-missing** on `update` (use +`--force-xcodeproj` for an explicit overwrite). This protects user-side +Xcode edits — signing, capabilities, Build Phases, scheme settings — from +being clobbered. Teammates can clone the repo and open Xcode immediately: +stub `Package.swift` files in each sub-package directory let SPM resolution +succeed before the first build, and the auto-sync build phase downloads +artifacts and writes the real sub-packages on first compile. + +### Header resolution + +React Native uses CocoaPods-style imports (`#import `) that +SPM does not natively support. Two mechanisms solve this: + +1. **XCFramework `Headers/` layout.** The prebuild step organizes headers by + `header_dir` (e.g., `Headers/React/`, `Headers/react/renderer/core/`). + Adding `-I Headers` to search paths resolves most imports directly. + +2. **VFS overlay.** A Clang virtual filesystem overlay (`React-VFS.yaml`) + remaps remaining edge cases — headers that appear in multiple pods or have + platform variants. The overlay is generated as a template at prebuild time + and resolved with local paths at setup time. + +### Local native modules + +Modules not discovered via autolinking (e.g., app-specific native modules) are +declared in `react-native.config.js`: + +```js +// react-native.config.js +module.exports = { + spmModules: [ + { + name: 'MyNativeModule', // SPM target name + path: 'ios/MyNativeModule', // path to source files + exclude: ['*.podspec'], // files to exclude from the target + publicHeadersPath: '.', // header search path for consumers + }, + ], +}; +``` + +Each entry becomes a target in `autolinked/Package.swift`. Sources outside the +autolinked directory are mirrored with **file-level symlinks** (SPM rejects +directory symlinks that resolve outside the package root). + +### Self-managed deps and package identity + +A community library that ships its own `Package.swift` (instead of being +wrapped by the autolinker) is referenced directly. SPM derives the package +identity for a `.package(path:)` dependency from the path's basename — and +a common convention is to ship the manifest inside an `ios/` subdir +(`/ios/Package.swift`). Two libs following that convention would both +have identity `"ios"`, and SPM rejects with `Conflicting identity for ios`. + +To make every reference globally unique by construction, the autolinker +materializes each self-managed dep as a symlink at +`build/generated/autolinking/libs//` pointing at the dep's real +manifest dir. The aggregator `Package.swift` then references the symlink +(`path: "libs/"`), and SPM uses the symlink basename — the +library's Swift module name — as the package identity. Swift module names +are already unique per dep (deriving from the npm package name), so this +sidesteps the collision in all cases, including against the codegen +package at `build/generated/ios/`. + +The `libs/` directory is wiped and recreated on every autolinker run, so +stale aliases for uninstalled deps disappear automatically. + +### Third-party library support + +The current implementation handles React Native's own frameworks and app-local +native modules. The primary goal for third-party libraries is to **build using +SPM**. Shipping prebuilt xcframeworks is the recommended approach for faster +builds, but it is not a requirement — libraries can also be compiled from +source via SPM targets. This ensures that library authors with limited +resources can support SPM without needing to set up a prebuild CI pipeline. + +#### Library metadata in `react-native.config.js` + +`react-native.config.js` is the canonical place for library SPM metadata. The +autolinking pipeline already scans `node_modules` for this file to discover +iOS and Android native modules. Adding SPM config alongside the existing +`dependency.platforms.ios` keeps a single source of truth, requires no new +discovery mechanism, and can express things `Package.swift` cannot — such as +Maven URL templates with version and flavor placeholders for downloading +prebuilt xcframeworks. Libraries may still ship a `Package.swift` for direct +SPM consumers outside the React Native ecosystem, but React Native autolinking +reads `react-native.config.js`. + +#### Prebuilt xcframeworks (primary path) + +React Native already prebuilds its core into xcframeworks and publishes them to +Maven. This is the model we want every library to follow. Libraries declare SPM +metadata in `react-native.config.js`: + +```js +// react-native-maps/react-native.config.js +module.exports = { + dependency: { + platforms: { + ios: { /* existing autolinking config */ }, + }, + }, + spm: { + // Primary: prebuilt xcframework (downloaded at setup time) + xcframework: { + name: 'ReactNativeMaps', + // URL template — {version}, {rn-version}, {flavor} resolved at download time + url: 'https://maven.example.com/.../react-native-maps-{version}-xcframework-{flavor}.tar.gz', + }, + // Fallback: source compilation (used during local development or when + // xcframework is unavailable) + source: { + name: 'ReactNativeMaps', + path: 'ios', + publicHeadersPath: '.', + exclude: ['*.podspec', 'Tests/**'], + dependencies: ['MapKit'], + resources: ['ios/Resources/**'], + }, + }, +}; +``` + +**Planned (Phase 2):** When `react-native spm` gains third-party library +support, it will: +1. If `spm.xcframework` is declared, download the prebuilt binary (fast path). +2. If the download fails or the `--source` flag is passed, fall back to + `spm.source` and compile from symlinked sources. +3. If neither is declared, the library requires a manual `spmModules` entry. + +Currently, only `spmModules` entries (see [Local native modules](#local-native-modules)) +are supported. The `spm.xcframework` and `spm.source` config fields — including +the `dependencies` field shown above — are not yet implemented. + +#### Source compilation (fallback) + +Source-level autolinking (`spmModules` / `spm.source`) remains available for: +- **Local development** — library authors iterating on native code +- **Libraries without prebuilt xcframeworks** — transitional state +- **App-specific native modules** — code that lives in the app repo + +This reuses the existing `spmModules` mechanism: sources are mirrored with +file-level symlinks into `autolinked/`, compiled as SPM targets with +appropriate header search paths. + +#### `react-native-prebuild` CLI + +React Native already has a mature prebuild pipeline (`scripts/ios-prebuild/`) +that produces signed, packaged xcframeworks published to Maven. Rather than +asking library authors to reinvent this, we can expose the same tooling as a +reusable CLI: + +```bash +npx react-native-prebuild \ + --podspec ios/MyLibrary.podspec \ + --react-native-version 0.80.0 \ + --platforms ios,ios-simulator \ + --flavor release \ + --output dist/ + +# Output: +# dist/MyLibrary.xcframework.tar.gz +# dist/MyLibrary.framework.dSYM.tar.gz +``` + +The tool would: + +1. **Download React Native xcframeworks** for the specified version. +2. **Parse the library's podspec** to discover source files, headers, + `header_dir`, dependencies, and compiler flags. +3. **Generate a temporary Package.swift** declaring the library as a target + with dependencies on the RN xcframeworks. +4. **Build** using `xcodebuild` for each platform slice. +5. **Compose** the xcframework with organized headers, module map, and + optional VFS overlay. +6. **Sign** the xcframework with the developer's code signing identity. +7. **Package** as `.tar.gz` with dSYM symbols. + +The tool includes code signing as a built-in step. Library authors provide +their own signing identity (Apple Developer certificate); the tool handles +the `codesign` invocation. Unsigned xcframeworks trigger macOS Gatekeeper +warnings, so signing is strongly recommended for distributed artifacts. +Documentation will cover how to create and manage a signing identity for +this purpose. + +Library authors can integrate this into CI to publish prebuilt artifacts on +every release, targeting a matrix of React Native versions and build flavors. + +#### Version compatibility + +A library's xcframework must be built against a compatible React Native +version. The prebuild tool embeds metadata (React Native version, library +version, build flavor, minimum iOS version) inside the xcframework. The +download step verifies compatibility at setup time, warning if a library was +built against a different React Native version than the app is using. + +## Drawbacks + +### Transition period: supporting both CocoaPods and SPM + +With CocoaPods trunk going read-only in December 2026, the migration to SPM is +necessary rather than optional. During the transition period, both build +systems must be supported in parallel. Bug fixes, new features, and build-phase +changes need to be tested against both CocoaPods and SPM until CocoaPods +support is eventually removed. + +### Download size + +Prebuilt xcframeworks for React Native core are compressed as tar.gz archives. +Individual library xcframeworks are typically 1–15 MB in debug mode including +dSYM bundles. Both debug and release flavors are needed, which doubles the +total. While artifacts are cached locally after the first download, CI +environments without persistent caches will re-download on every build. + +### Ecosystem adoption takes time + +Third-party libraries must opt in to the prebuild workflow. During the +transition period, many libraries will only support CocoaPods. Apps that depend +on these libraries cannot fully migrate to SPM until the libraries catch up. +This creates a chicken-and-egg problem that may slow adoption. + +### SPM limitations require `.xcodeproj` generation + +SPM does not support build script phases, `post_install` hooks, or the kind of +build-time customization that CocoaPods provides via its Podfile DSL. The +current design works around this by generating an `.xcodeproj` with explicit +build phases for JS bundling, Hermes engine copying, VFS overlay setup, and +autolinking sync. This is a known limitation of the current approach. If Apple +expands SPM's plugin API to support arbitrary script execution with file I/O +and network access, the `.xcodeproj` could be eliminated in favor of a purely +SPM-native workflow — but this is a future direction that depends on Apple's +roadmap, not something this proposal can resolve. + +### Committed xcodeproj edits + +The generated `.xcodeproj` is committed and may carry user edits — +signing, capabilities, Build Phases, custom schemes. The `update` action is +**create-if-missing** to protect those edits, which means the script does +not propagate generator improvements into existing projects automatically. +Bug fixes that change the emitted pbxproj need an explicit +`--force-xcodeproj` run to take effect. A future improvement could +preserve user-side edits through a merge step rather than full overwrite — +see "Hardening `update --force-xcodeproj`" in unresolved questions. + +## Alternatives + +### Compile React Native from source as SPM targets + +Compiling React Native's C++/Objective-C sources from source as SPM targets is +not the default path due to the ~2000 source files and complex header layout, +which makes clean build times significantly longer. However, source +compilation support is a goal for specific use cases: + +- **Debugging React Native internals** — developers investigating bugs or + contributing fixes to React Native itself need to build from source with + debug symbols. +- **Apps requiring source patches** — projects like Expo Go that need to modify + React Native source code to build successfully, or apps that apply patches + via tools like `patch-package`. + +The source compilation path would reuse the same SPM package structure but +replace binary xcframework targets with source targets. This is planned as a +`--source` flag to `react-native spm`. + +### SPM build tool plugins + +SPM plugins were evaluated as a way to eliminate the `.xcodeproj` (see +[SPM Plugins Assessment](spm-plugins-assessment.md) for details). The key +findings: + +- **Post-build phases are impossible.** JS bundling and Hermes engine copying + run after linking to place artifacts in the `.app` bundle. SPM has no + post-build plugin capability — this is a deliberate design choice for build + reproducibility. +- **Sandbox restrictions.** Build tool plugins cannot write to the source tree, + run `node`, or access `node_modules`. Pre-build phases like autolinking sync + require all of these. +- **No Xcode build settings.** SPM plugins do not receive `CONFIGURATION`, + `BUILT_PRODUCTS_DIR`, or other settings that the JS bundling script relies on. + +A hybrid approach (some SPM plugins + some Xcode build phases) would be harder +to reason about than the current uniform approach of all Xcode build phases. +SPM plugins are not a viable alternative today. + +## Adoption strategy + +This proposal introduces SPM as an **additional** build system. It is not a +breaking change. CocoaPods continues to work exactly as before. The two +workflows coexist — an app can have both `Podfile` and `Package.swift` in the +same directory. + +### Phase 1: React Native core (current) + +SPM works for React Native core frameworks and app-local native modules +declared as `spmModules` in `react-native.config.js`. No third-party library +support. This phase validates the architecture and developer experience with +rn-tester and the helloworld template. + +### Phase 2: Library ecosystem tooling + +Ship the `react-native-prebuild` CLI. Library authors can prebuild and publish +xcframeworks for their libraries. The autolinking step reads `spm.xcframework` +from installed libraries and downloads artifacts automatically. Libraries +without xcframeworks fall back to `spm.source` (source compilation) or manual +`spmModules` entries. + +### Phase 3: Ecosystem-wide adoption + +Popular libraries ship prebuilt xcframeworks from CI. App developers get +near-zero-compilation iOS builds — only app code and codegen output are +compiled. React Native provides clear documentation and tooling +(`react-native-prebuild`) to help library authors build and publish +xcframeworks — for example, CI workflow templates and guidance on publishing to +Maven or GitHub Releases. Prebuilt xcframeworks are recommended but not +required; libraries that don't provide them fall back to source compilation. + +### Migration path for existing apps + +1. Run `npx react-native spm` from the project root (auto-redirects into + `ios/`). +2. Accept the rename prompt — your existing `AppName.xcodeproj` becomes + `AppName.xcodeproj.legacy` (preserved for rollback). +3. Commit the new `AppName.xcodeproj/` (SPM-managed) and the renamed + `AppName.xcodeproj.legacy/`. `git mv` tracks the rename cleanly. +4. Run `npm run ios` and verify the SPM build. +5. Once validated, optionally delete the `.legacy` backup, `Podfile`, + `Pods/`, and `.xcworkspace`. + +To roll back: `npx react-native spm clean --project` deletes the SPM +xcodeproj and renames `.legacy` back to the canonical filename. + +No changes to JavaScript code, Metro configuration, or Android setup are +required. + +### Upgrading React Native + +After upgrading `react-native` in `package.json` and running `npm install`, +the auto-sync build phase detects the `node_modules` mtime change on the next +Xcode build and re-runs the sync step automatically. This downloads the new +version's xcframeworks, regenerates the sub-packages, and updates autolinking. +No manual edits are needed — the xcodeproj's sub-package references are +stable, and those sub-packages are fully regenerated each run. Developers +can also run `react-native spm` manually to trigger the update before +building. + +## How we teach this + +### Documentation + +- Add a **"Building with SPM"** guide to the React Native docs, parallel to the + existing CocoaPods setup guide. +- Update the **"Getting Started"** guide to present SPM as an option alongside + CocoaPods, with SPM as the recommended path for new projects once Phase 2 is + stable. +- Add a **library author guide** explaining how to use `react-native-prebuild` + and publish xcframeworks. + +### CLI discoverability + +- `react-native spm --help` should provide clear usage instructions and + explain each step. +- Error messages should include actionable suggestions (e.g., "Run + `react-native spm init` for first-time setup"). +- The auto-sync build phase should surface warnings in Xcode's issue navigator + when autolinking state is stale. + +### Community template + +- The `react-native init` template should include SPM as an option (e.g., + `--pm spm` flag or interactive prompt). +- The template should generate the initial `Package.swift` and `.xcodeproj` so + that new projects work with SPM out of the box. + +### Naming and terminology + +- **"SPM build"** or **"Swift Package Manager build"** to distinguish from the + CocoaPods-based workflow. +- **"xcframeworks"** when referring to the prebuilt binary artifacts. +- Avoid the term "pods" when discussing the SPM workflow to prevent confusion. + +## Unresolved questions + +1. **How should version compatibility be enforced?** A library's xcframework + must be built against a compatible React Native version. Should the download + step enforce strict version matching, accept semver-compatible ranges, or + simply warn on mismatch? + +2. **Where should library xcframeworks be hosted?** Maven Central (consistent + with React Native core), GitHub Releases (simpler for library authors), or a + dedicated registry (better discovery and compatibility metadata). Each has + different trade-offs for discoverability, reliability, and maintenance + burden. + +3. **Debug symbol (dSYM) distribution.** The prebuild pipeline produces dSYM + bundles alongside xcframeworks, but the best way to distribute and consume + them is not yet defined. Open questions include: should dSYMs be downloaded + alongside xcframeworks automatically or on demand? How should they integrate + with crash reporting services (Sentry, Crashlytics) that need dSYM UUIDs + for symbolication? Should the download step place dSYMs in a location that + Xcode's archive workflow picks up automatically? + +4. **How should library authors validate SPM compatibility?** A validation + command (`react-native-prebuild --validate`) could verify that a library's + sources compile as an SPM target without producing a full release artifact. + This would be useful for CI checks on pull requests. + +5. **Hardening `update --force-xcodeproj`.** The default `update` is + create-if-missing, which preserves user edits but means generator + improvements don't propagate to existing projects automatically. Passing + `--force-xcodeproj` clobbers everything. A future improvement could read + the existing pbxproj, merge changes (signing, capabilities, custom Build + Phases), and write back — likely via a proper Xcode project + parser/generator (e.g., `@bacons/xcode`) rather than the current + template-based approach. Planned work for production readiness. + +6. **Auto-sync failure visibility.** The sync build phase currently emits + `warning:` and exits 0 on failure, which means a broken autolinking state + can persist silently across builds. Planned improvements include a strict + mode (e.g., `RN_SPM_STRICT_SYNC=1` that exits non-zero on failure) and + generating a `#warning` directive in a source file when sync fails, so + Xcode surfaces the issue in the issue navigator even when build log + warnings are missed. + +7. **Monorepo and package manager compatibility.** The auto-sync build phase + uses `node_modules` mtime to detect dependency changes. This has been + tested with npm in the React Native monorepo but not yet with Yarn + workspaces (hoisted or PnP), pnpm (symlinked `node_modules`), or Bun. + These package managers structure `node_modules` differently and may require + adjustments to the mtime detection logic. Validating and fixing + compatibility across package managers is planned work. + +## References + +- [RFC0508: Out-of-NPM Artifacts](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0508-out-of-npm-artifacts.md) — established the Maven-based artifact distribution pattern this proposal builds on +- [Apple: Creating Swift Packages](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode) +- [SE-0272: Package Manager Binary Dependencies](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0272-swiftpm-binary-dependencies.md) diff --git a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md new file mode 100644 index 000000000000..c8ac204ca06d --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md @@ -0,0 +1,244 @@ +# SwiftPM Autolinking Plugins (Preview) + +> **Preview / unstable contract.** The discovery mechanism and the plugin +> function's context/return shape may change while the first consumers (Expo) +> validate it. Pin to a React Native version if you depend on it. + +How a framework with its own module system — Expo is the first consumer — +contributes to the SwiftPM autolinking graph that `npx react-native spm` +generates. See [spm-scripts.md](./spm-scripts.md) for the base tool. + +## Why a plugin (not a static list or a post-process) + +The documented extension points don't cover a framework: + +- `spm.modules` in `react-native.config.js` is a **static** list of simple + source modules. A framework discovers its modules **dynamically** (scanning + `node_modules`), generates a **module registry**, and ships mixed + Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't + handle. +- A one-shot **post-process** of the generated `Package.swift` is **clobbered + on the next sync**: the Xcode [auto-sync build phase](./spm-scripts.md#auto-sync-build-phase) + re-runs autolinking on every dependency change. A framework's contribution + must run *whenever autolinking runs*. + +A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s +`main()` — the single function that both `add` / `update` **and** the +build-time `sync` call — so the contribution is regenerated on every build and +never goes stale. + +(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, +`use_expo_modules!`, and `react_native_post_install` hooks.) + +## Discovery — transitive, zero app config + +A dependency opts in from its **own** `react-native.config.js`, so installing +the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!` +transitively): + +```js +// node_modules/expo/react-native.config.js +module.exports = { + spm: {autolinkingPlugin: './spm/autolinking-plugin.js'}, +}; +``` + +The autolinker already walks every dependency's `react-native.config.js`; any +that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level +registration or allowlist is required. + +**Opt-out escape hatch.** An app can exclude a plugin from its own +`react-native.config.js`: + +```js +module.exports = { + spm: {denyPlugins: ['some-framework']}, // npm names to skip +}; +``` + +## The contract + +A plugin is a function exported from the module named above +(`module.exports = fn`, or `default` / `plugin` named exports also work): + +```js +module.exports = function plugin(context) { + return { + packageDependencies: [ + // Local package (e.g. a scanned module dir) … + {name: 'ExpoModulesCore', path: '../../../node_modules/expo-modules-core/ios'}, + // … or a remote/published package: + // {name: 'SomePkg', url: 'https://…/SomePkg.git', version: '1.2.3'}, + ], + productDependencies: [ + // Linked by the app's AutolinkedAggregate target: + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ], + generatedSources: [ + // e.g. the generated module registry, registered with codegen: + {path: 'build/generated/expo/ExpoModulesProvider.swift'}, + ], + flavoredFrameworks: [ + // Precompiled dynamic XCFrameworks that come in mandatory Debug/Release + // pairs. RN validates and stages both outside the SwiftPM graph. + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/…/output/debug/xcframeworks/ExpoModulesCore.xcframework', + release: '/…/output/release/xcframeworks/ExpoModulesCore.xcframework', + }, + }, + ], + watchPaths: [ + // Inputs whose edits must re-trigger the auto-sync — the plugin's own + // manifest and per-module config (absolute paths, dirs or files): + '/…/node_modules/expo/Package.swift', + '/…/node_modules/expo/expo-module.config.json', + ], + }; +}; +``` + +#### `flavoredFrameworks` — per-configuration precompiled frameworks + +Each entry is +`{id, frameworkName, linkage: 'dynamic', flavors: {debug, release}}`. +Both flavor paths must be absolute and present when `spm add` or `spm update` +runs. The framework and executable names, public headers, and platform slices +must agree across flavors. Static binaries, nested frameworks, duplicate IDs, +and duplicate embedded framework names are fatal. + +The declarations are recorded to +`/.spm-plugin-flavored-frameworks.json`, normalized into the same +immutable app-local slots as React Native, and added to Xcode's exact linker and +embed settings. They are not emitted as SwiftPM product dependencies. Adding or +removing one requires `spm update`; the build-time `spm sync` intentionally does +not mutate runtime framework settings. + +#### `watchPaths` — plugin staleness inputs + +`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode +auto-sync build phase watches to decide whether it must re-sync. RN already +watches each module's source dir plus every npm dep's checked-in `Package.swift` +and `.react-native/` dir; a plugin adds the inputs only it knows about — e.g. +`packages/expo/Package.swift`, `expo-module.config.json`, and per-module +manifests. On the next build the phase re-syncs when a watched **file** is newer +than the last sync, a watched **dir** has a newer child, or a watched path has +**vanished** (a rename forces a re-sync so the config error surfaces). + +Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored with a warning +(never fatal), and each non-string / empty / **relative** entry is dropped with a +warning. Absolute-only, because the generated phase tests these paths with no cwd +context. The kept paths are folded into `/.spm-sync-watch-paths` +alongside RN's own, then deduped and sorted. + +### Context (input) + +| Field | Meaning | +|---|---| +| `appRoot` | The Xcode project directory (`/ios`) being injected — **not** the app package root. Deriving package-root-relative paths from it (e.g. `path.join(appRoot, 'node_modules')`) silently breaks; use `projectRoot` for that. | +| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. | +| `reactNativeRoot` | Resolved `react-native` package root. | +| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. | +| `outputDir` | `build/generated/autolinking` — where generated artifacts land. | +| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. | + +#### `context.react` — depending on React + +A plugin that emits its own `Package.swift` must declare React as a dependency. +Rather than re-deriving React Native's package path, identity, and product +names — which differ between local and remote mode and **move as RN +repackages** — take them from `context.react`: + +```js +react: { + packageRef: + {name: 'ReactNative', path: '', relPath: ''} // local + | {name: '', url: '', version: ''}, // remote (SPM-resolved) + products: [ + {name: 'ReactHeaders', package: 'ReactNative'}, + {name: 'ReactNativeHeaders', package: 'ReactNative'}, + {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, + {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, // ← separate, per-app package + ], +} +``` + +Local vs remote is signalled by which `packageRef` keys are present (`path` xor +`url`+`version`). `packageRef.path` is **absolute** — always correct no matter +which subdirectory of `outputDir` the plugin writes its own manifest into (the +generated manifests are gitignored and regenerated every sync, so there's no +portability cost); `relPath` (relative to `outputDir`) is provided as a +convenience. `products` is the set React Native wires into **its own** autolinked +targets (so a plugin's target compiles against exactly RN's React surface), +filtered to those resolvable this run — every listed product is safe to +reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in +the separate `React-GeneratedCode` package (per-app codegen), which a +hand-rolled plugin would miss, and which is omitted when that package is absent. +Because RN derives this list from one source of truth alongside its own product +wiring, it stays correct across repackaging. + +### Return (contributions, all optional) + +| Field | Merged into | +|---|---| +| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). | +| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | +| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | +| `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | + +The plugin returns **data** — it never writes into React Native's generated +tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` +byte-for-byte (idempotent). Package and product contributions are **deduped by +name** across plugins. + +## Lifecycle + +``` +react-native spm add / update ─┐ + ├─► generate-spm-autolinking main() +Xcode "Sync SPM Autolinking" ──┘ │ +(build phase, every build) ├─ 1. discover plugins (dep configs) + ├─ 2. RN builds its own dep graph + ├─ 3. invoke plugins (context in) + └─ 4. merge results → aggregator Package.swift +``` + +Because steps 1–4 run in the one `main()`, everything above shares the same +seam — there is no separate hook to wire for the build-time path. + +## Failure behavior + +Fail-closed and **named**: a plugin that fails to load, doesn't export a +function, throws, or returns a malformed contribution aborts the run with a +message identifying the framework. A framework silently dropping its modules +(a green build missing native code) is worse than a loud stop. + +## Status & open items (Preview) + +- **Implemented & tested:** discovery (transitive + deny-list), invocation, + package + product merge, fail-closed validation, and dual-flavor framework + normalization/link/embed outside SwiftPM. +- **Implemented & tested:** `generatedSources` **app-target wiring**. The + merge writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` + xcodeproj injector (generate-spm-xcodeproj.js) reads it and wires each source + **into the app target** — a `PBXFileReference` + `PBXBuildFile` + a + Sources-build-phase entry, parented under one "SPM Generated Sources" + navigator group. This is what makes an `@objc` class (e.g. Expo's + `ExpoModulesProvider`) reach the ObjC classlist: a class inside the static + Autolinked aggregate never does, so `NSClassFromString` discovery would fail. + Paths are stored SRCROOT-relative when under the app root (the usual + `build/generated/…` case), else absolute (`sourceTree = ""`). All + UUIDs are namespaced on the normalized path (deterministic/idempotent) and + recorded in the `.spm-injected.json` marker's `generatedSources` map, so + `deinit` reverts them and `update` reconciles entries that left the manifest. + A target without a Sources phase logs loudly and skips the wiring (injection + otherwise succeeds). v1 targets only the injected app target and assumes + `.swift` in practice (`.m`/`.mm` are mapped as future-proofing). +- **Co-design with Expo (not final):** codegen **provider ordering** — codegen + must consume the same discovered module set the plugin contributes — is + intentionally left for the first real plugin to drive to a stable shape. +- Contract to be ratified via RFC once Expo's plugin proves it (framed as a + generic hook, not Expo-specific code in RN). diff --git a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md new file mode 100644 index 000000000000..c62c46e0b781 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md @@ -0,0 +1,97 @@ +# SPM headers & package references — how they resolve + +React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search +paths and no `unsafeFlags` in any generated manifest. Headers are served by +SPM products/binary targets, and every generated `Package.swift` references +the React Native + codegen packages with plain, fixed-relative paths computed +at generation time (no runtime discovery). This document is the single source +of truth for how that resolves. + +> History: earlier iterations materialized two header trees and fed them to +> consumers as `-I` flags read from `spm-paths.json` / `.react-native/paths.json` +> via an inlined Swift loader. That whole mechanism (the loader +> `renderRNPathsLoader`, the `writeAppPathsJson` / `writeSharedPathsJson` +> writers, and both JSON files) has been **deleted** — manifests are now +> declarative. If you find a reference to those files, it is stale. + +## How headers resolve (no search paths) + +| Namespace | Served by | Mechanism | +|-----------|-----------|-----------| +| Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | +| Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | +| ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | + +The one remaining materialized header tree is the per-app farm at +`/build/generated/ios/ReactAppHeaders` (built by +`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It +is vended as the `ReactAppHeaders` SPM target — consumers reach it through a +product dependency, never through `-I`. + +`autolinking.json` (the `@react-native-community/cli config` output) is an +INPUT used to generate the manifests; it is never read by a manifest. + +## How each manifest references the React + codegen packages + +Every generated manifest sits at a known depth inside the app and is +regenerated on every `react-native spm` run, so package references are plain +fixed-relative paths — no walk-up, no JSON, no `import Foundation`. + +| Manifest | Location | How it references the React + codegen packages | +|----------|----------|-------------------------------------------------| +| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | +| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | +| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | +| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | +| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | + +## Remote-package mode + +Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted +`url`). When set, the whole app graph flips to a single remote React Native +package identity: `.package(path: build/xcframeworks)` becomes +`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), +and the local artifact download + compose is skipped. SPM's +one-version-per-package rule then unifies app + every library on one resolved +React Native. The package identity is derived from the URL tail (swift-tools 6 +dropped `.package(name:url:)`) — nothing hardcodes a repo name. + +**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version +is not a free parameter: the SPM graph must compile against the same React +Native the JS/native code uses, so the app (graph root) pins EXACT to the +*installed* RN version, read from `node_modules/react-native/package.json`. +`RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, +not the source of truth — they're only needed when the installed version isn't +publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote +tag). A *derived* version is never persisted, so an `npm install` that upgrades +RN auto-re-pins the SPM graph on the next `spm` run; an *override* is persisted +as `versionOverride` so it survives Xcode-phase re-syncs without the env. + +Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still +read, with `version` honored as an override (back-compat). If remote mode is on +but no usable version can be resolved — react-native isn't installed, or it's a +non-publishable dev placeholder and no override is set — the tooling errors +(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` +or install a released react-native, rather than silently pinning an unpublished +tag. + +## Hand-authored community library contract + +A library that ships its own `Package.swift` (no scaffolder/autolinker marker) +is left untouched by the tooling. It needs only two things, and **no discovery +code**: + +1. Depend on the React Native SPM package and its products — in remote mode + `.package(url: "", exact: "")` + `.product(name: "ReactNative", …)` + and `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a + version RANGE in production; the consuming app pins EXACT.) +2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` + and generate with `generate-codegen-artifacts.js --path . --targetPlatform + ios --source library`. Output lands at + `/build/generated/ios/ReactCodegen/`, reachable from the manifest + with one safe `.headerSearchPath(...)` into the library's own tree. The + app-side codegen then skips the lib's spec (no duplicate symbols). + +This makes the library self-contained — it carries no app-layout knowledge and +needs no per-app codegen headers from the consuming app. Proven with +`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). diff --git a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md new file mode 100644 index 000000000000..e20e4c93f672 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md @@ -0,0 +1,128 @@ +# SPM Build Plugins Assessment + +An evaluation of whether Swift Package Manager plugins can replace the Xcode build +phase scripts currently injected by `generate-spm-xcodeproj.js`. + +## Current Build Phases (6 total) + +| # | Phase | Timing | SPM Plugin Feasible? | +|---|-------|--------|----------------------| +| 1 | Sync SPM Autolinking | Pre-build | Partially | +| 2 | Prepare VFS Overlay | Pre-build | Partially | +| 3 | Sources (compile) | Build | N/A (standard) | +| 4 | Frameworks (link) | Build | N/A (standard) | +| 5 | Resources (copy) | Build | N/A (standard) | +| 6 | Build JS Bundle | **Post-build** | **See below** | + +> **Removed:** The "Copy Hermes Framework" phase was removed — it was a no-op. +> The underlying `copy-hermes-xcode.sh` script has been empty since Dec 2022. +> Hermes is already properly linked as an xcframework SPM dependency. + +## SPM Plugin Types + +SPM offers two plugin types: + +1. **Build Tool Plugins** (`BuildToolPlugin`) — run pre-build, can generate source + files/resources via `prebuildCommands` or per-file `buildCommands`. +2. **Command Plugins** (`CommandPlugin`) — run on-demand via + `swift package `. + +## Key Constraints + +### Sandbox restrictions + +SPM plugins run sandboxed by default — no network access, limited filesystem access. +The current scripts need to: + +- Run `node` (not on the sandbox-allowed path) +- Write to the source tree (`autolinked/`, `build/`) +- Access `node_modules/` +- Read git state + +Command plugins can request `--allow-writing-to-package-directory`, but build tool +plugins can only write to a designated plugin work directory, not the source tree. + +### No Xcode build settings + +SPM plugins do not receive Xcode build settings such as `CONFIGURATION`, +`PLATFORM_NAME`, `BUILT_PRODUCTS_DIR`, or `DERIVED_FILE_DIR`. The JS bundling script +relies heavily on these to decide debug-vs-release behavior and output paths. + +## JS Bundle Phase — Could Move to Pre-build + +The JS bundle has no dependency on native compilation. It only needs JS source files, +Metro, and knowledge of debug vs release. The current post-build placement is +historical — the script writes directly into `BUILT_PRODUCTS_DIR`. + +A potential restructuring: + +1. **Generate the bundle pre-build** into a known location (e.g. `build/jsbundle/`) +2. **Declare it as an SPM resource** so it gets copied into the app automatically + +Challenges: +- **Debug builds skip bundling** (app loads from Metro dev server). The script checks + `CONFIGURATION == Debug`, which is unavailable to SPM plugins. +- **Hermes bytecode compilation** also happens in this phase for release builds. +- Making it a command plugin (`swift package bundle-js --configuration release`) would + lose the automatic behavior — developers would need to run it explicitly. + +## What Could Theoretically Work + +### Codegen as a Command Plugin + +A Swift command plugin could shell out to `node` to run codegen: + +```swift +@main struct CodegenPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["node", "scripts/codegen/generate-codegen-artifacts.js"] + try process.run() + process.waitUntilExit() + } +} +``` + +Invoked as `swift package codegen`. This is essentially wrapping a shell script in +Swift with no real benefit over the current approach. + +### Autolinking sync as a Prebuild Command + +A `prebuildCommand` runs before every build, similar to Phase 1. But: + +- Output can only go to the plugin work directory (not `autolinked/`) +- Would need to restructure the package graph to consume generated files from the + plugin work directory +- Still needs to shell out to `node` + +This is a significant architectural rework for marginal benefit. + +## Recommendation + +**Do not invest in SPM plugins for this use case.** Reasons: + +1. **Pre-build phases already work well** as Xcode build phase scripts. Moving them + to SPM plugins adds Swift boilerplate around `Process()` calls to `node`, while + losing access to Xcode build settings. + +2. **The ROI is poor** — a hybrid (some SPM plugins + some Xcode build phases) is + harder to reason about than the current uniform approach of all Xcode build phases. + +3. **SPM plugins shine for pure Swift source generation** (SwiftGen, SwiftProtobuf) + where the plugin generates `.swift` files that feed into compilation. React + Native's build steps are fundamentally different — they orchestrate a JS toolchain + and copy runtime artifacts. + +4. **The JS bundle phase could move pre-build** but would lose automatic + debug/release detection without Xcode build settings. Worth revisiting if SPM + gains access to build configuration in a future Swift version. + +## Alternatives Worth Exploring + +- **Xcode Build Tool Plug-ins** (the Xcode-specific variant, not SPM) have access to + build settings and can run post-build, but require a different packaging model. +- **Move auto-sync to a `prepare` script** in `package.json` so it runs at + `yarn install` time instead of every build, reducing build-time overhead. +- **Pre-build JS bundling** with the bundle declared as an SPM resource, removing the + need for a post-build phase entirely (release builds only). diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md new file mode 100644 index 000000000000..d0d0f9cbf7a0 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -0,0 +1,451 @@ +# SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) + +> **Preview.** SwiftPM support is an early preview: the commands, flags, +> generated layout, and distribution model may change in future releases, and +> it is not yet recommended for production. Feedback is welcome. CocoaPods +> remains the supported default. + +Build React Native iOS apps using **Swift Package Manager** with prebuilt +XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — +CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` +in place and is fully reversible. + +## Quick Start + +```bash +cd ios + +# First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, +# in place. `npx react-native spm` with no action auto-resolves to `add` (or +# `update` once injected); on a fresh CocoaPods app it converts in one command +# (implies --deintegrate). To do it explicitly: +npx react-native spm add --deintegrate + +# Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. +open MyApp.xcodeproj +``` + +After the initial run, the `.xcodeproj` includes an **auto-sync build phase** +that detects dependency changes and re-runs autolinking before compilation +(see [Auto-Sync](#auto-sync-build-phase)) — you don't re-invoke +`react-native spm` manually for day-to-day dependency changes. **On a fresh +clone or CI checkout, run `npx react-native spm` once before building** (see +[Fresh clones & CI](#fresh-clones--ci)). + +> **Note:** `react-native spm` is a thin wrapper over +> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI +> alias is unavailable in your environment, invoke the script directly with +> the same actions and the kebab-case flag equivalents (e.g. +> `--skip-codegen`). + +## CocoaPods → SwiftPM migration + +`spm add` injects into a project that is **not** CocoaPods-integrated. On a +CocoaPods app it fails loud and points you at `--deintegrate`, which: + +1. runs `pod deintegrate` — removes CocoaPods integration from the + `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your + `Podfile` is left on disk. +2. strips **only** the React Native directives (`use_react_native!`, + `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — + every other line, **including your own `pod '…'` entries, is preserved**. +3. injects SwiftPM into the `.xcodeproj`. + +React Native now comes from SwiftPM; no pods are linked yet (deintegrate +removed the integration). + +### Keeping non-RN pods + +Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile +still lists them (only the RN directives were removed) — re-integrate them +with a normal install: + +```bash +pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace +``` + +Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes +the SwiftPM-injected project, so React Native resolves through SwiftPM and your +other pods through CocoaPods, together. + +> **Do not re-add `use_react_native!`.** React Native must be provided by +> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, +> so a dual-managed RN does not build. `spm add` refuses to run while the +> Podfile still declares `use_react_native!`. + +The migration is fully reversible — see +[Removing / resetting](#removing--resetting). + +## Brownfield apps + +`spm add` injects into your existing `.xcodeproj` in place, so an app that +embeds React Native works the same way — point it at the right project and +target: + +```bash +npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp +``` + +**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** +— i.e. the app's `package.json` is a parent directory of the project. Both +setup and the build-time sync locate React Native by walking up from the +project to the nearest `package.json`. The common "native project at the repo +root with the RN JS in a sibling/child subfolder" layout is **not supported +yet** — there is no way to point at a JS root outside the project's ancestors. + +Brownfield apps that keep CocoaPods for their other native dependencies follow +the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, +everything else from CocoaPods, and no `use_react_native!` in the Podfile. + +## CLI Actions + +```bash +react-native spm [action] [options] +``` + +With no action, the command **auto-resolves**: if SwiftPM has been injected +(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. +On a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the +zero-arg path additionally implies `--deintegrate` (the safe-gate), so +`npx react-native spm` converts a brand-new app to SwiftPM in one command. + +When invoked from the JS root of a standard RN app (sibling `ios/` subdir), +the command auto-redirects into `ios/` with a banner. + +| Action | Description | +|---|---| +| `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | +| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | +| `deinit` | The exact inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. | +| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | +| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync build phase. Regenerates invariant codegen and autolinking output only. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | +| `download` (advanced) | Download/check xcframework artifacts only. | + +## CLI Options + +Flags below use the `react-native spm` (camelCase) form. The raw script +accepts kebab-case equivalents (e.g. `--skip-codegen`). + +| Option | Description | +|---|---| +| `--version ` | RN version (default: from package.json) | +| `--yes` | Skip the dirty-pbxproj confirmation prompt | +| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | +| `--productName ` | [add] Which app target to inject into (when several exist) | +| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | +| `--download ` | [advanced] Artifact download policy (default: auto) | +| `--skipCodegen` | [advanced] Skip the codegen step | + +### Debug/Release flavor is automatic + +React Native ships **flavored** prebuilt binaries: the *debug* `React.framework` +(and `hermesvm` / `ReactNativeDependencies`) carry the dev experience — dev menu, +assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while *release* strips them for +production. A Debug build must embed the debug binaries and a Release/archive the +release ones. + +SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime +frameworks are deliberately kept out of the package graph. `spm add` downloads +and validates **both** flavors into immutable app-local slots. It injects +SDK/architecture-qualified Xcode settings that link the exact selected binaries, +plus one phase that copies and signs the selected frameworks into the app. +Configurations containing `debug` or `development` select Debug; every other +configuration selects Release. Selection uses only generated build settings and +standard macOS tools: builds do not run Node, mutate symlinks, regenerate the +package graph, or require a second build. + +## What to commit + +| Path | Commit? | Why | +|------|---------|-----| +| `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. | +| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. | +| `build/generated/` | No | Codegen/autolinking output; regenerated | +| `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache | +| `Package.resolved` | No | SwiftPM resolution file; machine-specific | + +Injection is **purely additive** and **idempotent**: `add`/`update` insert only +SwiftPM package refs, the React build settings, the Sync build phase, and a scheme +pre-action — every other byte (your signing / capabilities / Build Phases) +stays untouched, and a re-run is a no-op. The injected refs point at three +stable sub-package paths under `build/`; adding or removing community deps +changes the sub-package contents (gitignored) and never re-injects. `deinit` +removes exactly what was injected (using the marker), leaving the project +byte-identical to its pre-`add` state. + +Because everything under `build/` is gitignored, a clean checkout has no +resolvable Swift packages until they are regenerated — see the next section. + +## Fresh clones & CI + +Xcode resolves the Swift package graph **before any build phase runs**, so on a +clean checkout (where the gitignored `build/` packages don't exist yet) the +auto-sync build phase can't regenerate them in time — a bare `xcodebuild` +fails at *"Resolve Package Graph … build/generated/autolinking doesn't exist"*. + +Run the setup command once after cloning, before building — the SwiftPM analog +of `pod install`: + +```bash +npx react-native spm # downloads artifacts (if missing) + regenerates build/ +``` + +On an already-injected project this routes to `update`: it fetches the +xcframework artifacts into the shared cache if they aren't present and +regenerates `build/xcframeworks` + `build/generated`. After this first run, +incremental dependency changes are picked up automatically by the auto-sync +build phase. + +**Automate it** so nobody has to remember — add a `postinstall` hook, which +runs as part of the `npm install` / `yarn install` your CI already does before +`xcodebuild`: + +```json +{ + "scripts": { + "postinstall": "react-native spm" + } +} +``` + +`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook +works from the app root; in CI (non-interactive) it proceeds without prompting. +It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op +when nothing changed), so it is slightly heavier than the internal `sync` the +build phase calls — a fine trade for not having to remember a command. + +> A future remote-package distribution (a tagged `Package.swift` repo + +> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves and +> fetches the artifacts itself during normal package resolution. Until then, +> the one-time setup run is required on clean machines. + +## Local Native Modules + +Modules not discovered via autolinking can be declared in `react-native.config.js`: + +```js +module.exports = { + spm: { + modules: [ + { + name: 'MyNativeModule', + path: 'ios/MyNativeModule', // relative to app root + exclude: ['*.podspec'], // optional + publicHeadersPath: '.', // optional + }, + ], + }, +}; +``` + +Each entry becomes a target in `build/generated/autolinking/Package.swift`. +Sources outside `build/generated/autolinking/` are automatically mirrored with +file-level symlinks. + +## Self-managed community packages + +A community library that ships its own `Package.swift` is referenced +directly by the autolinker instead of being wrapped. To keep SwiftPM's +package identity (which it derives from the path basename) unique across +deps — even when several libs put their manifest inside an `ios/` subdir +— each self-managed dep is exposed through a uniquely-named symlink at +`build/generated/autolinking/libs//`. The aggregator +`Package.swift` references that path, so two libs both shipping +`/ios/Package.swift` never collide on identity `"ios"`. + +The `libs/` directory is wiped and recreated on every autolinker run, +so deleting a dep via `npm uninstall` cleans up the alias automatically +on the next build. + +## Community packages without a Package.swift + +If an autolinked library ships **no `Package.swift`**, the build fails with a +clear per-dep error (`Package.swift is missing for library ""`). Generate +one from the library's podspec: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +``` + +Because `node_modules/` isn't committed, persist it so it survives the next +install: + +```bash +npx patch-package # then commit the generated patch +``` + +**Better: contribute the manifest upstream.** The generated `Package.swift` is +a normal, committable manifest — the ideal fix is for the library to ship it +itself, so every consumer gets SwiftPM support without a local patch. Please +**file an issue or open a PR on the library** with the scaffolded +`Package.swift` (mention it was generated by `react-native spm scaffold` for +React Native SwiftPM support). Until it lands upstream, the `patch-package` +workaround keeps your app building. + +> A library whose sources mix Swift **and** Objective-C/C++ in one target, or +> that ships neither a `Package.swift` nor a podspec, can't be scaffolded +> automatically — the error says so. Opt it out via `react-native.config.js` +> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. + +## Framework plugins (Preview) + +Frameworks with their own module system (e.g. Expo) contribute to the +autolinking graph through a **plugin** — a function invoked on every +regeneration (including the build-time sync) that adds SwiftPM package refs, +product dependencies, and generated sources. Discovery is transitive +(installing the framework is enough), and the plugin returns data that RN +merges idempotently. + +See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the +discovery mechanism, the full context/return contract, lifecycle, and failure +behavior. + +## Removing / resetting + +To remove SwiftPM entirely, use `deinit` (the inverse of `add`): + +```bash +react-native spm deinit # surgically removes everything `add` injected +pod install # then, to restore CocoaPods +``` + +To reset the regenerable build state (without un-injecting), just delete the +gitignored dirs and re-run: + +```bash +rm -rf build/xcframeworks build/generated .build +react-native spm update +``` + +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does +not touch SwiftPM-generated directories. The cached xcframework slot is shared +across apps; refresh it with `react-native spm update --download force`. + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | +| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | +| Missing headers | Re-run `react-native spm` | +| "not contained in target" | Re-run setup (regenerates file-level symlinks) | +| Codegen fails | Use `--skipCodegen` to iterate on other parts | +| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | +| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | +| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | + +--- + +# Reference / internals + +## Pipeline + +`react-native spm add` and `react-native spm update` orchestrate these steps: + +| Step | Script | Output | +|------|--------|--------| +| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | +| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | +| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | +| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | +| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | + +## Directory Layout + +``` +my-app/ios/ + MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) + Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) + build/ + generated/ + autolinking/ <-- gitignored (regenerated at build time) + Package.swift + autolinking.json + packages/ <-- synth wrappers for autolinker-managed deps + libs/ <-- symlinks to self-managed deps' Package.swift + dirs, named by Swift module so SwiftPM + package identity stays unique + headers/ <-- generated header symlinks + ios/ <-- gitignored, codegen output + xcframeworks/ <-- gitignored, immutable runtime flavor slots + invariant package + debug/ + React.xcframework -> ~/Library/Caches/.../debug/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + release/ + React.xcframework -> ~/Library/Caches/.../release/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + ReactHeadersTarget/ <-- canonical Objective-C React headers + module map + ReactNativeHeaders.xcframework -> ... + ReactNativeDependenciesHeaders.xcframework -> ... + flavored-frameworks.json + .artifact-stamp +``` + +## Header Resolution + +React Native uses CocoaPods-style imports (`#import `) that +SwiftPM doesn't natively support. The prebuilt artifacts serve them through SwiftPM +package products — no `-I` search-path flags, and no clang VFS overlay: + +1. **`` and `import React`** resolve through the invariant + **`ReactHeaders` Clang target**. It stages one canonical header copy after + proving Debug and Release expose identical public headers, and uses a plain + `module React` module map with `React/`-prefixed paths. +2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, + `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a + headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM + auto-serves to dependents. +3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, + `double-conversion/`, `fast_float/`, `SocketRocket/`) come from + **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only + sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` + is framework-type and can't expose those headers to SwiftPM). + +Targets that compile against React take these as product dependencies +(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus the +app's `ReactAppHeaders`), so all of the above resolve with zero search-path +flags. + +## Auto-Sync Build Phase + +The generated `.xcodeproj` includes a **Sync SPM Autolinking** shell script +build phase. It keeps `build/generated/autolinking/Package.swift` up to date +without requiring manual re-runs of `react-native spm` for incremental +dependency changes. (It cannot bootstrap a fresh clone — Xcode resolves the +package graph before any phase runs; see [Fresh clones & CI](#fresh-clones--ci).) + +**How it works:** + +1. Compares timestamps of staleness inputs against `build/generated/autolinking/.spm-sync-stamp`: + - `package.json` — dependency declarations + - `react-native.config.js` — `spm.modules` config + - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups + - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks stale +2. If any input is newer (or the stamp is missing): runs `npx react-native spm sync`, + which re-executes autolinking + package generation (downloading artifacts if + the cache slot is incomplete) and writes the stamp file. +3. If all inputs are fresh: exits immediately (~1ms). + +**Build phase ordering:** + +| # | Phase | +|---|-------| +| 0 | Resolve Package Graph (Xcode — runs before all build phases) | +| 1 | Sync SPM Autolinking | +| 2 | Sources (compile) | +| 3 | Frameworks (link) | +| 4 | Embed React Native Flavored Frameworks | +| 5 | Resources (copy) | +| 6 | Build JS Bundle | + +Failures in the sync phase are non-fatal — it emits a `warning:` and exits 0, +so an already-generated package graph can still produce a successful build. diff --git a/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj b/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj new file mode 100644 index 000000000000..ee9fbf60dcf8 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj @@ -0,0 +1,171 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AA00000000000000000000A1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AA00000000000000000000B1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AA00000000000000000000C1 /* MyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MyApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA00000000000000000000D1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA00000000000000000000E1 = { + isa = PBXGroup; + children = ( + AA00000000000000000000B1 /* AppDelegate.swift */, + AA00000000000000000000F1 /* Products */, + ); + sourceTree = ""; + }; + AA00000000000000000000F1 /* Products */ = { + isa = PBXGroup; + children = ( + AA00000000000000000000C1 /* MyApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000101 /* MyApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000201 /* Build configuration list for PBXNativeTarget "MyApp" */; + buildPhases = ( + AA0000000000000000000301 /* Sources */, + AA00000000000000000000D1 /* Frameworks */, + AA0000000000000000000401 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MyApp; + productName = MyApp; + productReference = AA00000000000000000000C1 /* MyApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000501 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1600; + }; + buildConfigurationList = AA0000000000000000000601 /* Build configuration list for PBXProject "MyApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA00000000000000000000E1; + productRefGroup = AA00000000000000000000F1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000101 /* MyApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA0000000000000000000401 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA0000000000000000000301 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA00000000000000000000A1 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AA0000000000000000000701 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AA0000000000000000000801 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + AA0000000000000000000901 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + AA00000000000000000000A2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA0000000000000000000601 /* Build configuration list for PBXProject "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000701 /* Debug */, + AA0000000000000000000801 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA0000000000000000000201 /* Build configuration list for PBXNativeTarget "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000901 /* Debug */, + AA00000000000000000000A2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000501 /* Project object */; +} diff --git a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js new file mode 100644 index 000000000000..f45cc5b04a81 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js @@ -0,0 +1,343 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {discoverPlugins, invokePlugins} = require('../autolinking-plugins'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('discoverPlugins', () => { + let tmp; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugins-')); + }); + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + // Writes a dep dir with a plugin module and returns its {name, root}. + function makeDep(name, pluginBody /*: ?string */) { + const root = path.join(tmp, name); + fs.mkdirSync(root, {recursive: true}); + if (pluginBody != null) { + fs.writeFileSync(path.join(root, 'plugin.js'), pluginBody, 'utf8'); + } + return {name, root}; + } + + // readConfig fake: a dep opts in when opted[name] is truthy. + const readConfigFor = opted => root => { + const name = path.basename(root); + return opted[name] ? {spm: {autolinkingPlugin: './plugin.js'}} : null; + }; + + it('discovers a plugin declared via react-native.config.js', () => { + const dep = makeDep('expo', 'module.exports = () => ({});'); + const found = discoverPlugins([dep], readConfigFor({expo: true})); + expect(found).toHaveLength(1); + expect(found[0].depName).toBe('expo'); + expect(typeof found[0].plugin).toBe('function'); + }); + + it('skips deps that do not declare a plugin', () => { + const dep = makeDep('react-native-svg', null); + expect(discoverPlugins([dep], readConfigFor({}))).toHaveLength(0); + }); + + it('honors the app deny-list (opt-out, no allowlist needed)', () => { + const dep = makeDep('expo', 'module.exports = () => ({});'); + const found = discoverPlugins([dep], readConfigFor({expo: true}), ['expo']); + expect(found).toHaveLength(0); + }); + + it('accepts default/plugin export interop', () => { + const a = makeDep('a', 'module.exports.default = () => ({});'); + const b = makeDep('b', 'module.exports.plugin = () => ({});'); + const found = discoverPlugins([a, b], readConfigFor({a: true, b: true})); + expect(found.map(f => f.depName).sort()).toEqual(['a', 'b']); + }); + + it('fails closed when the plugin module is missing', () => { + const dep = makeDep('expo', null); // opted in below but no plugin.js + expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow( + /failed to load the autolinking plugin for 'expo'/, + ); + }); + + it('fails closed when the module does not export a function', () => { + const dep = makeDep('expo', 'module.exports = {nope: 1};'); + expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow( + /does not export a function/, + ); + }); +}); + +describe('invokePlugins', () => { + const ctx = { + appRoot: '/app/ios', + projectRoot: '/app', + reactNativeRoot: '/app/node_modules/react-native', + autolinking: {}, + outputDir: '/app/ios/build/generated/autolinking', + react: { + packageRef: {name: 'ReactNative', path: '../build/xcframeworks'}, + products: [{name: 'ReactHeaders', package: 'ReactNative'}], + }, + }; + const mk = (depName, fn) => ({ + depName, + pluginPath: `/x/${depName}.js`, + plugin: fn, + }); + + it('merges package/product/generated contributions', () => { + const res = invokePlugins( + [ + mk('expo', () => ({ + packageDependencies: [{name: 'ExpoModulesCore', path: '../expo'}], + productDependencies: [ + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ], + generatedSources: [{path: 'ExpoModulesProvider.swift'}], + })), + ], + ctx, + ); + expect(res.packageDependencies).toEqual([ + {name: 'ExpoModulesCore', path: '../expo'}, + ]); + expect(res.productDependencies).toEqual([ + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ]); + expect(res.generatedSources).toEqual([{path: 'ExpoModulesProvider.swift'}]); + }); + + it('passes the invariant React descriptor without a flavor', () => { + let seen; + invokePlugins( + [ + mk('expo', c => { + seen = c; + return {}; + }), + ], + ctx, + ); + expect(seen.flavor).toBeUndefined(); + expect(seen.projectRoot).toBe('/app'); + expect(seen.autolinking).toBe(ctx.autolinking); + // react descriptor is forwarded so plugins depend on React via one source. + expect(seen.react.packageRef).toEqual({ + name: 'ReactNative', + path: '../build/xcframeworks', + }); + }); + + it('dedupes packages and products by name across plugins', () => { + const res = invokePlugins( + [ + mk('a', () => ({ + packageDependencies: [{name: 'Dup', path: './a'}], + productDependencies: [{name: 'P', package: 'Dup'}], + })), + mk('b', () => ({ + packageDependencies: [{name: 'Dup', path: './b'}], + productDependencies: [{name: 'P', package: 'Dup'}], + })), + ], + ctx, + ); + expect(res.packageDependencies).toHaveLength(1); + expect(res.productDependencies).toHaveLength(1); + }); + + it('tolerates a plugin returning null/undefined', () => { + const res = invokePlugins([mk('a', () => undefined)], ctx); + expect(res.packageDependencies).toEqual([]); + }); + + it('fails closed and names the plugin when it throws', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => { + throw new Error('boom'); + }), + ], + ctx, + ), + ).toThrow(/plugin for 'expo'.*threw: boom/); + }); + + it('rejects a package dep without a path or url+version', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({packageDependencies: [{name: 'X'}]}))], + ctx, + ), + ).toThrow(/needs either a path or a url\+version/); + }); + + it('rejects a product dep missing name or package', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({productDependencies: [{name: 'X'}]}))], + ctx, + ), + ).toThrow(/productDependency needing name \+ package/); + }); + + it('merges valid flavoredFrameworks', () => { + const res = invokePlugins( + [ + mk('expo', () => ({ + flavoredFrameworks: [ + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/o/debug/ExpoModulesCore.xcframework', + release: '/o/release/ExpoModulesCore.xcframework', + }, + }, + ], + })), + ], + ctx, + ); + expect(res.flavoredFrameworks).toEqual([ + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/o/debug/ExpoModulesCore.xcframework', + release: '/o/release/ExpoModulesCore.xcframework', + }, + }, + ]); + }); + + it.each([ + [{id: 'a', frameworkName: 'A', linkage: 'dynamic', flavors: {debug: '/d'}}], + [ + { + id: 'a', + frameworkName: 'A', + linkage: 'static', + flavors: {debug: '/d', release: '/r'}, + }, + ], + [ + { + id: 'a', + frameworkName: 'A', + linkage: 'dynamic', + flavors: {debug: 'relative', release: '/r'}, + }, + ], + ])('rejects malformed or incomplete flavoredFrameworks', entry => { + expect(() => + invokePlugins([mk('expo', () => ({flavoredFrameworks: [entry]}))], ctx), + ).toThrow(/invalid flavoredFramework/); + }); + + it('rejects duplicate embedded framework destinations', () => { + const makeFramework = id => ({ + id, + frameworkName: 'Dup', + linkage: 'dynamic', + flavors: {debug: `/a/${id}`, release: `/b/${id}`}, + }); + expect(() => + invokePlugins( + [ + mk('a', () => ({flavoredFrameworks: [makeFramework('a')]})), + mk('b', () => ({flavoredFrameworks: [makeFramework('b')]})), + ], + ctx, + ), + ).toThrow(/multiple plugins embed 'Dup\.framework'/); + }); + + it('defaults flavoredFrameworks to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.flavoredFrameworks).toEqual([]); + }); + + it('rejects a non-array flavoredFrameworks declaration', () => { + expect(() => + invokePlugins([mk('expo', () => ({flavoredFrameworks: {id: 'X'}}))], ctx), + ).toThrow(/non-array flavoredFrameworks/); + }); + + it('keeps valid absolute watchPaths (dirs or files) across plugins', () => { + const res = invokePlugins( + [ + mk('expo', () => ({ + watchPaths: [ + '/app/node_modules/expo/Package.swift', + '/app/node_modules/expo/expo-module.config.json', + ], + })), + mk('b', () => ({watchPaths: ['/app/node_modules/b']})), + ], + ctx, + ); + expect(res.watchPaths).toEqual([ + '/app/node_modules/expo/Package.swift', + '/app/node_modules/expo/expo-module.config.json', + '/app/node_modules/b', + ]); + }); + + it('defaults watchPaths to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.watchPaths).toEqual([]); + }); + + it('drops relative / empty / non-string watchPaths with a per-entry warning', () => { + const warnings = []; + const res = invokePlugins( + [ + mk('expo', () => ({ + watchPaths: [ + '/app/node_modules/expo/Package.swift', // kept + 'relative/Package.swift', // relative → dropped + '', // empty → dropped + 42, // non-string → dropped + ], + })), + ], + ctx, + {warn: m => warnings.push(m)}, + ); + expect(res.watchPaths).toEqual(['/app/node_modules/expo/Package.swift']); + expect(warnings).toHaveLength(3); + expect(warnings.every(w => /invalid watchPath/.test(w))).toBe(true); + }); + + it('ignores a non-array watchPaths with a warning (never throws)', () => { + const warnings = []; + let res; + expect(() => { + res = invokePlugins( + [mk('expo', () => ({watchPaths: '/app/x'}))], // string, not array + ctx, + {warn: m => warnings.push(m)}, + ); + }).not.toThrow(); + expect(res.watchPaths).toEqual([]); + expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js new file mode 100644 index 000000000000..113bbee91606 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -0,0 +1,788 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + exists, + extractXCFramework, + findFirst, + formatBytes, + formatSpeed, + hermesReleaseUrl, + resolveCacheSlotVersion, + resolveHermesArtifact, + resolveLatestV1Version, + resolveNightlyVersion, + resolveRNCoreArtifact, + resolveRNDepsArtifact, + resolveSnapshotUrl, + rnCoreReleaseUrl, + rnDepsReleaseUrl, + validateArtifactsCache, +} = require('../download-spm-artifacts'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Shared fetch router used by the URL-resolution tests below. Each key is a +// URL substring; the matched value describes the response. Anything unmatched +// returns 404 (the "release not found, fall back to snapshot" path). +function routerFetch(routes /*: {[string]: any} */) { + return jest.fn(async (url, opts) => { + for (const [key, resp] of Object.entries(routes)) { + if (String(url).includes(key)) { + return { + ok: resp.ok ?? true, + status: resp.status ?? 200, + json: async () => resp.json, + text: async () => resp.text ?? '', + }; + } + } + return { + ok: false, + status: 404, + json: async () => ({}), + text: async () => '', + }; + }); +} + +// --------------------------------------------------------------------------- +// resolveHermesArtifact — hermes uses its own version space, decoupled from +// React Native's nightly cadence. The default behavior mirrors RN's +// CocoaPods prebuild (HERMES_VERSION='latest-v1'): resolve via the +// hermes-compiler npm dist-tag instead of trying to download a hermes-ios +// artifact at the RN nightly version (which won't exist on Maven). +// --------------------------------------------------------------------------- + +describe('resolveHermesArtifact', () => { + let origFetch; + let origHermesEnv; + + beforeEach(() => { + origFetch = globalThis.fetch; + origHermesEnv = process.env.HERMES_VERSION; + delete process.env.HERMES_VERSION; + }); + + afterEach(() => { + globalThis.fetch = origFetch; + if (origHermesEnv !== undefined) { + process.env.HERMES_VERSION = origHermesEnv; + } else { + delete process.env.HERMES_VERSION; + } + }); + + // Mock fetch with a router: each entry's key is a URL substring; the value + // describes the response. Anything not matched returns 404 (mimicking the + // "release not found, try snapshot" path). + function mockFetch(routes /*: {[string]: any} */) { + globalThis.fetch = routerFetch(routes); + } + + describe('default behavior (no HERMES_VERSION set)', () => { + it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => { + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + // Pretend the release URL exists once we ask for 0.13.0. + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).toContain('/0.13.0/'); + // The RN nightly hash MUST NOT leak into the hermes URL. + expect(result.url).not.toContain('20260519'); + }); + + it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => { + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + // Caller passes the original RN --version verbatim; hermes should + // still default to latest-v1 instead of using this. + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + '0.87.0-nightly-20260519-58cd1bf58', + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).not.toContain('20260519'); + }); + }); + + describe('HERMES_VERSION escape hatches', () => { + it('HERMES_VERSION= uses it verbatim', async () => { + process.env.HERMES_VERSION = '0.13.5'; + mockFetch({ + 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + expect(result.version).toBe('0.13.5'); + expect(result.url).toContain('/0.13.5/'); + }); + + it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => { + process.env.HERMES_VERSION = 'latest-v1'; + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + expect(result.version).toBe('0.13.0'); + }); + + it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => { + process.env.HERMES_VERSION = 'nightly'; + mockFetch({ + 'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}}, + 'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': { + ok: true, + }, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + expect(result.version).toBe('0.14.0-nightly-abc'); + }); + + it('falls back to the hermes snapshot URL when the release is missing', async () => { + process.env.HERMES_VERSION = '0.13.5'; + globalThis.fetch = jest.fn(async (url, opts) => { + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + return { + ok: true, + status: 200, + text: async () => + '20260303.000000' + + '2', + }; + }); + const result = await resolveHermesArtifact('0.87.0', 'debug', null); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('hermes-ios-debug.tar.gz'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Maven URL builders — pure string composition. +// --------------------------------------------------------------------------- + +describe('release URL builders', () => { + it('rnCoreReleaseUrl points at the reactnative-core classifier on Maven Central', () => { + const url = rnCoreReleaseUrl('0.85.0', 'debug'); + expect(url).toBe( + 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.85.0/' + + 'react-native-artifacts-0.85.0-reactnative-core-debug.tar.gz', + ); + }); + + it('rnDepsReleaseUrl points at the reactnative-dependencies classifier', () => { + const url = rnDepsReleaseUrl('0.85.0', 'release'); + expect(url).toContain('react-native-artifacts/0.85.0/'); + expect(url).toContain('reactnative-dependencies-release.tar.gz'); + }); + + it('hermesReleaseUrl points at the hermes-ios coordinate', () => { + const url = hermesReleaseUrl('0.13.0', 'debug'); + expect(url).toBe( + 'https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/0.13.0/' + + 'hermes-ios-0.13.0-hermes-ios-debug.tar.gz', + ); + }); + + it('honors ENTERPRISE_REPOSITORY for the release base URL', () => { + jest.isolateModules(() => { + const prev = process.env.ENTERPRISE_REPOSITORY; + process.env.ENTERPRISE_REPOSITORY = 'https://maven.internal.example'; + try { + const mod = require('../download-spm-artifacts'); + expect(mod.rnCoreReleaseUrl('0.85.0', 'debug')).toContain( + 'https://maven.internal.example/com/facebook/react/', + ); + } finally { + if (prev !== undefined) { + process.env.ENTERPRISE_REPOSITORY = prev; + } else { + delete process.env.ENTERPRISE_REPOSITORY; + } + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// formatBytes / formatSpeed — pure formatting with a 1 MB unit boundary. +// --------------------------------------------------------------------------- + +describe('formatBytes', () => { + it('renders sub-megabyte sizes in KB', () => { + expect(formatBytes(512)).toBe('0.5 KB'); + expect(formatBytes(1024)).toBe('1.0 KB'); + }); + + it('renders megabyte-and-larger sizes in MB', () => { + expect(formatBytes(1024 * 1024)).toBe('1.0 MB'); + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB'); + }); +}); + +describe('formatSpeed', () => { + it('renders sub-megabyte rates in KB/s (no decimals)', () => { + expect(formatSpeed(2048)).toBe('2 KB/s'); + }); + + it('renders megabyte-and-larger rates in MB/s', () => { + expect(formatSpeed(5 * 1024 * 1024)).toBe('5.0 MB/s'); + }); +}); + +// --------------------------------------------------------------------------- +// exists — HEAD probe used to choose release vs. snapshot. +// --------------------------------------------------------------------------- + +describe('exists', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('returns true on a 200 HEAD response', async () => { + globalThis.fetch = jest.fn(async () => ({status: 200})); + expect(await exists('https://example/x.tar.gz')).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith('https://example/x.tar.gz', { + method: 'HEAD', + }); + }); + + it('returns false on a non-200 response', async () => { + globalThis.fetch = jest.fn(async () => ({status: 404})); + expect(await exists('https://example/missing.tar.gz')).toBe(false); + }); + + it('returns false when fetch rejects (offline)', async () => { + globalThis.fetch = jest.fn(async () => { + throw new Error('network down'); + }); + expect(await exists('https://example/x.tar.gz')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resolveSnapshotUrl — parses maven-metadata.xml into a timestamped URL. +// --------------------------------------------------------------------------- + +describe('resolveSnapshotUrl', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + const METADATA = ` + + + 20260101.123456 + 7 + + `; + + it('builds a fully-versioned snapshot URL from timestamp + buildNumber', async () => { + globalThis.fetch = routerFetch({'maven-metadata.xml': {text: METADATA}}); + const url = await resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'reactnative-core-debug.tar.gz', + ); + expect(url).toContain('0.85.0-SNAPSHOT/'); + expect(url).toContain( + 'react-native-artifacts-0.85.0-20260101.123456-7-reactnative-core-debug.tar.gz', + ); + }); + + it('throws when the metadata request fails', async () => { + globalThis.fetch = routerFetch({ + 'maven-metadata.xml': {ok: false, status: 500}, + }); + await expect( + resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'x.tar.gz', + ), + ).rejects.toThrow(/Failed to fetch snapshot metadata/); + }); + + it('throws when timestamp/buildNumber are absent', async () => { + globalThis.fetch = routerFetch({ + 'maven-metadata.xml': {text: ''}, + }); + await expect( + resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'x.tar.gz', + ), + ).rejects.toThrow(/Could not parse timestamp\/buildNumber/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveNightlyVersion / resolveLatestV1Version — npm dist-tag lookups. +// --------------------------------------------------------------------------- + +describe('npm dist-tag resolvers', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('resolveNightlyVersion returns the version from the npm registry', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-xyz'}}, + }); + expect(await resolveNightlyVersion('react-native')).toBe( + '0.86.0-nightly-xyz', + ); + }); + + it('resolveNightlyVersion throws on a failed npm lookup', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {ok: false, status: 404}, + }); + await expect(resolveNightlyVersion('react-native')).rejects.toThrow( + /npm lookup failed/, + ); + }); + + it('resolveLatestV1Version reads hermes-compiler/latest-v1', async () => { + globalThis.fetch = routerFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + }); + expect(await resolveLatestV1Version()).toBe('0.13.0'); + }); + + it('resolveLatestV1Version throws on a failed lookup', async () => { + globalThis.fetch = routerFetch({ + 'hermes-compiler/latest-v1': {ok: false, status: 500}, + }); + await expect(resolveLatestV1Version()).rejects.toThrow(/npm lookup failed/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveRNCoreArtifact / resolveRNDepsArtifact — release-then-snapshot. +// --------------------------------------------------------------------------- + +describe('resolveRNCoreArtifact', () => { + let origFetch; + let tempDir; + beforeEach(() => { + origFetch = globalThis.fetch; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-core-')); + }); + afterEach(() => { + globalThis.fetch = origFetch; + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('uses the stable release URL when it exists', async () => { + globalThis.fetch = routerFetch({ + 'reactnative-core-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.version).toBe('0.85.0'); + expect(result.url).toContain('repo1.maven.org'); + expect(result.url).toContain('reactnative-core-debug.tar.gz'); + }); + + it('falls back to the snapshot URL when the release is missing', async () => { + globalThis.fetch = jest.fn(async (url, opts) => { + // HEAD probe of the release URL → 404. + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + // GET of maven-metadata.xml → snapshot coordinates. + return { + ok: true, + status: 200, + text: async () => + '20260101.000000' + + '1', + }; + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('20260101.000000-1'); + }); + + it('uses a local tarball override when the file exists', async () => { + const tarball = path.join(tempDir, 'core.tar.gz'); + fs.writeFileSync(tarball, 'x'); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', tarball); + expect(result.url).toBe(tarball); + expect(result.version).toBe('0.85.0-local'); + }); + + it('throws when the local tarball override is missing', async () => { + await expect( + resolveRNCoreArtifact( + '0.85.0', + 'debug', + path.join(tempDir, 'nope.tar.gz'), + ), + ).rejects.toThrow(/does not exist/); + }); +}); + +describe('resolveRNDepsArtifact', () => { + let origFetch; + let origDepEnv; + beforeEach(() => { + origFetch = globalThis.fetch; + origDepEnv = process.env.RN_DEP_VERSION; + delete process.env.RN_DEP_VERSION; + }); + afterEach(() => { + globalThis.fetch = origFetch; + if (origDepEnv !== undefined) { + process.env.RN_DEP_VERSION = origDepEnv; + } else { + delete process.env.RN_DEP_VERSION; + } + }); + + it('uses the RN version by default', async () => { + globalThis.fetch = routerFetch({ + 'reactnative-dependencies-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.85.0'); + expect(result.url).toContain('react-native-artifacts/0.85.0/'); + }); + + it('honors RN_DEP_VERSION override', async () => { + process.env.RN_DEP_VERSION = '0.84.2'; + globalThis.fetch = routerFetch({ + 'reactnative-dependencies-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.84.2'); + expect(result.url).toContain('react-native-artifacts/0.84.2/'); + }); + + it('uses a local deps tarball override when the file exists', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deps-local-')); + try { + const tarball = path.join(dir, 'deps.tar.gz'); + fs.writeFileSync(tarball, 'x'); + const result = await resolveRNDepsArtifact('0.85.0', 'debug', tarball); + expect(result.url).toBe(tarball); + expect(result.version).toBe('0.85.0-local'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('throws when the local deps tarball override is missing', async () => { + await expect( + resolveRNDepsArtifact( + '0.85.0', + 'debug', + path.join(os.tmpdir(), 'spm-deps-nope', 'nope.tar.gz'), + ), + ).rejects.toThrow(/does not exist/); + }); + + it('resolves RN_DEP_VERSION=nightly via the npm registry', async () => { + process.env.RN_DEP_VERSION = 'nightly'; + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-dep'}}, + 'react-native-artifacts/0.86.0-nightly-dep/': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.86.0-nightly-dep'); + }); + + it('falls back to the deps snapshot URL when the release is missing', async () => { + globalThis.fetch = jest.fn(async (url, opts) => { + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + return { + ok: true, + status: 200, + text: async () => + '20260202.000000' + + '3', + }; + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('reactnative-dependencies-debug.tar.gz'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCacheSlotVersion — stable label passthrough vs. nightly resolution. +// --------------------------------------------------------------------------- + +describe('resolveCacheSlotVersion', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('returns a stable version label unchanged (no npm lookup)', async () => { + globalThis.fetch = jest.fn(async () => { + throw new Error('should not be called'); + }); + expect(await resolveCacheSlotVersion('0.85.0')).toBe('0.85.0'); + }); + + it('resolves the 1000.0.0 dev label to the current nightly slot', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-zzz'}}, + }); + expect(await resolveCacheSlotVersion('1000.0.0')).toBe( + '0.86.0-nightly-zzz', + ); + }); + + it('falls back to the raw label when the nightly lookup fails', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {ok: false, status: 503}, + }); + expect(await resolveCacheSlotVersion('nightly')).toBe('nightly'); + }); +}); + +// --------------------------------------------------------------------------- +// findFirst — bounded recursive search. +// --------------------------------------------------------------------------- + +describe('findFirst', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-findfirst-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds a nested entry matching the predicate', () => { + const nested = path.join(tempDir, 'a', 'b'); + fs.mkdirSync(nested, {recursive: true}); + fs.writeFileSync(path.join(nested, 'Target.xcframework'), ''); + const hit = findFirst(tempDir, n => n.endsWith('.xcframework'), 8); + expect(hit).toBe(path.join(nested, 'Target.xcframework')); + }); + + it('returns null when depth is exhausted before the match', () => { + const nested = path.join(tempDir, 'a', 'b', 'c'); + fs.mkdirSync(nested, {recursive: true}); + fs.writeFileSync(path.join(nested, 'Target.xcframework'), ''); + // depth 1 only inspects the immediate children of tempDir. + expect(findFirst(tempDir, n => n.endsWith('.xcframework'), 1)).toBeNull(); + }); + + it('returns null for a nonexistent directory', () => { + expect(findFirst(path.join(tempDir, 'ghost'), () => true, 4)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractXCFramework — untar + locate the .xcframework dir. +// --------------------------------------------------------------------------- + +describe('extractXCFramework', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-extract-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('extracts a tarball and returns the contained .xcframework path', () => { + // Build a tarball whose payload contains a Foo.xcframework directory. + const payload = path.join(tempDir, 'payload'); + const xcfw = path.join(payload, 'Foo.xcframework'); + fs.mkdirSync(xcfw, {recursive: true}); + fs.writeFileSync(path.join(xcfw, 'Info.plist'), ''); + const tarPath = path.join(tempDir, 'foo.tar.gz'); + execSync(`tar -czf "${tarPath}" -C "${payload}" Foo.xcframework`); + + const extractDir = path.join(tempDir, 'out'); + const found = extractXCFramework(tarPath, extractDir); + expect(found).toBe(path.join(extractDir, 'Foo.xcframework')); + expect(fs.existsSync(path.join(found, 'Info.plist'))).toBe(true); + }); + + it('throws when the tarball contains no .xcframework', () => { + const payload = path.join(tempDir, 'payload'); + fs.mkdirSync(payload, {recursive: true}); + fs.writeFileSync(path.join(payload, 'readme.txt'), 'hi'); + const tarPath = path.join(tempDir, 'plain.tar.gz'); + execSync(`tar -czf "${tarPath}" -C "${payload}" readme.txt`); + + expect(() => + extractXCFramework(tarPath, path.join(tempDir, 'out')), + ).toThrow(/No .xcframework found/); + }); +}); + +// --------------------------------------------------------------------------- +// validateArtifactsCache — guards against stale / partial cache slots. +// --------------------------------------------------------------------------- + +describe('validateArtifactsCache', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-validate-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + // Writes artifacts.json plus the on-disk xcframework dir for each entry. + function seedCache(entries /*: {[string]: boolean} */) { + const json = {}; + for (const [name, onDisk] of Object.entries(entries)) { + const xcfwPath = path.join(tempDir, `${name}.xcframework`); + if (onDisk) { + fs.mkdirSync(xcfwPath, {recursive: true}); + } + json[name] = {xcframeworkPath: xcfwPath, url: 'https://example'}; + } + fs.writeFileSync( + path.join(tempDir, 'artifacts.json'), + JSON.stringify(json), + 'utf8', + ); + } + + // Stages the hermes public headers that validateArtifactsCache also requires. + function seedHermesHeaders() { + fs.mkdirSync(path.join(tempDir, 'hermes-headers', 'hermes'), { + recursive: true, + }); + } + + it('returns null when the cache is complete and on disk', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + 'hermes-engine': true, + }); + seedHermesHeaders(); + expect(validateArtifactsCache(tempDir)).toBeNull(); + }); + + it('reports unstaged Hermes public headers', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + 'hermes-engine': true, + }); + // No hermes-headers/hermes dir. + expect(validateArtifactsCache(tempDir)).toMatch( + /Hermes public headers not staged/, + ); + }); + + it('reports a missing headers companion (pure-RN split)', () => { + // ReactNativeDependenciesHeaders is the deps sidecar — without it no + // -style include resolves (ReactNativeHeaders is pure-RN). + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + 'hermes-engine': true, + }); + seedHermesHeaders(); + expect(validateArtifactsCache(tempDir)).toMatch( + /missing entry for "ReactNativeDependenciesHeaders"/, + ); + }); + + it('reports a missing artifacts.json', () => { + expect(validateArtifactsCache(tempDir)).toMatch(/artifacts.json missing/); + }); + + it('reports unreadable JSON', () => { + fs.writeFileSync(path.join(tempDir, 'artifacts.json'), '{not json', 'utf8'); + expect(validateArtifactsCache(tempDir)).toMatch(/unreadable/); + }); + + it('reports a missing required entry', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + }); + expect(validateArtifactsCache(tempDir)).toMatch( + /missing entry for "hermes-engine"/, + ); + }); + + it('reports an entry whose xcframework dir is gone', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + 'hermes-engine': false, + }); + expect(validateArtifactsCache(tempDir)).toMatch( + /xcframework for "hermes-engine" not found/, + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js new file mode 100644 index 000000000000..49e91fdd6404 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -0,0 +1,393 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +/** + * Red tests for the transitive dependency expander. + * + * Library authors declare transitive native deps in their react-native.config.js: + * + * module.exports = { + * dependency: { platforms: { ios: {} } }, + * spm: { dependencies: ['react-native-test-library-common'] }, + * }; + * + * The expander takes the directly-autolinked deps (from autolinking.json) and + * follows each one's spm.dependencies recursively, resolving names to package + * roots via Node module resolution. Behavior mirrors podspec `s.dependency`: + * + * - Transitive deps with iOS native code → added as autolinked targets + * - Transitive deps without iOS native code → silently skipped + * - Deduped by package name (first occurrence wins) + * - Cycles are detected (visited set keyed on name) + * - Unresolvable names throw with a clear message + * + * I/O is injected (readConfig, resolveDep) so the tests stay pure. + */ + +const { + expandSpmDependencies, + resolveSwiftName, +} = require('../expand-spm-dependencies'); +const {toSwiftName} = require('../spm-utils'); + +function makeReadConfig(configs /*: {[string]: ?Object} */) { + return (root /*: string */) => + Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; +} + +function makeResolveDep(resolutions /*: {[string]: ?string} */) { + return (name /*: string */) => + Object.prototype.hasOwnProperty.call(resolutions, name) + ? resolutions[name] + : null; +} + +// --------------------------------------------------------------------------- +// expandSpmDependencies +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies', () => { + it('returns direct deps with auto-derived swiftName when none declare spm.dependencies', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {}}), + resolveDep: makeResolveDep({}), + }); + expect(result).toEqual([ + {...direct[0], swiftName: toSwiftName('a'), spmDependencies: []}, + ]); + }); + + it('pulls in one transitive dep declared by a direct dep', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common'}), + }); + expect(result.map(d => d.name)).toEqual(['apple', 'common']); + expect(result[1].root).toBe('/common'); + expect(result[1].platforms.ios).toBeDefined(); + }); + + it('recurses through a chain (A → B → C)', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['b']}}, + '/b': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['c']}, + }, + '/c': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({b: '/b', c: '/c'}), + }); + expect(result.map(d => d.name)).toEqual(['a', 'b', 'c']); + }); + + it('handles cycles without infinite recursion (A → B → A)', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['b']}, + }, + '/b': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['a']}, + }, + }), + resolveDep: makeResolveDep({a: '/a', b: '/b'}), + }); + expect(result.map(d => d.name).sort()).toEqual(['a', 'b']); + }); + + it('dedups a diamond (A → X, B → X) — X appears exactly once', () => { + const direct = [ + {name: 'a', root: '/a', platforms: {ios: {}}}, + {name: 'b', root: '/b', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['x']}}, + '/b': {spm: {dependencies: ['x']}}, + '/x': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({x: '/x'}), + }); + expect(result.filter(d => d.name === 'x')).toHaveLength(1); + expect(result.map(d => d.name).sort()).toEqual(['a', 'b', 'x']); + }); + + it('throws with a clear message when a declared transitive cannot be resolved', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['ghost']}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/ghost.*apple|apple.*ghost/i); + }); + + it('silently skips transitives that have no iOS native code (matches autolinkingDepToSpmTarget behavior)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['js-only']}}, + // js-only has no dependency.platforms.ios — pure JS package + '/js-only': {}, + }), + resolveDep: makeResolveDep({'js-only': '/js-only'}), + }); + expect(result.map(d => d.name)).toEqual(['apple']); + }); + + it('does not re-add a transitive that is already a direct dep (first occurrence wins)', () => { + const direct = [ + {name: 'apple', root: '/apple', platforms: {ios: {}}}, + {name: 'common', root: '/common-direct', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common-other': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common-other'}), + }); + expect(result.filter(d => d.name === 'common')).toHaveLength(1); + // The direct-dep entry should be preserved, not overwritten by the transitive + expect(result.find(d => d.name === 'common').root).toBe('/common-direct'); + }); + + // ------------------------------------------------------------------------- + // spmDependencies field: each entry should carry the names of its iOS-native + // transitive deps, so the downstream emitter can wire SPM target-level deps + // (e.g. apple's .target(dependencies: [.target(name: "...Common")])). + // ------------------------------------------------------------------------- + + it('attaches spmDependencies: [] when the dep declares none', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const [a] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {}}), + resolveDep: makeResolveDep({}), + }); + expect(a.spmDependencies).toEqual([]); + }); + + it('attaches spmDependencies with the declared transitive names (preserving declaration order)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const [apple, common] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common', 'extra']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + '/extra': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common', extra: '/extra'}), + }); + expect(apple.spmDependencies).toEqual(['common', 'extra']); + expect(common.spmDependencies).toEqual([]); + }); + + it('omits JS-only transitives from spmDependencies (only iOS-native names appear)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const [apple] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['js-only', 'common']}}, + '/js-only': {}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'js-only': '/js-only', common: '/common'}), + }); + expect(apple.spmDependencies).toEqual(['common']); + }); + + it('records spmDependencies on both sides of a diamond (A→X, B→X)', () => { + const direct = [ + {name: 'a', root: '/a', platforms: {ios: {}}}, + {name: 'b', root: '/b', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['x']}}, + '/b': {spm: {dependencies: ['x']}}, + '/x': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({x: '/x'}), + }); + const a = result.find(d => d.name === 'a'); + const b = result.find(d => d.name === 'b'); + expect(a.spmDependencies).toEqual(['x']); + expect(b.spmDependencies).toEqual(['x']); + }); + + it('passes the declaring dep root as the second argument to resolveDep (for Node resolution paths)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + let receivedFromRoot /*: ?string */ = null; + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: (name, fromRoot) => { + if (name === 'common') { + receivedFromRoot = fromRoot; + return '/common'; + } + return null; + }, + }); + expect(receivedFromRoot).toBe('/apple'); + }); + + // ------------------------------------------------------------------------- + // swiftName resolution: each dep gets a Swift target name on the way out. + // Default is toSwiftName(npmName); the dep's react-native.config.js + // `spm.name` overrides it. Required for libraries whose import prefix + // differs from the auto-derived name (e.g. `react-native-worklets` + // publishes headers under ``). + // ------------------------------------------------------------------------- + + it('populates swiftName via toSwiftName when no spm.name override is set', () => { + const direct = [ + {name: 'react-native-foo', root: '/foo', platforms: {ios: {}}}, + ]; + const [foo] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/foo': {}}), + resolveDep: makeResolveDep({}), + }); + expect(foo.swiftName).toBe(toSwiftName('react-native-foo')); + expect(foo.swiftName).toBe('ReactNativeFoo'); + }); + + it('uses spm.name as swiftName when the direct dep declares one', () => { + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + ]; + const [w] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/w': {spm: {name: 'worklets'}}}), + resolveDep: makeResolveDep({}), + }); + expect(w.swiftName).toBe('worklets'); + }); + + it('applies spm.name override to transitive deps too', () => { + const direct = [ + {name: 'react-native-reanimated', root: '/r', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/r': { + dependency: {platforms: {ios: {}}}, + spm: {name: 'reanimated', dependencies: ['react-native-worklets']}, + }, + '/w': { + dependency: {platforms: {ios: {}}}, + spm: {name: 'worklets'}, + }, + }), + resolveDep: makeResolveDep({'react-native-worklets': '/w'}), + }); + const reanimated = result.find(d => d.name === 'react-native-reanimated'); + const worklets = result.find(d => d.name === 'react-native-worklets'); + expect(reanimated.swiftName).toBe('reanimated'); + expect(worklets.swiftName).toBe('worklets'); + }); + + it('throws on swiftName collision between two deps (override vs auto-derived)', () => { + // 'react-native-worklets' would auto-derive to 'ReactNativeWorklets', but + // here a second dep overrides its spm.name to that same value. + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-package', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {}, + '/o': {spm: {name: 'ReactNativeWorklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/ReactNativeWorklets/); + }); + + it('throws on a CASE-INSENSITIVE swiftName collision (worklets vs Worklets)', () => { + // Distinct as exact strings, but collide as directories on the default + // case-insensitive macOS filesystem. + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-worklets', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {spm: {name: 'worklets'}}, + '/o': {spm: {name: 'Worklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/case/i); + }); + + it('rejects empty-string spm.name with a clear error citing the npm name', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {spm: {name: ''}}}), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/'a' has an invalid 'spm.name'/); + }); + + it('rejects non-string spm.name (e.g. number, object) with a clear error', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {spm: {name: 42}}}), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/invalid 'spm.name'/); + }); + + it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { + expect(() => resolveSwiftName('a', {spm: {name: 'foo bar'}})).toThrow( + /invalid 'spm.name'/, + ); + expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow( + /invalid 'spm.name'/, + ); + expect(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow( + /invalid 'spm.name'/, + ); + }); + + it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe( + 'reanimated', + ); + expect(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe( + 'hermes-engine', + ); + expect(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe( + 'RNWorklets', + ); + expect(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe( + 'react_native_foo', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js new file mode 100644 index 000000000000..303e5852bc18 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js @@ -0,0 +1,102 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +// Regression test for the fresh-consumer-app failure: `plist` is NOT a +// dependency of the react-native package, so it only resolves inside this +// monorepo via hoisting. A generated app running `spm add` gets +// "Cannot find module 'plist'". flavored-frameworks.js must therefore never +// require it — Info.plist parsing goes through plutil instead. +jest.mock('plist', () => { + throw new Error( + "Cannot find module 'plist' — flavored-frameworks.js must not depend on it", + ); +}); + +// plutil is macOS-only; stand in with a portable plist-parse so the suite is +// hermetic on Linux CI. Module-level mock because flavored-frameworks.js +// destructures execFileSync at require time (same pattern the old +// swap-flavor-test used). +jest.mock('child_process', () => { + const actual = jest.requireActual<$FlowFixMe>('child_process'); + return { + ...actual, + execFileSync: (cmd, args, opts) => { + if (cmd === 'plutil') { + const fs = require('fs'); + const plist = jest.requireActual<$FlowFixMe>('plist'); + const file = args[args.length - 1]; + return Buffer.from( + JSON.stringify(plist.parse(fs.readFileSync(file, 'utf8'))), + ); + } + return actual.execFileSync(cmd, args, opts); + }, + }; +}); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const realPlist = jest.requireActual<$FlowFixMe>('plist'); + +function makeXcframework(root /*: string */) /*: string */ { + const xcframework = path.join(root, 'React.xcframework'); + const sliceId = 'ios-arm64'; + fs.mkdirSync(path.join(xcframework, sliceId, 'React.framework'), { + recursive: true, + }); + fs.writeFileSync( + path.join(xcframework, sliceId, 'React.framework', 'React'), + 'not-a-real-mach-o', + ); + fs.writeFileSync( + path.join(xcframework, 'Info.plist'), + realPlist.build({ + AvailableLibraries: [ + { + LibraryIdentifier: sliceId, + LibraryPath: 'React.framework', + SupportedPlatform: 'ios', + SupportedArchitectures: ['arm64'], + }, + ], + CFBundlePackageType: 'XFWK', + XCFrameworkFormatVersion: '1.0', + }), + ); + return xcframework; +} + +describe('flavored-frameworks without the plist module', () => { + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-plist-dep-')); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('loads without requiring plist', () => { + expect(() => require('../flavored-frameworks')).not.toThrow(); + }); + + it('parses an xcframework Info.plist via plutil', () => { + const {parseXcframework} = require('../flavored-frameworks'); + const parsed = parseXcframework(makeXcframework(tmp)); + expect(parsed.slices).toHaveLength(1); + expect(parsed.slices[0].libraryIdentifier).toBe('ios-arm64'); + expect(parsed.slices[0].platform).toBe('ios'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js new file mode 100644 index 000000000000..ab537391e5a4 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js @@ -0,0 +1,439 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// flavored-frameworks.js shells out to `plutil` to read xcframework +// Info.plists as JSON — plutil is macOS-only, so stand in with a portable +// plist-parse for Linux CI. Module-level mock because the module destructures +// `execFileSync` at require time (same pattern as the old swap-flavor-test). +jest.mock('child_process', () => { + const actual = jest.requireActual('child_process'); + return { + ...actual, + execFileSync: (cmd, args, opts) => { + if (cmd === 'plutil') { + const fsActual = require('fs'); + const plistActual = jest.requireActual('plist'); + const file = args[args.length - 1]; + return Buffer.from( + JSON.stringify( + plistActual.parse(fsActual.readFileSync(file, 'utf8')), + ), + ); + } + return actual.execFileSync(cmd, args, opts); + }, + }; +}); + +const { + PLUGIN_FRAMEWORKS_MANIFEST, + finalizeArtifactPublication, + prepareFlavoredFrameworks, + sdkConditionForSlice, + transformReactModuleMap, +} = require('../flavored-frameworks'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const plist = require('plist'); + +const BUILTINS = [ + ['React', 'React'], + ['ReactNativeDependencies', 'ReactNativeDependencies'], + ['hermes-engine', 'hermesvm'], +]; + +function writeFrameworkXcframework( + root, + artifactName, + frameworkName, + options = {}, +) { + const xcframework = path.join(root, `${artifactName}.xcframework`); + const architectures = options.architectures ?? ['arm64', 'x86_64']; + const identifier = options.identifier ?? 'ios-arm64_x86_64-simulator'; + const framework = path.join( + xcframework, + identifier, + `${frameworkName}.framework`, + ); + fs.mkdirSync(path.join(framework, 'Headers'), {recursive: true}); + fs.mkdirSync(path.join(framework, 'Modules'), {recursive: true}); + fs.writeFileSync( + path.join(framework, 'Headers', `${frameworkName}.h`), + options.header ?? `void ${frameworkName}Header(void);\n`, + ); + fs.writeFileSync( + path.join(framework, 'Modules', 'module.modulemap'), + `framework module ${frameworkName} {\n umbrella header "${frameworkName}.h"\n export *\n}\n`, + ); + if (options.swiftInterface != null) { + fs.writeFileSync( + path.join(framework, 'Modules', `${frameworkName}.swiftinterface`), + options.swiftInterface, + ); + } + fs.writeFileSync(path.join(framework, frameworkName), 'not-a-real-mach-o'); + fs.writeFileSync( + path.join(xcframework, 'Info.plist'), + plist.build({ + CFBundlePackageType: 'XFWK', + XCFrameworkFormatVersion: '1.0', + AvailableLibraries: [ + { + LibraryIdentifier: identifier, + LibraryPath: `${frameworkName}.framework`, + SupportedArchitectures: architectures, + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + }, + ], + }), + ); + return xcframework; +} + +function writeHeadersXcframework(root, name, header = 'void Header(void);\n') { + const xcframework = path.join(root, `${name}.xcframework`); + const identifier = 'ios-arm64_x86_64-simulator'; + const headers = path.join(xcframework, identifier, 'Headers'); + fs.mkdirSync(headers, {recursive: true}); + fs.writeFileSync(path.join(headers, `${name}.h`), header); + fs.writeFileSync( + path.join(xcframework, 'Info.plist'), + plist.build({ + CFBundlePackageType: 'XFWK', + XCFrameworkFormatVersion: '1.0', + AvailableLibraries: [ + { + LibraryIdentifier: identifier, + LibraryPath: `lib${name}.a`, + HeadersPath: 'Headers', + SupportedArchitectures: ['arm64', 'x86_64'], + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + }, + ], + }), + ); + return xcframework; +} + +function writeArtifactSlot(root, options = {}) { + fs.mkdirSync(root, {recursive: true}); + const artifacts = {}; + for (const [artifactName, frameworkName] of BUILTINS) { + if (options.missing === artifactName) { + continue; + } + artifacts[artifactName] = { + xcframeworkPath: writeFrameworkXcframework( + root, + artifactName, + frameworkName, + artifactName === 'React' + ? { + header: options.reactHeader, + swiftInterface: options.reactSwiftInterface, + identifier: options.reactIdentifier, + architectures: options.reactArchitectures, + } + : {}, + ), + }; + } + for (const name of ['ReactNativeHeaders', 'ReactNativeDependenciesHeaders']) { + if (options.missing === name) { + continue; + } + artifacts[name] = { + xcframeworkPath: writeHeadersXcframework( + root, + name, + options[`${name}Header`], + ), + }; + } + fs.writeFileSync( + path.join(root, 'artifacts.json'), + `${JSON.stringify(artifacts, null, 2)}\n`, + ); + return artifacts; +} + +describe('flavored framework preparation', () => { + let root; + let appRoot; + let debugRoot; + let releaseRoot; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-flavored-frameworks-')); + appRoot = path.join(root, 'app'); + debugRoot = path.join(root, 'artifacts', 'debug'); + releaseRoot = path.join(root, 'artifacts', 'release'); + fs.mkdirSync(appRoot, {recursive: true}); + }); + + afterEach(() => fs.rmSync(root, {recursive: true, force: true})); + + function prepare() { + return prepareFlavoredFrameworks({ + appRoot, + debugArtifactsDir: debugRoot, + releaseArtifactsDir: releaseRoot, + validateBinaries: false, + }); + } + + it('publishes immutable Debug/Release slots and invariant React headers', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + + const manifest = prepare(); + const packageRoot = path.join(appRoot, 'build', 'xcframeworks'); + expect(manifest.frameworks.map(f => f.frameworkName)).toEqual([ + 'React', + 'ReactNativeDependencies', + 'hermesvm', + ]); + for (const flavor of ['debug', 'release']) { + expect( + fs + .lstatSync(path.join(packageRoot, flavor, 'React.xcframework')) + .isSymbolicLink(), + ).toBe(true); + } + expect( + fs.readFileSync( + path.join( + packageRoot, + 'ReactHeadersTarget', + 'include', + 'module.modulemap', + ), + 'utf8', + ), + ).toContain('module React'); + expect( + fs.existsSync( + path.join( + packageRoot, + 'ReactHeadersTarget', + 'include', + 'React', + 'React.h', + ), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(packageRoot, '.artifact-stamp.pending')), + ).toBe(true); + expect(fs.existsSync(path.join(packageRoot, '.artifact-stamp'))).toBe( + false, + ); + expect(finalizeArtifactPublication(appRoot)).toBe(true); + expect(fs.existsSync(path.join(packageRoot, '.artifact-stamp'))).toBe(true); + expect( + fs.existsSync(path.join(packageRoot, '.artifact-stamp.pending')), + ).toBe(false); + }); + + it('is byte-identical when repeated with unchanged artifacts', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + prepare(); + finalizeArtifactPublication(appRoot); + const manifestPath = path.join( + appRoot, + 'build', + 'xcframeworks', + 'flavored-frameworks.json', + ); + const first = fs.readFileSync(manifestPath); + const reactLink = path.join( + appRoot, + 'build', + 'xcframeworks', + 'debug', + 'React.xcframework', + ); + const inode = fs.lstatSync(reactLink).ino; + prepare(); + expect(fs.readFileSync(manifestPath)).toEqual(first); + expect(fs.lstatSync(reactLink).ino).toBe(inode); + }); + + it('changes the artifact stamp when a selected binary changes in place', () => { + const debug = writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + prepare(); + finalizeArtifactPublication(appRoot); + const stampPath = path.join( + appRoot, + 'build', + 'xcframeworks', + '.artifact-stamp', + ); + const first = fs.readFileSync(stampPath, 'utf8'); + const binary = path.join( + debug.React.xcframeworkPath, + 'ios-arm64_x86_64-simulator', + 'React.framework', + 'React', + ); + fs.appendFileSync(binary, '-updated'); + prepare(); + finalizeArtifactPublication(appRoot); + expect(fs.readFileSync(stampPath, 'utf8')).not.toBe(first); + }); + + it('preserves the last complete publication when new artifacts fail validation', () => { + writeArtifactSlot(debugRoot); + const release = writeArtifactSlot(releaseRoot); + prepare(); + finalizeArtifactPublication(appRoot); + const manifestPath = path.join( + appRoot, + 'build', + 'xcframeworks', + 'flavored-frameworks.json', + ); + const first = fs.readFileSync(manifestPath, 'utf8'); + fs.writeFileSync( + path.join( + release.React.xcframeworkPath, + 'ios-arm64_x86_64-simulator', + 'React.framework', + 'Headers', + 'React.h', + ), + 'void BrokenReleaseHeaders(void);\n', + ); + expect(prepare).toThrow(/different Debug and Release headers/); + expect(fs.readFileSync(manifestPath, 'utf8')).toBe(first); + }); + + it('fails before publication when either flavor is incomplete', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot, {missing: 'hermes-engine'}); + expect(prepare).toThrow(/both flavors must contain 'hermes-engine'/); + expect(fs.existsSync(path.join(appRoot, 'build', 'xcframeworks'))).toBe( + false, + ); + }); + + it('rejects Debug/Release header differences', () => { + writeArtifactSlot(debugRoot, {reactHeader: 'void DebugOnly(void);\n'}); + writeArtifactSlot(releaseRoot, {reactHeader: 'void ReleaseOnly(void);\n'}); + expect(prepare).toThrow(/different Debug and Release headers/); + }); + + it('accepts flavor-specific Swift module metadata when public headers match', () => { + writeArtifactSlot(debugRoot, { + reactSwiftInterface: '// Debug -Onone interface metadata\n', + }); + writeArtifactSlot(releaseRoot, { + reactSwiftInterface: '// Release -O interface metadata\n', + }); + expect(prepare).not.toThrow(); + }); + + it('rejects incompatible Debug/Release slices', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot, { + reactIdentifier: 'ios-arm64-simulator', + reactArchitectures: ['arm64'], + }); + expect(prepare).toThrow(/incompatible Debug and Release XCFrameworks/); + }); + + it('normalizes plugin frameworks and rejects destination collisions', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + const pluginDebug = writeFrameworkXcframework( + path.join(root, 'plugin-debug'), + 'Plugin', + 'React', + ); + const pluginRelease = writeFrameworkXcframework( + path.join(root, 'plugin-release'), + 'Plugin', + 'React', + ); + const pluginManifest = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + fs.mkdirSync(path.dirname(pluginManifest), {recursive: true}); + fs.writeFileSync( + pluginManifest, + JSON.stringify([ + { + id: 'plugin', + frameworkName: 'React', + linkage: 'dynamic', + flavors: {debug: pluginDebug, release: pluginRelease}, + }, + ]), + ); + expect(prepare).toThrow(/multiple flavored frameworks embed as React/); + }); + + it('rejects plugin IDs that collide with built-in framework settings', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + const pluginDebug = writeFrameworkXcframework( + path.join(root, 'plugin-debug'), + 'Plugin', + 'PluginFramework', + ); + const pluginRelease = writeFrameworkXcframework( + path.join(root, 'plugin-release'), + 'Plugin', + 'PluginFramework', + ); + const pluginManifest = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + fs.mkdirSync(path.dirname(pluginManifest), {recursive: true}); + fs.writeFileSync( + pluginManifest, + JSON.stringify([ + { + id: 'react', + frameworkName: 'PluginFramework', + linkage: 'dynamic', + flavors: {debug: pluginDebug, release: pluginRelease}, + }, + ]), + ); + expect(prepare).toThrow(/duplicate flavored framework id 'react'/); + }); +}); + +describe('XCFramework helpers', () => { + it.each([ + ['ios', null, 'iphoneos*'], + ['ios', 'simulator', 'iphonesimulator*'], + ['ios', 'maccatalyst', 'macosx*'], + ['tvos', 'simulator', 'appletvsimulator*'], + ['xros', null, 'xros*'], + ])('maps %s/%s to %s', (platform, variant, expected) => { + expect(sdkConditionForSlice(platform, variant)).toBe(expected); + }); + + it('converts the framework module map to a plain prefixed React module', () => { + expect( + transformReactModuleMap( + 'framework module React {\n umbrella header "React.h"\n textual header "RCTDefines.h"\n}\n', + ), + ).toBe( + 'module React {\n umbrella header "React/React.h"\n textual header "React/RCTDefines.h"\n}\n', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js new file mode 100644 index 000000000000..6e56cca8171f --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js @@ -0,0 +1,259 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +/** + * Red tests for the SPM port of packages/react-native/scripts/cocoapods/autolinking.rb. + * + * The Ruby script (list_native_modules!) does the following: + * 1. Accepts a config_command (default-documented as ['npx', '@react-native-community/cli', 'config']) + * 2. Warns if config_command is empty / invalid (autolinking.rb:19-26) + * 3. Captures stdout + exit status of the command (autolinking.rb:28) + * 4. Warns if exit status is non-zero (autolinking.rb:30-37) + * 5. Parses the JSON output (autolinking.rb:38) + * 6. Derives output path = /build/generated/autolinking/autolinking.json (autolinking.rb:41-43) + * 7. Creates the output directory if missing (autolinking.rb:46) + * 8. Writes the RAW JSON string unchanged to that path (autolinking.rb:47) + * + * These tests assert the same surface for the JS port (generate-spm-autolinking-config.js), + * which does not yet exist — so they are red by construction. + */ + +const { + generateAutolinkingConfig, + resolveDefaultConfigCommand, +} = require('../generate-spm-autolinking-config'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +let tmpProjects = []; + +function makeTmpProject() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-')); + tmpProjects.push(tmp); + const projectRoot = path.join(tmp, 'project'); + const iosDir = path.join(projectRoot, 'ios'); + fs.mkdirSync(iosDir, {recursive: true}); + return {tmp, projectRoot, iosDir}; +} + +function fakeCliConfig(iosSourceDir) { + return { + root: path.dirname(iosSourceDir), + reactNativePath: '../react-native', + project: { + ios: {sourceDir: iosSourceDir}, + android: {sourceDir: path.join(path.dirname(iosSourceDir), 'android')}, + }, + dependencies: { + 'react-native-test-library-apple': { + name: 'react-native-test-library-apple', + root: '/somewhere/react-native-test-library/apple', + platforms: { + ios: { + podspecPath: + '/somewhere/react-native-test-library/apple/TestLibraryApple.podspec', + configurations: [], + scriptPhases: [], + version: '0.87.0-main', + }, + android: null, + }, + }, + }, + }; +} + +function autolinkingJsonPath(iosSourceDir) { + return path.join( + iosSourceDir, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ); +} + +// --------------------------------------------------------------------------- +// generateAutolinkingConfig +// --------------------------------------------------------------------------- + +describe('generateAutolinkingConfig', () => { + beforeEach(() => { + tmpProjects = []; + }); + + afterEach(() => { + for (const tmp of tmpProjects) { + fs.rmSync(tmp, {recursive: true, force: true}); + } + }); + + it('writes the CLI config JSON to /build/generated/autolinking/autolinking.json (autolinking.rb:41-47)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + const outPath = autolinkingJsonPath(iosDir); + expect(fs.existsSync(outPath)).toBe(true); + expect(fs.readFileSync(outPath, 'utf8')).toBe(raw); + }); + + it('writes the raw JSON unchanged — no filtering or reshaping of the upstream config (autolinking.rb:47)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const cfg = fakeCliConfig(iosDir); + const raw = JSON.stringify(cfg); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + const parsed = JSON.parse( + fs.readFileSync(autolinkingJsonPath(iosDir), 'utf8'), + ); + // iOS dep preserved + expect( + parsed.dependencies['react-native-test-library-apple'].platforms.ios + .podspecPath, + ).toBe( + cfg.dependencies['react-native-test-library-apple'].platforms.ios + .podspecPath, + ); + // Android section preserved (downstream consumer does its own iOS-only filtering) + expect(parsed.project.android).toBeDefined(); + expect( + parsed.dependencies['react-native-test-library-apple'].platforms.android, + ).toBeNull(); + }); + + it('creates the autolinking output directory if missing (autolinking.rb:46)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + + expect(fs.existsSync(path.join(iosDir, 'build'))).toBe(false); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + expect( + fs.existsSync(path.join(iosDir, 'build', 'generated', 'autolinking')), + ).toBe(true); + }); + + it('uses a no-install npx fallback when the local CLI cannot be resolved', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand /*: ?Array */ = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: cmd => { + receivedCommand = cmd; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual([ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', + ]); + }); + + it('prefers the locally resolved React Native CLI over npx', () => { + const {projectRoot} = makeTmpProject(); + const cliRoot = path.join( + projectRoot, + 'node_modules', + '@react-native-community', + 'cli', + ); + fs.mkdirSync(path.join(cliRoot, 'build'), {recursive: true}); + fs.writeFileSync( + path.join(cliRoot, 'package.json'), + JSON.stringify({bin: {'rnc-cli': 'build/bin.js'}}), + ); + fs.writeFileSync(path.join(cliRoot, 'build', 'bin.js'), ''); + + expect(resolveDefaultConfigCommand(projectRoot)).toEqual([ + process.execPath, + path.join(fs.realpathSync(cliRoot), 'build', 'bin.js'), + 'config', + ]); + }); + + it('passes projectRoot as the CWD to the cliRunner', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCwd /*: ?string */ = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: (_cmd, opts) => { + receivedCwd = opts && opts.cwd; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCwd).toBe(projectRoot); + }); + + it('throws when configCommand is empty (autolinking.rb:19-26)', () => { + const {projectRoot} = makeTmpProject(); + expect(() => + generateAutolinkingConfig({ + projectRoot, + configCommand: [], + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/config command/i); + }); + + it('throws when the CLI exits with a non-zero status (autolinking.rb:30-37)', () => { + const {projectRoot} = makeTmpProject(); + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({ + stdout: '', + stderr: 'cli failed', + exitCode: 1, + }), + }), + ).toThrow(/exit|status|1/i); + }); + + it('returns the parsed config, raw JSON, and path it wrote to', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const cfg = fakeCliConfig(iosDir); + const raw = JSON.stringify(cfg); + + const returned = generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + expect(returned).toEqual({ + config: cfg, + outputPath: autolinkingJsonPath(iosDir), + rawJson: raw, + }); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js new file mode 100644 index 000000000000..e8e790c9335d --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -0,0 +1,1420 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + AUTOGEN_MARKER, + MissingManifestError, + collectSpmSources, + expandSpmSourceGlobs, + findSelfManagedPackageDir, + generateAutolinkedPackageSwift, + generateSynthPackageSwift, + hasMixedLanguageSources, + hasPodspec, + linkHeaderTree, + main, + reactDescriptor, + reportMissingManifests, +} = require('../generate-spm-autolinking'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// reactDescriptor — the ReactDescriptor handed to autolinking plugins +// --------------------------------------------------------------------------- + +describe('reactDescriptor (plugin context.react)', () => { + it('builds a local package ref (absolute path + relPath) and the full product set when codegen exists', () => { + const d = reactDescriptor( + '/abs/app/build/xcframeworks', + '../xcframeworks', + true, + ); + expect(d.packageRef).toEqual({ + name: 'ReactNative', + path: '/abs/app/build/xcframeworks', + relPath: '../xcframeworks', + }); + // Full product set, incl. ReactAppHeaders in the SEPARATE React-GeneratedCode + // package (the entry a hand-rolled plugin would miss). + expect(d.products).toEqual([ + {name: 'ReactHeaders', package: 'ReactNative'}, + {name: 'ReactNativeHeaders', package: 'ReactNative'}, + {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, + {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, + ]); + }); + + it('omits React-GeneratedCode products when the codegen package is absent', () => { + const d = reactDescriptor( + '/abs/app/build/xcframeworks', + '../xcframeworks', + false, + ); + expect(d.products).toEqual([ + {name: 'ReactHeaders', package: 'ReactNative'}, + {name: 'ReactNativeHeaders', package: 'ReactNative'}, + {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, + ]); + // Invariant: no listed product references a package that isn't resolvable. + expect(d.products.some(p => p.package === 'React-GeneratedCode')).toBe( + false, + ); + }); + + it('returns null when there is no resolvable React dependency', () => { + expect(reactDescriptor(null, null, false)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// generateAutolinkedPackageSwift — top-level aggregator +// +// Post-refactor shape: autolinked/Package.swift is a thin meta-package that +// references each autolinked dep as its own sub-package via .package(path:), +// and re-exports them via a single AutolinkedAggregate target. Per-dep +// settings (cFlags, cxxFlags, header paths, link order) live in each synth +// sub-package — see generateSynthPackageSwift below. +// --------------------------------------------------------------------------- + +describe('generateAutolinkedPackageSwift (aggregator)', () => { + it('emits a valid swift-tools-version 6.0 package with an Autolinked product backed by AutolinkedAggregate', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).toContain('// swift-tools-version: 6.0'); + expect(result).toContain('import PackageDescription'); + expect(result).toContain( + '.library(name: "Autolinked", targets: ["AutolinkedAggregate"])', + ); + expect(result).toContain('name: "AutolinkedAggregate"'); + }); + + it('references each npm dep as .package(path: "packages/") and depends on its product', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [{swiftName: 'A'}, {swiftName: 'B'}], + }); + expect(result).toContain('.package(name: "A", path: "packages/A")'); + expect(result).toContain('.package(name: "B", path: "packages/B")'); + expect(result).toContain('.product(name: "A", package: "A")'); + expect(result).toContain('.product(name: "B", package: "B")'); + }); + + it('emits autolinking-plugin package + product contributions (path and remote)', () => { + const result = generateAutolinkedPackageSwift({ + pluginPackageDeps: [ + {name: 'ExpoModulesCore', path: '../../../../node_modules/expo'}, + {name: 'ReactNative', url: 'https://example/rn.git', version: '0.87.0'}, + ], + pluginProductDeps: [ + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ], + }); + expect(result).toContain( + '.package(name: "ExpoModulesCore", path: "../../../../node_modules/expo")', + ); + expect(result).toContain( + '.package(url: "https://example/rn.git", exact: "0.87.0")', + ); + expect(result).toContain( + '.product(name: "ExpoModulesCore", package: "ExpoModulesCore")', + ); + }); + + it('emits an eval-time missing-manifest guard naming each lib by npm name', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [ + { + swiftName: 'ReactNativeSafeAreaContext', + packagePath: 'libs/ReactNativeSafeAreaContext', + npmName: 'react-native-safe-area-context', + }, + ], + }); + // The guard runs at resolution (manifest eval) — before the Xcode sync + // build phase — so a wiped library manifest surfaces an actionable message + // instead of SwiftPM's opaque "manifest cannot be accessed". + expect(result).toContain('let __rnAutolinkedLibs'); + expect(result).toContain( + '(path: "libs/ReactNativeSafeAreaContext", npm: "react-native-safe-area-context")', + ); + expect(result).toContain('FileManager.default.fileExists'); + expect(result).toContain('npx react-native spm scaffold'); + expect(result).toContain('npx patch-package'); + expect(result).toContain('fatalError('); + // The guard reads its own location to resolve lib paths. + expect(result).toContain('#filePath'); + }); + + it('omits the guard entirely when there are no npm deps', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).not.toContain('__rnAutolinkedLibs'); + }); + + it('emits inline .target() blocks for each inlineTarget alongside AutolinkedAggregate', () => { + const result = generateAutolinkedPackageSwift({ + inlineTargets: [ + { + name: 'ScreenshotManager', + path: 'sources/ScreenshotManager', + exclude: [], + publicHeadersPath: '.', + }, + ], + xcframeworksRelPath: '../build/xcframeworks', + hasReactDep: true, + hasXcfwHeaders: true, + hasDepsHeaders: true, + }); + // ReactNative dep is needed because inline targets reference it + expect(result).toContain( + '.package(name: "ReactNative", path: "../build/xcframeworks")', + ); + // Aggregator depends on inline targets via .target(name: ...) + expect(result).toContain('.target(name: "ScreenshotManager")'); + // Inline target's own declaration appears in the targets array + expect(result).toMatch( + /name: "ScreenshotManager",[\s\S]*?path: "sources\/ScreenshotManager"/, + ); + // Inline target depends on the invariant React compile product. + expect(result).toMatch( + /name: "ScreenshotManager",[\s\S]*?\.product\(name: "ReactHeaders", package: "ReactNative"\)/, + ); + // Inline targets resolve headers via product deps — no -I flags, no VFS. + expect(result).toContain( + '.product(name: "ReactNativeHeaders", package: "ReactNative")', + ); + expect(result).toContain( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + expect(result).not.toContain('rnCoreHeaders'); + expect(result).not.toContain('-ivfsoverlay'); + expect(result).toContain('.linkedFramework("CoreGraphics")'); + }); + + it('mixes npm sub-package deps and inline targets in a single aggregator', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [{swiftName: 'NpmA'}], + inlineTargets: [ + { + name: 'LocalA', + path: 'sources/LocalA', + exclude: [], + publicHeadersPath: '.', + }, + ], + xcframeworksRelPath: '../build/xcframeworks', + hasReactDep: true, + hasXcfwHeaders: true, + }); + // Both forms of dep on AutolinkedAggregate + expect(result).toContain('.product(name: "NpmA", package: "NpmA")'); + expect(result).toContain('.target(name: "LocalA")'); + }); + + it('emits a stub aggregator when neither npmDeps nor inlineTargets are provided', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).toContain('name: "AutolinkedAggregate"'); + expect(result).not.toContain('.package(name:'); + }); +}); + +// --------------------------------------------------------------------------- +// generateSynthPackageSwift — per-dep synthesized Package.swift +// +// Each autolinked dep gets its own SPM package, written under +// autolinked/packages//Package.swift. Sources are mirrored into +// /Sources// so SPM's path-containment check passes. +// +// The synth Package.swift embeds the dep's settings (cFlags, cxxFlags, header +// paths) and declares cross-package dependencies for its transitive +// spmDependencies as sibling synth packages at path "../". +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift', () => { + function baseSpec(overrides /*: ?Object */) { + return { + swiftName: 'MyDep', + exclude: [], + publicHeadersPath: '.', + spmDependencies: [], + hasReactDep: true, + hasXcfwHeaders: true, + hasDepsHeaders: false, + codegenHeadersIncluded: false, + ...overrides, + }; + } + + it('emits a valid Package with name/product matching swiftName', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain('// swift-tools-version: 6.0'); + expect(result).toContain('name: "MyDep"'); + expect(result).toContain('.library('); + expect(result).toContain('name: "MyDep"'); + expect(result).toContain('targets: ["MyDep"]'); + // Target path points at the mirrored sources sub-dir + expect(result).toContain('path: "Sources/MyDep"'); + }); + + it('declares the library product as type: .dynamic so SPM framework-wraps it (enables includes)', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain( + '.library(name: "MyDep", type: .dynamic, targets: ["MyDep"])', + ); + }); + + it('emits DEBUG/NDEBUG config-gated cxxSettings so Fabric C++ matches the prebuilt React.framework ABI', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain('.define("DEBUG", .when(configuration: .debug))'); + expect(result).toContain( + '.define("NDEBUG", .when(configuration: .release))', + ); + }); + + it('depends on ReactNative via a fixed relative path (default synth depth)', () => { + const result = generateSynthPackageSwift(baseSpec({hasReactDep: true})); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + expect(result).toContain( + '.package(name: "React-GeneratedCode", path: "../../../ios")', + ); + expect(result).toContain( + '.product(name: "ReactHeaders", package: "ReactNative")', + ); + // Fully declarative — no runtime discovery, no Foundation import. + expect(result).not.toContain('import Foundation'); + expect(result).not.toContain('spm-paths.json'); + expect(result).not.toContain('#filePath'); + }); + + it('honors caller-supplied reactNativePackagePath / codegenPackagePath', () => { + const result = generateSynthPackageSwift( + baseSpec({ + hasReactDep: true, + reactNativePackagePath: '../../rel/xcframeworks', + codegenPackagePath: '../../rel/ios', + }), + ); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../rel/xcframeworks")', + ); + expect(result).toContain( + '.package(name: "React-GeneratedCode", path: "../../rel/ios")', + ); + }); + + it('declares sibling synth packages at path "../" for each spmDependencies entry', () => { + const result = generateSynthPackageSwift( + baseSpec({spmDependencies: [{swiftName: 'CommonDep'}]}), + ); + expect(result).toContain( + '.package(name: "CommonDep", path: "../CommonDep")', + ); + expect(result).toContain( + '.product(name: "CommonDep", package: "CommonDep")', + ); + }); + + it('serves React headers via product deps (binaryTargets + ReactAppHeaders)', () => { + const result = generateSynthPackageSwift(baseSpec({hasReactDep: true})); + expect(result).toContain( + '.product(name: "ReactNativeHeaders", package: "ReactNative")', + ); + expect(result).toContain( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + // No header-search-path vars, no flags, no legacy VFS machinery. + expect(result).not.toContain('rnCoreHeaders'); + expect(result).not.toContain('unsafeFlags(["-I"'); + expect(result).not.toContain('ReactHeadersAll'); + expect(result).not.toContain('-ivfsoverlay'); + expect(result).not.toContain('let xcfwHeaders'); + expect(result).not.toContain('let vfsOverlay'); + expect(result).not.toContain('let depsHeaders'); + }); + + it('emits exclude list when given', () => { + const result = generateSynthPackageSwift( + baseSpec({exclude: ['tests/', 'broken.m']}), + ); + expect(result).toContain('exclude: ["tests/", "broken.m"]'); + }); + + it('omits publicHeadersPath when null (not all targets expose headers)', () => { + const result = generateSynthPackageSwift( + baseSpec({publicHeadersPath: null}), + ); + expect(result).not.toContain('publicHeadersPath:'); + }); + + it('links UIKit and Foundation frameworks by default', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain( + '.linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS, .visionOS, .macCatalyst]))', + ); + expect(result).toContain( + '.linkedFramework("AppKit", .when(platforms: [.macOS]))', + ); + expect(result).toContain('.linkedFramework("Foundation")'); + }); + + // ------------------------------------------------------------------------- + // In-place mode: synth Package.swift lives in the dep's real source dir + // (target.path = ".") with an absolute appRoot. Used by the production + // emitter so Xcode can save source files normally (atomic-save through a + // symlink in autolinked/ fails with NSFileNoSuchFileError). + // ------------------------------------------------------------------------- + + it('emits no runtime discovery — fully declarative manifest', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + publicHeadersPath: 'include', + hasReactDep: true, + targetPath: '.', + }); + expect(result).not.toContain('rnSpmPaths'); + expect(result).not.toContain('spm-paths.json'); + expect(result).not.toContain('import Foundation'); + expect(result).toContain('path: "."'); + }); + + it('in-place mode: ReactNative dep path uses the default fixed relative path', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + targetPath: '.', + }); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + }); + + it('in-place mode: sibling synth refs use absolute paths from siblingSynthAbsolutePaths', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + targetPath: '.', + spmDependencies: [{swiftName: 'CommonDep'}], + siblingSynthAbsolutePaths: {CommonDep: '/abs/path/to/common'}, + }); + expect(result).toContain( + '.package(name: "CommonDep", path: "/abs/path/to/common")', + ); + expect(result).toContain( + '.product(name: "CommonDep", package: "CommonDep")', + ); + }); + + it('falls back to a relative sibling path when no absolute synth path is provided', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + targetPath: '.', + spmDependencies: [{swiftName: 'Missing'}], + siblingSynthAbsolutePaths: {}, + }); + expect(result).toContain('.package(name: "Missing", path: "../Missing")'); + }); + + it('wrapper-dir mode: target.path = "root" (a dir symlink) so Xcode atomic-save works on real files', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app/root', + autogenHeadersAbsolute: + '/abs/app/root/build/generated/autolinking/headers', + }); + expect(result).toContain('path: "root"'); + }); + + it('wrapper-dir mode: routes all includes through the single merged tree (autolinking headers folded in)', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/build/generated/autolinking/headers', + }); + // The autolinking headers dir is folded into the per-app farm (served by + // the ReactAppHeaders product) — never a separate -I. + expect(result).not.toContain( + '"-I", "/abs/app/build/generated/autolinking/headers"', + ); + }); + + it('wrapper-dir mode: omits publicHeadersPath (headers route through -I instead)', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs', + autogenHeadersAbsolute: '/abs/headers', + // publicHeadersPath intentionally not set + }); + expect(result).not.toContain('publicHeadersPath:'); + }); +}); + +// --------------------------------------------------------------------------- +// linkHeaderTree +// +// Mirrors header files from srcDir into a separate destDir via relative +// symlinks. Used for the centralized cross-package headers tree at +// /headers//. +// --------------------------------------------------------------------------- + +describe('linkHeaderTree', () => { + let tmpDirs; + + beforeEach(() => { + tmpDirs = []; + }); + + afterEach(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + function makeTmpDirs() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-headers-')); + tmpDirs.push(root); + const src = path.join(root, 'src'); + const dest = path.join(root, 'headers', 'MyDep'); + fs.mkdirSync(src, {recursive: true}); + return {root, src, dest}; + } + + it('symlinks each header from srcDir into destDir with a relative target', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'Foo.h'), '// foo\n'); + fs.writeFileSync(path.join(src, 'Foo.mm'), '// not a header — skip\n'); + + linkHeaderTree(src, dest); + + const link = path.join(dest, 'Foo.h'); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + // dest sits under root/headers/MyDep, src under root/src — relative target + // walks up two levels then back down into src/. + expect(fs.readlinkSync(link)).toBe('../../src/Foo.h'); + expect(fs.existsSync(path.join(dest, 'Foo.mm'))).toBe(false); + }); + + it('preserves nested header subdirs so resolves (ReactCommonSamples case)', () => { + const {src, dest} = makeTmpDirs(); + fs.mkdirSync(path.join(src, 'ReactCommon')); + fs.writeFileSync(path.join(src, 'ReactCommon', 'Nested.h'), '// nested\n'); + + linkHeaderTree(src, dest); + + const link = path.join(dest, 'ReactCommon', 'Nested.h'); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(link)).toBe('../../../src/ReactCommon/Nested.h'); + }); + + it('is idempotent: re-running with the same headers preserves symlink inodes', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'Stable.h'), '// h\n'); + + linkHeaderTree(src, dest); + const link = path.join(dest, 'Stable.h'); + const inoBefore = fs.lstatSync(link).ino; + + linkHeaderTree(src, dest); + expect(fs.lstatSync(link).ino).toBe(inoBefore); + }); + + it('prunes symlinks for headers that no longer exist in srcDir', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'A.h'), '// a\n'); + fs.writeFileSync(path.join(src, 'B.h'), '// b\n'); + linkHeaderTree(src, dest); + + expect(fs.existsSync(path.join(dest, 'B.h'))).toBe(true); + + // Remove B.h from src and re-run; the stale symlink should be gone. + fs.unlinkSync(path.join(src, 'B.h')); + linkHeaderTree(src, dest); + + expect(fs.existsSync(path.join(dest, 'A.h'))).toBe(true); + expect(fs.existsSync(path.join(dest, 'B.h'))).toBe(false); + }); + + it('removes destDir entirely when srcDir has no headers', () => { + const {src, dest} = makeTmpDirs(); + // No header files in src — just a non-header. + fs.writeFileSync(path.join(src, 'thing.mm'), '// impl\n'); + fs.mkdirSync(dest, {recursive: true}); + fs.writeFileSync(path.join(dest, 'Stale.h'), '// stale\n'); + + linkHeaderTree(src, dest); + + expect(fs.existsSync(dest)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// collectSpmSources — recursive auto-discovery used as the default `sources:` +// allowlist. Skip-dirs (tests/, __tests__/, android/, …) are pruned at every +// depth. Anything not matching ALL_SOURCE_EXTENSIONS is left out (no .js, +// .podspec, .md, package.json, CMakeLists.txt). +// --------------------------------------------------------------------------- + +describe('collectSpmSources', () => { + let tmpDirs; + + beforeEach(() => { + tmpDirs = []; + }); + + afterEach(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + function makeTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sources-')); + tmpDirs.push(dir); + return dir; + } + + it('returns every source file under sourcePath, sorted, forward-slash-separated', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.h'), ''); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + fs.mkdirSync(path.join(dir, 'Sub')); + fs.writeFileSync(path.join(dir, 'Sub', 'B.cpp'), ''); + fs.writeFileSync(path.join(dir, 'Sub', 'B.hpp'), ''); + + expect(collectSpmSources(dir)).toEqual([ + 'A.h', + 'A.mm', + 'Sub/B.cpp', + 'Sub/B.hpp', + ]); + }); + + it('ignores non-source files like .js, .ts, .podspec, .md, CMakeLists.txt, package.json', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'Module.mm'), ''); + fs.writeFileSync(path.join(dir, 'module.js'), ''); + fs.writeFileSync(path.join(dir, 'index.ts'), ''); + fs.writeFileSync(path.join(dir, 'My.podspec'), ''); + fs.writeFileSync(path.join(dir, 'README.md'), ''); + fs.writeFileSync(path.join(dir, 'CMakeLists.txt'), ''); + fs.writeFileSync(path.join(dir, 'package.json'), '{}'); + + expect(collectSpmSources(dir)).toEqual(['Module.mm']); + }); + + it('skips test/__tests__/__mocks__/jest/android/node_modules directories at the top level', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'Real.mm'), ''); + for (const skip of [ + 'tests', + '__tests__', + '__mocks__', + 'test', + 'jest', + 'android', + 'node_modules', + ]) { + fs.mkdirSync(path.join(dir, skip)); + fs.writeFileSync(path.join(dir, skip, 'Hidden.mm'), ''); + } + + expect(collectSpmSources(dir)).toEqual(['Real.mm']); + }); + + it('skips skip-dirs at any nesting depth (the regression that motivated the switch)', () => { + // NativeCxxModuleExample/tests/NativeCxxModuleExampleTests.cpp shape: + // the test dir lives under a nested subdir, not at the source root. + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'NativeCxxModuleExample', 'tests'), { + recursive: true, + }); + fs.writeFileSync( + path.join(dir, 'NativeCxxModuleExample', 'NativeCxxModuleExample.mm'), + '', + ); + fs.writeFileSync( + path.join( + dir, + 'NativeCxxModuleExample', + 'tests', + 'NativeCxxModuleExampleTests.cpp', + ), + '', + ); + + expect(collectSpmSources(dir)).toEqual([ + 'NativeCxxModuleExample/NativeCxxModuleExample.mm', + ]); + }); + + it('returns an empty list when sourcePath does not exist', () => { + expect(collectSpmSources('/no/such/dir/spm-test')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// expandSpmSourceGlobs — translates CocoaPods-style globs (e.g. +// 'ios/**/*.{h,m,mm}') into a sorted list of matching file paths. Skip-dir +// filtering still applies even when the pattern would otherwise match. +// --------------------------------------------------------------------------- + +describe('expandSpmSourceGlobs', () => { + let tmpDirs; + + beforeEach(() => { + tmpDirs = []; + }); + + afterEach(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + function makeTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-globs-')); + tmpDirs.push(dir); + return dir; + } + + it('** matches any depth, with brace alternation expanding extensions', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'ios', 'Sub'), {recursive: true}); + fs.writeFileSync(path.join(dir, 'ios', 'Root.h'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'Root.m'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'Sub', 'Deep.mm'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'ignored.txt'), ''); + + expect(expandSpmSourceGlobs(dir, ['ios/**/*.{h,m,mm}'])).toEqual([ + 'ios/Root.h', + 'ios/Root.m', + 'ios/Sub/Deep.mm', + ]); + }); + + it('single * stays within one segment', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'a', 'b'), {recursive: true}); + fs.writeFileSync(path.join(dir, 'a', 'Foo.mm'), ''); + fs.writeFileSync(path.join(dir, 'a', 'b', 'Bar.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['a/*.mm'])).toEqual(['a/Foo.mm']); + }); + + it('still skips SKIP_DIRS_DEFAULT even when the glob would match inside them', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'tests')); + fs.writeFileSync(path.join(dir, 'Real.mm'), ''); + fs.writeFileSync(path.join(dir, 'tests', 'Hidden.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['**/*.mm'])).toEqual(['Real.mm']); + }); + + it('multiple patterns are unioned and deduplicated', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.h'), ''); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['*.h', '*.mm', '*.{h,mm}'])).toEqual([ + 'A.h', + 'A.mm', + ]); + }); + + it('returns an empty list when no pattern matches', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['nope/*.swift'])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// generateSynthPackageSwift — sources: line rendering +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift (sources: allowlist)', () => { + it('emits a multi-line sources: array when spec.sources is non-empty', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: '.', + sources: ['root/A.mm', 'root/Sub/B.cpp'], + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + }); + expect(result).toContain('sources: ['); + expect(result).toContain('"root/A.mm"'); + expect(result).toContain('"root/Sub/B.cpp"'); + // Order matters for diff readability: sources: comes after path: and + // before publicHeadersPath:. + const sourcesIdx = result.indexOf('sources: ['); + const pathIdx = result.indexOf('path: "."'); + const publicHeadersIdx = result.indexOf('publicHeadersPath:'); + expect(pathIdx).toBeLessThan(sourcesIdx); + if (publicHeadersIdx !== -1) { + expect(sourcesIdx).toBeLessThan(publicHeadersIdx); + } + }); + + it('omits sources: line when spec.sources is null or empty (falls back to SPM auto-scan)', () => { + const a = generateSynthPackageSwift({ + swiftName: 'A', + targetPath: '.', + appRootAbsolute: '/abs', + }); + const b = generateSynthPackageSwift({ + swiftName: 'B', + targetPath: '.', + sources: [], + appRootAbsolute: '/abs', + }); + expect(a).not.toContain('sources: ['); + expect(b).not.toContain('sources: ['); + }); +}); + +// --------------------------------------------------------------------------- +// spm.name override — verifies that a non-default Swift name (set by a +// library author via react-native.config.js `spm.name`) flows verbatim into +// the synth Package.swift: target name, library name, product references, +// and sibling package paths all use the override. +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift (spm.name override)', () => { + it('uses the override Swift name for the target, library, and product', () => { + const result = generateSynthPackageSwift({ + swiftName: 'worklets', // override from spm.name (default would be "ReactNativeWorklets") + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + }); + expect(result).toContain('name: "worklets"'); + expect(result).toContain('.library(name: "worklets"'); + expect(result).toContain('targets: ["worklets"]'); + // The auto-derived name must not appear anywhere. + expect(result).not.toContain('ReactNativeWorklets'); + }); + + it('emits the override name in sibling .package(...) and .product(...) refs when a transitive dep was overridden', () => { + // Simulates the case where reanimated declares spm.dependencies on + // react-native-worklets, and worklets has set spm.name: "worklets". + // The autolinker's swiftNameByNpm map resolves the transitive to + // "worklets" before passing it to generateSynthPackageSwift. + const result = generateSynthPackageSwift({ + swiftName: 'reanimated', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + spmDependencies: [{swiftName: 'worklets'}], + siblingSynthAbsolutePaths: {worklets: '/abs/app/packages/worklets'}, + }); + expect(result).toContain( + '.package(name: "worklets", path: "/abs/app/packages/worklets")', + ); + expect(result).toContain('.product(name: "worklets", package: "worklets")'); + expect(result).not.toContain('ReactNativeWorklets'); + }); +}); + +// --------------------------------------------------------------------------- +// findSelfManagedPackageDir — detects hand-authored Package.swift at either +// the dep root or under ios/. The nested layout lets community libraries +// keep their npm-package root free of SPM artifacts (.build/, .swiftpm/). +// --------------------------------------------------------------------------- + +describe('findSelfManagedPackageDir', () => { + let depRoot; + + beforeEach(() => { + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-selfmgd-')); + }); + + afterEach(() => { + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + it('returns null when no Package.swift exists at any candidate location', () => { + expect(findSelfManagedPackageDir(depRoot)).toBe(null); + }); + + it('returns the dep root when /Package.swift exists without the AUTOGEN marker', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// swift-tools-version: 6.0\n// Hand-authored.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot); + }); + + it('returns null when /Package.swift carries the AUTOGEN marker', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + AUTOGEN_MARKER + '\n// synth wrapper content\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(null); + }); + + it('returns /ios when only the nested manifest exists and lacks the AUTOGEN marker', () => { + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// swift-tools-version: 6.0\n// Hand-authored nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); + }); + + it('prefers the root manifest when both root and nested manifests exist', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// Root manifest.\n', + ); + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// Nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot); + }); + + it('falls back to the nested manifest when the root one is autolinker-generated', () => { + // Models the transition state: dep was previously autolinker-wrapped and + // recently shipped its own ios/Package.swift. The root file (a leftover + // synth manifest from a prior run) shouldn't shadow the hand-authored one. + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + AUTOGEN_MARKER + '\n// stale synth\n', + ); + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// Hand-authored nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); + }); +}); + +// --------------------------------------------------------------------------- +// hasPodspec — does the dep ship a podspec (auto-scaffoldable)? +// --------------------------------------------------------------------------- +describe('hasPodspec', () => { + let depRoot; + + beforeEach(() => { + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podspec-')); + }); + + afterEach(() => { + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + it('returns false when no .podspec exists at the root or under ios/', () => { + expect(hasPodspec(depRoot)).toBe(false); + }); + + it('returns true for a .podspec at the dep root', () => { + fs.writeFileSync(path.join(depRoot, 'Foo.podspec'), '# podspec'); + expect(hasPodspec(depRoot)).toBe(true); + }); + + it('returns true for a .podspec under ios/', () => { + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.podspec'), '# podspec'); + expect(hasPodspec(depRoot)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Missing-manifest error — the gate that replaced the silent synth wrapper +// for community npm deps. The `error:` line prefix is the load-bearing +// contract: Xcode parses it to render a build error. +// --------------------------------------------------------------------------- +describe('MissingManifestError + reportMissingManifests', () => { + let errSpy; + + beforeEach(() => { + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errSpy.mockRestore(); + }); + + it('carries the dep list and a scaffold instruction on the error', () => { + const deps = [{name: 'Foo', npmName: 'react-native-foo', hasPodspec: true}]; + const err = new MissingManifestError(deps); + expect(err).toBeInstanceOf(Error); + expect(err.missingManifests).toEqual(deps); + expect(err.message).toContain('react-native spm scaffold'); + }); + + it('emits one `error:`-prefixed line per dep naming the npm package + fix', () => { + const err = reportMissingManifests([ + {name: 'Foo', npmName: 'react-native-foo', hasPodspec: true}, + {name: 'Bar', npmName: 'react-native-bar', hasPodspec: true}, + ]); + expect(err).toBeInstanceOf(MissingManifestError); + expect(errSpy).toHaveBeenCalledTimes(2); + const lines = errSpy.mock.calls.map(c => c[0]); + // Xcode only renders the `error: ` headline; each dep is one such message. + expect(lines.every(l => l.startsWith('error: '))).toBe(true); + expect(lines[0]).toContain('react-native-foo'); + expect(lines[0]).toContain('npx react-native spm scaffold'); + // The pressure mechanics: persist via patch-package, and the error returns + // on a fresh node_modules if you don't (no auto-scaffold/auto-restore). + expect(lines[0]).toContain('patch-package'); + expect(lines[0]).toContain('node_modules is reset'); + }); + + it('tells the user a podspec-less dep cannot be auto-scaffolded', () => { + reportMissingManifests([ + {name: 'Baz', npmName: 'react-native-baz', hasPodspec: false}, + ]); + const line = errSpy.mock.calls[0][0]; + expect(line.startsWith('error: ')).toBe(true); + expect(line).toContain('no podspec'); + expect(line).toContain('react-native-baz'); + }); + + it('gives a mixed-language dep a DISTINCT error (not "run scaffold") with an opt-out + binary path', () => { + reportMissingManifests([ + { + name: 'Screens', + npmName: 'react-native-screens', + hasPodspec: true, + mixed: true, + }, + ]); + const line = errSpy.mock.calls[0][0]; + expect(line.startsWith('error: ')).toBe(true); + expect(line).toContain('mixed Swift'); + // Must NOT tell them to scaffold — scaffolding can't fix mixed-language. + expect(line).not.toContain('react-native spm scaffold'); + // The two real escape hatches: + expect(line).toContain('react-native.config.js'); // opt out of autolinking + expect(line).toContain('platforms: { ios: null }'); + expect(line).toContain('xcframework'); // or consume as a prebuilt binary + }); +}); + +describe('hasMixedLanguageSources', () => { + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-mixed-')); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + it('is true when both .swift and .mm exist under the source dir (screens shape)', () => { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.swift'), ''); + fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.mm'), ''); + expect(hasMixedLanguageSources(root)).toBe(true); + }); + + it('is false for a pure-ObjC++ lib (svg/skia shape)', () => { + fs.mkdirSync(path.join(root, 'apple'), {recursive: true}); + fs.writeFileSync(path.join(root, 'apple', 'A.mm'), ''); + fs.writeFileSync(path.join(root, 'apple', 'B.h'), ''); + expect(hasMixedLanguageSources(root)).toBe(false); + }); + + it('ignores .swift that lives only under example/ or __tests__ (not real sources)', () => { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'ios', 'A.mm'), ''); + fs.mkdirSync(path.join(root, 'example', 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'example', 'ios', 'App.swift'), ''); + expect(hasMixedLanguageSources(root)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// main() — autolinking plugin host exemption +// +// A dep that declares an autolinking plugin OWNS its native contribution (the +// plugin returns its package/product deps). RN must NOT also try to +// source-build that dep through the community-lib path: a plugin host like +// Expo typically ships no Package.swift and is mixed Swift/ObjC, so the +// community-lib path would raise MissingManifestError before the plugin ever +// runs. The regression pair below pins that exemption down — the negative +// control proves it is load-bearing (remove the plugin and the SAME dep throws). +// --------------------------------------------------------------------------- + +describe('main() — autolinking plugin host exemption', () => { + let created = []; + let spies = []; + + beforeEach(() => { + // Silence the [generate-spm-autolinking] logger (console.log/warn/error). + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // Builds a minimal app fixture whose ONLY autolinked iOS dep is `expo`, which + // ships NO Package.swift. When `withPlugin` is set, expo declares an + // autolinking plugin in its own react-native.config.js (transitive opt-in). + function buildFixture({withPlugin}) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-host-')); + created.push(appRoot); + // rnRoot only needs to exist (main() existence-checks it, then passes it + // through as context.reactNativeRoot). + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + // package.json so findProjectRoot() resolves to appRoot. + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + // The plugin-host dep: native sources present, but NO Package.swift. + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(expoDir, 'ios', 'Expo.mm'), + '// native source\n', + ); + if (withPlugin) { + fs.writeFileSync( + path.join(expoDir, 'react-native.config.js'), + "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", + ); + fs.writeFileSync( + path.join(expoDir, 'spm-plugin.js'), + 'module.exports = function () {\n' + + ' return {\n' + + " packageDependencies: [{name: 'ExpoModulesCore', path: '../../../../node_modules/expo/ios'}],\n" + + " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],\n" + + ' };\n' + + '};\n', + ); + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + }), + ); + return {appRoot, rnRoot}; + } + + it('exempts a plugin-host dep from source-building (no MissingManifestError; plugin contribution merged)', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).not.toThrow(); + + const pkg = fs.readFileSync( + path.join(appRoot, 'build/generated/autolinking/Package.swift'), + 'utf8', + ); + // The plugin's contribution is present … + expect(pkg).toContain('.package(name: "ExpoModulesCore"'); + expect(pkg).toContain( + '.product(name: "ExpoModulesCore", package: "ExpoModulesCore")', + ); + // … and expo is NOT source-built as a community lib: no wrapper package + // reference and no eval-time missing-manifest guard. + expect(pkg).not.toContain('path: "packages/'); + expect(pkg).not.toContain('__rnAutolinkedLibs'); + }); + + it('negative control: without the plugin declaration the SAME dep throws MissingManifestError (exemption is load-bearing)', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: false}); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(MissingManifestError); + }); +}); + +// --------------------------------------------------------------------------- +// main() — plugin flavoredFrameworks sidecar +// +// Both plugin sidecars (.spm-plugin-flavored-frameworks.json, consumed by +// artifact preparation, and .spm-plugin-generated-sources.json, consumed by +// the injector) are ALWAYS rewritten — `[]` when no plugin +// declares any — so removing a plugin clears stale entries. +// --------------------------------------------------------------------------- + +describe('main() — flavoredFrameworks sidecar', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + const sidecarPath = appRoot => + path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-plugin-flavored-frameworks.json', + ); + + function scaffold(autolinkingDeps) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-flavart-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies: autolinkingDeps}), + ); + return {appRoot, rnRoot}; + } + + it('writes [] to the generated-sources sidecar when no plugin declares any (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold({}); + const genSourcesPath = path.join( + path.dirname(sidecarPath(appRoot)), + '.spm-plugin-generated-sources.json', + ); + // Pre-seed a stale manifest to prove it is overwritten, not left alone — + // the injector's reconciliation trusts this file, so a full plugin + // removal must reset it to []. + fs.writeFileSync(genSourcesPath, JSON.stringify([{path: '/stale.swift'}])); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(genSourcesPath, 'utf8'))).toEqual([]); + }); + + it('writes [] when no plugin declares flavored artifacts (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold({}); + // Pre-seed a stale sidecar to prove it is overwritten, not left alone. + fs.writeFileSync(sidecarPath(appRoot), JSON.stringify([{id: 'stale'}])); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual( + [], + ); + }); + + it('records a plugin-declared flavored framework to the sidecar', () => { + const {appRoot, rnRoot} = scaffold({ + expo: { + root: path.join('__EXPO__'), + platforms: {ios: {}}, + }, + }); + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); + // Point autolinking.json's expo root at the real dir. + fs.writeFileSync( + path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ), + JSON.stringify({ + dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + }), + ); + fs.writeFileSync( + path.join(expoDir, 'react-native.config.js'), + "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", + ); + fs.writeFileSync( + path.join(expoDir, 'spm-plugin.js'), + 'module.exports = function () {\n' + + ' return {\n' + + " packageDependencies: [{name: 'ExpoModulesCore', path: '../../../../node_modules/expo/ios'}],\n" + + " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],\n" + + ' flavoredFrameworks: [{\n' + + " id: 'expo-modules-core',\n" + + " frameworkName: 'ExpoModulesCore',\n" + + " linkage: 'dynamic',\n" + + " flavors: {debug: '/abs/debug/ExpoModulesCore.xcframework', release: '/abs/release/ExpoModulesCore.xcframework'},\n" + + ' }],\n' + + ' };\n' + + '};\n', + ); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual([ + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/abs/debug/ExpoModulesCore.xcframework', + release: '/abs/release/ExpoModulesCore.xcframework', + }, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// main() — .spm-sync-watch-paths emission (mixed dirs + files) +// +// The watch file drives the Xcode auto-sync stale check. It must carry, mixed +// and deduped/sorted: (1) each module's source DIR; (2) each npm dep's +// checked-in root Package.swift (FILE) and .react-native/ (DIR) — threaded from +// the autolinking model's dep root, not derived by walking up; (3) plugin +// watchPaths. Nonexistent paths are filtered at emission time. +// --------------------------------------------------------------------------- + +describe('main() — .spm-sync-watch-paths emission', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + function readWatchLines(appRoot) { + const contents = fs.readFileSync( + path.join(appRoot, 'build/generated/autolinking/.spm-sync-watch-paths'), + 'utf8', + ); + return contents.split('\n').filter(l => l.length > 0); + } + + it('emits source dirs + dep manifests + .react-native dirs + plugin paths, deduped and sorted', () => { + // realpath so paths derived here match a plugin's realpath'd __dirname + // (macOS /var → /private/var symlink). + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-watch-emit-')), + ); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + + // (B) A self-managed community dep: root Package.swift (no AUTOGEN marker) + // makes it self-managed; it also ships a .react-native/ metadata dir. + const fooDir = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(fooDir, {recursive: true}); + fs.writeFileSync( + path.join(fooDir, 'Package.swift'), + '// swift-tools-version:5.9\n// hand-authored\n', + ); + fs.mkdirSync(path.join(fooDir, '.react-native')); + fs.writeFileSync(path.join(fooDir, '.react-native', 'meta.json'), '{}\n'); + fs.writeFileSync(path.join(fooDir, 'Foo.swift'), '// src\n'); + + // (C) A plugin-host dep contributing watchPaths: one existing absolute path + // (kept), one absent absolute path (filtered at emission), one relative + // path (dropped by invokePlugins). + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); + fs.writeFileSync(path.join(expoDir, 'Package.swift'), '// expo manifest\n'); + fs.writeFileSync( + path.join(expoDir, 'react-native.config.js'), + "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", + ); + fs.writeFileSync( + path.join(expoDir, 'spm-plugin.js'), + [ + "const path = require('path');", + 'module.exports = function () {', + ' return {', + " packageDependencies: [{name: 'ExpoModulesCore', path: '../../../../node_modules/expo/ios'}],", + " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],", + ' watchPaths: [', + " path.join(__dirname, 'Package.swift'),", // exists → kept + " path.join(__dirname, 'MISSING.swift'),", // absent → filtered at emission + " 'rel/manifest',", // relative → dropped by invokePlugins + ' ],', + ' };', + '};', + ].join('\n') + '\n', + ); + + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-foo': {root: fooDir, platforms: {ios: {}}}, + expo: {root: expoDir, platforms: {ios: {}}}, + }, + }), + ); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const lines = readWatchLines(appRoot); + + // (A/B) foo's source dir, root manifest FILE, and .react-native DIR. + expect(lines).toContain(fooDir); + expect(lines).toContain(path.join(fooDir, 'Package.swift')); + expect(lines).toContain(path.join(fooDir, '.react-native')); + + // (C) plugin's existing absolute watchPath is kept. + expect(lines).toContain(path.join(expoDir, 'Package.swift')); + + // Filtered / dropped entries never reach the file. + expect(lines.some(l => l.includes('MISSING.swift'))).toBe(false); + expect(lines.some(l => l.includes('rel/manifest'))).toBe(false); + + // Deduped and sorted (stable, deterministic output). + expect(new Set(lines).size).toBe(lines.length); + expect([...lines].sort()).toEqual(lines); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js new file mode 100644 index 000000000000..0d96db1a5a7f --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js @@ -0,0 +1,282 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + findSourcePath, + generateXCFrameworksPackageSwift, + main, +} = require('../generate-spm-package'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// generateXCFrameworksPackageSwift +// --------------------------------------------------------------------------- + +describe('generateXCFrameworksPackageSwift', () => { + it('exposes only invariant compile-time products', () => { + const result = generateXCFrameworksPackageSwift(); + expect(result).toContain( + '.library(name: "ReactHeaders", targets: ["ReactHeaders"])', + ); + expect(result).toContain( + '.library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"])', + ); + expect(result).toContain( + '.library(name: "ReactNativeDependenciesHeaders", targets: ["ReactNativeDependenciesHeaders"])', + ); + expect(result).not.toContain('.library(name: "ReactNative"'); + expect(result).not.toContain('.library(name: "hermes-engine"'); + }); + + it('uses a Clang ReactHeaders target and header-only binary targets', () => { + const result = generateXCFrameworksPackageSwift(); + expect(result).toContain( + 'name: "ReactHeaders",\n dependencies: ["ReactNativeHeaders"]', + ); + expect(result).toContain('path: "ReactHeadersTarget"'); + expect(result).toContain('publicHeadersPath: "include"'); + expect(result).toContain( + 'name: "ReactNativeHeaders",\n path: "ReactNativeHeaders.xcframework"', + ); + expect(result).not.toContain('.binaryTarget(\n name: "React",'); + }); + + it('includes auto-generated header comment', () => { + const result = generateXCFrameworksPackageSwift(); + expect(result).toContain('AUTO-GENERATED'); + expect(result).toContain('swift-tools-version: 6.0'); + expect(result).toContain('name: "ReactNative"'); + }); +}); + +// --------------------------------------------------------------------------- +// findSourcePath +// --------------------------------------------------------------------------- + +describe('findSourcePath', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-find-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds directory matching derived name', () => { + fs.mkdirSync(path.join(tempDir, 'MyApp')); + expect(findSourcePath(tempDir, 'my-app')).toBe('MyApp'); + }); + + it('falls back to ios directory', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect(findSourcePath(tempDir, 'unknown-pkg')).toBe('ios'); + }); + + it('scans for directory with native sources', () => { + fs.mkdirSync(path.join(tempDir, 'CustomDir')); + fs.writeFileSync(path.join(tempDir, 'CustomDir', 'main.m'), ''); + expect(findSourcePath(tempDir, 'unrelated-name')).toBe('CustomDir'); + }); + + it('returns derived name when nothing found', () => { + expect(findSourcePath(tempDir, 'my-app')).toBe('MyApp'); + }); +}); + +// --------------------------------------------------------------------------- +// main — end-to-end generation of build/xcframeworks/{Package.swift,symlinks} +// from a local artifacts.json. The headers composer is injected so the +// happy paths stay inside a tempdir with no cross-package side effects. +// --------------------------------------------------------------------------- + +describe('main', () => { + let appRoot; + let rnRoot; + let origExitCode; + let logSpy; + let errSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-app-')); + rnRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-rn-')); + origExitCode = process.exitCode; + process.exitCode = undefined; + // main() is chatty via makeLogger/console.error — silence to keep output + // readable; assertions target the filesystem, not the logs. + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + process.exitCode = origExitCode; + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(rnRoot, {recursive: true, force: true}); + }); + + // Writes the app package.json so findProjectRoot/readPackageJson resolve. + function writeAppPkg(name /*: string */ = 'my-app') { + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name, version: '1.0.0'}), + 'utf8', + ); + } + + function writeArtifacts(entries /*: Array */) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-art-')); + const json = {}; + for (const name of entries) { + const xcfwPath = path.join(dir, `${name}.xcframework`); + fs.mkdirSync(xcfwPath, {recursive: true}); + json[name] = {xcframeworkPath: xcfwPath, url: 'https://example'}; + } + fs.writeFileSync( + path.join(dir, 'artifacts.json'), + JSON.stringify(json), + 'utf8', + ); + return dir; + } + + function run( + debugArtifactsDir /*:: ?: ?string */, + releaseArtifactsDir /*:: ?: ?string */, + prepare /*:: ?: ?Function */, + ) { + const argv = [ + '--app-root', + appRoot, + '--react-native-root', + rnRoot, + '--version', + '0.85.0', + ]; + if (debugArtifactsDir != null) { + argv.push('--debug-artifacts-dir', debugArtifactsDir); + } + if (releaseArtifactsDir != null) { + argv.push('--release-artifacts-dir', releaseArtifactsDir); + } + main( + argv, + prepare == null ? undefined : {prepareFlavoredFrameworks: prepare}, + ); + } + + it('prepares both flavors before generating the invariant Package.swift', () => { + writeAppPkg(); + const debugArtifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + const releaseArtifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + const prepare = jest.fn(({appRoot: root}) => { + fs.mkdirSync(path.join(root, 'build', 'xcframeworks'), {recursive: true}); + }); + try { + run(debugArtifactsDir, releaseArtifactsDir, prepare); + + expect(process.exitCode).toBeUndefined(); + expect(prepare).toHaveBeenCalledWith({ + appRoot, + debugArtifactsDir, + releaseArtifactsDir, + }); + + const pkgSwift = path.join( + appRoot, + 'build', + 'xcframeworks', + 'Package.swift', + ); + expect(fs.existsSync(pkgSwift)).toBe(true); + const contents = fs.readFileSync(pkgSwift, 'utf8'); + expect(contents).toContain('.library(name: "ReactHeaders"'); + expect(contents).not.toContain( + '.binaryTarget(\n name: "React",', + ); + } finally { + fs.rmSync(debugArtifactsDir, {recursive: true, force: true}); + fs.rmSync(releaseArtifactsDir, {recursive: true, force: true}); + } + }); + + it('requires Debug and Release together', () => { + writeAppPkg(); + const artifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + ]); + try { + expect(() => run(artifactsDir, null, jest.fn())).toThrow( + /both --debug-artifacts-dir and --release-artifacts-dir are required/, + ); + } finally { + fs.rmSync(artifactsDir, {recursive: true, force: true}); + } + }); + + it('throws when no package.json is found', () => { + // No app package.json written. + expect(() => run(null, null)).toThrow(/No package\.json/); + }); + + it('does not publish Package.swift if artifact preparation fails', () => { + writeAppPkg(); + const debugDir = writeArtifacts([]); + const releaseDir = writeArtifacts([]); + try { + expect(() => + run(debugDir, releaseDir, () => { + throw new Error('invalid release artifacts'); + }), + ).toThrow(/invalid release artifacts/); + expect( + fs.existsSync( + path.join(appRoot, 'build', 'xcframeworks', 'Package.swift'), + ), + ).toBe(false); + } finally { + fs.rmSync(debugDir, {recursive: true, force: true}); + fs.rmSync(releaseDir, {recursive: true, force: true}); + } + }); + + it('auto-detects an existing build/xcframeworks without --artifacts-dir', () => { + writeAppPkg(); + const xcfwDir = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(xcfwDir, {recursive: true}); + fs.writeFileSync(path.join(xcfwDir, 'Package.swift'), '// existing'); + run(null, null); + // No artifacts-dir: it should leave the existing manifest untouched. + expect(process.exitCode).toBeUndefined(); + expect(fs.readFileSync(path.join(xcfwDir, 'Package.swift'), 'utf8')).toBe( + '// existing', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js new file mode 100644 index 000000000000..459804f421f5 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -0,0 +1,228 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + addPreActionToScheme, + buildEmbedFrameworksScript, + buildSchemePreActionScript, + buildSyncAutolinkingScript, + flavorForBuildConfiguration, + frameworkConditionalSettings, + generateXcscheme, +} = require('../generate-spm-xcodeproj'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const FRAMEWORK = { + id: 'react', + frameworkName: 'React', + executableName: 'React', + artifactRelativePath: 'React.xcframework', + slices: [ + { + sdk: 'iphoneos*', + platform: 'ios', + variant: null, + architectures: ['arm64'], + libraryIdentifier: 'ios-arm64', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + { + sdk: 'iphonesimulator*', + platform: 'ios', + variant: 'simulator', + architectures: ['arm64', 'x86_64'], + libraryIdentifier: 'ios-arm64_x86_64-simulator', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + { + sdk: 'macosx*', + platform: 'ios', + variant: 'maccatalyst', + architectures: ['arm64', 'x86_64'], + libraryIdentifier: 'ios-arm64_x86_64-maccatalyst', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + ], +}; + +describe('scheme pre-action', () => { + it('contains the sync script and target-scoped build environment', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'SYNC_SENTINEL', + ); + expect(result).toContain(''); + expect(result).toContain('SYNC_SENTINEL'); + expect(result).toContain(''); + expect(result).toContain('BlueprintIdentifier = "TARGET_UUID"'); + }); + + it('XML-escapes the shell script', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'echo "x" > /tmp/x 2>&1 < /tmp/in', + ); + expect(result).toContain('"'); + expect(result).toContain('>'); + expect(result).toContain('&'); + expect(result).toContain('<'); + }); + + it('refreshes stale script text and is idempotent', () => { + const first = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'OLD_SCRIPT', + ); + const updated = addPreActionToScheme(first, 'TARGET_UUID', 'NEW_SCRIPT'); + expect(updated).toContain('NEW_SCRIPT'); + expect(updated).not.toContain('OLD_SCRIPT'); + expect(addPreActionToScheme(updated, 'TARGET_UUID', 'NEW_SCRIPT')).toBe( + updated, + ); + }); +}); + +describe('sync scripts', () => { + const baked = '../node_modules/react-native'; + const script = buildSyncAutolinkingScript(baked); + + it('resolves Node and React Native at build time and dispatches sync', () => { + expect(script).toContain('NODE_BINARY="${NODE_BINARY:-}"'); + expect(script).toContain('. "$SRCROOT/.xcode.env"'); + expect(script).toContain( + "require('path').dirname(require.resolve('react-native/package.json'))", + ); + expect(script).toContain( + '"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync', + ); + expect(script).toContain('npx react-native spm sync'); + }); + + it('does not select or mutate runtime framework flavors', () => { + expect(script).not.toContain('swap-flavor'); + expect(script).not.toContain('RN_SPM_FLAVOR'); + expect(script).not.toContain('React.xcframework'); + expect(script).not.toContain('BUILT_PRODUCTS_DIR'); + }); + + it('watches mixed directories/files and treats vanished paths as stale', () => { + expect(script).toContain('if [ -d "$P" ]; then'); + expect(script).toContain('elif [ -f "$P" ]; then'); + expect(script).toContain( + 'WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths"', + ); + }); + + it('is deterministic, shared with the pre-action, and valid POSIX shell', () => { + expect(buildSyncAutolinkingScript(baked)).toBe(script); + expect(buildSchemePreActionScript(baked)).toBe(script); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-script-')); + const file = path.join(dir, 'sync.sh'); + fs.writeFileSync(file, script); + try { + expect(() => execFileSync('/bin/sh', ['-n', file])).not.toThrow(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); + +describe('configuration-driven framework settings', () => { + it.each([ + ['Debug', 'debug'], + ['MyDEBUGBuild', 'debug'], + ['Development', 'debug'], + ['MyDevelopmentConfig', 'debug'], + ['Release', 'release'], + ['Staging', 'release'], + ['Production', 'release'], + ])('maps %s to %s', (configuration, expected) => { + expect(flavorForBuildConfiguration(configuration)).toBe(expected); + }); + + it('emits SDK-qualified exact framework, binary, and search paths', () => { + const settings = frameworkConditionalSettings([FRAMEWORK]); + const serialized = JSON.stringify(settings); + expect(serialized).toContain('RN_SPM_REACT_FRAMEWORK[sdk=iphoneos*]'); + expect(serialized).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]'); + expect(serialized).toContain( + '$(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64', + ); + expect(serialized).toContain('RN_SPM_REACT_SEARCH_PATH[sdk=macosx*]'); + }); + + it('uses architecture conditions when one SDK has disjoint slices', () => { + const split = { + ...FRAMEWORK, + slices: [ + {...FRAMEWORK.slices[1], architectures: ['arm64']}, + { + ...FRAMEWORK.slices[1], + architectures: ['x86_64'], + libraryIdentifier: 'ios-x86_64-simulator', + }, + ], + }; + const serialized = JSON.stringify(frameworkConditionalSettings([split])); + expect(serialized).toContain( + 'RN_SPM_REACT_FRAMEWORK[sdk=iphonesimulator*][arch=arm64]', + ); + expect(serialized).toContain( + 'RN_SPM_REACT_FRAMEWORK[sdk=iphonesimulator*][arch=x86_64]', + ); + }); +}); + +describe('embed framework phase script', () => { + const script = buildEmbedFrameworksScript([FRAMEWORK]); + + it('validates, rsyncs, and signs the exact selected framework', () => { + expect(script).toContain( + 'validate_framework "${RN_SPM_REACT_FRAMEWORK:-}" "React.framework"', + ); + expect(script).toContain( + 'copy_and_sign "${RN_SPM_REACT_FRAMEWORK:-}" "React.framework"', + ); + expect(script.indexOf('validate_framework "${RN_SPM_REACT')).toBeLessThan( + script.indexOf('copy_and_sign "${RN_SPM_REACT'), + ); + expect(script).toContain('/usr/bin/rsync -a --delete'); + expect(script).toContain('EXPANDED_CODE_SIGN_IDENTITY'); + expect(script).toContain('/usr/bin/codesign --force --sign -'); + expect(script).toContain('CODE_SIGNING_ALLOWED:-YES'); + }); + + it('reports configuration, framework, SDK, and selected source on failure', () => { + expect(script).toContain("framework '$name' is unavailable"); + expect(script).toContain("configuration '$CONFIGURATION'"); + expect(script).toContain("SDK '$SDK_NAME': $source"); + }); + + it('uses no Node, network, symlink mutation, or SwiftPM cache path', () => { + expect(script).not.toContain('node'); + expect(script).not.toContain('curl'); + expect(script).not.toContain('ln -s'); + expect(script).not.toContain('SourcePackages'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js new file mode 100644 index 000000000000..445b4c6ef31a --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -0,0 +1,444 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + injectSpmIntoPbxproj, + planInjection, +} = require('../generate-spm-xcodeproj'); +const fs = require('fs'); +const path = require('path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// Derive a CocoaPods-integrated variant by layering a Pods xcconfig onto the +// app target's Debug config (what makes in-place injection refuse). +const PODS = PLAIN.replace( + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {', + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {', +); + +const RN_PATH = '../node_modules/react-native'; + +// Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through +// a symlinked react-native would resolve to the wrong dir at build time). +const TEST_HERMES_CLI_PATH = + '/abs/node_modules/hermes-compiler/hermesc/osx-bin/hermesc'; +const TEST_FRAMEWORKS = [ + { + id: 'react', + frameworkName: 'React', + executableName: 'React', + artifactRelativePath: 'React.xcframework', + slices: [ + { + sdk: 'iphoneos*', + platform: 'ios', + variant: null, + architectures: ['arm64'], + libraryIdentifier: 'ios-arm64', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + { + sdk: 'iphonesimulator*', + platform: 'ios', + variant: 'simulator', + architectures: ['arm64', 'x86_64'], + libraryIdentifier: 'ios-arm64_x86_64-simulator', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + ], + }, +]; + +function inject( + text, + remote = null, + hermesCliPath = TEST_HERMES_CLI_PATH, + generatedSources = [], +) { + const plan = planInjection(text, {}); + expect(plan.ok).toBe(true); + return injectSpmIntoPbxproj( + text, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + RN_PATH, + remote, + hermesCliPath, + generatedSources, + TEST_FRAMEWORKS, + ); +} + +// A normalized generated source under the app root (the Expo case: +// build/generated/autolinking/expo/ExpoModulesProvider.swift). `path` is +// SRCROOT-relative, so `sourceTree = SOURCE_ROOT`. +const PROVIDER_SOURCE = { + path: 'build/generated/autolinking/expo/ExpoModulesProvider.swift', + name: 'ExpoModulesProvider.swift', + sourceTree: 'SOURCE_ROOT', + fileType: 'sourcecode.swift', +}; + +// A simple balanced-delimiter check (the injected file must stay well-formed). +function isBalanced(text) { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i++; + i++; + } + } else if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + } + } + return depth === 0; +} + +describe('planInjection', () => { + it('accepts a plain SPM-only app and resolves its anchors', () => { + const plan = planInjection(PLAIN, {}); + expect(plan.ok).toBe(true); + expect(plan.target.name).toBe('MyApp'); + expect(plan.configUuids).toHaveLength(2); // Debug + Release + expect(plan.frameworksPhaseUuid).toMatch(/^[0-9A-Fa-f]{24}$/); + // Also resolves the Sources phase (generated sources compile into it). + expect(plan.sourcesPhaseUuid).toMatch(/^[0-9A-Fa-f]{24}$/); + }); + + it('refuses a CocoaPods-integrated target (fail-closed for fallback)', () => { + const plan = planInjection(PODS, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toMatch(/CocoaPods/); + }); + + it('refuses when there is no application target', () => { + const noApp = PLAIN.replace( + '"com.apple.product-type.application"', + '"com.apple.product-type.framework"', + ); + const plan = planInjection(noApp, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toMatch(/no application target/); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { + it('adds the local package references and product dependencies', () => { + const {text} = inject(PLAIN); + expect(text).toContain('/* Begin XCLocalSwiftPackageReference section */'); + expect(text).toContain('relativePath = build/xcframeworks'); + expect(text).toContain('relativePath = build/generated/autolinking'); + expect(text).toContain('relativePath = build/generated/ios'); + // One XCSwiftPackageProductDependency per product (6). + expect(text.match(/isa = XCSwiftPackageProductDependency;/g)).toHaveLength( + 6, + ); + expect(text).toContain('productName = ReactHeaders'); + expect(text).not.toContain('productName = ReactNative;'); + expect(text).toContain('productName = Autolinked'); + expect(text).toContain('productName = ReactCodegen'); + }); + + it('wires packageReferences onto the project and product deps onto the target', () => { + const {text} = inject(PLAIN); + expect(text).toMatch(/packageReferences = \(/); + expect(text).toMatch(/packageProductDependencies = \(/); + // Product build files land in the Frameworks phase. + expect(text).toContain('ReactHeaders in Frameworks'); + }); + + it('uses remote package references in remote mode', () => { + const remote = { + url: 'https://github.com/facebook/react-native', + version: '0.87.0', + identity: 'react-native', + }; + const {text} = inject(PLAIN, remote); + expect(text).toContain('/* Begin XCRemoteSwiftPackageReference section */'); + expect(text).toContain( + 'repositoryURL = "https://github.com/facebook/react-native"', + ); + // build/xcframeworks is NOT referenced locally in remote mode. + expect(text).not.toContain('relativePath = build/xcframeworks'); + // The app's generated-code packages stay local. + expect(text).toContain('relativePath = build/generated/ios'); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { + it('merges React build settings into BOTH build configurations', () => { + const {text} = inject(PLAIN); + expect(text.match(/-ObjC/g)).toHaveLength(2); + expect(text.match(/REACT_NATIVE_PATH = /g)).toHaveLength(2); + expect(text).not.toContain('fmodule-map-file='); + expect(text).toContain('build/generated/autolinking/headers'); + expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength( + 2, + ); + // HERMES_CLI_PATH points react-native-xcode.sh at the hermes-compiler npm + // package (no hermes-engine pod under SPM), injected into both configs. + expect(text.match(/HERMES_CLI_PATH = /g)).toHaveLength(2); + expect(text).toContain(TEST_HERMES_CLI_PATH); + expect(text).toContain('RN_SPM_FLAVOR = debug'); + expect(text).toContain('RN_SPM_FLAVOR = release'); + expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphoneos*]'); + expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]'); + expect(text).toContain('$(RN_SPM_REACT_BINARY)'); + }); + + it('omits HERMES_CLI_PATH when hermesc could not be resolved', () => { + const {text} = inject(PLAIN, null, null); + expect(text).not.toContain('HERMES_CLI_PATH'); + }); + + it('prepends the Sync SPM Autolinking build phase', () => { + const {text} = inject(PLAIN); + expect(text).toContain('Sync SPM Autolinking'); + expect(text).toContain('npx react-native spm sync'); + // It runs before Sources. + const syncIdx = text.indexOf('Sync SPM Autolinking */,'); + const sourcesIdx = text.indexOf('Sources */,'); + expect(syncIdx).toBeGreaterThan(-1); + expect(syncIdx).toBeLessThan(sourcesIdx); + }); + + it('adds one generated embed phase immediately after Frameworks', () => { + const {text} = inject(PLAIN); + expect(text).not.toContain('Fix SPM Embedded Flavor'); + const bp = text.slice(text.indexOf('buildPhases = (')); + const arr = bp.slice(0, bp.indexOf(');')); + const comments = [...arr.matchAll(/\/\* ([^*]+) \*\//g)].map(m => m[1]); + expect(comments[0]).toBe('Sync SPM Autolinking'); // prepended, first + expect(comments.indexOf('Embed React Native Flavored Frameworks')).toBe( + comments.indexOf('Frameworks') + 1, + ); + expect(text).toContain('$(SRCROOT)/build/xcframeworks/.artifact-stamp'); + expect(text).toContain('$(RN_SPM_REACT_FRAMEWORK)'); + expect(text).toContain( + '$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/React.framework', + ); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { + it('wires a manifest entry into the app target (ref + build file + Sources + group)', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ + PROVIDER_SOURCE, + ]); + const [fileRefUuid, buildFileUuid] = + generatedSourceUuids[PROVIDER_SOURCE.path]; + expect(fileRefUuid).toMatch(/^[0-9A-F]{24}$/); + expect(buildFileUuid).toMatch(/^[0-9A-F]{24}$/); + + // PBXFileReference with the SRCROOT-relative path + SOURCE_ROOT tree. + expect(text).toContain(`${fileRefUuid} /* ExpoModulesProvider.swift */`); + expect(text).toContain('lastKnownFileType = sourcecode.swift'); + expect(text).toContain(`path = ${PROVIDER_SOURCE.path};`); + expect(text).toContain('sourceTree = SOURCE_ROOT;'); + + // PBXBuildFile → the file ref, and a Sources-phase membership. + expect(text).toContain( + `${buildFileUuid} /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ${fileRefUuid} /* ExpoModulesProvider.swift */;};`, + ); + // The build file is a member of the Sources phase (compiled into the app). + const sourcesPhase = text.slice( + text.indexOf('/* Begin PBXSourcesBuildPhase section */'), + ); + expect(sourcesPhase.slice(0, sourcesPhase.indexOf('/* End'))).toContain( + `${buildFileUuid} /* ExpoModulesProvider.swift in Sources */,`, + ); + + // The single "SPM Generated Sources" group, parented and holding the ref. + expect(text).toContain('/* SPM Generated Sources */ = {'); + expect(text).toContain('isa = PBXGroup;'); + const groupBlock = text.slice( + text.indexOf('/* SPM Generated Sources */ = {'), + ); + expect(groupBlock.slice(0, groupBlock.indexOf('};'))).toContain( + `${fileRefUuid} /* ExpoModulesProvider.swift */,`, + ); + // File ref + build file UUIDs are tracked for deinit. + const {injectedUuids} = inject(PLAIN, null, null, [PROVIDER_SOURCE]); + expect(injectedUuids).toEqual( + expect.arrayContaining([fileRefUuid, buildFileUuid]), + ); + expect(isBalanced(text)).toBe(true); + }); + + it('is idempotent with generated sources — a second run is byte-for-byte identical', () => { + const first = inject(PLAIN, null, null, [PROVIDER_SOURCE]).text; + const plan = planInjection(first, {}); + const second = injectSpmIntoPbxproj( + first, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + RN_PATH, + null, + null, + [PROVIDER_SOURCE], + TEST_FRAMEWORKS, + ).text; + expect(second).toBe(first); + }); + + it('stores an out-of-tree source as an absolute reference', () => { + const abs = { + path: '/opt/generated/OtherProvider.swift', + name: 'OtherProvider.swift', + sourceTree: '""', + fileType: 'sourcecode.swift', + }; + const {text} = inject(PLAIN, null, null, [abs]); + expect(text).toContain('path = /opt/generated/OtherProvider.swift;'); + expect(text).toContain('sourceTree = "";'); + }); + + it('logs loudly and skips wiring when the target has no Sources phase', () => { + const noSources = PLAIN.replace( + /\/\* Begin PBXSourcesBuildPhase section \*\/[\s\S]*?\/\* End PBXSourcesBuildPhase section \*\/\n\n/, + '', + ); + const plan = planInjection(noSources, {}); + expect(plan.ok).toBe(true); + expect(plan.sourcesPhaseUuid).toBeNull(); + + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const {text, generatedSourceUuids} = injectSpmIntoPbxproj( + noSources, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + RN_PATH, + null, + null, + [PROVIDER_SOURCE], + TEST_FRAMEWORKS, + ); + const logged = spy.mock.calls.map(c => c[0]).join('\n'); + spy.mockRestore(); + + expect(logged).toMatch(/no Sources build phase/); + // No generated source wired, but the SPM graph injection still happened. + expect(generatedSourceUuids).toEqual({}); + expect(text).not.toContain('SPM Generated Sources'); + expect(text).toContain('productName = ReactHeaders'); + }); +}); + +describe('injectSpmIntoPbxproj — invariants', () => { + it('produces a balanced (well-formed) pbxproj', () => { + const {text} = inject(PLAIN); + expect(isBalanced(PLAIN)).toBe(true); + expect(isBalanced(text)).toBe(true); + }); + + it('is idempotent — a second injection is a byte-for-byte no-op', () => { + const first = inject(PLAIN).text; + const plan = planInjection(first, {}); + const second = injectSpmIntoPbxproj( + first, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + }, + RN_PATH, + null, + null, + [], + TEST_FRAMEWORKS, + ).text; + expect(second).toBe(first); + }); + + it('keeps the diff small — only adds lines, never removes original ones', () => { + const {text} = inject(PLAIN); + // Every original line is preserved verbatim (purely additive splice). + for (const line of PLAIN.split('\n')) { + if (line.trim() === '') continue; + expect(text).toContain(line); + } + const added = text.split('\n').length - PLAIN.split('\n').length; + // Sanity bound: the complete SPM graph + conditional settings + phases is + // still a compact additive transform. + expect(added).toBeGreaterThan(0); + expect(added).toBeLessThan(220); + }); + + it('refreshes a stale shellScript on re-injection', () => { + const first = inject(PLAIN).text; + // Simulate an earlier run whose generated script has since changed (e.g. + // fixed dispatch logic) by corrupting a substring of the baked-in script. + const stale = first.replace( + 'npx react-native spm sync', + 'STALE_OLD_SYNC_COMMAND', + ); + expect(stale).not.toBe(first); + const plan = planInjection(stale, {}); + const second = injectSpmIntoPbxproj( + stale, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + }, + RN_PATH, + null, + null, + [], + TEST_FRAMEWORKS, + ).text; + // The stale marker is gone and the current script is restored. + expect(second).not.toContain('STALE_OLD_SYNC_COMMAND'); + expect(second).toContain('npx react-native spm sync'); + expect(second).toBe(first); + }); + + it('namespaces injected UUIDs by the host project root (collision-safe, stable)', () => { + const {injectedUuids} = inject(PLAIN); + // All injected UUIDs are valid 24-hex and none collide with the originals. + const originalUuids = new Set(PLAIN.match(/[0-9A-Fa-f]{24}/g)); + for (const u of injectedUuids) { + expect(u).toMatch(/^[0-9A-F]{24}$/); + expect(originalUuids.has(u)).toBe(false); + } + // Deterministic across runs. + expect(inject(PLAIN).injectedUuids).toEqual(injectedUuids); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/read-podspec-test.js b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js new file mode 100644 index 000000000000..135667fa729e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js @@ -0,0 +1,487 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {flattenSubspecs, readPodspec, regexPodspec} = require('../read-podspec'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Real-world podspec fixtures inlined as strings. The regex parser is tested +// against these directly; flattenSubspecs is tested against pod-ipc-style +// JSON objects that match what `pod ipc spec` actually emits. + +const SAFE_AREA_PODSPEC = ` +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "react-native-safe-area-context" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + s.platforms = { :ios => "12.4", :tvos => "12.4", :osx => "10.15" } + s.source = { :git => package["repository"]["url"], :tag => "#{s.version}" } + s.source_files = "ios/**/*.{h,m,mm}" + s.dependency "React-Core" +end +`; + +const SIMPLE_LIB_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.2.3" + s.source_files = "ios/**/*.{h,m,mm}" + s.public_header_files = "ios/**/*.h" + s.framework = "UIKit" + s.frameworks = ["Foundation", "CoreGraphics"] + s.dependency "React-Core" + s.dependency "React-jsi" +end +`; + +const REANIMATED_LIKE_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "RNReanimated" + s.version = "1.0.0" + s.dependency "RNWorklets" + install_modules_dependencies(s) + s.subspec "common" do |ss| + ss.source_files = "Common/cpp/reanimated/**/*.{cpp,h}" + ss.header_mappings_dir = "Common/cpp/reanimated" + ss.header_dir = "reanimated" + end + s.subspec "apple" do |ss| + ss.source_files = "apple/reanimated/**/*.{mm,h,m}" + ss.header_mappings_dir = "apple/reanimated" + end +end +`; + +const HEADER_SEARCH_PATHS_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "react-native-thing" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm}" + s.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\\"$(PODS_TARGET_SRCROOT)/common/cpp\\"" + } +end +`; + +// Helper: write a fixture to a temp file and return its path. +function writeFixture(name, content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podspec-')); + const file = path.join(dir, name); + fs.writeFileSync(file, content); + return {file, dir}; +} + +// --------------------------------------------------------------------------- +// regexPodspec — best-effort fallback when CocoaPods isn't available. +// Should handle simple RN libs cleanly and degrade gracefully on subspecs / +// install_modules_dependencies() (warns + partial = true). +// --------------------------------------------------------------------------- + +describe('regexPodspec', () => { + it('extracts name, version, source_files, dependency from a real-world simple podspec', () => { + const {file, dir} = writeFixture( + 'react-native-safe-area-context.podspec', + SAFE_AREA_PODSPEC, + ); + try { + const raw = regexPodspec(file); + expect(raw.name).toBe('react-native-safe-area-context'); + expect(raw.source_files).toEqual(['ios/**/*.{h,m,mm}']); + expect(raw.dependencies).toEqual(['React-Core']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('handles s.framework (singular method call) and s.frameworks (array assignment) together', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.frameworks.sort()).toEqual([ + 'CoreGraphics', + 'Foundation', + 'UIKit', + ]); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('collects multiple s.dependency lines in declaration order', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.dependencies).toEqual(['React-Core', 'React-jsi']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('extracts pod_target_xcconfig HEADER_SEARCH_PATHS (string form), preserving the $(PODS_TARGET_SRCROOT) token', () => { + const {file, dir} = writeFixture( + 'hsp.podspec', + HEADER_SEARCH_PATHS_PODSPEC, + ); + try { + const raw = regexPodspec(file); + const hsp = raw.pod_target_xcconfig.HEADER_SEARCH_PATHS; + expect(hsp).toContain('$(PODS_TARGET_SRCROOT)/common/cpp'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('warns on subspec blocks and install_modules_dependencies() so callers know coverage is partial', () => { + const {file, dir} = writeFixture( + 'subspec.podspec', + REANIMATED_LIKE_PODSPEC, + ); + try { + const raw = regexPodspec(file); + expect(raw.__warnings__.some(w => /Subspecs detected/.test(w))).toBe( + true, + ); + expect( + raw.__warnings__.some(w => /install_modules_dependencies/.test(w)), + ).toBe(true); + expect(raw.__regex_partial__).toBe(true); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('marks output as partial so flattenSubspecs can propagate to the PodspecModel', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.__regex_partial__).toBe(true); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); + +// --------------------------------------------------------------------------- +// flattenSubspecs — merges default_subspecs (or all subspecs) into a single +// logical PodspecModel. Tested with pod-ipc-shaped objects directly, since +// that's the shape that exercises the merging logic (regex doesn't extract +// subspec bodies). +// --------------------------------------------------------------------------- + +describe('flattenSubspecs', () => { + it('returns a model from a top-level-only spec without touching subspecs', () => { + const raw = { + name: 'react-native-foo', + version: '1.0', + source_files: 'ios/**/*.{h,m,mm}', + dependencies: {'React-Core': []}, + }; + const model = flattenSubspecs(raw); + expect(model.name).toBe('react-native-foo'); + expect(model.version).toBe('1.0'); + expect(model.sourceFiles).toEqual(['ios/**/*.{h,m,mm}']); + expect(model.dependencies).toEqual(['React-Core']); + expect(model.partial).toBe(false); + }); + + it('lifts preprocessor defines from OTHER_CFLAGS + GCC_PREPROCESSOR_DEFINITIONS (worklets shape)', () => { + const raw = { + name: 'RNWorklets', + version: '0.9.2', + pod_target_xcconfig: { + OTHER_CFLAGS: + '$(inherited) -DWORKLETS_FEATURE_FLAGS="[A:false][B:true]" -DWORKLETS_VERSION=0.9.2 ', + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Debug*]': + '$(inherited) HERMES_ENABLE_DEBUGGER=1', + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Release*]': '$(inherited)', + }, + }; + const model = flattenSubspecs(raw); + const byName = Object.fromEntries( + model.preprocessorDefines.map(d => [d.name, d]), + ); + // Quoted string-literal value kept intact (incl. its quotes). + expect(byName.WORKLETS_FEATURE_FLAGS).toEqual({ + name: 'WORKLETS_FEATURE_FLAGS', + value: '"[A:false][B:true]"', + config: null, + }); + expect(byName.WORKLETS_VERSION).toEqual({ + name: 'WORKLETS_VERSION', + value: '0.9.2', + config: null, + }); + // Per-config define scoped to debug; $(inherited) dropped. + expect(byName.HERMES_ENABLE_DEBUGGER).toEqual({ + name: 'HERMES_ENABLE_DEBUGGER', + value: '1', + config: 'debug', + }); + expect(model.preprocessorDefines).toHaveLength(3); + }); + + it('parses a multi-path HEADER_SEARCH_PATHS string with embedded quotes + recursive /** (skia shape)', () => { + const raw = { + name: 'react-native-skia', + version: '1.0', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: + '"$(PODS_TARGET_SRCROOT)/cpp/"/** "$(PODS_TARGET_SRCROOT)/cpp" "$(PODS_TARGET_SRCROOT)/cpp/skia" "$(PODS_TARGET_SRCROOT)/cpp/dawn/include"', + }, + }; + const model = flattenSubspecs(raw); + // Each space-separated, individually-quoted path becomes its own entry + // (quotes stripped); the `/**` recursive marker is preserved for translate. + expect(model.headerSearchPaths).toEqual([ + '$(PODS_TARGET_SRCROOT)/cpp//**', + '$(PODS_TARGET_SRCROOT)/cpp', + '$(PODS_TARGET_SRCROOT)/cpp/skia', + '$(PODS_TARGET_SRCROOT)/cpp/dawn/include', + ]); + }); + + it('lifts defines from s.xcconfig too, not just pod_target_xcconfig (reanimated shape)', () => { + const raw = { + name: 'RNReanimated', + version: '4.4.1', + // reanimated declares its version define in `s.xcconfig`, not + // pod_target_xcconfig (where worklets puts it). + xcconfig: { + OTHER_CFLAGS: '$(inherited) -DREANIMATED_VERSION=4.4.1', + }, + pod_target_xcconfig: { + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Debug*]': + '$(inherited) HERMES_ENABLE_DEBUGGER=1', + }, + }; + const model = flattenSubspecs(raw); + const byName = Object.fromEntries( + model.preprocessorDefines.map(d => [d.name, d]), + ); + expect(byName.REANIMATED_VERSION).toEqual({ + name: 'REANIMATED_VERSION', + value: '4.4.1', + config: null, + }); + expect(byName.HERMES_ENABLE_DEBUGGER.config).toBe('debug'); + }); + + it('drops non-define flags and unresolved tokens from OTHER_CFLAGS', () => { + const raw = { + name: 'foo', + version: '1', + pod_target_xcconfig: { + OTHER_CFLAGS: + '-Wno-comma -gen-cdb-fragment-path build/cdb -DGOOD=1 -D$(BAD_TOKEN)=x -DALSO_GOOD', + }, + }; + const model = flattenSubspecs(raw); + const names = model.preprocessorDefines.map(d => d.name).sort(); + // Only the two valid -D defines survive; -W / -gen-cdb-fragment-path and + // the unresolved $(...) token are dropped. + expect(names).toEqual(['ALSO_GOOD', 'GOOD']); + expect( + model.preprocessorDefines.find(d => d.name === 'ALSO_GOOD').value, + ).toBe(null); + }); + + it('unions source_files across selected subspecs', () => { + const raw = { + name: 'foo', + version: '1', + source_files: 'top/**/*.h', + subspecs: [ + {name: 'common', source_files: 'Common/cpp/**/*.cpp'}, + {name: 'apple', source_files: 'apple/**/*.mm'}, + ], + default_subspecs: ['common', 'apple'], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles.sort()).toEqual([ + 'Common/cpp/**/*.cpp', + 'apple/**/*.mm', + 'top/**/*.h', + ]); + }); + + it('selects ALL subspecs when default_subspecs is unset (matches CocoaPods behavior)', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + {name: 'a', source_files: 'a/**/*.h'}, + {name: 'b', source_files: 'b/**/*.h'}, + ], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles.sort()).toEqual(['a/**/*.h', 'b/**/*.h']); + }); + + it('honors default_subspecs by name — non-default subspecs are excluded', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + {name: 'core', source_files: 'core/**/*.h'}, + {name: 'optional', source_files: 'optional/**/*.h'}, + ], + default_subspecs: ['core'], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles).toEqual(['core/**/*.h']); + expect(model.sourceFiles).not.toContain('optional/**/*.h'); + }); + + it('merges pod_target_xcconfig HEADER_SEARCH_PATHS across subspecs and dedupes', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + { + name: 'a', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: + '"$(PODS_TARGET_SRCROOT)/a/cpp" "$(PODS_TARGET_SRCROOT)/shared"', + }, + }, + { + name: 'b', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: ['"$(PODS_TARGET_SRCROOT)/shared"'], + }, + }, + ], + }; + const model = flattenSubspecs(raw); + expect(model.headerSearchPaths).toEqual( + expect.arrayContaining([ + '$(PODS_TARGET_SRCROOT)/a/cpp', + '$(PODS_TARGET_SRCROOT)/shared', + ]), + ); + // dedup + const sharedCount = model.headerSearchPaths.filter( + p => p === '$(PODS_TARGET_SRCROOT)/shared', + ).length; + expect(sharedCount).toBe(1); + }); + + it('takes the first non-null header_mappings_dir (subspec layer-walk order)', () => { + const raw = { + name: 'foo', + version: '1', + // top-level has no mappings_dir + subspecs: [ + {name: 'common', header_mappings_dir: 'Common/cpp/foo'}, + {name: 'apple', header_mappings_dir: 'apple/foo'}, + ], + }; + const model = flattenSubspecs(raw); + expect(model.headerMappingsDir).toBe('Common/cpp/foo'); + }); + + it('accepts dependencies as a pod-ipc hash {name: [version]} OR as an array (regex fallback shape)', () => { + const fromIpc = flattenSubspecs({ + name: 'foo', + version: '1', + dependencies: {'React-Core': [], 'React-jsi': ['1.0']}, + }); + expect(fromIpc.dependencies.sort()).toEqual(['React-Core', 'React-jsi']); + + const fromRegex = flattenSubspecs({ + name: 'foo', + version: '1', + dependencies: ['React-Core', 'React-jsi'], + }); + expect(fromRegex.dependencies.sort()).toEqual(['React-Core', 'React-jsi']); + }); + + it('tokenizes compiler_flags from either string ("a b c") or array form', () => { + const a = flattenSubspecs({ + name: 'foo', + version: '1', + compiler_flags: '-Wno-documentation -fno-rtti', + }); + expect(a.compilerFlags).toEqual(['-Wno-documentation', '-fno-rtti']); + + const b = flattenSubspecs({ + name: 'foo', + version: '1', + compiler_flags: ['-Wno-documentation', '-fno-rtti'], + }); + expect(b.compilerFlags).toEqual(['-Wno-documentation', '-fno-rtti']); + }); + + it('propagates __regex_partial__ + __warnings__ from the regex fallback into the PodspecModel', () => { + const raw = { + name: 'foo', + version: '1', + __regex_partial__: true, + __warnings__: [ + 'Subspecs detected', + 'install_modules_dependencies detected', + ], + }; + const model = flattenSubspecs(raw); + expect(model.partial).toBe(true); + expect(model.warnings.length).toBe(2); + }); + + it('defaults requires_arc to true; explicit false is honored', () => { + expect(flattenSubspecs({name: 'a', version: '1'}).requiresArc).toBe(true); + expect( + flattenSubspecs({name: 'a', version: '1', requires_arc: false}) + .requiresArc, + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// readPodspec — orchestrator. We can't easily test the pod-ipc branch (would +// require either CocoaPods on the test runner or invasive child-process +// mocking), so we exercise the fallback path: when `pod` isn't on PATH, the +// regex parser kicks in transparently. +// --------------------------------------------------------------------------- + +describe('readPodspec', () => { + it('throws a clear error when the file does not exist', () => { + expect(() => readPodspec('/no/such/file.podspec')).toThrow( + /does not exist/, + ); + }); + + it('returns a flattened PodspecModel for a simple podspec (regex fallback path)', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + // We can't force pod-ipc to fail without mocking, but the regex + // parser produces a complete enough model that the test assertions + // hold regardless of which branch ran. + const model = readPodspec(file); + expect(model.name).toBe('react-native-foo'); + expect(model.version).toBe('1.2.3'); + expect(model.sourceFiles).toContain('ios/**/*.{h,m,mm}'); + expect(model.dependencies).toEqual( + expect.arrayContaining(['React-Core', 'React-jsi']), + ); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js new file mode 100644 index 000000000000..0282e4bf57fc --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -0,0 +1,395 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + SPM_INJECTED_MARKER, + injectSpmIntoExistingXcodeproj, + readArtifactsVersionOverride, + removeSpmInjection, +} = require('../generate-spm-xcodeproj'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +let scaffoldedAppRoots = []; + +afterEach(() => { + for (const appRoot of scaffoldedAppRoots) { + fs.rmSync(appRoot, {recursive: true, force: true}); + } + scaffoldedAppRoots = []; +}); + +// Build a throwaway app dir: /MyApp.xcodeproj/project.pbxproj seeded with +// the plain (SPM-only) fixture, and a node_modules/react-native sibling so the +// relative reactNativePath resolves. +function scaffoldApp() { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deinit-')); + scaffoldedAppRoots.push(appRoot); + const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8'); + const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + fs.mkdirSync(rnRoot, {recursive: true}); + const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(artifactRoot, {recursive: true}); + fs.writeFileSync( + path.join(artifactRoot, 'flavored-frameworks.json'), + JSON.stringify({version: 1, frameworks: []}), + ); + fs.writeFileSync(path.join(artifactRoot, '.artifact-stamp'), 'test\n'); + return {appRoot, xcodeprojPath, rnRoot}; +} + +function pbxprojOf(xcodeprojPath) { + return fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8'); +} + +// Absolute source paths under the app root — mirrors what Expo emits +// (/expo/ExpoModulesProvider.swift). The injector normalizes these +// to SRCROOT-relative. +const PROVIDER_REL = + 'build/generated/autolinking/expo/ExpoModulesProvider.swift'; +const OTHER_REL = 'build/generated/autolinking/other/OtherProvider.swift'; + +const GENERATED_SOURCES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-generated-sources.json', +); + +function writeManifest(appRoot, relPaths) { + const manifestPath = path.join(appRoot, GENERATED_SOURCES_MANIFEST); + fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); + fs.writeFileSync( + manifestPath, + JSON.stringify( + relPaths.map(rel => ({path: path.join(appRoot, rel)})), + null, + 2, + ), + 'utf8', + ); +} + +function readMarker(xcodeprojPath) { + return JSON.parse( + fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'), + ); +} + +describe('removeSpmInjection — the surgical inverse of add', () => { + it('round-trips: add then deinit restores the pbxproj byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + + const injected = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(injected.status).toBe('injected'); + // It actually changed something + wrote the marker. + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + true, + ); + + const removed = removeSpmInjection({appRoot, xcodeprojPath}); + expect(removed.status).toBe('removed'); + // Byte-identical to the pre-add pbxproj. + expect(pbxprojOf(xcodeprojPath)).toBe(before); + // Marker is gone. + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + }); + + it('preserves an unrelated edit made to the pbxproj after add', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + // Simulate a user edit AFTER injection: flip the deployment target. + const edited = pbxprojOf(xcodeprojPath).replace( + /IPHONEOS_DEPLOYMENT_TARGET = [0-9.]+;/g, + 'IPHONEOS_DEPLOYMENT_TARGET = 18.0;', + ); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + edited, + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + + const after = pbxprojOf(xcodeprojPath); + // The user's edit survives… + expect(after).toContain('IPHONEOS_DEPLOYMENT_TARGET = 18.0;'); + // …and all SPM injection is gone. + expect(after).not.toContain('Sync SPM Autolinking'); + expect(after).not.toContain('build/generated/autolinking/headers'); + expect(after).not.toContain('REACT_NATIVE_PATH'); + expect(after).not.toMatch(/relativePath = build\/xcframeworks/); + }); + + it('is a no-op (status: absent) when the project was never injected', () => { + const {appRoot, xcodeprojPath} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const result = removeSpmInjection({appRoot, xcodeprojPath}); + expect(result.status).toBe('absent'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('round-trips WITH a generated-sources manifest (add then deinit is byte-identical)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeManifest(appRoot, [PROVIDER_REL]); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + // The generated source was actually wired in. + expect(after).toContain('ExpoModulesProvider.swift'); + expect(after).toContain('SPM Generated Sources'); + // Stored SRCROOT-relative (under the app root). + expect(after).toContain(`path = ${PROVIDER_REL};`); + expect(after).toContain('sourceTree = SOURCE_ROOT;'); + + // Marker round-trip: the generatedSources section maps the normalized path. + const marker = readMarker(xcodeprojPath); + expect(Object.keys(marker.generatedSources)).toEqual([PROVIDER_REL]); + expect(marker.generatedSources[PROVIDER_REL]).toHaveLength(2); + + const removed = removeSpmInjection({appRoot, xcodeprojPath}); + expect(removed.status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); + +describe('generated-sources reconciliation on update', () => { + it('removes exactly the UUIDs of an entry dropped from the manifest, keeping the rest', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + // First run: two generated sources. + writeManifest(appRoot, [PROVIDER_REL, OTHER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const marker1 = readMarker(xcodeprojPath); + const droppedUuids = marker1.generatedSources[OTHER_REL]; + const keptUuids = marker1.generatedSources[PROVIDER_REL]; + expect(droppedUuids).toHaveLength(2); + + // Second run (simulating `spm update`): OTHER dropped from the manifest. + writeManifest(appRoot, [PROVIDER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + + // Exactly the dropped entry's objects are gone… + for (const u of droppedUuids) { + expect(after).not.toContain(u); + } + expect(after).not.toContain('OtherProvider.swift'); + // …the kept entry + the group survive. + for (const u of keptUuids) { + expect(after).toContain(u); + } + expect(after).toContain('ExpoModulesProvider.swift'); + expect(after).toContain('SPM Generated Sources'); + + // Marker no longer lists the dropped entry. + const marker2 = readMarker(xcodeprojPath); + expect(Object.keys(marker2.generatedSources)).toEqual([PROVIDER_REL]); + }); + + it('re-injecting an unchanged manifest is byte-for-byte identical', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeManifest(appRoot, [PROVIDER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(first); + }); + + it('retires the group when the last generated source leaves the manifest', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeManifest(appRoot, [PROVIDER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + // Manifest becomes empty on the next update. + writeManifest(appRoot, []); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('ExpoModulesProvider.swift'); + expect(after).not.toContain('SPM Generated Sources'); + expect(readMarker(xcodeprojPath).generatedSources).toEqual({}); + }); + + it('injects nothing generated-source-related when no manifest exists', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('SPM Generated Sources'); + expect(readMarker(xcodeprojPath).generatedSources).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// artifactsVersionOverride — the marker field persisting an explicit +// `spm add/update --version ` pin (see setup-apple-spm.js / +// sync-spm-autolinking.js). SETS on an explicit override; PRESERVES a +// previously-recorded value when the caller omits one; deinit drops it along +// with the rest of the marker. +// --------------------------------------------------------------------------- +describe('artifactsVersionOverride marker field', () => { + it('records an explicit override into the marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBe('0.80.0'); + expect(readArtifactsVersionOverride(appRoot)).toBe('0.80.0'); + }); + + it('defaults to null when no --version override has ever been given', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBeNull(); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); + + it('preserves a previously-recorded override on a later run without --version', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + // A later `update` (no --version) must not erase the pin. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBe('0.80.0'); + expect(readArtifactsVersionOverride(appRoot)).toBe('0.80.0'); + }); + + it('a later explicit --version overwrites the previous pin', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.81.0', + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBe('0.81.0'); + expect(readArtifactsVersionOverride(appRoot)).toBe('0.81.0'); + }); + + it('deinit drops the override along with the whole marker (no clear verb yet)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// readArtifactsVersionOverride — pure fs read, used by the build-time sync +// (sync-spm-autolinking.js) to prefer a pinned version over the one derived +// from node_modules/react-native/package.json. +// --------------------------------------------------------------------------- +describe('readArtifactsVersionOverride', () => { + it('returns null when no xcodeproj has been injected yet', () => { + const {appRoot} = scaffoldApp(); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); + + it('returns null (never throws) on a malformed marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + '{ not valid json', + 'utf8', + ); + expect(() => readArtifactsVersionOverride(appRoot)).not.toThrow(); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js new file mode 100644 index 000000000000..7e18c6b1e043 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -0,0 +1,1140 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + SCAFFOLDER_MARKER, + SCAFFOLDER_VERSION, + emitScaffoldedPackageSwift, + scaffoldAll, + scaffoldPackageSwiftForDep, + translatePodspecToSpmTarget, +} = require('../scaffold-package-swift'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Minimal PodspecModel fixture builder so each test stays focused on the +// field it exercises. +function podspec(overrides /*: Object */ = {}) { + return { + name: 'react-native-foo', + version: '1.0', + sourceFiles: [], + publicHeaderFiles: [], + privateHeaderFiles: [], + excludeFiles: [], + headerMappingsDir: null, + headerMappingsDirs: [], + headerDir: null, + frameworks: [], + weakFrameworks: [], + libraries: [], + dependencies: [], + compilerFlags: [], + headerSearchPaths: [], + preprocessorDefines: [], + resources: [], + requiresArc: true, + warnings: [], + partial: false, + usesInstallModulesDependencies: false, + ...overrides, + }; +} + +function autolinkedDep(overrides = {}) { + return { + name: 'react-native-foo', + root: '/fake/node_modules/react-native-foo', + platforms: { + ios: { + podspecPath: + '/fake/node_modules/react-native-foo/react-native-foo.podspec', + }, + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// translatePodspecToSpmTarget — pure: PodspecModel + AutolinkedDep → +// SpmScaffoldSpec. Buckets deps, substitutes Xcode tokens, validates names. +// --------------------------------------------------------------------------- + +describe('translatePodspecToSpmTarget', () => { + it('always uses toSwiftName(npm-name) as the SPM target name — header_dir does NOT change the target name', () => { + // The autolinker registers every autolinked dep under toSwiftName(npmName) + // in its aggregator. The scaffolded Package.swift's product MUST match + // that or SPM resolution fails on the .product(name:, package:) lookup. + // header_dir flows through headerSearchPaths instead. + const model = podspec({ + headerDir: 'react/renderer/components/safeareacontext', + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-safe-area-context'}), + ); + expect(spec.swiftName).toBe('ReactNativeSafeAreaContext'); + }); + + it('adds dirname(header_mappings_dir) as a header search path so namespaced includes resolve (reanimated/worklets pattern)', () => { + // reanimated/worklets ship headers at `apple/reanimated/...` and + // `Common/cpp/reanimated/...` with per-subspec header_mappings_dir, and + // include them as ``. SPM has no header_mappings_dir copy + // step, so the parent of each mappings dir must be on the search path. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'apple', 'reanimated'), {recursive: true}); + fs.mkdirSync(path.join(root, 'Common', 'cpp', 'reanimated'), { + recursive: true, + }); + const model = podspec({ + headerMappingsDirs: ['Common/cpp/reanimated', 'apple/reanimated'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated', root}), + ); + expect(spec.headerSearchPaths).toContain('apple'); + expect(spec.headerSearchPaths).toContain('Common/cpp'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('skips a header_mappings_dir whose parent dir does not exist on disk', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + const model = podspec({headerMappingsDirs: ['nope/reanimated']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.headerSearchPaths).not.toContain('nope'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('wires a pod-style dependency (RNWorklets) to its npm sibling via the podToNpm index', () => { + const model = podspec({dependencies: ['RNWorklets', 'React-jsi']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + new Map([ + ['RNWorklets', 'react-native-worklets'], + ['RNReanimated', 'react-native-reanimated'], + ]), + ); + // RNWorklets → sibling; React-jsi → collapses into ReactNative core. + expect(spec.siblingNames).toContain('react-native-worklets'); + expect(spec.coreReactNative).toBe(true); + }); + + it('does not self-wire when a pod dependency maps back to the dep itself', () => { + const model = podspec({dependencies: ['RNReanimated']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + new Map([['RNReanimated', 'react-native-reanimated']]), + ); + expect(spec.siblingNames).not.toContain('react-native-reanimated'); + }); + + it('derives publicHeadersPath from header_mappings_dir, preferring the cross-platform (Common) namespace root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wk-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'Common', 'cpp', 'worklets'), { + recursive: true, + }); + fs.mkdirSync(path.join(root, 'apple', 'worklets'), {recursive: true}); + const model = podspec({ + headerMappingsDirs: ['Common/cpp/worklets', 'apple/worklets'], + publicHeaderFiles: ['Common/cpp/worklets/**/*.h'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-worklets', root}), + ); + // Common/cpp (parent of Common/cpp/worklets) is what dependents need to + // resolve ; the apple/ root is not preferred. + expect(spec.publicHeadersPath).toBe('Common/cpp'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('header-map emulation: adds every header-containing subdir to the search path (flat-include libs like svg)', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hmap-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'apple', 'Elements'), {recursive: true}); + fs.mkdirSync(path.join(root, 'apple', 'Text'), {recursive: true}); + fs.writeFileSync(path.join(root, 'apple', 'Elements', 'A.h'), ''); + fs.writeFileSync(path.join(root, 'apple', 'Text', 'B.h'), ''); + fs.writeFileSync(path.join(root, 'apple', 'C.mm'), ''); + const model = podspec({ + sourceFiles: ['apple/Elements/A.h', 'apple/Text/B.h', 'apple/C.mm'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-svg', root}), + ); + expect(spec.headerSearchPaths).toContain('apple/Elements'); + expect(spec.headerSearchPaths).toContain('apple/Text'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('expands a recursive `/**` HEADER_SEARCH_PATH into the base dir + all subdirs (skia shape)', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'cpp', 'skia', 'include', 'core'), { + recursive: true, + }); + const model = podspec({ + headerSearchPaths: ['$(PODS_TARGET_SRCROOT)/cpp//**'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-skia', root}), + ); + expect(spec.headerSearchPaths).toContain('cpp'); // base + expect(spec.headerSearchPaths).toContain('cpp/skia'); // makes resolve + expect(spec.headerSearchPaths).toContain('cpp/skia/include/core'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('flags needsObjCPrefix (and adds "." to the search path) when the target has ObjC(++) sources', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'objc-scaffold-')); + try { + fs.writeFileSync(path.join(root, 'A.mm'), ''); + const model = podspec({sourceFiles: ['A.mm', 'B.cpp']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.needsObjCPrefix).toBe(true); + expect(spec.headerSearchPaths).toContain('.'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does not flag needsObjCPrefix for a C++-only target', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cpp-scaffold-')); + try { + fs.writeFileSync(path.join(root, 'A.cpp'), ''); + const model = podspec({sourceFiles: ['A.cpp']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.needsObjCPrefix).toBe(false); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does not add "." for a single-segment header_mappings_dir', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + const model = podspec({headerMappingsDirs: ['ios']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.headerSearchPaths).not.toContain('.'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('still uses toSwiftName(npm-name) even when header_dir is a plain identifier (matches autolinker registration)', () => { + const model = podspec({headerDir: 'reanimated'}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + ); + expect(spec.swiftName).toBe('ReactNativeReanimated'); + }); + + it('falls back cleanly when header_dir is absent', () => { + const model = podspec({headerDir: null}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo-bar'}), + ); + expect(spec.swiftName).toBe('ReactNativeFooBar'); + }); + + it('substitutes $(PODS_TARGET_SRCROOT) in HEADER_SEARCH_PATHS with the target-relative form', () => { + const model = podspec({ + headerSearchPaths: ['$(PODS_TARGET_SRCROOT)/common/cpp'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.headerSearchPaths).toEqual(['common/cpp']); + }); + + it('drops HEADER_SEARCH_PATHS entries with unresolved Xcode tokens and warns', () => { + const model = podspec({ + headerSearchPaths: [ + '$(PODS_TARGET_SRCROOT)/ok', + '$(SOMETHING_UNKNOWN)/foo', + ], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.headerSearchPaths).toEqual(['ok']); + expect(spec.warnings.some(w => /SOMETHING_UNKNOWN/.test(w))).toBe(true); + }); + + it('buckets React-Core / React-jsi / RCT-Folly / glog into the single ReactNative product', () => { + const model = podspec({ + dependencies: ['React-Core', 'React-jsi', 'RCT-Folly', 'glog'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(true); + expect(spec.siblingNames).toEqual([]); + }); + + it('routes sibling RN deps (react-native-*) into siblingNames', () => { + const model = podspec({ + dependencies: ['React-Core', 'react-native-worklets'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(true); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + }); + + it('treats a package.json codegenConfig as an implicit React-core dep (New-Arch libs strip install_modules_dependencies — svg shape)', () => { + // svg declares its React-core dep only via install_modules_dependencies(s), + // which we strip — so model.dependencies has NO React-Core. The codegenConfig + // marker is what tells us it still needs the React-GeneratedCode package. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'react-native-svg', + codegenConfig: {name: 'rnsvg'}, + }), + ); + const model = podspec({dependencies: []}); // nothing explicit + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-svg', root}), + ); + expect(spec.coreReactNative).toBe(true); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('treats install_modules_dependencies (no codegenConfig) as an implicit React-core dep (rn-tester TestLibrary shape)', () => { + // A plain ObjC module (rn-tester's TestLibraryApple/Common) wires React + // core ONLY via install_modules_dependencies(s) and has NO codegenConfig. + // The stripped helper leaves model.dependencies without React-Core, so the + // usesInstallModulesDependencies marker is what keeps coreReactNative true. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'imd-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({name: 'TestLibraryApple'}), // no codegenConfig + ); + const model = podspec({ + dependencies: [], + usesInstallModulesDependencies: true, + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'TestLibraryApple', root}), + ); + expect(spec.coreReactNative).toBe(true); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does NOT force coreReactNative for a non-codegen dep with no React deps', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'no-codegen-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({name: 'react-native-foo'}), // no codegenConfig + ); + const model = podspec({dependencies: []}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.coreReactNative).toBe(false); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('warns + drops unknown non-RN dependencies (MMKV, AFNetworking)', () => { + const model = podspec({dependencies: ['MMKV', 'AFNetworking']}); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(false); + expect(spec.siblingNames).toEqual([]); + expect(spec.warnings.some(w => /MMKV/.test(w))).toBe(true); + expect(spec.warnings.some(w => /AFNetworking/.test(w))).toBe(true); + }); + + it('silently drops cross-subspec refs like "react-native-foo/common" from the same podspec', () => { + // CocoaPods uses this for one subspec depending on another from the + // SAME spec — after flattenSubspecs merges everything into one SPM + // target the ref is meaningless. Must not be treated as a sibling. + const model = podspec({ + name: 'react-native-safe-area-context', + dependencies: ['React-Core', 'react-native-safe-area-context/common'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-safe-area-context'}), + ); + expect(spec.siblingNames).toEqual([]); + expect(spec.coreReactNative).toBe(true); + }); + + it('strips subspec suffix from sibling RN deps ("react-native-worklets/foo" → "react-native-worklets")', () => { + const model = podspec({ + dependencies: ['react-native-worklets/foo', 'react-native-worklets/bar'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + }); + + it('passes through frameworks, weak frameworks, compiler flags, resources', () => { + const model = podspec({ + frameworks: ['UIKit', 'CoreMotion'], + weakFrameworks: ['SafariServices'], + compilerFlags: ['-Wno-documentation'], + resources: ['Foo.png'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.extraFrameworks).toEqual(['UIKit', 'CoreMotion']); + expect(spec.weakFrameworks).toEqual(['SafariServices']); + expect(spec.compilerFlags).toEqual(['-Wno-documentation']); + expect(spec.resources).toEqual(['Foo.png']); + }); + + it('expands podspec source globs into explicit file paths against the dep root, and infers publicHeadersPath', () => { + // Fake a dep on disk so glob expansion can find real files. + const depDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-translate-')); + try { + fs.mkdirSync(path.join(depDir, 'ios', 'Sub'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'Foo.h'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Foo.mm'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Sub', 'Bar.h'), ''); + const model = podspec({sourceFiles: ['ios/**/*.{h,m,mm}']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({root: depDir}), + ); + // SPM rejects globs — these must be explicit relative paths now. + expect(spec.sources).toEqual( + expect.arrayContaining(['ios/Foo.h', 'ios/Foo.mm', 'ios/Sub/Bar.h']), + ); + // publicHeadersPath is inferred from the first existing prefix dir + // (so SPM's "publicHeadersPath defaults to non-existent include/" + // error doesn't fire). + expect(spec.publicHeadersPath).toBe('ios'); + } finally { + fs.rmSync(depDir, {recursive: true, force: true}); + } + }); + + it('filters out files matching exclude_files globs after expansion', () => { + const depDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-translate-')); + try { + fs.mkdirSync(path.join(depDir, 'ios', 'Fabric'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'KeepMe.h'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Fabric', 'SkipMe.h'), ''); + const model = podspec({ + sourceFiles: ['ios/**/*.h'], + excludeFiles: ['ios/Fabric/**'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({root: depDir}), + ); + expect(spec.sources).toContain('ios/KeepMe.h'); + expect(spec.sources).not.toContain('ios/Fabric/SkipMe.h'); + } finally { + fs.rmSync(depDir, {recursive: true, force: true}); + } + }); +}); + +// --------------------------------------------------------------------------- +// emitScaffoldedPackageSwift — pure: SpmScaffoldSpec → Swift string. +// Snapshot-style "contains" assertions on the key emitted lines. +// --------------------------------------------------------------------------- + +describe('emitScaffoldedPackageSwift', () => { + function baseSpec(overrides = {}) { + return { + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + preprocessorDefines: [], + needsObjCPrefix: false, + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + ...overrides, + }; + } + + it('contains the SCAFFOLDER marker (after the line-1 swift-tools-version directive) and NOT the autolinker AUTOGEN marker', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + // Line 1 is reserved for the swift-tools-version directive — SPM ignores + // it elsewhere. The scaffolder marker lives on a subsequent line. + expect(out.split('\n', 1)[0]).toMatch(/^\/\/ swift-tools-version: /); + expect(out).toContain(SCAFFOLDER_MARKER); + // The autolinker's marker — must be absent so isSelfManagedPackage + // treats this file as self-managed. + expect(out).not.toContain( + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js', + ); + }); + + it('includes a cache-slot label comment when provided (bumps SPM manifest hash on slot change)', () => { + const out = emitScaffoldedPackageSwift(baseSpec(), { + cacheSlotLabel: '0.87.0-nightly-20260513-abc/debug', + }); + expect(out).toContain('// Cache slot: 0.87.0-nightly-20260513-abc/debug'); + }); + + it('emits DEBUG/NDEBUG config-gated cxxSettings so Fabric C++ matches the prebuilt React.framework ABI', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + expect(out).toContain('.define("DEBUG", .when(configuration: .debug))'); + expect(out).toContain('.define("NDEBUG", .when(configuration: .release))'); + }); + + it('is fully declarative — no runtime discovery code, no Foundation import', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + expect(out).not.toContain('import Foundation'); + expect(out).not.toContain('#filePath'); + expect(out).not.toContain('FileManager'); + }); + + it('emits a header-search-path directive per podspec entry (.headerSearchPath("common/cpp"))', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({headerSearchPaths: ['common/cpp']}), + ); + expect(out).toContain('.headerSearchPath("common/cpp")'); + }); + + it('declares the ReactNative package + product via scaffold-time relative paths when coreReactNative is true', () => { + const out = emitScaffoldedPackageSwift(baseSpec({coreReactNative: true}), { + cacheSlotLabel: null, + remote: null, + codegenPackageDir: '../../ios/build/generated/ios', + localXcfwPackageDir: '../../ios/build/xcframeworks', + }); + expect(out).toContain( + '.package(name: "ReactNative", path: "../../ios/build/xcframeworks")', + ); + expect(out).toContain( + '.package(name: "React-GeneratedCode", path: "../../ios/build/generated/ios")', + ); + expect(out).toContain( + '.product(name: "ReactHeaders", package: "ReactNative")', + ); + }); + + it('throws when coreReactNative is set but no codegenPackageDir was provided', () => { + expect(() => + emitScaffoldedPackageSwift(baseSpec({coreReactNative: true})), + ).toThrow(/codegenPackageDir is required/); + }); + + it('remote mode: declares .package(url:exact:) and needs no local xcframeworks path', () => { + const out = emitScaffoldedPackageSwift(baseSpec({coreReactNative: true}), { + cacheSlotLabel: null, + remote: { + url: 'https://github.com/facebook/react-native-apple', + version: '0.87.0', + identity: 'react-native-apple', + }, + codegenPackageDir: '../../ios/build/generated/ios', + localXcfwPackageDir: null, + }); + expect(out).toContain( + '.package(url: "https://github.com/facebook/react-native-apple", exact: "0.87.0")', + ); + expect(out).toContain( + '.product(name: "ReactHeaders", package: "react-native-apple")', + ); + expect(out).not.toContain('build/xcframeworks'); + }); + + it('emits sibling .package(path: "../") + .product entries for sibling RN deps', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({siblingNames: ['react-native-worklets']}), + ); + // Path uses the libs/ symlink name (where the autolinker places + // the sibling), NOT the npm name — `../react-native-worklets` would be + // `libs/react-native-worklets`, which does not exist. + expect(out).toContain( + '.package(name: "ReactNativeWorklets", path: "../ReactNativeWorklets")', + ); + expect(out).toContain( + '.product(name: "ReactNativeWorklets", package: "ReactNativeWorklets")', + ); + }); + + it('-includes the ObjC prefix header in c/cxx settings when needsObjCPrefix is set', () => { + const withPrefix = emitScaffoldedPackageSwift( + baseSpec({needsObjCPrefix: true}), + ); + expect( + ( + withPrefix.match( + /\.unsafeFlags\(\["-include", "react-native-spm-prefix\.h"\]\)/g, + ) ?? [] + ).length, + ).toBe(2); // cSettings + cxxSettings + // Not emitted for a C/C++-only target. + const noPrefix = emitScaffoldedPackageSwift( + baseSpec({needsObjCPrefix: false}), + ); + expect(noPrefix).not.toContain('-include'); + }); + + it('emits preprocessor defines as .define(...) in c/cxx settings, escaping quoted values and honoring config', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({ + preprocessorDefines: [ + {name: 'WORKLETS_VERSION', value: '0.9.2', config: null}, + { + name: 'WORKLETS_FEATURE_FLAGS', + value: '"[A:false][B:true]"', + config: null, + }, + {name: 'HERMES_ENABLE_DEBUGGER', value: '1', config: 'debug'}, + {name: 'NDEBUG', value: null, config: 'release'}, + ], + }), + ); + expect(out).toContain('.define("WORKLETS_VERSION", to: "0.9.2")'); + // Embedded quotes escaped for the Swift string literal. + expect(out).toContain( + '.define("WORKLETS_FEATURE_FLAGS", to: "\\"[A:false][B:true]\\"")', + ); + expect(out).toContain( + '.define("HERMES_ENABLE_DEBUGGER", to: "1", .when(configuration: .debug))', + ); + // Valueless define + release config. + expect(out).toContain('.define("NDEBUG", .when(configuration: .release))'); + }); + + it('emits sources: array when podspec declared globs', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({sources: ['ios/**/*.{h,m,mm}', 'common/cpp/**/*.{cpp,h}']}), + ); + expect(out).toContain('sources: ['); + expect(out).toContain('"ios/**/*.{h,m,mm}"'); + expect(out).toContain('"common/cpp/**/*.{cpp,h}"'); + }); + + it('omits sources: line when no globs (SPM auto-scans target dir)', () => { + const out = emitScaffoldedPackageSwift(baseSpec({sources: []})); + expect(out).not.toContain('sources: ['); + }); + + it('emits publicHeadersPath when header_mappings_dir set', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({publicHeadersPath: 'common/cpp/foo'}), + ); + expect(out).toContain('publicHeadersPath: "common/cpp/foo"'); + }); + + it('dedups linker frameworks (default + extras = no UIKit twice)', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({extraFrameworks: ['UIKit', 'CoreMotion']}), + ); + const uikitCount = (out.match(/\.linkedFramework\("UIKit",/g) || []).length; + expect(uikitCount).toBe(1); + expect(out).toContain( + '.linkedFramework("AppKit", .when(platforms: [.macOS]))', + ); + expect(out).toContain('.linkedFramework("CoreMotion")'); + }); + + it('embeds podspec compiler_flags into cxxSettings unsafeFlags', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({compilerFlags: ['-Wno-documentation']}), + ); + expect(out).toContain('"-Wno-documentation"'); + }); +}); + +// --------------------------------------------------------------------------- +// scaffoldPackageSwiftForDep — orchestrator with I/O. Tested via temp dirs; +// covers each skip rule + the happy path. +// --------------------------------------------------------------------------- + +describe('scaffoldPackageSwiftForDep', () => { + let appRoot; + let depRoot; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-app-')); + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-dep-')); + }); + + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + function makePodspec() { + // Minimal valid podspec — exercises the regex-parser fallback path. + const podspecPath = path.join(depRoot, 'react-native-foo.podspec'); + fs.writeFileSync( + podspecPath, + ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm}" + s.dependency "React-Core" +end +`, + ); + return podspecPath; + } + + function makeCtx(overrides = {}) { + return { + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + force: false, + dryRun: false, + cacheSlotLabel: null, + ...overrides, + }; + } + + function makeDep(overrides = {}) { + return { + name: 'react-native-foo', + root: depRoot, + platforms: {ios: {}}, + ...overrides, + }; + } + + it('writes Package.swift into the dep root on the happy path', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(true); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + // Line 1 is the swift-tools-version directive; the scaffolder marker + // appears immediately after (still detectable by `isScaffolded` checks + // that scan the whole file). + expect(content.split('\n', 1)[0]).toMatch(/^\/\/ swift-tools-version: /); + expect(content).toContain(SCAFFOLDER_MARKER); + }); + + it('skips (does not write) a mixed-language dep — Swift + ObjC(++) cannot share one SPM target', () => { + // react-native-screens shape: a single source glob mixing .swift and .mm. + const podspecPath = path.join(depRoot, 'react-native-foo.podspec'); + fs.writeFileSync( + podspecPath, + ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm,swift}" + s.dependency "React-Core" +end +`, + ); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.swift'), ''); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), ''); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-mixed-language'); + // Fail-closed: no half-baked manifest left behind. + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + }); + + it('computes app paths relative to the libs/ symlink, not dep.root (fresh-resolve correctness)', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + // swiftName = ReactNativeFoo; the autolinker references the dep via + // build/generated/autolinking/libs/ReactNativeFoo. SwiftPM resolves the + // manifest's relative paths against THAT location, so: + // build/generated/ios -> ../../../ios + // build/xcframeworks -> ../../../../xcframeworks + expect(content).toContain( + '.package(name: "React-GeneratedCode", path: "../../../ios")', + ); + expect(content).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + // The old dep.root-relative form (doubled to …/autolinking/ios/build/... + // through the symlink) must NOT be emitted. + expect(content).not.toContain('../../ios/build/generated/ios'); + }); + + it('reports previouslyExisted=false for first-time scaffolds (so the CLI can prompt)', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + expect(result.previouslyExisted).toBe(false); + }); + + it('reports previouslyExisted=true when regenerating an existing scaffolder-marker file (slot change)', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// Cache slot: OLD\n`, + ); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'NEW'}), + ); + expect(result.status).toBe('written'); + expect(result.previouslyExisted).toBe(true); + }); + + it('skips with skipped-no-ios when autolinking.json has no ios platform', () => { + const result = scaffoldPackageSwiftForDep( + makeDep({platforms: {ios: null}}), + makeCtx(), + ); + expect(result.status).toBe('skipped-no-ios'); + }); + + it('skips with skipped-no-podspec when no .podspec exists in dep root', () => { + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-no-podspec'); + }); + + it('refuses to touch a Package.swift that lacks the scaffolder marker (user/upstream-managed)', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// Hand-written. Do not touch.', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-self-managed'); + // File unchanged + expect(fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8')).toBe( + '// Hand-written. Do not touch.', + ); + }); + + it('refuses to scaffold when a nested ios/Package.swift exists without markers', () => { + makePodspec(); + // Library ships its manifest under ios/ to keep the npm-package root + // free of SPM artifacts. The scaffolder should NOT write a stray root + // Package.swift — that would shadow the nested one (the autolinker + // checks the root first). + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + const nestedContent = + '// swift-tools-version: 6.0\n// Hand-written nested manifest.\n'; + fs.writeFileSync(path.join(depRoot, 'ios', 'Package.swift'), nestedContent); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-self-managed'); + // Root stayed clean + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + // Nested file untouched + expect( + fs.readFileSync(path.join(depRoot, 'ios', 'Package.swift'), 'utf8'), + ).toBe(nestedContent); + }); + + it('refuses to overwrite a Package.swift carrying the autolinker AUTOGEN_MARKER', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit.\n', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-autogen'); + }); + + it('skips re-scaffolding when the existing file carries the scaffolder marker AND the same cache slot', () => { + makePodspec(); + // Pre-existing scaffold from same slot AND current generator version + // — otherwise the version-bump skip-bypass kicks in. + const prior = + SCAFFOLDER_MARKER + + `\n// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}` + + '\n// Cache slot: 0.87.0-X/debug\n// rest unchanged'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: '0.87.0-X/debug'}), + ); + expect(result.status).toBe('skipped-scaffolder-marker'); + expect(fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8')).toBe( + prior, + ); + }); + + it('REGENERATES when the existing scaffolder file is from a different cache slot (manifest hash bump)', () => { + makePodspec(); + const prior = + SCAFFOLDER_MARKER + '\n// Cache slot: OLD-slot/debug\n// rest'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'NEW-slot/debug'}), + ); + expect(result.status).toBe('written'); + expect( + fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'), + ).toContain('// Cache slot: NEW-slot/debug'); + }); + + it('--force re-overwrites a scaffolder-marker file even when the slot is unchanged', () => { + makePodspec(); + const prior = + SCAFFOLDER_MARKER + + '\n// Cache slot: SLOT-A/debug\n// hand edits here will be lost'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'SLOT-A/debug', force: true}), + ); + expect(result.status).toBe('written'); + expect( + fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'), + ).not.toContain('hand edits here will be lost'); + }); + + it('--dry-run produces a ScaffoldResult but writes nothing', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({dryRun: true}), + ); + expect(result.status).toBe('written'); + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + }); + + it("honors a dep's spm: { scaffold: false } opt-out in its react-native.config.js", () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + 'module.exports = { spm: { scaffold: false } };', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-opt-out'); + }); + + it('returns skipped-is-react-native for `react-native` itself (handled by the xcframework path)', () => { + const result = scaffoldPackageSwiftForDep( + makeDep({name: 'react-native'}), + makeCtx(), + ); + expect(result.status).toBe('skipped-is-react-native'); + }); +}); + +// --------------------------------------------------------------------------- +// scaffoldAll — minimal smoke test. The orchestrator delegates everything +// to scaffoldPackageSwiftForDep (already covered above); here we just +// verify it reads autolinking.json and produces one result per dep. +// --------------------------------------------------------------------------- + +describe('scaffoldAll', () => { + let appRoot; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-all-')); + }); + + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + it('returns [] and logs when autolinking.json is absent', () => { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results).toEqual([]); + }); + + it('walks dependencies in autolinking.json and produces one result per entry', () => { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + 'react-native-b': {root: '/no/such/b', platforms: {ios: null}}, + }, + }), + ); + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.length).toBe(2); + expect(results.find(r => r.depName === 'react-native-b').status).toBe( + 'skipped-no-ios', + ); + // react-native-a's root doesn't exist → skipped-no-podspec + expect(results.find(r => r.depName === 'react-native-a').status).toBe( + 'skipped-no-podspec', + ); + }); +}); + +// --------------------------------------------------------------------------- +// SCAFFOLDER_VERSION — auto-regen when the emitter's output format changes +// +// Without versioning, a Package.swift scaffolded by an older generator stays +// on disk indefinitely (skip-on-marker), even when our template has since +// been fixed. Bumping SCAFFOLDER_VERSION triggers a one-time regeneration +// on next scaffold. Edits are persisted via patch-package per the marker +// comment, so destructive regen here aligns with the documented workflow. +// --------------------------------------------------------------------------- + +describe('SCAFFOLDER_VERSION', () => { + it('is a positive integer', () => { + expect(Number.isInteger(SCAFFOLDER_VERSION)).toBe(true); + expect(SCAFFOLDER_VERSION).toBeGreaterThanOrEqual(1); + }); + + it('emitter writes the current version to the file', () => { + const out = emitScaffoldedPackageSwift({ + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + preprocessorDefines: [], + needsObjCPrefix: false, + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + }); + expect(out).toMatch( + new RegExp(`^// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}$`, 'm'), + ); + }); +}); + +describe('scaffoldPackageSwiftForDep — version-based regen', () => { + let tempDir; + let depRoot; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-version-')); + depRoot = path.join(tempDir, 'node_modules', 'react-native-foo'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'package.json'), + JSON.stringify({name: 'react-native-foo', version: '1.0.0'}), + ); + fs.writeFileSync( + path.join(depRoot, 'react-native-foo.podspec'), + "Pod::Spec.new do |s|\n s.name = 'react-native-foo'\n s.version = '1.0'\n s.source_files = 'ios/**/*.{h,m,mm}'\nend\n", + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function makeDep() { + return { + name: 'react-native-foo', + root: depRoot, + platforms: {ios: {}}, + }; + } + + function makeCtx(overrides = {}) { + return { + appRoot: tempDir, + reactNativeRoot: depRoot, + force: false, + dryRun: false, + cacheSlotLabel: 'SLOT-A/debug', + skipDeps: new Set(), + ...overrides, + }; + } + + it('regenerates a file scaffolded under an older version, even without --force', () => { + const olderVersion = Math.max(1, SCAFFOLDER_VERSION - 1); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// AUTO-SCAFFOLDED-VERSION: ${olderVersion}\n// Cache slot: SLOT-A/debug\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const after = fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'); + expect(after).toContain( + `// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}`, + ); + }); + + it('regenerates a marker-tagged file with NO version line (treats as v1)', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// Cache slot: SLOT-A/debug\n// pre-versioning scaffold\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const after = fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'); + expect(after).not.toContain('pre-versioning scaffold'); + expect(after).toContain( + `// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}`, + ); + }); + + it('skips when the existing file is already at the current version and slot', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}\n// Cache slot: SLOT-A/debug\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-scaffolder-marker'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js new file mode 100644 index 000000000000..abdaaf823364 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -0,0 +1,292 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + detectStandardRnLayoutRedirect, + ensureBothArtifactFlavors, + findInjectedXcodeproj, + resolveAction, + shouldAutoDeintegrate, +} = require('../../setup-apple-spm'); +const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); +const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Create an in-place-injected xcodeproj fixture: a directory carrying the +// `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). +function mkInjectedXcodeproj(appRoot, name) { + const dir = path.join(appRoot, name); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, SPM_INJECTED_MARKER), + JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}), + ); + return dir; +} + +// Create a (CocoaPods or plain) xcodeproj fixture with a minimal pbxproj. +function mkXcodeproj(appRoot, name, {cocoapods = false} = {}) { + const dir = path.join(appRoot, name); + fs.mkdirSync(dir, {recursive: true}); + const baseConfig = cocoapods + ? 'baseConfigurationReference = ABC /* Pods-MyApp.debug.xcconfig */;\n' + : ''; + fs.writeFileSync( + path.join(dir, 'project.pbxproj'), + `// !$*UTF8*$!\n{\n\tobjects = {\n${baseConfig}\t};\n}\n`, + ); + return dir; +} + +function gitInitAndCommit(dir) { + const opts = {cwd: dir, stdio: 'ignore'}; + execFileSync('git', ['init'], opts); + execFileSync('git', ['config', 'user.email', 'test@example.com'], opts); + execFileSync('git', ['config', 'user.name', 'Test'], opts); + execFileSync('git', ['add', '-A'], opts); + execFileSync('git', ['commit', '-m', 'init'], opts); +} + +// --------------------------------------------------------------------------- +// resolveAction — zero-arg default. Explicit action wins; otherwise `update` +// when an injection marker exists, else `add` (first run). +// --------------------------------------------------------------------------- + +describe('resolveAction', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-resolve-action-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the requested action verbatim when one is given', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction('add', tempDir)).toBe('add'); + expect(resolveAction('update', tempDir)).toBe('update'); + expect(resolveAction('deinit', tempDir)).toBe('deinit'); + expect(resolveAction('scaffold', tempDir)).toBe('scaffold'); + }); + + it('defaults to `add` on first run (no injection marker)', () => { + expect(resolveAction(null, tempDir)).toBe('add'); + }); + + it('defaults to `add` even when a (non-injected) xcodeproj exists', () => { + mkXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction(null, tempDir)).toBe('add'); + }); + + it('defaults to `update` once an injection marker is present', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction(null, tempDir)).toBe('update'); + }); +}); + +// --------------------------------------------------------------------------- +// findInjectedXcodeproj — locates the `.xcodeproj` carrying the injection marker +// --------------------------------------------------------------------------- + +describe('findInjectedXcodeproj', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-find-injected-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the injected project path when a marker is present', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(findInjectedXcodeproj(tempDir)).toBe( + path.join(tempDir, 'MyApp.xcodeproj'), + ); + }); + + it('returns null when no injected project exists', () => { + mkXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(findInjectedXcodeproj(tempDir)).toBeNull(); + }); +}); + +describe('dual-flavor artifact input', () => { + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-dual-artifacts-')); + }); + + afterEach(() => fs.rmSync(root, {recursive: true, force: true})); + + function writeCompleteSlot(flavor) { + const slot = path.join(root, flavor); + fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { + recursive: true, + }); + const artifacts = {}; + for (const name of REQUIRED_ARTIFACTS) { + const xcframeworkPath = path.join(slot, `${name}.xcframework`); + fs.mkdirSync(xcframeworkPath, {recursive: true}); + artifacts[name] = {xcframeworkPath}; + } + fs.writeFileSync( + path.join(slot, 'artifacts.json'), + JSON.stringify(artifacts), + ); + return slot; + } + + function args() { + return { + version: null, + artifacts: root, + downloadPolicy: 'skip', + }; + } + + it('requires and returns complete Debug and Release slots', async () => { + const debug = writeCompleteSlot('debug'); + const release = writeCompleteSlot('release'); + await expect(ensureBothArtifactFlavors(args(), '0.85.0')).resolves.toEqual({ + debug, + release, + }); + }); + + it('fails if either flavor is incomplete', async () => { + writeCompleteSlot('debug'); + await expect(ensureBothArtifactFlavors(args(), '0.85.0')).rejects.toThrow( + /complete release slot/, + ); + }); + + it('rejects a single XCFramework as local artifact input', async () => { + const single = path.join(root, 'React.xcframework'); + fs.mkdirSync(single); + await expect( + ensureBothArtifactFlavors({...args(), artifacts: single}, '0.85.0'), + ).rejects.toThrow(/single XCFramework cannot satisfy automatic switching/); + }); +}); + +// --------------------------------------------------------------------------- +// detectStandardRnLayoutRedirect — auto-redirect into ios/ when run from the JS +// root of a standard RN app. +// --------------------------------------------------------------------------- + +describe('detectStandardRnLayoutRedirect', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-redirect-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the ios/ subdir when cwd === projectRoot AND ios/ exists', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBe( + path.join(tempDir, 'ios'), + ); + }); + + it('returns null when running from a subdirectory (already cd-ed)', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect( + detectStandardRnLayoutRedirect(path.join(tempDir, 'ios'), tempDir), + ).toBeNull(); + }); + + it('returns null for flat layouts (no ios/ subdir, e.g. rn-tester)', () => { + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBeNull(); + }); + + it('returns null when `ios` is a file, not a directory', () => { + fs.writeFileSync(path.join(tempDir, 'ios'), ''); + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// shouldAutoDeintegrate — the zero-arg safe-gate. Auto-convert ONLY a fresh +// CocoaPods RN project: CocoaPods pbxproj + stock Podfile (no third-party pods) +// + clean git tree. Anything else → false (strict `add`, fail-loud). +// --------------------------------------------------------------------------- + +describe('shouldAutoDeintegrate', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-safegate-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('false when the project is not CocoaPods-integrated', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: false, + }); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + it('false when there is no target project at all', () => { + expect(shouldAutoDeintegrate(tempDir, null)).toBe(false); + }); + + it('false for a CocoaPods project whose Podfile has third-party pods', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n use_react_native!\n pod 'MBProgressHUD'\nend\n", + ); + gitInitAndCommit(tempDir); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + it('false when the pbxproj has uncommitted edits (not revertible)', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n use_react_native!\nend\n", + ); + gitInitAndCommit(tempDir); + // Dirty the pbxproj itself after the commit → conversion not revertible. + fs.appendFileSync( + path.join(xcodeproj, 'project.pbxproj'), + '\n// local edit\n', + ); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + it('true despite an unrelated dirty file when pbxproj + Podfile are clean', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n config = use_native_modules!\n use_react_native!(:path => config[:reactNativePath])\nend\n", + ); + gitInitAndCommit(tempDir); + // A dirty lockfile / untracked file elsewhere must NOT block — the + // conversion only touches the pbxproj + Podfile, which stay clean. + fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}'); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js new file mode 100644 index 000000000000..ee5ee1e1c881 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -0,0 +1,516 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + addArrayMembers, + addArrayStringValues, + ensureScalarField, + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, + generateUUID, + insertObjectsIntoSection, + namespacedUUID, + quoteIfNeeded, + removeArrayMembersByUuid, + removeArrayStringValues, + removeDanglingJavaScriptCoreRef, + removeEmptyPodsGroup, + removeField, + removeObjectByUuid, + scanToClose, + serializeEntry, + setScalarField, + uuidsInArray, +} = require('../spm-pbxproj'); +const fs = require('fs'); +const path = require('path'); + +const PLAIN_PBXPROJ = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// --------------------------------------------------------------------------- +// generateUUID +// --------------------------------------------------------------------------- + +describe('generateUUID', () => { + it('produces a 24-character uppercase hex string', () => { + const result = generateUUID('test-seed'); + expect(result).toMatch(/^[0-9A-F]{24}$/); + }); + + it('is deterministic', () => { + expect(generateUUID('same')).toBe(generateUUID('same')); + }); + + it('produces different results for different seeds', () => { + expect(generateUUID('seed-a')).not.toBe(generateUUID('seed-b')); + }); +}); + +// --------------------------------------------------------------------------- +// quoteIfNeeded +// --------------------------------------------------------------------------- + +describe('quoteIfNeeded', () => { + it.each([ + ['foo.bar/baz', 'foo.bar/baz'], + ['foo bar', '"foo bar"'], + ['a\\b', '"a\\\\b"'], + ['a"b', '"a\\"b"'], + ['', '""'], + ])('quoteIfNeeded(%j) => %j', (input, expected) => { + expect(quoteIfNeeded(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Surgical-edit toolkit (in-place injection primitives) +// --------------------------------------------------------------------------- + +describe('namespacedUUID', () => { + it('is deterministic and 24-hex', () => { + const a = namespacedUUID('ROOT', 'sec', 'id'); + expect(a).toMatch(/^[0-9A-F]{24}$/); + expect(namespacedUUID('ROOT', 'sec', 'id')).toBe(a); + }); + + it('differs by root, section, id, and salt', () => { + const base = namespacedUUID('ROOT', 'sec', 'id'); + expect(namespacedUUID('OTHER', 'sec', 'id')).not.toBe(base); + expect(namespacedUUID('ROOT', 'other', 'id')).not.toBe(base); + expect(namespacedUUID('ROOT', 'sec', 'other')).not.toBe(base); + expect(namespacedUUID('ROOT', 'sec', 'id', '2')).not.toBe(base); + }); +}); + +describe('scanToClose', () => { + it('matches braces and parens, skipping quoted delimiters', () => { + const t = 'x = { a = ("a)b"); };'; + const open = t.indexOf('{'); + expect(t[scanToClose(t, open)]).toBe('}'); + const paren = t.indexOf('('); + // The ")" inside the quoted string must not close the paren early. + expect(scanToClose(t, paren)).toBe(t.indexOf(');') + 0); + }); +}); + +describe('findObjectByUuid / findField', () => { + it('locates an object body and reads scalar + array fields', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + expect(target.name).toBe('MyApp'); + const obj = findObjectByUuid(PLAIN_PBXPROJ, target.uuid); + expect(obj).not.toBeNull(); + const productType = findField(PLAIN_PBXPROJ, obj, 'productType'); + expect(productType.value).toContain('application'); + const buildPhases = findField(PLAIN_PBXPROJ, obj, 'buildPhases'); + expect(uuidsInArray(buildPhases.value).size).toBe(3); + }); + + it('returns null for an absent field', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + expect(findField(PLAIN_PBXPROJ, project, 'packageReferences')).toBeNull(); + }); +}); + +describe('addArrayMembers', () => { + it('creates an absent array field after the body open', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = addArrayMembers(PLAIN_PBXPROJ, project, 'packageReferences', [ + {uuid: 'CAFE0000000000000000CAFE', comment: 'ref'}, + ]); + expect(out).toMatch(/packageReferences = \(/); + expect(out).toContain('CAFE0000000000000000CAFE /* ref */'); + }); + + it('appends to and dedupes an existing array', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + const member = [{uuid: 'AA0000000000000000000301'}]; // already in buildPhases + const out = addArrayMembers(PLAIN_PBXPROJ, target, 'buildPhases', member); + // Dedup: no second occurrence added. + expect(out.match(/AA0000000000000000000301/g)).toHaveLength( + PLAIN_PBXPROJ.match(/AA0000000000000000000301/g).length, + ); + }); + + it('prepends when requested', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + const out = addArrayMembers( + PLAIN_PBXPROJ, + target, + 'buildPhases', + [{uuid: 'BEEF0000000000000000BEEF', comment: 'First'}], + {prepend: true}, + ); + const firstIdx = out.indexOf('BEEF0000000000000000BEEF'); + const sourcesIdx = out.indexOf('AA0000000000000000000301 /* Sources */'); + expect(firstIdx).toBeLessThan(sourcesIdx); + }); +}); + +describe('addArrayStringValues', () => { + function targetDebugDict(text) { + const cfg = findObjectByUuid(text, 'AA0000000000000000000901'); + const bs = findField(text, cfg, 'buildSettings'); + return {uuid: 'x', bodyOpen: bs.valueStart, bodyClose: bs.tokenEnd - 1}; + } + + it('creates an array seeded with $(inherited)', () => { + const out = addArrayStringValues( + PLAIN_PBXPROJ, + targetDebugDict(PLAIN_PBXPROJ), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect(out).toMatch(/OTHER_LDFLAGS = \(/); + expect(out).toContain('"$(inherited)"'); + expect(out).toContain('"-ObjC"'); + }); + + it('promotes an existing scalar to an array, preserving the old value', () => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = "-lz"; PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect(out).toMatch(/OTHER_LDFLAGS = \(/); + expect(out).toContain('"-lz"'); + expect(out).toContain('"-ObjC"'); + }); + + it('dedups by EXACT token, not substring (adds "-ObjC" even when "-ObjCFoo" is present)', () => { + const withArray = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = ("-ObjCFoo", ); PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + withArray, + targetDebugDict(withArray), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + // A substring check would have seen "-ObjC" inside "-ObjCFoo" and skipped it. + expect(out).toContain('"-ObjC"'); + expect(out).toContain('"-ObjCFoo"'); + }); + + it('does not re-add an exact existing member', () => { + const withArray = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = ("-ObjC", ); PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + withArray, + targetDebugDict(withArray), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect((out.match(/"-ObjC"/g) || []).length).toBe(1); + }); +}); + +describe('ensureScalarField', () => { + it('adds a scalar only when absent', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + expect(out).toContain('ORGANIZATIONNAME = Acme;'); + // Re-running is a no-op. + const project2 = findProjectObject(out); + expect(ensureScalarField(out, project2, 'ORGANIZATIONNAME', 'Other')).toBe( + out, + ); + }); +}); + +describe('setScalarField', () => { + it('replaces an existing value in place, preserving field order', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const withOrg = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + const before = withOrg.indexOf('ORGANIZATIONNAME'); + const project2 = findProjectObject(withOrg); + const out = setScalarField(withOrg, project2, 'ORGANIZATIONNAME', 'Other'); + expect(out).toContain('ORGANIZATIONNAME = Other;'); + expect(out).not.toContain('ORGANIZATIONNAME = Acme;'); + // Field stayed at the same position — no remove+re-append shuffling. + expect(out.indexOf('ORGANIZATIONNAME')).toBe(before); + }); + + it('is byte-identical when the value is unchanged', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const withOrg = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + const project2 = findProjectObject(withOrg); + const out = setScalarField(withOrg, project2, 'ORGANIZATIONNAME', 'Acme'); + expect(out).toBe(withOrg); + }); + + it('appends the field when absent, like ensureScalarField', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = setScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + expect(out).toContain('ORGANIZATIONNAME = Acme;'); + expect( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme'), + ).toBe(out); + }); + + it('preserves a quoted value fully, including its quotes', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const withScript = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'SOME_SCRIPT', + '"echo hi"', + ); + const project2 = findProjectObject(withScript); + const out = setScalarField( + withScript, + project2, + 'SOME_SCRIPT', + '"echo bye"', + ); + expect(out).toContain('SOME_SCRIPT = "echo bye";'); + expect(out).not.toContain('echo hi'); + }); +}); + +describe('insertObjectsIntoSection', () => { + it('creates a new section before the objects dict closes', () => { + const entry = serializeEntry({ + uuid: 'DEAD0000000000000000DEAD', + comment: 'XCLocalSwiftPackageReference "x"', + fields: {isa: 'XCLocalSwiftPackageReference', relativePath: 'x'}, + }); + const out = insertObjectsIntoSection( + PLAIN_PBXPROJ, + 'XCLocalSwiftPackageReference', + entry, + ); + expect(out).toContain('/* Begin XCLocalSwiftPackageReference section */'); + expect(out).toContain('DEAD0000000000000000DEAD'); + // Still inside the objects dict (before rootObject). + expect(out.indexOf('DEAD0000000000000000DEAD')).toBeLessThan( + out.indexOf('rootObject ='), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Surgical removal — inverses used by `deinit` +// --------------------------------------------------------------------------- + +describe('surgical removal (deinit inverse)', () => { + const FAKE = 'DEADBEEF0000000000001234'; + + it('removeObjectByUuid exactly inverts insertObjectsIntoSection', () => { + const inserted = insertObjectsIntoSection( + PLAIN_PBXPROJ, + 'PBXBuildFile', + serializeEntry({ + uuid: FAKE, + comment: 'Fake', + fields: {isa: 'PBXBuildFile'}, + }), + ); + expect(inserted).not.toBe(PLAIN_PBXPROJ); + expect(removeObjectByUuid(inserted, FAKE)).toBe(PLAIN_PBXPROJ); + }); + + it('removeObjectByUuid is a no-op when the uuid is absent', () => { + expect(removeObjectByUuid(PLAIN_PBXPROJ, FAKE)).toBe(PLAIN_PBXPROJ); + }); + + it('removeArrayMembersByUuid inverts addArrayMembers on an existing array', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const added = addArrayMembers(PLAIN_PBXPROJ, target, 'buildPhases', [ + {uuid: FAKE, comment: 'Fake'}, + ]); + expect(added).not.toBe(PLAIN_PBXPROJ); + expect(removeArrayMembersByUuid(added, [FAKE])).toBe(PLAIN_PBXPROJ); + }); + + it('removeField inverts ensureScalarField', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const added = ensureScalarField( + PLAIN_PBXPROJ, + target, + 'SPM_TEST_FLAG', + '"yes"', + ); + expect(added).not.toBe(PLAIN_PBXPROJ); + const [target2] = findApplicationTargets(added); + expect(removeField(added, target2, 'SPM_TEST_FLAG')).toBe(PLAIN_PBXPROJ); + }); + + it('removeArrayStringValues removes only the named values', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const seeded = addArrayStringValues(PLAIN_PBXPROJ, target, 'SPM_TEST_ARR', [ + '"-A"', + ]); + const [t2] = findApplicationTargets(seeded); + const appended = addArrayStringValues(seeded, t2, 'SPM_TEST_ARR', ['"-B"']); + const [t3] = findApplicationTargets(appended); + // Removing the appended "-B" returns to the seeded (single-value) state. + expect( + removeArrayStringValues(appended, t3, 'SPM_TEST_ARR', ['"-B"']), + ).toBe(seeded); + }); +}); + +// --------------------------------------------------------------------------- +// removeEmptyPodsGroup — clean up the leftover empty `Pods` group after +// `pod deintegrate` (which `add --deintegrate` runs). +// --------------------------------------------------------------------------- + +describe('removeEmptyPodsGroup', () => { + // A main group referencing an empty `Pods` group (what pod deintegrate leaves). + const WITH_EMPTY_PODS = [ + '// !$*UTF8*$!', + '{', + '\tobjects = {', + '/* Begin PBXGroup section */', + '\t\tAA0000000000000000000001 = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t\t13B07FAE1A68108700A75B9A /* App */,', + '\t\t\t\tBBD78D7AC51CEA395F1C20DB /* Pods */,', + '\t\t\t);', + '\t\t\tsourceTree = "";', + '\t\t};', + '\t\tBBD78D7AC51CEA395F1C20DB /* Pods */ = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t);', + '\t\t\tpath = Pods;', + '\t\t\tsourceTree = "";', + '\t\t};', + '/* End PBXGroup section */', + '\t};', + '}', + '', + ].join('\n'); + + it('removes the empty Pods group object and its parent reference', () => { + const out = removeEmptyPodsGroup(WITH_EMPTY_PODS); + expect(out).not.toContain('/* Pods */'); + expect(out).not.toContain('path = Pods;'); + // The unrelated child survives. + expect(out).toContain('13B07FAE1A68108700A75B9A /* App */,'); + }); + + it('leaves a NON-empty Pods group untouched (still integrated)', () => { + const nonEmpty = WITH_EMPTY_PODS.replace( + 'children = (\n\t\t\t);\n\t\t\tpath = Pods;', + 'children = (\n\t\t\t\tDEADBEEF0000000000000001 /* libPods.a */,\n\t\t\t);\n\t\t\tpath = Pods;', + ); + expect(removeEmptyPodsGroup(nonEmpty)).toBe(nonEmpty); + }); + + it('is a no-op when there is no Pods group', () => { + const noPods = WITH_EMPTY_PODS.split('\n') + .filter(l => !l.includes('Pods')) + .join('\n'); + expect(removeEmptyPodsGroup(noPods)).toBe(noPods); + }); +}); + +// --------------------------------------------------------------------------- +// removeDanglingJavaScriptCoreRef — strip the community template's leftover +// `JavaScriptCore.framework` PBXFileReference (navigator-only since RN 0.60, +// meaningless under Hermes) unless it's actually linked. +// --------------------------------------------------------------------------- + +describe('removeDanglingJavaScriptCoreRef', () => { + const JSC_FILE_REF = + '\t\tED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };'; + + // A Frameworks group referencing the dangling JavaScriptCore.framework ref. + const WITH_DANGLING_JSC = [ + '// !$*UTF8*$!', + '{', + '\tobjects = {', + '/* Begin PBXFileReference section */', + JSC_FILE_REF, + '/* End PBXFileReference section */', + '/* Begin PBXGroup section */', + '\t\t13B07FAF1A68108700A75B9A /* Frameworks */ = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t\tED297162215061F000B7C4FE /* JavaScriptCore.framework */,', + '\t\t\t);', + '\t\t\tname = Frameworks;', + '\t\t\tsourceTree = "";', + '\t\t};', + '/* End PBXGroup section */', + '\t};', + '}', + '', + ].join('\n'); + + it('removes both the file reference object and its group membership when dangling', () => { + const out = removeDanglingJavaScriptCoreRef(WITH_DANGLING_JSC); + expect(out).not.toContain('JavaScriptCore.framework'); + expect(out).not.toContain('ED297162215061F000B7C4FE'); + // The parent group survives, now childless. + expect(out).toContain('13B07FAF1A68108700A75B9A /* Frameworks */ = {'); + }); + + it('leaves a still-linked reference untouched (a PBXBuildFile references it)', () => { + const linked = WITH_DANGLING_JSC.replace( + '/* Begin PBXFileReference section */', + '/* Begin PBXBuildFile section */\n' + + '\t\tED2971642150620600B7C4FE /* JavaScriptCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED297162215061F000B7C4FE /* JavaScriptCore.framework */; };\n' + + '/* End PBXBuildFile section */\n' + + '/* Begin PBXFileReference section */', + ); + expect(removeDanglingJavaScriptCoreRef(linked)).toBe(linked); + }); + + it('is a no-op when there is no JavaScriptCore reference', () => { + const noJsc = WITH_DANGLING_JSC.split('\n') + .filter(l => !l.includes('JavaScriptCore')) + .join('\n'); + expect(removeDanglingJavaScriptCoreRef(noJsc)).toBe(noJsc); + }); + + it('is idempotent', () => { + const once = removeDanglingJavaScriptCoreRef(WITH_DANGLING_JSC); + const twice = removeDanglingJavaScriptCoreRef(once); + expect(twice).toBe(once); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js new file mode 100644 index 000000000000..87e116d32d51 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -0,0 +1,621 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + RemoteVersionError, + buildPerAppHeaderTree, + defaultCacheDir, + displayPath, + isPublishableVersion, + makeLogger, + readPackageJson, + remotePackageConfig, + resolveInstalledRnVersion, + resolveReactNativeRoot, + runCodegenAndInstallTemplate, + sharedCacheDir, + toSwiftName, +} = require('../spm-utils'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// toSwiftName +// --------------------------------------------------------------------------- + +describe('toSwiftName', () => { + it.each([ + ['@react-native/tester', 'Tester'], + ['my-app', 'MyApp'], + ['@scope/foo-bar', 'FooBar'], + ['simple', 'Simple'], + ['a--b', 'AB'], + ['my_great_app', 'MyGreatApp'], + ])('toSwiftName(%j) => %j', (input, expected) => { + expect(toSwiftName(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// defaultCacheDir +// --------------------------------------------------------------------------- + +describe('sharedCacheDir', () => { + it('matches CocoaPods shared_cache_dir (~/Library/Caches/ReactNative)', () => { + expect(sharedCacheDir()).toBe( + path.join(os.homedir(), 'Library', 'Caches', 'ReactNative'), + ); + }); +}); + +describe('defaultCacheDir', () => { + it('nests SPM artifacts under the canonical ReactNative cache root', () => { + const result = defaultCacheDir('0.80.0', 'debug'); + expect(result).toBe( + path.join( + os.homedir(), + 'Library', + 'Caches', + 'ReactNative', + 'spm-artifacts', + '0.80.0', + 'debug', + ), + ); + // No bundle-id-named dir that other tools might also use. + expect(result).not.toContain('com.facebook.ReactNative'); + }); + + it('varies by flavor', () => { + const debug = defaultCacheDir('1.0.0', 'debug'); + const release = defaultCacheDir('1.0.0', 'release'); + expect(debug).not.toBe(release); + expect(debug).toContain('debug'); + expect(release).toContain('release'); + }); +}); + +// --------------------------------------------------------------------------- +// displayPath +// --------------------------------------------------------------------------- + +describe('displayPath', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('replaces homedir with ~', () => { + const home = os.homedir(); + expect(displayPath(path.join(home, 'projects', 'app'))).toBe( + '~/projects/app', + ); + }); + + it('returns ~ for exact homedir', () => { + expect(displayPath(os.homedir())).toBe('~'); + }); + + it('returns relative path when close to cwd and not under $HOME', () => { + const cwd = path.join(os.tmpdir(), 'spm-display-cwd'); + jest.spyOn(process, 'cwd').mockReturnValue(cwd); + jest.spyOn(os, 'homedir').mockReturnValue('/home/spm-display-user'); + expect(displayPath(path.join(cwd, 'sub', 'dir'))).toBe( + path.join('sub', 'dir'), + ); + }); + + it('returns absolute path for deep relative', () => { + // Paths more than 2 levels above cwd should stay absolute + // (unless they fall under $HOME) + const home = os.homedir(); + const p = path.join(home, 'deep', 'nested', 'path'); + // This is under $HOME, so it should use ~/ + expect(displayPath(p)).toBe('~/deep/nested/path'); + }); +}); + +// --------------------------------------------------------------------------- +// makeLogger +// --------------------------------------------------------------------------- + +describe('makeLogger', () => { + let spies; + + afterEach(() => { + if (spies) { + spies.forEach(s => s.mockRestore()); + spies = null; + } + }); + + function mockConsole(...methods) { + spies = methods.map(m => + jest.spyOn(console, m).mockImplementation(() => {}), + ); + return spies; + } + + it('log writes to stdout with green prefix', () => { + const [spy] = mockConsole('log'); + const {log} = makeLogger('test'); + log('hello'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[test]')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello')); + }); + + it('warn writes to stderr with yellow prefix', () => { + const [spy] = mockConsole('warn'); + const {warn} = makeLogger('test'); + warn('caution'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[test]')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('caution')); + }); + + it('die throws, sets exitCode, writes to stderr', () => { + const [spy] = mockConsole('error'); + const origExitCode = process.exitCode; + try { + const {die} = makeLogger('test'); + expect(() => die('fatal')).toThrow('fatal'); + expect(process.exitCode).toBe(1); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('fatal')); + } finally { + process.exitCode = origExitCode; + } + }); +}); + +// --------------------------------------------------------------------------- +// readPackageJson +// --------------------------------------------------------------------------- + +describe('readPackageJson', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns parsed JSON for valid file', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({name: 'test-pkg', version: '1.0.0'}), + ); + const result = readPackageJson(tempDir); + expect(result).toEqual({name: 'test-pkg', version: '1.0.0'}); + }); + + it('returns null for missing file', () => { + expect(readPackageJson(tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveReactNativeRoot +// --------------------------------------------------------------------------- + +describe('resolveReactNativeRoot', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds react-native hoisted above the app package root', () => { + const workspaceRoot = path.join(tempDir, 'workspace'); + const appRoot = path.join(workspaceRoot, 'packages', 'app', 'ios'); + const rnRoot = path.join(workspaceRoot, 'node_modules', 'react-native'); + fs.mkdirSync(appRoot, {recursive: true}); + fs.mkdirSync(rnRoot, {recursive: true}); + + expect( + resolveReactNativeRoot( + appRoot, + path.join(workspaceRoot, 'packages', 'app'), + ), + ).toBe(rnRoot); + }); +}); + +// --------------------------------------------------------------------------- +// isPublishableVersion +// --------------------------------------------------------------------------- + +describe('isPublishableVersion', () => { + it.each([ + ['0.86.3', true], + ['0.87.0-nightly-20260608-2ff3b81dc', true], + ['1.2.3', true], + ['1000.0.0', false], + ['0.0.0', false], + ['0.0.0-canary', false], + [null, false], + ['', false], + ])('isPublishableVersion(%j) => %j', (input, expected) => { + expect(isPublishableVersion(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// resolveInstalledRnVersion +// --------------------------------------------------------------------------- + +describe('resolveInstalledRnVersion', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function writeRn(dir /*: string */, version /*: string */) { + const rnDir = path.join(dir, 'node_modules', 'react-native'); + fs.mkdirSync(rnDir, {recursive: true}); + fs.writeFileSync( + path.join(rnDir, 'package.json'), + JSON.stringify({name: 'react-native', version}), + ); + } + + it('reads the version from appRoot/node_modules/react-native', () => { + writeRn(tempDir, '0.86.3'); + expect(resolveInstalledRnVersion(tempDir)).toBe('0.86.3'); + }); + + it('walks up to find a hoisted react-native', () => { + const appRoot = path.join(tempDir, 'packages', 'app', 'ios'); + fs.mkdirSync(appRoot, {recursive: true}); + writeRn(tempDir, '0.87.0'); + expect(resolveInstalledRnVersion(appRoot)).toBe('0.87.0'); + }); + + it('returns null when react-native is not installed', () => { + expect(resolveInstalledRnVersion(tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// remotePackageConfig +// --------------------------------------------------------------------------- + +describe('remotePackageConfig', () => { + const REMOTE_CONFIG_REL = 'build/generated/autolinking/spm-remote.json'; + let tempDir; + let savedEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + savedEnv = { + url: process.env.RN_SPM_REMOTE_URL, + version: process.env.RN_SPM_REMOTE_VERSION, + }; + delete process.env.RN_SPM_REMOTE_URL; + delete process.env.RN_SPM_REMOTE_VERSION; + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (savedEnv.url == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = savedEnv.url; + } + if (savedEnv.version == null) { + delete process.env.RN_SPM_REMOTE_VERSION; + } else { + process.env.RN_SPM_REMOTE_VERSION = savedEnv.version; + } + }); + + function writeRn(version /*: string */) { + const rnDir = path.join(tempDir, 'node_modules', 'react-native'); + fs.mkdirSync(rnDir, {recursive: true}); + fs.writeFileSync( + path.join(rnDir, 'package.json'), + JSON.stringify({name: 'react-native', version}), + ); + } + + function writePersisted(obj /*: Object */) { + const cfgPath = path.join(tempDir, REMOTE_CONFIG_REL); + fs.mkdirSync(path.dirname(cfgPath), {recursive: true}); + fs.writeFileSync(cfgPath, JSON.stringify(obj)); + } + + function readPersisted() /*: Object */ { + return JSON.parse( + fs.readFileSync(path.join(tempDir, REMOTE_CONFIG_REL), 'utf8'), + ); + } + + it('returns null in local mode (no URL anywhere)', () => { + expect(remotePackageConfig(tempDir)).toBeNull(); + }); + + it('env override: activates remote mode and persists versionOverride', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + process.env.RN_SPM_REMOTE_VERSION = '0.86.0'; + writeRn('0.86.3'); // present but ignored — override wins + + const result = remotePackageConfig(tempDir); + expect(result).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.0', + identity: 'react-native-apple', + }); + expect(readPersisted()).toEqual({ + url: 'file:///tmp/react-native-apple', + versionOverride: '0.86.0', + }); + }); + + it('env URL only: derives version from npm and persists NO version', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + writeRn('0.86.3'); + + const result = remotePackageConfig(tempDir); + expect(result).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.3', + identity: 'react-native-apple', + }); + // Derived version is never frozen. + expect(readPersisted()).toEqual({url: 'file:///tmp/react-native-apple'}); + }); + + it('throws RemoteVersionError for a non-publishable derived version', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + writeRn('1000.0.0'); + expect(() => remotePackageConfig(tempDir)).toThrow(RemoteVersionError); + }); + + it('throws RemoteVersionError when react-native is not installed', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + expect(() => remotePackageConfig(tempDir)).toThrow(RemoteVersionError); + }); + + it('honors a persisted versionOverride with no env', () => { + writePersisted({ + url: 'file:///tmp/react-native-apple', + versionOverride: '0.86.0', + }); + writeRn('1000.0.0'); // dev placeholder — override still wins, no throw + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.0', + identity: 'react-native-apple', + }); + }); + + it('reads a legacy persisted {url, version} as an override', () => { + writePersisted({ + url: 'file:///tmp/react-native-apple', + version: '0.85.1', + }); + writeRn('1000.0.0'); + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.85.1', + identity: 'react-native-apple', + }); + }); + + it('persisted URL only: derives from npm without an env (the sync lever)', () => { + writePersisted({url: 'file:///tmp/react-native-apple'}); + writeRn('0.86.3'); + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.3', + identity: 'react-native-apple', + }); + // No env → no re-write of the persisted file. + expect(readPersisted()).toEqual({url: 'file:///tmp/react-native-apple'}); + }); +}); + +// --------------------------------------------------------------------------- +// runCodegenAndInstallTemplate +// --------------------------------------------------------------------------- + +describe('runCodegenAndInstallTemplate', () => { + let tempDir; + let reactNativeRoot; + let appRoot; + let codegenPkgSwift; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-codegen-test-')); + reactNativeRoot = path.join(tempDir, 'react-native'); + appRoot = path.join(tempDir, 'app'); + + // Minimal fake codegen script (no-op) so the execSync call exits cleanly. + fs.mkdirSync(path.join(reactNativeRoot, 'scripts'), {recursive: true}); + fs.writeFileSync( + path.join(reactNativeRoot, 'scripts', 'generate-codegen-artifacts.js'), + '// no-op codegen for tests\n', + ); + // Codegen template that installSpmCodegenTemplate renders + writes. + fs.mkdirSync( + path.join(reactNativeRoot, 'scripts', 'codegen', 'templates'), + { + recursive: true, + }, + ); + fs.writeFileSync( + path.join( + reactNativeRoot, + 'scripts', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ), + '// template\n', + ); + // build/generated/ios must exist for the template to be installed. + fs.mkdirSync(path.join(appRoot, 'build', 'generated', 'ios'), { + recursive: true, + }); + codegenPkgSwift = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'Package.swift', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('installs the codegen template by default', () => { + runCodegenAndInstallTemplate(appRoot, appRoot, reactNativeRoot); + expect(fs.existsSync(codegenPkgSwift)).toBe(true); + }); + + it('skips the template install when installTemplate is false', () => { + runCodegenAndInstallTemplate(appRoot, appRoot, reactNativeRoot, undefined, { + installTemplate: false, + }); + // The SPM sync re-points the xcframework symlinks and installs the template + // itself afterwards, so this in-codegen install must be suppressed. + expect(fs.existsSync(codegenPkgSwift)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// buildMergedHeaderTree +// --------------------------------------------------------------------------- +describe('per-app header farm (ReactAppHeaders SPM target)', () => { + let tempDir; + let appRoot; + let perAppDir; + + function writeFile(p, contents) { + fs.mkdirSync(path.dirname(p), {recursive: true}); + fs.writeFileSync(p, contents); + } + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-farm-test-')); + appRoot = path.join(tempDir, 'app'); + perAppDir = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'ReactAppHeaders', + ); + // Autolinking header farm — a SYMLINK farm (leaf headers are symlinks to + // the dep's real source). foldDir must follow symlinks, not skip them. + const realProviderHeader = path.join(tempDir, 'src', 'Provider.h'); + writeFile(realProviderHeader, '#pragma once\n// provider\n'); + const farmHeader = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + 'headers', + 'MyLib', + 'Provider.h', + ); + fs.mkdirSync(path.dirname(farmHeader), {recursive: true}); + fs.symlinkSync(realProviderHeader, farmHeader); + // Codegen output (folded both at generated/ios root and ReactCodegen/). + writeFile( + path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'ReactCodegen', + 'react', + 'renderer', + 'EventEmitters.h', + ), + '#pragma once\n// codegen\n', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('folds codegen + autolinking headers and lives inside the codegen package', () => { + const result = buildPerAppHeaderTree(appRoot); + expect(result.path).toBe(perAppDir); + expect(fs.existsSync(path.join(perAppDir, 'MyLib', 'Provider.h'))).toBe( + true, + ); + expect( + fs.existsSync( + path.join(perAppDir, 'react', 'renderer', 'EventEmitters.h'), + ), + ).toBe(true); + // include form resolves via the farm root. + expect( + fs.existsSync( + path.join( + perAppDir, + 'ReactCodegen', + 'react', + 'renderer', + 'EventEmitters.h', + ), + ), + ).toBe(true); + }); + + it('carries the SPM stub source so the farm is a valid target', () => { + buildPerAppHeaderTree(appRoot); + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeadersStub.c'))).toBe( + true, + ); + }); + + it('rebuilds cleanly on re-run without folding its previous self', () => { + buildPerAppHeaderTree(appRoot); + const second = buildPerAppHeaderTree(appRoot); + // No nested ReactAppHeaders/ReactAppHeaders self-fold artifacts. + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeaders'))).toBe(false); + // ...and no ReactAppHeaders.tmp/ either: the temp build dir must NOT live + // under a folded root (build/generated/ios), else foldDir walks the + // half-built farm and creates a spurious `ReactAppHeaders.tmp/` namespace + // duplicating every codegen header. + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeaders.tmp'))).toBe( + false, + ); + expect( + [...second.virtualPaths].some(p => p.includes('ReactAppHeaders.tmp')), + ).toBe(false); + // The out-of-tree temp is consumed (renamed into place), not left behind. + expect( + fs.existsSync(path.join(appRoot, 'build', '.react-app-headers.tmp')), + ).toBe(false); + expect(second.virtualPaths.has('MyLib/Provider.h')).toBe(true); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js b/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js new file mode 100644 index 000000000000..ed31a5115298 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js @@ -0,0 +1,97 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// --------------------------------------------------------------------------- +// SPM requires `// swift-tools-version: X.Y` on the FIRST LINE of Package.swift. +// When the directive isn't on line 1, SPM silently treats the manifest as +// tools-version 3.1.0 and recent Xcode rejects it outright: +// +// error: package 'package.swift' is using Swift tools version 3.1.0 which +// is no longer supported; consider using '// swift-tools-version: 6.3' +// +// This regression test pins every Package.swift our generators emit to that +// rule, and asserts the same for the static codegen template. +// --------------------------------------------------------------------------- + +const { + generateAutolinkedPackageSwift, + generateSynthPackageSwift, +} = require('../generate-spm-autolinking'); +const {generateXCFrameworksPackageSwift} = require('../generate-spm-package'); +const {emitScaffoldedPackageSwift} = require('../scaffold-package-swift'); +const fs = require('fs'); +const path = require('path'); + +const TOOLS_VERSION_RE = /^\/\/ swift-tools-version: \d+\.\d+/; + +function firstLineOf(s) { + return s.split('\n', 1)[0]; +} + +describe('swift-tools-version directive must be on line 1', () => { + it('generateXCFrameworksPackageSwift (xcframeworks sub-package)', () => { + const out = generateXCFrameworksPackageSwift( + ['React', 'ReactNativeDependencies', 'hermes-engine'], + '/tmp/cache/0.85.3/debug', + ); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('generateAutolinkedPackageSwift (autolinker aggregator)', () => { + const out = generateAutolinkedPackageSwift({}); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('generateSynthPackageSwift (per-dep synth wrapper)', () => { + const out = generateSynthPackageSwift({ + swiftName: 'MyDep', + exclude: [], + publicHeadersPath: '.', + spmDependencies: [], + hasReactDep: false, + hasXcfwHeaders: false, + hasDepsHeaders: false, + codegenHeadersIncluded: false, + }); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('emitScaffoldedPackageSwift (community-lib scaffold)', () => { + const out = emitScaffoldedPackageSwift({ + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + }); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('codegen Package.swift.spm-template (static file)', () => { + const templatePath = path.resolve( + __dirname, + '..', + '..', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ); + const out = fs.readFileSync(templatePath, 'utf8'); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js new file mode 100644 index 000000000000..0591735c5e49 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js @@ -0,0 +1,118 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {main} = require('../sync-spm-autolinking'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('sync-spm-autolinking main', () => { + let appRoot; + let rnRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-app-')); + rnRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-rn-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(rnRoot, {recursive: true, force: true}); + }); + + function makeDeps(overrides = {}) { + return { + runCodegenAndInstallTemplate: jest.fn(), + generateAutolinking: jest.fn(), + installSpmCodegenTemplate: jest.fn(), + buildPerAppHeaderTree: jest.fn(), + findProjectRoot: jest.fn(() => appRoot), + // Obsolete collaborators are deliberately supplied to prove sync does + // not download artifacts or regenerate the runtime package graph. + downloadArtifacts: jest.fn(), + generatePackage: jest.fn(), + ...overrides, + }; + } + + function run(deps) { + return main(['--app-root', appRoot, '--react-native-root', rnRoot], deps); + } + + function stampPath() { + return path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-sync-stamp', + ); + } + + it('regenerates only invariant autolinking output and writes a stamp', async () => { + const deps = makeDeps(); + await run(deps); + + expect(deps.runCodegenAndInstallTemplate).toHaveBeenCalledWith( + appRoot, + appRoot, + rnRoot, + expect.any(Object), + {installTemplate: false}, + ); + expect(deps.installSpmCodegenTemplate).toHaveBeenCalledWith( + appRoot, + rnRoot, + expect.any(Object), + ); + expect(deps.generateAutolinking).toHaveBeenCalledWith([ + '--app-root', + appRoot, + '--react-native-root', + rnRoot, + ]); + expect(deps.buildPerAppHeaderTree).toHaveBeenCalledWith( + appRoot, + expect.any(Object), + ); + expect(deps.downloadArtifacts).not.toHaveBeenCalled(); + expect(deps.generatePackage).not.toHaveBeenCalled(); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('continues with existing output when codegen fails', async () => { + const deps = makeDeps({ + runCodegenAndInstallTemplate: jest.fn(() => { + throw new Error('codegen failed'); + }), + }); + await expect(run(deps)).resolves.toBeUndefined(); + expect(deps.generateAutolinking).toHaveBeenCalledTimes(1); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('installs the codegen template before fail-closed plugin execution', async () => { + const deps = makeDeps({ + generateAutolinking: jest.fn(() => { + throw new Error('plugin failed'); + }), + }); + await expect(run(deps)).rejects.toThrow(/plugin failed/); + expect( + deps.installSpmCodegenTemplate.mock.invocationCallOrder[0], + ).toBeLessThan(deps.generateAutolinking.mock.invocationCallOrder[0]); + expect(fs.existsSync(stampPath())).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js new file mode 100644 index 000000000000..aa001e4a1cb1 --- /dev/null +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -0,0 +1,331 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * SwiftPM autolinking plugins — the extension seam for frameworks (Expo first) + * that need to contribute their own SwiftPM package refs / product deps / + * generated sources into the autolinked package graph. + * + * PREVIEW / UNSTABLE CONTRACT. The discovery mechanism and the plugin + * function's context/return shape may change while the first real consumers + * (Expo) validate it. + * + * Why a plugin and not a post-process: the Xcode auto-sync build phase + * re-runs autolinking on every dependency change, so a one-shot rewrite of the + * generated Package.swift is clobbered on the next sync. Plugins are invoked + * from generate-spm-autolinking.js:main() — the single function both `add`/ + * `update` and the build-time `sync` call — so a contribution runs on EVERY + * regeneration. + * + * Discovery (transitive, mirrors CocoaPods' `use_expo_modules!`): any + * autolinked dependency can register a plugin from its own + * react-native.config.js — + * + * // node_modules/expo/react-native.config.js + * module.exports = { spm: { autolinkingPlugin: './spm/plugin.js' } }; + * + * Installing the framework is enough; no app-level config is required. An app + * MAY still exclude a plugin via `spm.denyPlugins` (npm-name list) in its own + * react-native.config.js — an escape hatch, not a required allowlist. + * + * Contract: + * + * module.exports = function plugin(context) { + * // context: {appRoot, projectRoot, reactNativeRoot, autolinking, + * // outputDir, react} + * // context.react (?ReactDescriptor): how to depend on React — + * // {packageRef, products}. packageRef is local ({name, path (absolute), + * // relPath}) or remote ({name, url, version}); products is the set RN + * // gives its own autolinked targets (incl. ReactAppHeaders from the + * // separate React-GeneratedCode package), filtered to what resolves + * // this run. Use it instead of re-deriving RN's path/identity/products. + * return { + * packageDependencies: [{name, path}] | [{name, url, version}], + * productDependencies: [{name, package}], + * // Sources compiled INTO THE APP TARGET (not the static Autolinked + * // aggregate) — the only place an @objc class reaches the ObjC + * // classlist, so emit files here when a class must be reachable by + * // name (e.g. via NSClassFromString), as Expo's ExpoModulesProvider + * // needs. Recorded to `.spm-plugin-generated-sources.json`; the + * // `spm add`/`update` xcodeproj injector wires each into the app + * // target (PBXFileReference + Sources-phase entry), marker-tracked + * // so deinit reverts and update reconciles. + * generatedSources: [{path}], + * // Precompiled dynamic frameworks selected and embedded by RN outside + * // the SwiftPM graph. Both flavors are mandatory. + * flavoredFrameworks: [{id, frameworkName, linkage: 'dynamic', + * flavors: {debug, release}}], + * // Absolute paths (dirs OR files) the Xcode auto-sync build phase + * // should watch for staleness — e.g. the plugin dep's own + * // `Package.swift`, `expo-module.config.json`, and per-module + * // manifests. Folded into `.spm-sync-watch-paths`; a file edit or a + * // dir add/remove there re-triggers the sync. Relative/empty/non-string + * // entries are dropped with a warning; a non-array is ignored. + * watchPaths: ['/abs/path/Package.swift', '/abs/dir'], + * }; + * }; + * + * The plugin returns DATA; it never writes into RN's generated tree. RN owns + * the merge, so regeneration stays deterministic and idempotent. + */ + +const path = require('path'); + +/*:: import type { + AutolinkedDep, + PluginContext, + PluginResult, + DiscoveredPlugin, +} from './spm-types'; +*/ + +/** + * Discover plugins declared by autolinked deps. `readConfig(root)` returns the + * dep's parsed react-native.config.js (or null). `denyList` is the app's + * `spm.denyPlugins` (npm names to skip). Fail-closed: a declared-but-missing + * or unloadable plugin throws, naming the dep — a framework silently dropping + * its modules is worse than a loud stop. + */ +function discoverPlugins( + deps /*: ReadonlyArray */, + readConfig /*: (root: string) => ?{readonly [string]: unknown} */, + denyList /*: ReadonlyArray */ = [], +) /*: Array */ { + const denied = new Set(denyList); + const found /*: Array */ = []; + for (const dep of deps) { + if (denied.has(dep.name)) { + continue; + } + const config = readConfig(dep.root); + // $FlowFixMe[incompatible-use] config has a dynamic shape + const rel = config?.spm?.autolinkingPlugin; + if (rel == null) { + continue; + } + if (typeof rel !== 'string' || rel.length === 0) { + throw new Error( + `react-native spm: '${dep.name}' declares an invalid spm.autolinkingPlugin ` + + `(expected a module path string).`, + ); + } + const pluginPath = path.resolve(dep.root, rel); + let fn: unknown; + try { + // $FlowFixMe[unsupported-syntax] dynamic require by computed path + fn = require(pluginPath); + } catch (e) { + throw new Error( + `react-native spm: failed to load the autolinking plugin for '${dep.name}' ` + + `at ${pluginPath}: ${e.message}`, + ); + } + // Support `module.exports = fn` and `{default: fn}` / `{plugin: fn}`. + const resolved = + typeof fn === 'function' + ? fn + : // $FlowFixMe[incompatible-use] interop shapes + typeof fn?.default === 'function' + ? fn.default + : // $FlowFixMe[incompatible-use] + typeof fn?.plugin === 'function' + ? fn.plugin + : null; + if (resolved == null) { + throw new Error( + `react-native spm: the autolinking plugin for '${dep.name}' at ${pluginPath} ` + + `does not export a function.`, + ); + } + found.push({depName: dep.name, pluginPath, plugin: resolved}); + } + return found; +} + +/** + * Invoke discovered plugins and merge their results. Each plugin gets the same + * context. Fail-closed: a throwing plugin aborts (named), and a malformed + * return is rejected. Dedupe package + product deps by name so a plugin and an + * npm dep that both reference the same package don't double-declare. + */ +function invokePlugins( + plugins /*: ReadonlyArray */, + context /*: PluginContext */, + logger /*: {warn: (msg: string) => void} */ = { + warn: msg => console.warn(msg), + }, +) /*: PluginResult */ { + const packageDependencies /*: Array<{name: string, path?: string, url?: string, version?: string}> */ = + []; + const productDependencies /*: Array<{name: string, package: string}> */ = []; + const generatedSources /*: Array<{path: string}> */ = []; + const flavoredFrameworks /*: Array<{id: string, frameworkName: string, linkage: 'dynamic', flavors: {debug: string, release: string}}> */ = + []; + const watchPaths /*: Array */ = []; + const seenPackages /*: Set */ = new Set(); + const seenProducts /*: Set */ = new Set(); + const seenFrameworkIds /*: Set */ = new Set(); + const seenFrameworkNames /*: Set */ = new Set(); + + for (const {depName, pluginPath, plugin} of plugins) { + let result: unknown; + try { + result = plugin(context); + } catch (e) { + throw new Error( + `react-native spm: the autolinking plugin for '${depName}' (${pluginPath}) ` + + `threw: ${e.message}`, + ); + } + if (result == null) { + continue; + } + if (typeof result !== 'object') { + throw new Error( + `react-native spm: the autolinking plugin for '${depName}' returned a ` + + `${typeof result}; expected an object or undefined.`, + ); + } + // $FlowFixMe[incompatible-use] validated field-by-field below + const pkgs = result.packageDependencies ?? []; + // $FlowFixMe[incompatible-use] + const prods = result.productDependencies ?? []; + // $FlowFixMe[incompatible-use] + const srcs = result.generatedSources ?? []; + // $FlowFixMe[incompatible-use] + const rawFrameworks = result.flavoredFrameworks ?? []; + // $FlowFixMe[incompatible-use] + const rawWatch = result.watchPaths ?? []; + if (!Array.isArray(rawFrameworks)) { + throw new Error( + `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`, + ); + } + // Watch paths are best-effort staleness hints, so + // a non-array is ignored (warn, never fatal). + if (!Array.isArray(rawWatch)) { + logger.warn( + `react-native spm: '${depName}' returned a non-array watchPaths ` + + `— ignoring it.`, + ); + } + const watch = Array.isArray(rawWatch) ? rawWatch : []; + for (const p of pkgs) { + if (p == null || typeof p.name !== 'string') { + throw new Error( + `react-native spm: '${depName}' returned a packageDependency without a name.`, + ); + } + if (p.path == null && (p.url == null || p.version == null)) { + throw new Error( + `react-native spm: '${depName}' packageDependency '${p.name}' needs either ` + + `a path or a url+version.`, + ); + } + if (!seenPackages.has(p.name)) { + seenPackages.add(p.name); + packageDependencies.push(p); + } + } + for (const p of prods) { + if ( + p == null || + typeof p.name !== 'string' || + typeof p.package !== 'string' + ) { + throw new Error( + `react-native spm: '${depName}' returned a productDependency needing name + package.`, + ); + } + const key = `${p.package}/${p.name}`; + if (!seenProducts.has(key)) { + seenProducts.add(key); + productDependencies.push(p); + } + } + for (const s of srcs) { + if (s == null || typeof s.path !== 'string') { + throw new Error( + `react-native spm: '${depName}' returned a generatedSource without a path.`, + ); + } + generatedSources.push(s); + } + for (const framework of rawFrameworks) { + if ( + framework == null || + typeof framework.id !== 'string' || + !/^[A-Za-z0-9_.-]+$/.test(framework.id) || + typeof framework.frameworkName !== 'string' || + framework.frameworkName.length === 0 || + framework.linkage !== 'dynamic' || + framework.flavors == null || + typeof framework.flavors.debug !== 'string' || + typeof framework.flavors.release !== 'string' || + !path.isAbsolute(framework.flavors.debug) || + !path.isAbsolute(framework.flavors.release) + ) { + throw new Error( + `react-native spm: '${depName}' returned an invalid flavoredFramework ` + + '(need {id, frameworkName, linkage: "dynamic", flavors: ' + + '{debug, release}} with absolute flavor paths).', + ); + } + if (seenFrameworkIds.has(framework.id)) { + throw new Error( + `react-native spm: duplicate flavored framework id '${framework.id}'.`, + ); + } + if (seenFrameworkNames.has(framework.frameworkName)) { + throw new Error( + `react-native spm: multiple plugins embed '${framework.frameworkName}.framework'.`, + ); + } + seenFrameworkIds.add(framework.id); + seenFrameworkNames.add(framework.frameworkName); + flavoredFrameworks.push({ + id: framework.id, + frameworkName: framework.frameworkName, + linkage: 'dynamic', + flavors: { + debug: framework.flavors.debug, + release: framework.flavors.release, + }, + }); + } + // watchPaths are dropped (with a warning), not fatal. A non-string, empty, + // or relative entry only + // means one staleness input is missed, not a broken build. Absolute-only so + // the generated phase (which has no cwd context) can test them directly. + for (const w of watch) { + if (typeof w !== 'string' || w.length === 0 || !path.isAbsolute(w)) { + logger.warn( + `react-native spm: '${depName}' returned an invalid watchPath ` + + `(need a non-empty absolute path string) — dropping it.`, + ); + continue; + } + watchPaths.push(w); + } + } + + return { + packageDependencies, + productDependencies, + generatedSources, + flavoredFrameworks, + watchPaths, + }; +} + +module.exports = { + discoverPlugins, + invokePlugins, +}; diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js new file mode 100644 index 000000000000..4b428f354090 --- /dev/null +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -0,0 +1,1409 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type {DownloadArgs, ResolvedArtifact, ProcessResult, ArtifactResultEntry} from './spm-types'; */ + +/** + * download-spm-artifacts.js + * + * Downloads the three React Native iOS xcframeworks from Maven, extracts + * them to a local cache directory, and writes artifacts.json for use by + * generate-spm-package.js. + * + * Supports stable releases, nightlies, and snapshot builds, matching + * the same resolution logic used by the existing CocoaPods scripts. + * + * Artifacts handled: + * React – react-native-core tarball from Maven + * ReactNativeDependencies – react-native-dependencies tarball from Maven + * hermes-engine – hermes-ios tarball from Maven + * + * Usage: + * node scripts/download-spm-artifacts.js [options] + * + * Options: + * --version RN version. Defaults to version in package.json. + * Use "nightly" to resolve the latest nightly. + * --flavor debug (default) or release. + * --output Where to write xcframeworks. + * Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/ + * (downloaded tarballs are shared with CocoaPods in + * ~/Library/Caches/ReactNative/; RCT_SKIP_CACHES=1 bypasses.) + * + * Per-artifact version overrides (mirrors existing env vars): + * HERMES_VERSION= + * RN_DEP_VERSION= + * ENTERPRISE_REPOSITORY= Custom Maven mirror (must match Maven structure) + * + * Output: + * /React.xcframework/ + * /ReactNativeDependencies.xcframework/ + * /hermes-engine.xcframework/ + * /artifacts.json ← maps target names to xcframework paths + */ + +const { + defaultCacheDir, + displayPath, + makeLogger, + sharedCacheDir, +} = require('./spm-utils'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const stream = require('stream'); +const yargs = require('yargs'); + +const {log, warn, die} = makeLogger('download-spm-artifacts'); + +function parseArgs(argv /*: Array */) /*: DownloadArgs */ { + const parsed = yargs(argv) + .version(false) + .option('version', { + alias: 'v', + type: 'string', + describe: + 'RN version. Defaults to version in package.json. Use "nightly" to resolve the latest nightly.', + }) + .option('flavor', { + type: 'string', + default: 'debug', + describe: 'debug or release', + }) + .option('output', { + alias: 'o', + type: 'string', + describe: + 'Where to write xcframeworks. Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/', + }) + .option('core-tarball', { + type: 'string', + describe: + 'Local React core tarball to use instead of downloading (e.g. the prebuild output). Env fallback: RN_CORE_TARBALL_PATH.', + }) + .option('headers-tarball', { + type: 'string', + describe: + 'Local ReactNativeHeaders tarball to use instead of downloading. Env fallback: RN_HEADERS_TARBALL_PATH.', + }) + .option('deps-tarball', { + type: 'string', + describe: + 'Local ReactNativeDependencies tarball to use instead of downloading (e.g. the deps prebuild output). Carries the headers sidecar beside the binary, so this subsumes --deps-headers-tarball for a local build. Env fallback: RN_DEPS_TARBALL_PATH.', + }) + .option('deps-headers-tarball', { + type: 'string', + describe: + 'Local ReactNativeDependenciesHeaders tarball. Normally unneeded — the sidecar ships inside the ReactNativeDependencies tarball. Env fallback: RN_DEPS_HEADERS_TARBALL_PATH.', + }) + .usage( + 'Usage: $0 [options]\n\nDownloads React Native iOS xcframeworks from Maven.', + ) + .help() + .parseSync(); + + return { + version: parsed.version ?? null, + flavor: parsed.flavor.toLowerCase(), + output: parsed.output ?? null, + coreTarball: + parsed['core-tarball'] ?? process.env.RN_CORE_TARBALL_PATH ?? null, + headersTarball: + parsed['headers-tarball'] ?? process.env.RN_HEADERS_TARBALL_PATH ?? null, + depsTarball: + parsed['deps-tarball'] ?? process.env.RN_DEPS_TARBALL_PATH ?? null, + depsHeadersTarball: + parsed['deps-headers-tarball'] ?? + process.env.RN_DEPS_HEADERS_TARBALL_PATH ?? + null, + }; +} + +const MAVEN_RELEASE = + process.env.ENTERPRISE_REPOSITORY ?? 'https://repo1.maven.org/maven2'; +const MAVEN_SNAPSHOT = + 'https://central.sonatype.com/repository/maven-snapshots'; + +function rnCoreReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-core-${flavor}.tar.gz` + ); +} +function rnDepsReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-dependencies-${flavor}.tar.gz` + ); +} +function hermesReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/hermes/hermes-ios/${version}/` + + `hermes-ios-${version}-hermes-ios-${flavor}.tar.gz` + ); +} + +/** + * Resolves a Maven snapshot URL by fetching maven-metadata.xml and extracting + * the latest timestamp+buildNumber. Mirrors computeNightlyTarballURL() in utils.js. + * + * @param {string} version Base version without -SNAPSHOT suffix (e.g. "0.85.0") + * @param {string} subGroup com/facebook/ + * @param {string} coordinate Maven artifact coordinate (e.g. "react-native-artifacts") + * @param {string} artifactName Classifier part of the filename (e.g. "reactnative-core-debug.tar.gz") + */ +async function resolveSnapshotUrl( + version /*: string */, + subGroup /*: string */, + coordinate /*: string */, + artifactName /*: string */, +) /*: Promise */ { + const metadataUrl = + `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` + + `${version}-SNAPSHOT/maven-metadata.xml`; + + log(` Fetching snapshot metadata: ${metadataUrl}`); + const res = await fetch(metadataUrl); + if (!res.ok) { + throw new Error( + `Failed to fetch snapshot metadata (${res.status}): ${metadataUrl}`, + ); + } + const xml = await res.text(); + + const ts = (xml.match(/(.*?)<\/timestamp>/) ?? [])[1]; + const bn = (xml.match(/(.*?)<\/buildNumber>/) ?? [])[1]; + if (!ts || !bn) { + throw new Error( + `Could not parse timestamp/buildNumber from ${metadataUrl}`, + ); + } + + const fullVersion = `${version}-${ts}-${bn}`; + return ( + `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` + + `${version}-SNAPSHOT/${coordinate}-${fullVersion}-${artifactName}` + ); +} + +async function rnCoreSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'react', + 'react-native-artifacts', + `reactnative-core-${flavor}.tar.gz`, + ); +} +async function rnDepsSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'react', + 'react-native-artifacts', + `reactnative-dependencies-${flavor}.tar.gz`, + ); +} +async function hermesSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'hermes', + 'hermes-ios', + `hermes-ios-${flavor}.tar.gz`, + ); +} + +async function resolveNightlyVersion( + npmPackage /*: string */, +) /*: Promise */ { + log(` Resolving nightly version from npm: ${npmPackage}`); + + const res = await fetch(`https://registry.npmjs.org/${npmPackage}/nightly`); + if (!res.ok) { + throw new Error(`npm lookup failed for ${npmPackage}: ${res.status}`); + } + const ver = (await res.json())?.version; + assertSafeVersion(ver, `${npmPackage}/nightly`); + log(` Resolved nightly: ${ver}`); + return ver; +} + +// A version string flows into Maven URLs and local tarball filenames (which are +// then passed to tar/cp via execFileSync). Constrain it to a safe charset so a +// malformed/hostile registry response can't produce a surprising path or a +// confusing 404 — and so static analysis sees an explicit sanitizer. +function assertSafeVersion( + ver /*: unknown */, + source /*: string */, +) /*: void */ { + if (typeof ver !== 'string' || !/^[A-Za-z0-9._-]+$/.test(ver)) { + throw new Error( + `npm response for ${source} has no usable "version" field (got: ${String(ver)})`, + ); + } +} + +/** + * Returns the cache-slot key for a given raw version label. + * + * Stable versions ('0.80.0', '0.81.0', …) become their own slot. + * Dev / nightly labels ('1000.0.0', 'nightly') resolve to the current + * nightly version (e.g. '0.85.0-nightly-20260515-abc') so each published + * nightly is its own slot — a new nightly invalidates automatically + * instead of sticking on a stale `1000.0.0` cache forever. + * + * If the npm registry lookup fails (offline, transient error), falls back + * to the raw label so a previously-cached slot under that label can still + * be used. A subsequent download attempt would surface the real error. + */ +async function resolveCacheSlotVersion( + rawVersion /*: string */, +) /*: Promise */ { + if (rawVersion !== '1000.0.0' && rawVersion !== 'nightly') { + return rawVersion; + } + try { + return await resolveNightlyVersion('react-native'); + } catch { + return rawVersion; + } +} + +async function resolveLatestV1Version() /*: Promise */ { + log(' Resolving latest-v1 Hermes from npm...'); + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch( + 'https://registry.npmjs.org/hermes-compiler/latest-v1', + ); + if (!res.ok) { + throw new Error(`npm lookup failed: ${res.status}`); + } + const ver = (await res.json())?.version; + assertSafeVersion(ver, 'hermes-compiler/latest-v1'); + log(` Resolved latest-v1: ${ver}`); + return ver; +} + +async function exists(url /*: string */) /*: Promise */ { + try { + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch(url, {method: 'HEAD'}); + return res.status === 200; + } catch { + return false; + } +} + +/** + * Returns {url, version} for the React Native core xcframework tarball. + * Resolution order: + * 1. Stable release on Maven Central + * 2. Snapshot build on Sonatype + */ +async function resolveRNCoreArtifact( + version /*: string */, + flavor /*: string */, + localTarball /*: ?string */, +) /*: Promise */ { + // Local-tarball override (--core-tarball / RN_CORE_TARBALL_PATH): use a + // locally built core tarball (e.g. the prebuild's output) instead of + // downloading. processArtifact() treats an existing local path as "already + // downloaded" and always re-extracts it. NOTE: distinct from CocoaPods' + // RCT_TESTONLY_RNCORE_TARBALL_PATH — that one belongs to pod install. + if (localTarball != null && localTarball !== '') { + if (!fs.existsSync(localTarball)) { + throw new Error( + `core tarball override is set to ${localTarball} but the file does not exist`, + ); + } + log(` Using LOCAL core tarball: ${localTarball}`); + return {url: localTarball, version: `${version}-local`}; + } + const releaseUrl = rnCoreReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await rnCoreSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +/** + * Returns {url, version} for ReactNativeDependencies. + * Respects RN_DEP_VERSION env var. + */ +async function resolveRNDepsArtifact( + rnVersion /*: string */, + flavor /*: string */, + localTarball /*: ?string */, +) /*: Promise */ { + // Local-tarball override (--deps-tarball / RN_DEPS_TARBALL_PATH): use a + // locally built deps tarball (the deps prebuild output) instead of + // downloading. It carries ReactNativeDependenciesHeaders.xcframework beside + // the binary, so the existing companion-staging path (COMPANION_XCFRAMEWORKS + // 'rndeps' -> ReactNativeDependenciesHeaders) supplies the sidecar too — no + // separate --deps-headers-tarball needed for a local build. + if (localTarball != null && localTarball !== '') { + if (!fs.existsSync(localTarball)) { + throw new Error( + `deps tarball override is set to ${localTarball} but the file does not exist`, + ); + } + log(` Using LOCAL deps tarball: ${localTarball}`); + return {url: localTarball, version: `${rnVersion}-local`}; + } + let version = process.env.RN_DEP_VERSION ?? rnVersion; + if (version === 'nightly') { + version = await resolveNightlyVersion('react-native'); + } + + const releaseUrl = rnDepsReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await rnDepsSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +/** + * Returns {url, version} for Hermes. Hermes uses its own version space + * decoupled from React Native's nightly cadence — RN's `hermes-compiler` + * npm package publishes a `latest-v1` dist-tag that always resolves to a + * binary that's been built and uploaded to Maven. Our default mirrors RN's + * CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js): + * + * HERMES_VERSION unset → 'latest-v1' dist-tag + * HERMES_VERSION=latest-v1 → same (explicit) + * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag + * HERMES_VERSION= → use that version verbatim + * + * Note: rnVersion / rawVersion are intentionally not consulted. There is no + * guarantee a hermes-ios artifact exists for any given RN nightly hash — + * tying them together produces 404s like #(repro case from spikes/MyApp). + */ +async function resolveHermesArtifact( + rnVersion /*: string */, + flavor /*: string */, + rawVersion /*: string | null */, +) /*: Promise */ { + let version = process.env.HERMES_VERSION ?? 'latest-v1'; + + if (version === 'nightly') { + version = await resolveNightlyVersion('hermes-compiler'); + } else if (version === 'latest-v1') { + version = await resolveLatestV1Version(); + } + + const releaseUrl = hermesReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await hermesSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +function formatBytes(bytes /*: number */) /*: string */ { + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatSpeed(bytesPerSec /*: number */) /*: string */ { + if (bytesPerSec < 1024 * 1024) { + return `${(bytesPerSec / 1024).toFixed(0)} KB/s`; + } + return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`; +} + +/** + * Creates a multi-line progress display that keeps N lines pinned at the + * bottom of the terminal. Each line is prefixed and truncated to the current + * terminal width — without truncation, a long line (e.g. a FAILED message + * carrying a URL) wraps to a second row and `\x1b[2K` only clears the first, + * leaving stray fragments after the next update. + */ +function createProgressDisplay( + lineCount /*: number */, + prefix /*: string */ = '', +) /*: {update: (index: number, text: string) => void} */ { + let initialized = false; + + function truncateToWidth(s /*: string */) /*: string */ { + // $FlowFixMe[prop-missing] columns lives on tty$WriteStream not stream$Writable + const cols = process.stdout.columns ?? 120; + const budget = Math.max(10, cols - 1); + let out = ''; + let visLen = 0; + let i = 0; + while (i < s.length) { + if (s[i] === '\x1b' && s[i + 1] === '[') { + // CSI escape: forward through the final letter without counting. + let j = i + 2; + while (j < s.length && !/[a-zA-Z]/.test(s[j])) j++; + out += s.slice(i, j + 1); + i = j + 1; + } else { + if (visLen >= budget - 1) return out + '…\x1b[0m'; + out += s[i]; + visLen++; + i++; + } + } + return out; + } + + function update(index /*: number */, text /*: string */) { + if (!initialized) { + for (let i = 0; i < lineCount; i++) { + process.stdout.write('\n'); + } + initialized = true; + } + const moveUp = lineCount - index; + const line = truncateToWidth(prefix + text); + process.stdout.write(`\x1b[${moveUp}A\x1b[2K\r${line}\x1b[${moveUp}B\r`); + } + + return {update}; +} + +/*:: +type ProgressCallback = (label: string, downloaded: number, total: number, speed: number, done: boolean, elapsed: number) => void; +*/ + +async function download( + url /*: string */, + destPath /*: string */, + onProgress /*:: ?: ProgressCallback */, +) /*: Promise */ { + if (fs.existsSync(destPath)) { + log(` Already cached: ${path.basename(destPath)}`); + return; + } + + const tmpPath = destPath + '.download'; + try { + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`); + } + + const totalBytes = parseInt(res.headers.get('content-length') ?? '0', 10); + let downloadedBytes = 0; + let lastPrintTime = Date.now(); + let lastPrintBytes = 0; + const startTime = Date.now(); + + const fileStream = fs.createWriteStream(tmpPath); + + const reportProgress = (final /*: boolean */ = false) => { + const now = Date.now(); + const elapsed = (now - startTime) / 1000; + const intervalMs = now - lastPrintTime; + const intervalBytes = downloadedBytes - lastPrintBytes; + const speed = intervalMs > 0 ? (intervalBytes / intervalMs) * 1000 : 0; + + if (onProgress) { + onProgress( + path.basename(destPath), + downloadedBytes, + totalBytes, + speed, + final, + elapsed, + ); + } else { + // Fallback: single-line progress (used when not in parallel mode) + let line = ` ${formatBytes(downloadedBytes)}`; + if (totalBytes > 0) { + const pct = ((downloadedBytes / totalBytes) * 100).toFixed(1); + line += ` / ${formatBytes(totalBytes)} (${pct}%)`; + } + line += ` @ ${formatSpeed(speed)}`; + + if (final) { + const totalMb = formatBytes(downloadedBytes); + const totalSec = elapsed.toFixed(1); + const avgSpeed = + elapsed > 0 ? formatSpeed(downloadedBytes / elapsed) : ''; + process.stdout.write( + `\r Done: ${totalMb} in ${totalSec}s (avg ${avgSpeed}) \n`, + ); + } else { + process.stdout.write(`\r${line} `); + } + } + + if (!final) { + lastPrintTime = now; + lastPrintBytes = downloadedBytes; + } + }; + + if (res.body) { + // fetch() returns a Web ReadableStream, not a Node.js Readable. + // Convert it so we can pipe to a file stream and track progress. + // $FlowFixMe[prop-missing] stream.Readable.fromWeb not in Flow stubs + const nodeReadable = stream.Readable.fromWeb(res.body); + + await new Promise((resolve, reject) => { + let progressInterval; + try { + progressInterval = setInterval(() => reportProgress(), 500); + + nodeReadable + .on('data', chunk => { + downloadedBytes += chunk.length; + }) + .on('error', err => { + clearInterval(progressInterval); + reject(err); + }) + .pipe(fileStream) + .on('finish', () => { + clearInterval(progressInterval); + reportProgress(true); + resolve(); + }) + .on('error', err => { + clearInterval(progressInterval); + reject(err); + }); + } catch (err) { + if (progressInterval != null) clearInterval(progressInterval); + reject(err); + } + }); + } else { + const buf = await res.arrayBuffer(); + downloadedBytes = buf.byteLength; + fs.writeFileSync(tmpPath, Buffer.from(buf)); + reportProgress(true); + } + + fs.renameSync(tmpPath, destPath); + } catch (err) { + // Clean up partial .download temp file on failure + try { + fs.unlinkSync(tmpPath); + } catch { + // temp file may not exist yet + } + throw err; + } +} + +/** + * Extracts a .tar.gz and returns the path to the first .xcframework found. + */ +function extractXCFramework( + tarPath /*: string */, + extractDir /*: string */, +) /*: string */ { + fs.mkdirSync(extractDir, {recursive: true}); + log(` Extracting ${path.basename(tarPath)}...`); + execFileSync('tar', ['-xzf', tarPath, '-C', extractDir], {stdio: 'pipe'}); + + const found = findFirst(extractDir, name => name.endsWith('.xcframework'), 8); + if (found == null) { + throw new Error(`No .xcframework found after extracting ${tarPath}`); + } + log(` Found: ${path.relative(extractDir, found)}`); + return found; +} + +function findFirst( + dir /*: string */, + predicate /*: (name: string) => boolean */, + depth /*: number */, +) /*: string | null */ { + if (depth <= 0 || !fs.existsSync(dir)) { + return null; + } + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + const full /*: string */ = path.join(dir, entry.name); + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + if (predicate(entry.name)) { + return full; + } + if (entry.isDirectory()) { + const hit = findFirst(full, predicate, depth - 1); + if (hit != null) { + return hit; + } + } + } + return null; +} + +/** + * The hermes-ios tarball ships its public C++ API headers in + * `destroot/include/hermes` alongside the framework — which extractXCFramework + * discards (it keeps only the .xcframework). Stage the `hermes/` namespace into + * `/hermes-headers/hermes` so headers-compose can fold it into + * ReactNativeHeaders (making `` resolve for any RN-linking + * target). Only `hermes/` is staged — `jsi/` is already vended elsewhere. + * Best-effort: a tarball without these headers just leaves hermes unavailable. + */ +function stageHermesHeaders( + extractDir /*: string */, + outputDir /*: string */, +) /*: void */ { + let includeDir = path.join(extractDir, 'destroot', 'include'); + if (!fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h'))) { + // Fall back to locating the include dir wherever it landed in the tarball. + const hit = findFirst(extractDir, name => name === 'include', 8); + if (hit != null) { + includeDir = hit; + } + } + const src = path.join(includeDir, 'hermes'); + if (!fs.existsSync(path.join(src, 'hermes.h'))) { + log(' Hermes public headers not found in tarball — skipping header stage'); + return; + } + const destRoot = path.join(outputDir, 'hermes-headers'); + const dest = path.join(destRoot, 'hermes'); + fs.rmSync(dest, {recursive: true, force: true}); + fs.mkdirSync(destRoot, {recursive: true}); + execFileSync('/bin/cp', ['-R', src, dest], {stdio: 'pipe'}); + log(' Staged Hermes public headers → hermes-headers/hermes'); +} + +const DEPS_HEADERS_NAME = 'ReactNativeDependenciesHeaders'; + +// Headers-only companion xcframeworks that ship INSIDE another artifact's +// tarball: the ReactCore tarball carries ReactNativeHeaders.xcframework +// beside React.xcframework, and the ReactNativeDependencies tarball carries +// the ReactNativeDependenciesHeaders sidecar beside the binary (the binary is +// framework-type, so only the LIBRARY-type sidecar can serve the deps +// namespaces — folly/glog/boost/... — to SwiftPM; ReactNativeHeaders is +// pure-RN). extractXCFramework keeps only the first xcframework, so +// companions are staged separately into their own +// `/.xcframework`. +const COMPANION_XCFRAMEWORKS /*: {[label: string]: string} */ = { + 'react-core': 'ReactNativeHeaders', + rndeps: DEPS_HEADERS_NAME, +}; + +/** + * Stages a companion xcframework out of an extract dir. Returns true when + * staged; false when the tarball predates the companion. + */ +function stageCompanionXcframework( + extractDir /*: string */, + outputDir /*: string */, + name /*: string */, +) /*: boolean */ { + const src = findFirst(extractDir, n => n === `${name}.xcframework`, 8); + if (src == null) { + return false; + } + const dest = path.join(outputDir, `${name}.xcframework`); + fs.rmSync(dest, {recursive: true, force: true}); + fs.renameSync(src, dest); + log(` Staged companion ${name}.xcframework`); + return true; +} + +/** + * Self-heal: stage a companion xcframework into an already-extracted slot + * (the fast path skips extraction, so the companion was never staged). + * Prefers a CACHED tarball (no network); downloads only as a last resort. + */ +async function ensureCompanionStaged( + url /*: string */, + downloadDir /*: string */, + sharedTarballName /*: ?string */, + outputDir /*: string */, + name /*: string */, +) /*: Promise */ { + const candidates = [ + !/^https?:\/\//.test(url) ? url : null, // local-tarball override + path.join(downloadDir, url.split('/').pop() ?? ''), + sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null, + ].filter(Boolean); + let tarPath /*: ?string */ = candidates.find( + p => p != null && fs.existsSync(p), + ); + if (tarPath == null) { + if (!/^https?:\/\//.test(url)) { + return; // local override missing — nothing to recover from + } + const localPath = path.join( + downloadDir, + url.split('/').pop() ?? 'artifact.tar.gz', + ); + fs.mkdirSync(downloadDir, {recursive: true}); + await download(url, localPath); + tarPath = localPath; + } + const tmp = path.join(outputDir, `.companion-tmp-${name}`); + fs.rmSync(tmp, {recursive: true, force: true}); + fs.mkdirSync(tmp, {recursive: true}); + try { + execFileSync('tar', ['-xzf', tarPath, '-C', tmp], {stdio: 'pipe'}); + stageCompanionXcframework(tmp, outputDir, name); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +/** + * Self-heal: stage Hermes headers into an already-extracted slot (the fast + * path skips extraction, so the headers were never staged). Prefers a CACHED + * hermes tarball (no network); downloads only as a last resort so the slot + * can't get stuck "incomplete" forever. No-op only when the headers can't be + * obtained at all (e.g. a missing local-tarball override). + */ +async function ensureHermesHeadersStaged( + url /*: string */, + downloadDir /*: string */, + sharedTarballName /*: ?string */, + outputDir /*: string */, +) /*: Promise */ { + const candidates = [ + !/^https?:\/\//.test(url) ? url : null, // local-tarball override + path.join(downloadDir, url.split('/').pop() ?? ''), + sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null, + ].filter(Boolean); + let tarPath /*: ?string */ = candidates.find( + p => p != null && fs.existsSync(p), + ); + if (tarPath == null) { + if (!/^https?:\/\//.test(url)) { + return; // local override missing — nothing to recover from + } + const localPath = path.join( + downloadDir, + url.split('/').pop() ?? 'hermes.tar.gz', + ); + fs.mkdirSync(downloadDir, {recursive: true}); + await download(url, localPath); + tarPath = localPath; + } + const tmp = path.join(outputDir, '.hermes-hdr-tmp'); + fs.rmSync(tmp, {recursive: true, force: true}); + fs.mkdirSync(tmp, {recursive: true}); + try { + execFileSync('tar', ['-xzf', tarPath, '-C', tmp], {stdio: 'pipe'}); + stageHermesHeaders(tmp, outputDir); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +/** + * Downloads a tarball, extracts the xcframework, and places it directly in + * the output directory as .xcframework/. + * + * SPM binaryTarget(path:) accepts a bare .xcframework directory — no zip or + * checksum needed for local path-based targets. + * + * @param {string} label Internal label (used for log messages) + * @param {string} xcframeworkName The SPM target name (e.g. "React", "hermes-engine") + * @param resolvedArtifact {url, version} from resolve*Artifact() + * @param {string} downloadDir Where to cache downloaded tarballs + * @param {string} outputDir Where to place the final .xcframework directory + * @param sharedTarballName Filename in the flat shared cache to reuse/populate + * (matches CocoaPods' convention), or null to not share. + */ +async function processArtifact( + label /*: string */, + xcframeworkName /*: string */, + resolvedArtifact /*: ResolvedArtifact */, + downloadDir /*: string */, + outputDir /*: string */, + onProgress /*:: ?: ProgressCallback */, + sharedTarballName /*:: ?: ?string */, +) /*: Promise */ { + const {url, version} = resolvedArtifact; + + const destXcfwPath = path.join(outputDir, `${xcframeworkName}.xcframework`); + // Local-tarball override: `url` is an existing local file. Always + // re-extract (a changed local tarball must win over a previous extraction) + // and never touch the shared cache. + const isLocalTarball = !/^https?:\/\//.test(url) && fs.existsSync(url); + if (isLocalTarball && fs.existsSync(destXcfwPath)) { + fs.rmSync(destXcfwPath, {recursive: true, force: true}); + } + if (fs.existsSync(destXcfwPath)) { + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Already extracted: ${xcframeworkName}.xcframework`); + } + // The xcframework is cached, but a slot from older tooling won't have the + // Hermes headers staged. Backfill them from a cached tarball (no network). + if ( + label === 'hermes' && + !fs.existsSync(path.join(outputDir, 'hermes-headers', 'hermes')) + ) { + try { + await ensureHermesHeadersStaged( + url, + downloadDir, + sharedTarballName, + outputDir, + ); + } catch (e) { + log(` Hermes header backfill failed (${e.message}) — continuing`); + } + } + // Same for the headers-only companions (ReactNativeHeaders from the core + // tarball, the ReactNativeDependenciesHeaders sidecar from the deps + // tarball): backfill into slots extracted by older tooling from the + // cached tarball. + const companion = COMPANION_XCFRAMEWORKS[label]; + if ( + companion != null && + !fs.existsSync(path.join(outputDir, `${companion}.xcframework`)) + ) { + try { + await ensureCompanionStaged( + url, + downloadDir, + sharedTarballName, + outputDir, + companion, + ); + } catch (e) { + log(` ${companion} backfill failed (${e.message}) — continuing`); + } + } + return {label, version, xcframeworkPath: destXcfwPath, url}; + } + + // Tarball acquisition: prefer the flat shared cache (~/Library/Caches/ + // ReactNative/) that CocoaPods also populates, so SPM and `pod install` + // reuse the same download. RCT_SKIP_CACHES=1 bypasses it (mirrors CocoaPods). + const skipCaches = process.env.RCT_SKIP_CACHES === '1'; + const sharedPath = + !skipCaches && sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null; + + const downloadAndCache = async () /*: Promise */ => { + const tarName = url.split('/').pop() ?? ''; + const localPath = path.join(downloadDir, tarName); + await download( + url, + localPath, + onProgress + ? (name, downloaded, total, speed, done, elapsed) => + onProgress(xcframeworkName, downloaded, total, speed, done, elapsed) + : undefined, + ); + // Best-effort: save into the flat shared cache for future SPM/CocoaPods runs. + if (sharedPath != null) { + try { + fs.mkdirSync(sharedCacheDir(), {recursive: true}); + fs.copyFileSync(localPath, sharedPath); + } catch { + // ignore shared-cache write failures + } + } + return localPath; + }; + + let tarPath: string; + let fromShared = false; + if (isLocalTarball) { + tarPath = url; + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Using local tarball: ${url}`); + } + } else if (sharedPath != null && fs.existsSync(sharedPath)) { + // Shared cache hit — skip the download entirely. + tarPath = sharedPath; + fromShared = true; + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Shared cache hit: ${path.basename(sharedPath)}`); + } + } else { + tarPath = await downloadAndCache(); + } + + // Extract to a temp dir, rename to the expected name, then move into outputDir + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, false, 0); + } + const tmpExtractDir = path.join(outputDir, '.extract-tmp', label); + let xcfwPath: string; + try { + xcfwPath = extractXCFramework(tarPath, tmpExtractDir); + } catch (e) { + // A poisoned shared tarball must not permanently break SPM: drop it and + // re-download to the local dir once. + if (fromShared) { + try { + fs.rmSync(tarPath, {force: true}); + } catch {} + tarPath = await downloadAndCache(); + xcfwPath = extractXCFramework(tarPath, tmpExtractDir); + } else { + throw e; + } + } + + const actualBasename = path.basename(xcfwPath); + const expectedBasename = `${xcframeworkName}.xcframework`; + if (actualBasename !== expectedBasename) { + const renamed = path.join(tmpExtractDir, expectedBasename); + fs.renameSync(xcfwPath, renamed); + fs.renameSync(renamed, destXcfwPath); + } else { + fs.renameSync(xcfwPath, destXcfwPath); + } + + // Hermes ships its public headers in the same tarball; stage them next to + // the xcframeworks so headers-compose can fold `hermes/` into + // ReactNativeHeaders. (Other artifacts have no such headers — no-op.) + if (label === 'hermes') { + try { + stageHermesHeaders(tmpExtractDir, outputDir); + } catch (e) { + log(` Hermes header staging failed (${e.message}) — continuing`); + } + } + + // Headers-only companions ship inside the same tarball (ReactNativeHeaders + // in the core tarball, the ReactNativeDependenciesHeaders sidecar in the + // deps tarball) — stage them as their own artifacts. + const companionName = COMPANION_XCFRAMEWORKS[label]; + if (companionName != null) { + if (!stageCompanionXcframework(tmpExtractDir, outputDir, companionName)) { + log( + ` ${companionName}.xcframework not present in the ${label} tarball (pre-companion artifact)`, + ); + } + } + + fs.rmSync(tmpExtractDir, {recursive: true, force: true}); + + return {label, version, xcframeworkPath: destXcfwPath, url}; +} + +async function main(argv /*:: ?: Array */) /*: Promise */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + const rnRoot = path.resolve(__dirname, '../..'); + const flavor = args.flavor; + + // Resolve base RN version + // rawVersion preserves the original --version arg (e.g. 'nightly') before resolution. + // It is passed to Hermes resolution so it can independently resolve its nightly. + let rawVersion = args.version; + let rnVersion = args.version; + if (rnVersion == null) { + // $FlowFixMe[incompatible-type] JSON.parse returns any + const rnPkg /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(rnRoot, 'package.json'), 'utf8'), + ); + rnVersion = rnPkg.version; + } + if (rnVersion === '1000.0.0') { + log('Detected dev version (1000.0.0), resolving as nightly...'); + rawVersion = 'nightly'; + } + if (rnVersion === 'nightly' || rnVersion === '1000.0.0') { + rnVersion = await resolveNightlyVersion('react-native'); + } + if (rnVersion == null) { + die('Could not determine RN version'); + } + // Re-bind to const so Flow keeps the non-null narrowing across the closures + // below (let-bound vars are widened across function boundaries). + const resolvedRnVersion /*: string */ = rnVersion; + + // Cache key: stable versions slot under their own number. Dev / nightly + // labels use the resolved nightly hash (e.g. "0.85.0-nightly-20260515-abc") + // so each published nightly is its own slot — picks up new specs and fixes + // automatically instead of sticking on a stale "1000.0.0" cache forever. + const cacheVersionKey = + rawVersion === 'nightly' || rawVersion === '1000.0.0' || rawVersion == null + ? resolvedRnVersion + : rawVersion; + const outputDir = + args.output != null + ? path.resolve(args.output) + : defaultCacheDir(cacheVersionKey, flavor); + // Tarballs are cached in a .downloads/ subdirectory to keep them separate + // from the extracted .xcframework directories. + const downloadDir = path.join(outputDir, '.downloads'); + + fs.mkdirSync(outputDir, {recursive: true}); + fs.mkdirSync(downloadDir, {recursive: true}); + + log(`RN version : ${resolvedRnVersion}`); + log(`Flavor : ${flavor}`); + log(`Output : ${displayPath(outputDir)}`); + log(''); + + // Download all three artifacts in parallel for faster setup + log('Downloading artifacts in parallel...'); + + // `sharedName` builds the flat shared-cache filename in the canonical + // ~/Library/Caches/ReactNative/ dir, matching the names other RN tooling uses + // (CocoaPods' rncore.rb / rndependencies.rb for core+deps, and the hermes + // prebuilt tarball name) so SPM and `pod install` reuse the same downloads. + // `v` is each artifact's resolved version (RN version for core/deps, the + // hermes-ios version for hermes). + const artifactSpecs = [ + { + label: 'react-core', + name: 'React', + resolve: () => + resolveRNCoreArtifact(resolvedRnVersion, flavor, args.coreTarball), + // Local overrides skip the shared cache (test artifacts must not + // poison the canonical downloads). + sharedName: + args.coreTarball != null + ? null + : (v /*: string */) => `reactnative-core-${v}-${flavor}.tar.gz`, + }, + { + label: 'rndeps', + name: 'ReactNativeDependencies', + resolve: () => + resolveRNDepsArtifact(resolvedRnVersion, flavor, args.depsTarball), + // Local override skips the shared cache (as with core) so a local deps + // build can't poison the canonical downloads. + sharedName: + args.depsTarball != null + ? null + : (v /*: string */) => + `reactnative-dependencies-${v}-${flavor}.tar.gz`, + }, + { + label: 'hermes', + name: 'hermes-engine', + resolve: () => + resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion), + sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`, + }, + ]; + + // ReactNativeHeaders is normally staged straight out of the ReactCore + // tarball (it ships beside React.xcframework). The override exists for + // testing a standalone headers tarball (e.g. the prebuild output at + // .build/output/xcframeworks//ReactNativeHeaders.xcframework.tar.gz, + // published on Maven under classifier reactnative-headers-). + const headersTarball = args.headersTarball; + if (headersTarball != null && headersTarball !== '') { + if (!fs.existsSync(headersTarball)) { + die( + `headers tarball override is set to ${headersTarball} but the file does not exist`, + ); + } + artifactSpecs.push({ + label: 'rnheaders', + name: 'ReactNativeHeaders', + resolve: () => + Promise.resolve({ + url: headersTarball, + version: `${resolvedRnVersion}-local`, + }), + sharedName: null, + }); + } + + // ReactNativeDependenciesHeaders: normally staged straight out of the deps + // tarball (the sidecar ships inside it). The override exists for testing a + // standalone sidecar tarball (classifier + // reactnative-dependencies-headers-). + const depsHeadersTarball = args.depsHeadersTarball; + if (depsHeadersTarball != null && depsHeadersTarball !== '') { + if (!fs.existsSync(depsHeadersTarball)) { + die( + `deps-headers tarball override is set to ${depsHeadersTarball} but the file does not exist`, + ); + } + artifactSpecs.push({ + label: 'rndeps-headers', + name: DEPS_HEADERS_NAME, + resolve: () => + Promise.resolve({ + url: depsHeadersTarball, + version: `${resolvedRnVersion}-local`, + }), + sharedName: null, + }); + } + + const progress = createProgressDisplay( + artifactSpecs.length, + '\x1b[32m[download-spm-artifacts]\x1b[0m ', + ); + + const makeCallback = (index /*: number */) /*: ProgressCallback */ => + (name, downloaded, total, speed, done, elapsed) => { + if (done && downloaded === 0 && total === 0) { + progress.update(index, ` ${name}: already cached`); + } else if (done) { + const avg = elapsed > 0 ? formatSpeed(downloaded / elapsed) : ''; + progress.update( + index, + ` ${name}: done ${formatBytes(downloaded)} in ${elapsed.toFixed(1)}s (${avg})`, + ); + } else if (total > 0) { + const pct = ((downloaded / total) * 100).toFixed(1); + progress.update( + index, + ` ${name}: ${formatBytes(downloaded)} / ${formatBytes(total)} (${pct}%) @ ${formatSpeed(speed)}`, + ); + } else { + progress.update(index, ` ${name}: extracting...`); + } + }; + + const results /*: Array */ = await Promise.all( + artifactSpecs.map(async (spec, index) => { + try { + const artifact = await spec.resolve(); + progress.update(index, ` ${spec.name}: resolving...`); + const sharedTarballName = + spec.sharedName != null ? spec.sharedName(artifact.version) : null; + const r = await processArtifact( + spec.label, + spec.name, + artifact, + downloadDir, + outputDir, + makeCallback(index), + sharedTarballName, + ); + const ok /*: ArtifactResultEntry */ = { + name: spec.name, + error: undefined, + ...r, + }; + return ok; + } catch (e) { + progress.update(index, ` ${spec.name}: FAILED - ${e.message}`); + const failed /*: ArtifactResultEntry */ = { + name: spec.name, + error: e.message, + }; + return failed; + } + }), + ); + log(''); + + const succeeded = results.filter(r => r.error == null); + const failed = results.filter(r => r.error != null); + + log('='.repeat(60)); + if (succeeded.length > 0) { + log('Extracted xcframeworks:'); + log(''); + for (const r of succeeded) { + if (r.error == null) { + log(` ${r.name}`); + log(` path: ${displayPath(r.xcframeworkPath)}`); + log(''); + } + } + } + // Abort on ANY failure — every artifact (React, ReactNativeDependencies, + // hermes-engine + the headers companions) is required; proceeding with a + // partial set would only surface as a confusing build error in Xcode. We + // also intentionally do NOT + // write artifacts.json when there are failures: the orchestrator uses its + // presence as the "already present" signal, so a partial write would mask + // the problem and prevent retries. + if (failed.length > 0) { + log('Failed:'); + for (const r of failed) { + warn(` ${r.name}: ${r.error ?? 'unknown error'}`); + } + die( + `Failed to download ${failed.length} of ${results.length} artifact(s): ` + + failed.map(r => r.name).join(', '), + ); + } + + // Write artifacts.json only on full success. + const artifactsJson /*: {[string]: {xcframeworkPath: string, url: string}} */ = + {}; + for (const r of succeeded) { + if (r.error == null) { + artifactsJson[r.name] = {xcframeworkPath: r.xcframeworkPath, url: r.url}; + } + } + // The headers-only companions are staged by their parent's extraction (or + // by the --headers-tarball / --deps-headers-tarball overrides above, which + // already produced entries). A slot without them cannot resolve any + // non-React namespace (ReactNativeHeaders) or the -style deps + // includes (the sidecar; ReactNativeHeaders is pure-RN) — fail closed + // rather than hand Xcode a package graph that breaks with header-not-found + // errors much later. + const companions = [ + { + name: 'ReactNativeHeaders', + parent: 'React', + advice: + 'The React core tarball predates the headers-spec layout — use a ' + + 'matching react-native version, or pass --headers-tarball / ' + + 'RN_HEADERS_TARBALL_PATH.', + }, + { + name: DEPS_HEADERS_NAME, + parent: 'ReactNativeDependencies', + advice: + 'The ReactNativeDependencies tarball predates the headers sidecar — ' + + 'use a matching react-native version, or pass ' + + '--deps-headers-tarball / RN_DEPS_HEADERS_TARBALL_PATH.', + }, + ]; + for (const {name, parent, advice} of companions) { + if (artifactsJson[name] != null) { + continue; + } + const companionPath = path.join(outputDir, `${name}.xcframework`); + if (!fs.existsSync(companionPath)) { + die(`${name}.xcframework is missing from the artifact slot. ${advice}`); + } + const parentEntry = artifactsJson[parent]; + artifactsJson[name] = { + xcframeworkPath: companionPath, + url: parentEntry != null ? parentEntry.url : '', + }; + } + const artifactsJsonPath = path.join(outputDir, 'artifacts.json'); + fs.writeFileSync( + artifactsJsonPath, + JSON.stringify(artifactsJson, null, 2) + '\n', + 'utf8', + ); + log(`Artifact index: ${displayPath(artifactsJsonPath)}`); +} + +// Canonical set of xcframework artifacts an SPM slot must provide. The +// xcodeproj references them as package products; missing any one surfaces as +// "Missing package product" only at Xcode build time. Used by +// `setup-apple-spm.js` to validate the cache before skipping a re-download. +// ReactNativeHeaders (pure-RN) and ReactNativeDependenciesHeaders (the deps +// sidecar) are the two headers-only targets — both required: without the +// sidecar no -style include resolves. +const REQUIRED_ARTIFACTS = [ + 'React', + 'ReactNativeHeaders', + 'ReactNativeDependencies', + 'ReactNativeDependenciesHeaders', + 'hermes-engine', +]; + +/** + * Returns null if `artifacts.json` is present, complete (covers every entry + * in REQUIRED_ARTIFACTS), and each entry's xcframework dir exists on disk. + * Otherwise returns a string describing what's wrong — caller treats that as + * "needs re-download". Catches stale partial-write states from older runs + * that didn't fail loudly on download errors. + */ +function validateArtifactsCache( + artifactsDir /*: string */, +) /*: string | null */ { + const artifactsJsonPath = path.join(artifactsDir, 'artifacts.json'); + if (!fs.existsSync(artifactsJsonPath)) { + return `artifacts.json missing in ${artifactsDir}`; + } + let json: {[string]: {xcframeworkPath: string, url: string}}; + try { + // $FlowFixMe[unclear-type] JSON.parse returns any + const parsed /*: any */ = JSON.parse( + fs.readFileSync(artifactsJsonPath, 'utf8'), + ); + json = parsed; + } catch (e) { + return `artifacts.json is unreadable: ${e.message}`; + } + for (const name of REQUIRED_ARTIFACTS) { + const entry = json[name]; + if (entry == null) { + return `artifacts.json missing entry for "${name}"`; + } + if (!fs.existsSync(entry.xcframeworkPath)) { + return `xcframework for "${name}" not found at ${entry.xcframeworkPath}`; + } + } + // The Hermes public headers must be staged for headers-compose to fold + // `` into ReactNativeHeaders. A slot from older tooling won't + // have them — report incomplete so ensureArtifacts re-runs the download + // (which, with the xcframeworks already present, only backfills the headers + // from the cached tarball — no network re-download). + if (!fs.existsSync(path.join(artifactsDir, 'hermes-headers', 'hermes'))) { + return 'Hermes public headers not staged (hermes-headers/hermes)'; + } + return null; +} + +if (require.main === module) { + main().catch(err => { + console.error(`\x1b[31m${err.message}\x1b[0m`); + process.exitCode = 1; + }); +} + +module.exports = { + main, + resolveCacheSlotVersion, + resolveHermesArtifact, + REQUIRED_ARTIFACTS, + validateArtifactsCache, + // Exposed for unit tests (pure / fetch-stubbable helpers). + rnCoreReleaseUrl, + rnDepsReleaseUrl, + hermesReleaseUrl, + resolveSnapshotUrl, + resolveNightlyVersion, + resolveLatestV1Version, + resolveRNCoreArtifact, + resolveRNDepsArtifact, + exists, + formatBytes, + formatSpeed, + findFirst, + extractXCFramework, +}; diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js new file mode 100644 index 000000000000..0b23b983f0f0 --- /dev/null +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -0,0 +1,216 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {toSwiftName} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +/** + * expand-spm-dependencies.js — Resolves transitive native deps declared via + * `spm.dependencies` in a library's react-native.config.js. + * + * SPM has no equivalent of CocoaPods' podspec `s.dependency`, so library + * authors declare the same relationships explicitly: + * + * // react-native-reanimated/react-native.config.js + * module.exports = { + * dependency: { platforms: { ios: {} } }, + * spm: { dependencies: ['react-native-worklets'] }, + * }; + * + * This module reads the directly-autolinked deps (from autolinking.json), + * follows each one's spm.dependencies recursively, and returns the deduped + * list with autolinking-shaped entries so the downstream pipeline can convert + * each to an SPM target without further branching. + * + * I/O is injected (readConfig, resolveDep) so the logic stays pure and + * testable. + */ + +/*:: +import type {AutolinkedDep} from './spm-types'; + +// react-native.config.js entries have a user-defined shape, so we use an +// inexact object type and access properties dynamically. +type RnConfig = {...}; +type ReadConfig = (root: string) => ?RnConfig; +type ResolveDep = (name: string, fromRoot: string) => ?string; +type Options = { + readConfig: ReadConfig, + resolveDep: ResolveDep, +}; +*/ + +// Validates and returns the Swift target name for a dep. Falls back to +// toSwiftName(npmName) when no override is set. The override is the dep's +// `react-native.config.js` `spm.name`, intended for libraries whose import +// prefix differs from the auto-derived name (e.g. `react-native-worklets` +// publishes headers under `` via the podspec `s.header_dir`, +// so the SPM target name should be `worklets`, not `ReactNativeWorklets`). +function resolveSwiftName( + npmName /*: string */, + config /*: ?RnConfig */, +) /*: string */ { + // $FlowFixMe[prop-missing] config has dynamic shape + const override = config?.spm?.name; + if (override == null) { + return toSwiftName(npmName); + } + if (typeof override !== 'string' || override.length === 0) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, + ); + } + // Accept Swift-identifier style (TitleCase / snake_case) and header-dir + // style (lowercase, optional hyphens). Reject whitespace, slashes, and + // other characters that would break SPM target / module identifiers. + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(override)) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + return override; +} + +function expandSpmDependencies( + directDeps /*: Array */, + options /*: Options */, +) /*: Array */ { + const {readConfig, resolveDep} = options; + const byName /*: Map */ = new Map(); + for (const dep of directDeps) { + byName.set(dep.name, {...dep, spmDependencies: []}); + } + + const queue /*: Array */ = directDeps.map(d => d.name); + while (queue.length > 0) { + const currentName = queue.shift(); + if (typeof currentName !== 'string') { + continue; + } + const current = byName.get(currentName); + if (current == null) { + continue; + } + const config = readConfig(current.root); + // Resolve swiftName lazily from the same config read we already need for + // spm.dependencies — saves a duplicate readConfig call per direct dep. + if (current.swiftName == null) { + current.swiftName = resolveSwiftName(currentName, config); + } + // $FlowFixMe[prop-missing] config has dynamic shape + const transitives /*: Array */ = config?.spm?.dependencies ?? []; + + const currentSpmDeps /*: Array */ = []; + for (const transitiveName of transitives) { + if (!byName.has(transitiveName)) { + const transitiveRoot = resolveDep(transitiveName, current.root); + if (transitiveRoot == null) { + throw new Error( + `react-native autolinking: '${currentName}' declares an unresolvable spm.dependency '${transitiveName}'. Ensure '${transitiveName}' is installed and visible via Node module resolution from ${current.root}.`, + ); + } + + const transitiveConfig = readConfig(transitiveRoot); + // $FlowFixMe[prop-missing] config has dynamic shape + const iosPlatform = transitiveConfig?.dependency?.platforms?.ios; + if (iosPlatform == null) { + // No iOS native code — nothing to autolink and nothing to declare + // as an SPM target dep; mirrors the silent skip in + // autolinkingDepToSpmTarget for android-only deps. + continue; + } + + byName.set(transitiveName, { + name: transitiveName, + root: transitiveRoot, + platforms: {ios: iosPlatform}, + swiftName: resolveSwiftName(transitiveName, transitiveConfig), + spmDependencies: [], + }); + queue.push(transitiveName); + } + currentSpmDeps.push(transitiveName); + } + current.spmDependencies = currentSpmDeps; + } + + // Collision check: two deps mapping to the same Swift name (whether via + // override or auto-derivation) would clobber each other in the synth + // package layout and the centralized headers tree. Surface it now with a + // clear message instead of letting SPM emit a confusing duplicate-target + // error later. + // Key case-INSENSITIVELY: resolveSwiftName permits lowercase ('worklets') + // while toSwiftName produces TitleCase ('Worklets') — an exact-equality check + // passes but the two still collide as directories on the default + // case-insensitive macOS filesystem (synth package layout + headers tree). + const seen /*: Map */ = new Map(); + for (const dep of byName.values()) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + const key = swiftName.toLowerCase(); + const existing = seen.get(key); + if (existing != null) { + const same = existing.swiftName === swiftName; + throw new Error( + `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` + + (same + ? `both resolve to '${swiftName}'.` + : `differ only in case, which collides on case-insensitive filesystems.`) + + ` Set a distinct 'spm.name' in one of their react-native.config.js files.`, + ); + } + seen.set(key, {name: dep.name, swiftName}); + } + + return Array.from(byName.values()); +} + +// --------------------------------------------------------------------------- +// Default I/O implementations +// --------------------------------------------------------------------------- + +function defaultReadConfig(root /*: string */) /*: ?RnConfig */ { + const configPath = path.join(root, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return null; + } + try { + // $FlowFixMe[unsupported-syntax] + return require(configPath); + } catch { + return null; + } +} + +function defaultResolveDep( + name /*: string */, + fromRoot /*: string */, +) /*: ?string */ { + try { + const pkgJsonPath = require.resolve(`${name}/package.json`, { + paths: [fromRoot], + }); + return path.dirname(pkgJsonPath); + } catch { + return null; + } +} + +module.exports = { + expandSpmDependencies, + resolveSwiftName, + defaultReadConfig, + defaultResolveDep, +}; diff --git a/packages/react-native/scripts/spm/flavored-frameworks.js b/packages/react-native/scripts/spm/flavored-frameworks.js new file mode 100644 index 000000000000..d59e3692a541 --- /dev/null +++ b/packages/react-native/scripts/spm/flavored-frameworks.js @@ -0,0 +1,1008 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {execFileSync} = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// Info.plist parsing goes through plutil (as the pre-B1 swap script did): the +// `plist` npm module is NOT a react-native dependency, so it only resolves +// inside this monorepo via hoisting — requiring it breaks `spm add` in a +// fresh consumer app with "Cannot find module 'plist'". +function plistJson(p /*: string */) /*: $FlowFixMe */ { + return JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', p]).toString(), + ); +} + +/*:: import type { + FlavoredFrameworkManifestEntry, + FlavoredFrameworksManifest, + ParsedXcframework, + PluginFlavoredFramework, + XcframeworkSlice, +} from './spm-types'; */ + +/*:: +type ArtifactIndexEntry = {xcframeworkPath: string, url?: string}; +type ArtifactIndex = {[string]: ArtifactIndexEntry}; +type ValidatedFramework = { + id: string, + frameworkName: string, + executableName: string, + linkage: 'dynamic', + slices: Array, +}; +type StagedFramework = { + id: string, + frameworkName: string, + executableName: string, + linkage: 'dynamic', + artifactRelativePath: string, + slices: Array, + sourcePaths: {debug: string, release: string}, +}; +*/ + +const FLAVORS /*: ReadonlyArray<'debug' | 'release'> */ = ['debug', 'release']; + +const FLAVORED_FRAMEWORKS_MANIFEST = 'flavored-frameworks.json'; +const PENDING_ARTIFACT_STAMP = '.artifact-stamp.pending'; +const PLUGIN_FRAMEWORKS_MANIFEST /*: string */ = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-flavored-frameworks.json', +); + +const BUILTIN_FRAMEWORKS = [ + {id: 'react', artifactName: 'React'}, + {id: 'react-native-dependencies', artifactName: 'ReactNativeDependencies'}, + {id: 'hermes', artifactName: 'hermes-engine'}, +]; + +const INVARIANT_BINARY_TARGETS = [ + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', +]; + +function readJson(filePath /*: string */) /*: unknown */ { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function requireString( + value /*: unknown */, + label /*: string */, +) /*: string */ { + if (typeof value !== 'string') { + throw new Error(`${label} must be a string`); + } + return value; +} + +function readArtifactIndex(artifactsDir /*: string */) /*: ArtifactIndex */ { + const indexPath = path.join(artifactsDir, 'artifacts.json'); + if (!fs.existsSync(indexPath)) { + throw new Error(`artifacts.json missing in ${artifactsDir}`); + } + const parsed = readJson(indexPath); + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`artifacts.json in ${artifactsDir} is not an object`); + } + const result /*: ArtifactIndex */ = {}; + for (const name of Object.keys(parsed)) { + const entry = parsed[name]; + if ( + entry == null || + typeof entry !== 'object' || + typeof entry.xcframeworkPath !== 'string' + ) { + throw new Error( + `artifacts.json entry '${name}' in ${artifactsDir} is invalid`, + ); + } + result[name] = { + xcframeworkPath: entry.xcframeworkPath, + ...(typeof entry.url === 'string' ? {url: entry.url} : {}), + }; + } + return result; +} + +function frameworkNameFromLibraryPath(libraryPath /*: string */) /*: string */ { + if (!libraryPath.endsWith('.framework')) { + throw new Error( + `flavored binary '${libraryPath}' is not a dynamic framework; ` + + 'only .framework XCFramework libraries are supported', + ); + } + return path.basename(libraryPath, '.framework'); +} + +function sdkConditionForSlice( + platform /*: string */, + variant /*: ?string */, +) /*: string */ { + if (platform === 'ios' && variant === 'simulator') { + return 'iphonesimulator*'; + } + if (platform === 'ios' && variant === 'maccatalyst') { + return 'macosx*'; + } + if (platform === 'ios') { + return 'iphoneos*'; + } + if (platform === 'tvos' && variant === 'simulator') { + return 'appletvsimulator*'; + } + if (platform === 'tvos') { + return 'appletvos*'; + } + if (platform === 'xros' && variant === 'simulator') { + return 'xrsimulator*'; + } + if (platform === 'xros') { + return 'xros*'; + } + if (platform === 'macos') { + return 'macosx*'; + } + throw new Error( + `unsupported XCFramework platform '${platform}'` + + (variant != null ? ` variant '${variant}'` : ''), + ); +} + +function parseXcframework( + xcframeworkPath /*: string */, +) /*: ParsedXcframework */ { + const infoPath = path.join(xcframeworkPath, 'Info.plist'); + if (!fs.existsSync(infoPath)) { + throw new Error(`XCFramework Info.plist missing at ${infoPath}`); + } + const info = plistJson(infoPath); + const libraries = info.AvailableLibraries; + if (!Array.isArray(libraries) || libraries.length === 0) { + throw new Error( + `XCFramework has no AvailableLibraries: ${xcframeworkPath}`, + ); + } + let frameworkName = null; + let executableName = null; + const slices = libraries.map(library => { + const identifier = library.LibraryIdentifier; + const libraryPath = library.LibraryPath; + const platform = library.SupportedPlatform; + const variant = library.SupportedPlatformVariant ?? null; + const architectures = library.SupportedArchitectures; + if ( + typeof identifier !== 'string' || + typeof libraryPath !== 'string' || + typeof platform !== 'string' || + !Array.isArray(architectures) || + !architectures.every(a => typeof a === 'string') + ) { + throw new Error( + `XCFramework contains an invalid library entry: ${xcframeworkPath}`, + ); + } + const sliceFrameworkName = frameworkNameFromLibraryPath(libraryPath); + const binaryPath = + typeof library.BinaryPath === 'string' + ? library.BinaryPath + : path.join(libraryPath, sliceFrameworkName); + const sliceExecutableName = path.basename(binaryPath); + if (frameworkName == null) { + frameworkName = sliceFrameworkName; + executableName = sliceExecutableName; + } else if ( + frameworkName !== sliceFrameworkName || + executableName !== sliceExecutableName + ) { + throw new Error( + `XCFramework changes framework or executable name between slices: ${xcframeworkPath}`, + ); + } + return { + sdk: sdkConditionForSlice(platform, variant), + platform, + variant, + architectures: [...architectures].sort(), + libraryIdentifier: identifier, + libraryPath, + binaryPath, + }; + }); + if (frameworkName == null || executableName == null) { + throw new Error( + `could not identify framework binary in ${xcframeworkPath}`, + ); + } + slices.sort((a, b) => + `${a.sdk}:${a.libraryIdentifier}`.localeCompare( + `${b.sdk}:${b.libraryIdentifier}`, + ), + ); + return { + path: xcframeworkPath, + frameworkName, + executableName, + slices, + }; +} + +function sliceShape(parsed /*: ParsedXcframework */) /*: string */ { + return JSON.stringify(parsed.slices); +} + +function walkFiles(root /*: string */) /*: Array */ { + const files = []; + const visit = (dir /*: string */) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, String(entry.name)); + if (entry.isDirectory()) { + visit(full); + } else if (entry.isSymbolicLink()) { + const stat = fs.statSync(full); + if (stat.isDirectory()) { + visit(full); + } else { + files.push(full); + } + } else if (entry.isFile()) { + files.push(full); + } + } + }; + if (fs.existsSync(root) && fs.statSync(root).isFile()) { + files.push(root); + } else if (fs.existsSync(root)) { + visit(root); + } + return files.sort(); +} + +function hashTrees(roots /*: Array */) /*: string */ { + const hash = crypto.createHash('sha256'); + for (const root of roots) { + const realRoot = fs.realpathSync(root); + for (const file of walkFiles(realRoot)) { + hash.update(path.relative(realRoot, file)); + hash.update('\0'); + hash.update(fs.readFileSync(file)); + hash.update('\0'); + } + } + return hash.digest('hex'); +} + +function frameworkHeaderHashes( + parsed /*: ParsedXcframework */, +) /*: Array */ { + return parsed.slices.map(slice => { + const framework = path.join( + parsed.path, + slice.libraryIdentifier, + slice.libraryPath, + ); + const roots = []; + const headers = path.join(framework, 'Headers'); + const moduleMap = path.join(framework, 'Modules', 'module.modulemap'); + if (fs.existsSync(headers)) { + roots.push(headers); + } + if (fs.existsSync(moduleMap)) { + roots.push(moduleMap); + } + return roots.length === 0 ? '' : hashTrees(roots); + }); +} + +function invariantHeadersHashes( + xcframeworkPath /*: string */, +) /*: Array */ { + const info = plistJson(path.join(xcframeworkPath, 'Info.plist')); + if (!Array.isArray(info.AvailableLibraries)) { + throw new Error(`XCFramework has no libraries: ${xcframeworkPath}`); + } + return info.AvailableLibraries.map(library => { + const identifier = library.LibraryIdentifier; + const headersPath = library.HeadersPath; + if (typeof identifier !== 'string' || typeof headersPath !== 'string') { + throw new Error( + `headers XCFramework has an invalid slice: ${xcframeworkPath}`, + ); + } + return hashTrees([path.join(xcframeworkPath, identifier, headersPath)]); + }); +} + +function assertOneHash( + label /*: string */, + hashes /*: Array */, +) /*: string */ { + const distinct = Array.from(new Set(hashes)); + if (distinct.length !== 1) { + throw new Error(`${label} headers differ between XCFramework slices`); + } + return distinct[0]; +} + +function assertDynamicFramework(parsed /*: ParsedXcframework */) /*: void */ { + for (const slice of parsed.slices) { + const binary = path.join( + parsed.path, + slice.libraryIdentifier, + slice.binaryPath, + ); + if (!fs.existsSync(binary)) { + throw new Error( + `${parsed.frameworkName} binary is missing from slice ${slice.libraryIdentifier}: ${binary}`, + ); + } + let output; + try { + output = execFileSync('otool', ['-l', binary], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { + throw new Error(`could not inspect ${binary}: ${e.message}`); + } + if (!output.includes('LC_ID_DYLIB')) { + throw new Error( + `${parsed.frameworkName} is not a dynamic framework: ${binary}`, + ); + } + const frameworkRoot = path.join( + parsed.path, + slice.libraryIdentifier, + slice.libraryPath, + ); + const nested = walkFiles(frameworkRoot).find(file => + file.includes(`${path.sep}Frameworks${path.sep}`), + ); + if (nested != null) { + throw new Error( + `${parsed.frameworkName} contains nested code that RN cannot manage: ${nested}`, + ); + } + } +} + +function validatePair( + id /*: string */, + debugPath /*: string */, + releasePath /*: string */, + declaredFrameworkName /*: ?string */, + validateBinaries /*: boolean */, +) /*: ValidatedFramework */ { + const debug = parseXcframework(debugPath); + const release = parseXcframework(releasePath); + if ( + debug.frameworkName !== release.frameworkName || + debug.executableName !== release.executableName || + sliceShape(debug) !== sliceShape(release) + ) { + throw new Error( + `flavored framework '${id}' has incompatible Debug and Release XCFrameworks`, + ); + } + if ( + declaredFrameworkName != null && + declaredFrameworkName !== debug.frameworkName + ) { + throw new Error( + `flavored framework '${id}' declared '${declaredFrameworkName}' but contains '${debug.frameworkName}'`, + ); + } + // Headers and Swift module interfaces may legitimately differ between SDK + // slices (for example device vs simulator generated `-Swift.h` files and + // target-specific `.swiftinterface` names). `sliceShape` above already + // guarantees a one-to-one slice mapping, so compare each Debug slice with + // its matching Release slice instead of requiring every slice within one + // XCFramework to be byte-identical. + const debugHeaders = frameworkHeaderHashes(debug); + const releaseHeaders = frameworkHeaderHashes(release); + if (JSON.stringify(debugHeaders) !== JSON.stringify(releaseHeaders)) { + throw new Error( + `flavored framework '${id}' exposes different Debug and Release headers`, + ); + } + if (validateBinaries) { + assertDynamicFramework(debug); + assertDynamicFramework(release); + } + return { + id, + frameworkName: debug.frameworkName, + executableName: debug.executableName, + linkage: 'dynamic', + slices: debug.slices, + }; +} + +function transformReactModuleMap(contents /*: string */) /*: string */ { + if (!/framework\s+module\s+React\b/.test(contents)) { + throw new Error('React module map does not declare framework module React'); + } + return contents + .replace(/framework\s+module\s+React\b/, 'module React') + .replace( + /(umbrella header|textual header|private header|header)\s+"([^"]+)"/g, + (match, kind, header) => + `${kind} "${header.startsWith('React/') ? header : `React/${header}`}"`, + ); +} + +function replaceDirectoryAtomically( + destination /*: string */, + populate /*: (temp: string) => void */, +) /*: void */ { + const temp = `${destination}.tmp-${process.pid}`; + fs.rmSync(temp, {recursive: true, force: true}); + fs.mkdirSync(temp, {recursive: true}); + populate(temp); + fs.rmSync(destination, {recursive: true, force: true}); + fs.renameSync(temp, destination); +} + +function stageReactHeadersTarget( + packageRoot /*: string */, + canonicalReact /*: ParsedXcframework */, +) /*: void */ { + const slice = canonicalReact.slices[0]; + const framework = path.join( + canonicalReact.path, + slice.libraryIdentifier, + slice.libraryPath, + ); + const sourceHeaders = path.join(framework, 'Headers'); + const sourceModuleMap = path.join(framework, 'Modules', 'module.modulemap'); + if (!fs.existsSync(sourceHeaders) || !fs.existsSync(sourceModuleMap)) { + throw new Error( + `React headers or module map missing from canonical framework ${framework}`, + ); + } + const destination = path.join(packageRoot, 'ReactHeadersTarget'); + const transformed = transformReactModuleMap( + fs.readFileSync(sourceModuleMap, 'utf8'), + ); + const expectedHash = crypto + .createHash('sha256') + .update(hashTrees([sourceHeaders])) + .update(transformed) + .digest('hex'); + const marker = path.join(destination, '.headers-hash'); + try { + if (fs.readFileSync(marker, 'utf8') === `${expectedHash}\n`) { + return; + } + } catch {} + replaceDirectoryAtomically(destination, temp => { + const include = path.join(temp, 'include'); + fs.mkdirSync(include, {recursive: true}); + fs.cpSync(sourceHeaders, path.join(include, 'React'), { + recursive: true, + dereference: true, + }); + fs.writeFileSync( + path.join(include, 'module.modulemap'), + transformed, + 'utf8', + ); + fs.writeFileSync( + path.join(temp, 'empty.c'), + 'void RNReactHeadersAnchor(void) {}\n', + 'utf8', + ); + fs.writeFileSync(path.join(temp, '.headers-hash'), `${expectedHash}\n`); + }); +} + +function ensureSymlink(target /*: string */, link /*: string */) /*: void */ { + try { + if (fs.lstatSync(link).isSymbolicLink()) { + const current = path.resolve(path.dirname(link), fs.readlinkSync(link)); + if (current === path.resolve(target)) { + return; + } + } + } catch {} + const temp = `${link}.tmp-${process.pid}`; + fs.rmSync(temp, {recursive: true, force: true}); + fs.mkdirSync(path.dirname(link), {recursive: true}); + fs.symlinkSync(target, temp); + fs.rmSync(link, {recursive: true, force: true}); + fs.renameSync(temp, link); +} + +function symlinkPointsTo( + target /*: string */, + link /*: string */, +) /*: boolean */ { + try { + return ( + fs.lstatSync(link).isSymbolicLink() && + path.resolve(path.dirname(link), fs.readlinkSync(link)) === + path.resolve(target) + ); + } catch { + return false; + } +} + +function sourceIdentity(framework /*: StagedFramework */) /*: { + debug: {path: string, info: number, binaries: Array<{path: string, size: number, mtime: number}>}, + release: {path: string, info: number, binaries: Array<{path: string, size: number, mtime: number}>}, +} */ { + const identity /*: {[string]: {path: string, info: number, binaries: Array<{path: string, size: number, mtime: number}>}} */ = + {}; + for (const flavor of FLAVORS) { + const xcframeworkPath = framework.sourcePaths[flavor]; + identity[flavor] = { + path: fs.realpathSync(xcframeworkPath), + info: fs.statSync(path.join(xcframeworkPath, 'Info.plist')).mtimeMs, + binaries: framework.slices.map(slice => { + const binary = path.join( + xcframeworkPath, + slice.libraryIdentifier, + slice.binaryPath, + ); + const stat = fs.statSync(binary); + return {path: slice.binaryPath, size: stat.size, mtime: stat.mtimeMs}; + }), + }; + } + return { + debug: identity.debug, + release: identity.release, + }; +} + +function publicationIsCurrent( + packageRoot /*: string */, + sources /*: ReadonlyArray */, + debugIndex /*: ArtifactIndex */, + stamp /*: string */, + manifestContents /*: string */, +) /*: boolean */ { + try { + const pendingStamp = path.join(packageRoot, PENDING_ARTIFACT_STAMP); + const currentStamp = fs.existsSync(pendingStamp) + ? pendingStamp + : path.join(packageRoot, '.artifact-stamp'); + if ( + fs.readFileSync(currentStamp, 'utf8') !== stamp || + fs.readFileSync( + path.join(packageRoot, FLAVORED_FRAMEWORKS_MANIFEST), + 'utf8', + ) !== manifestContents || + !fs.existsSync( + path.join(packageRoot, 'ReactHeadersTarget', '.headers-hash'), + ) + ) { + return false; + } + for (const flavor of FLAVORS) { + for (const framework of sources) { + if ( + !symlinkPointsTo( + framework.sourcePaths[flavor], + path.join(packageRoot, flavor, framework.artifactRelativePath), + ) + ) { + return false; + } + } + } + return INVARIANT_BINARY_TARGETS.every(name => + symlinkPointsTo( + debugIndex[name].xcframeworkPath, + path.join(packageRoot, `${name}.xcframework`), + ), + ); + } catch { + return false; + } +} + +function publishDirectoryAtomically( + staged /*: string */, + destination /*: string */, +) /*: void */ { + const previous = `${destination}.previous-${process.pid}`; + fs.rmSync(previous, {recursive: true, force: true}); + const hadPrevious = fs.existsSync(destination); + if (hadPrevious) { + fs.renameSync(destination, previous); + } + try { + fs.renameSync(staged, destination); + } catch (error) { + if (hadPrevious && !fs.existsSync(destination)) { + fs.renameSync(previous, destination); + } + throw error; + } + fs.rmSync(previous, {recursive: true, force: true}); +} + +function writeIfChanged(filePath /*: string */, contents /*: string */) { + try { + if (fs.readFileSync(filePath, 'utf8') === contents) { + return false; + } + } catch {} + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, contents, 'utf8'); + return true; +} + +function readPluginFrameworks( + appRoot /*: string */, +) /*: Array */ { + const manifestPath = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + if (!fs.existsSync(manifestPath)) { + return []; + } + const parsed = readJson(manifestPath); + if (!Array.isArray(parsed)) { + throw new Error(`${PLUGIN_FRAMEWORKS_MANIFEST} must contain an array`); + } + const frameworks /*: Array */ = []; + for (const plugin of parsed) { + if ( + plugin == null || + typeof plugin !== 'object' || + typeof plugin.id !== 'string' || + !/^[A-Za-z0-9_.-]+$/.test(plugin.id) || + typeof plugin.frameworkName !== 'string' || + plugin.linkage !== 'dynamic' || + plugin.flavors == null || + typeof plugin.flavors !== 'object' || + typeof plugin.flavors.debug !== 'string' || + typeof plugin.flavors.release !== 'string' + ) { + throw new Error( + 'plugin flavoredFrameworks entries require a stable id, frameworkName, ' + + 'linkage="dynamic", and absolute debug/release paths', + ); + } + frameworks.push({ + id: plugin.id, + frameworkName: plugin.frameworkName, + linkage: 'dynamic', + flavors: { + debug: plugin.flavors.debug, + release: plugin.flavors.release, + }, + }); + } + return frameworks; +} + +function prepareFlavoredFrameworks( + opts /*: { + appRoot: string, + debugArtifactsDir: string, + releaseArtifactsDir: string, + validateBinaries?: boolean, + } */, +) /*: FlavoredFrameworksManifest */ { + const appRoot = path.resolve(opts.appRoot); + const packageRoot = path.join(appRoot, 'build', 'xcframeworks'); + const debugIndex = readArtifactIndex(path.resolve(opts.debugArtifactsDir)); + const releaseIndex = readArtifactIndex( + path.resolve(opts.releaseArtifactsDir), + ); + const validateBinaries = opts.validateBinaries !== false; + const sources /*: Array */ = []; + const destinations /*: Set */ = new Set(); + const frameworkIds /*: Set */ = new Set(); + + for (const builtin of BUILTIN_FRAMEWORKS) { + const debugEntry = debugIndex[builtin.artifactName]; + const releaseEntry = releaseIndex[builtin.artifactName]; + if (debugEntry == null || releaseEntry == null) { + throw new Error( + `both flavors must contain '${builtin.artifactName}' in artifacts.json`, + ); + } + const normalized = validatePair( + builtin.id, + path.resolve(debugEntry.xcframeworkPath), + path.resolve(releaseEntry.xcframeworkPath), + null, + validateBinaries, + ); + const artifactRelativePath = `${builtin.artifactName}.xcframework`; + if (destinations.has(normalized.frameworkName)) { + throw new Error( + `multiple flavored frameworks embed as ${normalized.frameworkName}.framework`, + ); + } + frameworkIds.add(normalized.id); + destinations.add(normalized.frameworkName); + sources.push({ + id: normalized.id, + frameworkName: normalized.frameworkName, + executableName: normalized.executableName, + linkage: 'dynamic', + slices: normalized.slices, + artifactRelativePath, + sourcePaths: { + debug: path.resolve(debugEntry.xcframeworkPath), + release: path.resolve(releaseEntry.xcframeworkPath), + }, + }); + } + + for (const plugin of readPluginFrameworks(appRoot)) { + if ( + plugin == null || + typeof plugin !== 'object' || + typeof plugin.id !== 'string' || + !/^[A-Za-z0-9_.-]+$/.test(plugin.id) || + typeof plugin.frameworkName !== 'string' || + plugin.linkage !== 'dynamic' || + plugin.flavors == null || + typeof plugin.flavors.debug !== 'string' || + typeof plugin.flavors.release !== 'string' || + !path.isAbsolute(plugin.flavors.debug) || + !path.isAbsolute(plugin.flavors.release) + ) { + throw new Error( + 'plugin flavoredFrameworks entries require a stable id, frameworkName, ' + + 'linkage="dynamic", and absolute debug/release paths', + ); + } + const normalized = validatePair( + plugin.id, + plugin.flavors.debug, + plugin.flavors.release, + plugin.frameworkName, + validateBinaries, + ); + if (frameworkIds.has(normalized.id)) { + throw new Error(`duplicate flavored framework id '${normalized.id}'`); + } + if (destinations.has(normalized.frameworkName)) { + throw new Error( + `multiple flavored frameworks embed as ${normalized.frameworkName}.framework`, + ); + } + frameworkIds.add(normalized.id); + destinations.add(normalized.frameworkName); + sources.push({ + id: normalized.id, + frameworkName: normalized.frameworkName, + executableName: normalized.executableName, + linkage: 'dynamic', + slices: normalized.slices, + artifactRelativePath: `plugins/${plugin.id}.xcframework`, + sourcePaths: { + debug: plugin.flavors.debug, + release: plugin.flavors.release, + }, + }); + } + + for (const name of INVARIANT_BINARY_TARGETS) { + const debugEntry = debugIndex[name]; + const releaseEntry = releaseIndex[name]; + if (debugEntry == null || releaseEntry == null) { + throw new Error(`both flavors must contain invariant target '${name}'`); + } + const debugHash = assertOneHash( + `${name} Debug`, + invariantHeadersHashes(debugEntry.xcframeworkPath), + ); + const releaseHash = assertOneHash( + `${name} Release`, + invariantHeadersHashes(releaseEntry.xcframeworkPath), + ); + if (debugHash !== releaseHash) { + throw new Error(`${name} headers differ between Debug and Release`); + } + } + + const canonicalReact = parseXcframework(debugIndex.React.xcframeworkPath); + const manifest /*: FlavoredFrameworksManifest */ = { + version: 1, + frameworks: sources.map(({sourcePaths, ...framework}) => framework), + }; + const contents = `${JSON.stringify(manifest, null, 2)}\n`; + const stampMaterial = JSON.stringify( + sources.map(framework => ({ + id: framework.id, + sourceIdentity: sourceIdentity(framework), + })), + ); + const stamp = `${crypto + .createHash('sha256') + .update(contents) + .update(stampMaterial) + .digest('hex')}\n`; + if (publicationIsCurrent(packageRoot, sources, debugIndex, stamp, contents)) { + return manifest; + } + + const staged = `${packageRoot}.publish-${process.pid}`; + fs.rmSync(staged, {recursive: true, force: true}); + fs.mkdirSync(staged, {recursive: true}); + try { + for (const flavor of FLAVORS) { + for (const framework of sources) { + ensureSymlink( + framework.sourcePaths[flavor], + path.join(staged, flavor, framework.artifactRelativePath), + ); + } + } + for (const name of INVARIANT_BINARY_TARGETS) { + ensureSymlink( + debugIndex[name].xcframeworkPath, + path.join(staged, `${name}.xcframework`), + ); + } + stageReactHeadersTarget(staged, canonicalReact); + writeIfChanged(path.join(staged, FLAVORED_FRAMEWORKS_MANIFEST), contents); + writeIfChanged(path.join(staged, PENDING_ARTIFACT_STAMP), stamp); + const existingPackage = path.join(packageRoot, 'Package.swift'); + if (fs.existsSync(existingPackage)) { + fs.copyFileSync(existingPackage, path.join(staged, 'Package.swift')); + } + const existingStamp = path.join(packageRoot, '.artifact-stamp'); + if (fs.existsSync(existingStamp)) { + fs.copyFileSync(existingStamp, path.join(staged, '.artifact-stamp')); + } + fs.mkdirSync(path.dirname(packageRoot), {recursive: true}); + publishDirectoryAtomically(staged, packageRoot); + } finally { + fs.rmSync(staged, {recursive: true, force: true}); + } + return manifest; +} + +function finalizeArtifactPublication(appRoot /*: string */) /*: boolean */ { + const packageRoot = path.join(path.resolve(appRoot), 'build', 'xcframeworks'); + const pending = path.join(packageRoot, PENDING_ARTIFACT_STAMP); + if (!fs.existsSync(pending)) { + return false; + } + const stamp = fs.readFileSync(pending, 'utf8'); + writeIfChanged(path.join(packageRoot, '.artifact-stamp'), stamp); + fs.rmSync(pending, {force: true}); + return true; +} + +function readFlavoredFrameworksManifest( + appRoot /*: string */, +) /*: FlavoredFrameworksManifest */ { + const manifestPath = path.join( + appRoot, + 'build', + 'xcframeworks', + FLAVORED_FRAMEWORKS_MANIFEST, + ); + if (!fs.existsSync(manifestPath)) { + throw new Error( + `${FLAVORED_FRAMEWORKS_MANIFEST} is missing; run npx react-native spm update`, + ); + } + const parsed = readJson(manifestPath); + if ( + parsed == null || + typeof parsed !== 'object' || + parsed.version !== 1 || + !Array.isArray(parsed.frameworks) + ) { + throw new Error(`${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid format`); + } + const frameworks /*: Array */ = []; + for (const framework of parsed.frameworks) { + if ( + framework == null || + typeof framework !== 'object' || + typeof framework.id !== 'string' || + typeof framework.frameworkName !== 'string' || + typeof framework.executableName !== 'string' || + framework.linkage !== 'dynamic' || + typeof framework.artifactRelativePath !== 'string' || + !Array.isArray(framework.slices) + ) { + throw new Error(`${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid entry`); + } + const id = framework.id; + const frameworkName = framework.frameworkName; + const executableName = framework.executableName; + const artifactRelativePath = framework.artifactRelativePath; + const rawSlices = framework.slices; + const slices /*: Array */ = []; + for (const slice of rawSlices) { + if ( + slice == null || + typeof slice !== 'object' || + typeof slice.sdk !== 'string' || + typeof slice.platform !== 'string' || + (slice.variant != null && typeof slice.variant !== 'string') || + !Array.isArray(slice.architectures) || + typeof slice.libraryIdentifier !== 'string' || + typeof slice.libraryPath !== 'string' || + typeof slice.binaryPath !== 'string' + ) { + throw new Error( + `${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid XCFramework slice`, + ); + } + const architectures /*: Array */ = []; + for (const architecture of slice.architectures) { + if (typeof architecture !== 'string') { + throw new Error( + `${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid XCFramework architecture`, + ); + } + architectures.push(architecture); + } + const sdk = requireString(slice.sdk, 'XCFramework SDK'); + const platform = requireString(slice.platform, 'XCFramework platform'); + const variant = typeof slice.variant === 'string' ? slice.variant : null; + const libraryIdentifier = requireString( + slice.libraryIdentifier, + 'XCFramework library identifier', + ); + const libraryPath = requireString( + slice.libraryPath, + 'XCFramework library path', + ); + const binaryPath = requireString( + slice.binaryPath, + 'XCFramework binary path', + ); + slices.push({ + sdk, + platform, + variant, + architectures, + libraryIdentifier, + libraryPath, + binaryPath, + }); + } + frameworks.push({ + id, + frameworkName, + executableName, + linkage: 'dynamic', + artifactRelativePath, + slices, + }); + } + return {version: 1, frameworks}; +} + +module.exports = { + BUILTIN_FRAMEWORKS, + FLAVORED_FRAMEWORKS_MANIFEST, + PENDING_ARTIFACT_STAMP, + INVARIANT_BINARY_TARGETS, + PLUGIN_FRAMEWORKS_MANIFEST, + parseXcframework, + finalizeArtifactPublication, + prepareFlavoredFrameworks, + readFlavoredFrameworksManifest, + sdkConditionForSlice, + transformReactModuleMap, + validatePair, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js new file mode 100644 index 000000000000..58ebb96b2f92 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -0,0 +1,161 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * generate-spm-autolinking-config.js — JS port of the autolinking.json + * generation step in packages/react-native/scripts/cocoapods/autolinking.rb. + * + * Invokes the React Native community CLI to produce its config and writes the + * raw JSON to /build/generated/autolinking/autolinking.json. + * + * No filtering or reshaping happens here — the downstream consumer + * (generate-spm-autolinking.js) does its own iOS-only filtering when reading + * the file. + * + * Removes the implicit `pod install` dependency the SPM flow has today for + * external dep discovery. + */ + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: +import type {CliConfigJson} from './spm-types'; + +type CliRunnerResult = { + stdout: string, + stderr: string, + exitCode: number, +}; +type CliRunner = ( + command: Array, + opts: {cwd: string}, +) => CliRunnerResult; +type Options = { + projectRoot: string, + configCommand?: Array, + cliRunner?: CliRunner, +}; +type GenerateAutolinkingConfigResult = { + config: CliConfigJson, + outputPath: string, + rawJson: string, +}; +*/ + +const FALLBACK_CONFIG_COMMAND = [ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', +]; + +function resolveDefaultConfigCommand( + projectRoot /*: string */, +) /*: Array */ { + try { + const pkgJsonPath = require.resolve( + '@react-native-community/cli/package.json', + {paths: [projectRoot]}, + ); + // $FlowFixMe[unclear-type] package.json has dynamic shape + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const bin = pkgJson.bin; + const binPath = + typeof bin === 'string' + ? bin + : typeof bin?.['rnc-cli'] === 'string' + ? bin['rnc-cli'] + : bin != null && typeof bin === 'object' + ? bin[Object.keys(bin)[0]] + : null; + + if (typeof binPath === 'string' && binPath.length > 0) { + return [ + process.execPath, + path.join(path.dirname(pkgJsonPath), binPath), + 'config', + ]; + } + } catch { + // Fall through to a no-install npx invocation for older layouts. + } + + return FALLBACK_CONFIG_COMMAND; +} + +function defaultCliRunner( + command /*: Array */, + opts /*: {cwd: string} */, +) /*: CliRunnerResult */ { + const result = spawnSync(command[0], command.slice(1), { + cwd: opts.cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + }); + return { + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + exitCode: typeof result.status === 'number' ? result.status : 1, + }; +} + +function generateAutolinkingConfig( + opts /*: Options */, +) /*: GenerateAutolinkingConfigResult */ { + const { + projectRoot, + configCommand = resolveDefaultConfigCommand(projectRoot), + cliRunner = defaultCliRunner, + } = opts; + + if (!Array.isArray(configCommand) || configCommand.length === 0) { + throw new Error( + 'generate-spm-autolinking-config: config command must be a non-empty array of strings', + ); + } + + const result = cliRunner(configCommand, {cwd: projectRoot}); + + if (result.exitCode !== 0) { + throw new Error( + `generate-spm-autolinking-config: '${configCommand.join(' ')}' exited with status ${result.exitCode}\n${result.stderr}`, + ); + } + + const rawJson = result.stdout; + const config /*: CliConfigJson */ = JSON.parse(rawJson); + + const iosSourceDir = config?.project?.ios?.sourceDir; + if (typeof iosSourceDir !== 'string' || iosSourceDir.length === 0) { + throw new Error( + 'generate-spm-autolinking-config: CLI config did not provide project.ios.sourceDir', + ); + } + + const outPath = path.join( + iosSourceDir, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ); + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, rawJson); + + return {config, outputPath: outPath, rawJson}; +} + +module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js new file mode 100644 index 000000000000..ce47509cb3c8 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -0,0 +1,1888 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type { + AggregatorInput, + AutolinkedDep, + AutolinkingArgs, + DiscoveredPlugin, + NpmDepRef, + PluginFlavoredFramework, + PluginPackageDep, + PluginProductDep, + ReactDescriptor, + RawAutolinkingJson, + SpmModuleConfig, + SpmTarget, + SynthPackageSpec, + TargetEntry, +} from './spm-types'; */ + +/** + * generate-spm-autolinking.js – Generates autolinked/Package.swift, the SPM + * equivalent of CocoaPods' `use_native_modules!`. + * + * Usage: + * node generate-spm-autolinking.js [options] + * + * Options: + * --app-root Path to the app directory (default: cwd) + * --react-native-root Path to react-native package root + * --autolinking-json Path to autolinking.json (default: build/generated/autolinking/autolinking.json) + * --output Output dir (default: autolinked/) + * + * Reads: + * - build/generated/autolinking/autolinking.json (produced by react-native codegen) + * - react-native.config.js (for spm.modules extra modules, optional) + * + * Generates: + * - autolinked/Package.swift + * + * V1 behavior: + * - Processes npm-package native modules with platforms.ios != null from autolinking.json + * - Also processes any `spm.modules` entries from react-native.config.js for local modules + * - Targets resolve React headers via SPM product dependencies (no flags) + * + * V2 behavior (future): + * - npm packages with their own Package.swift use .package(url: ...) instead of inline targets + */ + +const {discoverPlugins, invokePlugins} = require('./autolinking-plugins'); +const { + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, +} = require('./expand-spm-dependencies'); +const {readPodspec} = require('./read-podspec'); +const { + RemoteVersionError, + findProjectRoot, + makeLogger, + remotePackageConfig, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log, warn} = makeLogger('generate-spm-autolinking'); + +// Targets compiling against React get all headers via SPM product +// dependencies — no search-path flags: the React module from the invariant +// ReactHeaders Clang target, every other RN namespace from ReactNativeHeaders +// binaryTarget (pure-RN), the third-party deps namespaces (folly/glog/ +// boost/...) from the ReactNativeDependenciesHeaders sidecar, and the app's +// generated headers from the ReactAppHeaders target in the codegen package. +// +// Remote mode (remotePackageConfig): the ReactNative-family products come +// from the single remote package identity instead of the local path-based +// package, so app + every library unify on one SPM-resolved version. +let remoteCfg /*: ?{url: string, version: string, identity: string} */ = null; + +function reactNativePackageLabel() /*: string */ { + return remoteCfg != null ? remoteCfg.identity : 'ReactNative'; +} +function reactNativePackageDecl(localDecl /*: string */) /*: string */ { + return remoteCfg != null + ? `.package(url: "${remoteCfg.url}", exact: "${remoteCfg.version}")` + : localDecl; +} +// The React product set — single source of truth shared by reactProductDeps() +// (the emitted string) and the plugin ReactDescriptor. The invariant ReactNative +// products come from the RN package (identity varies local vs remote); +// ReactAppHeaders is in the separate, per-app React-GeneratedCode package +// (easy to miss when hand-rolling — hence exposing it to plugins). +function reactProducts() /*: Array<{name: string, package: string}> */ { + const rn = reactNativePackageLabel(); + return [ + {name: 'ReactHeaders', package: rn}, + {name: 'ReactNativeHeaders', package: rn}, + {name: 'ReactNativeDependenciesHeaders', package: rn}, + {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, + ]; +} +function reactProductDeps() /*: string */ { + return reactProducts() + .map(p => `.product(name: "${p.name}", package: "${p.package}")`) + .join(', '); +} + +// Structured React descriptor handed to autolinking plugins: how to depend on +// React through one source of truth, so a plugin's own Package.swift doesn't +// re-derive RN's package path / identity / product names. +// +// `packageRef` is local or remote (distinguished by which keys are present): +// - local: {name, path (ABSOLUTE), relPath (relative to outputDir)} +// - remote: {name, url, version} +// The canonical `path` is ABSOLUTE because a plugin may write its manifests in +// arbitrary subdirs of outputDir, for which an outputDir-relative path would be +// wrong; the generated manifests are gitignored + regenerated every sync, so an +// absolute path carries no portability cost. `relPath` is a convenience for the +// common case. `products` derives from the SAME reactProducts() RN wires into +// its own autolinked targets (parity: a plugin's target gets exactly RN's React +// surface), filtered to only products whose package is resolvable this run — +// the invariant is that any listed product can be depended on without guarding. +// Returns null when there is no resolvable React dependency. +function reactDescriptor( + absXcframeworks /*: ?string */, + xcframeworksRelPath /*: ?string */, + codegenPackageExists /*: boolean */, +) /*: ?ReactDescriptor */ { + let packageRef; + if (remoteCfg != null) { + packageRef = { + name: remoteCfg.identity, + url: remoteCfg.url, + version: remoteCfg.version, + }; + } else if (absXcframeworks != null) { + packageRef = { + name: 'ReactNative', + path: toPosix(absXcframeworks), + relPath: + xcframeworksRelPath != null ? toPosix(xcframeworksRelPath) : undefined, + }; + } else { + return null; + } + const products = reactProducts().filter( + p => p.package !== 'React-GeneratedCode' || codegenPackageExists, + ); + return {packageRef, products}; +} + +// Normalize a (possibly Windows) path to posix separators for embedding in +// a Package.swift `.package(path:)` literal — SPM expects forward slashes. +function toPosix(p /*: string */) /*: string */ { + return p.split(path.sep).join('/'); +} + +function parseArgs(argv /*: Array */) /*: AutolinkingArgs */ { + const parsed = yargs(argv) + .option('app-root', { + type: 'string', + default: process.cwd(), + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + describe: 'Path to react-native package root', + }) + .option('autolinking-json', { + type: 'string', + describe: + 'Path to autolinking.json (default: build/generated/autolinking/autolinking.json)', + }) + .option('output', { + type: 'string', + describe: 'Output dir (default: autolinked/)', + }) + .option('xcframeworks-path', { + type: 'string', + describe: + 'Path to the xcframeworks sub-package (absolute or relative to appRoot)', + }) + .usage( + 'Usage: $0 [options]\n\nGenerates autolinked/Package.swift for SPM autolinking.', + ) + .help() + .parseSync(); + + return { + appRoot: parsed['app-root'], + reactNativeRoot: parsed['react-native-root'] ?? null, + autolinkingJson: parsed['autolinking-json'] ?? null, + output: parsed.output ?? null, + xcframeworksPath: parsed['xcframeworks-path'] ?? null, + }; +} + +/** + * Reads autolinking.json and returns dependencies with iOS platform support. + */ +function readAutolinkingJson( + filePath /*: string */, +) /*: RawAutolinkingJson | null */ { + if (!fs.existsSync(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * Attempts to read react-native.config.js to find spm.modules entries. + * These are extra modules not discoverable via autolinking.json. + * + * Expected structure in react-native.config.js: + * module.exports = { + * ... + * spm: { + * modules: [ + * { + * name: "MyNativeModule", + * path: "ios/MyNativeModule", // relative to appRoot + * exclude: ["*.js", "*.podspec"], // optional + * publicHeadersPath: ".", // optional + * } + * ] + * } + * } + */ +function readSpmModulesFromConfig( + appRoot /*: string */, +) /*: Array */ { + const configPath = path.join(appRoot, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return []; + } + try { + // $FlowFixMe[unsupported-syntax] dynamic require by computed path + const config = require(configPath); + return config.spm?.modules ?? []; + } catch (e) { + // Config might use Ruby interop or other patterns – skip + return []; + } +} + +/** + * Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to + * skip. The escape hatch for the transitive plugin discovery (an app opts a + * framework's plugin OUT); no allowlist is required. + */ +function readDenyPluginsFromConfig(appRoot /*: string */) /*: Array */ { + const configPath = path.join(appRoot, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return []; + } + try { + // $FlowFixMe[unsupported-syntax] dynamic require by computed path + const config = require(configPath); + return config.spm?.denyPlugins ?? []; + } catch (e) { + return []; + } +} + +/** + * Returns "." if the source directory has .h/.hpp files directly at its root + * AND no subdirectories exist at that root (adjacent subdirectories would cause + * Clang to reject the umbrella header). + * Returns null otherwise. + */ +function inferPublicHeadersPath(sourcePath /*: string */) /*: string | null */ { + if (!fs.existsSync(sourcePath)) return null; + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(sourcePath, {withFileTypes: true}); + const hasHeaders = entries.some( + e => + (e.isFile() || e.isSymbolicLink()) && + (e.name.endsWith('.h') || e.name.endsWith('.hpp')), + ); + const hasSubdirs = entries.some(e => e.isDirectory()); + // Only use "." if headers at root AND no adjacent subdirectories. + // If both headers and subdirectories exist, Clang rejects the module map + // (umbrella header + adjacent directories = error). + return hasHeaders && !hasSubdirs ? '.' : null; +} + +/*:: +type ExtensionFilter = ReadonlySet; +*/ + +const HEADER_EXTENSIONS /*: ExtensionFilter */ = new Set(['.h', '.hpp']); +const IMPL_EXTENSIONS /*: ExtensionFilter */ = new Set([ + '.m', + '.mm', + '.c', + '.cpp', + '.swift', +]); +const ALL_SOURCE_EXTENSIONS /*: ExtensionFilter */ = new Set([ + ...HEADER_EXTENSIONS, + ...IMPL_EXTENSIONS, +]); + +// Directory names whose contents should never be included in an SPM target — +// test fixtures, Android sources, vendored modules. Shared between the source +// walker and the header linker so they agree on what to skip. +const SKIP_DIRS_DEFAULT /*: ReadonlySet */ = new Set([ + 'android', + 'tests', + '__tests__', + '__mocks__', + 'test', + 'jest', + 'node_modules', +]); + +// Name of the dir-symlink inside each wrapper that points at the dep's real +// source dir. With target.path = "." (the wrapper), SPM resolves source paths +// like "/Foo.mm" by following this link. +const WRAPPER_ROOT_NAME = 'root'; + +// The config-gated C++ defines the prebuilt React.framework is built with (see +// packages/react-native/Package.swift). Autolinked Fabric C++ must compile with +// the same NDEBUG state, else DebugStringConvertible's vtable / ShadowNode +// layout diverges from a Release React.framework and the link fails. +const REACT_CXX_CONFIG_DEFINES = [ + '.define("DEBUG", .when(configuration: .debug))', + '.define("NDEBUG", .when(configuration: .release))', +]; + +// Marker the autolinker stamps onto every synth Package.swift it writes. +// Files lacking this marker are treated as user-managed (self-managed) and +// are referenced directly rather than wrapped — see findSelfManagedPackageDir. +const AUTOGEN_MARKER = + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js'; + +/** + * A dep is "self-managed" when it ships a hand-written Package.swift + * (i.e. one that lacks our AUTOGEN_MARKER). The autolinker skips wrapping + * it and references the manifest's directory directly — useful for + * libraries that want to ship a real SPM manifest and have full control + * over their target settings. + * + * Two layouts are recognized: + * 1. /Package.swift — manifest at the npm-package root + * 2. /ios/Package.swift — manifest co-located with ObjC + * sources, keeping the npm-package + * root free of SPM artifacts + * (.build/, .swiftpm/, Package.resolved) + * + * Returns the directory that contains the hand-authored manifest, or null + * when no candidate exists. That directory is what the aggregator hands to + * SPM as `.package(path:)` — for layout 2 that means `/ios`. + */ +function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ { + for (const sub of ['', 'ios']) { + const dir = sub === '' ? absSource : path.join(absSource, sub); + try { + const content = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8'); + if (!content.includes(AUTOGEN_MARKER)) { + return dir; + } + } catch { + // candidate does not exist; try the next one + } + } + return null; +} + +/** + * Does this dep ship a CocoaPods podspec? (Checked at the dep root and under + * ios/.) A missing manifest is auto-scaffoldable only when a podspec exists — + * the scaffolder translates the podspec into a Package.swift. + */ +function hasPodspec(absSource /*: string */) /*: boolean */ { + for (const sub of ['', 'ios']) { + const dir = sub === '' ? absSource : path.join(absSource, sub); + try { + if ( + fs + .readdirSync(dir) + .some(e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-')) + ) { + return true; + } + } catch { + // dir does not exist; try the next candidate + } + } + return false; +} + +/** + * True when a dep has BOTH Swift and C-family (.m/.mm/.c/.cpp) sources. SPM + * cannot compile mixed-language sources in a single target, and RN libs that + * mix them are typically bidirectionally coupled (ObjC↔Swift) — which can't be + * split into two targets either (it would be a circular dependency). So such a + * dep is unsupportable by the scaffolder; we surface a clear, distinct error + * instead of emitting a manifest that fails with a cryptic SPM resolve error. + * Heuristic filesystem scan (bounded depth; skips examples/tests/build noise). + */ +function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { + const SKIP /*: Set */ = new Set([ + 'node_modules', + 'Pods', + 'build', + '.git', + '__tests__', + 'example', + 'Example', + 'examples', + ]); + let hasSwift = false; + let hasClang = false; + const walk = (dir /*: string */, depth /*: number */) => { + if (depth > 6 || (hasSwift && hasClang)) return; + let entries: Array<{name: string, isDirectory(): boolean}>; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(dir, {withFileTypes: true}); + } catch { + return; + } + for (const e of entries) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs + const name /*: string */ = e.name; + if (e.isDirectory()) { + if (!name.startsWith('.') && !SKIP.has(name)) { + walk(path.join(dir, name), depth + 1); + } + } else if (/\.swift$/i.test(name)) { + hasSwift = true; + } else if (/\.(mm?|c|cc|cpp|cxx)$/i.test(name)) { + hasClang = true; + } + if (hasSwift && hasClang) return; + } + }; + walk(absSource, 0); + return hasSwift && hasClang; +} + +/** + * Error thrown when one or more autolinked community npm deps have no Swift + * Package Manager manifest (neither a shipped Package.swift nor a scaffolded + * one). The autolinker no longer silently synthesizes a manifest for these — + * that hid the gap and duplicated the scaffolder. Carries the dep list so the + * CLI can surface a precise, actionable message and set a distinct exit code + * (the Xcode build phase keys off it to fail the build). + */ +class MissingManifestError extends Error { + /*:: missingManifests: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}>; */ + constructor( + deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */, + ) { + super( + `${deps.length} autolinked native module(s) have no Package.swift. ` + + 'Run `npx react-native spm scaffold` to generate them.', + ); + this.name = 'MissingManifestError'; + this.missingManifests = deps; + } +} + +/** + * Prints one `error:`-prefixed line per missing-manifest dep so Xcode surfaces + * each as a build error (Xcode parses lines beginning with `error: `), then + * returns the MissingManifestError to throw. Kept together so the message and + * the thrown error never drift. + */ +function reportMissingManifests( + deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */, +) /*: MissingManifestError */ { + for (const d of deps) { + if (d.mixed === true) { + console.error( + `error: "${d.npmName}" has mixed Swift + Objective-C/C++ sources, which Swift Package Manager cannot compile in a single target (and its Swift↔ObjC interop typically can't be split into two targets without a circular dependency).\n` + + ` • Opt it out of SPM autolinking in your app's react-native.config.js:\n` + + ` module.exports = { dependencies: { '${d.npmName}': { platforms: { ios: null } } } };\n` + + ` • Or consume ${d.npmName} as a prebuilt binary (xcframework) instead.`, + ); + continue; + } + if (d.hasPodspec) { + console.error( + `error: Package.swift is missing for library "${d.npmName}" — it ships no Swift Package Manager support.\n` + + ` 1. Run \`npx react-native spm scaffold\` to generate a Package.swift for ${d.npmName}.\n` + + ` 2. Persist it with a patch: \`npx patch-package ${d.npmName}\`, and commit the patch (node_modules is not committed).\n` + + ` 3. Ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).\n` + + ' 4. Without a committed patch, this same error returns whenever node_modules is reset (fresh install / CI).', + ); + } else { + console.error( + `error: Package.swift is missing for library "${d.npmName}", and it ships no podspec so it cannot be scaffolded automatically.\n` + + ` • It needs Swift Package Manager support added manually — ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).`, + ); + } + } + return new MissingManifestError(deps); +} + +/** + * Mirrors every header file under `srcDir` as a relative symlink at the same + * relative location under `destDir`. Used for the centralized cross-package + * headers tree at `/headers//` so consumers can resolve + * `#import ` via a single `-I /headers` flag. + * + * Idempotent: existing symlinks pointing at the right target are left alone; + * stale entries are pruned. Header symlinks here are inert to Xcode (it + * doesn't navigate them as editable source — they're compiler-only). + */ +function linkHeaderTree( + srcDir /*: string */, + destDir /*: string */, + skipDirNames /*: Set */ = new Set(), +) /*: void */ { + if (!srcDir || !path.isAbsolute(srcDir)) { + throw new Error( + `linkHeaderTree: srcDir must be a non-empty absolute path, got: "${srcDir}"`, + ); + } + if (!destDir || !path.isAbsolute(destDir)) { + throw new Error( + `linkHeaderTree: destDir must be a non-empty absolute path, got: "${destDir}"`, + ); + } + if (!fs.existsSync(srcDir)) { + return; + } + + /*:: type HeaderEntry = {relSrc: string, absSrc: string}; */ + const headers /*: Array */ = []; + function collect(dir /*: string */, relBase /*: string */) /*: void */ { + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const {name} = entry; + if (entry.isDirectory()) { + if (SKIP_DIRS_DEFAULT.has(name) || skipDirNames.has(name)) continue; + collect(path.join(dir, name), path.join(relBase, name)); + } else if (entry.isFile() && HEADER_EXTENSIONS.has(path.extname(name))) { + headers.push({ + relSrc: path.join(relBase, name), + absSrc: path.join(dir, name), + }); + } + } + } + collect(srcDir, ''); + + if (headers.length === 0) { + try { + if (fs.lstatSync(destDir).isDirectory()) { + fs.rmSync(destDir, {recursive: true, force: true}); + } + } catch { + // destDir does not exist – fine + } + return; + } + + fs.mkdirSync(destDir, {recursive: true}); + + const expected /*: Set */ = new Set(); + for (const {relSrc, absSrc} of headers) { + const linkPath = path.join(destDir, relSrc); + expected.add(relSrc); + fs.mkdirSync(path.dirname(linkPath), {recursive: true}); + const desiredTarget = path.relative(path.dirname(linkPath), absSrc); + try { + const existing = fs.lstatSync(linkPath); + if ( + existing.isSymbolicLink() && + fs.readlinkSync(linkPath) === desiredTarget + ) { + continue; + } + fs.unlinkSync(linkPath); + } catch { + // nothing to remove + } + fs.symlinkSync(desiredTarget, linkPath); + } + + // Prune stale entries: walk destDir and delete anything not in `expected`. + function pruneWalk(dir /*: string */, relBase /*: string */) /*: void */ { + if (!fs.existsSync(dir)) return; + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const rel = path.join(relBase, entry.name); + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + pruneWalk(abs, rel); + if (fs.readdirSync(abs).length === 0) { + fs.rmdirSync(abs); + } + } else { + if (!expected.has(rel)) { + fs.unlinkSync(abs); + } + } + } + } + pruneWalk(destDir, ''); +} + +/** + * Searches sourcePath for a PrivacyInfo.xcprivacy file (at root or one level deep). + * Returns the relative path from sourcePath if found, null otherwise. + */ +function findPrivacyManifest(sourcePath /*: string */) /*: string | null */ { + if (!fs.existsSync(sourcePath)) return null; + // Check root level + if (fs.existsSync(path.join(sourcePath, 'PrivacyInfo.xcprivacy'))) { + return 'PrivacyInfo.xcprivacy'; + } + // Check one level deep (e.g. ios/PrivacyInfo.xcprivacy) + const entries /*: Array<{name: string, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(sourcePath, {withFileTypes: true}); + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = path.join(sourcePath, entry.name, 'PrivacyInfo.xcprivacy'); + if (fs.existsSync(nested)) { + return path.join(entry.name, 'PrivacyInfo.xcprivacy'); + } + } + } + return null; +} + +/** + * Recursively yields forward-slash paths (relative to sourcePath) for every + * regular file under sourcePath, skipping directories whose name is in + * SKIP_DIRS_DEFAULT. Used as the building block for both the auto-discovery + * (collectSpmSources) and explicit-glob (expandSpmSourceGlobs) paths so they + * agree on what's a candidate before extension/glob filtering applies. + */ +function walkSourceFiles(sourcePath /*: string */) /*: Array */ { + const out /*: Array */ = []; + if (!fs.existsSync(sourcePath)) { + return out; + } + function walk(dir /*: string */, rel /*: string */) /*: void */ { + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const {name} = entry; + const childRel = rel === '' ? name : `${rel}/${name}`; + if (entry.isDirectory()) { + if (SKIP_DIRS_DEFAULT.has(name)) continue; + walk(path.join(dir, name), childRel); + } else if (entry.isFile() || entry.isSymbolicLink()) { + out.push(childRel); + } + } + } + walk(sourcePath, ''); + return out; +} + +/** + * Idempotent symlink: ensure `linkPath` is a symlink to `target`. If it + * already is, leave it untouched (preserves inode). If it points elsewhere + * or is a real file/directory, replace it. Returns true when the symlink + * was created or replaced, false when it was already correct. + */ +function ensureSymlink( + linkPath /*: string */, + target /*: string */, +) /*: boolean */ { + try { + const stat = fs.lstatSync(linkPath); + if (stat.isSymbolicLink() && fs.readlinkSync(linkPath) === target) { + return false; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + fs.unlinkSync(linkPath); + } else { + fs.rmSync(linkPath, {recursive: true, force: true}); + } + } catch { + // linkPath does not exist – fine + } + fs.symlinkSync(target, linkPath); + return true; +} + +// Default sources allowlist when no explicit glob is provided — analog of +// CocoaPods' `s.source_files` auto-discovery. +function collectSpmSources(sourcePath /*: string */) /*: Array */ { + return walkSourceFiles(sourcePath) + .filter(p => ALL_SOURCE_EXTENSIONS.has(path.extname(p))) + .sort(); +} + +// Filters walkSourceFiles output through CocoaPods-style globs via micromatch. +// Skip-dir filtering applies before matching, so `**/*.{h,mm}` never returns +// paths under `tests/`, `android/`, etc. — even if the pattern would match. +function expandSpmSourceGlobs( + sourcePath /*: string */, + patterns /*: Array */, +) /*: Array */ { + if (patterns.length === 0) { + return []; + } + // $FlowFixMe[untyped-import] micromatch ships no types + const micromatch = require('micromatch'); + return micromatch(walkSourceFiles(sourcePath), patterns).sort(); +} + +/** + * Converts an autolinking.json dependency to an SPM target spec. + * Returns null if the dependency doesn't have iOS support. + * + * `swiftNameByNpm` maps each autolinked dep's npm name to its resolved Swift + * name (populated by expandSpmDependencies, possibly overridden via the dep's + * `spm.name` config). Optional for backwards compatibility with callers that + * don't have the map; falls back to `toSwiftName(name)` per entry. + */ +/** + * Read the dep's podspec (if any) and extract its declared + * `pod_target_xcconfig` HEADER_SEARCH_PATHS, substituted relative to the dep + * source dir. Returns paths suitable for an SPM `.headerSearchPath()` + * directive whose target.path is the dep root (entries are NOT yet prefixed + * with the synth wrapper's `root/` — the emission site adds that prefix + * because the wrapper's target.path is `.`, not the source dir). + * + * Without these, path-style angle includes like + * `` (used by + * react-native-safe-area-context, reanimated, screens, etc.) fail to resolve + * because the headers live under the dep's `common/cpp/` rather than under + * the framework-imported xcframework headers. + */ +function extractPodspecHeaderSearchPaths( + sourceDir /*: string */, +) /*: Array */ { + let podspecPath /*: ?string */ = null; + try { + const entries = fs.readdirSync(sourceDir); + // Skip a crashed run's leftover `.spm-scaffold--.podspec` copy. + const candidate = entries.find( + e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-'), + ); + if (candidate != null) { + podspecPath = path.join(sourceDir, candidate); + } + } catch { + return []; + } + if (podspecPath == null) return []; + + let model; + try { + model = readPodspec(podspecPath); + } catch { + return []; + } + + const out /*: Array */ = []; + for (const raw of model.headerSearchPaths) { + const substituted = raw + .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.') + .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.'); + // Drop entries still containing unresolved Xcode tokens — emitting them + // verbatim would surface as clang "no such file" failures. + if (/\$[({]/.test(substituted)) continue; + const cleaned = substituted.replace(/^\.\//, '').replace(/^\//, ''); + if (cleaned.length > 0 && !out.includes(cleaned)) { + out.push(cleaned); + } + } + return out; +} + +function autolinkingDepToSpmTarget( + depName /*: string */, + dep /*: AutolinkedDep */, + outputDir /*: string */, + swiftNameByNpm /*: ?Map */, +) /*: SpmTarget | null */ { + const iosPlatform = dep.platforms.ios; + const sourceDir = iosPlatform.sourceDir ?? dep.root; + if (sourceDir == null) { + return null; + } + + // target.path is stored relative to the autolinker's outputDir so main()'s + // `path.resolve(outputDir, target.path)` recovers the absolute source dir — + // same convention the spmModule branch in main() follows. + const relSourcePath = path.relative(outputDir, sourceDir); + + // Prefer the resolved Swift name (which honors `spm.name` overrides set in + // the dep's react-native.config.js). Fall back to toSwiftName(depName) when + // the caller didn't run expandSpmDependencies. + const targetName = dep.swiftName ?? toSwiftName(depName); + + // No exclude inference — main()'s emission loop emits `sources:` (an + // explicit allowlist). User-supplied excludes still work. + + // Detect PrivacyInfo.xcprivacy + const privacyManifest = findPrivacyManifest(sourceDir); + const resources = privacyManifest != null ? [privacyManifest] : undefined; + + // Map declared spm.dependencies (npm names) to Swift target names so the + // synth's .product(...) deps list reaches the consuming target. Each + // transitive npm name's Swift name comes from the map (honoring overrides); + // toSwiftName fallback handles entries the map doesn't know about. + const spmDeps /*: Array */ = dep.spmDependencies ?? []; + const spmTargetDependencies = + spmDeps.length > 0 + ? spmDeps.map(n => swiftNameByNpm?.get(n) ?? toSwiftName(n)) + : undefined; + + const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir); + + return { + name: targetName, + path: relSourcePath, + exclude: [], + publicHeadersPath: inferPublicHeadersPath(sourceDir), + resources, + spmTargetDependencies, + headerSearchPaths: + headerSearchPaths.length > 0 ? headerSearchPaths : undefined, + }; +} + +/** + * Generates the full autolinked/Package.swift content. + * + * xcframeworksRelPath – path to the xcframeworks sub-package relative to the + * autolinked/ directory (e.g. "../build/xcframeworks"). When non-null a + * React dependency is declared. Headers need no search paths — React/react + * come from the ReactHeaders target, every other RN namespace from + * ReactNativeHeaders, folly/glog/boost from ReactNativeDependenciesHeaders, + * and the app's generated headers from the ReactAppHeaders product — so + * , , , folly/glog/boost, + * and all resolve. + */ +/** + * Top-level autolinked/Package.swift — a thin aggregator that references each + * autolinked dep as its own sub-package (under packages/) and + * re-exports them through a single AutolinkedAggregate target. Per-dep + * settings (header paths, cFlags, link order) live in each synth sub-package; + * see generateSynthPackageSwift below. + * + * input: { deps: Array<{swiftName: string}> } + */ +function generateAutolinkedPackageSwift( + input /*: AggregatorInput */, +) /*: string */ { + const npmDeps /*: ReadonlyArray */ = input.npmDeps ?? []; + const inlineTargets /*: ReadonlyArray */ = + input.inlineTargets ?? []; + const hasReactDep /*: boolean */ = input.hasReactDep !== false; + // Relative path from autolinked/ to build/xcframeworks/, e.g. "../build/xcframeworks". + const xcframeworksRelPath /*: ?string */ = input.xcframeworksRelPath; + // Autolinking-plugin contributions (Expo & other frameworks). + const pluginPackageDeps /*: ReadonlyArray */ = + input.pluginPackageDeps ?? []; + const pluginProductDeps /*: ReadonlyArray */ = + input.pluginProductDeps ?? []; + + // Package-level dependencies: one .package(path:) per autolinked dep, + // plus ReactNative if any inline target needs to import React headers. + const packageDeps /*: Array */ = npmDeps.map(d => { + const pkgPath = d.packagePath ?? `packages/${d.swiftName}`; + return `.package(name: "${d.swiftName}", path: "${pkgPath}")`; + }); + // Framework plugin packages: local (path) or remote (url + exact version). + for (const p of pluginPackageDeps) { + packageDeps.push( + p.path != null + ? `.package(name: "${p.name}", path: "${p.path}")` + : `.package(url: "${p.url ?? ''}", exact: "${p.version ?? ''}")`, + ); + } + if ( + inlineTargets.length > 0 && + hasReactDep && + typeof xcframeworksRelPath === 'string' + ) { + packageDeps.push( + reactNativePackageDecl( + `.package(name: "ReactNative", path: "${xcframeworksRelPath}")`, + ), + ); + // Per-app generated headers come from the ReactAppHeaders product in + // the codegen package (sibling of the autolinking dir). + packageDeps.push(`.package(name: "React-GeneratedCode", path: "../ios")`); + } + + // AutolinkedAggregate's target dependencies: .product(...) for npm sub-package + // products and .target(...) for inline spmModule targets in the same package. + const aggregateDeps /*: Array */ = [ + ...npmDeps.map( + d => `.product(name: "${d.swiftName}", package: "${d.swiftName}")`, + ), + ...inlineTargets.map(t => `.target(name: "${t.name}")`), + ...pluginProductDeps.map( + p => `.product(name: "${p.name}", package: "${p.package}")`, + ), + ]; + + const inlineDecls = inlineTargets.map(t => { + const excludeLine = + t.exclude && t.exclude.length > 0 + ? `\n exclude: [${t.exclude.map(e => `"${e}"`).join(', ')}],` + : ''; + const publicHeadersLine = + t.publicHeadersPath != null + ? `\n publicHeadersPath: "${t.publicHeadersPath}",` + : ''; + const resourcesLine = + t.resources && t.resources.length > 0 + ? `\n resources: [${t.resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + return ` .target( + name: "${t.name}", + dependencies: [${reactProductDeps()}], + path: "${t.path}",${excludeLine}${publicHeadersLine}${resourcesLine} + cxxSettings: [${REACT_CXX_CONFIG_DEFINES.join(', ')}], + linkerSettings: [.linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS, .visionOS, .macCatalyst])), .linkedFramework("AppKit", .when(platforms: [.macOS])), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] + )`; + }); + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + const aggregateDepsLine = + aggregateDeps.length > 0 + ? `\n dependencies: [${aggregateDeps.join(', ')}],` + : ''; + + const inlineDeclsBlock = + inlineDecls.length > 0 ? `,\n${inlineDecls.join(',\n')}` : ''; + + // Eval-time missing-manifest guard. SwiftPM resolves the package graph BEFORE + // the Xcode "Sync SPM Autolinking" build phase runs, so a community library + // whose Package.swift is absent at resolution time (e.g. a scaffolded manifest + // wiped by a node_modules reset without a committed patch) fails resolution + // with an opaque "package manifest cannot be accessed" error — and the + // actionable sync-phase message never prints. Manifest evaluation may READ the + // filesystem (only writes are sandboxed), so the aggregator checks each + // referenced library here and explains the cause + fix at resolution time. + const guardEntries = npmDeps.map(d => { + const pkgPath = d.packagePath ?? `packages/${d.swiftName}`; + return ` (path: "${pkgPath}", npm: "${d.npmName ?? d.swiftName}")`; + }); + const guardBlock = + guardEntries.length > 0 + ? `// Eval-time guard: surface a wiped/absent library Package.swift here (at +// resolution) instead of the opaque SwiftPM "manifest cannot be accessed". +let __rnAutolinkedLibs: [(path: String, npm: String)] = [ +${guardEntries.join(',\n')}, +] +do { + let __here = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let __missing = __rnAutolinkedLibs.filter { + !FileManager.default.fileExists( + atPath: __here.appendingPathComponent($0.path) + .appendingPathComponent("Package.swift").path) + } + if !__missing.isEmpty { + var __msg = "" + for lib in __missing { + __msg += "error: Package.swift is missing for library \\"\\(lib.npm)\\" — its Swift Package Manager manifest is not present (a scaffolded manifest wiped by a node_modules reset without a committed patch, or the library ships none).\\n" + __msg += " 1. Run \`npx react-native spm scaffold\` to (re)generate it.\\n" + __msg += " 2. Persist it with \`npx patch-package \\(lib.npm)\` and commit the patch (node_modules is not committed).\\n" + __msg += " 3. Ask \\(lib.npm)'s maintainer to ship a Package.swift upstream.\\n" + __msg += " 4. Without a committed patch, this error returns on every fresh install / CI.\\n" + } + FileHandle.standardError.write(Data(__msg.utf8)) + fatalError("Missing Package.swift for: \\(__missing.map { $0.npm }.joined(separator: ", ")). See the message above.") + } +} + +` + : ''; + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually. +// Top-level Autolinked package. Every autolinked dep (npm or spmModule) is +// referenced as .package(path: ) — each has its own synth +// Package.swift written in-place. AutolinkedAggregate depends on every dep's +// product so the app build pulls them all in. + +import PackageDescription +import Foundation + +${guardBlock}let package = Package( + name: "Autolinked", + platforms: [.iOS(.v15)], + products: [ + .library(name: "Autolinked", targets: ["AutolinkedAggregate"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "AutolinkedAggregate",${aggregateDepsLine} + path: "AutolinkedAggregate" + )${inlineDeclsBlock} + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +/** + * Per-dep synthesized Package.swift, written at + * /packages//Package.swift. `targetPath` points at a + * `root` directory symlink to the real source dir, so source files stay real + * (Xcode atomic-save works). + * + * The React + codegen package references are plain relative paths supplied by + * the caller (`reactNativePackagePath` / `codegenPackagePath`), computed from + * the synth's fixed location under the autolinking dir — the manifest holds no + * runtime discovery and no absolute paths. Headers are served by the + * ReactHeaders/ReactNativeHeaders/ReactNativeDependenciesHeaders targets and + * the ReactAppHeaders product, so no + * search-path flags are needed. Siblings use their absolute synth path from + * `siblingSynthAbsolutePaths` (production) or a `siblingPackageBaseRelative` + * fallback (tests). + */ +function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ { + const swiftName /*: string */ = spec.swiftName; + const exclude /*: Array */ = spec.exclude ?? []; + const sources /*: ?Array */ = spec.sources; + const publicHeadersPath /*: ?string */ = spec.publicHeadersPath ?? null; + // Per-dep header search paths from the podspec's + // pod_target_xcconfig HEADER_SEARCH_PATHS — already prefixed by the caller + // with the synth wrapper's `root/` so they resolve relative to target.path. + // Emitted as `.headerSearchPath()` directives, which SPM accepts on + // cSettings / cxxSettings without needing absolute paths. + const headerSearchPaths /*: Array */ = spec.headerSearchPaths ?? []; + const spmDependencies /*: Array<{swiftName: string}> */ = + spec.spmDependencies ?? []; + const hasReactDep /*: boolean */ = spec.hasReactDep !== false; + const resources /*: ?Array */ = spec.resources; + const isDynamic /*: boolean */ = spec.isDynamic !== false; + const targetPath /*: string */ = spec.targetPath ?? `Sources/${swiftName}`; + const siblingSynthAbsolutePaths /*: {[string]: string} */ = + spec.siblingSynthAbsolutePaths ?? {}; + + // Package dependencies — ReactNative + each spm sibling synth package. + // The React + codegen package paths are plain relative strings computed by + // the caller at generation time (the synth always lives at a fixed depth + // under the autolinking dir, and is regenerated on every `react-native + // spm` run), so the manifest holds no runtime discovery. Siblings use their + // absolute synth path when the caller provides one (production); else a + // relative fallback. + const packageDeps /*: Array */ = []; + if (hasReactDep) { + const reactNativePackagePath /*: string */ = + spec.reactNativePackagePath ?? '../../../../xcframeworks'; + const codegenPackagePath /*: string */ = + spec.codegenPackagePath ?? '../../../ios'; + packageDeps.push( + reactNativePackageDecl( + `.package(name: "ReactNative", path: "${reactNativePackagePath}")`, + ), + ); + // Per-app generated headers come from the ReactAppHeaders product in + // the codegen package. + packageDeps.push( + `.package(name: "React-GeneratedCode", path: "${codegenPackagePath}")`, + ); + } + for (const dep of spmDependencies) { + const absPath = siblingSynthAbsolutePaths[dep.swiftName]; + if (absPath != null) { + packageDeps.push( + `.package(name: "${dep.swiftName}", path: "${absPath}")`, + ); + } else { + const siblingRel /*: string */ = spec.siblingPackageBaseRelative ?? '..'; + packageDeps.push( + `.package(name: "${dep.swiftName}", path: "${siblingRel}/${dep.swiftName}")`, + ); + } + } + + // Target dependencies — products from each declared package dep. + const targetDeps /*: Array */ = []; + if (hasReactDep) { + targetDeps.push(reactProductDeps()); + } + for (const dep of spmDependencies) { + targetDeps.push( + `.product(name: "${dep.swiftName}", package: "${dep.swiftName}")`, + ); + } + + const excludeLine = + exclude.length > 0 + ? `\n exclude: [${exclude.map(e => `"${e}"`).join(', ')}],` + : ''; + // sources: explicit allowlist. One file per line because lists can run to + // dozens of entries and an unbroken array becomes unreadable in diffs. + const sourcesLine = + sources != null && sources.length > 0 + ? `\n sources: [\n${sources.map(s => ` "${s}",`).join('\n')}\n ],` + : ''; + const publicHeadersLine = + publicHeadersPath != null + ? `\n publicHeadersPath: "${publicHeadersPath}",` + : ''; + const resourcesLine = + resources != null && resources.length > 0 + ? `\n resources: [${resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + // `.headerSearchPath(...)` entries from the podspec — first-class + // directives keep SPM's diagnostics meaningful (clang reports the + // dep-relative path on miss). React headers need no paths at all. + const headerSearchPathList = headerSearchPaths.map( + p => `.headerSearchPath("${p}")`, + ); + const cSettingsLine = + headerSearchPathList.length > 0 + ? `\n cSettings: [${headerSearchPathList.join(', ')}],` + : ''; + // ALWAYS emit the config-gated C++ defines (even with no header paths): a + // Fabric C++ target must compile with the same NDEBUG state as the prebuilt + // React.framework, else DebugStringConvertible's vtable / ShadowNode layout + // diverges and a Release build fails to link. Mirrors Package.swift. + const cxxEntries = [...REACT_CXX_CONFIG_DEFINES, ...headerSearchPathList]; + const cxxSettingsLine = `\n cxxSettings: [${cxxEntries.join(', ')}],`; + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually. +// Synth Package.swift for autolinked dep "${swiftName}". + +import PackageDescription + +let package = Package( + name: "${swiftName}", + platforms: [.iOS(.v15)], + products: [ + .library(name: "${swiftName}"${isDynamic ? ', type: .dynamic' : ''}, targets: ["${swiftName}"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "${swiftName}", + dependencies: [${targetDeps.join(', ')}], + path: "${targetPath}",${excludeLine}${sourcesLine}${publicHeadersLine}${resourcesLine}${cSettingsLine}${cxxSettingsLine} + linkerSettings: [.linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS, .visionOS, .macCatalyst])), .linkedFramework("AppKit", .when(platforms: [.macOS])), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] + ), + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +function main(argv /*:: ?: Array */) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + // Resolve to absolute so path.join() produces absolute paths everywhere — + // entryAbsDirs, the headers farm, etc. all assume an absolute appRoot. + const appRoot = path.resolve(args.appRoot); + remoteCfg = remotePackageConfig(appRoot); + if (remoteCfg != null) { + log(`Remote ReactNative package: ${remoteCfg.url} @ ${remoteCfg.version}`); + } + + let rnRoot = args.reactNativeRoot; + if (rnRoot == null) { + rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + if (!fs.existsSync(rnRoot)) { + // Monorepo: try walking up + let dir = appRoot; + for (let i = 0; i < 5; i++) { + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + const c = path.join(dir, 'node_modules', 'react-native'); + if (fs.existsSync(c)) { + rnRoot = c; + break; + } + } + } + if (rnRoot == null || !fs.existsSync(rnRoot)) { + console.error( + '[generate-spm-autolinking] Could not find react-native. Pass --react-native-root.', + ); + process.exitCode = 1; + return; + } + } + + const autolinkingJsonPath = + args.autolinkingJson ?? + path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); + + // Output lands under /build/generated/autolinking/ — co-located + // with autolinking.json (written by generate-spm-autolinking-config.js) and + // alongside the iOS-conventional build/ tree (Pods/, build/xcframeworks/…). + const outputDir = + args.output != null + ? path.resolve(appRoot, args.output) + : path.join(appRoot, 'build', 'generated', 'autolinking'); + + // Collect all targets along with their routing metadata. + const entries /*: Array */ = []; + // Autolinking plugins discovered from deps' react-native.config.js + // (populated during the dep walk below; invoked before the aggregator write). + let discoveredPlugins /*: Array */ = []; + + // 1. From autolinking.json (npm packages with iOS native modules), expanded + // with transitive deps declared via `spm.dependencies` in each package's + // react-native.config.js (analog of podspec `s.dependency`). + const autolinkingData = readAutolinkingJson(autolinkingJsonPath); + const depsMap = autolinkingData?.dependencies; + if (depsMap != null) { + // Narrow each on-disk AutolinkingDepJson into the validated AutolinkedDep + // shape expected by expandSpmDependencies and autolinkingDepToSpmTarget. + const directDeps /*: Array */ = []; + for (const name of Object.keys(depsMap)) { + const dep = depsMap[name]; + if (dep == null) continue; + const iosPlatform = dep.platforms?.ios; + const root = dep.root; + if (iosPlatform == null || root == null) continue; + directDeps.push({ + name, + root, + platforms: {ios: iosPlatform}, + }); + } + const allDeps = expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + }); + + // Map every autolinked npm name to its resolved Swift name (post-override) + // so transitive references inside autolinkingDepToSpmTarget find the right + // target identifier — not just the auto-derived toSwiftName. + const swiftNameByNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + if (dep.swiftName != null) { + swiftNameByNpm.set(dep.name, dep.swiftName); + } + } + + // Discover framework autolinking plugins (Expo & others) from the same + // dep set — invoked before the aggregator is written (below). + discoveredPlugins = discoverPlugins( + allDeps, + defaultReadConfig, + readDenyPluginsFromConfig(appRoot), + ); + for (const p of discoveredPlugins) { + log(`Found SPM autolinking plugin: ${p.depName}`); + } + + // A dep that declares an autolinking plugin OWNS its native contribution: + // the plugin (invoked below) returns its package/product/generated-source + // deps. RN must not also try to source-build that package through the + // community-lib path — it typically has no Package.swift and may be mixed + // Swift/ObjC (e.g. expo → ExpoModulesCore), which would throw before the + // plugin ever runs. Mirrors CocoaPods: `use_expo_modules!` owns Expo's + // pods; podspec autolinking doesn't also build Expo as a community lib. + // Skipping here also keeps the dep out of `entries` → out of the + // missing-manifest scan and the aggregator's package refs. + const pluginHostDeps /*: Set */ = new Set( + discoveredPlugins.map(p => p.depName), + ); + + for (const dep of allDeps) { + if (pluginHostDeps.has(dep.name)) { + log( + `Skipping ${dep.name} target generation — provided by its SPM autolinking plugin`, + ); + continue; + } + const target = autolinkingDepToSpmTarget( + dep.name, + dep, + outputDir, + swiftNameByNpm, + ); + if (target != null) { + entries.push({ + target, + origin: 'npm', + npmName: dep.name, + root: dep.root, + }); + log(`Found npm native module: ${target.name} → ${target.path}`); + } + } + } else { + log( + `No autolinking.json found at ${path.relative(appRoot, autolinkingJsonPath)} or no dependencies. Using only built-in modules.`, + ); + } + + // 2. From react-native.config.js spm.modules (user-defined extra modules). + // If the module declares `sources: [glob, ...]` (CocoaPods-style), expand + // the globs now relative to its dir and attach the file list to the target + // so the emission loop below renders `sources: [...]` literally. + const configModules = readSpmModulesFromConfig(appRoot); + for (const mod of configModules) { + const absPath = path.resolve(appRoot, mod.path); + const relPath = path.relative(outputDir, absPath); + const userSources = + Array.isArray(mod.sources) && mod.sources.length > 0 + ? expandSpmSourceGlobs(absPath, mod.sources) + : null; + entries.push({ + target: { + name: mod.name, + path: relPath, + exclude: mod.exclude ?? [], + publicHeadersPath: mod.publicHeadersPath ?? null, + sources: userSources, + }, + origin: 'spmModule', + }); + log(`Config module: ${mod.name} → ${relPath}`); + } + + // Resolve xcframeworks package path relative to outputDir (autolinked/). + // When provided this causes each target to declare a React dependency so + // Xcode adds the xcframework's header search paths (needed for ). + // Always set xcframeworksRelPath to the default even if the directory doesn't + // exist yet — on first run, step 2 (autolinking) runs before step 4 + // (xcframework symlinks), but the generated Swift code resolves paths at + // Xcode build time, not generation time. + let xcframeworksRelPath /*: string | null */ = null; + const absXcframeworks /*: string */ = + args.xcframeworksPath != null + ? path.resolve(appRoot, args.xcframeworksPath) + : path.join(appRoot, 'build', 'xcframeworks'); + xcframeworksRelPath = path.relative(outputDir, absXcframeworks); + + if (xcframeworksRelPath != null) { + log( + `React xcframeworks → ${xcframeworksRelPath} (relative to autolinked/)`, + ); + } + + // Whether autolinked targets declare a React dependency at all. Headers are + // served by the ReactHeaders/ReactNativeHeaders/ReactNativeDependenciesHeaders + // binaryTargets and the ReactAppHeaders product — no `-I` flags anywhere. + const hasReactDep = xcframeworksRelPath != null; + + // Each entry gets a wrapper dir at /packages// that + // contains the synth Package.swift and a `root` directory symlink pointing + // at the dep's real source dir. SPM derives package identity from the path + // basename, so the wrapper's unique name (SwiftName) sidesteps the basename + // collision that in-place at the source dir would have. Files inside the + // source dir stay real, so Xcode's atomic-save works through the dir + // symlink (intermediate path components — even symlinks — resolve cleanly; + // the issue was only file-symlinks as the final path component). + const entryAbsDirs /*: Map */ = new Map(); + for (const entry of entries) { + entryAbsDirs.set( + entry.target.name, + path.resolve(outputDir, entry.target.path), + ); + } + + const packagesDir = path.join(outputDir, 'packages'); + const headersDir = path.join(outputDir, 'headers'); + // libs// symlinks for self-managed deps. The symlink basename + // is the Swift module name (guaranteed unique per dep), so SPM's + // path-basename-based package identity never collides — even when two + // libs ship their own Package.swift inside `ios/` (a common convention). + // Wiped on every run; populated below as self-managed deps are visited. + const libsDir = path.join(outputDir, 'libs'); + fs.mkdirSync(packagesDir, {recursive: true}); + fs.mkdirSync(headersDir, {recursive: true}); + fs.rmSync(libsDir, {recursive: true, force: true}); + fs.mkdirSync(libsDir, {recursive: true}); + + const wrapperDirs /*: Map */ = new Map(); + const selfManagedDirs /*: Map */ = new Map(); + const aggregatorPackageDeps /*: Array */ = []; + // Community npm deps that autolink but ship/scaffold no Package.swift. We no + // longer silently synthesize one for them (that duplicated the scaffolder and + // hid the gap from the developer and the library author) — collect them and + // fail with an actionable message after the classification pass. spmModules + // (app-local, podspec-less, explicitly declared in react-native.config.js) + // keep their synth wrappers: there is nothing to scaffold for them. + const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ = + []; + + for (const entry of entries) { + const {target} = entry; + const absSource /*: string */ = entryAbsDirs.get(target.name) ?? ''; + if (!fs.existsSync(absSource)) { + log(`Skipping ${target.name}: source dir missing (${absSource})`); + continue; + } + const selfManagedDir = findSelfManagedPackageDir(absSource); + if (selfManagedDir != null) { + // Record the manifest's actual directory — for the nested layout this + // is /ios, not . SPM resolves `.package(path:)` against that + // directory expecting Package.swift to live alongside. + selfManagedDirs.set(target.name, selfManagedDir); + // If a wrapper exists from a prior synth-mode run (i.e. the dep WAS + // autolinker-wrapped, then later transitioned to self-managed via + // `spm scaffold` or shipping its own Package.swift), remove the + // wrapper now. Without this, the pruning loop below preserves it + // (because the dep is "active" via selfManagedDirs) and Xcode's + // SwiftPM cache picks up the stale wrapper Package.swift instead of + // the self-managed one. + const staleWrapper = path.join(packagesDir, target.name); + if (fs.existsSync(staleWrapper)) { + fs.rmSync(staleWrapper, {recursive: true, force: true}); + log(`Removed stale wrapper: packages/${target.name}/`); + } + log( + `Self-managed: ${target.name} → ${path.relative(appRoot, selfManagedDir)} (using its own Package.swift)`, + ); + continue; + } + if (entry.origin === 'npm') { + // No shipped or scaffolded manifest — this is the gap we now surface. + // A mixed-language dep is reported distinctly (it can't be scaffolded at + // all, so "run spm scaffold" would be misleading). + missingManifests.push({ + name: target.name, + npmName: entry.npmName ?? target.name, + hasPodspec: hasPodspec(absSource), + mixed: hasMixedLanguageSources(absSource), + }); + // Drop any stale wrapper from a previous synth-mode run so SPM doesn't + // resolve against it. + const staleWrapper = path.join(packagesDir, target.name); + if (fs.existsSync(staleWrapper)) { + fs.rmSync(staleWrapper, {recursive: true, force: true}); + } + continue; + } + // spmModule: synth wrapper is the legitimate mechanism (no podspec exists + // to scaffold from, and the app developer declared it explicitly). But a + // mixed-language module can't be wrapped either — SPM can't compile Swift + + // C-family sources in one target, and a synth wrapper would fail with a + // cryptic SPM resolve error. Surface the same friendly diagnostic the + // community-dep path uses instead of letting SPM emit the cryptic one. + if (hasMixedLanguageSources(absSource)) { + throw new Error( + `react-native autolinking: the spm.module "${target.name}" mixes Swift ` + + `and C-family (.m/.mm/.c/.cpp) sources, which SwiftPM cannot compile ` + + `in a single target. Split it into separate single-language modules, ` + + `or ship a hand-written Package.swift with multiple targets.`, + ); + } + const wrapperDir = path.join(packagesDir, target.name); + wrapperDirs.set(target.name, wrapperDir); + fs.mkdirSync(wrapperDir, {recursive: true}); + ensureSymlink(path.join(wrapperDir, WRAPPER_ROOT_NAME), absSource); + } + + // Fail before writing any wrappers/aggregator: a missing community-lib + // manifest is a hard error the developer must resolve by scaffolding (or the + // library shipping its own). reportMissingManifests prints one `error:` line + // per dep so Xcode renders them as build errors. + if (missingManifests.length > 0) { + throw reportMissingManifests(missingManifests); + } + + // Sibling refs: each synth Package.swift declares its sibling deps via the + // dep's actual package root — wrapper dir for autolinker-managed deps, + // source dir for self-managed ones. SPM identity stays unique either way + // (wrapper basename = SwiftName; self-managed manifests declare the same + // package name). + const siblingPackagePaths /*: {[string]: string} */ = {}; + for (const [name, wrapper] of wrapperDirs.entries()) { + siblingPackagePaths[name] = wrapper; + } + for (const [name, sourceDir] of selfManagedDirs.entries()) { + siblingPackagePaths[name] = sourceDir; + } + + for (const entry of entries) { + const {target} = entry; + const absSource /*: string */ = entryAbsDirs.get(target.name) ?? ''; + + // Self-managed deps: skip the synth step entirely. The dep's own + // Package.swift handles its targets, headers, and React framework + // wiring. We just register it with the aggregator so the app pulls it + // in alongside autolinker-managed deps. The central headers// + // tree still gets populated so consumers (host app + sibling synths + // that hit -I autolinking/headers) can resolve `` + // by file path — synth packages use `-fno-implicit-module-maps`, so + // we can't rely on SPM's auto-generated module map alone. + if (selfManagedDirs.has(target.name)) { + // Centralized headers tree walks the WHOLE dep root, not just the + // manifest's directory — headers may live anywhere (e.g. common/cpp/ + // outside of ios/), and cross-package consumers should still resolve + // them via the centralized -I path. + linkHeaderTree(absSource, path.join(headersDir, target.name)); + // Route the manifest reference through a uniquely-named symlink at + // libs// so SPM derives the package identity from the + // alias basename. Two libs that both ship Package.swift inside their + // own `ios/` subdir would otherwise collide with identity "ios". + const realPackageDir = selfManagedDirs.get(target.name) ?? absSource; + const aliasPath = path.join(libsDir, target.name); + ensureSymlink(aliasPath, realPackageDir); + aggregatorPackageDeps.push({ + swiftName: target.name, + packagePath: `libs/${target.name}`, + npmName: entry.npmName ?? target.name, + }); + continue; + } + + const wrapperDir = wrapperDirs.get(target.name); + if (wrapperDir == null) continue; + const skipDirNames = new Set( + (target.exclude || []) + .filter(e => e.endsWith('/')) + .map(e => e.slice(0, -1)), + ); + + const siblingSynthAbsolutePaths /*: {[string]: string} */ = {}; + for (const sibling of target.spmTargetDependencies ?? []) { + const sibPath = siblingPackagePaths[sibling]; + if (sibPath != null) { + siblingSynthAbsolutePaths[sibling] = sibPath; + } + } + + // target.path = "." (the wrapper dir) so SPM sees an empty `include/` + // sibling of `root/` for its required `publicHeadersPath`. Without that, + // SPM defaults publicHeadersPath to "include" and errors out when no + // such dir exists inside the dep's source tree. Sources come from + // `root/<...>` via the dir symlink — paths from auto-discovery or + // user globs are relative to the dep's source dir, so we prefix with + // `root/` to keep them inside target.path. + const withRoot = (p /*: string */) => `${WRAPPER_ROOT_NAME}/${p}`; + const prefixedExclude /*: Array */ = (target.exclude ?? []).map( + withRoot, + ); + const prefixedResources /*: ?Array */ = + target.resources != null ? target.resources.map(withRoot) : undefined; + + // sources: explicit allowlist. Pre-resolved on the target (spmModule + // glob expansion) or auto-collected here. We always emit `sources:` so + // SPM never falls back to scanning the source dir verbatim (which would + // pick up tests/, *.js, *.podspec, etc.). + const rawSources /*: Array */ = + target.sources != null && target.sources.length > 0 + ? target.sources + : collectSpmSources(absSource); + const prefixedSources /*: ?Array */ = + rawSources.length > 0 ? rawSources.map(withRoot) : null; + + // Podspec HEADER_SEARCH_PATHS were captured relative to the dep's source + // dir. The wrapper exposes the source dir under `root/` (target.path is + // `.`, the wrapper dir), so each entry must be prefixed with `root/` so + // clang sees the real subtree. + const prefixedHeaderSearchPaths /*: ?Array */ = + target.headerSearchPaths != null && target.headerSearchPaths.length > 0 + ? target.headerSearchPaths.map(withRoot) + : null; + + const synthContent = generateSynthPackageSwift({ + swiftName: target.name, + exclude: prefixedExclude, + sources: prefixedSources, + // Stub include/ subdir lives in the wrapper dir; satisfies SPM's + // publicHeadersPath requirement without exposing anything. Cross-pkg + // angle includes resolve through the merged header tree (the autolinking + // header farm at /headers is folded into it). + publicHeadersPath: 'include', + resources: prefixedResources, + headerSearchPaths: prefixedHeaderSearchPaths, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep, + // Relative paths from the synth dir (/packages/) to the + // app's React xcframeworks + codegen packages. Computed here because the + // synth's depth is fixed and it is regenerated every run — no runtime + // discovery needed in the manifest. + reactNativePackagePath: toPosix( + path.relative(wrapperDir, absXcframeworks), + ), + codegenPackagePath: toPosix( + path.relative( + wrapperDir, + path.join(appRoot, 'build', 'generated', 'ios'), + ), + ), + isDynamic: false, + targetPath: '.', + siblingSynthAbsolutePaths, + }); + + fs.writeFileSync( + path.join(wrapperDir, 'Package.swift'), + synthContent, + 'utf8', + ); + // Centralized headers tree at /headers//.h. + // Used two ways: + // * SPM-internal: cFlags add `-I /headers`, so cross-package + // angle includes like resolve. + // * Host app + sibling consumers: each wrapper's `include/` is a dir + // symlink to its slice of this tree, so `#import ` + // (e.g. ) resolves through SPM's + // publicHeadersPath propagation (-I .../packages//include). + const pkgHeadersDir = path.join(headersDir, target.name); + linkHeaderTree(absSource, pkgHeadersDir, skipDirNames); + + const includePath = path.join(wrapperDir, 'include'); + if (fs.existsSync(pkgHeadersDir)) { + ensureSymlink(includePath, pkgHeadersDir); + } else { + // Header-less package (rare): keep an empty dir so SPM's + // publicHeadersPath: "include" requirement is still satisfied. + fs.mkdirSync(includePath, {recursive: true}); + } + + log( + `Synth: packages/${target.name}/ → ${path.relative(appRoot, absSource)}`, + ); + + aggregatorPackageDeps.push({ + swiftName: target.name, + packagePath: `packages/${target.name}`, + }); + } + + // Prune stale wrappers + header dirs for entries no longer autolinked. + // Preserve both wrapper-managed and self-managed names; only entries that + // are no longer autolinked at all get removed. Note: `packages/` only has + // wrapper-managed names (self-managed deps live in their own source dirs), + // but `headers/` has both since we populate the central tree for everyone. + const activeNames /*: Set */ = new Set([ + ...wrapperDirs.keys(), + ...selfManagedDirs.keys(), + ]); + for (const subdir of ['packages', 'headers']) { + const dir = path.join(outputDir, subdir); + try { + const existing /*: Array<{name: string, isSymbolicLink(): boolean, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of existing) { + if (activeNames.has(entry.name)) continue; + const stale = path.join(dir, entry.name); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + fs.unlinkSync(stale); + } else { + fs.rmSync(stale, {recursive: true, force: true}); + } + log(`Removed stale ${subdir}/${entry.name}`); + } + } catch { + // dir doesn't exist – fine + } + } + + // Invoke discovered framework plugins now that the RN dep graph + autolinking + // data are final. Their package/product contributions merge into the + // aggregator below; this runs in add/update AND every build-time sync (both + // call main), so a plugin's contribution survives every regeneration. + let pluginPackageDeps /*: Array */ = []; + let pluginProductDeps /*: Array */ = []; + let pluginGeneratedSources /*: Array<{path: string}> */ = []; + let pluginFlavoredFrameworks /*: Array */ = []; + let pluginWatchPaths /*: Array */ = []; + if (discoveredPlugins.length > 0) { + // React-GeneratedCode is the per-app codegen package (referenced as + // `../ios` from outputDir). It may be absent (no codegen this run), so the + // descriptor only lists its products when it actually resolves. + const codegenPackageExists = fs.existsSync( + path.join(outputDir, '..', 'ios', 'Package.swift'), + ); + const result = invokePlugins( + discoveredPlugins, + { + appRoot, + projectRoot: findProjectRoot(appRoot), + reactNativeRoot: rnRoot, + autolinking: autolinkingData ?? {}, + outputDir, + react: reactDescriptor( + absXcframeworks, + xcframeworksRelPath, + codegenPackageExists, + ), + }, + {warn}, + ); + pluginPackageDeps = result.packageDependencies; + pluginProductDeps = result.productDependencies; + pluginGeneratedSources = result.generatedSources; + pluginFlavoredFrameworks = result.flavoredFrameworks; + pluginWatchPaths = result.watchPaths; + log( + `SPM plugins contributed ${pluginPackageDeps.length} package(s), ` + + `${pluginProductDeps.length} product(s), ` + + `${pluginGeneratedSources.length} generated source(s), ` + + `${pluginFlavoredFrameworks.length} flavored framework(s)`, + ); + } + + // Plugin sidecars. Both are ALWAYS written — even `[]` — so removing a + // plugin (or dropping its declaration) clears stale entries. Machine-local + // absolute paths; gitignored + regenerated every sync. + fs.mkdirSync(outputDir, {recursive: true}); + // Generated-source registration (e.g. Expo's ExpoModulesProvider.swift), + // consumed by the injector at add/update time — the provider ordering + // contract is still Preview and co-designed with the first consumer. + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-generated-sources.json'), + JSON.stringify(pluginGeneratedSources, null, 2) + '\n', + 'utf8', + ); + // Flavored-framework sidecar consumed by `spm add` / `spm update` when it + // regenerates the app's linker settings and sole embed phase. + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-flavored-frameworks.json'), + JSON.stringify(pluginFlavoredFrameworks, null, 2) + '\n', + 'utf8', + ); + + // Top-level aggregator: references every entry as .package(path:) and + // depends on each via .product(...). No more inline targets — every + // autolinked dep is a real SPM package in its own source dir. + const aggregatorContent = generateAutolinkedPackageSwift({ + npmDeps: aggregatorPackageDeps, + hasReactDep, + xcframeworksRelPath, + pluginPackageDeps, + pluginProductDeps, + }); + fs.mkdirSync(outputDir, {recursive: true}); + const outputPath = path.join(outputDir, 'Package.swift'); + fs.writeFileSync(outputPath, aggregatorContent, 'utf8'); + log(`Generated: ${path.relative(appRoot, outputPath)}`); + + // .spm-sync-watch-paths: absolute paths (dirs OR files) the Xcode auto-sync + // build phase watches for staleness. Three kinds of input, mixed freely: + // 1. Each module's source dir (entryAbsDirs) — a dir; adding/removing a + // child bumps its mtime so `find -newer` trips and the `sources:` + // allowlist regenerates. + // 2. Each npm dep's checked-in root `Package.swift` (a file) and its + // `.react-native/` metadata dir. Editing a manifest does NOT bump the + // source dir's mtime, so without these a manifest edit stays silently + // stale until an unrelated install triggers a sync. The dep ROOT is + // threaded through the entry (entry.root, from the autolinking model), + // not derived by walking up from the (possibly nested) source dir. + // 3. Plugin-contributed paths (e.g. Expo's own Package.swift / per-module + // manifests) — already validated absolute in invokePlugins. + // The phase distinguishes dir vs file with `-d`/`-f` at build time (no + // markers). The existsSync filter here is safe: these paths come from a + // successful sync so they exist now; a path that later VANISHES is caught at + // phase time against this file and forces a re-sync. + const watchCandidates /*: Array */ = [...entryAbsDirs.values()]; + for (const entry of entries) { + const root = entry.root; + if (entry.origin !== 'npm' || root == null) { + continue; + } + watchCandidates.push(path.join(root, 'Package.swift')); + watchCandidates.push(path.join(root, '.react-native')); + } + watchCandidates.push(...pluginWatchPaths); + const watchPaths = Array.from(new Set(watchCandidates)) + .filter(p => p.length > 0 && fs.existsSync(p)) + .sort(); + fs.writeFileSync( + path.join(outputDir, '.spm-sync-watch-paths'), + watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''), + 'utf8', + ); + + // AutolinkedAggregate is glue; needs at least one source file (Swift, so we + // sidestep the Obj-C public-headers-dir requirement). + const aggregateDir = path.join(outputDir, 'AutolinkedAggregate'); + fs.mkdirSync(aggregateDir, {recursive: true}); + const stubPath = path.join(aggregateDir, 'AutolinkedAggregate.swift'); + if (!fs.existsSync(stubPath)) { + fs.writeFileSync( + stubPath, + '// Placeholder. Real native modules live in transitively-referenced sub-packages.\n', + 'utf8', + ); + } + const legacyStub = path.join(aggregateDir, 'AutolinkedAggregate.m'); + if (fs.existsSync(legacyStub)) { + fs.unlinkSync(legacyStub); + } + + // One-time migration cleanup: remove the legacy /autolinked/ tree + // and any stale in-source `Package.swift` / `include//` from the + // prior in-place layout (those files lived in user source dirs and have + // been replaced by the wrapper layout under outputDir). + const legacyAutolinkedDir = path.join(appRoot, 'autolinked'); + if ( + fs.existsSync(legacyAutolinkedDir) && + path.resolve(legacyAutolinkedDir) !== path.resolve(outputDir) + ) { + fs.rmSync(legacyAutolinkedDir, {recursive: true, force: true}); + log(`Removed legacy autolinked/ tree`); + } + for (const absSource of entryAbsDirs.values()) { + const legacyPkg = path.join(absSource, 'Package.swift'); + let removedLegacyPkg = false; + try { + const content = fs.readFileSync(legacyPkg, 'utf8'); + if (content.includes(AUTOGEN_MARKER)) { + fs.unlinkSync(legacyPkg); + removedLegacyPkg = true; + log( + `Removed legacy in-place synth: ${path.relative(appRoot, legacyPkg)}`, + ); + } + } catch { + // not present – fine + } + // Only treat include/ as a legacy leftover when the in-place synth + // manifest was just removed alongside it: a SCAFFOLDED dep legitimately + // owns include// (the namespaced shim headers behind its + // publicHeadersPath: "include") — nuking it unconditionally would break + // every dependent's `#import ` on the next sync. + if (removedLegacyPkg) { + const legacyInclude = path.join(absSource, 'include'); + try { + if (fs.lstatSync(legacyInclude).isDirectory()) { + fs.rmSync(legacyInclude, {recursive: true, force: true}); + log( + `Removed legacy in-place include/: ${path.relative(appRoot, legacyInclude)}`, + ); + } + } catch { + // not present – fine + } + } + } +} + +if (require.main === module) { + try { + main(); + } catch (e) { + if (e instanceof RemoteVersionError) { + log(e.message); + process.exitCode = 2; + } else { + throw e; + } + } +} + +module.exports = { + main, + generateAutolinkedPackageSwift, + generateSynthPackageSwift, + reactDescriptor, + linkHeaderTree, + collectSpmSources, + expandSpmSourceGlobs, + findSelfManagedPackageDir, + hasPodspec, + hasMixedLanguageSources, + MissingManifestError, + reportMissingManifests, + AUTOGEN_MARKER, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-package.js b/packages/react-native/scripts/spm/generate-spm-package.js new file mode 100644 index 000000000000..813359cf0d46 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-package.js @@ -0,0 +1,302 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type {GeneratePackageArgs} from './spm-types'; */ + +/** + * generate-spm-package.js – Generates the xcframeworks sub-package for a + * React Native app using prebuilt XCFrameworks via Swift Package Manager. + * + * Usage: + * node generate-spm-package.js [options] + * + * Options: + * --app-root Path to the app directory (default: cwd) + * --react-native-root Path to react-native package root + * --version RN version for Maven artifact URLs + * --debug-artifacts-dir Complete Debug artifact cache slot + * --release-artifacts-dir Complete Release artifact cache slot + * --app-name App/package name (default: from package.json) + * --target-name Main app target name (default: derived from app-name) + * --source-path Path to app source relative to app-root (default: auto-detected) + * --ios-version Minimum iOS version (default: 15) + * + * Generates build/xcframeworks/Package.swift + symlinks. The xcodeproj + * references this sub-package directly; no separate app-level Package.swift + * is needed. + */ + +const {prepareFlavoredFrameworks} = require('./flavored-frameworks'); +const { + deriveAppName, + displayPath, + findProjectRoot, + makeLogger, + readPackageJson, + resolveReactNativeRoot, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log} = makeLogger('generate-spm-package'); + +function parseArgs(argv /*: Array */) /*: GeneratePackageArgs */ { + const parsed = yargs(argv) + .version(false) + .option('app-root', { + type: 'string', + default: process.cwd(), + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + describe: 'Path to react-native package root', + }) + .option('version', { + type: 'string', + describe: 'RN version for Maven artifact URLs', + }) + .option('debug-artifacts-dir', { + type: 'string', + describe: 'Path to the complete Debug artifacts cache slot', + }) + .option('release-artifacts-dir', { + type: 'string', + describe: 'Path to the complete Release artifacts cache slot', + }) + .option('app-name', { + type: 'string', + describe: 'App/package name (default: from package.json)', + }) + .option('target-name', { + type: 'string', + describe: 'Main app target name (default: derived from app-name)', + }) + .option('source-path', { + type: 'string', + describe: + 'Path to app source relative to app-root (default: auto-detected)', + }) + .option('ios-version', { + type: 'string', + default: '15', + describe: 'Minimum iOS version', + }) + .usage( + 'Usage: $0 [options]\n\nGenerates the xcframeworks sub-package for a React Native app using SPM.', + ) + .help() + .parseSync(); + + return { + appRoot: parsed['app-root'], + reactNativeRoot: parsed['react-native-root'] ?? null, + version: parsed.version ?? null, + debugArtifactsDir: parsed['debug-artifacts-dir'] ?? null, + releaseArtifactsDir: parsed['release-artifacts-dir'] ?? null, + appName: parsed['app-name'] ?? null, + targetName: parsed['target-name'] ?? null, + sourcePath: parsed['source-path'] ?? null, + iosVersion: parsed['ios-version'], + }; +} + +/** + * Find the app's main Swift/ObjC source directory. + * Looks for directories that contain native iOS source files. + */ +function findSourcePath( + appRoot /*: string */, + packageName /*: string */, +) /*: string */ { + // Derive from package name (e.g. "@react-native/tester" -> "Tester") + const derived = toSwiftName(packageName.replace(/^@[^/]+\//, '')); + + // Also check "RN" + derived (e.g. "Tester" -> "RNTester") and "RN" + whole name + const rnPrefixed = 'RN' + derived; + const candidates = [derived, rnPrefixed, 'ios', 'App', 'Sources', 'src']; + for (const c of candidates) { + if (fs.existsSync(path.join(appRoot, c))) { + return c; + } + } + + // Scan for a directory that looks like an iOS source root + // (contains .m, .mm, .swift, or .h files) + try { + const entries /*: Array<{name: string, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + fs.readdirSync(appRoot, {withFileTypes: true}); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const dirPath = path.join(appRoot, entry.name); + const subEntries = fs.readdirSync(dirPath); + const hasNativeSources = subEntries.some((f /*: string | Buffer */) => + /\.(m|mm|swift|cpp|h|hpp)$/.test(String(f)), + ); + if (hasNativeSources) { + return entry.name; + } + } + } catch (_) { + // ignore + } + + return derived; +} + +/** + * Generates the Package.swift for the xcframeworks sub-package. + * + * When using local xcframeworks (from the cache), we put the binary targets in + * a dedicated Package.swift at build/xcframeworks/. The generated .xcodeproj + * references this sub-package via XCLocalSwiftPackageReference; the codegen + * Package.swift also imports it as a named package dependency. + */ +function generateXCFrameworksPackageSwift() /*: string */ { + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-package.js – do not edit manually. +import PackageDescription + +let package = Package( + name: "ReactNative", + products: [ + .library(name: "ReactHeaders", targets: ["ReactHeaders"]), + .library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"]), + .library(name: "ReactNativeDependenciesHeaders", targets: ["ReactNativeDependenciesHeaders"]), + ], + targets: [ + .target( + name: "ReactHeaders", + dependencies: ["ReactNativeHeaders"], + path: "ReactHeadersTarget", + publicHeadersPath: "include" + ), + .binaryTarget( + name: "ReactNativeHeaders", + path: "ReactNativeHeaders.xcframework" + ), + .binaryTarget( + name: "ReactNativeDependenciesHeaders", + path: "ReactNativeDependenciesHeaders.xcframework" + ), + ] +) +`; +} + +function main( + argv /*:: ?: Array */, + dependencies /*:: ?: {prepareFlavoredFrameworks?: typeof prepareFlavoredFrameworks} */, +) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + // Ensure appRoot is always absolute so path.join/path.resolve produce absolute paths + // even when called with --app-root . or other relative paths. + const appRoot = path.resolve(args.appRoot); + + // Read app package.json + // package.json may be in a parent directory (e.g. when appRoot is ios/). + const projectRoot = findProjectRoot(appRoot); + const pkgJson = readPackageJson(projectRoot); + if (!pkgJson) { + throw new Error( + `[generate-spm-package] No package.json found in ${appRoot} or parent directories`, + ); + } + + let rnRoot = + args.reactNativeRoot != null + ? path.resolve(args.reactNativeRoot) + : resolveReactNativeRoot(appRoot, projectRoot); + if (rnRoot == null) { + throw new Error( + '[generate-spm-package] Could not find react-native. Pass --react-native-root.', + ); + } + + let version = args.version; + if (version == null) { + const rnPkg = readPackageJson(rnRoot); + version = rnPkg?.version ?? '0.0.0'; + } + + const rawName = pkgJson.name ?? path.basename(appRoot); + const sourcePath = args.sourcePath ?? findSourcePath(appRoot, rawName); + const appName = args.appName ?? deriveAppName(rawName, sourcePath); + const targetName = args.targetName ?? appName + 'App'; + + log(`App name: ${appName}`); + log(`Target name: ${targetName}`); + log(`Source path: ${sourcePath}`); + log(`Version: ${version}`); + + const debugArtifactsDir = args.debugArtifactsDir; + const releaseArtifactsDir = args.releaseArtifactsDir; + if ((debugArtifactsDir == null) !== (releaseArtifactsDir == null)) { + throw new Error( + '[generate-spm-package] both --debug-artifacts-dir and --release-artifacts-dir are required', + ); + } + if (debugArtifactsDir != null && releaseArtifactsDir != null) { + const prepare = + dependencies?.prepareFlavoredFrameworks ?? prepareFlavoredFrameworks; + prepare({ + appRoot, + debugArtifactsDir, + releaseArtifactsDir, + }); + const xcfwLinksDir = path.join(appRoot, 'build', 'xcframeworks'); + const xcfwPkgContent = generateXCFrameworksPackageSwift(); + const xcfwPkgPath = path.join(xcfwLinksDir, 'Package.swift'); + let packageChanged = true; + try { + packageChanged = fs.readFileSync(xcfwPkgPath, 'utf8') !== xcfwPkgContent; + } catch {} + if (packageChanged) { + fs.writeFileSync(xcfwPkgPath, xcfwPkgContent, 'utf8'); + } + log( + `${packageChanged ? 'Generated' : 'Unchanged'}: ${path.relative( + appRoot, + xcfwPkgPath, + )}`, + ); + log( + `Artifact slots: debug=${displayPath(debugArtifactsDir)}, ` + + `release=${displayPath(releaseArtifactsDir)}`, + ); + } else { + // Auto-detect an already-generated invariant package. + const xcfwLinksDir = path.join(appRoot, 'build', 'xcframeworks'); + if (fs.existsSync(path.join(xcfwLinksDir, 'Package.swift'))) { + log(`Auto-detected local xcframeworks: build/xcframeworks`); + } + } +} + +if (require.main === module) { + try { + main(); + } catch (e) { + console.error(e.message); + process.exitCode = 1; + } +} + +module.exports = { + main, + generateXCFrameworksPackageSwift, + findSourcePath, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js new file mode 100644 index 000000000000..f22fe26455e7 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -0,0 +1,2224 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager + * integration toolkit for an existing `.xcodeproj`. + * + * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build + * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to + * a user's existing project — purely additively, recording every edit in a + * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used + * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI. + */ + +const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); +const { + addArrayMembers, + addArrayStringValues, + ensureScalarField, + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, + insertObjectsIntoSection, + namespacedUUID, + quoteIfNeeded, + removeArrayMembersByUuid, + removeArrayStringValues, + removeDanglingJavaScriptCoreRef, + removeEmptyPodsGroup, + removeField, + removeObjectByUuid, + serializeEntry, + setScalarField, +} = require('./spm-pbxproj'); +const {makeLogger, remotePackageConfig} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +/*:: import type { + FlavoredFrameworkManifestEntry, + XcframeworkSlice, +} from './spm-types'; */ + +const {log} = makeLogger('generate-spm-xcodeproj'); + +// Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in +// place. Records the host project's root UUID + every edit so `spm deinit` +// (removeSpmInjection) can surgically revert and re-runs stay idempotent. +const SPM_INJECTED_MARKER = '.spm-injected.json'; + +// Manifest of plugin-contributed sources that must COMPILE INTO THE APP TARGET +// (e.g. Expo's ExpoModulesProvider.swift — an `@objc` class only reaches the +// ObjC classlist, and so is discoverable via NSClassFromString, when it +// compiles into the app target, NOT the static Autolinked aggregate). Written +// by generate-spm-autolinking.js (the plugin merge) BEFORE setupXcodeproj runs +// on both `add` and `update`, so the injector reads it synchronously. Path is +// relative to the app root (== SRCROOT / the .xcodeproj's dir). +const SPM_GENERATED_SOURCES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-generated-sources.json', +); + +// The single navigator group all injected generated sources are parented under +// (created on first use). Its namespacedUUID id + display name. +const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources'; +const SPM_GENERATED_SOURCES_GROUP_NAME = 'SPM Generated Sources'; + +// pbxproj `lastKnownFileType` per source extension. v1 plugins emit Swift only; +// .m/.mm are mapped as future-proofing (the plugin contract permits ObjC/ObjC++ +// sources). An unmapped extension is skipped with a loud log. +const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = { + '.swift': 'sourcecode.swift', + '.m': 'sourcecode.c.objc', + '.mm': 'sourcecode.cpp.objcpp', +}; + +// Maps each SPM product to its sub-package path (relative to app root). +// The xcodeproj must reference each sub-package directly so Xcode can +// resolve the product dependencies — SPM doesn't expose transitive products. +const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ = + [ + { + product: 'ReactHeaders', + packagePath: 'build/xcframeworks', + packageName: 'ReactNative', + }, + { + product: 'ReactNativeHeaders', + packagePath: 'build/xcframeworks', + packageName: 'ReactNative', + }, + { + product: 'ReactNativeDependenciesHeaders', + packagePath: 'build/xcframeworks', + packageName: 'ReactNative', + }, + { + product: 'Autolinked', + packagePath: 'build/generated/autolinking', + packageName: 'Autolinked', + }, + { + product: 'ReactCodegen', + packagePath: 'build/generated/ios', + packageName: 'React-GeneratedCode', + }, + { + product: 'ReactAppDependencyProvider', + packagePath: 'build/generated/ios', + packageName: 'React-GeneratedCode', + }, + ]; + +/*:: +type RemoteCfg = {url: string, version: string, identity: string}; +// Precise record of the build-setting edits injection made to ONE build config, +// so deinit can reverse exactly those (and nothing the user already had). +type BuildSettingChange = { + configUuid: string, + createdArrayKeys: Array, + appendedArrayValues: {[string]: Array}, + createdScalars: Array, + // Scalars whose pre-injection value was replaced (key → original raw + // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once + // CocoaPods is deintegrated. Deinit restores the original. + replacedScalars?: {[string]: string}, +}; +// A plugin-contributed source, normalized for pbxproj emission. `path` is +// SRCROOT-relative when under the app root, else absolute; `sourceTree` is the +// matching pbxproj token ('SOURCE_ROOT' or '""'). +type GeneratedSource = {path: string, name: string, sourceTree: string, fileType: string}; +type SpmGraph = { + uniquePackages: Array<{packagePath: string, packageName: string}>, + localPkgRefs: Array<{uuid: string, packagePath: string, comment: string}>, + remotePkgRef: ?{uuid: string, url: string, version: string, identity: string, comment: string}, + products: Array<{product: string, depUuid: string, buildFileUuid: string, pkgRefUuid: string, refComment: string}>, +}; +*/ + +/** + * Resolve the SPM dependency graph (package references + product + * dependencies + their frameworks build files) from SPM_PRODUCT_PACKAGES. + * `mkUuid(section, id)` supplies UUIDs, seeded with the host project's root + * UUID so injected IDs are stable across re-runs and collision-safe. + */ +function buildSpmDependencyGraph( + mkUuid /*: (section: string, id: string) => string */, + remote /*: ?RemoteCfg */, +) /*: SpmGraph */ { + // Remote mode: ReactNative-family products move to the remote package. + const productPackages = SPM_PRODUCT_PACKAGES.map(e => + remote != null && e.packagePath === 'build/xcframeworks' + ? {...e, packagePath: 'REMOTE', packageName: remote.identity} + : e, + ); + const uniquePackages = Array.from( + new Map( + productPackages + .filter(e => e.packagePath !== 'REMOTE') + .map(e => [ + e.packagePath, + {packagePath: e.packagePath, packageName: e.packageName}, + ]), + ).values(), + ); + const localPkgRefs = uniquePackages.map(pkg => ({ + uuid: mkUuid('XCLocalSwiftPackageReference', pkg.packagePath), + packagePath: pkg.packagePath, + comment: `XCLocalSwiftPackageReference "${pkg.packagePath}"`, + })); + const remotePkgRef = + remote != null + ? { + uuid: mkUuid('XCRemoteSwiftPackageReference', remote.url), + url: remote.url, + version: remote.version, + identity: remote.identity, + comment: `XCRemoteSwiftPackageReference "${remote.identity}"`, + } + : null; + const localByPath = new Map(localPkgRefs.map(r => [r.packagePath, r])); + const products = productPackages.map(entry => { + const {product, packagePath} = entry; + const isRemote = packagePath === 'REMOTE' && remotePkgRef != null; + const pkgRefUuid = isRemote + ? // $FlowFixMe[incompatible-use] guarded by isRemote + remotePkgRef.uuid + : // $FlowFixMe[incompatible-use] every non-REMOTE path is in localByPath + localByPath.get(packagePath).uuid; + const refComment = isRemote + ? // $FlowFixMe[incompatible-use] guarded by isRemote + `XCRemoteSwiftPackageReference "${remotePkgRef.identity}"` + : `XCLocalSwiftPackageReference "${packagePath}"`; + return { + product, + depUuid: mkUuid('XCSwiftPackageProductDependency', product), + buildFileUuid: mkUuid('PBXBuildFile', `spm:${product}`), + pkgRefUuid, + refComment, + }; + }); + return {uniquePackages, localPkgRefs, remotePkgRef, products}; +} + +/** + * Render the SPM graph into pbxproj section entry objects the in-place injector + * splices into an existing project. + */ +/*:: type PbxEntryT = {uuid: string, comment: string, fields: {[string]: string}}; */ + +function spmGraphToEntries( + graph /*: SpmGraph */, +) /*: {localRefs: Array, remoteRef: ?PbxEntryT, productDeps: Array, buildFiles: Array} */ { + const localRefs /*: Array */ = graph.localPkgRefs.map(ref => ({ + uuid: ref.uuid, + comment: ref.comment, + fields: { + isa: 'XCLocalSwiftPackageReference', + relativePath: quoteIfNeeded(ref.packagePath), + }, + })); + const remote = graph.remotePkgRef; + const remoteRef /*: ?PbxEntryT */ = + remote != null + ? { + uuid: remote.uuid, + comment: remote.comment, + fields: { + isa: 'XCRemoteSwiftPackageReference', + repositoryURL: quoteIfNeeded(remote.url), + requirement: `{\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = "${remote.version}";\n\t\t\t}`, + }, + } + : null; + const productDeps /*: Array */ = graph.products.map(p => ({ + uuid: p.depUuid, + comment: p.product, + fields: { + isa: 'XCSwiftPackageProductDependency', + package: `${p.pkgRefUuid} /* ${p.refComment} */`, + productName: quoteIfNeeded(p.product), + }, + })); + const buildFiles /*: Array */ = graph.products.map(p => ({ + uuid: p.buildFileUuid, + comment: `${p.product} in Frameworks`, + fields: { + isa: 'PBXBuildFile', + productRef: `${p.depUuid} /* ${p.product} */`, + }, + })); + return {localRefs, remoteRef, productDeps, buildFiles}; +} + +// Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by +// the build phase (safety net) and the scheme pre-action (the one that +// actually fires before SPM resolution, so a single build picks up +// dep-graph changes from `npm install`). +// Build a PBXShellScriptBuildPhase entry (the "Sync SPM Autolinking" phase). +function shellScriptPhase( + phaseUUID /*: string */, + name /*: string */, + script /*: string */, + options /*: {inputPaths?: string, outputPaths?: string} */ = {}, +) /*: {uuid: string, comment: string, fields: {[string]: string}} */ { + const empty = '(\n\t\t\t)'; + return { + uuid: phaseUUID, + comment: name, + fields: { + isa: 'PBXShellScriptBuildPhase', + buildActionMask: '2147483647', + files: empty, + inputFileListPaths: empty, + inputPaths: options.inputPaths ?? empty, + name: quoteIfNeeded(name), + outputFileListPaths: empty, + outputPaths: options.outputPaths ?? empty, + runOnlyForDeploymentPostprocessing: '0', + shellPath: '/bin/sh', + shellScript: quoteIfNeeded(script), + }, + }; +} + +function frameworkSettingPrefix(id /*: string */) /*: string */ { + return `RN_SPM_${id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`; +} + +function flavorForBuildConfiguration( + configurationName /*: string */, +) /*: 'debug' | 'release' */ { + const lower = configurationName.toLowerCase(); + return lower.includes('debug') || lower.includes('development') + ? 'debug' + : 'release'; +} + +function buildConfigurationName( + text /*: string */, + configUuid /*: string */, +) /*: string */ { + const config = findObjectByUuid(text, configUuid); + const name = config != null ? findField(text, config, 'name') : null; + if (name == null) { + throw new Error(`pbxproj: build configuration ${configUuid} has no name`); + } + return name.value.replace(/^"|"$/g, ''); +} + +function frameworkConditionalSettings( + frameworks /*: ReadonlyArray */, +) /*: Array<{key: string, value: string}> */ { + const settings /*: Array<{key: string, value: string}> */ = []; + for (const framework of frameworks) { + const prefix = frameworkSettingPrefix(framework.id); + const bySdk /*: Map> */ = new Map(); + // The injected target is an Apple mobile/Catalyst application. Native + // macOS slices share `sdk=macosx*` with Catalyst and cannot be + // distinguished by an XCBuildConfiguration condition, so use the Catalyst + // slice and leave native-mac packaging out of this iOS integration. + for (const slice of framework.slices.filter( + candidate => candidate.platform !== 'macos', + )) { + const existing = bySdk.get(slice.sdk) ?? []; + existing.push(slice); + bySdk.set(slice.sdk, existing); + } + for (const [sdk, slices] of bySdk) { + const emit = (slice /*: XcframeworkSlice */, condition /*: string */) => { + const root = + `$(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/` + + `${framework.artifactRelativePath}/${slice.libraryIdentifier}`; + settings.push( + { + key: quoteIfNeeded(`${prefix}_FRAMEWORK${condition}`), + value: quoteIfNeeded(`${root}/${slice.libraryPath}`), + }, + { + key: quoteIfNeeded(`${prefix}_BINARY${condition}`), + value: quoteIfNeeded(`${root}/${slice.binaryPath}`), + }, + { + key: quoteIfNeeded(`${prefix}_SEARCH_PATH${condition}`), + value: quoteIfNeeded(root), + }, + ); + }; + if (slices.length === 1) { + emit(slices[0], `[sdk=${sdk}]`); + continue; + } + const seenArchitectures /*: Set */ = new Set(); + for (const slice of slices) { + for (const architecture of slice.architectures) { + if (seenArchitectures.has(architecture)) { + throw new Error( + `${framework.frameworkName} has ambiguous ${sdk}/${architecture} slices`, + ); + } + seenArchitectures.add(architecture); + emit(slice, `[sdk=${sdk}][arch=${architecture}]`); + } + } + } + } + return settings; +} + +function frameworkArrayBuildSettings( + frameworks /*: ReadonlyArray */, +) /*: Array<{key: string, values: Array}> */ { + return [ + { + key: 'OTHER_LDFLAGS', + values: [ + '"-ObjC"', + ...frameworks.map( + framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`, + ), + ], + }, + { + key: 'FRAMEWORK_SEARCH_PATHS', + values: frameworks.map( + framework => `"$(${frameworkSettingPrefix(framework.id)}_SEARCH_PATH)"`, + ), + }, + { + key: 'LD_RUNPATH_SEARCH_PATHS', + values: ['"@executable_path/Frameworks"'], + }, + ]; +} + +function pbxPathList(paths /*: ReadonlyArray */) /*: string */ { + if (paths.length === 0) { + return '(\n\t\t\t)'; + } + return `(\n${paths + .map(value => `\t\t\t\t${quoteIfNeeded(value)},\n`) + .join('')}\t\t\t)`; +} + +function buildEmbedFrameworksScript( + frameworks /*: ReadonlyArray */, +) /*: string */ { + const validations = frameworks + .map(framework => { + const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`; + return `validate_framework "\${${variable}:-}" "${framework.frameworkName}.framework"`; + }) + .join('\n'); + const copies = frameworks + .map(framework => { + const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`; + return `copy_and_sign "\${${variable}:-}" "${framework.frameworkName}.framework"`; + }) + .join('\n'); + return `set -euo pipefail + +destination="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH" +mkdir -p "$destination" + +validate_framework() { + source="$1" + name="$2" + if [ -z "$source" ] || [ ! -d "$source" ]; then + echo "error: React Native SwiftPM framework '$name' is unavailable for configuration '$CONFIGURATION' and SDK '$SDK_NAME': $source" + exit 1 + fi + binary="\${name%.framework}" + if [ ! -e "$source/$binary" ] && [ ! -e "$source/Versions/Current/$binary" ]; then + echo "error: React Native SwiftPM framework '$name' is invalid for configuration '$CONFIGURATION': expected $source/$binary or $source/Versions/Current/$binary" + exit 1 + fi +} + +copy_and_sign() { + source="$1" + name="$2" + /usr/bin/rsync -a --delete "$source/" "$destination/$name/" + if [ "\${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]; then + identity="\${EXPANDED_CODE_SIGN_IDENTITY:--}" + if [ "$identity" = "-" ]; then + /usr/bin/codesign --force --sign - --timestamp=none --preserve-metadata=identifier,entitlements,flags "$destination/$name" + else + /usr/bin/codesign --force --sign "$identity" --preserve-metadata=identifier,entitlements,flags "$destination/$name" + fi + fi +} + +${validations} +${copies} +`; +} + +function addBuildPhaseAfter( + text /*: string */, + target /*: {bodyOpen: number, bodyClose: number, ...} */, + afterUuid /*: string */, + member /*: {uuid: string, comment: string} */, +) /*: string */ { + const field = findField(text, target, 'buildPhases'); + if (field == null || field.value.includes(member.uuid)) { + return text; + } + const after = new RegExp(`(^|\\n)([\\t ]*)${afterUuid}\\b[^\\n]*,`).exec( + field.value, + ); + if (after == null) { + return addArrayMembers(text, target, 'buildPhases', [member]); + } + const absoluteStart = field.valueStart + after.index; + const lineEnd = text.indexOf('\n', absoluteStart + after[0].length); + const indent = after[2]; + const line = `\n${indent}${member.uuid} /* ${member.comment} */,`; + return text.slice(0, lineEnd) + line + text.slice(lineEnd); +} + +// The node + react-native-dir resolution preamble shared by the sync build +// phase and scheme pre-action. Both dispatch DIRECTLY into react-native's +// scripts rather than through 'npx react-native' — that CLI requires +// @react-native-community/cli (absent in e.g. Expo apps), so it would exit +// non-zero and the failure would be silently swallowed. +function nodeAndRnDirPreamble(reactNativePath /*: string */) /*: string */ { + return `set -euo pipefail + +# --------------------------------------------------------------------------- +# Resolve a node binary and the react-native package dir at BUILD TIME. +# --------------------------------------------------------------------------- +NODE_BINARY="\${NODE_BINARY:-}" +if [ -z "$NODE_BINARY" ]; then + # Source RN's standard app-local node-path files. They reference vars that + # may be unset and may return non-zero, so relax nounset AND errexit while + # sourcing — a buggy user .xcode.env must degrade to PATH-based node + # resolution below, not silently abort every build. + set +eu + if [ -f "$SRCROOT/.xcode.env" ]; then + . "$SRCROOT/.xcode.env" + fi + if [ -f "$SRCROOT/.xcode.env.local" ]; then + . "$SRCROOT/.xcode.env.local" + fi + set -eu + NODE_BINARY="\${NODE_BINARY:-}" +fi +if [ -z "$NODE_BINARY" ]; then + NODE_BINARY="$(command -v node 2>/dev/null || true)" +fi + +# Resolve react-native's dir FROM THE APP (require.resolve), not a +# generation-time baked path — the baked path goes stale in pnpm / hoisted +# stores. Fall back to the baked path if resolution fails or the resolved dir +# has no setup-apple-spm.js. +RN_DIR="" +if [ -n "$NODE_BINARY" ]; then + RN_DIR="$(cd "$SRCROOT" && "$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json'))" 2>/dev/null || true)" +fi +if [ -z "$RN_DIR" ] || [ ! -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then + RN_DIR="${reactNativePath}" +fi`; +} + +// Shared: the STALE-input check + conditional codegen/autolinking sync dispatch. +// Runtime framework slots are never touched here; add/update owns them. +function syncStaleCheckAndDispatch() /*: string */ { + return `STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp" +STALE=0 + +# Find project root (where package.json lives — may be an ancestor of SRCROOT) +PROJECT_ROOT="$SRCROOT" +while [ "$PROJECT_ROOT" != "/" ] && [ ! -f "$PROJECT_ROOT/package.json" ]; do + PROJECT_ROOT="$(dirname "$PROJECT_ROOT")" +done +if [ ! -f "$PROJECT_ROOT/package.json" ]; then + PROJECT_ROOT="$SRCROOT" +fi + +# Check 1: dependency inputs (covers app projects after any package manager install) +for INPUT in \\ + "$PROJECT_ROOT/package.json" \\ + "$PROJECT_ROOT/react-native.config.js"; do + if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then + STALE=1 + break + fi +done + +# Check workspace lockfiles and package-manager metadata. These cover package +# managers that do not reliably bump node_modules mtimes, and Yarn PnP projects +# that do not have node_modules at all. +if [ "$STALE" -eq 0 ]; then + DIR="$PROJECT_ROOT" + while [ "$DIR" != "/" ]; do + for INPUT in \\ + "$DIR/package-lock.json" \\ + "$DIR/npm-shrinkwrap.json" \\ + "$DIR/yarn.lock" \\ + "$DIR/pnpm-lock.yaml" \\ + "$DIR/bun.lock" \\ + "$DIR/bun.lockb" \\ + "$DIR/.pnp.cjs" \\ + "$DIR/.pnp.loader.mjs"; do + if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then + STALE=1 + break + fi + done + if [ "$STALE" -eq 1 ]; then + break + fi + DIR="$(dirname "$DIR")" + done +fi + +# Check node_modules mtime. In monorepos, node_modules may be hoisted to any +# ancestor between the app package and the workspace root. +if [ "$STALE" -eq 0 ]; then + DIR="$PROJECT_ROOT" + while [ "$DIR" != "/" ]; do + NM_DIR="$DIR/node_modules" + if [ -d "$NM_DIR" ] && [ "$NM_DIR" -nt "$STAMP" ]; then + STALE=1 + break + fi + DIR="$(dirname "$DIR")" + done +fi + +# Also check the app root directly when SRCROOT is not the package root. +if [ "$STALE" -eq 0 ] && [ "$SRCROOT" != "$PROJECT_ROOT" ]; then + if [ -d "$SRCROOT/node_modules" ] && [ "$SRCROOT/node_modules" -nt "$STAMP" ]; then + STALE=1 + fi +fi + +# Check 1.5: watched paths (mixed dirs AND files). Dirs catch add/remove of +# source files in spm.modules and autolinked deps (dir mtime updates on both); +# files catch edits to a dep's checked-in Package.swift / plugin manifests that +# would not bump any parent dir mtime. A path that has VANISHED (renamed/moved +# module root) forces a re-sync so the autolinker surfaces the real, actionable +# config error rather than the build failing later on dangling-symlink noise. +WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths" +if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then + while IFS= read -r P; do + [ -z "$P" ] && continue + if [ -d "$P" ]; then + if [ -n "$(find "$P" -newer "$STAMP" -print -quit 2>/dev/null)" ]; then + STALE=1 + break + fi + elif [ -f "$P" ]; then + if [ "$P" -nt "$STAMP" ]; then + STALE=1 + break + fi + else + STALE=1 + break + fi + done < "$WATCH_FILE" +fi + +# Check 2: codegen spec files changed via git (covers monorepo after git pull) +if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then + STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) + LATEST_SPEC_COMMIT=$(git -C "$SRCROOT" log -1 --format=%ct -- '*.js' '*.ts' 2>/dev/null || echo 0) + if [ "$LATEST_SPEC_COMMIT" -gt "$STAMP_TIME" ]; then + STALE=1 + fi +fi + +if [ ! -f "$STAMP" ]; then + STALE=1 +fi + +# Re-sync codegen + autolinking when a dependency input changed. Runtime +# framework slots and Xcode linker settings are only changed by spm update. +if [ "$STALE" -eq 1 ]; then + echo "SPM sync inputs changed — re-syncing (codegen + autolinking)..." + + WITH_ENVIRONMENT="$RN_DIR/scripts/xcode/with-environment.sh" + + if [ -f "$WITH_ENVIRONMENT" ]; then + # with-environment.sh references PODS_ROOT and $1, which may be unset. + # Temporarily disable nounset to avoid failures when sourcing. + export PODS_ROOT="\${PODS_ROOT:-$SRCROOT}" + set +u + . "$WITH_ENVIRONMENT" + set -u + fi + + cd "$SRCROOT" + # \`|| RC=$?\` so a non-zero exit is CAPTURED rather than aborting the phase + # under \`set -e\` — the whole point is to branch on the code below (2 = fail + # the build with a scaffold hint; other non-zero = warn but don't break). + RC=0 + if [ -n "$NODE_BINARY" ] && [ -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then + # Direct, dependency-free dispatch (no \`npx react-native\`, which needs + # @react-native-community/cli). + "$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync || RC=$? + elif command -v npx >/dev/null 2>&1; then + npx react-native spm sync || RC=$? + else + echo "warning: node/npx not found — skipping SPM sync" + fi + if [ "$RC" -eq 2 ]; then + # Exit 2 = an autolinked community dependency has no Package.swift. The + # autolinker already printed an \`error:\` line per dep (so Xcode shows them + # and the fix). Fail the build — the developer must run + # \`npx react-native spm scaffold\` from a terminal to generate the manifest. + exit 1 + elif [ "$RC" -ne 0 ]; then + echo "warning: SPM sync failed — build may use stale codegen/autolinking" + fi +fi +`; +} + +// Scheme pre-action: re-run codegen + autolinking before package resolution. +function buildSchemePreActionScript( + reactNativePath /*: string */, +) /*: string */ { + return `${nodeAndRnDirPreamble(reactNativePath)} + +${syncStaleCheckAndDispatch()} +`; +} + +// The in-target phase is only an autolinking safety net. Runtime framework +// selection is expressed entirely through build settings and the independent +// Embed React Native Flavored Frameworks phase. +function buildSyncAutolinkingScript( + reactNativePath /*: string */, +) /*: string */ { + return `${nodeAndRnDirPreamble(reactNativePath)} + +${syncStaleCheckAndDispatch()} +`; +} +// XML-attribute escape (the five named entities). The sync script uses `>` +// and `&` for redirection and bg/and chains, plus `<` for heredocs and +// comparisons — all of which break Xcode's scheme parser if left raw. +function escapeXmlAttribute(s /*: string */) /*: string */ { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function generateXcscheme( + appName /*: string */, + targetUUID /*: string */, + projName /*: string */, + syncScript /*: string */, +) /*: string */ { + const escapedSync = escapeXmlAttribute(syncScript); + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +} + +// When the xcodeproj is generated, the referenced SPM package directories +// (build/xcframeworks, autolinked, build/generated/ios) may not exist yet. +// Xcode resolves packages before any build phase runs, so we write minimal +// stub Package.swift files to let resolution succeed. The real generators +// (sync-spm-autolinking.js) overwrite these during the first build. + +/*:: +type StubPackageDef = { + packageName: string, + products: Array, +}; +*/ + +function generateStubPackageSwift(def /*: StubPackageDef */) /*: string */ { + const {packageName, products} = def; + const stubTarget = `${packageName.replace(/[^a-zA-Z0-9]/g, '')}Stub`; + const productLines = products + .map(p => ` .library(name: "${p}", targets: ["${stubTarget}"]),`) + .join('\n'); + return `// swift-tools-version: 5.9 +// GENERATED STUB — will be overwritten by sync-spm-autolinking.js during build. +import PackageDescription + +let package = Package( + name: "${packageName}", + products: [ +${productLines} + ], + targets: [ + .target(name: "${stubTarget}", path: "_stub", sources: ["Stub.swift"]), + ] +) +`; +} + +/** + * Ensures each referenced SPM sub-package directory has a valid Package.swift + * so Xcode can resolve packages before any build phase runs. + * Skips directories that already contain a Package.swift (from a previous build). + */ +function ensureStubPackages(appRoot /*: string */) /*: void */ { + // Derive stub definitions from SPM_PRODUCT_PACKAGES + const byPath = new Map /*:: */(); + for (const entry of SPM_PRODUCT_PACKAGES) { + const existing = byPath.get(entry.packagePath); + if (existing != null) { + existing.products.push(entry.product); + } else { + byPath.set(entry.packagePath, { + packageName: entry.packageName, + products: [entry.product], + }); + } + } + + for (const [relPath, def] of byPath) { + const pkgDir = path.join(appRoot, relPath); + const pkgSwiftPath = path.join(pkgDir, 'Package.swift'); + + if (fs.existsSync(pkgSwiftPath)) { + continue; + } + + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(pkgSwiftPath, generateStubPackageSwift(def), 'utf8'); + + // Create minimal stub source file required by SPM + const stubDir = path.join(pkgDir, '_stub'); + fs.mkdirSync(stubDir, {recursive: true}); + const stubSwift = path.join(stubDir, 'Stub.swift'); + if (!fs.existsSync(stubSwift)) { + fs.writeFileSync( + stubSwift, + '// Placeholder — replaced during first build.\n', + 'utf8', + ); + } + + log(`Wrote stub Package.swift: ${relPath}/Package.swift`); + } +} + +// --------------------------------------------------------------------------- +// In-place injection: add SPM packages to a user's EXISTING xcodeproj. +// +// This never creates a target or scans sources — it splices the SPM dependency +// graph, the React build settings, and the sync build phase / scheme pre-action +// into the project the user already owns, leaving everything else +// byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so +// hand-tuned signing / capabilities / extra targets survive. Fails loud (the +// caller surfaces the error) when the project is CocoaPods-integrated or its +// shape can't be safely anchored. +// --------------------------------------------------------------------------- + +// The React build settings the app target needs to compile against the SPM +// products. +const INJECTED_ARRAY_SETTINGS = [ + { + key: 'HEADER_SEARCH_PATHS', + values: ['"$(SRCROOT)/build/generated/autolinking/headers"'], + }, +]; + +/** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ +function targetBuildConfigUuids( + text /*: string */, + targetObj /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: Array */ { + const listField = findField(text, targetObj, 'buildConfigurationList'); + if (listField == null) { + return []; + } + const listMatch = listField.value.match(/[0-9A-Fa-f]{24}/); + if (listMatch == null) { + return []; + } + const listObj = findObjectByUuid(text, listMatch[0]); + if (listObj == null) { + return []; + } + const configs = findField(text, listObj, 'buildConfigurations'); + if (configs == null) { + return []; + } + const matches = configs.value.match(/[0-9A-Fa-f]{24}/g); + return matches != null ? Array.from(matches) : []; +} + +/** True when a build config layers a CocoaPods `Pods-*.xcconfig`. */ +function configUsesPods( + text /*: string */, + configUuid /*: string */, +) /*: boolean */ { + const obj = findObjectByUuid(text, configUuid); + if (obj == null) { + return false; + } + const base = findField(text, obj, 'baseConfigurationReference'); + return base != null && /Pods[-/]/.test(base.value); +} + +/** + * Inspect an existing pbxproj and decide whether it can be injected. Returns + * the chosen app target + its config/frameworks anchors, or a refusal reason + * the caller surfaces (fail-loud). + */ +function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: + | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} + | {ok: false, reason: string} */ { + const project = findProjectObject(text); + if (project == null) { + return {ok: false, reason: 'no PBXProject object found'}; + } + const apps = findApplicationTargets(text); + if (apps.length === 0) { + return {ok: false, reason: 'no application target found'}; + } + let target; + if (apps.length === 1) { + target = apps[0]; + } else { + const appName = opts.appName; + if (appName == null) { + return { + ok: false, + reason: `multiple application targets (${apps + .map(a => a.name) + .join(', ')}); pass --app-name to disambiguate`, + }; + } + target = apps.find(a => a.name === appName); + if (target == null) { + return { + ok: false, + reason: `no application target named "${appName}"`, + }; + } + } + const configUuids = targetBuildConfigUuids(text, target); + if (configUuids.length === 0) { + return {ok: false, reason: 'could not resolve target build configurations'}; + } + if (configUuids.some(c => configUsesPods(text, c))) { + return { + ok: false, + reason: + 'target uses CocoaPods (Pods-*.xcconfig) — in-place injection only ' + + 'supports SPM-only targets', + }; + } + // The target's own Frameworks build phase (where product build files link). + const buildPhases = findField(text, target, 'buildPhases'); + const phaseUuids = + buildPhases != null + ? (buildPhases.value.match(/[0-9A-Fa-f]{24}/g) ?? []) + : []; + let frameworksPhaseUuid = null; + // Also capture the Sources phase — plugin generated sources compile into it + // (see injectSpmIntoPbxproj step 8). Nullable: a target may legitimately + // lack one, in which case generated-source wiring is skipped (not fatal). + let sourcesPhaseUuid = null; + for (const pu of phaseUuids) { + const po = findObjectByUuid(text, pu); + if (po == null) { + continue; + } + const isa = findField(text, po, 'isa'); + if (isa == null) { + continue; + } + if ( + frameworksPhaseUuid == null && + /PBXFrameworksBuildPhase/.test(isa.value) + ) { + frameworksPhaseUuid = pu; + } else if ( + sourcesPhaseUuid == null && + /PBXSourcesBuildPhase/.test(isa.value) + ) { + sourcesPhaseUuid = pu; + } + } + if (frameworksPhaseUuid == null) { + return {ok: false, reason: 'target has no Frameworks build phase'}; + } + return { + ok: true, + rootUuid: project.uuid, + target, + configUuids, + frameworksPhaseUuid, + sourcesPhaseUuid, + }; +} + +/** + * Splice the SPM dependency graph + React build settings + sync build phase + * into `text` and return the modified pbxproj. Pure string transform (no I/O), + * idempotent: objects already present (by UUID) and array members / settings + * already applied are skipped, so a second run is a no-op. + */ +function injectSpmIntoPbxproj( + input /*: string */, + plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, + reactNativePath /*: string */, + remote /*: ?RemoteCfg */, + hermesCliPath /*: ?string */ = null, + generatedSources /*: ReadonlyArray */ = [], + flavoredFrameworks /*: ReadonlyArray */ = [], +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}} */ { + let text = input; + const mkUuid = (section /*: string */, id /*: string */) => + namespacedUUID(plan.rootUuid, section, id); + const graph = buildSpmDependencyGraph(mkUuid, remote); + const entries = spmGraphToEntries(graph); + const injectedUuids /*: Array */ = []; + + // 1. Insert the new objects (skip any UUID already present — idempotency). + const insertObjects = ( + sectionName /*: string */, + objs /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...}> */, + ) => { + const fresh = objs.filter(o => !text.includes(o.uuid)); + for (const o of objs) { + injectedUuids.push(o.uuid); + } + if (fresh.length === 0) { + return; + } + text = insertObjectsIntoSection( + text, + sectionName, + fresh.map(serializeEntry).join('\n'), + ); + }; + insertObjects('XCLocalSwiftPackageReference', entries.localRefs); + if (entries.remoteRef != null) { + insertObjects('XCRemoteSwiftPackageReference', [entries.remoteRef]); + } + insertObjects('XCSwiftPackageProductDependency', entries.productDeps); + insertObjects('PBXBuildFile', entries.buildFiles); + + // Track array fields we CREATE (vs. append to a pre-existing one) so deinit + // can remove the whole field and land byte-identical to the original. + const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ = + []; + + // 2. packageReferences on the PBXProject. + const pkgRefMembers = [ + ...(graph.remotePkgRef != null + ? [{uuid: graph.remotePkgRef.uuid, comment: graph.remotePkgRef.comment}] + : []), + ...graph.localPkgRefs.map(r => ({uuid: r.uuid, comment: r.comment})), + ]; + const project = findProjectObject(text); + if (project != null) { + if (findField(text, project, 'packageReferences') == null) { + createdArrayFields.push({container: 'project', key: 'packageReferences'}); + } + text = addArrayMembers(text, project, 'packageReferences', pkgRefMembers); + } + + // 3. packageProductDependencies on the app target. + const productMembers = graph.products.map(p => ({ + uuid: p.depUuid, + comment: p.product, + })); + if ( + findField( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'packageProductDependencies', + ) == null + ) { + createdArrayFields.push({ + container: 'target', + key: 'packageProductDependencies', + }); + } + text = addArrayMembers( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'packageProductDependencies', + productMembers, + ); + + // 4. product build files into the target's Frameworks phase. + const phase = findObjectByUuid(text, plan.frameworksPhaseUuid); + if (phase != null) { + text = addArrayMembers( + text, + phase, + 'files', + graph.products.map(p => ({ + uuid: p.buildFileUuid, + comment: `${p.product} in Frameworks`, + })), + ); + } + + // 5. React build settings into every build config (Debug + Release). + const buildSettingChanges /*: Array */ = []; + for (const configUuid of plan.configUuids) { + const merged = mergeReactBuildSettings( + text, + configUuid, + buildConfigurationName(text, configUuid), + reactNativePath, + hermesCliPath, + flavoredFrameworks, + ); + text = merged.text; + buildSettingChanges.push(merged.change); + } + + // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action + // is what fires before SPM resolution). Prepended so it runs before + // Sources. We do NOT add a JS-bundle phase — an existing app already + // bundles JS via its own phase. + const syncScript = buildSyncAutolinkingScript(reactNativePath); + const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking'); + if (!text.includes(syncPhaseUuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry( + shellScriptPhase(syncPhaseUuid, 'Sync SPM Autolinking', syncScript), + ), + ); + } else { + // Already injected on a prior run — the phase object owns its + // shellScript, so refresh it in place (same quoting used at creation) in + // case the generated script changed since. Byte-identical when it + // didn't; field order and every other byte of the phase are untouched. + const existingPhase = findObjectByUuid(text, syncPhaseUuid); + if (existingPhase != null) { + text = setScalarField( + text, + existingPhase, + 'shellScript', + quoteIfNeeded(syncScript), + ); + } + } + injectedUuids.push(syncPhaseUuid); + text = addArrayMembers( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'buildPhases', + [{uuid: syncPhaseUuid, comment: 'Sync SPM Autolinking'}], + {prepend: true}, + ); + + // 7. The sole writer of flavored frameworks under the final app bundle. + // SwiftPM owns only invariant header/source products, so no implicit SPM + // embed task competes with this phase. + const embedPhaseUuid = mkUuid( + 'PBXShellScriptBuildPhase', + 'EmbedFlavoredFrameworks', + ); + const embedScript = buildEmbedFrameworksScript(flavoredFrameworks); + const embedInputs = [ + '$(SRCROOT)/build/xcframeworks/.artifact-stamp', + ...flavoredFrameworks.map( + framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`, + ), + ]; + const embedOutputs = flavoredFrameworks.map( + framework => + `$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/${framework.frameworkName}.framework`, + ); + const embedEntry = shellScriptPhase( + embedPhaseUuid, + 'Embed React Native Flavored Frameworks', + embedScript, + { + inputPaths: pbxPathList(embedInputs), + outputPaths: pbxPathList(embedOutputs), + }, + ); + if (!text.includes(embedPhaseUuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry(embedEntry), + ); + } else { + const existingPhase = findObjectByUuid(text, embedPhaseUuid); + if (existingPhase != null) { + for (const key of ['shellScript', 'inputPaths', 'outputPaths']) { + const current = findObjectByUuid(text, embedPhaseUuid); + if (current != null) { + text = setScalarField(text, current, key, embedEntry.fields[key]); + } + } + } + } + injectedUuids.push(embedPhaseUuid); + text = addBuildPhaseAfter( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + plan.frameworksPhaseUuid, + { + uuid: embedPhaseUuid, + comment: 'Embed React Native Flavored Frameworks', + }, + ); + + // 8. Plugin generated sources compiled INTO THE APP TARGET (e.g. Expo's + // ExpoModulesProvider.swift). An `@objc` class only reaches the ObjC + // classlist — required for NSClassFromString discovery — when it compiles + // into the app target, not the static Autolinked aggregate. Each source + // gets a PBXFileReference + PBXBuildFile + a Sources-phase entry, parented + // under a single "SPM Generated Sources" group. Every UUID is keyed on the + // normalized path (deterministic → idempotent) and recorded so `deinit` + // reverts it and `update` reconciles it (removal is done by the caller, + // which owns the prior marker; emission here is purely additive). + const generatedSourceUuids /*: {[string]: Array} */ = {}; + const sourcesPhaseUuid = plan.sourcesPhaseUuid; + if (generatedSources.length > 0) { + if (sourcesPhaseUuid == null) { + log( + 'warning: the app target has no Sources build phase — cannot compile ' + + `${generatedSources.length} SPM plugin generated source(s) into the ` + + 'app target; skipping. Any @objc classes they define will not be ' + + 'discoverable via NSClassFromString.', + ); + } else { + const fileRefs = []; + const buildFiles = []; + const sourcesMembers = []; + const groupChildren = []; + for (const src of generatedSources) { + const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`); + const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`); + generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid]; + fileRefs.push({ + uuid: fileRefUuid, + comment: src.name, + fields: { + isa: 'PBXFileReference', + lastKnownFileType: src.fileType, + name: quoteIfNeeded(src.name), + path: quoteIfNeeded(src.path), + sourceTree: src.sourceTree, + }, + }); + buildFiles.push({ + uuid: buildFileUuid, + comment: `${src.name} in Sources`, + fields: { + isa: 'PBXBuildFile', + fileRef: `${fileRefUuid} /* ${src.name} */`, + }, + }); + sourcesMembers.push({ + uuid: buildFileUuid, + comment: `${src.name} in Sources`, + }); + groupChildren.push({uuid: fileRefUuid, comment: src.name}); + } + insertObjects('PBXFileReference', fileRefs); + insertObjects('PBXBuildFile', buildFiles); + + // Compile membership — the actual reason these are wired into the app. + const sourcesPhase = findObjectByUuid(text, sourcesPhaseUuid); + if (sourcesPhase != null) { + text = addArrayMembers(text, sourcesPhase, 'files', sourcesMembers); + } + + // The "SPM Generated Sources" group (created on first use, then reused). + // Insert with empty children so the ONE population path (addArrayMembers) + // handles both create and reconcile, keeping formatting identical. + const groupUuid = mkUuid('PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID); + if (!text.includes(groupUuid)) { + text = insertObjectsIntoSection( + text, + 'PBXGroup', + serializeEntry({ + uuid: groupUuid, + comment: SPM_GENERATED_SOURCES_GROUP_NAME, + fields: { + isa: 'PBXGroup', + children: '(\n\t\t\t)', + name: quoteIfNeeded(SPM_GENERATED_SOURCES_GROUP_NAME), + sourceTree: '""', + }, + }), + ); + } + injectedUuids.push(groupUuid); + const groupObj = findObjectByUuid(text, groupUuid); + if (groupObj != null) { + text = addArrayMembers(text, groupObj, 'children', groupChildren); + } + + // Parent the group under the project's main group (idempotent). Appends + // to a pre-existing children array, so no createdArrayField is recorded — + // deinit removes the group's membership via removeArrayMembersByUuid and + // the group object itself via removeObjectByUuid (groupUuid is injected). + const proj = findProjectObject(text); + const mainGroupField = + proj != null ? findField(text, proj, 'mainGroup') : null; + const mainGroupMatch = + mainGroupField != null + ? mainGroupField.value.match(/[0-9A-Fa-f]{24}/) + : null; + const mainGroupObj = + mainGroupMatch != null + ? findObjectByUuid(text, mainGroupMatch[0]) + : null; + if (mainGroupObj != null) { + text = addArrayMembers(text, mainGroupObj, 'children', [ + {uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME}, + ]); + } + } + } + + return { + text, + injectedUuids, + createdArrayFields, + buildSettingChanges, + generatedSourceUuids, + }; +} + +/** Re-locate an application target by UUID against the current text. */ +function findApplicationTargetByUuid( + text /*: string */, + targetUuid /*: string */, +) /*: {uuid: string, bodyOpen: number, bodyClose: number} */ { + const obj = findObjectByUuid(text, targetUuid); + if (obj == null) { + throw new Error(`pbxproj: app target ${targetUuid} disappeared mid-edit`); + } + return obj; +} + +/** + * Merge the React build settings into one XCBuildConfiguration's dict. Returns + * the modified text plus a precise record of what was actually added — so + * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching + * a value the user already had (key insight: ensureScalarField/ + * addArrayStringValues are no-ops / dedupe when a value is already present). + */ +/** + * Resolves the host `hermesc` from the `hermes-compiler` npm package and returns + * its ABSOLUTE path as the HERMES_CLI_PATH value, or null when it can't be found + * (e.g. USE_HERMES=false apps without the package). require.resolve (anchored at + * reactNativeRoot) follows Node's lookup, so a hoisted monorepo layout — where + * hermes-compiler sits in the workspace-root node_modules, NOT next to + * react-native — resolves correctly. + * + * The value is intentionally ABSOLUTE, not `$(REACT_NATIVE_PATH)/../...`: when + * react-native is a symlink (the monorepo default, and common in real apps), a + * `..` after it resolves — kernel-side — to the symlink TARGET's parent, not the + * node_modules dir, so the relative form points at a non-existent + * `/../hermes-compiler`. An absolute path sidesteps that entirely + * (and matches how the CocoaPods hermes-engine pod sets HERMES_CLI_PATH). It is + * regenerated on every `spm add`, so machine-specificity is a non-issue. + */ +function resolveHermesCliPathSetting( + reactNativeRoot /*: string */, +) /*: ?string */ { + try { + const pkg = require.resolve('hermes-compiler/package.json', { + paths: [reactNativeRoot], + }); + const hermesc = path.join( + path.dirname(pkg), + 'hermesc', + 'osx-bin', + 'hermesc', + ); + return fs.existsSync(hermesc) ? hermesc : null; + } catch { + return null; + } +} + +function mergeReactBuildSettings( + input /*: string */, + configUuid /*: string */, + configurationName /*: string */, + reactNativePath /*: string */, + hermesCliPath /*: ?string */ = null, + flavoredFrameworks /*: ReadonlyArray */ = [], +) /*: {text: string, change: BuildSettingChange} */ { + let text = input; + const scalars = [ + {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'}, + {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)}, + // Under SwiftPM there is no hermes-engine pod, so react-native-xcode.sh's + // fallback ($PODS_ROOT/hermes-engine/destroot/bin/hermesc) resolves to a + // non-existent "/hermes-engine/..." and the Release JS→Hermes bundling + // fails. Point HERMES_CLI_PATH at the hermes-compiler npm package's host + // hermesc (an ABSOLUTE path resolved by the caller — see + // resolveHermesCliPathSetting). react-native-xcode.sh honors an already-set + // HERMES_CLI_PATH before its pod fallback; ensureScalarField leaves any + // user-provided value untouched. + ...(hermesCliPath != null + ? [{key: 'HERMES_CLI_PATH', value: quoteIfNeeded(hermesCliPath)}] + : []), + ]; + // Re-locate the buildSettings dict before each edit (offsets shift). + const dict = () => { + const cfg = findObjectByUuid(text, configUuid); + if (cfg == null) { + return null; + } + const bs = findField(text, cfg, 'buildSettings'); + if (bs == null) { + return null; + } + return { + uuid: configUuid, + bodyOpen: bs.valueStart, + bodyClose: bs.tokenEnd - 1, + }; + }; + const createdArrayKeys /*: Array */ = []; + const appendedArrayValues /*: {[string]: Array} */ = {}; + const createdScalars /*: Array */ = []; + const arraySettings = [ + ...INJECTED_ARRAY_SETTINGS, + ...frameworkArrayBuildSettings(flavoredFrameworks), + ]; + for (const {key, values} of arraySettings) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + if (existing == null) { + createdArrayKeys.push(key); + } else { + const fresh = values.filter(v => !existing.value.includes(v)); + if (fresh.length > 0) { + appendedArrayValues[key] = fresh; + } + } + text = addArrayStringValues(text, d, key, values); + } + const replacedScalars /*: {[string]: string} */ = {}; + for (const {key, value} of scalars) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + if (existing == null) { + createdScalars.push(key); + } else if ( + key === 'REACT_NATIVE_PATH' && + existing.value.includes('PODS_ROOT') + ) { + // A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template + // default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves + // empty at build time, so the Bundle React Native code and images + // phase looks for "/../…/scripts/xcode/with-environment.sh". Replace + // it with the SPM-computed path, recording the original for deinit. + replacedScalars[key] = existing.value; + text = removeField(text, d, key); + const d2 = dict(); + if (d2 == null) { + continue; + } + text = ensureScalarField(text, d2, key, value); + continue; + } + text = ensureScalarField(text, d, key, value); + } + const ownedScalars = [ + { + key: 'RN_SPM_FLAVOR', + value: flavorForBuildConfiguration(configurationName), + }, + ...frameworkConditionalSettings(flavoredFrameworks), + ]; + for (const {key, value} of ownedScalars) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + if (existing == null) { + createdScalars.push(key); + } else if (existing.value !== value) { + replacedScalars[key] = existing.value; + } + text = setScalarField(text, d, key, value); + } + return { + text, + change: { + configUuid, + createdArrayKeys, + appendedArrayValues, + createdScalars, + replacedScalars, + }, + }; +} + +// Write only when content changed (avoids spurious Xcode reloads / git churn). +function writeIfChanged( + filePath /*: string */, + content /*: string */, +) /*: boolean */ { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + try { + if (fs.readFileSync(filePath, 'utf8') === content) { + return false; + } + } catch { + /* file doesn't exist yet */ + } + fs.writeFileSync(filePath, content, 'utf8'); + return true; +} + +/** + * Add the "Sync SPM Autolinking" pre-action to an existing scheme's + * BuildAction, reusing the scheme's own primary BuildableReference. Returns + * the XML unchanged when the pre-action is already present. + */ +function addPreActionToScheme( + xml /*: string */, + targetUuid /*: string */, + syncScript /*: string */, +) /*: string */ { + const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); + if (titleIdx >= 0) { + // Already injected on a prior run — refresh a possibly-stale scriptText + // in place (same escaping used at creation) rather than leaving it + // forever. Splice by index (not a regex/string replace) since the script + // itself may contain `$`-sequences that String.replace's replacement- + // pattern syntax would otherwise misinterpret. Byte-identical when the + // script is unchanged; every other byte of the scheme is untouched. + const scriptTextMarker = 'scriptText = "'; + const stIdx = xml.indexOf(scriptTextMarker, titleIdx); + if (stIdx < 0) { + return xml; // malformed — leave untouched rather than guess + } + const valueStart = stIdx + scriptTextMarker.length; + // escapeXmlAttribute maps a literal `"` to `"`, so the attribute + // value itself never contains one — the next `"` is always the closing + // delimiter. + const valueEnd = xml.indexOf('"', valueStart); + return ( + xml.slice(0, valueStart) + + escapeXmlAttribute(syncScript) + + xml.slice(valueEnd) + ); + } + const refMatch = xml.match( + new RegExp( + `]*BlueprintIdentifier = "${targetUuid}"[^>]*>`, + ), + ); + const attr = (name /*: string */) => { + const m = + refMatch != null + ? refMatch[0].match(new RegExp(`${name} = "([^"]*)"`)) + : null; + return m != null ? m[1] : ''; + }; + const cleanRef = + `\n` + + ` `; + const executionAction = + ` \n` + + ` \n` + + ` \n` + + ` ${cleanRef}\n` + + ` \n` + + ` \n` + + ` `; + + if (//.test(xml)) { + return xml.replace( + '', + `${executionAction}\n `, + ); + } + const openEnd = xml.indexOf('>', xml.indexOf('\n${executionAction}\n `; + return xml.slice(0, openEnd + 1) + block + xml.slice(openEnd + 1); +} + +/** + * Ensure the app target's shared scheme runs the sync pre-action before SPM + * resolution. Updates the scheme that builds the target if one exists, + * otherwise creates a fresh shared scheme. Returns 'updated' | 'created' | + * 'unchanged'. + */ +function injectOrCreateScheme( + xcodeprojDir /*: string */, + opts /*: {appName: string, targetUuid: string, projName: string, syncScript: string} */, +) /*: {status: 'updated' | 'unchanged' | 'created', file: string} */ { + const schemesDir = path.join(xcodeprojDir, 'xcshareddata', 'xcschemes'); + let schemeFiles /*: Array */ = []; + try { + schemeFiles = fs + .readdirSync(schemesDir) + .filter(f => f.endsWith('.xcscheme')); + } catch { + /* no shared schemes dir yet */ + } + for (const f of schemeFiles) { + const p = path.join(schemesDir, f); + const xml = fs.readFileSync(p, 'utf8'); + if (xml.includes(`BlueprintIdentifier = "${opts.targetUuid}"`)) { + const updated = addPreActionToScheme( + xml, + opts.targetUuid, + opts.syncScript, + ); + return { + status: writeIfChanged(p, updated) ? 'updated' : 'unchanged', + file: f, + }; + } + } + const file = `${opts.appName}.xcscheme`; + const xml = generateXcscheme( + opts.appName, + opts.targetUuid, + opts.projName, + opts.syncScript, + ); + writeIfChanged(path.join(schemesDir, file), xml); + return {status: 'created', file}; +} + +/** + * Strip the empty `Pods` group `pod deintegrate` leaves in the navigator. + * Called by `add --deintegrate` after deintegration so the converted project is + * visually clean. No-op when absent or when the group still has children. + */ +function cleanupLeftoverPodsGroup(xcodeprojPath /*: string */) /*: boolean */ { + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return false; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const cleaned = removeEmptyPodsGroup(original); + return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false; +} + +/** + * Strip the dangling `JavaScriptCore.framework` file reference the community + * template has carried since RN 0.60 (navigator-only, meaningless under + * Hermes) — see `removeDanglingJavaScriptCoreRef` for the full rationale and + * the safety gate that leaves a still-linked reference untouched. No-op when + * absent or when the pbxproj is missing. + */ +function cleanupDanglingJavaScriptCoreRef( + xcodeprojPath /*: string */, +) /*: boolean */ { + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return false; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const cleaned = removeDanglingJavaScriptCoreRef(original); + return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false; +} + +/** + * Normalize one plugin generated-source path into the fields a PBXFileReference + * needs. Stores an SRCROOT-relative path (`sourceTree = SOURCE_ROOT`) when the + * source lives under the app root — the typical case (build/generated/…) — and + * an absolute path (`sourceTree = ""`) otherwise. Returns null (with a + * loud log) for an extension the pbxproj can't compile. + */ +function normalizeGeneratedSource( + appRoot /*: string */, + srcPath /*: string */, +) /*: ?GeneratedSource */ { + const ext = path.extname(srcPath).toLowerCase(); + const fileType = GENERATED_SOURCE_FILE_TYPES[ext]; + if (fileType == null) { + log( + `warning: unsupported generated-source extension "${ext}" for ` + + `${srcPath}; skipping (SPM plugin sources must be .swift/.m/.mm).`, + ); + return null; + } + const abs = path.isAbsolute(srcPath) + ? srcPath + : path.resolve(appRoot, srcPath); + const rel = path.relative(appRoot, abs); + const underAppRoot = + rel !== '' && + rel !== '..' && + !rel.startsWith('..' + path.sep) && + !path.isAbsolute(rel); + return { + path: underAppRoot ? rel : abs, + name: path.basename(abs), + sourceTree: underAppRoot ? 'SOURCE_ROOT' : '""', + fileType, + }; +} + +/** + * Read + normalize the plugin generated-sources manifest at + * `/build/generated/autolinking/.spm-plugin-generated-sources.json`. + * Absent, empty, or malformed → `[]` (the feature stays inert for non-plugin + * apps and never breaks injection). The file need not exist yet at inject time: + * the build-time sync regenerates it before compile, and a PBXFileReference to a + * not-yet-created path is valid. + */ +function readGeneratedSourcesManifest( + appRoot /*: string */, +) /*: Array */ { + const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST); + let raw: string; + try { + raw = fs.readFileSync(manifestPath, 'utf8'); + } catch { + return []; + } + let entries: unknown; + try { + entries = JSON.parse(raw); + } catch { + log( + `warning: could not parse ${SPM_GENERATED_SOURCES_MANIFEST}; ` + + 'skipping generated sources.', + ); + return []; + } + if (!Array.isArray(entries)) { + return []; + } + const out /*: Array */ = []; + for (const entry of entries) { + if ( + entry == null || + typeof entry !== 'object' || + typeof entry.path !== 'string' + ) { + continue; + } + const normalized = normalizeGeneratedSource(appRoot, entry.path); + // Dedupe by normalized path — a duplicate manifest entry would otherwise + // double-insert identical-UUID pbxproj objects. + if (normalized != null && !out.some(s => s.path === normalized.path)) { + out.push(normalized); + } + } + return out; +} + +/** + * Read the `.spm-injected.json` marker of a previously-injected project, or + * null when absent/unreadable. Used to reconcile generated sources on `update` + * and to read back a pinned `artifactsVersionOverride` (see below). + */ +function readMarker( + xcodeprojPath /*: string */, +) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array, ...} */ { + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + try { + // $FlowFixMe[incompatible-return] JSON.parse returns any + return JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch { + return null; + } +} + +// Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json` +// marker (the user-owned project SPM packages were injected into in place), +// or null when none has been injected yet. Pure fs reads — safe for the +// build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride +// below) to call without pulling in any pbxproj-editing machinery at runtime. +function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { + let entries: Array<{name: string, isDirectory(): boolean}>; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(appRoot, {withFileTypes: true}); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (!name.endsWith('.xcodeproj')) continue; + if (fs.existsSync(path.join(appRoot, name, SPM_INJECTED_MARKER))) { + return path.join(appRoot, name); + } + } + return null; +} + +/** + * Read the `artifactsVersionOverride` a previous `spm add --version` / `spm + * update --version` pinned into the injected xcodeproj's `.spm-injected.json` + * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj + * below), or null when no project is injected yet, no override is pinned, or + * the marker can't be read (never throws). Pure fs reads — the build-time + * sync (sync-spm-autolinking.js) calls this to prefer the pinned version over + * the one derived from node_modules/react-native/package.json, so a + * version-mismatched setup keeps healing against the SAME artifact slot the + * explicit `--version` selected. + */ +function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { + const xcodeprojPath = findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return null; + } + const override = readMarker(xcodeprojPath)?.artifactsVersionOverride; + return typeof override === 'string' && override.length > 0 ? override : null; +} + +/** + * Add SPM packages to a user's EXISTING xcodeproj in place. Returns + * {status: 'injected', target} on success, or {status: 'refused', reason} + * when the project can't be safely edited (caller surfaces it; fail-loud). + */ +function injectSpmIntoExistingXcodeproj( + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string} */, +) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { + const {appRoot, reactNativeRoot, xcodeprojPath} = opts; + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return { + status: 'refused', + reason: `no project.pbxproj at ${xcodeprojPath}`, + }; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const plan = planInjection(original, {appName: opts.appName}); + if (!plan.ok) { + return {status: 'refused', reason: plan.reason}; + } + const reactNativePath = path.relative(appRoot, reactNativeRoot); + const remote = remotePackageConfig(appRoot); + const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot); + const generatedSources = readGeneratedSourcesManifest(appRoot); + const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; + + const prevMarker = readMarker(xcodeprojPath); + + // Reconcile generated sources injected on a PRIOR run that are no longer in + // the manifest (a plugin's entry was dropped, or the plugin was removed). + // Diff the marker's `generatedSources` map against the current manifest and + // delete only the stale UUIDs — the additive injection below re-emits (and + // idempotently skips) everything that remains, so an unchanged run stays + // byte-identical. deinit needs none of this: the removed objects live in + // `injectedUuids`. + const prevGeneratedSources /*: {[string]: Array} */ = + prevMarker?.generatedSources ?? {}; + const currentPaths = new Set(generatedSources.map(s => s.path)); + const staleUuids /*: Array */ = []; + for (const p of Object.keys(prevGeneratedSources)) { + if (!currentPaths.has(p)) { + staleUuids.push(...prevGeneratedSources[p]); + } + } + // When the last generated source is gone, retire the now-empty group too. + if ( + generatedSources.length === 0 && + Object.keys(prevGeneratedSources).length > 0 + ) { + staleUuids.push( + namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID), + ); + } + // Re-apply generated settings from a clean recorded baseline. This removes + // linker entries for plugin frameworks that disappeared and keeps the new + // marker a complete inverse after an idempotent update. + let base = removeRecordedBuildSettings( + original, + prevMarker?.buildSettingChanges ?? [], + ); + if (staleUuids.length > 0) { + base = removeArrayMembersByUuid(base, staleUuids); + for (const u of staleUuids) { + base = removeObjectByUuid(base, u); + } + } + + const { + text, + injectedUuids, + createdArrayFields, + buildSettingChanges, + generatedSourceUuids, + } = injectSpmIntoPbxproj( + base, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + reactNativePath, + remote, + hermesCliPath, + generatedSources, + flavoredFrameworks, + ); + + const changed = writeIfChanged(pbxprojPath, text); + log( + changed + ? `Injected SPM packages into ${path.relative(appRoot, pbxprojPath)}` + : `${path.relative(appRoot, pbxprojPath)} already up to date`, + ); + + const projName = path.basename(xcodeprojPath, '.xcodeproj'); + const schemeResult = injectOrCreateScheme(xcodeprojPath, { + appName: plan.target.name, + targetUuid: plan.target.uuid, + projName, + // The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap + // could win its race and mask a mismatch from the in-target detector. + syncScript: buildSchemePreActionScript(reactNativePath), + }); + log(`Scheme sync pre-action: ${schemeResult.status}`); + + // The RN version this app's xcframework artifact-cache slot should be + // pinned to, when `add`/`update` was given an EXPLICIT `--version` — SETS + // the pin. Omitting `--version` (opts.artifactsVersionOverride is null) + // PRESERVES whatever was recorded on a prior run, since it's an + // intentional pin, not something to silently re-derive from + // node_modules/react-native/package.json. There is no "clear" verb yet; + // `deinit` (removeSpmInjection) drops the whole marker, including this + // field. Read back by readArtifactsVersionOverride (above) so the + // build-time sync (sync-spm-autolinking.js) heals against the SAME slot + // `add`/`update` selected, even on a version-mismatched setup. + const artifactsVersionOverride = + opts.artifactsVersionOverride ?? + prevMarker?.artifactsVersionOverride ?? + null; + + // Marker: idempotency signal + the exact, reversible record of every edit so + // `deinit` (removeSpmInjection) can undo precisely what was added. + writeIfChanged( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify( + { + rootUuid: plan.rootUuid, + target: plan.target.name, + targetUuid: plan.target.uuid, + injectedUuids: Array.from(new Set(injectedUuids)).sort(), + createdArrayFields, + buildSettingChanges, + // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next + // `update` to reconcile away entries that left the manifest. + generatedSources: generatedSourceUuids, + artifactsVersionOverride, + scheme: { + file: schemeResult.file, + created: schemeResult.status === 'created', + }, + }, + null, + 2, + ) + '\n', + ); + + ensureStubPackages(appRoot); + return {status: 'injected', target: plan.target.name}; +} + +/** + * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added + * to a scheme, and drop the `` wrapper if it is left empty (the + * byte-identical inverse for the common case where injection created it). + */ +function removePreActionFromScheme(xml /*: string */) /*: string */ { + const withoutAction = xml.replace( + /[ \t]*)[\s\S])*?title = "Sync SPM Autolinking"(?:(?!<\/ExecutionAction>)[\s\S])*?<\/ExecutionAction>\n?/, + '', + ); + return withoutAction.replace(/\n[ \t]*\s*<\/PreActions>/, ''); +} + +function removeRecordedBuildSettings( + input /*: string */, + changes /*: ReadonlyArray */, +) /*: string */ { + let text = input; + for (const change of changes) { + const dict = () => { + const config = findObjectByUuid(text, change.configUuid); + if (config == null) { + return null; + } + const buildSettings = findField(text, config, 'buildSettings'); + if (buildSettings == null) { + return null; + } + return { + uuid: change.configUuid, + bodyOpen: buildSettings.valueStart, + bodyClose: buildSettings.tokenEnd - 1, + }; + }; + for (const key of Object.keys(change.appendedArrayValues ?? {})) { + const current = dict(); + if (current != null) { + text = removeArrayStringValues( + text, + current, + key, + change.appendedArrayValues[key], + ); + } + } + for (const key of change.createdArrayKeys ?? []) { + const current = dict(); + if (current != null) { + text = removeField(text, current, key); + } + } + for (const key of change.createdScalars ?? []) { + const current = dict(); + if (current != null) { + text = removeField(text, current, key); + } + } + const replacedScalars /*: {[string]: string} */ = + change.replacedScalars ?? {}; + for (const key of Object.keys(replacedScalars)) { + const current = dict(); + if (current != null) { + text = removeField(text, current, key); + const replacement = dict(); + if (replacement != null) { + const originalValue = replacedScalars[key]; + if (typeof originalValue === 'string') { + text = ensureScalarField(text, replacement, key, originalValue); + } + } + } + } + } + return text; +} + +/** + * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the + * `.spm-injected.json` marker's precise record of every edit, remove only what + * injection added — leaving any other (user) edits made afterwards intact. No + * `git checkout`, no prompt. Returns {status:'absent'} when the project was + * never injected. + */ +function removeSpmInjection( + opts /*: {appRoot: string, xcodeprojPath: string} */, +) /*: {status: 'removed', target: string} | {status: 'absent'} */ { + const {appRoot, xcodeprojPath} = opts; + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + if (!fs.existsSync(markerPath)) { + return {status: 'absent'}; + } + // $FlowFixMe[incompatible-type] JSON.parse returns any + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + let text = fs.readFileSync(pbxprojPath, 'utf8'); + + const injectedUuids /*: Array */ = marker.injectedUuids ?? []; + + // 1. Drop our array members, then the array fields we created (now empty), + // then the injected object definitions. + text = removeArrayMembersByUuid(text, injectedUuids); + for (const f of marker.createdArrayFields ?? []) { + const obj = + f.container === 'project' + ? findProjectObject(text) + : findObjectByUuid(text, marker.targetUuid); + if (obj != null) { + text = removeField(text, obj, f.key); + } + } + for (const uuid of injectedUuids) { + text = removeObjectByUuid(text, uuid); + } + // Drop any section that injection created and we just emptied (e.g. + // XCLocalSwiftPackageReference) — a well-formed pbxproj never carries an + // empty `/* Begin X *​/ /* End X *​/` section, so this lands byte-identical. + text = text.replace( + /\/\* Begin (\w+) section \*\/\n\/\* End \1 section \*\/\n\n/g, + '', + ); + + // 2. Reverse the per-config build-setting edits (only what we added). + text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []); + writeIfChanged(pbxprojPath, text); + log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); + + // 3. Scheme: delete it if injection created it, else strip the pre-action. + const scheme = marker.scheme; + if (scheme != null && scheme.file != null) { + const schemePath = path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + scheme.file, + ); + if (scheme.created === true) { + fs.rmSync(schemePath, {force: true}); + } else if (fs.existsSync(schemePath)) { + const xml = fs.readFileSync(schemePath, 'utf8'); + writeIfChanged(schemePath, removePreActionFromScheme(xml)); + } + } + + // 4. Drop the marker — the project is no longer SPM-injected. + fs.rmSync(markerPath, {force: true}); + return {status: 'removed', target: marker.target}; +} + +module.exports = { + generateXcscheme, + buildSyncAutolinkingScript, + buildSchemePreActionScript, + buildEmbedFrameworksScript, + flavorForBuildConfiguration, + frameworkConditionalSettings, + ensureStubPackages, + buildSpmDependencyGraph, + spmGraphToEntries, + planInjection, + injectSpmIntoPbxproj, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, + cleanupLeftoverPodsGroup, + cleanupDanglingJavaScriptCoreRef, + addPreActionToScheme, + removePreActionFromScheme, + findInjectedXcodeproj, + readArtifactsVersionOverride, + SPM_INJECTED_MARKER, +}; diff --git a/packages/react-native/scripts/spm/read-podspec.js b/packages/react-native/scripts/spm/read-podspec.js new file mode 100644 index 000000000000..cb79aec7a03e --- /dev/null +++ b/packages/react-native/scripts/spm/read-podspec.js @@ -0,0 +1,695 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type {PodspecModel, PreprocessorDefine} from './spm-types'; */ + +/** + * read-podspec.js — produces a flattened, SPM-friendly view of an iOS + * podspec for the scaffolder. + * + * Strategy: prefer `pod ipc spec ` (CocoaPods evaluates the Ruby DSL, + * including `install_modules_dependencies(s)` and `$config[:...]` + * interpolation, so the JSON output reflects the real spec). Fall back to a + * best-effort regex parser when CocoaPods isn't available — handles simple + * RN libs but skips subspec blocks and Ruby helpers with a warning. + * + * The output `PodspecModel` collapses the spec's default subspecs into a + * single logical target view, since that's how RN consumers compile these + * libraries in practice. + */ + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type RawSpec = {[string]: unknown}; +*/ + +// --------------------------------------------------------------------------- +// Pod-IPC primary path +// --------------------------------------------------------------------------- + +/** + * Runs `pod ipc spec ` and parses the JSON it prints. Returns null + * on any failure: CocoaPods missing, command threw, output not JSON, etc. + * Callers should treat null as a signal to fall back to the regex parser. + */ +/** + * Strips RN-specific Ruby helpers from a podspec source so `pod ipc spec` + * can evaluate it without RN's Podfile-side helpers loaded. The stripped + * helpers (`install_modules_dependencies(s)`) typically inject the React-Core + * / React-Fabric family — all of which the scaffolder collapses into the + * single `ReactNative` product anyway, so dropping them is safe as long as + * the podspec also has an explicit `s.dependency "React-Core"` (almost all + * RN libs do). Writes the patched content to a temp file and returns its + * path; caller deletes when done. + */ +function patchPodspecForPodIpc(podspecPath /*: string */) /*: string */ { + const content = fs.readFileSync(podspecPath, 'utf8'); + // Comment out RN-Podfile-only helpers. We do NOT remove the lines so + // line numbers in any pod ipc errors still match the original file. + const patched = content + .replace( + /^(\s*)install_modules_dependencies\(([^)]*)\)/gm, + '$1# install_modules_dependencies($2) # stripped for pod ipc', + ) + .replace( + /^(\s*)use_react_native!\(([^)]*)\)/gm, + '$1# use_react_native!($2) # stripped for pod ipc', + ); + // Write the patched copy NEXT TO the original podspec, not in os.tmpdir. + // Podspecs commonly do `File.read(File.join(__dir__, 'package.json'))` or + // similar, expecting the package.json sibling. Keeping the patched file + // in the same directory makes those reads continue to work. + const depDir = path.dirname(podspecPath); + // pod ipc only accepts files ending in `.podspec` or `.podspec.json`. + // Use a dotfile prefix so the patched copy is invisible to normal listing + // but the suffix CocoaPods requires is preserved. + const tmpFile = path.join( + depDir, + `.spm-scaffold-${process.pid}-${path.basename(podspecPath)}`, + ); + fs.writeFileSync(tmpFile, patched, 'utf8'); + return tmpFile; +} + +function runPodIpcSpec(podspecPath /*: string */) /*: RawSpec | null */ { + // RN podspecs often call `install_modules_dependencies(s)`, a helper + // defined by RN's Podfile-side scripts. `pod ipc spec` doesn't load + // those, so the helper is undefined and the whole spec fails to parse. + // Pre-process to strip those calls before invoking pod ipc. + let patchedPath /*: ?string */ = null; + try { + patchedPath = patchPodspecForPodIpc(podspecPath); + } catch { + return null; + } + let result; + try { + result = spawnSync('pod', ['ipc', 'spec', patchedPath], { + encoding: 'utf8', + timeout: 30000, + maxBuffer: 8 * 1024 * 1024, + // Many RN podspecs gate subspec definitions on `RCT_NEW_ARCH_ENABLED` + // (e.g. safe-area-context wraps its Fabric `common` + `fabric` + // subspecs in `if fabric_enabled`). RN 0.76+ defaults to the new + // architecture, so unless the caller has explicitly opted out, set + // the env var here so `pod ipc spec` evaluates the full podspec. + env: { + ...process.env, + RCT_NEW_ARCH_ENABLED: process.env.RCT_NEW_ARCH_ENABLED ?? '1', + }, + }); + } catch { + cleanupPatchedPodspec(patchedPath); + return null; + } + cleanupPatchedPodspec(patchedPath); + if (result == null || result.error != null) { + return null; + } + if (typeof result.status !== 'number' || result.status !== 0) { + return null; + } + if (typeof result.stdout !== 'string' || result.stdout.length === 0) { + return null; + } + // Some podspecs print diagnostics to stdout during evaluation (e.g. skia: + // `-- SK_GRAPHITE: OFF ...`) before `pod ipc` emits the JSON, and a + // diagnostic can itself contain braces (`Building {module}`). `pod ipc` + // pretty-prints the spec as a JSON object whose outermost braces sit at + // column 0, so ANCHOR on a line-boundary `{`…`}` rather than the first/last + // brace anywhere — otherwise a brace inside a diagnostic shifts the slice and + // JSON.parse throws. When the authoritative parse is abandoned we log (not + // silently drop to the much weaker, subspec-blind regex parser). + const stdout = result.stdout; + const startMatch = stdout.match(/^\{/m); + const start = startMatch != null ? startMatch.index : -1; + const lastLineBrace = stdout.lastIndexOf('\n}'); + const end = + lastLineBrace >= 0 ? lastLineBrace + 2 : stdout.lastIndexOf('}') + 1; + if (start < 0 || end <= start) { + console.warn( + '[read-podspec] pod ipc spec produced no line-anchored JSON object; ' + + 'falling back to the regex parser (subspecs/helpers may be missed).', + ); + return null; + } + try { + return JSON.parse(stdout.slice(start, end)); + } catch (e) { + console.warn( + `[read-podspec] pod ipc spec JSON parse failed (${e.message}); ` + + 'falling back to the regex parser (subspecs/helpers may be missed).', + ); + return null; + } +} + +function cleanupPatchedPodspec(patchedPath /*: ?string */) /*: void */ { + if (patchedPath == null) return; + try { + fs.unlinkSync(patchedPath); + } catch { + // best-effort cleanup; the file is named with `.spm-scaffold-...tmp` + // so a leftover is identifiable. + } +} + +// --------------------------------------------------------------------------- +// Regex fallback +// --------------------------------------------------------------------------- + +/** + * Best-effort Ruby podspec parser. Extracts the literal-string and + * literal-array fields most RN libs use. Skips subspec blocks, Ruby helper + * calls (install_modules_dependencies, ENV[], $config[:...]), and + * interpolation — appends a warning so the caller can surface it to the + * user. Always returns a RawSpec; pure-JS, no Ruby dep required. + */ +function regexPodspec(podspecPath /*: string */) /*: RawSpec */ { + const content = fs.readFileSync(podspecPath, 'utf8'); + const warnings /*: Array */ = []; + + // Matches: s. = "value" or s. = 'value' + function getStringField(name /*: string */) /*: string | null */ { + const re = new RegExp(`(?:s|spec)\\.${name}\\s*=\\s*["']([^"']+)["']`); + const m = content.match(re); + return m ? m[1] : null; + } + + // Matches: + // s. = ["a", "b"] (array) + // s. = "single" (single value treated as 1-element array) + function getArrayField(name /*: string */) /*: Array */ { + const reArr = new RegExp(`(?:s|spec)\\.${name}\\s*=\\s*\\[([^\\]]*)\\]`); + const mArr = content.match(reArr); + if (mArr != null) { + const inner = mArr[1]; + const out /*: Array */ = []; + const itemRe = /["']([^"']+)["']/g; + while (true) { + const m = itemRe.exec(inner); + if (m == null) break; + out.push(m[1]); + } + return out; + } + const single = getStringField(name); + return single != null ? [single] : []; + } + + // Matches: s.dependency "Name" (with optional version constraint) + function getDependencies() /*: Array */ { + const out /*: Array */ = []; + const re = /(?:s|spec)\.dependency\s+["']([^"']+)["']/g; + while (true) { + const m = re.exec(content); + if (m == null) break; + out.push(m[1]); + } + return out; + } + + // Matches: + // s.framework "X" → ["X"] + // s.framework = "X" → ["X"] + // s.frameworks = ["X","Y"] → ["X","Y"] + function getFrameworks(weak /*: boolean */) /*: Array */ { + const prefix = weak ? 'weak_framework' : 'framework'; + const out /*: Array */ = []; + // Plural form: s.frameworks = [...] or s.frameworks = "Foo" + out.push(...getArrayField(`${prefix}s`)); + // Singular form: s.framework = "Foo" — single value treated as a 1-elem list + out.push(...getArrayField(prefix)); + // Method-call form: s.framework "Foo", "Bar" — no `=`. `getArrayField` + // only matches assignment, so handle this case separately. + const callRe = new RegExp( + `(?:s|spec)\\.${prefix}s?\\s+((?:["'][^"']+["']\\s*,?\\s*)+)`, + 'g', + ); + while (true) { + const m = callRe.exec(content); + if (m == null) break; + const itemRe = /["']([^"']+)["']/g; + while (true) { + const im = itemRe.exec(m[1]); + if (im == null) break; + out.push(im[1]); + } + } + return Array.from(new Set(out)); + } + + // Matches: 'HEADER_SEARCH_PATHS' => '...' or => ["...","..."] + // Returns the raw token strings (still containing $(PODS_TARGET_SRCROOT) + // etc.); substitution happens at translation time. + function getHeaderSearchPaths() /*: Array */ { + const out /*: Array */ = []; + // Array form + const arrRe = /["']HEADER_SEARCH_PATHS["']\s*=>\s*\[([\s\S]*?)\]/g; + while (true) { + const m = arrRe.exec(content); + if (m == null) break; + const itemRe = /["']((?:[^"'\\]|\\.)+)["']/g; + while (true) { + const im = itemRe.exec(m[1]); + if (im == null) break; + // Each entry can be a single path or a space-separated list of paths. + for (const token of im[1].split(/\s+/)) { + if (token.length > 0) { + out.push(stripWrappingQuotes(token)); + } + } + } + } + // String form + const strRe = + /["']HEADER_SEARCH_PATHS["']\s*=>\s*["']((?:[^"'\\]|\\.)+)["']/g; + while (true) { + const m = strRe.exec(content); + if (m == null) break; + for (const token of m[1].split(/\s+/)) { + if (token.length > 0) { + out.push(stripWrappingQuotes(token)); + } + } + } + return Array.from(new Set(out)); + } + + // Surface known unparseable constructs so the caller can warn the user. + if (/(?:s|spec)\.subspec\s+["']/.test(content)) { + warnings.push( + 'Subspecs detected — regex parser only extracts top-level fields. Install CocoaPods (`gem install cocoapods`) to enable full `pod ipc spec` parsing.', + ); + } + if (/install_modules_dependencies/.test(content)) { + warnings.push( + '`install_modules_dependencies(s)` detected — dependency wiring may be incomplete without CocoaPods.', + ); + } + if (/\bENV\[/.test(content) || /\$config\[/.test(content)) { + warnings.push( + 'Env-var or Ruby-config interpolation detected — values may not translate cleanly without CocoaPods.', + ); + } + + return { + name: getStringField('name'), + version: getStringField('version'), + source_files: getArrayField('source_files'), + public_header_files: getArrayField('public_header_files'), + private_header_files: getArrayField('private_header_files'), + exclude_files: getArrayField('exclude_files'), + header_mappings_dir: getStringField('header_mappings_dir'), + header_dir: getStringField('header_dir'), + frameworks: getFrameworks(false), + weak_frameworks: getFrameworks(true), + libraries: getArrayField('libraries'), + dependencies: getDependencies(), + compiler_flags: tokenizeFlags(getStringField('compiler_flags')), + pod_target_xcconfig: {HEADER_SEARCH_PATHS: getHeaderSearchPaths()}, + resources: getArrayField('resources'), + requires_arc: /(?:s|spec)\.requires_arc\s*=\s*true/.test(content), + __regex_partial__: true, + __warnings__: warnings, + }; +} + +// Strips outer quote-like characters from a captured token. Handles bare +// quotes (`"foo"`, `'foo'`) AND Ruby-escaped quotes (`\"foo\"`) which show +// up verbatim when the regex captures something like +// `"HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/x\""`. +function stripWrappingQuotes(s /*: string */) /*: string */ { + return s.replace(/^\\?["']/, '').replace(/\\?["']$/, ''); +} + +function tokenizeFlags(value /*: string | null */) /*: Array */ { + if (value == null) return []; + return value.split(/\s+/).filter(Boolean); +} + +/** + * Split a flag string on whitespace while keeping quoted spans intact, so a + * define like `-DWORKLETS_FEATURE_FLAGS="[A:false][B:true]"` stays one token + * (the quotes are part of the macro value). A naive whitespace split would + * shred any define whose value contains spaces. + */ +function shellTokenize(value /*: string */) /*: Array */ { + const tokens /*: Array */ = []; + let cur = ''; + let quote /*: string | null */ = null; + let has = false; + for (let i = 0; i < value.length; i++) { + const c = value[i]; + if (quote != null) { + cur += c; + if (c === quote) quote = null; + } else if (c === '"' || c === "'") { + cur += c; + quote = c; + has = true; + } else if (/\s/.test(c)) { + if (has) tokens.push(cur); + cur = ''; + has = false; + } else { + cur += c; + has = true; + } + } + if (has) tokens.push(cur); + return tokens; +} + +// --------------------------------------------------------------------------- +// Subspec flattening +// --------------------------------------------------------------------------- + +/** + * Merges a podspec's default subspecs (or all subspecs when no defaults are + * declared) into a single logical view. RN libraries use subspecs for + * platform/feature gating (`apple` vs `common`); consumers compile the + * union anyway, so the flat view matches the actual build. + * + * Merge rules: + * - Array fields → concat + dedup + * - String fields → top-level wins; if absent, take the first subspec's value + * - pod_target_xcconfig HEADER_SEARCH_PATHS → array-union across all selected subspecs + * - dependencies → array-union of name strings (version constraints dropped) + */ +function flattenSubspecs(rawSpec /*: RawSpec */) /*: PodspecModel */ { + const warnings /*: Array */ = []; + // $FlowFixMe[prop-missing] dynamic shape + const partial /*: boolean */ = rawSpec.__regex_partial__ === true; + if (Array.isArray(rawSpec.__warnings__)) { + // $FlowFixMe[incompatible-type] runtime-validated dynamic shape + const rawWarnings /*: ReadonlyArray */ = rawSpec.__warnings__; + for (const w of rawWarnings) { + warnings.push(w); + } + } + + // Determine which subspecs to merge. pod ipc returns: + // - `subspecs`: array of nested specs (or undefined) + // - `default_subspecs`: array of names (or undefined → use all subspecs) + const subspecs = Array.isArray(rawSpec.subspecs) ? rawSpec.subspecs : []; + let selected = subspecs; + if (Array.isArray(rawSpec.default_subspecs)) { + const wanted = new Set(rawSpec.default_subspecs); + selected = subspecs.filter(s => { + // $FlowFixMe[prop-missing] dynamic shape + const name = s != null && typeof s.name === 'string' ? s.name : null; + return name != null && wanted.has(name); + }); + } + const layers = [rawSpec, ...selected]; + + function mergeArrayField(key /*: string */) /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer[key] : null; + if (Array.isArray(value)) { + for (const v of value) { + if (typeof v === 'string') out.push(v); + } + } else if (typeof value === 'string') { + out.push(value); + } + } + return Array.from(new Set(out)); + } + + function mergeStringField(key /*: string */) /*: string | null */ { + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer[key] : null; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return null; + } + + function mergeHeaderSearchPaths() /*: Array */ { + const out /*: Array */ = []; + // HSP can live in any of the xcconfig blocks, and a single value often + // PACKS multiple space-separated paths, each individually quoted, plus a + // CocoaPods `/**` recursive-glob suffix (e.g. skia: + // `"$(SRCROOT)/cpp/"/** "$(SRCROOT)/cpp/skia" ...`). Shell-tokenize to keep + // each path intact, then strip ALL quotes (not just wrapping) per token. + const XCCONFIG_KEYS = [ + 'pod_target_xcconfig', + 'xcconfig', + 'user_target_xcconfig', + ]; + for (const layer of layers) { + if (layer == null || typeof layer !== 'object') continue; + for (const xcKey of XCCONFIG_KEYS) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed` + const xc = layer[xcKey]; + if (xc == null || typeof xc !== 'object') continue; + const hsp = xc.HEADER_SEARCH_PATHS; + const values = + typeof hsp === 'string' + ? [hsp] + : Array.isArray(hsp) + ? hsp.filter(v => typeof v === 'string') + : []; + for (const v of values) { + for (const tok of shellTokenize(v)) { + const cleaned = tok.replace(/['"]/g, ''); + if (cleaned.length > 0) out.push(cleaned); + } + } + } + } + return Array.from(new Set(out)); + } + + // Lift preprocessor defines from pod_target_xcconfig across all layers: + // `-D` tokens in OTHER_CFLAGS, and NAME[=VALUE] entries in + // GCC_PREPROCESSOR_DEFINITIONS (incl. per-config `[config=*Debug*]` keys). + // Non-define compiler flags in OTHER_CFLAGS are intentionally dropped — only + // `-D`s are safe to forward; arbitrary flags may be machine- or + // example-app-specific. $(inherited), unresolved $(...) tokens, and invalid + // C identifiers are skipped. + function mergePreprocessorDefines() /*: Array */ { + const out /*: Array */ = []; + const seen /*: Set */ = new Set(); + const validName = /^[A-Za-z_]\w*$/; + const add = ( + name /*: string */, + value /*: ?string */, + config /*: ?('debug' | 'release') */, + ) => { + if (!validName.test(name)) return; + if (/\$[({]/.test(name) || (value != null && /\$[({]/.test(value))) { + return; // unresolved Xcode/Ruby token — don't emit a broken define + } + const key = `${name}|${config ?? ''}`; + if (seen.has(key)) return; + seen.add(key); + out.push({name, value, config}); + }; + // Defines can live in the target xcconfig (pod_target_xcconfig) OR the + // aggregate/user xcconfig (`s.xcconfig` / user_target_xcconfig). worklets + // puts its version define in pod_target_xcconfig; reanimated puts its in + // `s.xcconfig` — scan all three. + const XCCONFIG_KEYS = [ + 'pod_target_xcconfig', + 'xcconfig', + 'user_target_xcconfig', + ]; + for (const layer of layers) { + if (layer == null || typeof layer !== 'object') continue; + for (const xcKey of XCCONFIG_KEYS) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed` + const xc = layer[xcKey]; + if (xc == null || typeof xc !== 'object') continue; + for (const rawKey of Object.keys(xc)) { + const cflags = /^OTHER_CFLAGS(?:\[config=\*(\w+)\*\])?$/i.exec( + rawKey, + ); + const ppDefs = + /^GCC_PREPROCESSOR_DEFINITIONS(?:\[config=\*(\w+)\*\])?$/i.exec( + rawKey, + ); + if (cflags == null && ppDefs == null) continue; + const cfgRaw = ( + (cflags?.[1] ?? ppDefs?.[1] ?? '') + '' + ).toLowerCase(); + const config = + cfgRaw === 'debug' + ? 'debug' + : cfgRaw === 'release' + ? 'release' + : null; + // $FlowFixMe[incompatible-use] xc value access is intentional + const val = xc[rawKey]; + const strs = + typeof val === 'string' + ? [val] + : Array.isArray(val) + ? val.filter(v => typeof v === 'string') + : []; + for (const s of strs) { + for (const tok of shellTokenize(s)) { + if (tok === '$(inherited)') continue; + // OTHER_CFLAGS: only `-D` tokens are defines; others are flags. + // GCC_PREPROCESSOR_DEFINITIONS: every token is `NAME[=VALUE]`. + let body /*: ?string */ = null; + if (cflags != null) { + if (tok.startsWith('-D')) body = tok.slice(2); + } else { + body = tok; + } + if (body == null || body.length === 0) continue; + const eq = body.indexOf('='); + const name = eq >= 0 ? body.slice(0, eq) : body; + const value = eq >= 0 ? body.slice(eq + 1) : null; + add(name, value, config); + } + } + } + } + } + return out; + } + + function mergeDependencies() /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const deps = layer != null ? layer.dependencies : null; + if (deps == null) continue; + // pod ipc returns deps as: {name: [versionConstraint, ...], ...} + // regex returns deps as: [name, name, ...] + if (Array.isArray(deps)) { + for (const d of deps) { + if (typeof d === 'string') out.push(d); + } + } else if (typeof deps === 'object') { + for (const name of Object.keys(deps)) { + out.push(name); + } + } + } + return Array.from(new Set(out)); + } + + function mergeCompilerFlags() /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer.compiler_flags : null; + if (typeof value === 'string') { + for (const tok of value.split(/\s+/)) { + if (tok.length > 0) out.push(tok); + } + } else if (Array.isArray(value)) { + for (const v of value) { + if (typeof v === 'string') { + for (const tok of v.split(/\s+/)) { + if (tok.length > 0) out.push(tok); + } + } + } + } + } + return out; + } + + // $FlowFixMe[prop-missing] dynamic shape + const reqArc /*: unknown */ = rawSpec.requires_arc; + const requiresArc = + reqArc === false || (Array.isArray(reqArc) && reqArc.length === 0) + ? false + : true; // RN ecosystem default + + return { + name: mergeStringField('name') ?? '', + version: mergeStringField('version') ?? '', + sourceFiles: mergeArrayField('source_files'), + publicHeaderFiles: mergeArrayField('public_header_files'), + privateHeaderFiles: mergeArrayField('private_header_files'), + excludeFiles: mergeArrayField('exclude_files'), + headerMappingsDir: mergeStringField('header_mappings_dir'), + // ALL subspecs' header_mappings_dir values (not just the merged one). Each + // implies a header search path of its parent dir so namespaced includes + // (`` for a header physically at + // `apple/reanimated/apple/sensor/X.h` with mappings dir `apple/reanimated`) + // resolve from the physical tree — CocoaPods does this via the + // header_mappings_dir copy step, which SPM has no equivalent for. + headerMappingsDirs: mergeArrayField('header_mappings_dir'), + headerDir: mergeStringField('header_dir'), + frameworks: mergeArrayField('frameworks'), + weakFrameworks: mergeArrayField('weak_frameworks'), + libraries: mergeArrayField('libraries'), + dependencies: mergeDependencies(), + preprocessorDefines: mergePreprocessorDefines(), + compilerFlags: mergeCompilerFlags(), + headerSearchPaths: mergeHeaderSearchPaths(), + resources: mergeArrayField('resources'), + requiresArc, + warnings, + partial, + // Default: overridden in readPodspec, which can see the podspec source. + usesInstallModulesDependencies: false, + }; +} + +// --------------------------------------------------------------------------- +// Top-level entry +// --------------------------------------------------------------------------- + +/** + * Reads and flattens a podspec at `podspecPath`. Tries `pod ipc spec` first + * (full Ruby DSL evaluation); falls back to a regex parser when CocoaPods + * isn't available. + * + * Throws when the file doesn't exist. Otherwise always returns a + * PodspecModel — warnings on the model surface partial parses to the + * caller, who can decide whether to proceed or abort scaffolding for that + * dep. + */ +function readPodspec(podspecPath /*: string */) /*: PodspecModel */ { + if (!fs.existsSync(podspecPath)) { + throw new Error(`readPodspec: file does not exist: ${podspecPath}`); + } + const podIpc = runPodIpcSpec(podspecPath); + const raw = podIpc != null ? podIpc : regexPodspec(podspecPath); + const model = flattenSubspecs(raw); + // Record whether the podspec calls `install_modules_dependencies(s)`. We + // strip that helper before `pod ipc` and the regex parser can't expand it, + // so the React-Core family it injects never lands in `model.dependencies`. + // The scaffolder uses this as an implicit React-core marker (plain ObjC + // modules using the helper have no `codegenConfig` to detect instead). + try { + const src = fs.readFileSync(podspecPath, 'utf8'); + model.usesInstallModulesDependencies = + /\binstall_modules_dependencies\b/.test(src); + } catch { + // best-effort; leave the flattenSubspecs default (false) + } + return model; +} + +module.exports = { + readPodspec, + runPodIpcSpec, + regexPodspec, + flattenSubspecs, +}; diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js new file mode 100644 index 000000000000..09e246b5cb17 --- /dev/null +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -0,0 +1,1215 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: +import type { + AutolinkedDep, + AutolinkingIosPlatform, + PodspecModel, + ScaffoldResult, + SpmScaffoldSpec, +} from './spm-types'; +*/ + +/** + * scaffold-package-swift.js — generates a `Package.swift` into + * `node_modules//` for community RN libraries that don't ship SPM + * support. The autolinker's existing `isSelfManagedPackage` check + * (generate-spm-autolinking.js, AUTOGEN_MARKER) treats these scaffolded + * files as self-managed and references them directly from the aggregator — + * no autolinker changes required. + * + * Three layers: + * - translatePodspecToSpmTarget(model, dep): pure translation + * - emitScaffoldedPackageSwift(spec, ctx): pure Swift emission + * - scaffoldPackageSwiftForDep(dep, ctx): I/O + skip-rule + write + * - scaffoldAll(opts): orchestrator over autolinking.json + */ + +const { + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, +} = require('./expand-spm-dependencies'); +const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); +const {readPodspec} = require('./read-podspec'); +const { + SCAFFOLDER_MARKER, + makeLogger, + remotePackageConfig, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +const {log} = makeLogger('scaffold-package-swift'); + +// SCAFFOLDER_MARKER lives in spm-utils.js (shared, no import cycle). It must NOT +// contain the autolinker's AUTOGEN_MARKER ('// AUTO-GENERATED by +// scripts/generate-spm-autolinking.js') — that marker is what the autolinker +// uses to tell self-managed deps from its own synth output. Our marker is +// recognized by the scaffolder itself (skip-rule), but invisible to the +// autolinker (treated as self-managed). + +// Bump when the emitter's output format changes in a way that requires +// re-scaffolding existing files (rather than just being a content drift the +// cache-slot label already handles). v2: swift-tools-version moved to +// line 1 — pre-v2 scaffolds emit it on a later line, which recent Xcode +// rejects with "Swift tools version 3.1.0 ... no longer supported". v4: the +// single `let rnHeaders = appRoot + "/.../ReactHeadersAll"` split into the two +// `rnCoreHeaders` / `appHeaders` lets (merged tree replaced by two trees), so +// pre-v4 scaffolds reference a tree that is no longer materialized. v5: +// header resolution moved to product dependencies (ReactNativeHeaders + +// ReactAppHeaders binary/headers targets) — the rnCoreHeaders/appHeaders +// trees no longer exist, so pre-v5 scaffolds carry dead lets and would break +// if anything still referenced them. v7: the runtime appRoot walker and the +// siblingPath helper are gone — package paths are plain relative strings +// computed at scaffold time (the walker anchored on +// build/xcframeworks/Package.swift, which remote mode no longer writes). +// +// Skip-rule contract: when an existing file's version is < this constant, +// the scaffolder regenerates regardless of --force (the bump implies the +// existing file is broken under current tooling). A file with the marker +// but no version line is treated as v1. +// v8: relative app paths (codegen / xcframeworks) are now computed from the +// autolinker's libs/ symlink location instead of the real dep.root, +// fixing a doubled-path resolution failure on fresh SwiftPM resolves. +// v9: header search paths derived from each subspec's header_mappings_dir +// (dirname) so namespaced includes (``) resolve. v10: +// publicHeadersPath derived from the header_mappings_dir namespace root so a +// package exposes `` to dependents; pod-style sibling deps +// (reanimated's `s.dependency "RNWorklets"`) wired to their npm package. v11: +// sibling .package path uses the libs/ symlink name, not the npm +// name (fixes "package ... doesn't exist" on resolve). v12: preprocessor +// defines from pod_target_xcconfig emitted as `.define(...)`. v13: ObjC(++) +// targets get an ambient-import prefix header (Foundation/UIKit) `-include`d, +// replacing CocoaPods' generated prefix.pch. v18: React-core deps also get +// the ReactNativeDependenciesHeaders product (ReactNativeHeaders went +// pure-RN; folly/glog/boost/... now come from the deps sidecar); +// publicHeadersPath falls back to "." for root-level-source podspecs +// (`s.source_files = "*.{h,m,mm}"`) instead of SPM's nonexistent `include/`. +// v19: scaffolded C++ targets carry DEBUG/NDEBUG config defines so their Fabric +// ABI matches the prebuilt React.framework (Release strips DebugStringConvertible +// under NDEBUG). Bumped so existing scaffolds regenerate with the defines. +const SCAFFOLDER_VERSION = 19; +const SCAFFOLDER_VERSION_LINE_RE = /^\/\/ AUTO-SCAFFOLDED-VERSION: (\d+)$/m; + +const AUTOGEN_MARKER = + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js'; + +// CocoaPods auto-generates a `-prefix.pch` that imports Foundation + +// UIKit into every ObjC translation unit, so pod sources can use NSThread / +// dispatch / UIKit symbols without an explicit import. SPM has no prefix-header +// mechanism, so we emit this file at the dep root and `-include` it on the +// target (cSettings + cxxSettings) to reproduce that ambient import. The +// `__OBJC__` guard makes it inert for plain C/C++ sources, and UIKit is +// `__has_include`-guarded for platforms that lack it. +// The config-gated C++ defines the prebuilt React.framework is built with (see +// packages/react-native/Package.swift). Fabric C++ in autolinked deps must +// compile with the SAME NDEBUG state or its DebugStringConvertible / ShadowNode +// ABI diverges from the Release React.framework and the link fails. +const REACT_CXX_CONFIG_DEFINES = [ + '.define("DEBUG", .when(configuration: .debug))', + '.define("NDEBUG", .when(configuration: .release))', +]; + +const SCAFFOLD_PREFIX_HEADER = 'react-native-spm-prefix.h'; +const SCAFFOLD_PREFIX_HEADER_CONTENTS = `// AUTO-SCAFFOLDED by react-native spm scaffold — mirrors CocoaPods' default +// prefix header so ObjC sources that rely on an implicit Foundation/UIKit +// import compile under SPM (which has no prefix-header mechanism). Safe to +// delete + regenerate via \`npx react-native spm scaffold\`. +#ifdef __OBJC__ +#import +#if __has_include() +#import +#endif +#endif +`; + +// Names of deps the scaffolder always refuses to touch — `react-native` +// itself is handled by the xcframework subpackage, never as an autolinked +// target. +const NEVER_SCAFFOLD /*: ReadonlySet */ = new Set(['react-native']); + +// React-core / React-Fabric / etc. dependency names collapse to a single +// invariant React header-product set. The app target links the prebuilt +// runtime frameworks outside SwiftPM. This +// list matches what `install_modules_dependencies(s)` materializes plus +// common podspec hand-rolled additions. +const REACT_CORE_DEP_PREFIXES = [ + 'React-', + 'React_', + 'ReactCommon', + 'RCT-Folly', + 'RCT', + 'glog', + 'boost', + 'fmt', + 'DoubleConversion', + 'Yoga', + 'hermes-engine', +]; + +function isReactCoreDep(name /*: string */) /*: boolean */ { + return REACT_CORE_DEP_PREFIXES.some(p => name.startsWith(p)); +} + +// True when the dep ships a `codegenConfig` in package.json — RN's standard +// marker that the library participates in the New Architecture / codegen. Such +// a lib's Fabric sources include the app-generated component headers +// (`/ShadowNodes.h>` etc.), which are vended by +// the per-app React-GeneratedCode package — so it implicitly depends on React +// core even when its podspec only wires that via `install_modules_dependencies` +// (which we strip). Safe/quiet: a missing or unparseable package.json → false. +function depHasCodegenConfig(depRoot /*: string */) /*: boolean */ { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(depRoot, 'package.json'), 'utf8'), + ); + return pkg != null && pkg.codegenConfig != null; + } catch { + return false; + } +} + +// Every subdirectory (relative to depRoot) under depRoot/base, recursively — +// used to expand a CocoaPods `path/**` recursive header search path into the +// concrete dirs SPM needs (SPM has no recursive search-path syntax). Skips +// VCS / build / dependency noise. +function collectSubdirs( + depRoot /*: string */, + base /*: string */, +) /*: Array */ { + const SKIP /*: Set */ = new Set([ + 'node_modules', + 'Pods', + 'build', + '.git', + ]); + const out /*: Array */ = []; + const walk = (absDir /*: string */, relDir /*: string */) => { + let entries: Array<{name: string, isDirectory(): boolean}>; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(absDir, {withFileTypes: true}); + } catch { + return; + } + for (const e of entries) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs + const name /*: string */ = e.name; + if (!e.isDirectory() || name.startsWith('.') || SKIP.has(name)) continue; + const rel = relDir === '.' ? name : `${relDir}/${name}`; + out.push(rel); + walk(path.join(absDir, name), rel); + } + }; + walk(path.join(depRoot, base), base === '.' ? '.' : base); + return out; +} + +// --------------------------------------------------------------------------- +// Translation +// --------------------------------------------------------------------------- + +/** + * Pure: turns a flattened PodspecModel into an SpmScaffoldSpec the emitter + * can consume. Substitutes Xcode build-setting tokens (`$(PODS_TARGET_SRCROOT)`) + * against the dep root, buckets dependencies, drops unknown tokens with a + * warning. + */ +function translatePodspecToSpmTarget( + model /*: PodspecModel */, + dep /*: AutolinkedDep */, + // Maps a podspec name (e.g. "RNWorklets") to the npm package name of an + // autolinked sibling (e.g. "react-native-worklets"). Lets us wire a + // `s.dependency "RNWorklets"` — a pod-style name the `react-native-*` + // heuristic can't recognize — to the right sibling package. Empty by default. + podToNpm /*: Map */ = new Map(), +) /*: SpmScaffoldSpec */ { + const warnings = [...model.warnings]; + + // Swift target name: ALWAYS toSwiftName(npm-name). The autolinker + // registers each autolinked dep under that name in its aggregator (and in + // any sibling spm.dependencies refs), so the scaffolded Package.swift's + // product/library name must match — otherwise SPM resolution fails with + // a name mismatch on `.product(name: "X", package: "X")`. + // + // The podspec's `header_dir` is captured separately: when it changes the + // include surface (e.g. `` instead of ``), + // header resolution already works via the -I flags from headerSearchPaths + // (path-style includes like safe-area-context's + // `` resolve through + // `-I common/cpp/`). Module-style includes that NEED the target name to + // match (e.g. reanimated's `` via SwiftPM's auto-generated + // module map) require an explicit `spm.name` override in + // react-native.config.js — handled by the existing autolinker flow, not + // here. + const swiftName = toSwiftName(dep.name); + + // Header search paths — substitute Xcode build-setting tokens against the + // dep root. Anything we can't substitute is dropped + warned (avoids + // emitting `$(SOMETHING)` literally into the Swift file). + const headerSearchPaths /*: Array */ = []; + const addSearchPath = (p /*: string */) => { + if (p.length > 0 && !headerSearchPaths.includes(p)) { + headerSearchPaths.push(p); + } + }; + for (const raw of model.headerSearchPaths) { + let substituted = raw + .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.') + .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.'); + if (/\$[({]/.test(substituted)) { + warnings.push( + `Dropped HEADER_SEARCH_PATHS entry "${raw}" — contains unresolved Xcode token. ` + + `Add it manually if needed.`, + ); + continue; + } + // CocoaPods `path/**` (or `/*`) = recursive search. SPM has no recursive + // search-path syntax, so add the base dir + every subdirectory under it. + const recursive = /\/\*\*?$/.test(substituted); + substituted = substituted + .replace(/\/\*\*?$/, '') // strip the glob marker + .replace(/\/{2,}/g, '/'); // collapse `cpp//` → `cpp/` + // Strip leading "./" (emitter prefixes with the target path) + trailing "/". + const base = substituted + .replace(/^\.\//, '') + .replace(/^\//, '') + .replace(/\/$/, ''); + addSearchPath(base === '' ? '.' : base); + if (recursive) { + for (const sub of collectSubdirs(dep.root, base === '' ? '.' : base)) { + addSearchPath(sub); + } + } + } + + // header_mappings_dir → search path. CocoaPods exposes a subspec's headers + // under `/...` by copying them into Pods/Headers + // preserving structure relative to the mappings dir. SPM has no such copy + // step, so namespaced includes like `` (header + // physically at `apple/reanimated/apple/sensor/X.h`, mappings dir + // `apple/reanimated`) only resolve if the mappings dir's PARENT (`apple`) is + // on the search path. Add dirname() of every subspec's mappings dir. + for (const mappingsDir of model.headerMappingsDirs) { + const parent = path.posix.dirname(mappingsDir.replace(/^\.\//, '')); + // dirname of a single-segment dir is "." (root) — already implicitly + // searched; skip it and anything that doesn't exist on disk. + if ( + parent.length > 0 && + parent !== '.' && + !headerSearchPaths.includes(parent) && + fs.existsSync(path.join(dep.root, parent)) + ) { + headerSearchPaths.push(parent); + } + } + + // Bucket dependencies. React-Core / React-jsi / RCT-Folly / glog etc. ALL + // collapse to the invariant React header products + // reference because they're bundled in the prebuilt React.xcframework. + // Sibling RN libs (autolinked or self-managed deps) flow through the + // existing spm-deps mechanism. Unknown names are dropped with a warning. + let coreReactNative = false; + const siblingNames /*: Array */ = []; + const selfPodspecName = model.name; + for (const depName of model.dependencies) { + // Cross-subspec refs like "react-native-safe-area-context/common" are a + // CocoaPods construct for one subspec depending on another from the + // SAME podspec. After flattenSubspecs has merged everything into one + // target the subspec ref is meaningless — drop silently. + if (depName.includes('/') && depName.startsWith(`${selfPodspecName}/`)) { + continue; + } + const podSibling = podToNpm.get(depName.split('/')[0]); + if (isReactCoreDep(depName)) { + coreReactNative = true; + } else if (depName.startsWith('react-native-')) { + // Generic cross-subspec ref guard for any "package/subspec" form. + const baseName = depName.split('/')[0]; + if (!siblingNames.includes(baseName)) { + siblingNames.push(baseName); + } + } else if (podSibling != null && podSibling !== dep.name) { + // A pod-style dependency name (e.g. reanimated's `s.dependency + // "RNWorklets"`) that resolves to an autolinked sibling's npm package + // (react-native-worklets). Wire it as a sibling .package/.product so + // the dep's cross-package includes (``) resolve. + if (!siblingNames.includes(podSibling)) { + siblingNames.push(podSibling); + } + } else { + // Could be a non-RN dep ("MMKV", "AFNetworking"). The scaffolder + // doesn't know how to wire those — surface a warning so user can + // edit the generated Package.swift. + warnings.push( + `Unknown dependency "${depName}" — not wired into the scaffolded Package.swift. Edit manually if needed.`, + ); + } + } + + // New-Architecture libraries declare their React-core dependency via the + // `install_modules_dependencies(s)` podspec helper (which auto-adds + // React-Core / React-RCTFabric / React-Codegen), NOT an explicit + // `s.dependency "React-Core"`. We strip that helper when evaluating the + // podspec, so the React-core dependency never surfaces in model.dependencies + // and `coreReactNative` would stay false. The authoritative, RN-standard + // marker for "this lib participates in the New Architecture / codegen" is a + // `codegenConfig` block in package.json — when present, the app's codegen has + // generated `react/renderer/components//{ShadowNodes,Props,…}.h` that + // the lib's Fabric sources include via angle brackets. Those generated + // headers live in the per-app React-GeneratedCode package, so the lib must + // depend on it (and, transitively, on React core). Treat codegenConfig as + // an implicit React-core dependency. + // `codegenConfig` only marks Fabric/New-Arch libraries. A plain ObjC module + // (e.g. rn-tester's TestLibrary*) that wires React core solely through + // `install_modules_dependencies(s)` has no codegenConfig — but the helper's + // presence is itself an authoritative "depends on React core" signal, so + // treat it the same way. + if ( + !coreReactNative && + (depHasCodegenConfig(dep.root) || + model.usesInstallModulesDependencies === true) + ) { + coreReactNative = true; + } + + // SPM's `sources:` field does NOT accept CocoaPods-style globs — it wants + // a list of explicit file paths (relative to the target's `path:`). + // Expand the podspec globs against the actual filesystem so the emitted + // Package.swift parses cleanly. + let expandedSources /*: Array */ = []; + try { + expandedSources = expandSpmSourceGlobs(dep.root, model.sourceFiles); + } catch (e) { + warnings.push( + `Source glob expansion failed (${e.message}); emitting raw globs — SPM will likely reject them.`, + ); + expandedSources = model.sourceFiles; + } + // Strip files matching exclude_files (podspec's negation list). + if (model.excludeFiles.length > 0) { + try { + // $FlowFixMe[untyped-import] micromatch has no types + const micromatch = require('micromatch'); + const isExcluded = micromatch.matcher(model.excludeFiles); + expandedSources = expandedSources.filter(f => !isExcluded(f)); + } catch (e) { + warnings.push( + `exclude_files filtering failed (${e.message}); keeping all sources.`, + ); + } + } + // Never self-ingest scaffold artifacts: a re-scaffold runs with the + // previous run's output on disk, and root-level podspec globs sweep it + // into sources — `*.{h,...,swift}` matches Package.swift itself, whose + // `.swift` extension then trips the mixed-language gate into DELETING the + // manifest it just wrote (alternating scaffold/self-destruct runs); the + // prefix header and the include/ shim mirror would similarly feed back. + expandedSources = expandedSources.filter( + f => + f !== SCAFFOLD_PREFIX_HEADER && + f !== 'Package.swift' && + !f.startsWith('include/'), + ); + + // Header-map emulation: CocoaPods builds a header map (USE_HEADERMAP=YES by + // default) so a source can `#import "Foo.h"` by bare name regardless of which + // subdirectory Foo.h lives in. SPM has no header map, so add every directory + // that contains a header to the search path. Covers libs (e.g. svg) that + // spread flat-named headers across many subdirs. + const headerFiles = expandedSources.filter(f => /\.(h|hh|hpp)$/i.test(f)); + try { + for (const f of expandSpmSourceGlobs(dep.root, model.publicHeaderFiles)) { + if (/\.(h|hh|hpp)$/i.test(f)) headerFiles.push(f); + } + } catch { + // public_header_files globbing is best-effort — source_files usually + // already covers the headers. + } + for (const f of headerFiles) { + const d = path.posix.dirname(f); + addSearchPath(d === '' ? '.' : d); + } + + // ObjC(++) sources may rely on CocoaPods' implicit prefix-header import of + // Foundation/UIKit. When present, emit + `-include` a prefix header (below). + // The prefix is `-include`d by bare name, so the dep root (".") must be on + // the header search path for clang to find it. + const needsObjCPrefix = expandedSources.some(f => /\.mm?$/.test(f)); + if (needsObjCPrefix && !headerSearchPaths.includes('.')) { + headerSearchPaths.push('.'); + } + + // SPM REQUIRES publicHeadersPath to be a real directory inside the target + // when the target compiles C-family sources (default is "include" which + // typically doesn't exist in a podspec-shaped dep). Pick the first + // existing prefix dir from public_header_files / source_files. The + // chosen dir doesn't need to be the include surface — path-style + // includes (``) resolve via headerSearchPaths + // instead. publicHeadersPath just has to point at a real dir containing + // some .h files so SPM accepts the target definition. + let publicHeadersPath /*: ?string */ = null; + // Prefer the namespace root (parent of a header_mappings_dir). SPM propagates + // a target's publicHeadersPath to DEPENDENT packages as a search path, so + // setting it to e.g. `Common/cpp` (parent of `Common/cpp/worklets`) is what + // lets a sibling package resolve ``. Prefer a cross-platform + // (Common) dir — that holds the C++ API siblings consume — over a + // platform-specific (apple/) one. + const mappingsParents = model.headerMappingsDirs + .map(d => path.posix.dirname(d.replace(/^\.\//, ''))) + .filter( + p => p.length > 0 && p !== '.' && fs.existsSync(path.join(dep.root, p)), + ); + if (mappingsParents.length > 0) { + publicHeadersPath = + mappingsParents.find(p => /(?:^|\/)common(?:\/|$)/i.test(p)) ?? + mappingsParents[0]; + } + if (publicHeadersPath == null) { + for (const glob of [...model.publicHeaderFiles, ...model.sourceFiles]) { + const prefix = glob.split('/')[0]; + if ( + prefix.length > 0 && + !prefix.includes('*') && + fs.existsSync(path.join(dep.root, prefix)) + ) { + publicHeadersPath = prefix; + break; + } + } + } + let namespacedShimHeaders /*: Array */ = []; + if (publicHeadersPath == null) { + // Root-level podspecs (`s.source_files = "*.{h,m,mm}"`) keep their + // headers in the package root. CocoaPods exposes them to dependents as + // `#import ` via header maps; SPM has no equivalent, so + // the scaffold writer mirrors them into a generated include// + // shim dir and publicHeadersPath points there (also fixing SPM's + // rejection over the nonexistent default `include/`). + const rootHeaders = expandedSources.filter( + f => f.endsWith('.h') && !f.includes('/'), + ); + if (rootHeaders.length > 0) { + namespacedShimHeaders = rootHeaders; + publicHeadersPath = 'include'; + } + } + if (publicHeadersPath == null) { + warnings.push( + `Could not infer publicHeadersPath from source globs. SPM may reject the target — add a publicHeadersPath manually to the scaffolded Package.swift.`, + ); + } + + return { + swiftName, + sources: expandedSources, + headerSearchPaths, + preprocessorDefines: model.preprocessorDefines, + needsObjCPrefix, + coreReactNative, + siblingNames, + extraFrameworks: model.frameworks, + weakFrameworks: model.weakFrameworks, + compilerFlags: model.compilerFlags, + publicHeadersPath, + namespacedShimHeaders, + resources: model.resources, + warnings, + }; +} + +// --------------------------------------------------------------------------- +// Swift emission +// --------------------------------------------------------------------------- + +/*:: +type EmitContext = { + // Identifier embedded as a comment so SPM's manifest hash bumps when the + // active xcframework slot changes (otherwise cached evaluations would + // keep pointing at the prior slot's headers). + cacheSlotLabel: ?string, + // Remote SPM package mode (url/version/identity) — see remotePackageConfig. + remote?: ?{url: string, version: string, identity: string}, + // Relative path (posix, from the dep's package dir) to the app's codegen + // package (/build/generated/ios). Computed at scaffold time — + // safe because the file is re-scaffolded per app/cache slot, and any + // node_modules relayout implies a reinstall that drops the file anyway. + codegenPackageDir?: ?string, + // Relative path to the app's local xcframeworks package + // (/build/xcframeworks). Only referenced when remote == null. + localXcfwPackageDir?: ?string, +}; +*/ + +/** + * Renders the SpmScaffoldSpec to a complete Package.swift string suitable + * for writing into `node_modules//`. Fully declarative: all package + * references are plain relative paths computed at scaffold time (no runtime + * discovery — the scaffolder knows both the dep dir and the app root, and + * re-scaffolds whenever either could have moved). + */ +function emitScaffoldedPackageSwift( + spec /*: SpmScaffoldSpec */, + ctx /*:: ?: EmitContext */ = {cacheSlotLabel: null, remote: null}, +) /*: string */ { + const slotComment = + ctx.cacheSlotLabel != null ? `\n// Cache slot: ${ctx.cacheSlotLabel}` : ''; + + // React headers need NO search paths — they come from the React / + // ReactNativeHeaders binaryTargets and the ReactAppHeaders product (see + // the dependencies block below). Per-dep HEADER_SEARCH_PATHS from the + // podspec are emitted as `.headerSearchPath("")` so they resolve + // relative to `path:` ("." below). Custom podspec compiler flags keep + // using unsafeFlags (rare; surfaced verbatim). + const customFlagsCxx = spec.compilerFlags.map(f => `"${f}"`); + + const headerSearchPathDirectives = spec.headerSearchPaths + .map(p => `.headerSearchPath("${p}")`) + .join(', '); + + // Preprocessor defines → `.define("NAME", to: "VALUE", .when(...))`. The + // value is escaped as a Swift string literal because it may itself contain + // quotes (e.g. a string-literal macro `-DFOO="[A:false]"`). + const swiftStr = (s /*: string */) => + `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + const defineDirectives = (spec.preprocessorDefines ?? []) + .map(d => { + const toPart = d.value != null ? `, to: ${swiftStr(d.value)}` : ''; + const condPart = + d.config === 'debug' + ? ', .when(configuration: .debug)' + : d.config === 'release' + ? ', .when(configuration: .release)' + : ''; + return `.define(${swiftStr(d.name)}${toPart}${condPart})`; + }) + .join(', '); + + const settingsEntries = (extra /*: Array */) => { + const parts = [ + defineDirectives, + headerSearchPathDirectives, + ...extra, + ].filter(e => e.length > 0); + return `[${parts.join(', ')}]`; + }; + // Force-include the ambient-import prefix header on ObjC(++) sources (the + // `__OBJC__` guard makes it a no-op for plain C/C++). `-include` resolves the + // bare name via the dep-root header search path (".", ensured in translate). + const prefixFlag = spec.needsObjCPrefix + ? `.unsafeFlags(["-include", "${SCAFFOLD_PREFIX_HEADER}"])` + : ''; + const cSettings = settingsEntries([prefixFlag].filter(e => e.length > 0)); + const cxxSettings = settingsEntries( + [ + prefixFlag, + ...(customFlagsCxx.length > 0 + ? [`.unsafeFlags([${customFlagsCxx.join(', ')}])`] + : []), + // Match the prebuilt React.framework's config-gated C++ ABI. NDEBUG in + // Release strips DebugStringConvertible's vtable (and shifts the + // ShadowNode layout that inherits it), so a Fabric C++ target compiled + // WITHOUT NDEBUG fails to link against a Release React.framework. Mirrors + // packages/react-native/Package.swift's own C++ targets. + ...REACT_CXX_CONFIG_DEFINES, + ].filter(e => e.length > 0), + ); + + // Linker frameworks: defaults + podspec-declared extras + weak frameworks. + // Dedup on render so the user doesn't see duplicate UIKit lines. + const defaults = ['UIKit', 'AppKit', 'Foundation', 'CoreGraphics']; // [macOS] + const linkedFrameworks = Array.from( + new Set([...defaults, ...spec.extraFrameworks]), + ); + const linkerEntries = [ + ...linkedFrameworks.map(f => { + // [macOS] Framework defaults must be valid on the target Apple platform. + const condition = + f === 'UIKit' + ? ', .when(platforms: [.iOS, .tvOS, .visionOS, .macCatalyst])' + : f === 'AppKit' + ? ', .when(platforms: [.macOS])' + : ''; + return `.linkedFramework("${f}"${condition})`; + }), + ...spec.weakFrameworks.map( + f => `.linkedFramework("${f}", .when(platforms: [.iOS]))`, + ), + ]; + + // Dependencies block. Always declares ReactNative if any React-core dep + // was in the podspec; sibling RN deps come from autolinking's existing + // .package(path: ...) graph — we reference them by toSwiftName(npmName) + // since that's what the autolinker registers them under. + const packageDeps /*: Array */ = []; + const targetDeps /*: Array */ = []; + if (spec.coreReactNative) { + // Remote mode: React Native comes from the remote package identity (no + // app-layout knowledge). The per-app codegen package is generated INTO + // the app by definition, so it stays a path reference — relative, + // computed at scaffold time. + const remote = ctx.remote; + const rnLabel = remote != null ? remote.identity : 'ReactNative'; + const codegenDir = ctx.codegenPackageDir; + if (codegenDir == null) { + throw new Error( + 'emitScaffoldedPackageSwift: codegenPackageDir is required when the dep depends on React core.', + ); + } + if (remote != null) { + packageDeps.push( + `.package(url: "${remote.url}", exact: "${remote.version}")`, + ); + } else { + const xcfwDir = ctx.localXcfwPackageDir; + if (xcfwDir == null) { + throw new Error( + 'emitScaffoldedPackageSwift: localXcfwPackageDir is required when no remote package is configured.', + ); + } + packageDeps.push(`.package(name: "ReactNative", path: "${xcfwDir}")`); + } + packageDeps.push( + `.package(name: "React-GeneratedCode", path: "${codegenDir}")`, + ); + targetDeps.push(`.product(name: "ReactHeaders", package: "${rnLabel}")`); + targetDeps.push( + `.product(name: "ReactNativeHeaders", package: "${rnLabel}")`, + ); + targetDeps.push( + `.product(name: "ReactNativeDependenciesHeaders", package: "${rnLabel}")`, + ); + targetDeps.push( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + } + for (const siblingName of spec.siblingNames) { + const swiftSibling = toSwiftName(siblingName); + // The autolinker references each self-managed (scaffolded) dep through a + // `libs/` symlink, and SPM resolves a manifest's relative + // package paths against that symlink location — so a sibling lives at + // `../` (NOT `../`, which would be `libs/` + // and not exist). + packageDeps.push( + `.package(name: "${swiftSibling}", path: "../${swiftSibling}")`, + ); + targetDeps.push( + `.product(name: "${swiftSibling}", package: "${swiftSibling}")`, + ); + } + + // sources: emit only when the podspec declared them. If empty (regex-fallback + // podspec), no `sources:`/`exclude:` is emitted and SPM auto-scans the target + // dir — which for `path: "."` is the entire dep root. That is a known + // limitation of the regex-fallback path (the pod-ipc parse yields explicit + // sources); a future improvement could emit an `exclude:` list for the dep's + // example/test/build dirs. + const sourcesLine = + spec.sources.length > 0 + ? `\n sources: [\n${spec.sources.map(s => ` "${s}",`).join('\n')}\n ],` + : ''; + + const publicHeadersLine = + spec.publicHeadersPath != null + ? `\n publicHeadersPath: "${spec.publicHeadersPath}",` + : ''; + + const resourcesLine = + spec.resources.length > 0 + ? `\n resources: [${spec.resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + + const targetDepsLine = + targetDeps.length > 0 + ? `\n dependencies: [${targetDeps.join(', ')}],` + : ''; + + return `// swift-tools-version: 6.0 +${SCAFFOLDER_MARKER} +// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}${slotComment} +// Edit the contents below if needed and re-run \`npx patch-package \` +// to persist across \`npm install\`. To regenerate from the podspec, remove +// this file (or just this marker) and re-run \`npx react-native spm scaffold\`. +// +// Package references are plain relative paths, computed when this file was +// scaffolded. They stay correct because the file is re-scaffolded per app +// and cache slot, and any node_modules relayout reinstalls this package +// (dropping the file) anyway. + +import PackageDescription + +let package = Package( + name: "${spec.swiftName}", + platforms: [.iOS(.v15)], + products: [ + .library(name: "${spec.swiftName}", targets: ["${spec.swiftName}"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "${spec.swiftName}",${targetDepsLine} + path: ".",${sourcesLine}${publicHeadersLine}${resourcesLine} + cSettings: ${cSettings}, + cxxSettings: ${cxxSettings}, + linkerSettings: [${linkerEntries.join(', ')}] + ), + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +// --------------------------------------------------------------------------- +// Per-dep orchestrator +// --------------------------------------------------------------------------- + +/*:: +type ScaffoldContext = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + // Forces overwrite of files carrying the scaffolder marker. Files + // WITHOUT the marker (user-edited, upstream-shipped) are never touched. + force: boolean, + // When true, no file is written — caller gets the would-be content and + // can preview. + dryRun: boolean, + // Slot label (e.g. "0.87.0-nightly-20260513-6e262624f/debug") embedded as + // a comment so SPM's manifest hash bumps on slot changes. + cacheSlotLabel: ?string, + // podspec-name → npm-name index over all autolinked deps, so pod-style + // `s.dependency` names (e.g. "RNWorklets") wire to the right sibling. + podToNpm?: Map, +}; +*/ + +/** + * Decides whether to scaffold one dep, runs the translation + emission, + * writes the file (unless dryRun). Pure path/skip logic — all I/O via fs. + */ +function scaffoldPackageSwiftForDep( + dep /*: AutolinkedDep */, + ctx /*: ScaffoldContext */, +) /*: ScaffoldResult */ { + const depName = dep.name; + + if (NEVER_SCAFFOLD.has(depName)) { + return { + depName, + status: 'skipped-is-react-native', + reason: 'react-native itself is handled by the xcframework package.', + }; + } + + // ios platform missing → no native code to wrap. + if (dep.platforms.ios == null) { + return { + depName, + status: 'skipped-no-ios', + reason: 'autolinking.json has no ios platform entry.', + }; + } + + // Dep can opt out via its own react-native.config.js. + const cfg = defaultReadConfig(dep.root); + // $FlowFixMe[prop-missing] config has dynamic shape + if (cfg != null && cfg.spm != null && cfg.spm.scaffold === false) { + return { + depName, + status: 'skipped-opt-out', + reason: 'react-native.config.js sets spm.scaffold = false.', + }; + } + + const pkgSwiftPath = path.join(dep.root, 'Package.swift'); + + // A self-managed Package.swift may live either at the dep root (legacy + // convention) or inside ios/ (preferred for community RN libs that want + // their npm-package root free of SPM artifacts). If a nested manifest + // exists and is user-authored, skip — writing a stray root manifest would + // cause the autolinker to prefer the wrong file. + const nestedPkgSwiftPath = path.join(dep.root, 'ios', 'Package.swift'); + if (!fs.existsSync(pkgSwiftPath) && fs.existsSync(nestedPkgSwiftPath)) { + const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8'); + if ( + !nested.includes(AUTOGEN_MARKER) && + !nested.includes(SCAFFOLDER_MARKER) + ) { + return { + depName, + status: 'skipped-self-managed', + reason: + 'Existing ios/Package.swift was not produced by this scaffolder. ' + + 'Leaving it alone.', + }; + } + } + + // Skip rules around existing files. + if (fs.existsSync(pkgSwiftPath)) { + const existing = fs.readFileSync(pkgSwiftPath, 'utf8'); + if (existing.includes(AUTOGEN_MARKER)) { + return { + depName, + status: 'skipped-autogen', + reason: + 'Existing Package.swift carries the autolinker AUTOGEN marker. ' + + 'spm update will regenerate the synth wrapper.', + }; + } + if (existing.includes(SCAFFOLDER_MARKER)) { + // Version bump → unconditional regen. A bumped SCAFFOLDER_VERSION + // implies the emitter's output format changed in a way that requires + // regenerating existing files; --force is not needed here. + const vMatch = existing.match(SCAFFOLDER_VERSION_LINE_RE); + const existingVersion = vMatch != null ? parseInt(vMatch[1], 10) : 1; + const versionStale = existingVersion < SCAFFOLDER_VERSION; + if (!ctx.force && !versionStale) { + // Slot label change is the other auto-regen trigger — bumps SPM's + // manifest hash so the cache-slot path gets re-resolved. + const m = existing.match(/^\/\/ Cache slot: (.+)$/m); + const existingSlot = m != null ? m[1] : null; + if (ctx.cacheSlotLabel == null || existingSlot === ctx.cacheSlotLabel) { + return { + depName, + status: 'skipped-scaffolder-marker', + reason: + 'Already scaffolded for this cache slot. Pass --force to regenerate.', + }; + } + } + if (versionStale) { + log( + `Re-scaffolding ${depName} (format v${existingVersion} → v${SCAFFOLDER_VERSION})`, + ); + } + // Fall through: regenerate (force, slot changed, or version bumped). + } else { + // No marker at all → user-managed or upstream-shipped. Never touch. + return { + depName, + status: 'skipped-self-managed', + reason: + 'Existing Package.swift was not produced by this scaffolder. ' + + 'Leaving it alone.', + }; + } + } + + // Find a podspec to read. autolinking.json may have provided podspecPath; + // otherwise glob for *.podspec in dep root. + // $FlowFixMe[prop-missing] dynamic shape from autolinking.json + let podspecPath /*: ?string */ = dep.platforms.ios.podspecPath ?? null; + if (podspecPath == null) { + try { + const entries = fs.readdirSync(dep.root); + // Skip a crashed run's leftover patched copy (read-podspec.js stages it + // as `.spm-scaffold--.podspec` next to the original). + const candidate = entries.find( + e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-'), + ); + if (candidate != null) { + podspecPath = path.join(dep.root, candidate); + } + } catch { + // dep.root may not exist; treat as no-podspec + } + } + if (podspecPath == null || !fs.existsSync(podspecPath)) { + return { + depName, + status: 'skipped-no-podspec', + reason: 'No .podspec found in dep root.', + }; + } + + let model: PodspecModel; + try { + model = readPodspec(podspecPath); + } catch (e) { + return { + depName, + status: 'error', + reason: `Podspec read failed: ${e.message}`, + }; + } + + const spec = translatePodspecToSpmTarget( + model, + dep, + ctx.podToNpm ?? new Map(), + ); + + // Mixed-language fail-closed: SPM can't compile Swift + C-family in one + // target. Don't emit a manifest that would fail with a cryptic "mixed + // language source files" resolve error — skip with a clear reason (and remove + // any stale scaffolded manifest so the autolinker reports it distinctly and + // points the user at opting it out / a binary distribution). + const hasSwift = spec.sources.some(f => /\.swift$/i.test(f)); + const hasClang = spec.sources.some(f => /\.(mm?|c|cc|cpp|cxx)$/i.test(f)); + if (hasSwift && hasClang) { + if (!ctx.dryRun && fs.existsSync(pkgSwiftPath)) { + const existing = fs.readFileSync(pkgSwiftPath, 'utf8'); + if (existing.includes(SCAFFOLDER_MARKER)) { + fs.rmSync(pkgSwiftPath, {force: true}); + } + } + return { + depName, + status: 'skipped-mixed-language', + reason: + 'has mixed Swift + Objective-C/C++ sources, which SPM cannot compile ' + + 'in one target (and the bidirectional ObjC↔Swift interop typical of ' + + 'such libs cannot be split without a circular dependency). Opt it out ' + + 'via react-native.config.js (platforms.ios = null) or use a prebuilt ' + + 'xcframework.', + }; + } + + // Relative paths into the app, embedded in the scaffolded Package.swift. + // + // The manifest is written to /Package.swift, but the autolinker + // references the dep through a symlink at + // /build/generated/autolinking/libs/. On a fresh resolve + // SwiftPM interprets a manifest's relative `.package(path:)` entries against + // that SYMLINK location (it does not canonicalize the symlink first), so the + // paths must be relative to the symlink dir — NOT the real dep.root. + // Computing from dep.root produced a doubled path + // (…/autolinking/ios/build/generated/ios) → opaque "package manifest cannot + // be accessed" resolution failure. posix separators, as SPM expects. + const symlinkDir = path.join( + ctx.appRoot, + 'build', + 'generated', + 'autolinking', + 'libs', + spec.swiftName, + ); + const relFromManifest = (...segments /*: Array */) => + path + .relative(symlinkDir, path.join(ctx.appRoot, ...segments)) + .split(path.sep) + .join('/'); + const content = emitScaffoldedPackageSwift(spec, { + cacheSlotLabel: ctx.cacheSlotLabel, + remote: remotePackageConfig(ctx.appRoot), + codegenPackageDir: relFromManifest('build', 'generated', 'ios'), + localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), + }); + + // Distinguish "first-time scaffold" (no file at all) from "regenerate" + // (file with the scaffolder marker, e.g. slot changed). Used by the + // CLI orchestrator to decide whether to prompt for confirmation: + // first-time scaffolds touch a node_modules dir the user may not expect, + // regens are silent because we're maintaining a file we already own. + const previouslyExisted = fs.existsSync(pkgSwiftPath); + + if (!ctx.dryRun) { + fs.writeFileSync(pkgSwiftPath, content, 'utf8'); + // Emit the ambient-import prefix header next to the manifest when the + // target has ObjC(++) sources (the manifest `-include`s it by bare name). + if (spec.needsObjCPrefix) { + fs.writeFileSync( + path.join(dep.root, SCAFFOLD_PREFIX_HEADER), + SCAFFOLD_PREFIX_HEADER_CONTENTS, + 'utf8', + ); + } + // Mirror root-level headers into the generated include// shim + // dir (publicHeadersPath) so dependents can use the CocoaPods header-map + // spelling `#import `. + if (spec.namespacedShimHeaders.length > 0) { + const shimDir = path.join(dep.root, 'include', spec.swiftName); + fs.mkdirSync(shimDir, {recursive: true}); + for (const header of spec.namespacedShimHeaders) { + fs.writeFileSync( + path.join(shimDir, header), + `// AUTO-SCAFFOLDED by react-native spm scaffold — the namespaced\n` + + `// spelling (<${spec.swiftName}/${header}>) of the package-root header,\n` + + `// matching how CocoaPods header maps expose it.\n` + + `#import "../../${header}"\n`, + 'utf8', + ); + } + } + } + + return { + depName, + status: 'written', + packageSwiftPath: pkgSwiftPath, + warnings: spec.warnings, + previouslyExisted, + }; +} + +// --------------------------------------------------------------------------- +// Multi-dep orchestrator +// --------------------------------------------------------------------------- + +/*:: +type ScaffoldAllOptions = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + force?: boolean, + dryRun?: boolean, + cacheSlotLabel?: ?string, + autolinkingJsonPath?: string, + // npm dep names to skip entirely — used when the user declined the + // confirmation prompt for first-time scaffolds. Skipped deps still + // appear in the returned results array with status='skipped-opt-out'. + skipDeps?: ReadonlyArray, +}; +*/ + +/** + * Walks autolinking.json, scaffolds each iOS-native dep, returns one + * ScaffoldResult per dep. Caller (setup-apple-spm.js) prints a structured + * summary. + */ +function scaffoldAll( + opts /*: ScaffoldAllOptions */, +) /*: Array */ { + const {appRoot, projectRoot, reactNativeRoot} = opts; + const autolinkingJsonPath = + opts.autolinkingJsonPath ?? + path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); + + if (!fs.existsSync(autolinkingJsonPath)) { + log( + `autolinking.json not found at ${path.relative(appRoot, autolinkingJsonPath)}; nothing to scaffold.`, + ); + return []; + } + + /*:: type AutolinkingJson = {dependencies?: ?{[string]: {root?: string, platforms?: {ios?: ?{...}, ...}, ...}}, ...}; */ + // $FlowFixMe[incompatible-type] JSON.parse returns any + const data /*: AutolinkingJson */ = JSON.parse( + fs.readFileSync(autolinkingJsonPath, 'utf8'), + ); + const deps = data.dependencies; + if (deps == null) { + return []; + } + + // Narrow the direct autolinking.json entries with an iOS platform, then + // expand transitive `spm.dependencies` so the scaffolder covers EXACTLY the + // set the autolinker considers. Without this, a transitive native dep that + // ships no Package.swift would be flagged by the autolinker but never + // scaffolded here — leaving `react-native spm scaffold` unable to clear the + // autolinker's missing-manifest error. + const results /*: Array */ = []; + const directDeps /*: Array */ = []; + for (const name of Object.keys(deps)) { + const raw = deps[name]; + if (raw == null) continue; + const root = raw.root; + const ios = raw.platforms?.ios; + if (typeof root !== 'string' || ios == null) { + results.push({ + depName: name, + status: 'skipped-no-ios', + reason: 'no iOS platform in autolinking.json', + }); + continue; + } + // $FlowFixMe[incompatible-type] `ios` shape is runtime-validated above + const iosPlatform /*: AutolinkingIosPlatform */ = ios; + directDeps.push({name, root, platforms: {ios: iosPlatform}}); + } + + let allDeps: Array; + try { + allDeps = expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + }); + } catch (e) { + // A transitive-resolution failure shouldn't abort the whole scaffold pass; + // fall back to the direct deps so at least those get manifests. + log(`Transitive spm.dependencies expansion failed: ${e.message}`); + allDeps = directDeps; + } + + // Index every autolinked dep's podspec name → its npm name, so a dep that + // depends on a sibling by its pod name (reanimated's `s.dependency + // "RNWorklets"`) can be wired to the sibling's package (react-native-worklets). + // The podspec file basename is the pod name for RN-ecosystem libs (and is + // cheap — no `pod ipc` pre-pass). + const podToNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + const podspecPath = dep.platforms?.ios?.podspecPath; + if (typeof podspecPath === 'string' && podspecPath.length > 0) { + podToNpm.set(path.basename(podspecPath, '.podspec'), dep.name); + } else { + // Transitive entries synthesized by expandSpmDependencies (declared via + // `spm.dependencies` in react-native.config.js) carry no podspecPath — + // discover the podspec at the dep root so podspec-name sibling deps + // (e.g. `s.dependency "TestLibraryCommon"`) still wire to their npm + // package. + try { + for (const f of fs.readdirSync(dep.root)) { + if (f.endsWith('.podspec') && !f.startsWith('.spm-scaffold-')) { + podToNpm.set(path.basename(f, '.podspec'), dep.name); + } + } + } catch { + // unreadable dep root — sibling wiring falls back to the warning path + } + } + } + + const ctx /*: ScaffoldContext */ = { + appRoot, + projectRoot, + reactNativeRoot, + force: opts.force === true, + dryRun: opts.dryRun === true, + cacheSlotLabel: opts.cacheSlotLabel ?? null, + podToNpm, + }; + const skipSet /*: Set */ = new Set(opts.skipDeps ?? []); + + for (const dep of allDeps) { + if (skipSet.has(dep.name)) { + results.push({ + depName: dep.name, + status: 'skipped-opt-out', + reason: 'User declined scaffolding for this dep.', + }); + continue; + } + try { + results.push(scaffoldPackageSwiftForDep(dep, ctx)); + } catch (e) { + results.push({depName: dep.name, status: 'error', reason: e.message}); + } + } + return results; +} + +module.exports = { + scaffoldAll, + scaffoldPackageSwiftForDep, + translatePodspecToSpmTarget, + emitScaffoldedPackageSwift, + SCAFFOLDER_MARKER, + SCAFFOLDER_VERSION, +}; diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js new file mode 100644 index 000000000000..37db184129f4 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -0,0 +1,654 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const crypto = require('crypto'); + +/** + * Generate a deterministic 24-hex-character UUID from a seed string. + * SHA-256 truncated to 24 chars (standard Xcode pbxproj UUID length). Not a + * security use — the hash only provides stable, collision-unlikely IDs — but + * sha256 keeps static analysis (CodeQL weak-crypto) quiet. + */ +function generateUUID(seed /*: string */) /*: string */ { + return crypto + .createHash('sha256') + .update(seed) + .digest('hex') + .substring(0, 24) + .toUpperCase(); +} + +/** + * Escapes a string for OpenStep plist format if needed. + */ +function quoteIfNeeded(s /*: string */) /*: string */ { + if (/^[a-zA-Z0-9._/]+$/.test(s)) { + return s; + } + return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; +} + +/** + * Serialize a single pbxproj object entry to its OpenStep text form, + * including the leading `\t\t` and trailing `};` but NO trailing + * newline. Short entries (≤3 scalar fields) collapse to one line, matching + * Xcode's own formatting. Used by the in-place injector to splice single + * entries into an existing project. + */ +function serializeEntry( + entry /*: {readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...} */, +) /*: string */ { + const comment = + entry.comment != null && entry.comment !== '' + ? ` /* ${entry.comment} */` + : ''; + let out = `\t\t${entry.uuid}${comment} = {`; + const fieldKeys = Object.keys(entry.fields); + if ( + fieldKeys.length <= 3 && + !fieldKeys.some(k => entry.fields[k].includes('\n')) + ) { + // Single-line format for short entries + out += fieldKeys.map(k => `${k} = ${entry.fields[k]};`).join(' '); + out += '};'; + } else { + out += '\n'; + for (const key of fieldKeys) { + out += `\t\t\t${key} = ${entry.fields[key]};\n`; + } + out += '\t\t};'; + } + return out; +} + +// --------------------------------------------------------------------------- +// Surgical in-place pbxproj editing. +// +// To ADD SPM packages to a user's EXISTING project.pbxproj we splice new +// objects and array members into the existing text by string anchors, leaving +// every untouched byte identical (so the git diff is just the added lines). +// These helpers operate on the raw OpenStep text — there is no AST. Quote-aware +// delimiter matching lets them skip over field values (e.g. a shellScript +// containing braces/parens) without miscounting. +// --------------------------------------------------------------------------- + +/** + * Derive a deterministic UUID for an injected object, namespaced by the host + * project's root-object UUID so it is (a) stable across re-runs (idempotency) + * and (b) astronomically unlikely to collide with the user's existing + * randomly-assigned 24-hex IDs. `salt` lets the caller re-derive on the + * ~1-in-2^96 collision. + */ +function namespacedUUID( + rootUUID /*: string */, + section /*: string */, + id /*: string */, + salt /*: string */ = '', +) /*: string */ { + return generateUUID(`${rootUUID}:spm${salt}:${section}:${id}`); +} + +function escapeRegExp(s /*: string */) /*: string */ { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Given an index pointing at an opening `"`, return the index of the matching + * closing `"` (honoring backslash escapes). + */ +function scanString(text /*: string */, openIdx /*: number */) /*: number */ { + for (let i = openIdx + 1; i < text.length; i++) { + const c = text[i]; + if (c === '\\') { + i++; + continue; + } + if (c === '"') { + return i; + } + } + throw new Error('pbxproj: unterminated string literal'); +} + +/** + * Given an index pointing at an opening `{` or `(`, return the index of the + * matching close delimiter. Nesting counts both brace and paren forms; quoted + * strings are skipped. Well-formed OpenStep never mismatches the two forms. + */ +function scanToClose(text /*: string */, openIdx /*: number */) /*: number */ { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i = scanString(text, i); + continue; + } + if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + if (depth === 0) { + return i; + } + } + } + throw new Error('pbxproj: unbalanced delimiters'); +} + +/*:: +type ObjectRange = {uuid: string, bodyOpen: number, bodyClose: number}; +// Any object whose body range is known — field accessors only need the body +// bounds, so they accept the richer shapes callers carry (e.g. app targets +// with a name, or buildSettings dicts) inexactly. +type BodyRange = {bodyOpen: number, bodyClose: number, ...}; +type FieldRange = {matchStart: number, valueStart: number, value: string, tokenEnd: number}; +*/ + +/** + * Locate the object with the given 24-hex UUID. Returns the index of the body + * `{` and its matching `}`. Matches both single-line and multi-line entries. + */ +function findObjectByUuid( + text /*: string */, + uuid /*: string */, +) /*: ObjectRange | null */ { + const m = new RegExp(`\\n\\t*${uuid}\\b[^\\n]*?= \\{`).exec(text); + if (m == null) { + return null; + } + const bodyOpen = text.indexOf('{', m.index); + const bodyClose = scanToClose(text, bodyOpen); + return {uuid, bodyOpen, bodyClose}; +} + +/** + * Find a field within a multi-line object body (`\n\t+key = value;`). Returns + * the value token range (value excludes the trailing `;`; `tokenEnd` points AT + * the `;`). Containers (`( … )` / `{ … }`) and quoted strings are matched as a + * whole. Returns null when the key is absent. + */ +function findField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, +) /*: FieldRange | null */ { + const body = text.slice(obj.bodyOpen, obj.bodyClose); + const m = new RegExp(`\\n\\t+${escapeRegExp(key)} = `).exec(body); + if (m == null) { + return null; + } + const matchStart = obj.bodyOpen + m.index; + const valueStart = matchStart + m[0].length; + const fc = text[valueStart]; + let tokenEnd; + if (fc === '(' || fc === '{') { + tokenEnd = scanToClose(text, valueStart) + 1; + } else if (fc === '"') { + tokenEnd = scanString(text, valueStart) + 1; + } else { + tokenEnd = text.indexOf(';', valueStart); + } + return { + matchStart, + valueStart, + value: text.slice(valueStart, tokenEnd), + tokenEnd, + }; +} + +/** Locate the `/* Begin X section *​/ … /* End X section *​/` byte range. */ +function findSection( + text /*: string */, + name /*: string */, +) /*: {begin: number, contentStart: number, end: number} | null */ { + const beginTag = `/* Begin ${name} section */`; + const endTag = `/* End ${name} section */`; + const begin = text.indexOf(beginTag); + const end = text.indexOf(endTag); + if (begin < 0 || end < 0) { + return null; + } + return {begin, contentStart: begin + beginTag.length, end}; +} + +/** The PBXProject root object (via the trailing `rootObject = ;`). */ +function findProjectObject(text /*: string */) /*: ObjectRange | null */ { + const m = /\n\trootObject = ([0-9A-Fa-f]{24})/.exec(text); + if (m == null) { + return null; + } + return findObjectByUuid(text, m[1]); +} + +/** + * Every PBXNativeTarget whose productType is an application. Returns uuid + + * name + body range for each. Used to pick the app target to inject into + * (and to refuse on ambiguity). + */ +function findApplicationTargets( + text /*: string */, +) /*: Array<{uuid: string, name: string, bodyOpen: number, bodyClose: number}> */ { + const section = findSection(text, 'PBXNativeTarget'); + if (section == null) { + return []; + } + const out = []; + const re = /\n\t\t([0-9A-Fa-f]{24})(?: \/\* (.*?) \*\/)? = \{/g; + re.lastIndex = section.contentStart; + for (;;) { + const m = re.exec(text); + if (m == null || m.index >= section.end) { + break; + } + const uuid = m[1]; + const comment = m[2]; + const bodyOpen = text.indexOf('{', m.index); + const bodyClose = scanToClose(text, bodyOpen); + const obj = {uuid, bodyOpen, bodyClose}; + const productType = findField(text, obj, 'productType'); + if ( + productType != null && + /com\.apple\.product-type\.application/.test(productType.value) + ) { + const nameField = findField(text, obj, 'name'); + const name = + nameField != null + ? nameField.value.replace(/^"|"$/g, '') + : (comment ?? uuid); + out.push({uuid, name, bodyOpen, bodyClose}); + } + re.lastIndex = bodyClose; + } + return out; +} + +/** UUIDs already referenced inside a `( … )` array field value. */ +function uuidsInArray(value /*: string */) /*: Set */ { + const found = new Set /*:: */(); + const re = /\b([0-9A-Fa-f]{24})\b/g; + for (;;) { + const m = re.exec(value); + if (m == null) { + break; + } + found.add(m[1]); + } + return found; +} + +/** + * The leading-tab indent of fields inside an object body (e.g. `\t\t\t` for a + * top-level object, `\t\t\t\t` for a nested dict like buildSettings). Used so + * inserted fields/members match the surrounding depth at any nesting level. + */ +function detectFieldIndent( + text /*: string */, + obj /*: BodyRange */, +) /*: string */ { + const m = /\n(\t+)\S/.exec(text.slice(obj.bodyOpen, obj.bodyClose)); + return m != null ? m[1] : '\t\t\t'; +} + +/** + * Insert one or more already-serialized object entries (text produced by + * serializeEntry, no surrounding newlines) into the named section — created + * just before the close of the `objects` dict if the section is absent. + */ +function insertObjectsIntoSection( + text /*: string */, + sectionName /*: string */, + entriesText /*: string */, +) /*: string */ { + const section = findSection(text, sectionName); + if (section != null) { + return ( + text.slice(0, section.end) + entriesText + '\n' + text.slice(section.end) + ); + } + // No such section yet — create it just before the `objects` dict closes. + const anchor = '\n\t};\n\trootObject = '; + const at = text.indexOf(anchor); + if (at < 0) { + throw new Error('pbxproj: could not find end of objects dict'); + } + const block = + `/* Begin ${sectionName} section */\n${entriesText}\n` + + `/* End ${sectionName} section */\n\n`; + return text.slice(0, at + 1) + block + text.slice(at + 1); +} + +/** + * Append members to a `( … )` array field, deduping by UUID. Creates the field + * (with a `$(inherited)`-free literal list) after the object's opening `{` when + * absent. `members` are `{uuid, comment}`. Indentation is derived from the + * object so it works for top-level fields and nested dicts alike. + */ +function addArrayMembers( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + members /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, ...}> */, + options /*: {prepend?: boolean} */ = {}, +) /*: string */ { + const fieldIndent = detectFieldIndent(text, obj); + const memberIndent = fieldIndent + '\t'; + const line = ( + m /*: {readonly uuid: string, readonly comment?: ?string, ...} */, + ) => + `${memberIndent}${m.uuid}${m.comment != null && m.comment !== '' ? ` /* ${m.comment} */` : ''},\n`; + + const field = findField(text, obj, key); + if (field != null) { + const existing = uuidsInArray(field.value); + const fresh = members.filter(m => !existing.has(m.uuid)); + if (fresh.length === 0) { + return text; + } + // Prepend: insert right after the array's opening `(\n` so the new members + // run first (used for the sync phase, which must precede Sources). + const insertAt = + options.prepend === true + ? text.indexOf('\n', field.valueStart) + 1 + : text.lastIndexOf('\n', field.tokenEnd - 1) + 1; + return ( + text.slice(0, insertAt) + fresh.map(line).join('') + text.slice(insertAt) + ); + } + const block = `\n${fieldIndent}${key} = (\n${members.map(line).join('')}${fieldIndent});`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Append raw string values to a `( … )` array build-setting (e.g. + * OTHER_LDFLAGS), deduping by exact token. Creates the setting seeded with + * `"$(inherited)"` when absent. Values must already be plist-quoted by caller. + */ +function addArrayStringValues( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + values /*: Array */, +) /*: string */ { + const fieldIndent = detectFieldIndent(text, obj); + const memberIndent = fieldIndent + '\t'; + const arrayBlock = (members /*: Array */) => + `(\n${members.map(v => `${memberIndent}${v},\n`).join('')}${fieldIndent})`; + + const field = findField(text, obj, key); + if (field != null) { + // Dedup by EXACT existing member, not substring — a substring check would + // treat `"-ObjC"` as already present when only `"-ObjCFoo"` is there (and + // vice-versa). Parse the current members (array `( … )` or bare scalar). + const existingMembers = new Set( + field.value + .replace(/^\s*\(/, '') + .replace(/\)\s*$/, '') + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0), + ); + const fresh = values.filter(v => !existingMembers.has(v)); + if (fresh.length === 0) { + return text; + } + if (field.value.trimStart().startsWith('(')) { + // Existing array — splice fresh members before the closing `)`. + const lineStart = text.lastIndexOf('\n', field.tokenEnd - 1) + 1; + const lines = fresh.map(v => `${memberIndent}${v},\n`).join(''); + return text.slice(0, lineStart) + lines + text.slice(lineStart); + } + // Existing scalar — promote to an array preserving the prior value. + const replacement = arrayBlock([ + '"$(inherited)"', + field.value.trim(), + ...fresh, + ]); + return ( + text.slice(0, field.valueStart) + replacement + text.slice(field.tokenEnd) + ); + } + const block = `\n${fieldIndent}${key} = ${arrayBlock(['"$(inherited)"', ...values])};`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Add a scalar field after the object's `{` only when ABSENT (never clobbers a + * value the user already set). Returns text unchanged if the key exists. + */ +function ensureScalarField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + value /*: string */, +) /*: string */ { + if (findField(text, obj, key) != null) { + return text; + } + const fieldIndent = detectFieldIndent(text, obj); + const block = `\n${fieldIndent}${key} = ${value};`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Set a scalar field's value, UNLIKE ensureScalarField this overwrites an + * existing value in place rather than leaving it alone — used by fields the + * injector itself owns (e.g. the generated `shellScript`) that must be kept + * in sync on re-injection. When the field is present, only the value token + * (`findField`'s `valueStart..tokenEnd` range — the trailing `;` is NOT part + * of that range and is preserved untouched) is replaced in place, so field + * order never shifts and passing the same `value` again yields + * byte-identical output. Falls back to `ensureScalarField`'s append-after-`{` + * behavior when the field is absent. + */ +function setScalarField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + value /*: string */, +) /*: string */ { + const field = findField(text, obj, key); + if (field != null) { + return text.slice(0, field.valueStart) + value + text.slice(field.tokenEnd); + } + return ensureScalarField(text, obj, key, value); +} +// --------------------------------------------------------------------------- +// Surgical removal — the inverse of the additive helpers above. `deinit` uses +// these to undo exactly what injection added, leaving every other byte (incl. +// user edits made after injection) untouched. All are pure string transforms. +// --------------------------------------------------------------------------- + +/** + * Remove the object whose UUID is `uuid` (its whole `\t\t … = { … };` + * entry, single- or multi-line). No-op when the object is absent. + */ +function removeObjectByUuid( + text /*: string */, + uuid /*: string */, +) /*: string */ { + const obj = findObjectByUuid(text, uuid); + if (obj == null) { + return text; + } + // Start at the newline preceding the entry's line; end just past its `;`. + // Leaving the trailing newline in place preserves it as the next entry's + // separator (byte-identical to never having inserted the line). + const start = text.lastIndexOf('\n', obj.bodyOpen); + let end = obj.bodyClose + 1; // past `}` + if (text[end] === ';') { + end++; + } + return text.slice(0, start) + text.slice(end); +} + +/** + * Remove array-member lines (`\n\t+ /* … *​/,`) referencing any of + * `uuids` from every `( … )` list in the file (packageReferences, + * packageProductDependencies, a Frameworks phase's `files`, buildPhases, …). + * Only matches member lines (trailing comma), never the object-definition line + * (which ends in `= {`), so it composes safely with removeObjectByUuid. + */ +function removeArrayMembersByUuid( + text /*: string */, + uuids /*: ReadonlyArray */, +) /*: string */ { + let out = text; + for (const uuid of uuids) { + out = out.replace( + new RegExp(`\\n[\\t ]*${escapeRegExp(uuid)}\\b[^\\n]*,`, 'g'), + '', + ); + } + return out; +} + +/** Remove a whole `\n\t+key = value;` field from `obj`. No-op when absent. */ +function removeField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, +) /*: string */ { + const f = findField(text, obj, key); + if (f == null) { + return text; + } + // f.matchStart points at the leading `\n`; f.tokenEnd points AT the `;`. + return text.slice(0, f.matchStart) + text.slice(f.tokenEnd + 1); +} + +/** + * Remove specific raw string members from an existing `( … )` array field + * (inverse of addArrayStringValues' append branch). Leaves the field and any + * other members in place. No-op when the field or a value is absent. + */ +function removeArrayStringValues( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + values /*: ReadonlyArray */, +) /*: string */ { + const f = findField(text, obj, key); + if (f == null) { + return text; + } + let region = text.slice(f.valueStart, f.tokenEnd); + for (const val of values) { + region = region.replace(new RegExp(`\\n[\\t ]*${escapeRegExp(val)},`), ''); + } + return text.slice(0, f.valueStart) + region + text.slice(f.tokenEnd); +} + +/** + * Remove the empty `Pods` PBXGroup that `pod deintegrate` can leave behind in + * the navigator (build integration is already gone — xcconfigs/[CP] phases/ + * linking — but the group lingers). Removes the group object AND its membership + * in any parent group. Only acts when the group is EMPTY (`children = ()`), so a + * still-integrated project (non-empty Pods group) is never touched. No-op when + * absent. PBXGroup bodies contain no nested braces, so `[^{}]` body matching is + * safe. + */ +function removeEmptyPodsGroup(text /*: string */) /*: string */ { + const m = /\n[\t ]*([0-9A-Fa-f]{24}) \/\* Pods \*\/ = \{[^{}]*?\};/.exec( + text, + ); + if (m == null) { + return text; + } + const block = m[0]; + if ( + !/isa = PBXGroup;/.test(block) || + !/children = \(\s*\);/.test(block) || + !/\b(?:path|name) = Pods;/.test(block) + ) { + return text; + } + const uuid = m[1]; + // Drop the parent group's child reference first, then the group object. + return removeObjectByUuid(removeArrayMembersByUuid(text, [uuid]), uuid); +} + +/** + * Remove the dangling `JavaScriptCore.framework` PBXFileReference the + * community template has carried since RN 0.60 (an SDK framework reference + * left over from the pre-Hermes JSC-via-CocoaPods era). It's navigator-only — + * never wired into any PBXBuildFile/build phase — so it's meaningless (and + * confusing) now that React Native uses Hermes. Only removes references that + * are truly unlinked: if a PBXBuildFile still references the UUID (e.g. an + * app that deliberately links JSC, such as via react-native-javascriptcore), + * that reference is left completely untouched. Removes the file-reference + * object AND its membership in any parent group. No-op when absent. Like + * `removeEmptyPodsGroup`, `deinit` does not restore this removal — git is the + * safety net. + */ +function removeDanglingJavaScriptCoreRef(text /*: string */) /*: string */ { + const re = + /\n[\t ]*([0-9A-Fa-f]{24}) \/\* JavaScriptCore\.framework \*\/ = \{[^{}]*?\};/g; + const uuidsToRemove = []; + for (const m of text.matchAll(re)) { + const block = m[0]; + if ( + !/isa = PBXFileReference;/.test(block) || + !/path = System\/Library\/Frameworks\/JavaScriptCore\.framework;/.test( + block, + ) || + !/sourceTree = SDKROOT;/.test(block) + ) { + continue; + } + const uuid = m[1]; + const linked = new RegExp(`\\bfileRef = ${escapeRegExp(uuid)}\\b`).test( + text, + ); + if (!linked) { + uuidsToRemove.push(uuid); + } + } + if (uuidsToRemove.length === 0) { + return text; + } + // Drop the parent group's child reference(s) first, then the file objects. + let out = removeArrayMembersByUuid(text, uuidsToRemove); + for (const uuid of uuidsToRemove) { + out = removeObjectByUuid(out, uuid); + } + return out; +} + +module.exports = { + generateUUID, + namespacedUUID, + serializeEntry, + quoteIfNeeded, + // Surgical-edit toolkit (in-place injection): + scanString, + scanToClose, + findObjectByUuid, + findField, + findSection, + findProjectObject, + findApplicationTargets, + uuidsInArray, + detectFieldIndent, + insertObjectsIntoSection, + addArrayMembers, + addArrayStringValues, + ensureScalarField, + setScalarField, + escapeRegExp, + // Surgical removal (deinit): + removeObjectByUuid, + removeArrayMembersByUuid, + removeField, + removeArrayStringValues, + removeEmptyPodsGroup, + removeDanglingJavaScriptCoreRef, +}; diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js new file mode 100644 index 000000000000..cc30c8823b65 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-types.js @@ -0,0 +1,517 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/*:: +export type SetupArgs = { + action: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' | null, + version: string | null, + // Local two-flavor artifact source (advanced). The directory contains + // `debug/` and `release/` cache slots, each with artifacts.json. + artifacts: string | null, + skipCodegen: boolean, + // Artifact download policy: 'auto' fetches when missing, 'skip' never + // fetches, 'force' clears the cache slot and re-downloads. + downloadPolicy: 'auto' | 'skip' | 'force', + // `add` target selection: which app target (when several) and which project. + productName: string | null, + xcodeprojPath: string | null, + // `add`: run `pod deintegrate` + strip RN from the Podfile before injecting. + // Also implied on the zero-arg path when resolveAction's safe-gate detects a + // freshly-scaffolded CocoaPods project (see setup-apple-spm.js). + deintegrate: boolean, + // Skip the single remaining confirmation (the add/update dirty-pbxproj + // warning). Non-TTY auto-proceeds regardless (git is the safety net). + yes: boolean, +}; + +export type DownloadArgs = { + version: string | null, + flavor: string, + output: string | null, + coreTarball: string | null, + headersTarball: string | null, + depsTarball: string | null, + depsHeadersTarball: string | null, +}; + +export type ResolvedArtifact = { + url: string, + version: string, +}; + +export type ProcessResult = { + label: string, + version: string, + xcframeworkPath: string, + url: string, +}; + +export type ArtifactResultEntry = + | {name: string, error: void, label: string, version: string, xcframeworkPath: string, url: string} + | {name: string, error: string}; + +export type AutolinkingArgs = { + appRoot: string, + reactNativeRoot: string | null, + autolinkingJson: string | null, + output: string | null, + xcframeworksPath: string | null, +}; + +export type SpmTarget = { + name: string, + path: string, + exclude: Array, + publicHeadersPath: string | null, + resources?: Array, + // Swift target names this target depends on (already toSwiftName()'d). + // Emitted into the target's SPM `dependencies:` array so the compiler sees + // the dependent target's headers / module map. + spmTargetDependencies?: Array, + // Explicit allowlist of source files (paths relative to target.path). + // Mirrors CocoaPods' `s.source_files`. When non-empty, the SPM target + // declares `sources: [...]` and only these files are compiled — test dirs, + // .js/.podspec/.md siblings, etc. can never sneak in. Null/empty means + // fall back to SPM's default scan of target.path. + sources?: ?Array, + // Header search paths declared by the dep's podspec + // (pod_target_xcconfig HEADER_SEARCH_PATHS), already substituted relative + // to the dep's source dir. Path-style includes like + // `` resolve through these. + // Emitted as `.headerSearchPath(...)` directives in the target's + // cSettings / cxxSettings. + headerSearchPaths?: ?Array, +}; + +// Routing metadata kept alongside an SpmTarget in main(). Lives in a wrapper +// (not on SpmTarget) so the target type stays describable from the outside. +export type TargetEntry = { + target: SpmTarget, + origin: 'npm' | 'spmModule', + // The dep's npm package name (origin 'npm' only). Used in the + // missing-manifest error so the message names the package the developer + // installed, not its derived Swift target name. + npmName?: string, + // The dep's checked-in package root (origin 'npm' only) — the directory that + // holds its root `Package.swift` and `.react-native/` metadata. Threaded from + // the autolinking model (`dep.root`) so the watch file can flag manifest edits + // without walking up from the (possibly nested) target source dir. + root?: string, + // Filled in for npm-origin entries during the mirror step; consumed by the + // synth-package emission step further down. + mirrorReady?: ?{ + synthPkgDir: string, + mirroredResources: ?Array, + }, +}; + +// --------------------------------------------------------------------------- +// autolinking.json shape (output of @react-native-community/cli config). +// All fields are optional because the JSON is user-influenced; the consumer +// checks at runtime. +// --------------------------------------------------------------------------- +export type AutolinkingIosPlatform = { + sourceDir?: ?string, + podspecPath?: ?string, + ... +}; +// As parsed from autolinking.json — all fields optional because the JSON is +// user-influenced. main() validates and narrows to AutolinkedDep before use. +export type AutolinkingDepJson = { + root?: ?string, + platforms?: ?{ios?: ?AutolinkingIosPlatform, ...}, + ... +}; +export type RawAutolinkingJson = { + dependencies?: ?{[string]: AutolinkingDepJson}, + ... +}; +// Validated/normalized dep — name and ios platform are guaranteed present. +// Produced from AutolinkingDepJson in main() and passed through +// expandSpmDependencies to autolinkingDepToSpmTarget. +export type AutolinkedDep = { + name: string, + root: string, + platforms: {ios: AutolinkingIosPlatform, ...}, + // Resolved Swift target / module / headers-subdir name. Defaults to + // toSwiftName(name) and is overridden by the dep's react-native.config.js + // `spm.name`. Populated by expandSpmDependencies — always present after + // expansion; optional in the type so caller-side construction stays simple. + swiftName?: string, + // Populated by expandSpmDependencies from each dep's + // react-native.config.js `spm.dependencies` array. + spmDependencies?: Array, + ... +}; + +// CLI `config` output minimally typed for the bits we read in +// generate-spm-autolinking-config.js. +export type CliConfigJson = { + root?: ?string, + reactNativePath?: ?string, + project?: ?{ios?: ?{sourceDir?: ?string, ...}, ...}, + ... +}; + +// Entry shape for an spmModule declared in react-native.config.js. +export type SpmModuleConfig = { + name: string, + path: string, + exclude?: Array, + publicHeadersPath?: ?string, + // Optional CocoaPods-style glob allowlist (analog of s.source_files). + // When set, replaces auto source discovery for the module — only files + // matching one of these patterns are passed to SPM via `sources:`. + sources?: Array, +}; + +// --------------------------------------------------------------------------- +// Inputs to the Swift emitters in generate-spm-autolinking.js. +// --------------------------------------------------------------------------- +export type NpmDepRef = { + swiftName: string, + // Path passed to .package(path:). Relative to autolinked/ (the aggregator's + // dir). For in-place synth this is the dep's real source dir. + packagePath?: string, + // The npm package name (e.g. react-native-safe-area-context). Used by the + // aggregator's eval-time missing-manifest guard to name the library a + // developer installed (not its Swift target name). + npmName?: string, +}; + +export type AggregatorInput = { + npmDeps?: ReadonlyArray, + inlineTargets?: ReadonlyArray, + hasReactDep?: boolean, + // Relative path from the aggregator's dir (autolinking/) to + // build/xcframeworks. Used for the inline-target ReactNative dep. + xcframeworksRelPath?: ?string, + // Autolinking-plugin contributions (Expo & other frameworks). Merged into + // the aggregator's package deps + the AutolinkedAggregate target deps. + pluginPackageDeps?: ReadonlyArray, + pluginProductDeps?: ReadonlyArray, +}; + +// --- Autolinking plugins (PREVIEW / unstable contract) --- + +// A framework's plugin package dependency: local (path) or remote (url+version). +export type PluginPackageDep = { + name: string, + path?: string, + url?: string, + version?: string, +}; + +// A product the AutolinkedAggregate target should link. +export type PluginProductDep = {name: string, package: string}; + +// A generated source file (e.g. Expo's ExpoModulesProvider.swift) to register +// in the codegen package so it compiles. +export type PluginGeneratedSource = {path: string}; + +// A plugin-declared dynamic XCFramework pair. RN validates both paths, stages +// immutable app-local slots, and links/embeds the selected framework outside +// SwiftPM. +export type PluginFlavoredFramework = { + id: string, + frameworkName: string, + linkage: 'dynamic', + flavors: {debug: string, release: string}, +}; + +export type XcframeworkSlice = { + sdk: string, + platform: string, + variant: ?string, + architectures: Array, + libraryIdentifier: string, + libraryPath: string, + binaryPath: string, +}; + +export type ParsedXcframework = { + path: string, + frameworkName: string, + executableName: string, + slices: Array, +}; + +export type FlavoredFrameworkManifestEntry = { + id: string, + frameworkName: string, + executableName: string, + linkage: 'dynamic', + artifactRelativePath: string, + slices: Array, +}; + +export type FlavoredFrameworksManifest = { + version: 1, + frameworks: Array, +}; + +// How a plugin depends on React — the single source of truth so a plugin's +// own Package.swift doesn't re-derive RN's package path/identity/products. +// Local vs remote is distinguished by which keys are present: +// - local: {name, path (absolute), relPath (relative to outputDir)} +// - remote: {name, url, version} +// `path` is absolute so it's correct no matter which subdir of outputDir the +// plugin writes its manifest into (the manifests are gitignored + regenerated). +export type ReactPackageRef = + | {name: string, path: string, relPath?: string} + | {name: string, url: string, version: string}; + +export type ReactDescriptor = { + packageRef: ReactPackageRef, + // The products a React-consuming target may depend on — the same set RN wires + // into its own autolinked targets (parity), filtered to those resolvable this + // run. Every listed product is safe to reference without guarding. Note + // ReactAppHeaders lives in the separate, per-app React-GeneratedCode package. + products: Array<{name: string, package: string}>, +}; + +// Context handed to every plugin. Includes the JS root (projectRoot) and the +// parsed autolinking data so a framework can scan the discovered deps. +export type PluginContext = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + autolinking: {readonly [string]: unknown}, + outputDir: string, + // How to depend on React (package ref + product set); null only when there is + // no resolvable React dependency at all. + react: ?ReactDescriptor, +}; + +export type PluginResult = { + packageDependencies: Array, + productDependencies: Array, + generatedSources: Array, + flavoredFrameworks: Array, + // Absolute paths (dirs or files) the Xcode auto-sync build phase should watch + // for staleness, e.g. the plugin dep's own `Package.swift` and per-module + // manifests. Folded into `.spm-sync-watch-paths` by main(). + watchPaths: Array, +}; + +export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ + packageDependencies?: Array, + productDependencies?: Array, + generatedSources?: Array, + flavoredFrameworks?: Array, + watchPaths?: Array, +}; + +export type DiscoveredPlugin = { + depName: string, + pluginPath: string, + plugin: SpmAutolinkingPlugin, +}; + +export type SynthPackageSpec = { + swiftName: string, + exclude?: Array, + publicHeadersPath?: ?string, + // Explicit allowlist of source paths (relative to `targetPath`). When + // present and non-empty, the synth Package.swift emits `sources: [...]` + // — SPM will only compile these files. + sources?: ?Array, + spmDependencies?: Array<{swiftName: string}>, + hasReactDep?: boolean, + // Relative path (posix, from the synth dir /packages/) to + // the app's React xcframeworks package and codegen package. Computed by the + // caller at generation time — the synth holds no runtime discovery. + reactNativePackagePath?: string, + codegenPackagePath?: string, + resources?: ?Array, + isDynamic?: boolean, + targetPath?: string, + // Fallback relative base for sibling synth packages when the caller does not + // supply absolute paths (tests). Production uses siblingSynthAbsolutePaths. + siblingPackageBaseRelative?: string, + siblingSynthAbsolutePaths?: {[swiftName: string]: string}, + // Header search paths from the dep's podspec `pod_target_xcconfig` + // HEADER_SEARCH_PATHS, with `$(PODS_TARGET_SRCROOT)` substituted and the + // synth wrapper's `root/` prefix applied. Emitted as `.headerSearchPath()` + // directives on cSettings / cxxSettings so path-style includes like + // `` resolve through the + // dep's own `common/cpp/` subtree. + headerSearchPaths?: ?Array, +}; + + +export type GeneratePackageArgs = { + appRoot: string, + reactNativeRoot: string | null, + version: string | null, + debugArtifactsDir: string | null, + releaseArtifactsDir: string | null, + appName: string | null, + targetName: string | null, + sourcePath: string | null, + iosVersion: string, +}; + +// A preprocessor define lifted from a podspec's pod_target_xcconfig +// (OTHER_CFLAGS `-D...` tokens + GCC_PREPROCESSOR_DEFINITIONS entries). `value` +// is null for a bare `-DNAME` (define with no value); `config` scopes the +// define to a build configuration (from `[config=*Debug*]`/`[config=*Release*]` +// xcconfig keys), null = unconditional. Emitted as SPM `.define(...)`. +export type PreprocessorDefine = { + name: string, + value: ?string, + config: ?('debug' | 'release'), +}; + +// --------------------------------------------------------------------------- +// Scaffold types — for the `npx react-native spm scaffold` command that +// generates a `Package.swift` into `node_modules//` for community RN +// libraries that don't ship SPM support. Inputs come from the dep's podspec +// (read via `pod ipc spec --format=json` when CocoaPods is available, or a +// regex fallback). The output Package.swift is treated as "self-managed" by +// the autolinker — see isSelfManagedPackage in generate-spm-autolinking.js. +// --------------------------------------------------------------------------- + +// Flattened, subspec-merged view of a podspec — what the translation layer +// consumes. HEADER_SEARCH_PATHS / source globs are kept as raw strings; +// substitution of `$(PODS_TARGET_SRCROOT)` etc. happens during translation +// so the raw model stays portable. +export type PodspecModel = { + name: string, + version: string, + sourceFiles: Array, + publicHeaderFiles: Array, + privateHeaderFiles: Array, + excludeFiles: Array, + headerMappingsDir: ?string, + // Every subspec's header_mappings_dir (union). dirname() of each is added as + // a header search path so `` includes resolve from the + // physical source tree (SPM has no header_mappings_dir copy step). + headerMappingsDirs: Array, + headerDir: ?string, + frameworks: Array, + weakFrameworks: Array, + libraries: Array, + // Merged dependency name list. With pod-ipc, includes the deps materialized + // by `install_modules_dependencies(s)` (React-Core, React-Fabric, ...). + // Version constraints stripped — we only need the name to bucket. + dependencies: Array, + compilerFlags: Array, + // Raw header-search-path entries from `pod_target_xcconfig['HEADER_SEARCH_PATHS']`. + // May contain Xcode build setting placeholders like `$(PODS_TARGET_SRCROOT)`. + headerSearchPaths: Array, + // Preprocessor defines lifted from pod_target_xcconfig OTHER_CFLAGS (`-D` + // tokens) + GCC_PREPROCESSOR_DEFINITIONS (incl. per-config variants). Already + // resolved by `pod ipc` (e.g. `-DWORKLETS_VERSION=#{package['version']}` → + // `WORKLETS_VERSION=0.9.2`). Emitted as `.define(...)` on the SPM target. + preprocessorDefines: Array, + // File paths or glob patterns the dep declares as bundled resources. + resources: Array, + requiresArc: boolean, + // Warnings collected during parsing — surfaced in the scaffold summary so + // a user can spot fields that didn't translate cleanly (unknown env vars, + // unrecognized $(...) tokens, etc.). Never throws on parse errors. + warnings: Array, + // True when produced by the regex fallback rather than pod-ipc. Used to + // emit a louder banner in the scaffold summary explaining that dependency + // wiring may be incomplete. + partial: boolean, + // True when the podspec source calls `install_modules_dependencies(s)` — + // RN's Podfile-side helper that injects the React-Core family. We strip that + // helper before evaluating the spec (and the regex parser can't expand it), + // so React-Core never surfaces in `dependencies`. The scaffolder treats this + // flag as an implicit React-core dependency (a plain ObjC module using the + // helper has no `codegenConfig` to key off). + usesInstallModulesDependencies: boolean, +}; + +// Intermediate translation result — concrete data the Swift emitter consumes. +// Decouples podspec reading from SPM-specific shaping so each side can be +// tested in isolation. +export type SpmScaffoldSpec = { + // Swift target / module name. Default: toSwiftName(podspec.name); overridden + // by `header_dir` when present. + swiftName: string, + // Source file paths relative to the dep root, ready for `sources: [...]` + // emission after the `root/` wrapper-dir prefix is applied at emit time. + sources: Array, + // Header search paths resolved to dep-root-relative form. Each entry + // becomes `.headerSearchPath("")` in cSettings + cxxSettings. + headerSearchPaths: Array, + // Preprocessor defines (resolved by pod ipc) — emitted as `.define(...)` in + // cSettings + cxxSettings, honoring any per-config scope. + preprocessorDefines: Array, + // True when the target has ObjC(++) sources (.m/.mm). Drives emitting + + // `-include`ing a prefix header that ambient-imports Foundation/UIKit (which + // CocoaPods provides via a generated prefix.pch and SPM does not). + needsObjCPrefix: boolean, + // Bucketed dependency references — pre-computed by the translation layer. + // `coreReactNative` is true when ANY React-* / RCT* / RCT-Folly / glog + // dep is present (so we add React's invariant header products). + // `siblingNames` are npm names that match other autolinked deps — resolved + // to Swift names by the scaffold orchestrator before emit. + coreReactNative: boolean, + siblingNames: Array, + // Extra frameworks beyond the autolinker's default UIKit/Foundation/CoreGraphics + // set. Merged with the defaults at emit time. + extraFrameworks: Array, + weakFrameworks: Array, + // Compiler flags lifted from `s.compiler_flags`. Tokenized, ready for + // `cxxSettings: [.unsafeFlags([...])]`. + compilerFlags: Array, + // Public-headers strategy. `mappingsDir` (when set) drives publicHeadersPath + // so the autolinker's centralized headers tree exposes `#import `. + publicHeadersPath: ?string, + // Root-level headers (podspecs like `s.source_files = "*.{h,m,mm}"`) that the + // scaffold writer mirrors into a generated include// shim dir so + // the CocoaPods header-map spelling `#import ` resolves + // for dependents (publicHeadersPath is then "include"). + namespacedShimHeaders: Array, + // File paths (relative to dep root) the emitter should declare as `.copy(...)` + // resources on the target. + resources: Array, + // Carried through from the PodspecModel; surfaced in the scaffold summary. + warnings: Array, +}; + +// Per-dep outcome from one scaffold run. The orchestrator returns an array +// of these so the CLI summary can print a structured table. +export type ScaffoldResult = + | { + depName: string, + status: 'written', + packageSwiftPath: string, + warnings: Array, + // True when the dep's Package.swift already existed (a regen — slot + // changed, --force, etc.); false on first-time scaffolds. The CLI + // orchestrator prompts only for first-time scaffolds. + previouslyExisted: boolean, + } + | { + depName: string, + status: + | 'skipped-self-managed' + | 'skipped-autogen' + | 'skipped-scaffolder-marker' + | 'skipped-no-ios' + | 'skipped-no-podspec' + | 'skipped-opt-out' + | 'skipped-mixed-language' + | 'skipped-is-react-native', + reason: string, + } + | { + depName: string, + status: 'error', + reason: string, + }; +*/ + +module.exports = {}; diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js new file mode 100644 index 000000000000..e01afe05c368 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -0,0 +1,648 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** + * Creates a logger trio {log, warn, die} that prefixes messages with [name]. + * log – green prefix, writes to stdout + * warn – yellow prefix, writes to stderr + * die – red prefix, writes to stderr, sets exitCode=1, throws + */ +function makeLogger(name /*: string */) /*: { + log: (msg: string) => void, + warn: (msg: string) => void, + die: (msg: string) => empty, +} */ { + // Prefix every newline-separated line of the message so multi-line output + // wraps cleanly when terminal log scrapers look for the `[name]` tag. + function format(color /*: string */, msg /*: string */) /*: string */ { + const prefix = `\x1b[${color}m[${name}]\x1b[0m`; + return msg + .split('\n') + .map(line => `${prefix} ${line}`) + .join('\n'); + } + return { + log(msg /*: string */) /*: void */ { + console.log(format('32', msg)); + }, + warn(msg /*: string */) /*: void */ { + console.warn(format('33', msg)); + }, + die(msg /*: string */) /*: empty */ { + console.error(format('31', msg)); + process.exitCode = 1; + throw new Error(msg); + }, + }; +} + +/** + * Returns a short, human-readable representation of an absolute path: + * - Paths under $HOME are shown as ~/... + * - Paths under cwd are shown as relative (if ≤2 levels up) + * - Otherwise the absolute path is returned unchanged + */ +function displayPath(p /*: string */) /*: string */ { + const home = os.homedir(); + if (p === home) return '~'; + if (p.startsWith(home + path.sep)) { + return '~' + p.slice(home.length); + } + const rel = path.relative(process.cwd(), p); + if (rel && !rel.startsWith('../../..')) { + return rel; + } + return p; +} + +/** + * Canonical React Native binary cache root. Mirrors CocoaPods' + * `ReactNativePodsUtils.shared_cache_dir()` (~/Library/Caches/ReactNative, added + * in #56847) so SPM and CocoaPods share one cache root — and so SPM stops using + * a `com.facebook.ReactNative` (bundle-id) dir that other tools may also touch. + * Honor `RCT_SKIP_CACHES=1` (same env var as CocoaPods) to bypass the shared + * tarball cache. + */ +function sharedCacheDir() /*: string */ { + return path.join(os.homedir(), 'Library', 'Caches', 'ReactNative'); +} + +/** + * Returns the default versioned cache directory for SPM's EXTRACTED xcframeworks, + * nested under the canonical cache root. Downloaded tarballs themselves go in the + * flat shared cache (sharedCacheDir()) so they are reused across SPM/CocoaPods. + * + * @param {string} versionKey Version string used as directory name. + * Pass the raw --version arg (e.g. 'nightly') so the + * cache slot is stable regardless of the resolved hash. + * @param {string} flavor 'debug' or 'release' + */ +function defaultCacheDir( + versionKey /*: string */, + flavor /*: string */, +) /*: string */ { + return path.join(sharedCacheDir(), 'spm-artifacts', versionKey, flavor); +} + +/** + * Sanitize a package/app name to a valid Swift identifier. + * e.g. "@react-native/tester" -> "RNTester", "my-app" -> "MyApp" + */ +function toSwiftName(name /*: string */) /*: string */ { + const base = name.replace(/^@[^/]+\//, ''); + return base + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +/** + * Derive a default app name from the raw package name and source path. + * Prefers the source directory name when it's meaningful (e.g. "RNTester"), + * falls back to the package name for generic dirs like "ios" or "src". + */ +function deriveAppName( + rawName /*: string */, + sourcePath /*: string */, +) /*: string */ { + const genericSourceDirs = new Set(['ios', 'app', 'sources', 'src']); + const cleanName = rawName.replace(/^@[^/]+\//, ''); + return toSwiftName( + sourcePath !== toSwiftName(cleanName) && + !genericSourceDirs.has(sourcePath.toLowerCase()) + ? sourcePath + : cleanName, + ); +} + +// $FlowFixMe[unclear-type] JSON data has dynamic shape +function readPackageJson(dir /*: string */) /*: Object | null */ { + const pkgPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return null; + } + // $FlowFixMe[incompatible-return] JSON.parse returns any + return JSON.parse(fs.readFileSync(pkgPath, 'utf8')); +} + +/** + * Walk up from startDir until we find a directory containing package.json. + * Returns startDir itself if it contains package.json, or startDir as fallback + * if no package.json is found anywhere up the tree. + */ +function findProjectRoot(startDir /*: string */) /*: string */ { + const start = path.resolve(startDir); + let dir = start; + // Bounded by filesystem depth — path.dirname converges to '/' or 'C:\\'. + // The `dir = ...` updates would otherwise drop the start-fallback narrowing. + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + dir = path.dirname(dir); + } + // At filesystem root — last check before falling back. + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + return start; +} + +/** + * Resolve the react-native package root from an app directory. + * Checks appRoot/projectRoot and their ancestors for node_modules/react-native, + * then falls back to __dirname-relative resolution (monorepo layout). + * + * Returns null if react-native cannot be found. + */ +function resolveReactNativeRoot( + appRoot /*: string */, + projectRoot /*: string */, +) /*: string | null */ { + const candidates /*: Array */ = []; + const seen /*: Set */ = new Set(); + + function addAncestorCandidates(startDir /*: string */) /*: void */ { + let dir = path.resolve(startDir); + while (true) { + // [macOS] Prefer this fork when both packages are installed. + for (const name of ['react-native-macos', 'react-native']) { + const candidate = path.join(dir, 'node_modules', name); + if (!seen.has(candidate)) { + seen.add(candidate); + candidates.push(candidate); + } + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } + + addAncestorCandidates(appRoot); + addAncestorCandidates(projectRoot); + candidates.push(path.resolve(__dirname, '../..')); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + return null; +} + +// The per-app farm lives INSIDE the codegen package (build/generated/ios) so +// it can be vended as a normal SPM headers target ("ReactAppHeaders") — the +// farm reaches consumers via SPM product dependencies, not -I. +const PER_APP_HEADERS_REL = 'build/generated/ios/ReactAppHeaders'; + +// Marker at the top of a scaffolder-generated Package.swift. Lives here (not in +// scaffold-package-swift.js) so the autolinker can recognize scaffolded files +// without a circular import (scaffold-package-swift requires the autolinker). +const SCAFFOLDER_MARKER = + '// AUTO-SCAFFOLDED by react-native spm scaffold — safe to edit & commit via patch-package.'; + +// Remote SPM package mode (prototype of the GitHub-distribution endgame): +// when active, app + libraries all depend on ONE remote package identity +// (`.package(url:exact:)`) instead of the local path-based artifacts package +// — SPM unifies the version across the graph and the local compose/symlink +// machinery is skipped. Activated via RN_SPM_REMOTE_URL + +// RN_SPM_REMOTE_VERSION (persisted per-app so Xcode-phase re-syncs without +// the env keep the mode). +const REMOTE_CONFIG_REL = 'build/generated/autolinking/spm-remote.json'; +function remotePackageIdentity(url /*: string */) /*: string */ { + const tail = url.replace(/\/+$/, '').split('/').pop() ?? ''; + return tail.replace(/\.git$/, '').toLowerCase(); +} + +/** + * Thrown by remotePackageConfig when remote SPM mode is active (a URL is set) + * but no usable RN version can be determined: react-native isn't installed, or + * the installed version is a non-publishable dev placeholder (e.g. the monorepo + * '1000.0.0', which has no remote tag) and no override was supplied. Carries a + * developer-facing message; the CLI turns it into a hard build error (exit 2). + */ +class RemoteVersionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'RemoteVersionError'; + } +} + +/** + * Resolve the version of the installed react-native by walking up from appRoot + * looking for node_modules/react-native/package.json. Mirrors the autolinker's + * appRoot-first, up-to-5-ancestors walk-up. Returns null when not found or the + * package.json has no string version. + */ +function resolveInstalledRnVersion(appRoot /*: string */) /*: ?string */ { + let dir = path.resolve(appRoot); + for (let i = 0; i <= 5; i++) { + const pkgPath = path.join( + dir, + 'node_modules', + 'react-native', + 'package.json', + ); + if (fs.existsSync(pkgPath)) { + try { + const j = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (typeof j.version === 'string') { + return j.version; + } + } catch {} + return null; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return null; +} + +/** + * True when a version string can be resolved to a published remote tag. False + * for the monorepo dev placeholder ('1000.0.0') and 0.0.0-* dev builds, neither + * of which is published — in remote mode these require an explicit override. + */ +function isPublishableVersion(v /*: ?string */) /*: boolean */ { + if (v == null || v === '') { + return false; + } + if (v === '1000.0.0') { + return false; + } + if (/^0\.0\.0(-|$)/.test(v)) { + return false; + } + return true; +} + +/** + * Resolve the remote SPM package config for `appRoot`, or null for local mode. + * + * Remote mode is gated by a URL alone (env RN_SPM_REMOTE_URL or persisted + * `url`). The version is an OVERRIDE chain — env RN_SPM_REMOTE_VERSION → + * persisted `versionOverride` → legacy persisted `version` (back-compat) — and + * when no override is set it is DERIVED from the installed react-native. The + * derived value is never persisted, so an npm RN upgrade auto-re-pins the SPM + * graph on the next sync. A derived version that isn't publishable (or a + * missing RN install) throws RemoteVersionError so the dev placeholder doesn't + * silently pin an unpublished tag. + */ +function remotePackageConfig( + appRoot /*: string */, +) /*: ?{url: string, version: string, identity: string} */ { + const envUrl = process.env.RN_SPM_REMOTE_URL; + const envVersion = process.env.RN_SPM_REMOTE_VERSION; + const cfgPath = path.join(appRoot, REMOTE_CONFIG_REL); + + // Read any persisted config first. Legacy {url, version} (where `version` + // was a hard pin) is read with `version` honored as an override. + let persisted /*: {url?: string, versionOverride?: string, version?: string} */ = + {}; + if (fs.existsSync(cfgPath)) { + try { + const j = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + if (j != null && typeof j === 'object') { + persisted = j; + } + } catch {} + } + + // URL alone activates remote mode (env wins over persisted). + const url = envUrl != null && envUrl !== '' ? envUrl : persisted.url; + if (url == null || url === '') { + return null; // local mode + } + + // Version override chain. When none is set, derive from the installed RN — + // the SPM graph must compile against the same RN the JS/native code uses. + const override = + envVersion != null && envVersion !== '' + ? envVersion + : (persisted.versionOverride ?? persisted.version); + const version = override ?? resolveInstalledRnVersion(appRoot); + + if (version == null) { + throw new RemoteVersionError( + `Remote SPM mode is on (URL=${url}) but no React Native version could ` + + 'be resolved (react-native was not found in node_modules). Set ' + + 'RN_SPM_REMOTE_VERSION to a published tag, or install react-native.', + ); + } + if (override == null && !isPublishableVersion(version)) { + throw new RemoteVersionError( + `Remote SPM mode is on (URL=${url}) but React Native resolves to ` + + `non-publishable version '${version}'. Set RN_SPM_REMOTE_VERSION to a ` + + 'published tag, or install a released react-native.', + ); + } + + // Persist {url, versionOverride?} only when env-driven (matching the prior + // behavior of capturing env so Xcode-phase re-syncs keep the mode). A derived + // version is never frozen: omit versionOverride so the next run re-derives. + const envDriven = + (envUrl != null && envUrl !== '') || + (envVersion != null && envVersion !== ''); + if (envDriven) { + const toPersist /*: {url: string, versionOverride?: string} */ = {url}; + if (override != null) { + toPersist.versionOverride = override; + } + fs.mkdirSync(path.dirname(cfgPath), {recursive: true}); + fs.writeFileSync(cfgPath, JSON.stringify(toPersist, null, 2) + '\n'); + } + + return { + url, + version, + identity: remotePackageIdentity(url), + }; +} +function perAppHeadersDir(appRoot /*: string */) /*: string */ { + return path.join(appRoot, PER_APP_HEADERS_REL); +} + +/** + * Creates a first-wins symlink linker rooted at `outDir`. `linkInto` maps a + * virtual import path to a physical file; `foldDir` recursively links every + * .h/.hpp under a source root. `seen` records virtual->realpath so identical + * duplicates collapse to one inode and non-identical collisions are surfaced. + * Each header tree gets its OWN linker (own `seen` map) so first-wins does not + * span the shared/per-app boundary. + */ +function createHeaderLinker( + outDir /*: string */, + logger /*: {log: (msg: string) => void} */, +) /*: { + seen: Map, + stats: {collisions: number}, + linkInto: (virtualPath: string, physical: string) => void, + foldDir: (srcRoot: string) => void, +} */ { + const seen /*: Map */ = new Map(); + const stats = {collisions: 0}; + + function linkInto( + virtualPath /*: string */, + physical /*: string */, + ) /*: void */ { + let real; + try { + real = fs.realpathSync(physical); + } catch { + return; // physical file missing — skip + } + const prev = seen.get(virtualPath); + if (prev != null) { + if (prev !== real) { + stats.collisions++; + logger.log( + `WARNING: merged headers: non-identical collision for ${virtualPath} (kept first)`, + ); + } + return; + } + seen.set(virtualPath, real); + const dest = path.join(outDir, virtualPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.symlinkSync(real, dest); + } + + function foldDir(srcRoot /*: string */) /*: void */ { + let real; + try { + real = fs.realpathSync(srcRoot); + } catch { + return; + } + if (!fs.statSync(real).isDirectory()) { + return; + } + const dirs /*: Array */ = [real]; + while (dirs.length > 0) { + const dir = dirs.pop(); + if (dir == null) { + break; + } + for (const ent of fs.readdirSync(dir, {withFileTypes: true})) { + const name = String(ent.name); + const child = path.join(dir, name); + // statSync (not the Dirent flags) so symlinks are followed — the + // autolinking header farm is itself a symlink farm, so its leaf + // headers are symlinks, not regular files. + let st; + try { + st = fs.statSync(child); + } catch { + continue; // broken symlink — skip + } + if (st.isDirectory()) { + dirs.push(child); + } else if ( + st.isFile() && + (name.endsWith('.h') || name.endsWith('.hpp')) + ) { + linkInto(path.relative(real, child), child); + } + } + } + } + + return {seen, stats, linkInto, foldDir}; +} + +/*:: +type HeaderTreeResult = {path: ?string, virtualPaths: Set}; +*/ + +/** + * Materializes the PER-APP header tree at + * /build/xcframeworks/ReactAppHeaders: autolinking dep headers + codegen + * output. Per-app because it depends on which libraries the app links and the + * app's generated specs. Returns {path, virtualPaths}. + */ +function buildPerAppHeaderTree( + appRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, +) /*: HeaderTreeResult */ { + const outDir = perAppHeadersDir(appRoot); + // Build into a temp dir OUTSIDE every folded root, then swap into place. The + // farm's final home (outDir) is INSIDE build/generated/ios — a tree foldDir + // walks — so `outDir + '.tmp'` would ALSO sit there and foldDir would fold + // the half-built farm back into itself (a spurious `ReactAppHeaders.tmp/` + // namespace duplicating every codegen header). build/.react-app-headers.tmp + // is under build/ but not under any folded subdir. + const tmpDir = path.join(appRoot, 'build', '.react-app-headers.tmp'); + fs.rmSync(tmpDir, {recursive: true, force: true}); + fs.rmSync(outDir, {recursive: true, force: true}); + fs.mkdirSync(tmpDir, {recursive: true}); + + const linker = createHeaderLinker(tmpDir, logger); + linker.foldDir( + path.join(appRoot, 'build', 'generated', 'autolinking', 'headers'), + ); + linker.foldDir(path.join(appRoot, 'build', 'generated', 'ios')); + linker.foldDir( + path.join(appRoot, 'build', 'generated', 'ios', 'ReactCodegen'), + ); + + // Stub source so the farm is a valid SPM target (vended headers-only — + // see the ReactAppHeaders target in the codegen Package.swift template). + fs.writeFileSync( + path.join(tmpDir, 'ReactAppHeadersStub.c'), + '// ReactAppHeaders vends the per-app generated headers; this stub\n' + + '// satisfies SPM, which requires at least one source file per target.\n' + + 'static int ReactAppHeadersStub __attribute__((unused)) = 0;\n', + ); + fs.mkdirSync(path.dirname(outDir), {recursive: true}); + fs.renameSync(tmpDir, outDir); + + logger.log( + `Built per-app header tree (${linker.seen.size} headers` + + (linker.stats.collisions > 0 + ? `, ${linker.stats.collisions} non-identical collisions` + : '') + + ')', + ); + return {path: outDir, virtualPaths: new Set(linker.seen.keys())}; +} + +/** + * Runs React Native codegen and installs the SPM Package.swift template + * into build/generated/ios/. Used by both setup-apple-spm.js and + * sync-spm-autolinking.js. + */ + +/** + * Installs the SPM codegen template into build/generated/ios/Package.swift. + * No-op when the template or the generated/ios dir is missing — codegen + * may not have produced output yet, or the project may be SPM-only. + * + * The template is copied verbatim in local mode: it holds only fixed-relative + * `.package(path:)` references (it lives at a known depth inside the app), and + * headers come from the ReactHeaders/ReactNativeHeaders targets + the + * ReactAppHeaders product — no loader, no absolute paths. In remote mode the + * ReactNative path-dep is rewritten to the `.package(url:exact:)` identity. + */ +function installSpmCodegenTemplate( + appRoot /*: string */, + reactNativeRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, +) /*: void */ { + const spmTemplate = path.join( + reactNativeRoot, + 'scripts', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ); + const codegenPkgSwift = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'Package.swift', + ); + if (!fs.existsSync(path.dirname(codegenPkgSwift))) { + // Codegen hasn't produced build/generated/ios yet — normal, nothing to do. + return; + } + if (!fs.existsSync(spmTemplate)) { + // Abnormal: the react-native package is missing its SPM codegen template. + // Without it, codegen's default mis-rooted Package.swift stays in place + // and every subsequent Resolve Package Graph fails — say so loudly. + logger.log( + `warning: SPM codegen template missing at ${spmTemplate} — ` + + 'build/generated/ios/Package.swift was NOT corrected and package ' + + 'resolution will likely fail. Is the react-native package complete?', + ); + return; + } + let content = fs.readFileSync(spmTemplate, 'utf8'); + // Remote mode: the codegen package depends on the remote ReactNative + // package identity instead of the local path-based artifacts package. + const remote = remotePackageConfig(appRoot); + if (remote != null) { + content = content + .replace( + '.package(name: "ReactNative", path: "../../xcframeworks"),', + `.package(url: "${remote.url}", exact: "${remote.version}"),`, + ) + .split('package: "ReactNative")') + .join(`package: "${remote.identity}")`); + } + fs.writeFileSync(codegenPkgSwift, content, 'utf8'); + logger.log( + 'Installed SPM codegen template' + (remote != null ? ' (remote mode)' : ''), + ); +} + +function runCodegenAndInstallTemplate( + projectRoot /*: string */, + appRoot /*: string */, + reactNativeRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, + opts /*: {installTemplate?: boolean} */ = {}, +) /*: void */ { + const codegenScript = path.join( + reactNativeRoot, + 'scripts', + 'generate-codegen-artifacts.js', + ); + if (!fs.existsSync(codegenScript)) { + return; + } + logger.log('Running codegen...'); + const {execSync} = require('child_process'); + const codegenArgs = + `node "${codegenScript}" -p "${projectRoot}" -t ios` + + (projectRoot !== appRoot ? ` -o "${appRoot}"` : ''); + execSync(codegenArgs, {stdio: 'inherit', cwd: projectRoot}); + // Callers that re-point the xcframework symlinks after codegen (e.g. the SPM + // sync, which runs generate-spm-package afterwards) install the template + // themselves once the symlinks are final; they pass installTemplate: false to + // avoid a wasted write that the later install would immediately supersede. + if (opts.installTemplate !== false) { + installSpmCodegenTemplate(appRoot, reactNativeRoot, logger); + } +} + +module.exports = { + makeLogger, + displayPath, + sharedCacheDir, + defaultCacheDir, + toSwiftName, + deriveAppName, + readPackageJson, + findProjectRoot, + resolveReactNativeRoot, + buildPerAppHeaderTree, + remotePackageConfig, + resolveInstalledRnVersion, + isPublishableVersion, + RemoteVersionError, + installSpmCodegenTemplate, + runCodegenAndInstallTemplate, + SCAFFOLDER_MARKER, +}; diff --git a/packages/react-native/scripts/spm/sync-spm-autolinking.js b/packages/react-native/scripts/spm/sync-spm-autolinking.js new file mode 100644 index 000000000000..c2e2992cc923 --- /dev/null +++ b/packages/react-native/scripts/spm/sync-spm-autolinking.js @@ -0,0 +1,160 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * sync-spm-autolinking.js – Lightweight script invoked by the Xcode pre-build + * phase to re-run invariant codegen and autolinking output when dependency + * inputs have changed. + * + * Usage (called from the Xcode build phase shell script): + * node sync-spm-autolinking.js --app-root --react-native-root + * + * The autolinking config (autolinking.json) is generated by the caller + * (setup-apple-spm.js) BEFORE this script runs, so codegen below can reuse it + * (findCodegenEnabledLibraries short-circuits on a present autolinking.json) + * and the slow `@react-native-community/cli config` runs exactly once per sync. + * + * This script: + * 0. Runs react-native codegen → build/generated/ios/ (reuses autolinking.json) + * 1. Installs the codegen template into build/generated/ios/Package.swift, + * replacing codegen's mis-rooted default (done immediately, before any + * step below that can throw — see the failure-atomicity comment at the + * call site) + * 2. Calls generate-spm-autolinking.js → build/generated/autolinking/Package.swift + * 3. Rebuilds the generated-headers farm + * 4. Writes build/generated/autolinking/.spm-sync-stamp + * + * Runtime frameworks are deliberately not downloaded or regenerated from an + * Xcode build. `spm add` / `spm update` own the immutable dual-flavor slots and + * the app target's linker/embed configuration. + */ + +const {main: generateAutolinking} = require('./generate-spm-autolinking'); +const { + RemoteVersionError, + buildPerAppHeaderTree, + findProjectRoot, + installSpmCodegenTemplate, + makeLogger, + runCodegenAndInstallTemplate, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log} = makeLogger('sync-spm-autolinking'); + +// Collaborators are injected (with these real implementations as defaults) so +// main() can be exercised end-to-end in tests without mocking the module +// system. Tests pass fakes that record calls and point defaultCacheDir at a +// tempdir; everything fs-based runs for real against that tempdir. +const defaultDeps = { + runCodegenAndInstallTemplate, + generateAutolinking, + installSpmCodegenTemplate, + buildPerAppHeaderTree, + findProjectRoot, +}; + +async function main( + argv /*:: ?: Array */, + overrides /*:: ?: Partial */, +) /*: Promise */ { + const deps = {...defaultDeps, ...(overrides ?? {})}; + const parsed = yargs(argv ?? process.argv.slice(2)) + .version(false) + .option('app-root', { + type: 'string', + demandOption: true, + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + demandOption: true, + describe: 'Path to react-native package root', + }) + .help() + .parseSync(); + + const appRoot = path.resolve(parsed['app-root']); + const reactNativeRoot = path.resolve(parsed['react-native-root']); + const projectRoot = deps.findProjectRoot(appRoot); + + // The caller (setup-apple-spm.js) already generated autolinking.json before + // invoking this script, so codegen reuses it here. + try { + deps.runCodegenAndInstallTemplate( + projectRoot, + appRoot, + reactNativeRoot, + {log}, + { + installTemplate: false, + }, + ); + } catch { + log('Codegen failed — continuing with existing output'); + } + + // Install the codegen template right away, before artifact download and + // autolinking-plugin execution below. Failure-atomicity: generateAutolinking + // runs fail-closed plugins that can throw, and if that happens before the + // template is installed, codegen's mis-rooted default manifest is left in + // build/generated/ios/Package.swift — breaking every subsequent Xcode + // "Resolve Package Graph". Installing it immediately after codegen means a + // later throw can never leave that broken manifest in place. + deps.installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + + log('Re-generating build/generated/autolinking/Package.swift...'); + deps.generateAutolinking([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + + // Rebuild the per-app generated-headers farm (vended as the ReactAppHeaders + // SPM target inside the codegen package). React core headers need no trees + // — they are vended by the invariant ReactHeaders/ReactNativeHeaders products. + // The + // generated manifests are fully declarative (fixed-relative package paths), + // so no path-locator JSON is written. + deps.buildPerAppHeaderTree(appRoot, {log}); + + const stampPath = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-sync-stamp', + ); + fs.mkdirSync(path.dirname(stampPath), {recursive: true}); + fs.writeFileSync(stampPath, new Date().toISOString() + '\n', 'utf8'); + log('SPM autolinking sync complete.'); +} + +if (require.main === module) { + main().catch(e => { + if (e instanceof RemoteVersionError) { + // Clean message + exit 2 (the build phase hard-fails on it) instead of a + // stack trace, matching how setup-apple-spm.js surfaces remote-mode + // version errors. + log(e.message); + process.exitCode = 2; + return; + } + console.error(e); + process.exitCode = 1; + }); +} + +module.exports = {main}; diff --git a/packages/react-native/sdks/hermes-engine/utils/replace_hermes_version.js b/packages/react-native/sdks/hermes-engine/utils/replace_hermes_version.js index 18ef3877b563..90689d251c08 100644 --- a/packages/react-native/sdks/hermes-engine/utils/replace_hermes_version.js +++ b/packages/react-native/sdks/hermes-engine/utils/replace_hermes_version.js @@ -14,7 +14,7 @@ const {spawnSync} = require('child_process'); const fs = require('fs'); const yargs = require('yargs'); -const LAST_BUILD_FILENAME = '.last_build_configuration'; +const LAST_BUILD_FILENAME = 'hermes-engine/.last_build_configuration'; function validateBuildConfiguration(configuration) { if (!['Debug', 'Release'].includes(configuration)) { @@ -56,15 +56,29 @@ function shouldReplaceHermesConfiguration(configuration) { function replaceHermesConfiguration(configuration, version, podsRoot) { const tarballURLPath = `${podsRoot}/hermes-engine-artifacts/hermes-ios-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`; + if (!fs.existsSync(tarballURLPath)) { + throw new Error(`Hermes tarball not found at ${tarballURLPath}`); + } + const finalLocation = 'hermes-engine'; console.log('Preparing the final location'); fs.rmSync(finalLocation, {force: true, recursive: true}); fs.mkdirSync(finalLocation, {recursive: true}); console.log('Extracting the tarball'); - spawnSync('tar', ['-xf', tarballURLPath, '-C', finalLocation], { - stdio: 'inherit', - }); + const result = spawnSync( + 'tar', + ['-xf', tarballURLPath, '-C', finalLocation], + { + stdio: 'inherit', + }, + ); + if (result.status !== 0) { + fs.rmSync(finalLocation, {force: true, recursive: true}); + throw new Error( + `Failed to extract ${tarballURLPath}: ${result.error?.message ?? `tar exited with status ${result.status}`}`, + ); + } } function updateLastBuildConfiguration(configuration) { diff --git a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec index a0cffd1930e3..1095c440b671 100644 --- a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec +++ b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec @@ -53,13 +53,13 @@ Pod::Spec.new do |spec| # Check if XCFRAMEWORK_PATH is empty if [ -z "$XCFRAMEWORK_PATH" ]; then echo "ERROR: XCFRAMEWORK_PATH is empty." - exit 0 + exit 1 fi # Check if HEADERS_PATH is empty if [ -z "$HEADERS_PATH" ]; then echo "ERROR: HEADERS_PATH is empty." - exit 0 + exit 1 fi cp -R "$HEADERS_PATH/." Headers diff --git a/packages/rn-tester/RNTester/AppDelegate.h b/packages/rn-tester/RNTester/AppDelegate.h index ca0531416fdf..da5f5f42d87d 100644 --- a/packages/rn-tester/RNTester/AppDelegate.h +++ b/packages/rn-tester/RNTester/AppDelegate.h @@ -5,8 +5,17 @@ * LICENSE file in the root directory of this source tree. */ +// ZERO-I: bare angle includes have no framework spelling, so the SPM zero-I +// build uses the form. rn-tester also builds via CocoaPods, where +// only the bare form resolves — hence the dual. Single-mode consumers write +// just the form matching their setup. +#if __has_include() +#import +#import +#else #import #import +#endif #import // [macOS] #if !TARGET_OS_OSX // [macOS] diff --git a/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js b/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js new file mode 100644 index 000000000000..9c825a0737d6 --- /dev/null +++ b/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js @@ -0,0 +1,102 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +const {RNTesterThemeContext} = require('../../components/RNTesterTheme'); +const React = require('react'); +const {Alert, Button, StyleSheet, Text, View} = require('react-native'); +const {greet} = require('react-native-test-library-apple'); +const {getVersion} = require('react-native-test-library-common'); + +class TestLibraryDemo extends React.Component< + {...}, + {appleResult: string, commonResult: string}, +> { + state: {appleResult: string, commonResult: string} = { + appleResult: '(not called yet)', + commonResult: '(not called yet)', + }; + + render(): React.Node { + return ( + + {theme => ( + + +