diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000000..7cfd02b41a8c --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,41 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@react-native/asset-utils": "0.87.0-rc.1", + "@react-native/assets-registry": "0.87.0-rc.1", + "@react-native/babel-plugin-codegen": "0.87.0-rc.1", + "@react-native/community-cli-plugin": "0.87.0-rc.1", + "@react-native/debugger-frontend": "0.87.0-rc.1", + "@react-native/debugger-shell": "0.87.0-rc.1", + "@react-native/dev-middleware": "0.87.0-rc.1", + "@react-native/eslint-config": "0.87.0-rc.1", + "@react-native/eslint-plugin": "0.87.0-rc.1", + "@react-native/eslint-plugin-specs": "0.87.0-rc.1", + "@react-native/gradle-plugin": "0.87.0-rc.1", + "@react-native/jest-preset": "0.87.0-rc.1", + "@react-native/metro-config": "0.87.0-rc.1", + "@react-native/new-app-screen": "0.87.0-rc.1", + "@react-native/normalize-colors": "0.87.0-rc.1", + "@react-native/js-polyfills": "0.87.0-rc.1", + "react-native-macos": "0.87.0-rc.0", + "@react-native/babel-preset": "0.87.0-rc.1", + "@react-native/metro-babel-transformer": "0.87.0-rc.1", + "@react-native/codegen": "0.87.0-rc.1", + "@react-native/compatibility-check": "0.87.0-rc.1", + "react-native-macos-init": "2.1.3", + "@react-native/popup-menu-android": "0.87.0-rc.1", + "@react-native/oss-library-example": "0.81.0-main", + "react-native-test-library-apple": "0.87.0-main", + "react-native-test-library-common": "0.87.0-main", + "@react-native/tester": "0.87.0-rc.1", + "@react-native/typescript-config": "0.87.0-rc.1", + "@react-native-macos/virtualized-lists": "0.87.0-rc.0", + "@react-native/core-cli-utils": "0.0.0", + "@react-native/eslint-plugin-monorepo": "0.0.0", + "@react-native/monorepo-tests": "0.0.0", + "@react-native/codegen-typescript-test": "0.0.0", + "@react-native/fantom": "0.0.0" + }, + "changesets": [] +} diff --git a/.changeset/stable-sync.md b/.changeset/stable-sync.md new file mode 100644 index 000000000000..fdec15b918b5 --- /dev/null +++ b/.changeset/stable-sync.md @@ -0,0 +1,6 @@ +--- +"react-native-macos": patch +"@react-native-macos/virtualized-lists": patch +--- + +Sync to upstream React Native 0.87.0-rc.1. 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/actions/build-npm-package/action.yml b/.github/actions/build-npm-package/action.yml index 2c5dd61a7d67..e3771ce3d51a 100644 --- a/.github/actions/build-npm-package/action.yml +++ b/.github/actions/build-npm-package/action.yml @@ -31,6 +31,15 @@ runs: pattern: ReactCore* path: ./packages/react-native/ReactAndroid/external-artifacts/artifacts merge-multiple: true + # ReactNativeDependenciesHeaders* is already covered by the + # ReactNativeDependencies* pattern above; ReactNativeHeaders* needs its own. + - name: Download ReactNativeHeaders artifacts + if: ${{ inputs.skip-apple-prebuilts != 'true' }} + uses: actions/download-artifact@v7 + with: + pattern: ReactNativeHeaders* + path: ./packages/react-native/ReactAndroid/external-artifacts/artifacts + merge-multiple: true - name: Print Artifacts Directory if: ${{ inputs.skip-apple-prebuilts != 'true' }} shell: bash diff --git a/.github/actions/test-ios-rntester/action.yml b/.github/actions/test-ios-rntester/action.yml index fbb2d7474c38..cdc2f5213a25 100644 --- a/.github/actions/test-ios-rntester/action.yml +++ b/.github/actions/test-ios-rntester/action.yml @@ -15,7 +15,23 @@ 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 + build-from-source: + description: >- + Force building ReactCore/ReactNativeDependencies from source, disabling the + prebuilt Maven fallback (RCT_USE_PREBUILT_RNCORE / RCT_USE_RN_DEP, which + otherwise default to '1'). Used by the from-source dynamic-frameworks lane. required: false default: false @@ -41,24 +57,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,11 +102,22 @@ 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 + if [[ "${{ inputs.build-from-source }}" == "true" ]]; then + # Force building ReactCore/ReactNativeDependencies from source. Both + # RCT_USE_PREBUILT_RNCORE and RCT_USE_RN_DEP default to '1', so on a stable + # branch (where released Maven artifacts exist for the current version) the + # install would otherwise consume those prebuilts instead of building from + # source. That is wrong for the from-source dynamic-frameworks lane and also + # fails when a published artifact predates newer podspec layout changes + # (e.g. a React core tarball without ReactNativeHeaders.xcframework). + export RCT_USE_PREBUILT_RNCORE=0 + export RCT_USE_RN_DEP=0 + fi cd packages/rn-tester diff --git a/.github/scripts/__tests__/resolve-hermes-url.test.mjs b/.github/scripts/__tests__/resolve-hermes-url.test.mjs new file mode 100644 index 000000000000..6b368d247796 --- /dev/null +++ b/.github/scripts/__tests__/resolve-hermes-url.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {createRequire, stripTypeScriptTypes} from 'node:module'; +import {test} from 'node:test'; +import {runInNewContext} from 'node:vm'; + +const callerSource = readFileSync(new URL('../resolve-hermes.mts', import.meta.url), 'utf8'); +const helperSource = readFileSync(new URL( + '../../../packages/react-native/scripts/ios-prebuild/utils.js', import.meta.url, +), 'utf8'); +// Execute the actual downloader without the CLI dispatch or the zx dependency. +const downloaderSource = stripTypeScriptTypes(callerSource.slice( + callerSource.indexOf('async function downloadUpstreamHermesTarball('), + callerSource.indexOf('/**\n * Extracts an upstream Hermes tarball'), +)); + +for (const buildType of ['Debug', 'Release']) { + for (const metadataAvailable of [true, false]) { + test(`${buildType}: ${metadataAvailable ? 'resolves nightly metadata' : 'missing metadata keeps release fallback'}`, async () => { + const version = buildType === 'Debug' ? '123.4.5' : '987.6.5'; + const flavor = buildType.toLowerCase(); + const snapshotRoot = `https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/${version}-SNAPSHOT`; + const metadataUrl = `${snapshotRoot}/maven-metadata.xml`; + const releaseUrl = `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-${flavor}.tar.gz`; + const nightlyUrl = `${snapshotRoot}/hermes-ios-${version}-20260915.123456-7-hermes-ios-${flavor}.tar.gz`; + const requests = []; + const fetch = async url => { + requests.push(url); + if (url === metadataUrl && metadataAvailable) { + return { + ok: true, + text: async () => '20260915.1234567', + }; + } + return {ok: false, status: 404, statusText: 'Not Found'}; + }; + const module = {exports: {}}; + runInNewContext(helperSource, { + module, + require: createRequire(import.meta.url), + fetch, + }); + const download = runInNewContext(`${downloaderSource}\ndownloadUpstreamHermesTarball`, { + computeNightlyTarballURL: module.exports.computeNightlyTarballURL, + resolveHermesArtifactVersion: () => version, + fetch, + echo: () => {}, + }); + + assert.equal(await download(buildType), null); + assert.deepEqual(requests, metadataAvailable + ? [metadataUrl, releaseUrl, nightlyUrl] + : [metadataUrl, releaseUrl]); + }); + } +} diff --git a/.github/workflow-scripts/__tests__/maestro-ios-test.js b/.github/workflow-scripts/__tests__/maestro-ios-test.js new file mode 100644 index 000000000000..e77c0c05cfd0 --- /dev/null +++ b/.github/workflow-scripts/__tests__/maestro-ios-test.js @@ -0,0 +1,41 @@ +/** + * 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 + */ + +jest.mock('child_process', () => ({ + execSync: jest.fn(), + spawn: jest.fn(), +})); +const childProcess = require('child_process'); + +const {findAvailableSimulator} = require('../maestro-ios'); + +describe('Maestro iOS runner', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('selects an iPhone Pro simulator from the latest runtime', () => { + childProcess.execSync.mockReturnValue( + JSON.stringify({ + devices: { + 'iOS 18.5': [{name: 'iPhone 16 Pro', udid: 'old-pro'}], + 'iOS 26.5': [ + {name: 'iPhone 17 Pro Max', udid: 'new-pro-max'}, + {name: 'iPhone 17 Pro', udid: 'new-pro'}, + ], + }, + }), + ); + + expect(findAvailableSimulator()).toEqual({ + name: 'iPhone 17 Pro', + udid: 'new-pro', + }); + }); +}); diff --git a/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js b/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js index e77e7c4e2e49..8e9758f28609 100644 --- a/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js +++ b/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js @@ -22,6 +22,28 @@ jest.mock('../utils.js', () => ({ process.exit = mockExit; global.fetch = mockFetch; +const BASE_URL = + 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts'; + +// The verifier HEAD-checks the POM plus every classifier tarball attached to +// the react-native-artifacts publication (external-artifacts/build.gradle.kts). +const expectedUrls = version => [ + `${BASE_URL}/${version}/react-native-artifacts-${version}.pom`, + ...[ + 'reactnative-core-debug', + 'reactnative-core-release', + 'reactnative-dependencies-debug', + 'reactnative-dependencies-release', + 'reactnative-headers-debug', + 'reactnative-headers-release', + 'reactnative-dependencies-headers-debug', + 'reactnative-dependencies-headers-release', + ].map( + classifier => + `${BASE_URL}/${version}/react-native-artifacts-${version}-${classifier}.tar.gz`, + ), +]; + describe('#verifyArtifactsAreOnMaven', () => { beforeEach(jest.clearAllMocks); @@ -29,17 +51,18 @@ describe('#verifyArtifactsAreOnMaven', () => { mockSleep.mockReturnValueOnce(Promise.resolve()).mockImplementation(() => { throw new Error('Should not be called again!'); }); + // First attempt: the POM is not there yet. Second attempt: every URL is. mockFetch .mockReturnValueOnce(Promise.resolve({status: 404})) - .mockReturnValueOnce(Promise.resolve({status: 200})); + .mockReturnValue(Promise.resolve({status: 200})); const version = '0.78.1'; await verifyArtifactsAreOnMaven(version); expect(mockSleep).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', - ); + for (const url of expectedUrls('0.78.1')) { + expect(mockFetch).toHaveBeenCalledWith(url, {method: 'HEAD'}); + } }); it('waits for the packages to be published on maven, when version starts with v', async () => { @@ -48,27 +71,46 @@ describe('#verifyArtifactsAreOnMaven', () => { }); mockFetch .mockReturnValueOnce(Promise.resolve({status: 404})) - .mockReturnValueOnce(Promise.resolve({status: 200})); + .mockReturnValue(Promise.resolve({status: 200})); const version = 'v0.78.1'; await verifyArtifactsAreOnMaven(version); expect(mockSleep).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', - ); + for (const url of expectedUrls('0.78.1')) { + expect(mockFetch).toHaveBeenCalledWith(url, {method: 'HEAD'}); + } }); it('passes immediately if packages are already on Maven', async () => { - mockFetch.mockReturnValueOnce(Promise.resolve({status: 200})); + mockFetch.mockReturnValue(Promise.resolve({status: 200})); const version = '0.78.1'; await verifyArtifactsAreOnMaven(version); expect(mockSleep).toHaveBeenCalledTimes(0); - expect(mockFetch).toHaveBeenCalledWith( - 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', - ); + // All nine URLs (POM + 8 classifier tarballs) are verified in one pass. + expect(mockFetch).toHaveBeenCalledTimes(9); + for (const url of expectedUrls('0.78.1')) { + expect(mockFetch).toHaveBeenCalledWith(url, {method: 'HEAD'}); + } + }); + + it('waits when a classifier artifact is missing even though the POM exists', async () => { + mockSleep.mockReturnValueOnce(Promise.resolve()).mockImplementation(() => { + throw new Error('Should not be called again!'); + }); + // First attempt: POM ok, first classifier missing. Second attempt: all ok. + mockFetch + .mockReturnValueOnce(Promise.resolve({status: 200})) + .mockReturnValueOnce(Promise.resolve({status: 404})) + .mockReturnValue(Promise.resolve({status: 200})); + + const version = '0.78.1'; + await verifyArtifactsAreOnMaven(version); + + expect(mockSleep).toHaveBeenCalledTimes(1); + expect(mockExit).not.toHaveBeenCalled(); }); it('tries 90 times and then exits', async () => { @@ -82,6 +124,7 @@ describe('#verifyArtifactsAreOnMaven', () => { expect(mockExit).toHaveBeenCalledWith(1); expect(mockFetch).toHaveBeenCalledWith( 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', + {method: 'HEAD'}, ); }); }); diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js index bad5f8a35248..0fcae5a54b89 100644 --- a/.github/workflow-scripts/maestro-ios.js +++ b/.github/workflow-scripts/maestro-ios.js @@ -23,25 +23,28 @@ node maestro-android.js /^iPhone .* Pro$/.test(device.name)); -const APP_PATH = args[0]; -const APP_ID = args[1]; -const MAESTRO_FLOW = args[2]; -const JS_ENGINE = args[3]; -const IS_DEBUG = args[4] === 'Debug'; -const WORKING_DIRECTORY = args[5]; + if (simulator == null) { + throw new Error('Unable to find an available iPhone Pro simulator'); + } -const MAX_ATTEMPTS = 5; + return simulator; +} -function launchSimulator(simulatorName) { - console.log(`Launching simulator ${simulatorName}`); +function launchSimulator(simulator) { + console.log(`Launching simulator ${simulator.name} (${simulator.udid})`); try { - childProcess.execSync(`xcrun simctl boot "${simulatorName}"`); + childProcess.execSync(`xcrun simctl boot "${simulator.udid}"`); } catch (error) { if ( !error.message.includes('Unable to boot device in current state: Booted') @@ -56,14 +59,6 @@ function installAppOnSimulator(appPath) { childProcess.execSync(`xcrun simctl install booted "${appPath}"`); } -function extractSimulatorUDID() { - console.log('Retrieving device UDID'); - const command = `xcrun simctl list devices booted -j | jq -r '[.devices[]] | add | first | .udid'`; - const udid = String(childProcess.execSync(command)).trim(); - console.log(`UDID is ${udid}`); - return udid; -} - function bringSimulatorInForeground() { console.log('Bringing simulator in foreground'); childProcess.execSync('open -a simulator'); @@ -151,26 +146,42 @@ function executeTestsWithRetries( } } -async function main() { +async function main(args = process.argv.slice(2)) { + if (args.length !== 6) { + throw new Error(`Invalid number of arguments.\n${usage}`); + } + + const appPath = args[0]; + const appId = args[1]; + const maestroFlow = args[2]; + const jsengine = args[3]; + const isDebug = args[4] === 'Debug'; + const workingDirectory = args[5]; + console.info('\n=============================='); console.info('Running tests for iOS with the following parameters:'); - console.info(`APP_PATH: ${APP_PATH}`); - console.info(`APP_ID: ${APP_ID}`); - console.info(`MAESTRO_FLOW: ${MAESTRO_FLOW}`); - console.info(`JS_ENGINE: ${JS_ENGINE}`); - console.info(`IS_DEBUG: ${IS_DEBUG}`); - console.info(`WORKING_DIRECTORY: ${WORKING_DIRECTORY}`); + console.info(`APP_PATH: ${appPath}`); + console.info(`APP_ID: ${appId}`); + console.info(`MAESTRO_FLOW: ${maestroFlow}`); + console.info(`JS_ENGINE: ${jsengine}`); + console.info(`IS_DEBUG: ${isDebug}`); + console.info(`WORKING_DIRECTORY: ${workingDirectory}`); console.info('==============================\n'); - const simulatorName = 'iPhone 16 Pro'; - launchSimulator(simulatorName); - installAppOnSimulator(APP_PATH); - const udid = extractSimulatorUDID(); + const simulator = findAvailableSimulator(); + launchSimulator(simulator); + installAppOnSimulator(appPath); bringSimulatorInForeground(); - await launchAppOnSimulator(APP_ID, udid, IS_DEBUG); - executeTestsWithRetries(APP_ID, udid, MAESTRO_FLOW, JS_ENGINE, 1); + await launchAppOnSimulator(appId, simulator.udid, isDebug); + executeTestsWithRetries(appId, simulator.udid, maestroFlow, jsengine, 1); console.log('Test finished'); process.exit(0); } -main(); +if (require.main === module) { + main(); +} + +module.exports = { + findAvailableSimulator, +}; diff --git a/.github/workflow-scripts/verifyArtifactsAreOnMaven.js b/.github/workflow-scripts/verifyArtifactsAreOnMaven.js index 1bb46163e0a2..5feb3777c032 100644 --- a/.github/workflow-scripts/verifyArtifactsAreOnMaven.js +++ b/.github/workflow-scripts/verifyArtifactsAreOnMaven.js @@ -7,6 +7,7 @@ * @format */ +// @flow const {log, sleep} = require('./utils'); const SLEEP_S = 60; // 1 minute @@ -15,23 +16,64 @@ const ARTIFACT_URL = 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/'; const ARTIFACT_NAME = 'react-native-artifacts-'; -async function verifyArtifactsAreOnMaven(version, retries = MAX_RETRIES) { +// The primary xcframework classifier tarballs attached to the +// react-native-artifacts publication (external-artifacts/build.gradle.kts). +// The 4 dSYM classifiers (core/deps dSYM debug+release) are intentionally +// excluded — they are debug-symbol sidecars, not consumed at install time. +// The POM check alone would pass even when a classifier artifact never made +// it to Maven. +const ARTIFACT_CLASSIFIERS = [ + 'reactnative-core-debug', + 'reactnative-core-release', + 'reactnative-dependencies-debug', + 'reactnative-dependencies-release', + 'reactnative-headers-debug', + 'reactnative-headers-release', + 'reactnative-dependencies-headers-debug', + 'reactnative-dependencies-headers-release', +]; + +async function verifyArtifactsAreOnMaven( + version /*: string */, + retries /*: number */ = MAX_RETRIES, +) /*: Promise */ { if (version.startsWith('v')) { version = version.substring(1); } - const artifactUrl = `${ARTIFACT_URL}${version}/${ARTIFACT_NAME}${version}.pom`; + const urls = [ + `${ARTIFACT_URL}${version}/${ARTIFACT_NAME}${version}.pom`, + ...ARTIFACT_CLASSIFIERS.map( + classifier => + `${ARTIFACT_URL}${version}/${ARTIFACT_NAME}${version}-${classifier}.tar.gz`, + ), + ]; for (let currentAttempt = 1; currentAttempt <= retries; currentAttempt++) { - const response = await fetch(artifactUrl); - - if (response.status !== 200) { - log( - `${currentAttempt}) Artifact's for version ${version} are not on maven yet.\nURL: ${artifactUrl}\nLet's wait a minute and try again.\n`, - ); - await sleep(SLEEP_S); - } else { + let missingUrl = null; + for (const url of urls) { + try { + const response = await fetch(url, {method: 'HEAD'}); + if (response.status === 200) { + continue; + } + log(`Got status ${response.status} while checking ${url}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Network error while checking ${url}: ${message}`); + missingUrl = url; + break; + } + missingUrl = url; + break; + } + + if (missingUrl == null) { return; } + log( + `${currentAttempt}) Artifact's for version ${version} are not on maven yet.\nURL: ${missingUrl}\nLet's wait a minute and try again.\n`, + ); + await sleep(SLEEP_S); } log( diff --git a/.github/workflows/bump-podfile-lock.yml b/.github/workflows/bump-podfile-lock.yml index 94895d94e19b..9e5c42764430 100644 --- a/.github/workflows/bump-podfile-lock.yml +++ b/.github/workflows/bump-podfile-lock.yml @@ -5,7 +5,7 @@ on: jobs: bump-podfile-lock: - runs-on: macos-latest + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/e2e-ios-rntester.yml b/.github/workflows/e2e-ios-rntester.yml index cd53e954788c..f7ec8894d3d9 100644 --- a/.github/workflows/e2e-ios-rntester.yml +++ b/.github/workflows/e2e-ios-rntester.yml @@ -16,7 +16,7 @@ on: jobs: test: - runs-on: macos-15-large + runs-on: macos-26-large outputs: status: ${{ steps.report-status.outputs.status }} strategy: @@ -35,8 +35,6 @@ jobs: path: /tmp/RNTesterBuild/RNTester.app - name: Check downloaded folder content run: ls -lR /tmp/RNTesterBuild - - name: Setup xcode - uses: ./.github/actions/setup-xcode - name: Run E2E Tests id: run-tests continue-on-error: true diff --git a/.github/workflows/e2e-ios-templateapp.yml b/.github/workflows/e2e-ios-templateapp.yml index 44b64858bb48..20e5a9c5dff3 100644 --- a/.github/workflows/e2e-ios-templateapp.yml +++ b/.github/workflows/e2e-ios-templateapp.yml @@ -16,7 +16,7 @@ on: jobs: test: - runs-on: macos-15-large + runs-on: macos-26-large outputs: status: ${{ steps.report-status.outputs.status }} strategy: @@ -26,8 +26,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 - - name: Setup xcode - uses: ./.github/actions/setup-xcode - name: Setup node.js uses: ./.github/actions/setup-node - name: Run yarn diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index ad47de482fef..9c6bad03a434 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: v4-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,45 @@ 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: Set Hermes version + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + shell: bash + run: | + # Non-stable RN builds resolve Hermes from npm's latest-v1 dist-tag. + # TODO: rename 'latest-v1' to 'latest' once V1 is the only Hermes on npm. + # Stable builds use the version pinned in version.properties. + if [ "${{ inputs.use-hermes-prebuilt }}" == "true" ]; then + HERMES_VERSION="latest-v1" + else + HERMES_VERSION=$(sed -n 's/^HERMES_VERSION_NAME=//p' packages/react-native/sdks/hermes-engine/version.properties) + fi + echo "Using Hermes version: $HERMES_VERSION" + echo "HERMES_VERSION=$HERMES_VERSION" >> $GITHUB_ENV + - name: Stage Hermes headers + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + working-directory: packages/react-native + env: + FLAVOR: ${{ matrix.flavor }} + run: | + node -e "require('./scripts/ios-prebuild/hermes').prepareHermesArtifactsAsync(require('./package.json').version, process.env.FLAVOR).then(()=>process.exit(0)).catch(e=>{console.error(e);process.exit(1)})" - 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 @@ -167,22 +206,44 @@ jobs: if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT == '' }} run: | cd packages/react-native - node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" + node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-hermes' || '' }} - name: Create and Sign XCFramework if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} run: | cd packages/react-native - node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" + node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" ${{ inputs.version-type != '' && '--require-hermes' || '' }} + - 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 }}" ${{ inputs.version-type != '' && '--require-stamped-version' || '' }} - 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: | cd packages/react-native/.build/output/xcframeworks/${{matrix.flavor}}/Symbols tar -cz -f ../../ReactCore${{ matrix.flavor }}.framework.dSYM.tar.gz . + - name: Rename ReactNativeHeaders XCFramework tarball + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + run: | + # The compose step already tars ReactNativeHeaders.xcframework standalone; + # published as its own Maven artifact (classifier reactnative-headers-*) + # so SwiftPM consumers can wire it as a separate binaryTarget. It also + # ships inside the ReactCore tarball for the CocoaPods pod. + cp packages/react-native/.build/output/xcframeworks/${{matrix.flavor}}/ReactNativeHeaders.xcframework.tar.gz \ + packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz - name: Upload XCFramework Artifact uses: actions/upload-artifact@v6 with: @@ -193,6 +254,11 @@ jobs: with: name: ReactCore${{ matrix.flavor }}.framework.dSYM.tar.gz path: packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.framework.dSYM.tar.gz + - name: Upload ReactNativeHeaders XCFramework Artifact + uses: actions/upload-artifact@v6 + with: + name: ReactNativeHeaders${{ matrix.flavor }}.xcframework.tar.gz + path: packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz - name: Save cache if present if: ${{ github.ref == 'refs/heads/main' }} # To avoid that the cache explode uses: actions/cache/save@v5 @@ -200,4 +266,5 @@ 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/**/*') }} + packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz + key: v4-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..b34fcf203eb7 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,13 @@ 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: Compress Headers Sidecar XCFramework + if: steps.restore-xcframework.outputs.cache-hit != 'true' + run: | + tar -cz -f packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz \ + 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 @@ -179,6 +185,11 @@ jobs: with: name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz path: packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + - name: Upload Headers Sidecar XCFramework Artifact + uses: actions/upload-artifact@v6 + with: + name: ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz + path: packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz - name: Upload dSYM Artifact uses: actions/upload-artifact@v6 with: @@ -191,5 +202,6 @@ jobs: with: path: | packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + packages/react-native/third-party/ReactNativeDependenciesHeaders${{ 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/publish-npm.yml b/.github/workflows/publish-npm.yml index 2eb06590225b..fad0e0b3a47d 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -201,7 +201,15 @@ jobs: post_publish: runs-on: ubuntu-latest needs: [determine_mode, publish_react_native] - if: needs.determine_mode.outputs.mode == 'release' + # Use always() so this job still runs in release mode even though some + # upstream jobs (e.g. build_android) are skipped and would otherwise + # poison the implicit success() gate. The explicit result checks below + # handle the real gating: only run for a successful release publish. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.publish_react_native.result == 'success' && + needs.determine_mode.outputs.mode == 'release' env: REACT_NATIVE_BOT_GITHUB_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} steps: @@ -256,19 +264,38 @@ jobs: # ─── Release-only: changelog, podfile bump, draft release ──────── generate_changelog: needs: [determine_mode, publish_react_native] - if: needs.determine_mode.outputs.mode == 'release' + # always() + explicit result checks: run for a successful release publish + # even when skipped upstream jobs would trip the implicit success() gate. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.publish_react_native.result == 'success' && + needs.determine_mode.outputs.mode == 'release' uses: ./.github/workflows/generate-changelog.yml secrets: inherit bump_podfile_lock: needs: [determine_mode, publish_react_native] - if: needs.determine_mode.outputs.mode == 'release' + # always() + explicit result checks: run for a successful release publish + # even when skipped upstream jobs would trip the implicit success() gate. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.publish_react_native.result == 'success' && + needs.determine_mode.outputs.mode == 'release' uses: ./.github/workflows/bump-podfile-lock.yml secrets: inherit create_draft_release: needs: [determine_mode, generate_changelog, set_hermes_version] - if: needs.determine_mode.outputs.mode == 'release' + # always() + explicit result checks: run for a successful release even when + # skipped upstream jobs would trip the implicit success() gate. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.generate_changelog.result == 'success' && + needs.set_hermes_version.result == 'success' && + needs.determine_mode.outputs.mode == 'release' uses: ./.github/workflows/create-draft-release.yml secrets: inherit with: diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 422a90c712ae..9579037a44ab 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -136,6 +136,7 @@ jobs: with: flavor: ${{ matrix.flavor }} use-frameworks: true + build-from-source: true run-unit-tests: false # tests for dynamic frameworks are already run in the test_ios_rntester job; this is to just a test build from source (no prebuilds) test_ios_rntester: @@ -155,6 +156,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: @@ -471,9 +478,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/__docs__/README.md b/__docs__/README.md index dd25a3fd248d..6ce5cdddef3e 100644 --- a/__docs__/README.md +++ b/__docs__/README.md @@ -80,6 +80,7 @@ TODO: Explain the different components of React Native at a high level. - Build system - Android - iOS + - [SwiftPM](../packages/react-native/scripts/spm/__docs__/README.md) - C++ - JavaScript - Metro diff --git a/gradle.properties b/gradle.properties index 1028b5c5238e..0d5b4a717b54 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,8 +13,8 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # Controls whether to use Hermes from stable builds. This will force hermes version # set in the sdks/hermes-engine/version.properties file to be used. This has a higher # priority than react.internal.useHermesNightly. -react.internal.useHermesStable=false +react.internal.useHermesStable=true # Controls whether to use Hermes from nightly builds. This will speed up builds # but should NOT be turned on for CI or release builds. -react.internal.useHermesNightly=true +react.internal.useHermesNightly=false diff --git a/jest.config.js b/jest.config.js index f881ea65f144..273b27feb344 100644 --- a/jest.config.js +++ b/jest.config.js @@ -52,6 +52,8 @@ module.exports = { // [macOS moduleNameMapper: { // These mappers allow out-of-tree platforms tests to resolve RN imports + '^react-native/setup-env$': + '/packages/react-native/src/setup-env.js', '^react-native/(.*)': '/packages/react-native/$1', '^react-native$': '/packages/react-native/index.js', }, diff --git a/package.json b/package.json index 88e1f61326d9..887e05bfe5b0 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", "test": "jest", "fantom": "./scripts/fantom.sh", "fantom-cli": "./scripts/fantom-cli.sh", @@ -48,6 +48,7 @@ "workspaces": [ "packages/*", "tools/*", + "packages/react-native-test-library/*", "private/*", "!packages/helloworld", "!packages/hermes-inspector-msggen", @@ -129,10 +130,10 @@ "markdownlint-rule-relative-links": "^3.0.0", "memfs": "^4.38.2", "metro": "^0.84.3", - "metro-babel-register": "^0.86.0", + "metro-babel-register": "^0.87.0", "metro-config": "^0.84.3", "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/asset-utils/package.json b/packages/asset-utils/package.json index edfd02042443..00ac2c5cc4ff 100644 --- a/packages/asset-utils/package.json +++ b/packages/asset-utils/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/asset-utils", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Asset path utilities for React Native.", "license": "MIT", "repository": { diff --git a/packages/assets-registry/package.json b/packages/assets-registry/package.json index 4929f5a28fd1..a169616748f2 100644 --- a/packages/assets-registry/package.json +++ b/packages/assets-registry/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/assets-registry", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Asset support code for React Native.", "license": "MIT", "repository": { diff --git a/packages/babel-plugin-codegen/package.json b/packages/babel-plugin-codegen/package.json index 166bd3cb4dc9..e2c3229095c1 100644 --- a/packages/babel-plugin-codegen/package.json +++ b/packages/babel-plugin-codegen/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/babel-plugin-codegen", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Babel plugin to generate native module and view manager code for React Native.", "license": "MIT", "repository": { diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index 6d15fc021ba6..e466ac7d6e3f 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/community-cli-plugin", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Core CLI commands for React Native", "keywords": [ "react-native", @@ -36,12 +36,12 @@ "chalk": "^4.0.0", "debug": "^4.4.0", "invariant": "^2.2.4", - "metro": "^0.86.0", + "metro": "^0.87.0", "metro-config": "^0.86.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..c209e829a3cf 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,15 @@ 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]}, - ), + require.resolve('react-native/setup-env', { + paths: [ctx.root], + }), ...outOfTreePlatforms.map(platform => require.resolve( - `${ctx.platforms[platform].npmPackageName}/Libraries/Core/InitializeCore`, + `${ctx.platforms[platform].npmPackageName}/setup-env`, {paths: [ctx.root]}, ), ), diff --git a/packages/debugger-frontend/package.json b/packages/debugger-frontend/package.json index c7a9e765eac7..1ef8f5e49d03 100644 --- a/packages/debugger-frontend/package.json +++ b/packages/debugger-frontend/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/debugger-frontend", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Debugger frontend for React Native based on Chrome DevTools", "keywords": [ "react-native", diff --git a/packages/debugger-shell/bin/react-native-devtools b/packages/debugger-shell/bin/react-native-devtools index 3c6f6a90b9fd..c4685c9f270c 100755 --- a/packages/debugger-shell/bin/react-native-devtools +++ b/packages/debugger-shell/bin/react-native-devtools @@ -1,6 +1,6 @@ #!/usr/bin/env dotslash -// @generated SignedSource<<01dcf5246a0b3f95d617d1d52bc1998e>> +// @generated SignedSource<<3542489cbb6fe7c6958575bcc6cf0ab3>> { @@ -11,6 +11,9 @@ "hash": "sha256", "digest": "020eea3cf10f77b8f4e1ae4a2aaa7d78a77c3802de6b00448866aa9c860509c4", "providers": [ + { + "url": "https://github.com/facebook/react-native/releases/download/v0.87.1/React.Native.DevTools-linux-aarch64.tar.gz" + }, { "type": "http", "url": "https://scontent.xx.fbcdn.net/mci_ab/uap/?ab_b=m&ab_page=react_native_devtools_binaries&ab_entry=AQPgk_PO-qLmLS1ZUyNtm9PZa0Z6XoCXar8Mo-ZDooUwm7-0tv-ga6cl1eB-9o0pVmKxH0-9xTS4NmFyyF1Qwn_x4J0olUnVH8AEHASw1VlnpkN5EPuTII29SmB_1Gis6_twDOo8CwWXFsa2p4XxRceIj-_IINp-fqrGuekCH-Kwd7BqsDsnBykigqY" @@ -24,6 +27,9 @@ "hash": "sha256", "digest": "933aa98eb68da7f8d1b6e83e611e51f66d026f3b410dc13577faa6adf1b0f75c", "providers": [ + { + "url": "https://github.com/facebook/react-native/releases/download/v0.87.1/React.Native.DevTools-linux-x86_64.tar.gz" + }, { "type": "http", "url": "https://scontent.xx.fbcdn.net/mci_ab/uap/?ab_b=m&ab_page=react_native_devtools_binaries&ab_entry=AQM0YWmFH-pEdvrIlqLqZfCcRl1DxhzvJDOHJnFrBIVxrxjq6LqnqU9fwc7qXsS3JHcg7miVifXIM4cZTAsjVeusaVbQTYj2SevDy-9ajAnzjZj92m_WAwfmcp9gb7s9axqohmC1E_LCiC1BTdV-KEIyEcJWvZNJpFu3f44f8vpctlucJgm79I5n" @@ -37,6 +43,9 @@ "hash": "sha256", "digest": "4a1c234b1cf27f1ff006fafce6628b8ef6993d7d3298c6ed2127e3055c93dcc6", "providers": [ + { + "url": "https://github.com/facebook/react-native/releases/download/v0.87.1/React.Native.DevTools-macos-aarch64.tar.gz" + }, { "type": "http", "url": "https://scontent.xx.fbcdn.net/mci_ab/uap/?ab_b=m&ab_page=react_native_devtools_binaries&ab_entry=AQNRWwxQIl4mkFPfIFJaGmmhuPTZXHz3uDISd0w_ptFazAGOLvONiln4ntQBt2iaBaNomMlXRuqAkdCXjHMu2lDarvjjZUfgtFpt8NEeEgILgWxbdB1j2rSiJrFZQoRb-VC0gGq3ALskrTQrLof9QJ_MSz_by779saXHEa_Wcd6-rk-D3PZyY6iwXYvy" @@ -50,6 +59,9 @@ "hash": "sha256", "digest": "9f1de3cb78688befaea4d9e5f640a66833dd44ad58b05590a7e07bce279bf532", "providers": [ + { + "url": "https://github.com/facebook/react-native/releases/download/v0.87.1/React.Native.DevTools-macos-x86_64.tar.gz" + }, { "type": "http", "url": "https://scontent.xx.fbcdn.net/mci_ab/uap/?ab_b=m&ab_page=react_native_devtools_binaries&ab_entry=AQMTN_mkHiVIEbUrA3teX7PDWCRTwVW9ffBc9X2rMjQOkZCzShBebMG1Zchl39m1P09Wxm0E_3r_Vg-ahgNsUzd1UzNiGmJ-4-T_xU_sEegXkheLn5Vi1_y5e0KVBQs0caeP5jUaUqn1JqrRRxwqwEPhV4YE8MCutcd3VXwluA4yI3ReI36c4BtgLg" @@ -63,6 +75,9 @@ "hash": "sha256", "digest": "5b56e22828549f12be49f0431fd74c54dcebfbfb4277e4b87573ec1ab084c288", "providers": [ + { + "url": "https://github.com/facebook/react-native/releases/download/v0.87.1/React.Native.DevTools-windows-x86_64.tar.gz" + }, { "type": "http", "url": "https://scontent.xx.fbcdn.net/mci_ab/uap/?ab_b=m&ab_page=react_native_devtools_binaries&ab_entry=AQNoewIvekFkYIhisR8u_iD720Mh6OCyo-JLIwW3AE0RZlWKUVtiMpi95a4kEX2mUSkVwcImnvEgpiKH7Oxz0ZlTGwxXyoUN4QrDQtfF5V4ng2rbBvdovpfev0qdxA3WGF9qcWmgE2qA5vfiHQ_dhOpogBxu3ZYaUkrQ3oZS3MrT9NU7HmRDWPpu" diff --git a/packages/debugger-shell/package.json b/packages/debugger-shell/package.json index af7e305e600e..3d6efa042b25 100644 --- a/packages/debugger-shell/package.json +++ b/packages/debugger-shell/package.json @@ -1,7 +1,7 @@ { "name": "@react-native/debugger-shell", "productName": "React Native DevTools", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Experimental debugger shell for React Native for use with @react-native/debugger-frontend", "keywords": [ "react-native", diff --git a/packages/dev-middleware/package.json b/packages/dev-middleware/package.json index 7ff5ae0f37ff..014c8a54dea5 100644 --- a/packages/dev-middleware/package.json +++ b/packages/dev-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/dev-middleware", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Dev server middleware for React Native", "keywords": [ "react-native", diff --git a/packages/eslint-config-react-native/package.json b/packages/eslint-config-react-native/package.json index a22bdaaa74f4..bebbc9aaf1ae 100644 --- a/packages/eslint-config-react-native/package.json +++ b/packages/eslint-config-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/eslint-config", - "version": "0.87.0-main", + "version": "0.87.1", "description": "ESLint config for React Native", "license": "MIT", "repository": { 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/package.json b/packages/eslint-plugin-react-native/package.json index c0e163ca78d6..c974920925a2 100644 --- a/packages/eslint-plugin-react-native/package.json +++ b/packages/eslint-plugin-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/eslint-plugin", - "version": "0.87.0-main", + "version": "0.87.1", "description": "ESLint rules for @react-native/eslint-config", "license": "MIT", "repository": { 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/eslint-plugin-specs/package.json b/packages/eslint-plugin-specs/package.json index d73bc1dfef80..23906138acb9 100644 --- a/packages/eslint-plugin-specs/package.json +++ b/packages/eslint-plugin-specs/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/eslint-plugin-specs", - "version": "0.87.0-main", + "version": "0.87.1", "description": "ESLint rules to validate NativeModule and Component Specs", "license": "MIT", "repository": { diff --git a/packages/gradle-plugin/package.json b/packages/gradle-plugin/package.json index 2fac41a75815..d25a44c2590f 100644 --- a/packages/gradle-plugin/package.json +++ b/packages/gradle-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/gradle-plugin", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Gradle Plugin for React Native", "license": "MIT", "repository": { 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..07112331be45 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -18,6 +18,13 @@ module.exports = { platforms: ['android', 'ios', 'macos', 'native'], // [macOS] }, moduleNameMapper: { + // `setup-env` is a secondary entry point exposed via the package's + // `exports`, but `./jest/resolver.js` strips `exports` and the generic + // mapper below resolves subpaths as literal directory paths. Alias it + // explicitly so it resolves to its `src/` implementation. + '^react-native/setup-env$': `${path.dirname( + require.resolve('react-native-macos'), // [macOS] + )}/src/setup-env.js`, '^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/jest-preset/package.json b/packages/jest-preset/package.json index 925dcfac00ae..9256a60b3a0e 100644 --- a/packages/jest-preset/package.json +++ b/packages/jest-preset/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/jest-preset", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Jest preset for React Native apps", "keywords": [ "jest", diff --git a/packages/metro-config/package.json b/packages/metro-config/package.json index a98e14e6c91b..969303ade454 100644 --- a/packages/metro-config/package.json +++ b/packages/metro-config/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/metro-config", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Metro configuration for React Native.", "license": "MIT", "repository": { @@ -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..74fc4053c2b7 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -59,9 +59,9 @@ 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/setup-env'), ], getPolyfills: () => require('@react-native/js-polyfills')(), isThirdPartyModule({path: modulePath}: Readonly<{path: string, ...}>) { diff --git a/packages/new-app-screen/package.json b/packages/new-app-screen/package.json index e0fa4762e8b1..60db244be069 100644 --- a/packages/new-app-screen/package.json +++ b/packages/new-app-screen/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/new-app-screen", - "version": "0.87.0-main", + "version": "0.87.1", "description": "NewAppScreen component for React Native", "keywords": [ "react-native" @@ -21,7 +21,7 @@ "peerDependencies": { "@types/react": "^19.1.0", "react": "*", - "react-native": "1000.0.0" + "react-native": "0.87.1" }, "peerDependenciesMeta": { "@types/react": { diff --git a/packages/normalize-color/package.json b/packages/normalize-color/package.json index 78e61f03ac85..dfbcd60fe6bb 100644 --- a/packages/normalize-color/package.json +++ b/packages/normalize-color/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/normalize-colors", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Color normalization for React Native.", "license": "MIT", "repository": { diff --git a/packages/polyfills/package.json b/packages/polyfills/package.json index 72f0f872cef7..25d562532734 100644 --- a/packages/polyfills/package.json +++ b/packages/polyfills/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/js-polyfills", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Polyfills for React Native.", "license": "MIT", "repository": { diff --git a/packages/react-native-babel-preset/package.json b/packages/react-native-babel-preset/package.json index 61fe41c6a423..cfc4c0645859 100644 --- a/packages/react-native-babel-preset/package.json +++ b/packages/react-native-babel-preset/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/babel-preset", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Babel preset for React Native applications", "repository": { "type": "git", 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-babel-transformer/package.json b/packages/react-native-babel-transformer/package.json index 2993f906aadc..5f90938aae97 100644 --- a/packages/react-native-babel-transformer/package.json +++ b/packages/react-native-babel-transformer/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/metro-babel-transformer", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Babel transformer for React Native applications.", "repository": { "type": "git", diff --git a/packages/react-native-codegen/package.json b/packages/react-native-codegen/package.json index df8059ba2fa0..36664b2743de 100644 --- a/packages/react-native-codegen/package.json +++ b/packages/react-native-codegen/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/codegen", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Code generation tools for React Native", "license": "MIT", "repository": { diff --git a/packages/react-native-compatibility-check/package.json b/packages/react-native-compatibility-check/package.json index d2be45f46dea..56c545e6c154 100644 --- a/packages/react-native-compatibility-check/package.json +++ b/packages/react-native-compatibility-check/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/compatibility-check", - "version": "0.87.0-main", + "version": "0.87.1", "description": "Check a React Native app's boundary between JS and Native for incompatibilities", "license": "MIT", "repository": { diff --git a/packages/react-native-popup-menu-android/package.json b/packages/react-native-popup-menu-android/package.json index 07ee1ff97b81..50fd2a326666 100644 --- a/packages/react-native-popup-menu-android/package.json +++ b/packages/react-native-popup-menu-android/package.json @@ -1,6 +1,6 @@ { "name": "@react-native/popup-menu-android", - "version": "0.87.0-main", + "version": "0.87.1", "description": "PopupMenu for the Android platform", "repository": { "type": "git", @@ -31,7 +31,7 @@ "peerDependencies": { "@types/react": "^19.1.0", "react": "*", - "react-native": "1000.0.0" + "react-native": "0.87.1" }, "peerDependenciesMeta": { "@types/react": { 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..8dda511cbefa --- /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": "0.87.0-main" + }, + "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/rn-get-polyfills.js b/packages/react-native-test-library/common/react-native.config.js similarity index 70% rename from packages/react-native/rn-get-polyfills.js rename to packages/react-native-test-library/common/react-native.config.js index bf0d0428de37..3de9a9829be7 100644 --- a/packages/react-native/rn-get-polyfills.js +++ b/packages/react-native-test-library/common/react-native.config.js @@ -4,10 +4,16 @@ * 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 + * @noflow */ 'use strict'; -module.exports = require('@react-native/js-polyfills'); +module.exports = { + dependency: { + platforms: { + ios: {}, + }, + }, +}; diff --git a/packages/react-native-test-library/package.json b/packages/react-native-test-library/package.json index f097a59eec5d..af9dab3102e5 100644 --- a/packages/react-native-test-library/package.json +++ b/packages/react-native-test-library/package.json @@ -31,7 +31,7 @@ }, "peerDependencies": { "react": "*", - "react-native-macos": "*" + "react-native-macos": "0.87.0-rc.0" }, "codegenConfig": { "name": "OSSLibraryExampleSpec", diff --git a/packages/react-native/Libraries/Animated/AnimatedEvent.js b/packages/react-native/Libraries/Animated/AnimatedEvent.js index 3f1245e3fd27..29647ce76051 100644 --- a/packages/react-native/Libraries/Animated/AnimatedEvent.js +++ b/packages/react-native/Libraries/Animated/AnimatedEvent.js @@ -199,7 +199,7 @@ export class AnimatedEvent { this._attachedEvent && this._attachedEvent.detach(); } - __getHandler(): any | ((...args: any) => void) { + __getHandler(): (...args: any) => void { if (this.__isNative) { if (__DEV__) { let validatedMapping = false; diff --git a/packages/react-native/Libraries/Animated/AnimatedImplementation.js b/packages/react-native/Libraries/Animated/AnimatedImplementation.js index 4a9d76a4806a..7fc01bb27249 100644 --- a/packages/react-native/Libraries/Animated/AnimatedImplementation.js +++ b/packages/react-native/Libraries/Animated/AnimatedImplementation.js @@ -576,10 +576,14 @@ function unforkEventImpl( } } -const eventImpl = function ( +// NOTE: With `useNativeDriver: true` this returns an `AnimatedEvent` instance +// rather than a callable handler. That object is only ever meant to be handed +// straight back to an animated component's event prop, so the declared type +// describes the handler shape both branches are consumed as. +const eventImpl: ( argMapping: ReadonlyArray, config: EventConfig, -): any { +) => (...args: Array) => void = function (argMapping, config): any { const animatedEvent = new AnimatedEvent(argMapping, config); if (animatedEvent.__isNative) { return animatedEvent; diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js index b10fe9da8bee..d4e2d7db8d8b 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js @@ -13,7 +13,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; import invariant from 'invariant'; -type ValueListenerCallback = (state: {value: number, ...}) => unknown; +export type ValueListenerCallback = (state: {value: number}) => unknown; export type AnimatedNodeConfig = Readonly<{ debugID?: string, diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index 76dba2196f48..c393e27cf3ea 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -17,7 +17,7 @@ import type { InterpolationConfigType, } from './AnimatedInterpolation'; import type AnimatedNode from './AnimatedNode'; -import type {AnimatedNodeConfig} from './AnimatedNode'; +import type {AnimatedNodeConfig, ValueListenerCallback} from './AnimatedNode'; import type AnimatedTracking from './AnimatedTracking'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; @@ -138,7 +138,7 @@ export default class AnimatedValue extends AnimatedWithChildren { } } - addListener(callback: (value: any) => unknown): string { + addListener(callback: ValueListenerCallback): string { const id = super.addListener(callback); this._listenerCount++; if (this.__isNative) { 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/ReactNativeVersion.js b/packages/react-native/Libraries/Core/ReactNativeVersion.js index 1ff12f563e62..6839550b14eb 100644 --- a/packages/react-native/Libraries/Core/ReactNativeVersion.js +++ b/packages/react-native/Libraries/Core/ReactNativeVersion.js @@ -26,10 +26,10 @@ * ``` */ export default class ReactNativeVersion { - static major: number = 1000; - static minor: number = 0; + static major: number = 0; + static minor: number = 87; static patch: number = 0; - static prerelease: string | null = null; + static prerelease: string | null = 'rc.0'; static getVersionString(): string { return `${this.major}.${this.minor}.${this.patch}${this.prerelease != null ? `-${this.prerelease}` : ''}`; 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/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/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/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/Base/RCTVersion.m b/packages/react-native/React/Base/RCTVersion.m index 69ce4f75320e..0cd9952b7031 100644 --- a/packages/react-native/React/Base/RCTVersion.m +++ b/packages/react-native/React/Base/RCTVersion.m @@ -21,10 +21,10 @@ static dispatch_once_t onceToken; dispatch_once(&onceToken, ^(void){ __rnVersion = @{ - RCTVersionMajor: @(1000), - RCTVersionMinor: @(0), + RCTVersionMajor: @(0), + RCTVersionMinor: @(87), RCTVersionPatch: @(0), - RCTVersionPrerelease: [NSNull null], + RCTVersionPrerelease: @"rc.0", }; }); return __rnVersion; 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/ReactAndroid/external-artifacts/build.gradle.kts b/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts index 1ed2851218df..48a676d31c64 100644 --- a/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts +++ b/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts @@ -53,6 +53,30 @@ val reactNativeDependenciesReleaseDSYMArtifact: PublishArtifact = classifier = "reactnative-dependencies-dSYM-release" } +// [iOS] React Native Dependencies Headers — the headers-only LIBRARY-type +// sidecar (per-slice Headers/ + HeadersPath) that SwiftPM auto-serves; the +// binary xcframework above is framework-type and cannot expose headers to +// SwiftPM binaryTargets. Also shipped INSIDE the deps tarball for CocoaPods. +val reactNativeDependenciesHeadersDebugArtifactFile: RegularFile = + layout.projectDirectory.file("artifacts/ReactNativeDependenciesHeadersDebug.xcframework.tar.gz") +val reactNativeDependenciesHeadersDebugArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeDependenciesHeadersDebugArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-dependencies-headers-debug" + } + +val reactNativeDependenciesHeadersReleaseArtifactFile: RegularFile = + layout.projectDirectory.file( + "artifacts/ReactNativeDependenciesHeadersRelease.xcframework.tar.gz" + ) +val reactNativeDependenciesHeadersReleaseArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeDependenciesHeadersReleaseArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-dependencies-headers-release" + } + // [iOS] React Native Core val reactCoreDebugArtifactFile: RegularFile = layout.projectDirectory.file("artifacts/ReactCoreDebug.xcframework.tar.gz") @@ -89,6 +113,27 @@ val reactCoreReleaseDSYMArtifact: PublishArtifact = classifier = "reactnative-core-dSYM-release" } +// [iOS] React Native Headers — the pure-RN headers-only xcframework, published +// standalone (it also ships inside the ReactCore tarball for CocoaPods) so +// SwiftPM consumers can wire it as its own binaryTarget. +val reactNativeHeadersDebugArtifactFile: RegularFile = + layout.projectDirectory.file("artifacts/ReactNativeHeadersDebug.xcframework.tar.gz") +val reactNativeHeadersDebugArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeHeadersDebugArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-headers-debug" + } + +val reactNativeHeadersReleaseArtifactFile: RegularFile = + layout.projectDirectory.file("artifacts/ReactNativeHeadersRelease.xcframework.tar.gz") +val reactNativeHeadersReleaseArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeHeadersReleaseArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-headers-release" + } + apply(from = "../publish.gradle") publishing { @@ -99,10 +144,14 @@ publishing { artifact(reactNativeDependenciesReleaseArtifact) artifact(reactNativeDependenciesDebugDSYMArtifact) artifact(reactNativeDependenciesReleaseDSYMArtifact) + artifact(reactNativeDependenciesHeadersDebugArtifact) + artifact(reactNativeDependenciesHeadersReleaseArtifact) artifact(reactCoreDebugArtifact) artifact(reactCoreReleaseArtifact) artifact(reactCoreDebugDSYMArtifact) artifact(reactCoreReleaseDSYMArtifact) + artifact(reactNativeHeadersDebugArtifact) + artifact(reactNativeHeadersReleaseArtifact) } } } diff --git a/packages/react-native/ReactAndroid/gradle.properties b/packages/react-native/ReactAndroid/gradle.properties index 71b1bf4cf759..cbf112a44498 100644 --- a/packages/react-native/ReactAndroid/gradle.properties +++ b/packages/react-native/ReactAndroid/gradle.properties @@ -1,4 +1,4 @@ -VERSION_NAME=1000.0.0 +VERSION_NAME=0.87.0-rc.0 react.internal.publishingGroup=com.facebook.react react.internal.hermesPublishingGroup=com.facebook.hermes diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/SynchronousMountItem.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/SynchronousMountItem.kt index 108c628ce158..756dc4934b9b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/SynchronousMountItem.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/SynchronousMountItem.kt @@ -28,8 +28,14 @@ internal class SynchronousMountItem(private val reactTag: Int, private val props } override fun toString(): String { - val propsString = if (IS_DEVELOPMENT_ENVIRONMENT) props.toHashMap().toString() else "" - return "SYNC UPDATE PROPS [$reactTag]: $propsString" + // NOTE: Intentionally written as an early return rather than assigning the result of an + // `if` expression to a local `val`. The latter shape triggers a crash in the Android lint + // K2 UAST analyzer (resolveSyntheticJavaPropertyAccessorCall) while resolving the local + // variable's `if`-expression initializer, failing `lintVitalAnalyzeRelease`. + if (!IS_DEVELOPMENT_ENVIRONMENT) { + return "SYNC UPDATE PROPS [$reactTag]: " + } + return "SYNC UPDATE PROPS [$reactTag]: ${props.toHashMap()}" } override fun getSurfaceId(): Int = View.NO_ID diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt index 0288966c6fa7..b767f1657e8f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt @@ -87,11 +87,15 @@ public open class IntentModule(reactContext: ReactApplicationContext) : override fun onHostResume() { reactApplicationContext.removeLifecycleEventListener(this) synchronized(this@IntentModule) { - for (pendingPromise in pendingOpenURLPromises) { + // getInitialURL can re-enter and re-add to pendingOpenURLPromises when the activity + // is still null at resume, so drain a snapshot (after clearing the list and listener) + // to avoid mutating the list being iterated (ConcurrentModificationException). + val pendingPromises = ArrayList(pendingOpenURLPromises) + pendingOpenURLPromises.clear() + initialURLListener = null + for (pendingPromise in pendingPromises) { getInitialURL(pendingPromise) } - initialURLListener = null - pendingOpenURLPromises.clear() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt index 889fa4d728c4..334a5a99f7dc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt @@ -1,15 +1,20 @@ /** * 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. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. * * @generated by scripts/releases/set-version.js */ + package com.facebook.react.modules.systeminfo public object ReactNativeVersion { @JvmField - public val VERSION: Map = - mapOf("major" to 1000, "minor" to 0, "patch" to 0, "prerelease" to null) + public val VERSION: Map = mapOf( + "major" to 0, + "minor" to 87, + "patch" to 0, + "prerelease" to "rc.0" + ) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt new file mode 100644 index 000000000000..6528449969bd --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt @@ -0,0 +1,143 @@ +/* + * 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. + */ + +package com.facebook.react.modules.intent + +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IntentModuleTest { + + private lateinit var context: ReactApplicationContext + private lateinit var intentModule: IntentModule + + @Before + fun setUp() { + context = mock() + intentModule = IntentModule(context) + } + + /** + * Regression test for the ConcurrentModificationException thrown from onHostResume. When the + * current activity is still null at resume time, draining pendingOpenURLPromises re-enters + * getInitialURL -> waitForActivityAndGetInitialURL, which adds back into the same list. Before + * the fix this mutated the list mid-iteration and crashed. The synchronized guard does not help: + * the re-entrancy is on the same thread and already holds the lock. + */ + @Test + fun getInitialURL_onHostResumeWithNullActivity_doesNotThrowAndPreservesPromise() { + // No current activity: getInitialURL queues the promise and registers a lifecycle listener. + whenever(context.currentActivity).thenReturn(null) + + val promise = SimplePromise() + intentModule.getInitialURL(promise) + + val listenerCaptor = argumentCaptor() + verify(context).addLifecycleEventListener(listenerCaptor.capture()) + + // Resume while the activity is still null (e.g. a deep link landing mid activity-transition). + // The drain re-queues the promise; before the fix this threw ConcurrentModificationException. + assertThatCode { listenerCaptor.firstValue.onHostResume() }.doesNotThrowAnyException() + + // The promise was re-queued rather than silently dropped: a fresh listener is registered for + // the next resume, and the promise is left pending (neither resolved nor rejected). + verify(context, times(2)).addLifecycleEventListener(any()) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.rejected).isEqualTo(0) + } + + internal class SimplePromise : Promise { + companion object { + private const val ERROR_DEFAULT_CODE = "EUNSPECIFIED" + private const val ERROR_DEFAULT_MESSAGE = "Error not specified." + } + + var resolved = 0 + private set + + var rejected = 0 + private set + + var value: Any? = null + private set + + var errorCode: String? = null + private set + + var errorMessage: String? = null + private set + + override fun resolve(value: Any?) { + resolved++ + this.value = value + } + + override fun reject(code: String?, message: String?) { + reject(code, message, null, null) + } + + override fun reject(code: String?, throwable: Throwable?) { + reject(code, null, throwable, null) + } + + override fun reject(code: String?, message: String?, throwable: Throwable?) { + reject(code, message, throwable, null) + } + + override fun reject(throwable: Throwable) { + reject(null, null, throwable, null) + } + + override fun reject(throwable: Throwable, userInfo: WritableMap) { + reject(null, null, throwable, userInfo) + } + + override fun reject(code: String?, userInfo: WritableMap) { + reject(code, null, null, userInfo) + } + + override fun reject(code: String?, throwable: Throwable?, userInfo: WritableMap) { + reject(code, null, throwable, userInfo) + } + + override fun reject(code: String?, message: String?, userInfo: WritableMap) { + reject(code, message, null, userInfo) + } + + override fun reject( + code: String?, + message: String?, + throwable: Throwable?, + userInfo: WritableMap?, + ) { + rejected++ + + errorCode = code ?: ERROR_DEFAULT_CODE + errorMessage = message ?: throwable?.message ?: ERROR_DEFAULT_MESSAGE + } + + @Deprecated("Method deprecated", ReplaceWith("reject(code, message)")) + override fun reject(message: String) { + reject(null, message, null, null) + } + } +} diff --git a/packages/react-native/ReactCommon/cxxreact/ReactNativeVersion.h b/packages/react-native/ReactCommon/cxxreact/ReactNativeVersion.h index 25468f63f050..dda586e1c631 100644 --- a/packages/react-native/ReactCommon/cxxreact/ReactNativeVersion.h +++ b/packages/react-native/ReactCommon/cxxreact/ReactNativeVersion.h @@ -12,17 +12,17 @@ #include #include -#define REACT_NATIVE_VERSION_MAJOR 1000 -#define REACT_NATIVE_VERSION_MINOR 0 +#define REACT_NATIVE_VERSION_MAJOR 0 +#define REACT_NATIVE_VERSION_MINOR 87 #define REACT_NATIVE_VERSION_PATCH 0 namespace facebook::react { struct ReactNativeVersionType { - int32_t Major = 1000; - int32_t Minor = 0; + int32_t Major = 0; + int32_t Minor = 87; int32_t Patch = 0; - std::string_view Prerelease = ""; + std::string_view Prerelease = "rc.0"; }; constexpr ReactNativeVersionType ReactNativeVersion; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp index a6e2d5da63f6..f3022e786838 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp @@ -64,13 +64,16 @@ tracing::HostTracingProfile HostTargetTraceRecording::stop() { auto startTime = *startTime_; startTime_.reset(); - return tracing::HostTracingProfile{ - .processId = oscompat::getCurrentProcessId(), - .startTime = startTime, - .frameTimings = frameTimings_.pruneExpiredAndExtract(), - .instanceTracingProfiles = std::move(state.instanceTracingProfiles), - .runtimeSamplingProfiles = std::move(state.runtimeSamplingProfiles), - }; + // Member-wise assignment instead of designated initializers: + // HostTracingProfile declares its special members (move-only) and is no + // longer an aggregate. + tracing::HostTracingProfile profile; + profile.processId = oscompat::getCurrentProcessId(); + profile.startTime = startTime; + profile.frameTimings = frameTimings_.pruneExpiredAndExtract(); + profile.instanceTracingProfiles = std::move(state.instanceTracingProfiles); + profile.runtimeSamplingProfiles = std::move(state.runtimeSamplingProfiles); + return profile; } void HostTargetTraceRecording::recordFrameTimings( diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h index fd32cb3c2257..aa37579fc696 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h @@ -23,9 +23,22 @@ namespace facebook::react::jsinspector_modern::tracing { * messages. */ struct HostTracingProfile { + HostTracingProfile() = default; + + // Explicitly move-only: FrameTimingSequence and RuntimeSamplingProfile are + // not copyable, so the implicit copy constructor is ill-formed the moment it + // is instantiated. Plain C++ never instantiates it, but Swift's C++ interop + // does when these headers are reached from an imported module, turning it + // into a hard compile error (Xcode 26.3). + HostTracingProfile(const HostTracingProfile &) = delete; + HostTracingProfile &operator=(const HostTracingProfile &) = delete; + HostTracingProfile(HostTracingProfile &&) = default; + HostTracingProfile &operator=(HostTracingProfile &&) = default; + ~HostTracingProfile() = default; + // The ID of the OS-level process that this Trace Recording is associated // with. - ProcessId processId; + ProcessId processId{}; // The timestamp at which this Trace Recording started. HighResTimeStamp startTime; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h index db0acc40b76c..2a3187c7610a 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h @@ -31,6 +31,17 @@ struct TraceRecordingState { { } + // Explicitly move-only: RuntimeSamplingProfile is not copyable, so the + // implicit copy constructor is ill-formed the moment it is instantiated. + // Plain C++ never instantiates it, but Swift's C++ interop (ClangImporter) + // does when a consumer imports these headers as part of a module, turning + // it into a hard compile error (Xcode 26.3). + TraceRecordingState(const TraceRecordingState &) = delete; + TraceRecordingState &operator=(const TraceRecordingState &) = delete; + TraceRecordingState(TraceRecordingState &&) = default; + TraceRecordingState &operator=(TraceRecordingState &&) = default; + ~TraceRecordingState() = default; + // The mode of this Trace Recording. tracing::Mode mode; diff --git a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp index da40ea3b7899..603bd0d69d5a 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp @@ -198,10 +198,6 @@ jsi::Value NativeDOM::getParentNode( } auto shadowNode = getShadowNode(rt, nativeNodeReference); - if (isRootShadowNode(*shadowNode)) { - // The parent of the root node is the document. - return jsi::Value{shadowNode->getSurfaceId()}; - } auto currentRevision = getCurrentShadowTreeRevision(rt, shadowNode->getSurfaceId()); @@ -209,6 +205,16 @@ jsi::Value NativeDOM::getParentNode( return jsi::Value::undefined(); } + // The parent of the surface's root node is the document. Only the actual + // root node qualifies: nested nodes that carry the `RootNodeKind` trait + // (e.g. , portals/overlays) still have a real parent in the shadow + // tree and must report it. Otherwise capture/bubble event propagation is + // silently severed at that boundary (a listener on an ancestor rendered + // above the modal would never receive descendant focus/blur, etc.). + if (ShadowNode::sameFamily(*currentRevision, *shadowNode)) { + return jsi::Value{shadowNode->getSurfaceId()}; + } + auto parentShadowNode = dom::getParentNode(currentRevision, *shadowNode); if (parentShadowNode == nullptr) { return jsi::Value::undefined(); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 1cf2055b1644..9984d6a66338 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<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -243,7 +243,7 @@ declare const event: typeof $$AnimatedImplementation.event declare const eventImpl: ( argMapping: ReadonlyArray, config: EventConfig, -) => any +) => (...args: Array) => void declare const findNodeHandle: typeof $$ReactFabric.findNodeHandle declare const flatten: typeof $$flattenStyle declare const forkEvent: typeof $$AnimatedImplementation.forkEvent @@ -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, @@ -467,7 +457,6 @@ declare const ToastAndroid_default: { yOffset: number, ) => void } -declare const Touchable: typeof TouchableImpl_default declare const Touchable_default: ( props: TouchableOpacityProps & { ref?: React.Ref @@ -479,33 +468,6 @@ declare const TouchableHighlight_default: ( ref?: React.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 -} declare const TouchableOpacity: typeof Touchable_default declare const UIManager: typeof UIManager_default declare const UIManager_default: UIManagerJSInterface @@ -1499,7 +1461,7 @@ declare class AnimatedTracking_default extends AnimatedNode_default { update(): void } declare class AnimatedValue_default extends AnimatedWithChildren_default { - addListener(callback: (value: any) => unknown): string + addListener(callback: ValueListenerCallback): string animate( animation: Animation_default, callback: EndCallback | null | undefined, @@ -1765,11 +1727,11 @@ declare interface ButtonProps { readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onBlur?: (e: BlurEvent) => void readonly onFocus?: (e: FocusEvent) => void + readonly onPress?: (event?: GestureResponderEvent) => unknown readonly testID?: string readonly title: string readonly tooltip?: string readonly touchSoundDisabled?: boolean - readonly onPress?: (event?: GestureResponderEvent) => unknown } declare function cancelHeadlessTask(taskId: number, taskKey: string): void declare type Category = string @@ -2755,12 +2717,12 @@ declare type InterpolationConfigType< OutputT extends InterpolationConfigSupportedOutputType, > = Readonly< AnimatedNodeConfig & { + easing?: (input: number) => number extrapolate?: ExtrapolateType extrapolateLeft?: ExtrapolateType extrapolateRight?: ExtrapolateType inputRange: ReadonlyArray outputRange: ReadonlyArray - easing?: (input: number) => number } > declare type IOSKeyboardEvent = Readonly< @@ -3075,10 +3037,10 @@ declare type MacOSViewProps = { readonly enableFocusRing?: boolean 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 tooltip?: string } declare type Mapping = | AnimatedValue_default @@ -3404,6 +3366,14 @@ declare type OptionalFlatListProps = { end: number start: number } + getItemLayout?: ( + data: Readonly> | undefined, + index: number, + ) => { + index: number + length: number + offset: number + } horizontal?: boolean initialNumToRender?: number initialScrollIndex?: number @@ -3414,14 +3384,6 @@ declare type OptionalFlatListProps = { removeClippedSubviews?: boolean renderItem?: ListRenderItem strictMode?: boolean - getItemLayout?: ( - data: Readonly> | undefined, - index: number, - ) => { - index: number - length: number - offset: number - } } declare type OptionalPlatformSelectSpec = { [key in PlatformOSType]?: T } declare type OptionalSectionListProps = { @@ -3679,15 +3641,15 @@ declare type PressabilityConfig = { readonly pressRectOffset?: RectOrSize } declare type PressabilityEventHandlers = { - readonly onBlur: (event: BlurEvent) => void - readonly onClick: (event: GestureResponderEvent) => void - readonly onFocus: (event: FocusEvent) => 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 onBlur: (event: BlurEvent) => void + readonly onClick: (event: GestureResponderEvent) => void + readonly onFocus: (event: FocusEvent) => void readonly onResponderGrant: (event: GestureResponderEvent) => boolean | void readonly onResponderMove: (event: GestureResponderEvent) => void readonly onResponderRelease: (event: GestureResponderEvent) => void @@ -3724,6 +3686,9 @@ declare type PressableBaseProps = { readonly keyUpEvents?: Array readonly mouseDownCanMoveWindow?: boolean readonly onBlur?: (event: BlurEvent) => void + readonly onDragEnter?: (event: DragEvent) => void + readonly onDragLeave?: (event: DragEvent) => void + readonly onDrop?: (event: DragEvent) => void readonly onFocus?: (event: FocusEvent) => void readonly onHoverIn?: (event: MouseEvent) => unknown readonly onHoverOut?: (event: MouseEvent) => unknown @@ -3745,9 +3710,6 @@ 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 } declare type PressableInstance = HostInstance declare interface PressableProps @@ -4349,6 +4311,14 @@ declare type ScrollViewBaseProps = { readonly autoscrollToTopThreshold?: number readonly minIndexForVisible: number } + readonly onContentSizeChange?: ( + 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 onMomentumScrollBegin?: (event: ScrollEvent) => void readonly onMomentumScrollEnd?: (event: ScrollEvent) => void readonly onScroll?: (event: ScrollEvent) => void @@ -4369,14 +4339,6 @@ declare type ScrollViewBaseProps = { readonly StickyHeaderComponent?: StickyHeaderComponentType readonly stickyHeaderHiddenOnScroll?: boolean readonly stickyHeaderIndices?: ReadonlyArray - readonly onContentSizeChange?: ( - 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 } declare type ScrollViewComponentStatics = { readonly Context: typeof $$ScrollViewContext @@ -4457,13 +4419,13 @@ declare type ScrollViewPropsIOS = { readonly indicatorStyle?: "black" | "default" | "white" readonly maximumZoomScale?: number readonly minimumZoomScale?: number + readonly onScrollToTop?: (event: ScrollEvent) => void readonly pinchGestureEnabled?: boolean readonly scrollIndicatorInsets?: EdgeInsetsProp readonly scrollsToTop?: boolean readonly scrollToOverflowEnabled?: boolean readonly showsHorizontalScrollIndicator?: boolean readonly zoomScale?: number - readonly onScrollToTop?: (event: ScrollEvent) => void } declare interface ScrollViewScrollToOptions { animated?: boolean @@ -4612,6 +4574,17 @@ declare type ShareOptions = { } declare interface Spec extends TurboModule { readonly blur?: (reactTag: number) => void + readonly focus?: (reactTag: number) => void + readonly getConstantsForViewManager?: ( + viewManagerName: string, + ) => Object | undefined + readonly getDefaultEventTypes?: () => Array + readonly lazilyLoadView?: (name: string) => Object + readonly sendAccessibilityEvent?: ( + reactTag: number, + eventType: number, + ) => void + readonly setLayoutAnimationEnabledExperimental?: (enabled: boolean) => void readonly clearJSResponder: () => void readonly configureNextLayoutAnimation: ( config: Object, @@ -4640,13 +4613,7 @@ declare interface Spec extends TurboModule { height: number, ) => void, ) => void - readonly focus?: (reactTag: number) => void readonly getConstants: () => Object - readonly getConstantsForViewManager?: ( - viewManagerName: string, - ) => Object | undefined - readonly getDefaultEventTypes?: () => Array - readonly lazilyLoadView?: (name: string) => Object readonly manageChildren: ( containerTag: number, moveFromIndices: Array, @@ -4679,16 +4646,11 @@ declare interface Spec extends TurboModule { height: number, ) => void, ) => void - readonly sendAccessibilityEvent?: ( - reactTag: number, - eventType: number, - ) => void readonly setChildren: (containerTag: number, reactTags: Array) => void readonly setJSResponder: ( reactTag: number, blockNativeResponder: boolean, ) => void - readonly setLayoutAnimationEnabledExperimental?: (enabled: boolean) => void readonly updateView: ( reactTag: number, viewName: string, @@ -5205,11 +5167,11 @@ declare type TextInputMacOSProps = { readonly onGrammarCheckChange?: (e: SettingChangeEvent) => unknown readonly onKeyDown?: (e: KeyDownEvent) => unknown readonly onKeyUp?: (e: KeyUpEvent) => unknown + readonly onPaste?: (event: PasteEvent) => void readonly onSpellCheckChange?: (e: SettingChangeEvent) => unknown readonly pastedTypes?: PastedTypesType readonly submitKeyEvents?: ReadonlyArray readonly tooltip?: string - readonly onPaste?: (event: PasteEvent) => void } declare interface TextInputProps extends Readonly< @@ -5307,6 +5269,7 @@ declare type TimingAnimationConfig = Readonly< AnimationConfig & { delay?: number duration?: number + easing?: (value: number) => number toValue: | AnimatedColor_default | AnimatedInterpolation_default @@ -5318,11 +5281,9 @@ declare type TimingAnimationConfig = Readonly< readonly x: number readonly y: number } - easing?: (value: number) => number } > declare type ToastAndroid = typeof ToastAndroid -declare type Touchable = typeof Touchable declare type TouchableHighlight = typeof TouchableHighlight declare type TouchableHighlightBaseProps = { readonly activeOpacity?: number @@ -5429,15 +5390,6 @@ 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 @@ -5481,12 +5433,12 @@ declare type TouchableWithoutFeedbackPropsIOS = { acceptsFirstMouse?: boolean 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 + tooltip?: string } declare type TouchEventProps = { readonly onTouchCancel?: (e: GestureResponderEvent) => void @@ -5555,6 +5507,7 @@ declare type UTFSequence = typeof UTFSequence declare type Value = null | { horizontal: boolean } +declare type ValueListenerCallback = (state: { value: number }) => unknown declare type ValueOfUnion = T extends any ? K extends keyof T ? T[K] @@ -5755,29 +5708,30 @@ declare function Wrapper_default( ): React.ReactNode declare type WrapperComponentProvider = ( appParameters: Object, + appKey?: string, ) => React.ComponentType export { AccessibilityActionEvent, // a0d4daa0 AccessibilityActionInfo, // db47a917 AccessibilityInfo, // 489ed53e - AccessibilityProps, // afa87704 + AccessibilityProps, // 9f493a69 AccessibilityRole, // 3cf8c6c5 AccessibilityState, // b0c2b3f7 AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // 493900fa + ActivityIndicator, // f2f302b6 ActivityIndicatorInstance, // a82dd4e7 - ActivityIndicatorProps, // 4227530f - Alert, // 24958ab5 + ActivityIndicatorProps, // ecd7ee4c + Alert, // 8968797d AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 - AlertOptions, // 39b16cfa + AlertOptions, // 702d616d AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // be5ea01d + Animated, // 15d69422 AppConfig, // 35c0ca70 - AppRegistry, // 5ee0a63b + AppRegistry, // 137f9efd AppState, // 12012be5 AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -5789,9 +5743,9 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // 4ba4f941 BoxShadowValue, // b679703f - Button, // 505b6cc1 - ButtonInstance, // 7c607915 - ButtonProps, // 29cfadcc + Button, // 084cd0fd + ButtonInstance, // e8b1549b + ButtonProps, // 387eff29 Clipboard, // 41addb89 CodegenTypes, // ab4986cc ColorSchemeName, // 6615edd6 @@ -5810,9 +5764,9 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // 699ed56a + DrawerLayoutAndroid, // 26d38c47 DrawerLayoutAndroidInstance, // c0694352 - DrawerLayoutAndroidProps, // a8e2c17a + DrawerLayoutAndroidProps, // 3220e19b DrawerSlideEvent, // c4ab8fba DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -5830,39 +5784,39 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // baec8ab3 - FlatListInstance, // f892502d - FlatListProps, // fb158bc8 + FlatList, // 18250745 + FlatListInstance, // 3aa9c1ce + FlatListProps, // 1e70bc52 FocusEvent, // 850f1517 FontVariant, // 7c7558bb GestureResponderEvent, // 3bd4697e - GestureResponderHandlers, // 3f4b3d6f + GestureResponderHandlers, // a4627cef HostComponent, // a611806a HostInstance, // f78dcaf8 I18nManager, // f9870e00 IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // a802d222 - ImageBackground, // f92847c7 - ImageBackgroundInstance, // 8a827b32 - ImageBackgroundProps, // f44ac281 + Image, // 10888e6c + ImageBackground, // 211ba673 + ImageBackgroundInstance, // d845afa1 + ImageBackgroundProps, // 5eb44874 ImageErrorEvent, // 978933f4 ImageInstance, // 9a100753 ImageLoadEvent, // 77f0b718 ImageProgressEventIOS, // 445331a4 - ImageProps, // c61ffdb6 - ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // 68fb3ba8 - ImagePropsIOS, // 4a080668 + ImageProps, // 50dbdb29 + ImagePropsAndroid, // ee00e1d5 + ImagePropsBase, // 404b5d42 + ImagePropsIOS, // 9e19c85d ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 ImageResolvedAssetSource, // f3060931 ImageSize, // 1c47cf88 - ImageSource, // 48c7f316 - ImageSourcePropType, // bfb5e5c6 + ImageSource, // ea31cf4a + ImageSourcePropType, // f522e093 ImageStyle, // ccf7055c - ImageURISource, // 016eb083 + ImageURISource, // 443d047c InputAccessoryView, // 35edf5de InputAccessoryViewProps, // ea39d6b6 InputModeOptions, // 4e8581b9 @@ -5871,9 +5825,9 @@ export { KeyEvent, // 2a18a436 KeyUpEvent, // ef64af4c Keyboard, // 49414c97 - KeyboardAvoidingView, // 39ef8a97 - KeyboardAvoidingViewInstance, // 00a5c52c - KeyboardAvoidingViewProps, // b5f6093a + KeyboardAvoidingView, // e89f11cb + KeyboardAvoidingViewInstance, // f6d2605a + KeyboardAvoidingViewProps, // 635db14e KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -5898,12 +5852,12 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // 3e0b762f - ModalBaseProps, // ac3931ad + Modal, // 529ffa78 + ModalBaseProps, // c294cc46 ModalInstance, // d466ce77 - ModalProps, // bcbc89bb + ModalProps, // 20523155 ModalPropsAndroid, // 515fb173 - ModalPropsIOS, // 0e13cfcc + ModalPropsIOS, // 664ecb7e ModeChangeEvent, // f64bf69d MouseEvent, // d1f889fd NativeAppEventEmitter, // 08d4c47d @@ -5921,10 +5875,10 @@ export { Networking, // bbc5be42 OpaqueColorValue, // 25f3fa5b PackagerAsset, // d1c88cf4 - PanResponder, // 17de9d78 - PanResponderCallbacks, // 4ee3d101 + PanResponder, // 7a455cf3 + PanResponderCallbacks, // 47ced9e9 PanResponderGestureState, // 54baf558 - PanResponderInstance, // b3afacbe + PanResponderInstance, // 90afdf2e Permission, // 08f1c82f PermissionStatus, // 4b7de97b PermissionsAndroid, // 8a0bc8d8 @@ -5935,17 +5889,17 @@ export { PlatformSelectSpec, // 09ed7758 PointValue, // 69db075f PointerEvent, // ff599afe - PressabilityConfig, // 2374a593 - PressabilityEventHandlers, // 9b800ac5 - Pressable, // 53bb82ea + PressabilityConfig, // d1a82506 + PressabilityEventHandlers, // 8ed82d90 + Pressable, // 3d5e67e1 PressableAndroidRippleConfig, // ee32eaca PressableInstance, // eebfe911 - PressableProps, // b4227cfb + PressableProps, // 43838b85 PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // e8b7eaf6 + ProgressBarAndroid, // 8b95ba69 ProgressBarAndroidInstance, // ab545ef1 - ProgressBarAndroidProps, // b3103b9c + ProgressBarAndroidProps, // aca7b2f1 PublicRootInstance, // 8040afd7 PublicTextInstance, // 6937c7bf PushNotificationEventName, // 84e7e150 @@ -5953,10 +5907,10 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 5ddff7bf - RefreshControlInstance, // 561b5344 - RefreshControlProps, // cc88e35f - RefreshControlPropsAndroid, // 99f64c97 + RefreshControl, // 06bc0555 + RefreshControlInstance, // fd62709f + RefreshControlProps, // 887131c7 + RefreshControlPropsAndroid, // 8ac931ca RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d ResponderSyntheticEvent, // ebfa7f48 @@ -5967,24 +5921,24 @@ export { RootViewStyleProvider, // 6a15dcfc Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 5b0aef39 + SafeAreaView, // 3229b039 SafeAreaViewInstance, // 21dba39c ScaledSize, // 07e417c7 ScrollEvent, // 10f01ee3 - ScrollResponderType, // 02f30132 + ScrollResponderType, // 4c762408 ScrollToLocationParamsType, - ScrollView, // fcdcd27c - ScrollViewImperativeMethods, // 63083e1a - ScrollViewInstance, // 4bdb26a9 - ScrollViewProps, // 11071735 - ScrollViewPropsAndroid, // 44210553 - ScrollViewPropsIOS, // 19a1147f + ScrollView, // a98416cb + ScrollViewImperativeMethods, // d751ae4f + ScrollViewInstance, // 6ab7c19d + ScrollViewProps, // b2b197f6 + ScrollViewPropsAndroid, // 02f3df2e + ScrollViewPropsIOS, // aa75a790 ScrollViewScrollToOptions, // 3313411e SectionBase, - SectionList, // 58abd791 + SectionList, // 78b6c3d9 SectionListData, - SectionListInstance, // 5faca954 - SectionListProps, // 6061d1b8 + SectionListInstance, // 00aeaac2 + SectionListProps, // 87e6ad8f SectionListRenderItem, // c118edfb SectionListRenderItemInfo, // bd038703 Separators, @@ -5995,64 +5949,63 @@ export { ShareActionSheetIOSOptions, // eff574f5 ShareContent, // 7c627896 ShareOptions, // 800c3a4e - StatusBar, // 81b4e92f + StatusBar, // f95dbb76 StatusBarAnimation, // 7fd047e6 - StatusBarInstance, // 4e03048c - StatusBarProps, // bfedeab1 + StatusBarInstance, // 416e9640 + StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // 3145ad8d + StyleSheet, // 3d36407f SubmitBehavior, // c4ddf490 - Switch, // 3dac27a2 + Switch, // ed03a94a SwitchChangeEvent, // 899635b1 SwitchInstance, // 3c50eec5 - SwitchProps, // 9f9ae665 + SwitchProps, // ed6717bc Systrace, // 626d178c TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // c0e9e9ee + Text, // 037daf25 TextContentType, // 239b3ecc - TextInput, // 0632b08e - TextInputAndroidProps, // 7109938a + TextInput, // ba7b8e23 + TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 TextInputContentSizeChangeEvent, // a27cd32a TextInputEndEditingEvent, // e690b56b TextInputFocusEvent, // 19851a88 - TextInputIOSProps, // e8905f3e + TextInputIOSProps, // 0b56e3e3 TextInputInstance, // 9d5cf2e6 TextInputKeyPressEvent, // 546c5d07 - TextInputProps, // 070786dd + TextInputProps, // e1eefbe3 TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 TextLayoutEvent, // 3f54186f - TextProps, // 7e0b961b + TextProps, // f68b0442 TextStyle, // 694c5ffb ToastAndroid, // 88a8969a - Touchable, // e8712e89 - TouchableHighlight, // 2ce6532d + TouchableHighlight, // 8b4748e2 TouchableHighlightInstance, // b510c0eb - TouchableHighlightProps, // d786ab40 - TouchableNativeFeedback, // ab2adc08 - TouchableNativeFeedbackInstance, // 06a9ced1 - TouchableNativeFeedbackProps, // 3ef45dab - TouchableOpacity, // 0f6c7488 + TouchableHighlightProps, // 312222b8 + TouchableNativeFeedback, // 420e618f + TouchableNativeFeedbackInstance, // 12f82b74 + TouchableNativeFeedbackProps, // c7dcdb88 + TouchableOpacity, // f0475667 TouchableOpacityInstance, // b186055b - TouchableOpacityProps, // d0aee298 - TouchableWithoutFeedback, // 30af9ea1 - TouchableWithoutFeedbackProps, // b6a23a82 + TouchableOpacityProps, // e5a90751 + TouchableWithoutFeedback, // 58c53a54 + TouchableWithoutFeedbackProps, // 38681413 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 - UIManager, // a1a7cc01 + UIManager, // afbcdf05 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 578096b7 + View, // 2d2ee408 ViewInstance, // ffde5573 - ViewProps, // 8abb0217 - ViewPropsAndroid, // ca64ec97 + ViewProps, // 9fc8ad04 + ViewPropsAndroid, // 6d1dbdcd ViewPropsIOS, // 58ee19bf ViewStyle, // 2fc81116 VirtualViewMode, // 6be59722 @@ -6062,17 +6015,17 @@ export { VirtualizedSectionList, // 486a3a9d VirtualizedSectionListInstance, // b8db2acd VirtualizedSectionListProps, - WrapperComponentProvider, // 4b8c7962 + WrapperComponentProvider, // 9ef54e61 codegenNativeCommands, // 628a7c0a codegenNativeComponent, // 32a1bca6 findNodeHandle, // 93f80214 processColor, // 6e877698 registerCallableModule, // 839c8cfe requireNativeComponent, // aa36a6dd - useAnimatedColor, // e3511f81 - useAnimatedValue, // b18adb63 - useAnimatedValueXY, // c7ee2332 + useAnimatedColor, // 31a919f9 + useAnimatedValue, // 3eb9d3c0 + useAnimatedValueXY, // b434ca0f useColorScheme, // d585efdb - usePressability, // c504c51d + usePressability, // f8e06f43 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/scripts/js-api/build-types/templates/tsconfig.test.json b/packages/react-native/__typetests__/tsconfig.json similarity index 55% rename from scripts/js-api/build-types/templates/tsconfig.test.json rename to packages/react-native/__typetests__/tsconfig.json index 7af1dd074cb8..6cee9f150756 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,13 @@ "jsx": "react", "noEmit": true, "forceConsistentCasingInFileNames": true, - "paths": {"react-native": ["."]}, "moduleResolution": "bundler", - "customConditions": ["react-native-strict-api"] + "baseUrl": "..", + "ignoreDeprecations": "6.0", + "paths": { + "react-native": ["types_generated/index.d.ts"], + "react-native/*": ["*"] + } }, - "include": ["**/*.d.ts", "../__typetests__/**/*"] + "include": ["**/*"] } diff --git a/packages/react-native/__typetests__/tsconfig.legacy.json b/packages/react-native/__typetests__/tsconfig.legacy.json new file mode 100644 index 000000000000..f4d02c468dac --- /dev/null +++ b/packages/react-native/__typetests__/tsconfig.legacy.json @@ -0,0 +1,11 @@ +{ + "$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"], + "ignoreDeprecations": "6.0" + } +} 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..e9902c5b7331 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, 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..c664ab8ac5a5 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -1,6 +1,6 @@ { "name": "react-native-macos", - "version": "1000.0.0", + "version": "0.87.0-rc.0", "description": "React Native for macOS", "license": "MIT", "repository": { @@ -28,33 +28,38 @@ "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, - "default": "./Libraries/*.js" - }, - "./scripts/*": "./scripts/*", - "./src/*": { "types": null, - "default": "./src/*.js" + "default": "./Libraries/*.js" }, + "./scripts/*": "./scripts/*.js", + "./scripts/*.sh": "./scripts/*.sh", + "./scripts/*.rb": "./scripts/*.rb", "./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 +75,6 @@ "gradle/libs.versions.toml", "index.js", "index.js.flow", - "jest-preset.js", "Libraries", "local-cli", "LICENSE", @@ -92,7 +96,6 @@ "ReactApple", "ReactCommon", "README.md", - "rn-get-polyfills.js", "scripts/replace-rncore-version.js", "scripts/bundle.js", "scripts/cocoapods/**", @@ -113,6 +116,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", @@ -140,26 +145,22 @@ "featureflags": "node ./scripts/featureflags/index.js" }, "peerDependencies": { - "@react-native/jest-preset": "workspace:*", "@types/react": "^19.1.1", - "react": "^19.2.3" + "react": "^19.2.3", + "react-native": "0.87.1" }, "peerDependenciesMeta": { - "@react-native/jest-preset": { - "optional": true - }, "@types/react": { "optional": true } }, "dependencies": { - "@react-native-macos/virtualized-lists": "workspace:*", - "@react-native/asset-utils": "workspace:*", - "@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:*", + "@react-native-macos/virtualized-lists": "0.87.0-rc.0", + "@react-native/asset-utils": "0.87.1", + "@react-native/codegen": "0.87.1", + "@react-native/community-cli-plugin": "0.87.1", + "@react-native/gradle-plugin": "0.87.1", + "@react-native/normalize-colors": "0.87.1", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.36.1", @@ -167,13 +168,14 @@ "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", - "hermes-compiler": "250829098.0.13", + "hermes-compiler": "250829098.0.17", "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", + "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/__tests__/replace-rncore-version-test.js b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js new file mode 100644 index 000000000000..ad077aa93ddf --- /dev/null +++ b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js @@ -0,0 +1,220 @@ +/** + * 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 {replaceRNCoreConfiguration} = require('../replace-rncore-version'); +const {execFileSync} = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const VERSION = '0.87.0-test'; +const SLICE = 'ios-arm64_x86_64-simulator'; +const BINARY = path.join(SLICE, 'React.framework', 'React'); +const SCRIPT = require.resolve('../replace-rncore-version.js'); +const MARKER = path.join('React-Core-prebuilt', '.last_build_configuration'); + +// Runs the script the way the "[RNCore] Replace React Native Core for the right +// configuration" build phase does, from Pods/ and through the CLI entry point. +function runScriptPhase(podsRoot, configuration) { + return execFileSync( + process.execPath, + [SCRIPT, '-c', configuration, '-r', VERSION, '-p', podsRoot], + {cwd: podsRoot, encoding: 'utf8'}, + ); +} + +function writeFile(filePath, contents) { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, contents); +} + +function buildTarball(podsRoot, configuration) { + const stage = fs.mkdtempSync(path.join(podsRoot, `stage-${configuration}-`)); + writeFile(path.join(stage, 'React.xcframework', 'Info.plist'), ''); + writeFile( + path.join(stage, 'React.xcframework', BINARY), + `binary-${configuration}`, + ); + // The tarball must also ship ReactNativeHeaders: without it the pre-fix code + // throws its fail-closed error before reaching the module map, so the + // regression test below would pass for the wrong reason. + writeFile( + path.join( + stage, + 'ReactNativeHeaders.xcframework', + SLICE, + 'Headers', + 'module.modulemap', + ), + 'module yoga {}\n', + ); + const artifacts = path.join(podsRoot, 'ReactNativeCore-artifacts'); + fs.mkdirSync(artifacts, {recursive: true}); + execFileSync('tar', [ + '-czf', + path.join( + artifacts, + `reactnative-core-${VERSION.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`, + ), + '-C', + stage, + '.', + ]); + fs.rmSync(stage, {recursive: true, force: true}); +} + +describe('replaceRNCoreConfiguration', () => { + let podsRoot; + let pod; + let cwd; + + beforeEach(() => { + podsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rncore-test-')); + pod = path.join(podsRoot, 'React-Core-prebuilt'); + // What the podspec prepare_command leaves behind after `pod install`. + writeFile( + path.join(pod, 'Headers', 'module.modulemap'), + 'module yoga {}\n', + ); + writeFile(path.join(pod, 'React.xcframework', 'Info.plist'), ''); + writeFile(path.join(pod, 'React.xcframework', BINARY), 'binary-Debug'); + buildTarball(podsRoot, 'Release'); + cwd = process.cwd(); + // The script phase runs with Pods/ as its working directory. + process.chdir(podsRoot); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(podsRoot, {recursive: true, force: true}); + }); + + it('installs the framework for the requested configuration', () => { + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect( + fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'), + ).toBe('binary-Release'); + }); + + // Regression test for #57803: recreating the module map mid-build lets a + // concurrent dependency scan miss it, and the React module then precompiles + // without -fmodule-map-file and fails on non-modular includes. + it('leaves Headers/module.modulemap untouched', () => { + const moduleMap = path.join(pod, 'Headers', 'module.modulemap'); + const before = fs.statSync(moduleMap).ino; + const contentsBefore = fs.readFileSync(moduleMap, 'utf8'); + + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect(fs.statSync(moduleMap).ino).toBe(before); + expect(fs.readFileSync(moduleMap, 'utf8')).toBe(contentsBefore); + }); + + // The swap used to purge every directory and restore this file by hand. It + // now replaces React.xcframework alone, so the file is never disturbed. + it('leaves an Expo-generated React-use-frameworks.modulemap in place', () => { + const expoModuleMap = path.join(pod, 'React-use-frameworks.modulemap'); + writeFile(expoModuleMap, 'module React {}\n'); + + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect(fs.readFileSync(expoModuleMap, 'utf8')).toBe('module React {}\n'); + }); + + // Regression tests for #57598. The marker used to be written only after the + // framework had already been replaced, so a build cancelled in between left it + // naming a flavor that was no longer on disk. Every later build for that + // flavor then took the "nothing to do" path and linked against the other + // configuration's core, which fails with undefined C++ symbols and which a + // clean does not undo because the pod directory survives it. + describe('marker bookkeeping', () => { + const marker = () => path.join(podsRoot, MARKER); + const binary = () => + fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'); + + it('records the configuration when it skips a fresh install', () => { + // `pod install` leaves the debug flavor and no marker, so a Debug build + // has nothing to swap. It still has to write down what is on disk, + // otherwise the state stays implicit and stays unverifiable. + expect(fs.existsSync(marker())).toBe(false); + + runScriptPhase(podsRoot, 'Debug'); + + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + expect(binary()).toBe('binary-Debug'); + }); + + it('invalidates the marker before it touches the framework', () => { + fs.writeFileSync(marker(), 'Debug'); + // Drop the tarball so the Release run fails once it is already under way, + // standing in for a build cancelled part way through the swap. + fs.rmSync( + path.join( + podsRoot, + 'ReactNativeCore-artifacts', + `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`, + ), + ); + + expect(() => runScriptPhase(podsRoot, 'Release')).toThrow(); + + // The marker must no longer claim Debug: the framework may already have + // been swapped, and a Debug build that trusts it would silently skip. + expect(fs.readFileSync(marker(), 'utf8')).not.toBe('Debug'); + }); + + it('replaces the framework when the marker shows an unfinished swap', () => { + buildTarball(podsRoot, 'Debug'); + // A swap that was interrupted: the Release flavor is on disk and the + // marker never got its final value. + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + fs.writeFileSync(marker(), 'in-progress'); + expect(binary()).toBe('binary-Release'); + + runScriptPhase(podsRoot, 'Debug'); + + expect(binary()).toBe('binary-Debug'); + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + }); + + it('still skips when the marker already matches the configuration', () => { + fs.writeFileSync(marker(), 'Release'); + const before = binary(); + + const output = runScriptPhase(podsRoot, 'Release'); + + expect(output).toContain('No need to replace React-Core-prebuilt'); + expect(binary()).toBe(before); + }); + }); + + it('fails when the tarball has no React.xcframework', () => { + const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-')); + writeFile(path.join(stage, 'unrelated.txt'), 'nope'); + execFileSync('tar', [ + '-czf', + path.join( + podsRoot, + 'ReactNativeCore-artifacts', + `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`, + ), + '-C', + stage, + '.', + ]); + + expect(() => + replaceRNCoreConfiguration('Release', VERSION, podsRoot), + ).toThrow(/Extraction verification failed/); + }); +}); 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__/rndependencies-test.rb b/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb new file mode 100644 index 000000000000..854a3ecefa60 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb @@ -0,0 +1,89 @@ +# 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 "shellwords" +require_relative "../rndependencies.rb" +require_relative "./test_utils/SpecMock.rb" + +class RNDependenciesTests < Test::Unit::TestCase + + # A pod that exports a Swift compatibility header ships this path, and the + # directory name contains spaces. + SWIFT_HEADER = "${PODS_CONFIGURATION_BUILD_DIR}/MyPod/Swift Compatibility Header" + + def teardown + ReactNativeDependenciesUtils.class_variable_set(:@@build_from_source, true) + end + + # Xcode joins an array setting with spaces, then splits it back on + # whitespace while honouring quotes. This is what the compiler ends up with. + def resolved_paths(xcconfig) + value = xcconfig["HEADER_SEARCH_PATHS"] + Shellwords.shellsplit(value.is_a?(Array) ? value.join(" ") : value) + end + + # ================================== # + # TEST - append_header_search_paths # + # ================================== # + + def test_appendHeaderSearchPaths_whenUnset_quotesTheAddedPaths + xcconfig = {} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["\"$(PODS_ROOT)/glog\""], xcconfig["HEADER_SEARCH_PATHS"]) + end + + def test_appendHeaderSearchPaths_whenStringHasQuotedPathWithSpaces_keepsItIntact + xcconfig = {"HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/DoubleConversion\" \"#{SWIFT_HEADER}\""} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["$(PODS_ROOT)/DoubleConversion", SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + def test_appendHeaderSearchPaths_whenArrayHasQuotedPathWithSpaces_keepsItIntact + xcconfig = {"HEADER_SEARCH_PATHS" => ["\"$(PODS_ROOT)/DoubleConversion\"", "\"#{SWIFT_HEADER}\""]} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["$(PODS_ROOT)/DoubleConversion", SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + def test_appendHeaderSearchPaths_whenCalledTwice_doesNotDuplicateEntries + xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal([SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + # ======================================= # + # TEST - add_rn_third_party_dependencies # + # ======================================= # + + def test_addRNThirdPartyDependencies_whenBuildingFromSource_keepsQuotedPathWithSpaces + spec = SpecMock.new + spec.pod_target_xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + add_rn_third_party_dependencies(spec) + + paths = resolved_paths(spec.pod_target_xcconfig) + assert_equal(SWIFT_HEADER, paths.first) + assert(paths.include?("$(PODS_ROOT)/RCT-Folly")) + end + + def test_addRNThirdPartyDependencies_whenUsingPrebuiltDeps_keepsQuotedPathWithSpaces + ReactNativeDependenciesUtils.class_variable_set(:@@build_from_source, false) + spec = SpecMock.new + spec.pod_target_xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + add_rn_third_party_dependencies(spec) + + assert_equal([SWIFT_HEADER, "$(PODS_ROOT)/ReactNativeDependencies/Headers"], resolved_paths(spec.pod_target_xcconfig)) + 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..89a308293c37 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb @@ -0,0 +1,100 @@ +# 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) + + 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) + 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)) + 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 +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..d8586b10f9cf 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,19 @@ 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_CPLUSPLUSFLAGS", 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..c88a8d159be2 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/rncore_facades.rb @@ -0,0 +1,232 @@ +# 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", + "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, supported_versions) + @@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" => supported_versions.transform_keys(&:to_s), + # 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..e5ce5a6551fa 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -33,24 +33,26 @@ def add_rn_third_party_dependencies(s) s.dependency "RCT-Folly/Fabric" end - header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] - - if header_search_paths.is_a?(String) - header_search_paths = header_search_paths.split(" ") - end - - header_search_paths << "$(PODS_ROOT)/glog" - header_search_paths << "$(PODS_ROOT)/boost" - header_search_paths << "$(PODS_ROOT)/DoubleConversion" - header_search_paths << "$(PODS_ROOT)/fast_float/include" - header_search_paths << "$(PODS_ROOT)/fmt/include" - header_search_paths << "$(PODS_ROOT)/SocketRocket" - header_search_paths << "$(PODS_ROOT)/RCT-Folly" - - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths + ReactNativeDependenciesUtils.append_header_search_paths(current_pod_target_xcconfig, [ + "$(PODS_ROOT)/glog", + "$(PODS_ROOT)/boost", + "$(PODS_ROOT)/DoubleConversion", + "$(PODS_ROOT)/fast_float/include", + "$(PODS_ROOT)/fmt/include", + "$(PODS_ROOT)/SocketRocket", + "$(PODS_ROOT)/RCT-Folly", + ]) 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" + + # Artifact headers are flattened into the pod-local Headers/ by the podspec + # prepare_command (see __docs__/prebuilt-deps.md). + ReactNativeDependenciesUtils.append_header_search_paths(current_pod_target_xcconfig, [ + "$(PODS_ROOT)/ReactNativeDependencies/Headers", + ]) end s.pod_target_xcconfig = current_pod_target_xcconfig @@ -133,12 +135,77 @@ def self.setup_react_native_dependencies(react_native_path, react_native_version end end + # Xcode splits HEADER_SEARCH_PATHS on whitespace, so every path we add is + # quoted - PODS_ROOT can expand to a directory with spaces in its name. + # Paths already in the xcconfig are left untouched: they carry the podspec + # author's own quoting, and re-quoting them would break it. + def self.append_header_search_paths(xcconfig, paths) + quoted = paths.map { |path| "\"#{path}\"" } + existing = xcconfig["HEADER_SEARCH_PATHS"] + + # reject so a second call on the same spec can't duplicate entries. + case existing + when nil + xcconfig["HEADER_SEARCH_PATHS"] = quoted + when Array + xcconfig["HEADER_SEARCH_PATHS"] = existing + quoted.reject { |path| existing.include?(path) } + else + xcconfig["HEADER_SEARCH_PATHS"] = ([existing] + quoted.reject { |path| existing.include?(path) }).join(" ") + end + end + def self.abort_if_use_local_rndeps_with_no_file() if !File.exist?(ENV["RCT_USE_LOCAL_RN_DEP"]) abort("RCT_USE_LOCAL_RN_DEP is set to #{ENV["RCT_USE_LOCAL_RN_DEP"]} but the file does not exist!") 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..e9609c00caee --- /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, supported_versions) + 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, supported_versions) + 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, supported_versions) + 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, supported_versions) + { + "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" => supported_versions.transform_keys(&:to_s), + # 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, supported_versions) + spec = base_spec(name, real.version.to_s, supported_versions) + + 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, supported_versions) + version = synthesized_version(name) + base_spec(name, version, supported_versions) + 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/__tests__/__snapshots__/generate-artifacts-executor-test.js.snap b/packages/react-native/scripts/codegen/__tests__/__snapshots__/generate-artifacts-executor-test.js.snap index 9ce1c901040e..5e8048debe75 100644 --- a/packages/react-native/scripts/codegen/__tests__/__snapshots__/generate-artifacts-executor-test.js.snap +++ b/packages/react-native/scripts/codegen/__tests__/__snapshots__/generate-artifacts-executor-test.js.snap @@ -79,7 +79,7 @@ exports[`execute test-app "ReactAppDependencyProvider/ReactAppDependencyProvider # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -version = \\"1000.0.0\\" +version = \\"0.87.0-rc.0\\" source = { :git => 'https://github.com/facebook/react-native.git' } if version == '1000.0.0' # This is an unpublished version, use the latest commit hash of the react-native repo, which we’re presumably in. @@ -399,7 +399,7 @@ exports[`execute test-app "ReactCodegen/ReactCodegen.podspec" should match snaps # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -version = \\"1000.0.0\\" +version = \\"0.87.0-rc.0\\" source = { :git => 'https://github.com/facebook/react-native.git' } if version == '1000.0.0' # This is an unpublished version, use the latest commit hash of the react-native repo, which we’re presumably in. @@ -590,7 +590,7 @@ exports[`execute test-app-legacy "ReactAppDependencyProvider/ReactAppDependencyP # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -version = \\"1000.0.0\\" +version = \\"0.87.0-rc.0\\" source = { :git => 'https://github.com/facebook/react-native.git' } if version == '1000.0.0' # This is an unpublished version, use the latest commit hash of the react-native repo, which we’re presumably in. @@ -882,7 +882,7 @@ exports[`execute test-app-legacy "ReactCodegen/ReactCodegen.podspec" should matc # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -version = \\"1000.0.0\\" +version = \\"0.87.0-rc.0\\" source = { :git => 'https://github.com/facebook/react-native.git' } if version == '1000.0.0' # This is an unpublished version, use the latest commit hash of the react-native repo, which we’re presumably in. 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.js b/packages/react-native/scripts/ios-prebuild.js index 1109b99df299..7079821cc2e9 100644 --- a/packages/react-native/scripts/ios-prebuild.js +++ b/packages/react-native/scripts/ios-prebuild.js @@ -77,6 +77,7 @@ async function main() { frameworkPaths, buildType, cli.identity, + cli.requireHermes, ); } diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 4d2786314714..70411582212b 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -111,123 +111,54 @@ 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). CocoaPods consumes that layout directly, through + `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly: the + XCFramework is not a member of the Swift package graph, so the consumer side + stages a copy of `React.framework/Headers` into + `ReactHeadersTarget/include/React` and rewrites `framework module React` to a + plain `module React`, vended as the `ReactHeaders` target (see + `spm-header-paths-contract.md` in the SwiftPM docs). +- `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..05878f8740c9 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js @@ -0,0 +1,371 @@ +/** + * 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, + }); + } + }); + + 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, + ); + } + }); +}); 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..768b895ca21b --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -0,0 +1,191 @@ +/** + * 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 resources = require('../framework-resources'); +const { + COMPOSE_TOOLING_FILES, + buildReactNativeHeadersXcframework, + composeToolingHash, + ensureHeadersLayout, +} = require('../headers-compose'); +const inventory = require('../headers-inventory'); +const spec = require('../headers-spec'); +const xcframework = require('../headers-xcframework'); +const childProcess = require('child_process'); +const fs = require('fs'); +const os = require('os'); +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}$/); + }); +}); + +describe('binary-derived header sidecars', () => { + let tmp = ''; + const plan = { + react: [], + reactNativeHeaders: [], + umbrella: [], + namespaceUmbrellas: [], + namespaceModules: {}, + depsNamespaces: [], + collisions: [], + privateReactHeaders: {modular: [], textual: []}, + }; + const writeBinary = (name, platform) => { + const dir = path.join(tmp, name + '.xcframework'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'Info.plist'), + JSON.stringify({ + AvailableLibraries: [ + {SupportedPlatform: platform, SupportedArchitectures: ['arm64']}, + ], + }), + ); + return dir; + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'header-slices-test-')); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest + .spyOn(inventory, 'computeInventory') + .mockReturnValue({headers: [], collisions: []}); + jest.spyOn(spec, 'planFromInventory').mockReturnValue(plan); + jest.spyOn(resources, 'buildReactPrivacyManifest').mockReturnValue(null); + jest.spyOn(resources, 'collectLprojDirs').mockReturnValue([]); + jest + .spyOn(childProcess, 'execFileSync') + .mockImplementation((command, args) => { + if (command === 'plutil') { + return fs.readFileSync(args[args.length - 1]); + } + if (command === '/bin/cp') { + fs.cpSync(args[1], args[2], {recursive: true}); + return Buffer.from(''); + } + throw new Error(`Unexpected native command: ${command}`); + }); + jest + .spyOn(xcframework, 'composeHeadersOnlyXcframework') + .mockImplementation((out, name) => { + const dir = path.join(out, name + '.xcframework'); + fs.mkdirSync(dir, {recursive: true}); + return dir; + }); + jest + .spyOn(xcframework, 'buildDepsHeadersXcframework') + .mockImplementation(out => { + const dir = path.join( + out, + 'ReactNativeDependenciesHeaders.xcframework', + ); + fs.mkdirSync(dir, {recursive: true}); + return dir; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + test('passes the supplied slices unchanged to the RN emitter', () => { + const slices = [ + {name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}, + ]; + buildReactNativeHeadersXcframework(tmp, plan, tmp, slices); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenCalledWith( + tmp, + 'ReactNativeHeaders', + expect.any(String), + slices, + ); + }); + + test('consumer derives each sidecar from its own binary and refreshes changed deps slices', () => { + writeBinary('React', 'macos'); + const deps = writeBinary('ReactNativeDependencies', 'xros'); + const out = path.join(tmp, 'composed'); + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenLastCalledWith( + out, + 'ReactNativeHeaders', + expect.any(String), + [{name: 'macos', sdk: 'macosx', targets: ['arm64-apple-macosx11.0']}], + ); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenLastCalledWith( + out, + path.join(deps, 'Headers'), + [], + [{name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}], + ); + + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenCalledTimes(1); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenCalledTimes(1); + + writeBinary('ReactNativeDependencies', 'ios'); + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenLastCalledWith( + out, + path.join(deps, 'Headers'), + [], + [{name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}], + ); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenCalledTimes(2); + }); + + test.each(['React', 'ReactNativeDependencies'])( + 'rejects invalid %s metadata before emitting either sidecar', + name => { + writeBinary('React', 'macos'); + writeBinary('ReactNativeDependencies', 'xros'); + fs.writeFileSync( + path.join(tmp, name + '.xcframework', 'Info.plist'), + '{}', + ); + expect(() => + ensureHeadersLayout(tmp, tmp, path.join(tmp, 'composed')), + ).toThrow(/non-empty AvailableLibraries/); + expect(xcframework.composeHeadersOnlyXcframework).not.toHaveBeenCalled(); + expect(xcframework.buildDepsHeadersXcframework).not.toHaveBeenCalled(); + }, + ); +}); 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..d85796253395 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js @@ -0,0 +1,174 @@ +/** + * 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 { + NATURAL_PATH_SOURCE_PREFERENCES, + PLATFORM_DISPATCH_AUXILIARY_HEADERS, + PLATFORM_DISPATCH_IMPLEMENTATIONS, + computeInventory, + scanHeader, +} = require('../headers-inventory'); +const path = require('path'); + +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); + }); +}); + +describe('header source precedence', () => { + test('resolves known platform dispatch collisions explicitly', () => { + const rnRoot = path.join(__dirname, '..', '..', '..'); + const inventory = computeInventory(rnRoot); + + expect(inventory.collisions).toEqual([]); + for (const [naturalPath, source] of NATURAL_PATH_SOURCE_PREFERENCES) { + const header = inventory.headers.find( + candidate => candidate.naturalPath === naturalPath, + ); + expect(header?.identities.map(identity => identity.source)).toEqual([ + source, + ]); + } + for (const source of PLATFORM_DISPATCH_IMPLEMENTATIONS.values()) { + const naturalPath = source.slice('ReactCommon/'.length); + const header = inventory.headers.find( + candidate => candidate.naturalPath === naturalPath, + ); + expect(header?.identities.map(identity => identity.source)).toEqual([ + source, + ]); + } + for (const source of PLATFORM_DISPATCH_AUXILIARY_HEADERS.keys()) { + const naturalPath = source.slice('ReactCommon/'.length); + const header = inventory.headers.find( + candidate => candidate.naturalPath === naturalPath, + ); + expect(header?.identities.map(identity => identity.source)).toEqual([ + source, + ]); + } + const textUIKitConsumers = inventory.headers.filter(header => + [ + 'RCTText/RCTUITextField.h', + 'RCTText/RCTUITextView.h', + 'RCTText/RCTWrappedTextView.h', + 'React/RCTUITextField.h', + 'React/RCTUITextView.h', + 'React/RCTUnimplementedNativeComponentView.h', + 'React/RCTWrappedTextView.h', + ].includes(header.naturalPath), + ); + expect( + textUIKitConsumers.flatMap(header => header.includes.quotedNotShipped), + ).toEqual([]); + }); +}); 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..1ee1b0a5fbc9 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.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 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__', + '#import ', + '#if TARGET_OS_OSX', + '#import ', + '#else', + '#import ', + '#endif', + '#endif', + ].join('\n'), + ); + // 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..98a64a69bc00 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.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 strict-local + * @format + */ + +'use strict'; + +const { + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, + 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/, + ); + }); + + const iosLibraries = [ + {SupportedPlatform: 'ios', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ]; + + test('matches all five Microsoft platforms without adding Catalyst or architectures', () => { + const exec = mockPlist({ + AvailableLibraries: [ + ...iosLibraries, + { + SupportedPlatform: 'macos', + SupportedArchitectures: ['x86_64', 'arm64'], + }, + {SupportedPlatform: 'xros', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'xros', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64'], + }, + ], + }); + expect(stubSlicesFromXcframework('/React.xcframework')).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', + ], + }, + { + name: 'macos', + sdk: 'macosx', + targets: ['x86_64-apple-macosx11.0', 'arm64-apple-macosx11.0'], + }, + {name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}, + { + name: 'xros-simulator', + sdk: 'xrsimulator', + targets: ['arm64-apple-xros1.0-simulator'], + }, + ]); + expect(exec).toHaveBeenCalledWith('plutil', [ + '-convert', + 'json', + '-o', + '-', + '/React.xcframework/Info.plist', + ]); + }); + + test('matches the three upstream platforms including Catalyst', () => { + mockPlist({ + AvailableLibraries: [ + ...iosLibraries, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'maccatalyst', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ], + }); + const slices = stubSlicesFromXcframework('/React.xcframework'); + expect(slices.map(s => s.name)).toEqual([ + 'ios', + 'ios-simulator', + 'ios-maccatalyst', + ]); + expect(slices[2]).toEqual({ + name: 'ios-maccatalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], + }); + }); + + test.each([ + ['macos', undefined, 'macosx', 'macosx11.0'], + ['ios', 'simulator', 'iphonesimulator', 'ios15.0-simulator'], + ['xros', undefined, 'xros', 'xros1.0'], + ['tvos', undefined, 'appletvos', 'tvos15.1'], + ['tvos', 'simulator', 'appletvsimulator', 'tvos15.1-simulator'], + ])('preserves a single %s / %s slice', (platform, variant, sdk, target) => { + mockPlist({ + AvailableLibraries: [ + { + SupportedPlatform: platform, + SupportedPlatformVariant: variant, + SupportedArchitectures: ['arm64'], + }, + ], + }); + expect(stubSlicesFromXcframework('/single.xcframework')).toEqual([ + { + name: variant == null ? platform : `${platform}-${variant}`, + sdk, + targets: [`arm64-apple-${target}`], + }, + ]); + }); + + test.each([ + null, + {}, + {AvailableLibraries: null}, + {AvailableLibraries: {}}, + {AvailableLibraries: []}, + ])('rejects missing or empty libraries: %p', plist => { + mockPlist(plist); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /non-empty AvailableLibraries/, + ); + }); + + test.each([ + null, + {}, + {SupportedPlatform: ''}, + {SupportedPlatform: 1}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: ''}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: null}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: 1}, + ])('rejects malformed platform metadata: %p', lib => { + mockPlist({AvailableLibraries: [lib]}); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /invalid platform metadata/, + ); + }); + + test.each(['watchos', 'visionos', 'toString', '__proto__'])( + 'rejects unknown platform %s', + platform => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: platform, SupportedArchitectures: ['arm64']}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /no stub recipe/, + ); + }, + ); + + test('rejects an unknown variant', () => { + mockPlist({ + AvailableLibraries: [ + {...iosLibraries[0], SupportedPlatformVariant: 'unknown'}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /no stub recipe for slice 'ios-unknown'/, + ); + }); + + test.each( + [ + undefined, + null, + [], + 'arm64', + [null], + [1], + [''], + ['arm64-apple-ios'], + ['arm64', 'arm64'], + ].map(archs => [archs]), + )( + 'rejects malformed architectures instead of inventing a default: %p', + archs => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'ios', SupportedArchitectures: archs}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /invalid SupportedArchitectures/, + ); + }, + ); + + test('rejects duplicate platform/variant entries', () => { + mockPlist({AvailableLibraries: [iosLibraries[0], iosLibraries[0]]}); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /duplicate slice 'ios'/, + ); + }); + + test('reports invalid JSON with the artifact path', () => { + jest + .spyOn(childProcess, 'execFileSync') + .mockReturnValue(Buffer.from('not JSON')); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /failed to parse Info.plist of \/bad.xcframework/, + ); + }); + + test('reports a missing or unreadable plist', () => { + jest.spyOn(childProcess, 'execFileSync').mockImplementation(() => { + throw new Error('missing plist'); + }); + expect(() => stubSlicesFromXcframework('/missing.xcframework')).toThrow( + /failed to parse Info.plist.*missing plist/, + ); + }); + + test('rejects empty emitter input before invoking native tools', () => { + const exec = jest.spyOn(childProcess, 'execFileSync'); + expect(() => + composeHeadersOnlyXcframework('/unused', 'Headers', '/unused', []), + ).toThrow(/requires non-empty slices/); + expect(exec).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js new file mode 100644 index 000000000000..c6156bd29f21 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js @@ -0,0 +1,153 @@ +/** + * 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 codegen = require('../../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); +const compose = require('../headers-compose'); +const {buildXCFrameworks, resolveHermesHeaders} = require('../xcframework'); +const childProcess = require('child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('resolveHermesHeaders', () => { + let tmp /*: string */ = ''; + let buildFolder /*: string */ = ''; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xcframework-test-')); + buildFolder = path.join(tmp, '.build'); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + test('returns the base include directory when hermes/hermes.h exists', () => { + const includeDir = path.join( + buildFolder, + 'artifacts', + 'hermes', + 'destroot', + 'include', + ); + fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true}); + fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), ''); + + expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir); + }); + + test('finds a non-standard nested include directory', () => { + const includeDir = path.join( + buildFolder, + 'artifacts', + 'hermes', + 'nested', + 'archive', + 'destroot', + 'include', + ); + fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true}); + fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), ''); + + expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir); + }); + + test('returns null when Hermes headers are absent and not required', () => { + expect(resolveHermesHeaders(buildFolder, false)).toBeNull(); + }); + + test('throws when Hermes headers are absent and required', () => { + expect(() => resolveHermesHeaders(buildFolder, true)).toThrow( + /ReactNativeHeaders[\s\S]*[\s\S]*destroot\/include/, + ); + }); +}); + +describe('producer header slices', () => { + afterEach(() => jest.restoreAllMocks()); + + test.each(['macos', 'xros', 'unknown'])( + 'derives the RN sidecar from the composed %s binary', + platform => { + jest + .spyOn(codegen, 'generateFBReactNativeSpecIOS') + .mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(fs, 'rmSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const plan = {}; + jest.spyOn(compose, 'computeSpecPlan').mockReturnValue(plan); + jest + .spyOn(compose, 'emitReactFrameworkHeaders') + .mockImplementation(() => {}); + const emit = jest + .spyOn(compose, 'buildReactNativeHeadersXcframework') + .mockReturnValue( + '/build/output/xcframeworks/Debug/ReactNativeHeaders.xcframework', + ); + const exec = jest + .spyOn(childProcess, 'execFileSync') + .mockImplementation(command => { + if (command === 'plutil') { + return Buffer.from( + JSON.stringify({ + AvailableLibraries: [ + { + SupportedPlatform: platform, + SupportedArchitectures: ['arm64'], + }, + ], + }), + ); + } + return Buffer.from(''); + }); + if (platform === 'unknown') { + expect(() => + buildXCFrameworks('/root', '/build', [], 'Debug', null), + ).toThrow(/no stub recipe/); + expect(emit).not.toHaveBeenCalled(); + expect(exec.mock.calls.some(([command]) => command === 'tar')).toBe( + false, + ); + return; + } + buildXCFrameworks('/root', '/build', [], 'Debug', null); + expect(exec).toHaveBeenCalledWith('plutil', [ + '-convert', + 'json', + '-o', + '-', + '/build/output/xcframeworks/Debug/React.xcframework/Info.plist', + ]); + expect(emit).toHaveBeenCalledWith( + '/build/output/xcframeworks/Debug', + plan, + '/root', + [ + { + name: platform, + sdk: platform === 'macos' ? 'macosx' : 'xros', + targets: [ + platform === 'macos' + ? 'arm64-apple-macosx11.0' + : 'arm64-apple-xros1.0', + ], + }, + ], + null, + null, + ); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index ebb3ba1dfc65..03125f39d52e 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -72,6 +72,11 @@ const cli = yargs describe: 'Specify the code signing identity to use for signing the frameworks.', }) + .option('require-hermes', { + type: 'boolean', + describe: + 'Require Hermes headers when composing the ReactNativeHeaders XCFramework.', + }) .help(); /** @@ -86,6 +91,7 @@ async function getCLIConfiguration() /*: Promise, identity: ?string, + requireHermes: boolean, |}> */ { // Run input parsing const argv = await cli.argv; @@ -131,6 +137,7 @@ async function getCLIConfiguration() /*: Promise, + ... +}; +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..9bcd76335980 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -0,0 +1,420 @@ +/** + * 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 { + 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'; */ +/*:: import type {StubSlice} from './headers-xcframework'; */ + +/** + * 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 */, + overlayDir /*: ?string */ = null, +) /*: 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 { + // ReactNativeVersion.h is the one shipped header STAMPED at build time: + // the slice job runs set-rn-artifacts-version before building, while the + // source tree keeps the 1000.0.0 dev sentinel. Take just this file's + // content from the built header tree (`overlayDir`, i.e. `.build/headers`) + // when present, so the compose ships the real version without re-stamping + // its own checkout. Every OTHER header is authoritative in the source + // tree (only the layout is spec-derived), so it always copies from + // source — never from a build tree that could be stale relative to it. + const isStamped = path.basename(e.source) === 'ReactNativeVersion.h'; + const overlaySource = + isStamped && overlayDir != null + ? path.join(overlayDir, e.source) + : null; + const src = + overlaySource != null && fs.existsSync(overlaySource) + ? overlaySource + : path.join(rnRoot, e.source); + fs.copyFileSync(src, 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 */, + overlayDir /*: ?string */ = null, +) /*: void */ { + const stage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.react-stage-'), + ); + stageEntries(stage, plan.react, rnRoot, overlayDir); + 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'); + 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(fwk, '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(fwk, '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 */, + slices /*: Array */, + // 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, + overlayDir /*: ?string */ = null, +) /*: string */ { + // ---- stage headers ---- + const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); + stageEntries(stage, plan.reactNativeHeaders, rnRoot, overlayDir); + // 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 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 depsXcfw = path.join( + artifactsDir, + 'ReactNativeDependencies.xcframework', + ); + const depsHeaders = path.join(depsXcfw, 'Headers'); + const reactSlices = stubSlicesFromXcframework(sourceXcfw); + const depsSlices = stubSlicesFromXcframework(depsXcfw); + // 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). + // Include both binary slice sets so a changed deps platform set also + // invalidates the cached sidecars, even when React itself is unchanged. + const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\ntooling:${composeToolingHash()}\nslices:${JSON.stringify([reactSlices, depsSlices])}\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, + reactSlices, + hermesHeaders, + ); + // Each sidecar matches its own binary, which may carry a different slice set. + buildDepsHeadersXcframework( + outDir, + depsHeaders, + plan.depsNamespaces, + depsSlices, + ); + 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-include-baseline.json b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json new file mode 100644 index 000000000000..94fcea3de137 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json @@ -0,0 +1,23 @@ +[ + "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 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..9f4cc270a084 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -0,0 +1,802 @@ +/** + * 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'); + +// The macOS fork keeps top-level HostPlatform* dispatch headers that select the +// macOS or generic implementation. Podspec globs also discover the nested +// implementations at the same natural include path, so choose the dispatch +// header explicitly. Legacy interop is the inverse: upstream's platform/ios +// header is the guarded canonical implementation and already carries the macOS +// type adaptations. +const NATURAL_PATH_SOURCE_PREFERENCES /*: Map */ = new Map([ + [ + 'react/renderer/components/legacyviewmanagerinterop/RCTLegacyViewManagerInteropCoordinator.h', + 'ReactCommon/react/renderer/components/legacyviewmanagerinterop/platform/ios/react/renderer/components/legacyviewmanagerinterop/RCTLegacyViewManagerInteropCoordinator.h', + ], + ...[ + 'HostPlatformTouch.h', + 'HostPlatformViewEventEmitter.h', + 'HostPlatformViewProps.h', + 'HostPlatformViewTraitsInitializer.h', + ].map(name => [ + `react/renderer/components/view/${name}`, + `ReactCommon/react/renderer/components/view/${name}`, + ]), +]); + +const PLATFORM_DISPATCH_IMPLEMENTATIONS /*: Map */ = new Map( + [ + 'HostPlatformTouch.h', + 'HostPlatformViewEventEmitter.h', + 'HostPlatformViewProps.h', + 'HostPlatformViewTraitsInitializer.h', + 'KeyEvent.h', + 'MouseEvent.h', + ].map(name => [ + `react/renderer/components/view/${name}`, + `ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/${name}`, + ]), +); + +const PLATFORM_DISPATCH_AUXILIARY_HEADERS /*: Map */ = new Map([ + [ + 'ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEvents.h', + 'react/renderer/components/view/HostPlatformViewProps.h', + ], +]); + +/*:: +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', + 'AVFoundation', + 'AVKit', + 'CFNetwork', + 'CommonCrypto', + 'CoreFoundation', + 'CoreGraphics', + 'CoreLocation', + 'CoreMedia', + 'CoreServices', + 'CoreText', + 'CoreVideo', + '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); + + // Podspec header maps flatten platform implementations to their public + // include spelling. Dispatch headers include those implementations by + // their full ReactCommon path, so ship that physical spelling too. + const sourcePath = path.relative(rootFolder, header.source); + const reactCommonPrefix = 'ReactCommon/'; + if ( + sourcePath.startsWith(reactCommonPrefix) && + sourcePath.includes('/platform/') && + sourcePath.endsWith(path.basename(naturalPath)) + ) { + const physicalNaturalPath = sourcePath.slice( + reactCommonPrefix.length, + ); + if (physicalNaturalPath !== naturalPath) { + addIdentity( + physicalNaturalPath, + { + ...identity, + namespacedPath: path.join(podName, physicalNaturalPath), + }, + 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, + ); + } + } + } + } + + for (const [ + wrapperPath, + implementationSource, + ] of PLATFORM_DISPATCH_IMPLEMENTATIONS) { + const wrapper = entries.get(wrapperPath); + const implementationPath = implementationSource.slice( + 'ReactCommon/'.length, + ); + const implementationAbsSource = path.join(rootFolder, implementationSource); + if ( + wrapper == null || + entries.has(implementationPath) || + !fs.existsSync(implementationAbsSource) + ) { + continue; + } + const wrapperIdentity = wrapper.identities[0]; + addIdentity( + implementationPath, + { + ...wrapperIdentity, + namespacedPath: path.join(wrapperIdentity.pod, implementationPath), + source: implementationSource, + }, + implementationAbsSource, + ); + } + + for (const [ + implementationSource, + wrapperPath, + ] of PLATFORM_DISPATCH_AUXILIARY_HEADERS) { + const wrapper = entries.get(wrapperPath); + const implementationPath = implementationSource.slice( + 'ReactCommon/'.length, + ); + const implementationAbsSource = path.join(rootFolder, implementationSource); + if ( + wrapper == null || + entries.has(implementationPath) || + !fs.existsSync(implementationAbsSource) + ) { + continue; + } + const wrapperIdentity = wrapper.identities[0]; + addIdentity( + implementationPath, + { + ...wrapperIdentity, + namespacedPath: path.join(wrapperIdentity.pod, implementationPath), + source: implementationSource, + }, + implementationAbsSource, + ); + } + + for (const [ + naturalPath, + preferredSource, + ] of NATURAL_PATH_SOURCE_PREFERENCES) { + const sources = naturalToSources.get(naturalPath); + const preferredAbsSource = path.join(rootFolder, preferredSource); + if ( + sources == null || + sources.size < 2 || + !sources.has(preferredAbsSource) + ) { + continue; + } + + naturalToSources.set(naturalPath, new Set([preferredAbsSource])); + const entry = entries.get(naturalPath); + if (entry != null) { + entry.identities = entry.identities.filter( + identity => identity.source === preferredSource, + ); + } + for (const source of sources) { + if (source === preferredAbsSource) { + continue; + } + const naturals = sourceToNatural.get(source); + if (naturals != null) { + sourceToNatural.set( + source, + naturals.filter(candidate => candidate !== naturalPath), + ); + } + } + } + + 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 resolved = path.resolve( + path.dirname(absSource), + token.slice(1, -1), + ); + const naturals = sourceToNatural.get(resolved); + if (naturals && naturals.length > 0) { + entry.includes.internal.push({ + naturalPath: naturals[0], + cxxGuarded: inc.cxxGuarded, + }); + } else { + const quotedPath = token.slice(1, -1); + const packagedSibling = quotedPath.includes('/') + ? quotedPath + : path.posix.join( + path.posix.dirname(entry.naturalPath), + quotedPath, + ); + if (entries.has(packagedSibling)) { + entry.includes.internal.push({ + naturalPath: packagedSibling, + cxxGuarded: inc.cxxGuarded, + }); + continue; + } + // 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, + NATURAL_PATH_SOURCE_PREFERENCES, + PLATFORM_DISPATCH_IMPLEMENTATIONS, + PLATFORM_DISPATCH_AUXILIARY_HEADERS, +}; 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..ef44e48dd366 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -0,0 +1,529 @@ +/** + * 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'); + return `#ifdef __OBJC__ +#import +#if TARGET_OS_OSX +#import +#else +#import +#endif +#endif + +${imports} +`; +} + +/** + * 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..30a9ce778954 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -0,0 +1,557 @@ +/** + * 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] + * [--require-stamped-version] + */ + +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}); + } +} + +// --------------------------------------------------------------------------- +// Version stamp gate +// --------------------------------------------------------------------------- + +/** + * Release/nightly artifacts must not ship ReactNativeVersion.h with the + * 1000.0.0 dev sentinel: the compose step copies headers from the source + * tree, so a compose job that forgot to run set-rn-artifacts-version.js + * would silently publish a sentinel header, breaking every library that + * gates code on REACT_NATIVE_VERSION_MAJOR/MINOR. + */ +function verifyVersionStamp(artifactsDir /*: string */) /*: void */ { + const copies = []; + const walk = (dir /*: string */) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const name = String(entry.name); + const full = path.join(dir, name); + if (entry.isDirectory()) { + walk(full); + } else if (name === 'ReactNativeVersion.h') { + copies.push(full); + } + } + }; + walk(artifactsDir); + if (copies.length === 0) { + throw new Error( + `no ReactNativeVersion.h found under ${artifactsDir} — cannot verify the version stamp.`, + ); + } + const unstamped = copies.filter(f => + /REACT_NATIVE_VERSION_MAJOR\s+1000\b/.test(fs.readFileSync(f, 'utf8')), + ); + if (unstamped.length > 0) { + throw new Error( + `ReactNativeVersion.h still contains the 1000.0.0 dev sentinel — run ` + + `scripts/releases/set-rn-artifacts-version.js before composing:\n ` + + unstamped.join('\n '), + ); + } + log(`version stamp OK (${copies.length} copies checked).`); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv /*: Array */) /*: { + flavor: string, + artifacts: ?string, + skipCompile: boolean, + updateBaseline: boolean, + requireStampedVersion: boolean, +} */ { + let flavor = 'Debug'; + let artifacts /*: ?string */ = null; + let skipCompile = false; + let updateBaseline = false; + let requireStampedVersion = 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; + } else if (argv[i] === '--require-stamped-version') { + requireStampedVersion = true; + } + } + return { + flavor, + artifacts, + skipCompile, + updateBaseline, + requireStampedVersion, + }; +} + +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}).`, + ); + } + if (args.requireStampedVersion) { + verifyVersionStamp(artifactsDir); + } + + 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..50369f56f815 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -0,0 +1,299 @@ +/** + * 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) +}; +*/ + +// 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}`, + ); + } + if ( + !Array.isArray(plist?.AvailableLibraries) || + plist.AvailableLibraries.length === 0 + ) { + throw new Error( + `headers-xcframework: ${xcfwPath} must have a non-empty AvailableLibraries array.`, + ); + } + const seen = new Set(); + return plist.AvailableLibraries.map(lib => { + if ( + lib == null || + typeof lib.SupportedPlatform !== 'string' || + lib.SupportedPlatform.length === 0 || + (lib.SupportedPlatformVariant !== undefined && + (typeof lib.SupportedPlatformVariant !== 'string' || + lib.SupportedPlatformVariant.length === 0)) + ) { + throw new Error( + `headers-xcframework: invalid platform metadata in ${xcfwPath}.`, + ); + } + const key = + lib.SupportedPlatformVariant != null + ? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}` + : lib.SupportedPlatform; + if (!Object.prototype.hasOwnProperty.call(PLATFORM_STUB_RECIPES, key)) { + throw new Error( + `headers-xcframework: no stub recipe for slice '${key}' of ` + + `${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`, + ); + } + const recipe = PLATFORM_STUB_RECIPES[key]; + const archs = lib.SupportedArchitectures; + if ( + !Array.isArray(archs) || + archs.length === 0 || + archs.some(a => typeof a !== 'string' || !/^[A-Za-z0-9_]+$/.test(a)) || + new Set(archs).size !== archs.length + ) { + throw new Error( + `headers-xcframework: invalid SupportedArchitectures for slice '${key}' of ${xcfwPath}.`, + ); + } + if (seen.has(key)) { + throw new Error( + `headers-xcframework: duplicate slice '${key}' of ${xcfwPath}.`, + ); + } + seen.add(key); + return { + name: key, + sdk: recipe.sdk, + targets: archs.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 */ { + if (!Array.isArray(slices) || slices.length === 0) { + throw new Error(`headers-xcframework: ${name} requires non-empty slices.`); + } + 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 = { + 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..81f88ff1448b 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -128,6 +128,38 @@ 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)) { + fs.cpSync( + headersSidecarSource, + path.join(artifactsPath, path.basename(headersSidecarSource)), + {recursive: true}, + ); + } 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 +229,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/utils.js b/packages/react-native/scripts/ios-prebuild/utils.js index d1c60f838b52..99a98c65e34b 100644 --- a/packages/react-native/scripts/ios-prebuild/utils.js +++ b/packages/react-native/scripts/ios-prebuild/utils.js @@ -12,6 +12,7 @@ const {execSync} = require('child_process'); const fs = require('fs'); +const path = require('path'); /** * Creates a folder if it does not exist @@ -28,6 +29,31 @@ function createFolderIfNotExists(folderPath /*:string*/) /*: string */ { return folderPath; } +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; +} + function throwIfOnEden() { try { execSync('eden info', {stdio: 'ignore'}); @@ -104,6 +130,7 @@ async function computeNightlyTarballURL( module.exports = { createFolderIfNotExists, + findFirst, throwIfOnEden, createLogger, computeNightlyTarballURL, 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..291540992d05 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,46 +13,44 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); -const headers = require('./headers'); +const {stubSlicesFromXcframework} = require('./headers-xcframework'); 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 {execFileSync} = childProcess; +const {createLogger, findFirst} = 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', -); +function resolveHermesHeaders( + buildFolder /*: string */, + required /*: boolean */, +) /*: ?string */ { + const hermesArtifacts = path.join(buildFolder, 'artifacts', 'hermes'); + const baseInclude = path.join(hermesArtifacts, 'destroot', 'include'); + if (fs.existsSync(path.join(baseInclude, 'hermes', 'hermes.h'))) { + return baseInclude; + } -/** - * 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 includeDir = findFirst(hermesArtifacts, name => name === 'include', 8); + if ( + includeDir != null && + fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h')) + ) { + return includeDir; + } -const RN_MODULEMAP_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'module.modulemap', -); + if (required) { + throw new Error( + 'Cannot compose ReactNativeHeaders: will not resolve because ' + + "hermes/hermes.h is missing. Stage the hermes-ios tarball's " + + 'destroot/include into .build/artifacts/hermes before composing.', + ); + } + return null; +} function buildXCFrameworks( rootFolder /*: string */, @@ -60,6 +58,7 @@ function buildXCFrameworks( frameworkFolders /*: Array */, buildType /*: BuildFlavor */, identity /*: ?string */, + requireHermes /*: boolean */ = false, ) { // Let's run codegen for FBReactNativeSpec otherwise some headers will be missing generateFBReactNativeSpecIOS('.'); @@ -71,7 +70,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 +106,50 @@ 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); + // Built header tree from the slice jobs (downloaded to `.build/headers`). + // When present, the compose sources header CONTENT from here (see + // stageEntries) so build-time generated/stamped headers — notably the + // version-stamped ReactNativeVersion.h — ship without re-stamping this + // checkout. Absent (e.g. a local compose with no prior build) → source tree. + const builtHeadersDir = path.join(buildFolder, 'headers'); + const overlayDir = fs.existsSync(builtHeadersDir) ? builtHeadersDir : null; + emitReactFrameworkHeaders(outputPath, plan, rootFolder, overlayDir); + // 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 for local + // development. CI release composition requires it and fails closed. + const hermesHeaders = resolveHermesHeaders(buildFolder, requireHermes); + const headersXcfw = buildReactNativeHeadersXcframework( + path.dirname(outputPath), + plan, rootFolder, - outputPath, - moduleMapFile, - umbrellaHeaders, - copiedHeaderFilesWithPodspecNames, + stubSlicesFromXcframework(outputPath), + hermesHeaders, + overlayDir, ); - // 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 +162,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 +174,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 +275,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 @@ -453,4 +307,5 @@ function signXCFramework( module.exports = { buildXCFrameworks, + resolveHermesHeaders, }; 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..3d1cffa645a9 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) + 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-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) + 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..0d0e93da8927 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -18,6 +18,9 @@ const yargs = require('yargs'); const LAST_BUILD_FILENAME = 'React-Core-prebuilt/.last_build_configuration'; +// Not a valid configuration, so finding it means the swap did not finish. +const REPLACEMENT_IN_PROGRESS = 'in-progress'; + function validateBuildConfiguration(configuration /*: string */) { if (!['Debug', 'Release'].includes(configuration)) { throw new Error(`Invalid configuration ${configuration}`); @@ -42,10 +45,12 @@ function shouldReplaceRnCoreConfiguration(configuration /*: string */) { ); return false; } + return true; } - // Assumption: if there is no stored last build, we assume that it was build for debug. - if (!fileExists && configuration === 'Debug') { + // With no marker the on-disk flavor is Debug: the podspec installs the debug + // tarball (see resolve_podspec_source in scripts/cocoapods/rncore.rb). + if (configuration === 'Debug') { console.log( 'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt', ); @@ -59,7 +64,7 @@ function replaceRNCoreConfiguration( configuration /*: string */, version /*: string */, podsRoot /*: string */, -) { +) /*: void */ { // Filename comes from rncore.rb const tarballURLPath = `${podsRoot}/ReactNativeCore-artifacts/reactnative-core-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`; @@ -73,18 +78,6 @@ function replaceRNCoreConfiguration( const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt'); fs.mkdirSync(tmpExtractDir, {recursive: true}); - // Preserve Expo-generated modulemap before replacing directories - const useFrameworksModulemapName = 'React-use-frameworks.modulemap'; - const useFrameworksModulemapPath = path.join( - finalLocation, - useFrameworksModulemapName, - ); - let savedModulemap = null; - if (fs.existsSync(useFrameworksModulemapPath)) { - console.log('Preserving', useFrameworksModulemapName); - savedModulemap = fs.readFileSync(useFrameworksModulemapPath); - } - try { console.log('Extracting the tarball to temp dir', tarballURLPath); const result = spawnSync( @@ -99,62 +92,41 @@ 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 - const dirs = fs - .readdirSync(finalLocation, {withFileTypes: true}) - .filter(dirent => dirent.isDirectory()); - for (const dirent of dirs) { - const direntName = - typeof dirent.name === 'string' ? dirent.name : dirent.name.toString(); - const dirPath = `${finalLocation}/${direntName}`; - console.log('Removing directory', dirPath); - fs.rmSync(dirPath, {force: true, recursive: true}); - } - - // Move extracted directories from temp to final location - const extractedEntries = fs - .readdirSync(tmpExtractDir, {withFileTypes: true}) - .filter(dirent => dirent.isDirectory()); - for (const dirent of extractedEntries) { - const direntName = - typeof dirent.name === 'string' ? dirent.name : dirent.name.toString(); - const src = path.join(tmpExtractDir, direntName); - const dst = path.join(finalLocation, direntName); - const mvResult = spawnSync('mv', [src, dst], {stdio: 'inherit'}); - if (mvResult.status !== 0) { - // Fallback: copy recursively then remove source - console.log(`mv failed for ${direntName}, falling back to cp -R`); - const cpResult = spawnSync('cp', ['-R', src, dst], { - stdio: 'inherit', - }); - if (cpResult.status !== 0) { - throw new Error( - `cp fallback failed with exit code ${cpResult.status}`, - ); - } + // Replace only the compiled framework. Headers/ is flattened from + // ReactNativeHeaders by the podspec prepare_command, and the prebuild + // compose job emits one set of those headers for both configurations, so a + // config switch leaves them identical. Leaving them alone keeps + // Headers/module.modulemap — which consumers activate through + // -fmodule-map-file — in place for the whole build; deleting and recreating + // it mid-build lets a concurrent dependency scan miss it, and the React + // module then precompiles without it (#57803). + const dest = path.join(finalLocation, 'React.xcframework'); + console.log('Replacing', dest); + fs.rmSync(dest, {force: true, recursive: true}); + const mvResult = spawnSync('mv', [xcfwPath, dest], {stdio: 'inherit'}); + if (mvResult.status !== 0) { + // Fallback: copy recursively then remove source + console.log('mv failed for React.xcframework, falling back to cp -R'); + const cpResult = spawnSync('cp', ['-R', xcfwPath, dest], { + stdio: 'inherit', + }); + if (cpResult.status !== 0) { + throw new Error(`cp fallback failed with exit code ${cpResult.status}`); } } } finally { - // Clean up temp directory fs.rmSync(tmpDir, {force: true, recursive: true}); - - // Restore Expo-generated modulemap after directory replacement. - // Runs in finally so it is not skipped if mv/cp partially fails. - if (savedModulemap != null) { - const restoredPath = path.join(finalLocation, useFrameworksModulemapName); - fs.writeFileSync(restoredPath, savedModulemap); - console.log('Restored', useFrameworksModulemapName); - } } } @@ -163,6 +135,10 @@ function updateLastBuildConfiguration(configuration /*: string */) { fs.writeFileSync(LAST_BUILD_FILENAME, configuration); } +function markReplacementInProgress() /*: void */ { + fs.writeFileSync(LAST_BUILD_FILENAME, REPLACEMENT_IN_PROGRESS); +} + function main( configuration /*: string */, version /*: string */, @@ -172,37 +148,48 @@ function main( validateVersion(version); if (!shouldReplaceRnCoreConfiguration(configuration)) { + // A fresh install leaves no marker; record the flavor we skipped on. + if (!fs.existsSync(LAST_BUILD_FILENAME)) { + updateLastBuildConfiguration(configuration); + } return; } + // Invalidate before touching the framework so an interrupted swap is + // detectable on the next run. + markReplacementInProgress(); replaceRNCoreConfiguration(configuration, version, podsRoot); updateLastBuildConfiguration(configuration); console.log('Done replacing React Native prebuilt'); } -// This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok -const argv = yargs - .option('c', { - alias: 'configuration', - description: - 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".', - }) - .option('r', { - alias: 'reactNativeVersion', - description: - 'The Version of React Native associated with the React-Core prebuilt tarball.', - }) - .option('p', { - alias: 'podsRoot', - description: 'The path to the Pods root folder', - }) - .usage('Usage: $0 -c Debug -r -p ').argv; - -// $FlowFixMe[prop-missing] -const configuration = argv.configuration; -// $FlowFixMe[prop-missing] -const version = argv.reactNativeVersion; -// $FlowFixMe[prop-missing] -const podsRoot = argv.podsRoot; - -main(configuration, version, podsRoot); +if (require.main === module) { + // This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok + const argv = yargs + .option('c', { + alias: 'configuration', + description: + 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".', + }) + .option('r', { + alias: 'reactNativeVersion', + description: + 'The Version of React Native associated with the React-Core prebuilt tarball.', + }) + .option('p', { + alias: 'podsRoot', + description: 'The path to the Pods root folder', + }) + .usage('Usage: $0 -c Debug -r -p ').argv; + + // $FlowFixMe[prop-missing] + const configuration = argv.configuration; + // $FlowFixMe[prop-missing] + const version = argv.reactNativeVersion; + // $FlowFixMe[prop-missing] + const podsRoot = argv.podsRoot; + + main(configuration, version, podsRoot); +} + +module.exports = {replaceRNCoreConfiguration}; 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..0ad73872420f --- /dev/null +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -0,0 +1,1283 @@ +/** + * 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. Pinned into + * .spm-injected.json and reused by later runs + * until a new one is passed (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. + * [advanced] --config-command Override the autolinking config command. + * + * 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, + parseConfigCommandJson, + readEnvConfigCommand, + resolveEnvConfigCommand, +} = require('./spm/generate-spm-autolinking-config'); +const {main: generatePackage} = require('./spm/generate-spm-package'); +const {findSourcePath} = require('./spm/generate-spm-package'); +const { + SPM_INJECTED_MARKER, + cleanupDanglingJavaScriptCoreRef, + cleanupLeftoverPodsGroup, + findInjectedXcodeproj, + injectSpmIntoExistingXcodeproj, + readArtifactsVersionOverride, + readPinnedConfigCommand, + 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). Sticks: later runs reuse it until you pass a new one. 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', + }) + .option('config-command', { + type: 'string', + describe: + '[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Either way `add`/`update` remembers the value in .spm-injected.json, so later runs and Xcode builds reuse it. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'', + }) + .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'], + configCommand: + parsed['config-command'] != null + ? parseConfigCommandJson(parsed['config-command'], '--config-command') + : null, + 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; +} + +// Explicit `--version` → the version an earlier `--version` pinned into the +// injection marker → node_modules/react-native/package.json. The pin makes +// `--version` stick for later flagless runs, which would otherwise re-point the +// project at a different artifact slot than the one it was wired to. +function determineVersion( + args /*: SetupArgs */, + reactNativeRoot /*: string */, + appRoot /*: string */, +) /*: string */ { + if (args.version != null) { + return args.version; + } + const pinned = readArtifactsVersionOverride(appRoot); + if (pinned != null) { + log( + `Using version ${pinned} pinned in ${SPM_INJECTED_MARKER} by an earlier ` + + '--version. Pass --version to change it.', + ); + return pinned; + } + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkgJson /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + ); + return pkgJson.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 = determineVersion(args, reactNativeRoot, appRoot); + 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, + configCommand: resolveConfigCommandToPin(args), + }); + 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`'); +} + +// The autolinking config command for this run: an explicit `--config-command` +// first, then the value a previous `add`/`update` pinned into the injection +// marker. undefined means "no explicit command", which is what makes +// generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and +// then to the built-in default — so the pin has to be WITHHELD while the env +// var is set, or a stale pin would outrank a developer's env override. +function resolveExplicitConfigCommand( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: Array | void */ { + if (args.configCommand != null) { + return args.configCommand; + } + if (readEnvConfigCommand() != null) { + return undefined; + } + const pinned = readPinnedConfigCommand(appRoot); + if (pinned == null) { + return undefined; + } + log( + `Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` + + pinned.join(' '), + ); + return pinned; +} + +// The command to record in the injection marker. The env var is resolved here +// too, because the Xcode build phase inherits neither the flag nor the shell +// that set it — an env-only override that went unpinned would leave the build +// re-deriving autolinking.json with the default command. null pins nothing and +// preserves any earlier pin. An invalid env value throws, as the flag does, +// though `add` has already failed closed on it by this point. +function resolveConfigCommandToPin( + args /*: SetupArgs */, +) /*: ?Array */ { + return args.configCommand ?? resolveEnvConfigCommand(); +} + +// Generate autolinking.json, failing closed on a config-command error. +// +// generateAutolinkingConfig throws ONLY when the config command itself fails — +// a non-zero exit, unparseable output, or a config missing +// project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed +// and emit an empty Autolinked package, which only surfaced much later as an +// inscrutable `unable to resolve module dependency` at build time. Instead we +// set process.exitCode = 2 (a hard Xcode build-phase error, matching the +// RemoteVersionError path) and return null so the caller stops. +// +// A genuinely native-module-free app does NOT reach the error path: its command +// exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig +// returns normally and the empty-package path downstream stays valid. +function generateAutolinkingConfigOrFailClosed( + opts /*: { + projectRoot: string, + configCommand?: Array, + generate?: typeof generateAutolinkingConfig, + } */, +) /*: ?AutolinkingConfigResult */ { + const generate = opts.generate ?? generateAutolinkingConfig; + try { + return generate({ + projectRoot: opts.projectRoot, + configCommand: opts.configCommand, + }); + } catch (e) { + logError( + `Failed to generate autolinking.json: ${e.message}\n` + + 'The autolinking config command failed. If this app replaces ' + + '@react-native-community/cli autolinking (e.g. an Expo app), set ' + + 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' + + 'JSON argv array whose command prints the React Native CLI config, ' + + 'e.g. \'["npx","expo-modules-autolinking","react-native-config",' + + '"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' + + `have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` + + '--config-command to replace a stale one.', + ); + process.exitCode = 2; + return null; + } +} + +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)...'); + autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({ + projectRoot, + configCommand: resolveExplicitConfigCommand(args, appRoot), + }); + if (autolinkingConfigResult == null) { + // Fail closed: the config command errored and the helper already set + // process.exitCode = 2. Stop rather than emit an empty Autolinked package. + return; + } + log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`); + } + const reactNativeRoot = resolveReactNativeRoot( + autolinkingConfigResult, + projectRoot, + ); + const version = determineVersion(args, reactNativeRoot, appRoot); + 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, + determineVersion, + findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, + parseArgs, + resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, + shouldAutoDeintegrate, + ensureBothArtifactFlavors, +}; diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md new file mode 100644 index 000000000000..caeb710be67a --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -0,0 +1,92 @@ +# SwiftPM (Apple platforms) — Preview + +[🏠 Home](../../../../../__docs__/README.md) + +> **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. CocoaPods remains the supported +> default. + +The scripts in `scripts/spm/` let a React Native iOS app consume React Native +through **Swift Package Manager** instead of CocoaPods, using prebuilt +XCFrameworks. Support is opt-in and additive: `npx react-native spm` injects +package references into the app's existing `.xcodeproj` in place, and `deinit` +reverses exactly what it injected. + +The motivation, staged migration plan, and open questions live in +[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md). +The documents here describe how the implementation actually works. + +## 🚀 Usage + +```bash +cd ios +npx react-native spm # add on first run, update thereafter +``` + +**If any autolinked dependency ships no `Package.swift`, this stops with +`error: Package.swift is missing for library ""` and exit code 2.** That +is deliberate — `add` and `update` never scaffold silently, so a missing +manifest is visible and fixed on purpose. Generate the manifests first, then +re-run setup: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +npx react-native spm # then inject as usual +``` + +Because `node_modules` isn't committed, persist each scaffolded manifest with +`npx patch-package ` and commit the patch — otherwise the same error +returns on every fresh install and in CI. Better still, contribute the manifest +upstream. See +[Community packages without a Package.swift](./spm-scripts.md#community-packages-without-a-packageswift). + +See **[spm-scripts.md](./spm-scripts.md)** for the CLI actions and flags, +CocoaPods migration, brownfield apps, what to commit, fresh clones and CI, and +troubleshooting. + +## 📐 Design + +Three documents cover the design, each owning one area: + +| Document | Covers | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [spm-scripts.md](./spm-scripts.md) | The tool itself: CLI surface, the six-step pipeline, [every file it creates or modifies](./spm-scripts.md#files-the-tool-touches), the two auto-sync hooks, and how Debug/Release flavor selection works. | +| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no header search paths and no `unsafeFlags` in any generated manifest. Also covers remote mode. | +| [spm-autolinking-plugins.md](./spm-autolinking-plugins.md) | The extension seam for frameworks with their own module system (Expo is the first consumer): discovery, the full context/return contract including `flavoredFrameworks`, `watchPaths` and `scriptPhases`, and failure behavior. | + +Two ideas explain most of the architecture: + +- **Headers go through SwiftPM; runtime binaries do not.** A `binaryTarget` + cannot vary by build configuration, but React Native ships flavored binaries + (a debug `React.framework` carries the dev menu and assertions; release strips + them). So the package graph vends headers only, and the flavored frameworks + are linked and embedded through generated Xcode build settings instead. +- **Generated state is regenerable, and the injection is reversible.** + Everything under `build/` is gitignored and rebuilt from the app's + `package.json`; everything written into the `.xcodeproj` is recorded in a + `.spm-injected.json` marker so `deinit` can undo precisely that. + +## 🔗 Relationship with other systems + +### Part of + +- iOS build system — the alternative to the CocoaPods integration in + [`scripts/cocoapods/`](../../cocoapods). + +### Used by this + +- **Prebuilt XCFrameworks** from [`scripts/ios-prebuild/`](../../ios-prebuild) — + produces the `React`, `ReactNativeDependencies`, `hermes-engine`, and + headers-only artifacts that these scripts download, stage, and link. +- **Codegen** (`generate-codegen-artifacts.js`) — its output is installed as a + local `React-GeneratedCode` package rather than a Pod. +- **`@react-native-community/cli config`** — supplies the autolinking metadata + (`autolinking.json`) that the SwiftPM autolinker turns into a `Package.swift`. + Overridable via `--configCommand`. + +### Uses this + +- Apps opting into SwiftPM, via the `spm` React Native CLI command. +- Frameworks layering their own module system on top of React Native, via + [autolinking plugins](./spm-autolinking-plugins.md). diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md new file mode 100644 index 000000000000..3a23c378f91e --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -0,0 +1,359 @@ +# 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 hooks](./spm-scripts.md#auto-sync) + re-run 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', + ], + scriptPhases: [ + // Build-time shell phases on the app target — SwiftPM's missing + // `script_phase`: + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: '"$NODE_BINARY" .../createExpoConfig.js', + position: 'beforeCompile', // default: 'end' + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/EXConstants.bundle/app.config'], + alwaysOutOfDate: true, + }, + ], + }; +}; +``` + +### `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 hooks watch to decide whether they 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. + +### `scriptPhases` — build-time shell phases on the app target + +SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must +run a script during the app's build — `expo-constants` writing +`EXConstants.bundle/app.config` is the first consumer — declares it here. Each +entry is recorded to `/.spm-plugin-script-phases.json`, which +`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry +on the injected app target: + +| Key | Meaning | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | **Stable key** — the ledger entry and the deterministic UUID seed. Charset `/^[@A-Za-z0-9_./-]+$/`, so a scoped npm name like `@expo/log-box` is a valid id; a `:` is not, because the id is hashed into the UUID seed as `plugin:` and the separator must stay unambiguous. Renaming it is a _remove + add_, not a rename. | +| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. | +| `script` | The shell body. | +| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. | +| `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. | +| `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. | + +**Placement.** `'end'` appends at the true end of the target's `buildPhases` — +after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the +"Sync SPM Autolinking" phase, which stays first because it regenerates the +content everything else reads, and always **before Sources**: React Native never +re-seats its own sync phase, so if you have dragged that below Sources your +`beforeCompile` phases are seated ahead of Sources instead of following it. +Phases sharing a position keep their declared order. + +**Position is enforced on every sync.** `add`/`update` compares where the plugin +phases actually sit in `buildPhases` against the declared placement and, **only +when the two differ**, lifts their membership lines and re-seats them in +declared order. So changing `position` — or swapping two phases that share one — +takes effect on the next `spm add`/`update`, with no remove + re-add. When they +agree nothing is rewritten, which is what keeps an unchanged declaration +re-syncing to a byte-identical project. The consequence worth knowing: a phase +you **drag somewhere else in Xcode is moved back** to its declared position on +the next sync, because the plugin's declaration is the source of truth. Only the +`id` behaves differently — it is a key, not a label, so renaming it is a remove + +- add. + +Phases are injected by `spm add` / `spm update` **only**. The build-time `sync` +rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared +phase appears on the next `add`/`update`, not on the next build. Each phase's +UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's +`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path +lists, `alwaysOutOfDate` and placement in place, `update` removes phases that +left the sidecar, and `deinit` reverts all of them. + +Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a +non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one +plugin or across plugins) aborts the run. A silently dropped phase would produce +a green build whose generated content was never written — a runtime failure with +no build-time signal — and two phases sharing an `id` would collapse onto one +ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even +though the charset admits them: as keys of that ledger they never become own +properties, so the phase would look recorded, disappear when the marker is +serialized, and be unremovable by `deinit`. + +**Hostile names.** A `name` reaches the project file twice. In the `name` field +— what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any +single-line string is expressible. Beside the phase's UUID, on the object's +definition line and on its `buildPhases` member line, it also becomes a +`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the +`name` field) but the text around them is scanned by delimiter, so the name is +**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single +spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` — +normalized the same way — and then to no comment at all if nothing survives +either. Without that, a `{` in a comment would make the injector read the next +object's body as this one's, and a `,` would make `deinit` delete the wrong line +— corruption with no error. Only a line break is therefore rejected outright; a +name is a display name, and no Xcode phase name spans lines. + +The injector's read of the sidecar is deliberately lenient — the file does not +exist yet on a first `spm add`, and a stale or hand-edited copy must not break +injection. An absent file yields no phases silently, an unparseable one warns, +and a single entry failing the same checks (bad or reserved `id`, empty or +multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is +**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it +enforces exactly the rules the plugin contract does. + +**Gating is the script's job.** The phase runs for every configuration and +platform the target builds; if it should be a no-op for some of them (Release +only, simulator only, …), the script must check `$CONFIGURATION` / +`$PLATFORM_NAME` and exit early. + +### 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. | +| `scriptPhases` | Recorded for `spm add` / `update` to emit one `PBXShellScriptBuildPhase` per entry on the app target. Malformed entries and duplicate `id`s 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 + +```text +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). +- **Implemented & tested:** `scriptPhases`, contract through injection. + `invokePlugins` validates every entry fatally (`id` charset plus the reserved + `__proto__`/`constructor`/`prototype` names, a single-line `name`, a required + `script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus + duplicate `id`s), and the merge always rewrites + `.spm-plugin-script-phases.json` — `[]` when no plugin declares any, so + removing a plugin clears stale entries. The `spm add`/`update` xcodeproj + injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry + on the app target at the requested position, recording the id→UUID map in the + `.spm-injected.json` marker so a re-run refreshes each phase's content in + place, re-seats it when its declared position or order changed, `update` + removes phases that left the sidecar, and `deinit` reverts them. Like + `flavoredFrameworks`, the build-time `sync` only rewrites the sidecar; it + never mutates the project. +- **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/__docs__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md new file mode 100644 index 000000000000..ff467c249b92 --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -0,0 +1,100 @@ +# 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/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md new file mode 100644 index 000000000000..af1c6e8a463c --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -0,0 +1,646 @@ +# 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 project carries **auto-sync hooks** that detect +dependency changes and re-run autolinking before compilation (see +[Auto-Sync](#auto-sync)) — 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 inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Three things it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `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 hooks. 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. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | +| `--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 | +| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | + +### The autolinking config command is remembered + +An app that replaces `@react-native-community/cli` autolinking (an Expo app, for +example) has to tell `spm` how to produce `autolinking.json`. Pass the command +once, on `add` or `update`: + +```bash +npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +``` + +Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and +the build-time `sync` — resolves the command in this order: + +1. `--configCommand` +2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` +3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an + earlier `add`/`update` +4. the default `@react-native-community/cli config` + +`add`/`update` pin whichever of the first two routes supplied the command, +validated as an argv array; a later run that passes neither keeps the existing +pin, and passing `--configCommand` again replaces it. The pin exists because the +**Sync SPM Autolinking** build phase inherits neither your flag nor the shell +that exported the env var — without it, a successful `add` is followed by +failing builds, because the phase re-derives `autolinking.json` with the default +command. A pin never shadows the env var, so an override in your shell still +takes effect, and a pin that no longer parses is ignored in favor of the +default. + +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` +therefore falls back to the default command unless you pass `--configCommand` +(or export the env var) again. + +### Pinning the React Native version + +The resolved version selects **which artifact slots the project is wired to**, +so it has to stay the same from one run to the next. `--version` is therefore +recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and +read back by later runs, which resolve the version in this order: + +1. an explicit `--version `, +2. the version a previous `--version` pinned into the marker, +3. `node_modules/react-native/package.json`. + +So you pass the flag once, and a later flagless `add`/`update` stays on the +slots it selected. Without the pin, that flagless run falls back to +`package.json` and re-points the project at different artifact slots while the +marker still advertises the pinned version. + +`deinit` deletes the marker, and with it the pin — a later `add` resolves +`node_modules/react-native/package.json` again unless you pass `--version`. + +### Debug/Release flavor is automatic + +React Native ships **flavored** prebuilt binaries: the _debug_ `React.framework` +(and `hermes-engine` / `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. + +Those same debug-flavored configurations also get +`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"` — the only thing +that makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches +C/ObjC/C++ only), and what `AppDelegate.swift`'s `bundleURL()` branches on to +load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at +`pod install` time, so this keeps SwiftPM apps at parity. An existing value is +left alone. + +## Files the tool touches + +Paths are relative to the Xcode project directory (`ios/`) unless noted. + +### In your repo — committed + +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — the scheme is deleted if `add` created it, otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | + +The two pinned settings are the `--version` pin (`artifactsVersionOverride`, see +[Pinning the React Native version](#pinning-the-react-native-version)) and the +[autolinking config command](#the-autolinking-config-command-is-remembered). +Because `deinit` drops the marker, it drops both. + +### In your repo — generated, gitignored + +| Path | Written by | Contents | +| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | +| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. | +| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | +| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | + +`deinit` leaves all of the above in place — it is regenerable, and removing it +is `rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). + +### Outside your repo + +| Path | Written by | Notes | +| ---------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | +| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `sync`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | +| `~/Library/Caches/ReactNative/` | `download` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | + +Injection is **purely additive** and **idempotent**: every other byte of your +project — signing, capabilities, your own Build Phases — stays untouched, and a +re-run is a no-op. The injected refs point at three stable sub-package paths +under `build/`, so adding or removing community deps changes the sub-package +contents (gitignored) and never re-injects. `deinit` removes exactly what was +injected, leaving the project byte-identical to its pre-`add` state — with the +exceptions called out above, and one more described next. + +**Build settings that already exist** are edited in place. The four array +settings `add` merges into — `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, +`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS` — keep the shape they were +written in: Xcode's multi-line form as well as the compact one-line form hand +edits and other generators (XcodeGen, Tuist) emit. One that exists as a plain +_scalar_ is promoted to a `( … )` array — the shape an Xcode-authored target can +carry, e.g. a +`LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` written +as a scalar rather than a list. `add` records the pre-injection value in the +marker and `deinit` restores it by rewriting the whole field — once folded +together, the injected members and your own are indistinguishable — so **members +you add to a promoted array by hand afterwards are lost**. That applies to +`update` too, which reverts to the recorded baseline before re-injecting. + +## Fresh clones & CI + +Everything under `build/` is gitignored, so a clean checkout has no resolvable +Swift packages until they are regenerated. Xcode resolves the package graph +before build phases **and** before scheme pre-actions, so neither +[auto-sync hook](#auto-sync) can rescue this: with `build/generated/autolinking` +missing, the build stops at _"Resolve Package Graph … doesn't exist"_ having run +neither hook. + +Verified on Xcode 26.6 against a freshly-injected app with `build/` deleted: +`xcodebuild -scheme … build` fails in nine lines of log, with +`Resolve Package Graph` as the first step and no trace of the pre-action; +`xcodebuild -resolvePackageDependencies` fails identically. Opening the project +in Xcode also resolves the graph on load, before you press Build. + +So 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 +hooks. + +**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 + }, + ], + }, +}; +``` + +Each entry becomes a target in `build/generated/autolinking/Package.swift`. +Sources outside `build/generated/autolinking/` are automatically mirrored with +file-level symlinks. + +## Dependencies between libraries + +SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs +another native library declares it explicitly with `spm.dependencies` in its +**own** `react-native.config.js` — a list of npm names: + +```js +// react-native-reanimated/react-native.config.js +module.exports = { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['react-native-worklets']}, +}; +``` + +The autolinker starts from the directly-autolinked deps, follows each one's +`spm.dependencies` **recursively**, and dedupes the result, so a transitive +dependency is pulled into the package graph even when the app never depends on +it directly. Declared names are mapped to Swift target names, so the dependent +library's target can import it. + +This is a **library-author** surface, like the podspec dependency it replaces — +apps don't normally set it. + +### Config module format + +`react-native.config.js` may be CommonJS or ESM, and both named and default +exports are read. A key defined twice — as a named export and on the default +export — resolves to the named one. Avoid that shape anyway: the Community CLI +has two loaders that disagree about it, a sync one that sees named exports and +an async one that takes only the default export. For maximum compatibility, +prefer the one-line CommonJS form: + +```js +module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}}; +``` + +If the config fails to load, a warning names the file and the reason — the `spm` +settings in it are ignored rather than silently applied. + +## 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`**, `spm add`/`update` stops +with a per-dep error (`Package.swift is missing for library ""`) and exits +**2** — a distinct code from a generic failure, so CI and the Xcode sync hooks +can treat it as a hard error while staying lenient about transient sync +failures. + +`add` and `update` deliberately **never** scaffold on your behalf: +auto-scaffolding would hide a real gap in the dependency's SPM support. Generate +the manifest from the library's podspec explicitly, then re-run setup: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +npx react-native spm # then inject/update as usual +``` + +(`scaffold` also runs codegen and regenerates the autolinking package, but it +does not inject into the `.xcodeproj` — so on a first-time setup you still +follow it with `npx react-native spm`.) + +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). | +| `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | +| 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 | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| 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 + +```text +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 + +Autolinking is kept up to date without manual re-runs of `react-native spm` by +**two hooks running the same sync script**, injected by `add`/`update`: + +| Hook | Where | Role | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Scheme pre-action | The app's **shared** scheme (`xcshareddata/xcschemes/`), under `BuildAction` → `PreActions` | Fires earlier in the build than a build phase can, so it is the one that normally does the re-sync. | +| `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | + +Neither hook can bootstrap a clean checkout. Xcode resolves the Swift package +graph before build phases **and** before scheme pre-actions, so if the generated +packages are missing entirely, resolution fails and the build stops before +either hook runs — see [Fresh clones & CI](#fresh-clones--ci). The hooks keep an +_existing_ set of generated packages current; they do not create the first one. + +**How the sync script 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 + - every path in `.spm-sync-watch-paths` — RN's own inputs plus any + [plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs) + `watchPaths`; a watched file that is newer, a watched dir with a newer + child, or a watched path that has **vanished** all mark 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). + +**Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: + +| Step | Owner | +| ----------------------------------------------- | ----------------- | +| Resolve Package Graph | Xcode | +| **Sync SPM Autolinking** | scheme pre-action | +| Prepare packages / ComputeTargetDependencyGraph | Xcode | +| CreateBuildDescription | Xcode | +| **Sync SPM Autolinking** (safety net) | build phase 1 | +| Sources (compile) | build phase 2 | +| Frameworks (link) | build phase 3 | +| Embed React Native Flavored Frameworks | build phase 4 | +| Resources (copy) | build phase 5 | +| Build JS Bundle | build phase 6 | + +Resolution coming first is what makes the one-time setup run necessary on a +clean checkout; it is not something either hook can work around. + +A sync failure is lenient by default but **not unconditionally**. The generated +script branches on the exit code: + +- **Exit 2** — an autolinked dependency ships no `Package.swift`. This **fails + the build** (`exit 1`), deliberately: the autolinker has already printed an + `error:` line per dep, and the fix needs a terminal (see + [Community packages without a Package.swift](#community-packages-without-a-packageswift)). +- **Any other non-zero exit** — emits + `warning: SPM sync failed — build may use stale codegen/autolinking` and lets + the build continue, so an already-generated package graph can still produce a + successful build. + +That split is the whole reason the missing-manifest case has its own exit code: +a transient sync hiccup should not break a build that could still succeed, while +a genuinely missing manifest should not pass silently. 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..5671fbeb7c1e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js @@ -0,0 +1,543 @@ +/** + * 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); + }); + + it('merges scriptPhases across plugins, preserving optional fields', () => { + const res = invokePlugins( + [ + mk('expo-constants', () => ({ + scriptPhases: [ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + ], + })), + mk('b', () => ({ + scriptPhases: [{id: 'b.stamp', name: 'Stamp', script: 'echo hi'}], + })), + ], + ctx, + ); + expect(res.scriptPhases).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + // A package's own npm name is the obvious stable id, and the scoped form is + // the common one (`@expo/log-box` is a named consumer). + it.each([['@expo/log-box'], ['@expo/ui']])( + 'accepts the scoped npm name %s as a scriptPhase id', + id => { + const res = invokePlugins( + [mk('expo', () => ({scriptPhases: [{id, name: 'X', script: 'echo'}]}))], + ctx, + ); + expect(res.scriptPhases).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it('defaults scriptPhases to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.scriptPhases).toEqual([]); + }); + + it('rejects a non-array scriptPhases declaration', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({scriptPhases: {id: 'x', name: 'X', script: 'y'}}))], + ctx, + ), + ).toThrow(/non-array scriptPhases/); + }); + + it.each([ + ['missing id', {name: 'X', script: 'echo'}], + ['empty id', {id: '', name: 'X', script: 'echo'}], + ['non-string id', {id: 7, name: 'X', script: 'echo'}], + ['an id containing a space', {id: 'a b', name: 'X', script: 'echo'}], + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id containing a colon', {id: 'expo:phase', name: 'X', script: 'echo'}], + ['missing name', {id: 'a', script: 'echo'}], + ['empty name', {id: 'a', name: '', script: 'echo'}], + ['missing script', {id: 'a', name: 'X'}], + ['empty script', {id: 'a', name: 'X', script: ''}], + [ + 'unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + [ + 'non-array inputPaths', + {id: 'a', name: 'X', script: 'echo', inputPaths: '/in'}, + ], + [ + 'non-string inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: [7]}, + ], + [ + 'empty inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: ['']}, + ], + [ + 'non-array outputPaths', + {id: 'a', name: 'X', script: 'echo', outputPaths: '/out'}, + ], + [ + 'non-string outputPaths entry', + {id: 'a', name: 'X', script: 'echo', outputPaths: [null]}, + ], + [ + 'non-boolean alwaysOutOfDate', + {id: 'a', name: 'X', script: 'echo', alwaysOutOfDate: 'yes'}, + ], + ['null entry', null], + ['a number instead of an entry', 42], + ['a string instead of an entry', 'echo'], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + ])('rejects a scriptPhase with %s', (_label, entry) => { + expect(() => + invokePlugins([mk('expo', () => ({scriptPhases: [entry]}))], ctx), + ).toThrow(/invalid scriptPhase/); + }); + + it('names the offending id in the invalid-scriptPhase error', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'ok.phase', name: 'Fine', script: 'echo'}, + {id: 'bad:phase', name: 'Bad', script: 'echo'}, + ], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase 'bad:phase'/); + }); + + it('rejects a duplicate scriptPhase id within a single plugin', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'dup.phase', name: 'One', script: 'echo one'}, + {id: 'dup.phase', name: 'Two', script: 'echo two'}, + ], + })), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + it('rejects a duplicate scriptPhase id across plugins, naming it', () => { + const phase = () => ({id: 'dup.phase', name: 'Dup', script: 'echo'}); + expect(() => + invokePlugins( + [ + mk('a', () => ({scriptPhases: [phase()]})), + mk('b', () => ({scriptPhases: [phase()]})), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + // A line break is the one thing an Xcode phase display name can never carry. + // Every other hostile character is safe by construction: the injector + // normalizes the name for the `/* … */` comments and escapes it in the `name` + // field, so nothing structural reaches the project text. + it.each([ + ['a newline', 'Line one\nLine two'], + ['a carriage return', 'Line one\rLine two'], + ])('rejects a scriptPhase name containing %s', (_label, name) => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase name for 'expo\.phase'/); + }); + + it.each([ + ['pbxproj quoting', 'Bundle "app.config"'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* opener'], + ])('keeps a scriptPhase name needing %s verbatim', (_label, name) => { + const res = invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ); + expect(res.scriptPhases[0].name).toBe(name); + }); +}); 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..f3caadf5e0b3 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -0,0 +1,893 @@ +/** + * 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, + resolveLocalHermesCompilerVersion, + 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). +// --------------------------------------------------------------------------- + +// Creates a scratch dir with (optionally) a `node_modules/hermes-compiler` +// package inside it, mimicking a real project root for +// resolveLocalHermesCompilerVersion()'s require.resolve({paths: [rnRoot]}). +function makeFakeRnRoot(hermesCompilerVersion /*: ?string */) /*: string */ { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-root-')); + if (hermesCompilerVersion != null) { + const pkgDir = path.join(root, 'node_modules', 'hermes-compiler'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({name: 'hermes-compiler', version: hermesCompilerVersion}), + ); + } + return root; +} + +// Forces resolveLocalHermesCompilerVersion() to behave as if hermes-compiler +// isn't installed, regardless of any workspace-hoisted hermes-compiler the host +// environment provides. Jest's require.resolve ignores the {paths: [rnRoot]} +// scoping and still finds the hoisted package, so stubbing module resolution is +// ineffective here; instead we stub fs.readFileSync to throw MODULE_NOT_FOUND +// for the resolved hermes-compiler/package.json — the exact signal a genuine +// resolution miss produces — which drives the function down its "not installed" +// branch. Reads of any other file fall through to the real implementation. +// Undo with jest.restoreAllMocks(). +function mockHermesCompilerUnresolvable() { + const realReadFileSync = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { + if (String(file).includes(`${path.sep}hermes-compiler${path.sep}`)) { + const error = new Error( + "Cannot find module 'hermes-compiler/package.json'", + ); + error.code = 'MODULE_NOT_FOUND'; + throw error; + } + return realReadFileSync.call(fs, file, ...rest); + }); +} + +describe('resolveLocalHermesCompilerVersion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reads the version from the locally installed hermes-compiler package', () => { + const root = makeFakeRnRoot('0.13.7'); + expect(resolveLocalHermesCompilerVersion(root)).toBe('0.13.7'); + }); + + it('returns null when no hermes-compiler is resolvable from the given root', () => { + // Resolve from a real, isolated scratch root that has no hermes-compiler + // installed (same setup as the fallback test below) so require.resolve + // stays scoped to that root and misses, rather than falling back to + // whatever hermes-compiler the host environment happens to hoist. The + // function must report absence rather than fabricating a version. + const root = makeFakeRnRoot(null); + mockHermesCompilerUnresolvable(); + expect(resolveLocalHermesCompilerVersion(root)).toBeNull(); + }); +}); + +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; + } + jest.restoreAllMocks(); + }); + + // 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('uses the locally pinned hermes-compiler version, without hitting npm', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); + mockFetch({ + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + rnRoot, + ); + expect(result.version).toBe('0.13.7'); + expect(result.url).toContain('/0.13.7/'); + // Must resolve straight from node_modules — no npm registry round trip. + expect(globalThis.fetch).not.toHaveBeenCalledWith( + expect.stringContaining('registry.npmjs.org'), + expect.anything(), + ); + }); + + it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); + mockFetch({ + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, + }); + // Caller passes the original RN --version verbatim; hermes should + // still use the locally pinned version instead of using this. + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + '0.87.0-nightly-20260519-58cd1bf58', + rnRoot, + ); + expect(result.version).toBe('0.13.7'); + expect(result.url).not.toContain('20260519'); + }); + + it('falls back to the latest-v1 npm dist-tag when hermes-compiler is not locally installed', async () => { + const rnRoot = makeFakeRnRoot(null); + // Stub require.resolve to fail so the local lookup is guaranteed to miss, + // even in environments that hoist a workspace hermes-compiler. The + // resolver must then fall through to the latest-v1 dist-tag (0.13.0). + mockHermesCompilerUnresolvable(); + 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-20260519-58cd1bf58', + 'debug', + null, + rnRoot, + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).toContain('/0.13.0/'); + // Confirm the dist-tag lookup actually ran — the fallback path, not a + // locally pinned version, produced this result. + const hitLatestV1 = globalThis.fetch.mock.calls.some(([url]) => + String(url).includes('hermes-compiler/latest-v1'), + ); + expect(hitLatestV1).toBe(true); + }); + }); + + describe('HERMES_VERSION escape hatches', () => { + it('HERMES_VERSION= uses it verbatim, even with a local package installed', async () => { + process.env.HERMES_VERSION = '0.13.5'; + const rnRoot = makeFakeRnRoot('0.13.7'); + mockFetch({ + 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + rnRoot, + ); + expect(result.version).toBe('0.13.5'); + expect(result.url).toContain('/0.13.5/'); + }); + + it('HERMES_VERSION=latest-v1 resolves via npm dist-tag, even with a local package installed', async () => { + process.env.HERMES_VERSION = 'latest-v1'; + const rnRoot = makeFakeRnRoot('0.13.7'); + 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, + rnRoot, + ); + expect(result.version).toBe('0.13.0'); + }); + + it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => { + process.env.HERMES_VERSION = 'nightly'; + const rnRoot = makeFakeRnRoot(null); + 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, + rnRoot, + ); + 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'; + const rnRoot = makeFakeRnRoot(null); + 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, + rnRoot, + ); + 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..1e4848930ca1 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -0,0 +1,959 @@ +/** + * 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 { + SpmNameCollisionError, + defaultReadConfig, + expandSpmDependencies, + isValidSwiftName, + resolveSwiftName, +} = require('../expand-spm-dependencies'); +const { + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, + toSwiftName, +} = require('../spm-utils'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +function makeReadConfig(configs /*: {[string]: ?Object} */) { + return (root /*: string */) => + Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; +} + +// The reserved map its caller builds; these cases only exercise `spm.name`. +const NONE = new Map(); + +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 SpmNameCollisionError on a dep-vs-dep collision too', () => { + 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(SpmNameCollisionError); + }); + + 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)', () => { + const resolve = name => () => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('foo bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo/bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo.bar')).toThrow(/invalid 'spm.name'/); + }); + + it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + const resolve = name => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('reanimated')).toBe('reanimated'); + expect(resolve('hermes-engine')).toBe('hermes-engine'); + expect(resolve('RNWorklets')).toBe('RNWorklets'); + expect(resolve('react_native_foo')).toBe('react_native_foo'); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation: a derived name that lands on one React Native reserves. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('prepends the scope when the derived name is reserved', () => { + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + ); + expect(dep.swiftName).toBe('PowersyncReactNative'); + }); + + it('logs one line naming the package, the reserved name and the name it got', () => { + const log = jest.fn(); + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {log}, + ); + expect(log).toHaveBeenCalledTimes(1); + const [line] = log.mock.calls[0]; + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + expect(line).toContain("'PowersyncReactNative'"); + }); + + it('says nothing when no disambiguation happens', () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/common', root: '/c', platforms: {ios: {}}}], + {'/c': {}}, + {log}, + ); + expect(dep.swiftName).toBe('Common'); + expect(log).not.toHaveBeenCalled(); + }); + + it('title-cases a hyphenated scope', () => { + const [dep] = expand( + [{name: '@my-org/react-native', root: '/o', platforms: {ios: {}}}], + {'/o': {}}, + ); + expect(dep.swiftName).toBe('MyOrgReactNative'); + }); + + it('disambiguates a name that matches a reserved one only in case', () => { + // toSwiftName('@scope/reactcodegen') === 'Reactcodegen' — distinct from + // 'ReactCodegen' as a string, the same directory on a case-insensitive + // filesystem. + const [dep] = expand( + [{name: '@scope/reactcodegen', root: '/s', platforms: {ios: {}}}], + {'/s': {}}, + ); + expect(dep.swiftName).toBe('ScopeReactcodegen'); + }); + + it('disambiguates a transitive dep too', () => { + const result = expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['@scope/react-native']}}, + '/s': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@scope/react-native': '/s'}), + }, + ); + expect(result.map(d => d.swiftName)).toEqual(['Top', 'ScopeReactNative']); + }); + + it('disambiguates against a caller-supplied reserved name (remote identity)', () => { + const [dep] = expand( + [{name: '@acme/my-fork', root: '/f', platforms: {ios: {}}}], + {'/f': {}}, + {extraReservedNames: ['MyFork']}, + ); + expect(dep.swiftName).toBe('AcmeMyFork'); + }); + + it("leaves an explicit 'spm.name' alone on a package that would have collided", () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {spm: {name: 'PowerSync'}}}, + {log}, + ); + expect(dep.swiftName).toBe('PowerSync'); + expect(log).not.toHaveBeenCalled(); + }); + + it('throws when the disambiguated name is reserved as well', () => { + const run = () => + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {extraReservedNames: ['PowersyncReactNative']}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('gives two scoped packages that would take the same reserved name distinct names', () => { + const result = expand( + [ + {name: '@a/react-native', root: '/a', platforms: {ios: {}}}, + {name: '@b/react-native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual([ + 'AReactNative', + 'BReactNative', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation across deps: two libraries deriving one name. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation across deps)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + const scoped = (name, root) => ({name, root, platforms: {ios: {}}}); + + it('pulls two scoped deps apart with their scopes', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); + + it('logs one line per rewritten dep, naming the shared name and the new one', () => { + const log = jest.fn(); + expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + { + '/a': {}, + '/b': {}, + }, + {log}, + ); + expect(log).toHaveBeenCalledTimes(2); + const lines = log.mock.calls.map(([line]) => line); + expect(lines[0]).toContain('@a/foo'); + expect(lines[0]).toContain("'Foo'"); + expect(lines[0]).toContain("'AFoo'"); + expect(lines[1]).toContain('@b/foo'); + expect(lines[1]).toContain("'BFoo'"); + }); + + it('leaves an unscoped member alone — it has no scope to borrow', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('foo', '/f')], { + '/a': {}, + '/f': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'Foo']); + }); + + it("leaves a member's explicit 'spm.name' alone and moves the others around it", () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + {'/a': {spm: {name: 'Foo'}}, '/b': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'BFoo']); + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain('@b/foo'); + }); + + it('groups case-insensitively, so a lowercase override still moves the others', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {spm: {name: 'foo'}}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['foo', 'BFoo']); + }); + + it('rewrites every scoped member of a three-way collision', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('@c/foo', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'CFoo']); + }); + + it('rewrites the scoped members of a three-way collision and keeps the unscoped one', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('foo', '/f')], + {'/a': {}, '/b': {}, '/f': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'Foo']); + }); + + it('throws when a borrowed scope lands on a third package instead of producing two of the same name', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const run = () => + expand( + [ + scoped('@a/foo', '/a'), + scoped('@b/foo', '/b'), + scoped('a-foo', '/af'), + ], + {'/a': {}, '/b': {}, '/af': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/both resolve to 'AFoo'/); + }); + + it('throws when a borrowed scope lands on a name React Native reserves', () => { + // Both derive 'Native'; the borrow takes '@react/native' to 'ReactNative'. + const run = () => + expand([scoped('@react/native', '/r'), scoped('@other/native', '/o')], { + '/r': {}, + '/o': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('still throws for two unscoped deps deriving the same name', () => { + const run = () => + expand( + [scoped('react-native-foo', '/a'), scoped('react_native_foo', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-native-foo' \('ReactNativeFoo'\) and 'react_native_foo' \('ReactNativeFoo'\) both resolve to 'ReactNativeFoo'\./, + ); + expect(run).toThrow(/Set a distinct 'spm\.name'/); + }); + + it('changes nothing, and says nothing, for a set with no collisions', () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/bar', '/b'), scoped('baz', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'Bar', 'Baz']); + expect(log).not.toHaveBeenCalled(); + }); + + it('borrows a second time when an already-borrowed name collides, and the incumbent keeps its name', () => { + // Both land on 'AReactNative': one by borrowing, one by derivation. + const result = expand( + [scoped('@a/react-native', '/a'), scoped('a-react-native', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual([ + 'AAReactNative', + 'AReactNative', + ]); + }); + + it('disambiguates a transitive dep against a direct one', () => { + const result = expandSpmDependencies([scoped('@a/foo', '/a')], { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['@b/foo']}}, + '/b': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@b/foo': '/b'}), + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); +}); + +// --------------------------------------------------------------------------- +// Reserved React Native names — the backstop for what a scope cannot resolve. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (reserved React Native names)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('throws when an unscoped dep auto-derives a reserved product name', () => { + const run = () => + expand([{name: 'react-headers', root: '/rh', platforms: {ios: {}}}], { + '/rh': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-headers' resolves to 'ReactHeaders', which React Native reserves/, + ); + expect(run).toThrow( + /Set a different 'spm\.name' in react-headers's react-native\.config\.js\./, + ); + }); + + it('throws when an explicit spm.name override lands on a reserved name', () => { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'ReactAppHeaders'}}, + }), + ).toThrow( + /'some-lib' resolves to 'ReactAppHeaders', which React Native reserves/, + ); + }); + + it('throws when a transitive dep lands on a reserved name', () => { + expect(() => + expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['react-native-headers']}}, + '/rnh': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'react-native-headers': '/rnh'}), + }, + ), + ).toThrow( + /'react-native-headers' resolves to 'ReactNativeHeaders', which React Native reserves/, + ); + }); + + it('reserves the caller-supplied extraReservedNames (remote package identity)', () => { + const direct = [{name: 'my-fork', root: '/f', platforms: {ios: {}}}]; + expect(() => + expand(direct, {'/f': {}}, {extraReservedNames: ['MyFork']}), + ).toThrow(/'my-fork' resolves to 'MyFork', which React Native reserves/); + }); + + it('accepts that same name when no extraReservedNames are supplied', () => { + const [dep] = expand( + [{name: 'my-fork', root: '/f', platforms: {ios: {}}}], + { + '/f': {}, + }, + ); + expect(dep.swiftName).toBe('MyFork'); + }); + + it('leaves a non-colliding dep untouched', () => { + const [dep] = expand( + [{name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}], + {'/w': {spm: {name: 'worklets'}}}, + {extraReservedNames: ['SomeRemoteIdentity']}, + ); + expect(dep.swiftName).toBe('worklets'); + }); + + it('reports the reserved-name diagnosis in preference to the dep-vs-dep one', () => { + // Both unscoped deps derive 'ReactNative', so neither can borrow a scope. + expect(() => + expand( + [ + {name: 'react-native', root: '/a', platforms: {ios: {}}}, + {name: 'react_native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ), + ).toThrow(/React Native reserves/); + }); + + it('rejects every name in RESERVED_SWIFT_NAMES', () => { + expect(RESERVED_SWIFT_NAMES.length).toBeGreaterThan(0); + for (const reserved of RESERVED_SWIFT_NAMES) { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: reserved}}, + }), + ).toThrow(/React Native reserves/); + } + }); + + it('rejects the autolinking aggregator package name', () => { + expect(() => + expand([{name: 'autolinked', root: '/a', platforms: {ios: {}}}], { + '/a': {}, + }), + ).toThrow( + /'autolinked' resolves to 'Autolinked', which React Native reserves/, + ); + }); + + it('accepts the React headers TARGET dir name — it is not a package or product, so nothing collides', () => { + const [dep] = expand( + [{name: 'some-lib', root: '/s', platforms: {ios: {}}}], + { + '/s': {spm: {name: REACT_HEADERS_TARGET_DIR}}, + }, + ); + expect(dep.swiftName).toBe(REACT_HEADERS_TARGET_DIR); + }); + + it('reports a case-only match against a reserved name, naming both spellings', () => { + const run = () => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'reactnative'}}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'some-lib' resolves to 'reactnative', which differs from React Native's reserved 'ReactNative' only in case/, + ); + expect(run).toThrow(/spm\.name/); + }); +}); + +// --------------------------------------------------------------------------- +// isValidSwiftName — the charset rule `spm.name` enforces. +// --------------------------------------------------------------------------- + +describe('isValidSwiftName', () => { + it.each(['worklets', 'ReactNativeFoo', 'hermes-engine', 'react_native_foo'])( + 'accepts %j', + name => { + expect(isValidSwiftName(name)).toBe(true); + }, + ); + + it.each(['', 'foo bar', 'foo/bar', 'foo.bar', '9lives', 42, null])( + 'rejects %j', + name => { + expect(isValidSwiftName(name)).toBe(false); + }, + ); +}); +// defaultReadConfig +// +// The community CLI's own loaders disagree — sync reads named exports, async +// reads the default one — so a config that sets only `export default` must not +// be invisible here. Fixtures are transpiled by babel, so they present the +// `__esModule`/`default` interop shape; a Node namespace object from +// `require(ESM)` has no `__esModule` but exposes `.default` alongside +// enumerable named keys the same way, which is what the merge reads. +// --------------------------------------------------------------------------- + +describe('defaultReadConfig', () => { + let tmpRoot; + + beforeAll(() => { + tmpRoot = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), 'spm-read-config-'), + ); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + }); + + function writeConfig(name, source) { + const root = path.join(tmpRoot, name); + fs.mkdirSync(root, {recursive: true}); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({name})); + fs.writeFileSync(path.join(root, 'react-native.config.js'), source); + return root; + } + + it('returns null when the library ships no config', () => { + const root = path.join(tmpRoot, 'no-config'); + fs.mkdirSync(root, {recursive: true}); + expect(defaultReadConfig(root)).toBeNull(); + }); + + it('reads a CommonJS config', () => { + const root = writeConfig('cjs', "module.exports = {spm: {name: 'Cjs'}};\n"); + expect(defaultReadConfig(root).spm.name).toBe('Cjs'); + }); + + it('unwraps an ESM config that only has a default export', () => { + const root = writeConfig( + 'esm-default', + "export default {spm: {name: 'EsmDefault'}};\n", + ); + expect(defaultReadConfig(root).spm.name).toBe('EsmDefault'); + }); + + it('reads an ESM config that only has named exports', () => { + const root = writeConfig( + 'esm-named', + "export const spm = {name: 'EsmNamed'};\n", + ); + expect(defaultReadConfig(root).spm.name).toBe('EsmNamed'); + }); + + it('prefers the named export when a config ships both (the PowerSync shape)', () => { + const root = writeConfig( + 'esm-both', + "export const spm = {name: 'Named'};\n" + + "export default {spm: {name: 'Default'}, dependency: {platforms: {ios: {}}}};\n", + ); + const config = defaultReadConfig(root); + expect(config.spm.name).toBe('Named'); + // Only the merge satisfies this: `dependency` exists on the default export + // alone, so reading the module raw would miss it. + expect(config.dependency.platforms.ios).toEqual({}); + }); + + it('keeps sibling keys of the default export (dependency.platforms.ios)', () => { + const root = writeConfig( + 'esm-siblings', + "export default {dependency: {platforms: {ios: {}}}, spm: {name: 'Siblings'}};\n", + ); + const config = defaultReadConfig(root); + expect(config.dependency.platforms.ios).toEqual({}); + expect(config.spm.name).toBe('Siblings'); + }); + + it('passes a function-style config through unchanged (module.exports = () => ({...}))', () => { + const root = writeConfig( + 'fn-style', + "module.exports = () => ({spm: {name: 'FnStyle'}});\n", + ); + const config = defaultReadConfig(root); + expect(typeof config).toBe('function'); + expect(config().spm.name).toBe('FnStyle'); + }); + + it('warns with the config path and the reason when the config fails to load, and returns null', () => { + const root = writeConfig( + 'broken', + "require('a-dev-dependency-that-is-not-installed');\n", + ); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(defaultReadConfig(root)).toBeNull(); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).toContain(path.join(root, 'react-native.config.js')); + expect(message).toContain('a-dev-dependency-that-is-not-installed'); + } finally { + warnSpy.mockRestore(); + } + }); +}); 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..5118a5e09854 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js @@ -0,0 +1,364 @@ +/** + * 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 = []; +let originalConfigCommandEnv; + +beforeEach(() => { + originalConfigCommandEnv = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; +}); + +afterEach(() => { + if (originalConfigCommandEnv == null) { + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + } else { + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = originalConfigCommandEnv; + } +}); + +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, + }); + }); + + describe('config command override', () => { + it('uses the config command from RCT_SPM_AUTOLINKING_CONFIG_COMMAND', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'my-cli', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['my-cli', 'config']); + }); + + it('prefers an explicit configCommand over the environment variable', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'environment', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + configCommand: ['explicit', 'config'], + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['explicit', 'config']); + }); + + it('throws when the environment variable is not JSON', () => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = 'not json'; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }); + + it.each(['[]', '[1,2]'])( + 'throws when the environment variable is not a non-empty string array: %s', + rawConfigCommand => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = rawConfigCommand; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }, + ); + + it('falls back to the default command when the environment variable is unset', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual([ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', + ]); + }); + }); +}); 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..a1a92e1fa4d2 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -0,0 +1,1960 @@ +/** + * 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 {SpmNameCollisionError} = require('../expand-spm-dependencies'); +const { + AUTOGEN_MARKER, + MissingManifestError, + autolinkingDepToSpmTarget, + 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")'); + 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, depName = 'expo'}) { + 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', ...depName.split('/')); + 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: { + [depName]: {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); + }); + + // Adds a dep that declares the plugin host in its own `spm.dependencies`. + // `selfManaged` gives it a Package.swift of its own — which is what decides + // whether React Native emits its package references or the dep does. + function addDependentOfExpo(appRoot, name, {selfManaged = false} = {}) { + const depDir = path.join(appRoot, 'node_modules', name); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'Dep.mm'), '// native source\n'); + if (selfManaged) { + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version: 6.0\n', + ); + } + fs.writeFileSync( + path.join(depDir, 'react-native.config.js'), + 'module.exports = {dependency: {platforms: {ios: {}}}, ' + + "spm: {dependencies: ['expo']}};\n", + ); + const jsonPath = path.join( + appRoot, + 'build/generated/autolinking/autolinking.json', + ); + const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + json.dependencies[name] = {root: depDir, platforms: {ios: {}}}; + fs.writeFileSync(jsonPath, JSON.stringify(json)); + } + + it('fails when a dep whose manifest RN generates declares the plugin host in its spm.dependencies', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + addDependentOfExpo(appRoot, 'react-native-y'); + const run = () => + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + // Both packages, so the reader knows which config to edit and why. + expect(run).toThrow(/'react-native-y'/); + expect(run).toThrow(/'expo'/); + expect(run).toThrow(/autolinking plugin/); + expect(run).toThrow(/spm\.dependencies/); + }); + + it('leaves a self-managed dependent alone — its own Package.swift declares its package references, so RN emits none', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + addDependentOfExpo(appRoot, 'react-native-y', {selfManaged: true}); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).not.toThrow(); + }); + + it('leaves the same pair alone when the host ships no plugin — a plain spm.dependency is not a plugin host', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: false}); + addDependentOfExpo(appRoot, 'react-native-y'); + // Both deps ship no manifest, so the missing-manifest error is the expected + // one — the plugin-host diagnosis must not fire for a plain dependency. + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(MissingManifestError); + }); + // `spm scaffold` has no plugin knowledge, so exempting the autolinker alone + // left the two commands disagreeing about the same dep. + it.each([[true], [false]])( + 'rejects a dep deriving a reserved name whether or not it ships a plugin (withPlugin=%s)', + withPlugin => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope. + const {appRoot, rnRoot} = buildFixture({ + withPlugin, + depName: 'react-headers', + }); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(/'react-headers'.*React Native reserves/s); + }, + ); +}); + +// --------------------------------------------------------------------------- +// main() — spm.modules name validation +// +// App-local module names land in the manifest exactly as written, so they need +// the checks an autolinked dep's Swift name gets: a valid identifier, not one +// of React Native's reserved names, and unique across modules and deps. +// --------------------------------------------------------------------------- + +describe('main() — spm.modules names', () => { + 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 = []; + }); + + // App fixture whose react-native.config.js declares `spm.modules`, plus an + // optional autolinked dep (for the module-vs-dep collision case). + function buildApp({modules, dep}) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-modules-')); + 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'}), + ); + for (const mod of modules) { + const modDir = path.join(appRoot, mod.path); + fs.mkdirSync(modDir, {recursive: true}); + fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n'); + } + fs.writeFileSync( + path.join(appRoot, 'react-native.config.js'), + `module.exports = ${JSON.stringify({spm: {modules}})};\n`, + ); + const dependencies = {}; + if (dep != null) { + const depDir = path.join(appRoot, 'node_modules', dep.name); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'ios', 'Dep.mm'), + '// native source\n', + ); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version: 6.0\n', + ); + dependencies[dep.name] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + const run = ({appRoot, rnRoot}) => + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + it('accepts a normal module name', () => { + const app = buildApp({ + modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).not.toThrow(); + }); + + it('rejects a module named after a reserved React Native name', () => { + const app = buildApp({ + modules: [{name: 'ReactNative', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/, + ); + expect(() => run(app)).toThrow(/'spm\.modules'\.$/); + }); + + it('rejects a reserved product name in any casing', () => { + const app = buildApp({ + modules: [{name: 'reactheaders', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/, + ); + }); + + it('rejects a module name that is not a valid Swift identifier', () => { + const app = buildApp({ + modules: [{name: 'My Module', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/); + }); + + it('rejects two modules resolving to the same name', () => { + const app = buildApp({ + modules: [ + {name: 'Shared', path: 'ios/one'}, + {name: 'shared', path: 'ios/two'}, + ], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/, + ); + }); + + it('rejects a module colliding with an autolinked dep', () => { + const app = buildApp({ + modules: [{name: 'ReactNativeFoo', path: 'ios/MyNativeModule'}], + dep: {name: 'react-native-foo'}, + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// 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() — plugin scriptPhases sidecar +// +// `.spm-plugin-script-phases.json` records the phases a plugin wants injected +// into the app target (SwiftPM has no `script_phase`). Like the other sidecars +// it is ALWAYS rewritten — `[]` when no plugin declares any — so removing a +// plugin clears stale entries. +// --------------------------------------------------------------------------- + +describe('main() — scriptPhases 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-script-phases.json', + ); + + // An app whose only autolinked dep is `expo`; when `pluginReturn` is given, + // expo declares a plugin returning that literal. + function scaffold(pluginReturn) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scriptphase-')); + 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}); + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + if (pluginReturn != null) { + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\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'), + `module.exports = function () { return ${pluginReturn}; };\n`, + ); + } + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: + pluginReturn != null + ? {expo: {root: expoDir, platforms: {ios: {}}}} + : {}, + }), + ); + return {appRoot, rnRoot}; + } + + it('records plugin-declared script phases, normalized', () => { + const {appRoot, rnRoot} = scaffold(`{ + scriptPhases: [{ + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + }], + }`); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, + { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + position: 'end', + }, + ]); + }); + + it('writes [] when no plugin declares any (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold(null); + fs.writeFileSync( + sidecarPath(appRoot), + JSON.stringify([{id: 'stale', name: 'Stale', script: 'echo'}]), + ); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual( + [], + ); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); +}); + +// --------------------------------------------------------------------------- +// main() — scope disambiguation: the borrowed name reaching a real manifest. +// --------------------------------------------------------------------------- + +describe('main() — scope disambiguation', () => { + let created = []; + let spies = []; + let logSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + spies.push(logSpy); + for (const m of ['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 = []; + }); + + // Each dep ships a Package.swift, so it reaches the aggregator as self-managed. + function buildFixture(...depNames) { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scope-disambig-')), + ); + 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 dependencies = {}; + for (const depName of depNames) { + const depDir = path.join(appRoot, 'node_modules', ...depName.split('/')); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version:6.0\n// hand-authored\n', + ); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.h'), '// header\n'); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.mm'), '// src\n'); + dependencies[depName] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + it('emits the disambiguated name as the package ref, the product ref and the header slice', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + expect(pkg).toContain( + '.package(name: "PowersyncReactNative", path: "libs/PowersyncReactNative")', + ); + expect(pkg).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + // Nothing is referenced under the name the derivation would have taken. + expect(pkg).not.toContain('"ReactNative", path: "libs/'); + expect(pkg).not.toContain('package: "ReactNative"'); + + // So `#import ` resolves for consumers. + expect( + fs.existsSync( + path.join(outDir, 'headers/PowersyncReactNative/ios/Lib.h'), + ), + ).toBe(true); + expect(fs.existsSync(path.join(outDir, 'libs/PowersyncReactNative'))).toBe( + true, + ); + expect(fs.existsSync(path.join(outDir, 'headers/ReactNative'))).toBe(false); + }); + + it('tells the developer which name it took and why', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const line = logSpy.mock.calls + .map(call => call.join(' ')) + .find(l => l.includes('PowersyncReactNative')); + expect(line).toBeDefined(); + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + }); + + it('still rejects an unscoped dep deriving a reserved name — it has no scope to borrow', () => { + const {appRoot, rnRoot} = buildFixture('react-headers'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); + + it('emits both names of a dep-vs-dep collision as package refs, product refs and header slices', () => { + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + for (const name of ['AFoo', 'BFoo']) { + expect(pkg).toContain(`.package(name: "${name}", path: "libs/${name}")`); + expect(pkg).toContain(`.product(name: "${name}", package: "${name}")`); + expect( + fs.existsSync(path.join(outDir, `headers/${name}/ios/Lib.h`)), + ).toBe(true); + } + expect(pkg).not.toContain('"Foo", path: "libs/'); + expect(pkg).not.toContain('package: "Foo"'); + expect(fs.existsSync(path.join(outDir, 'headers/Foo'))).toBe(false); + }); + + it('still rejects a collision the scopes cannot resolve', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo', 'a-foo'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); +}); + +// --------------------------------------------------------------------------- +// autolinkingDepToSpmTarget — resolved names only, never re-derived ones. +// --------------------------------------------------------------------------- + +describe('autolinkingDepToSpmTarget', () => { + const dep = (extra = {}) => ({ + name: '@powersync/react-native', + root: '/dep', + platforms: {ios: {sourceDir: '/dep/ios'}}, + ...extra, + }); + + it('carries a resolved sibling name into the emitted sibling refs', () => { + const target = autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map([['@powersync/react-native', 'PowersyncReactNative']]), + ); + const manifest = generateSynthPackageSwift({ + swiftName: target.name, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep: false, + targetPath: '.', + }); + expect(manifest).toContain( + '.package(name: "PowersyncReactNative", path: "../PowersyncReactNative")', + ); + expect(manifest).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + expect(manifest).not.toContain('ReactNative", package: "ReactNative"'); + }); + + it('fails loudly instead of re-deriving a dep with no resolved name', () => { + expect(() => + autolinkingDepToSpmTarget( + '@powersync/react-native', + dep(), + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); + + it('fails loudly instead of re-deriving an unmapped spm.dependency', () => { + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/@powersync\/react-native/); + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); +}); 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..29ed28e7e843 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js @@ -0,0 +1,335 @@ +/** + * 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 +// --------------------------------------------------------------------------- + +// Adding a product must be one edit to REACT_NATIVE_PRODUCTS (plus the static +// codegen template): the manifest's products AND targets both derive from it. +// ReactHeaders is the Clang umbrella target; every other product is served by +// an xcframework of the same name. +describe('generateXCFrameworksPackageSwift derives from the shared name constants', () => { + // jest.doMock registers in the module registry beyond the isolateModules + // scope, so the mocked constants must be dropped before the next test. + afterEach(() => { + jest.dontMock('../spm-utils'); + jest.resetModules(); + }); + + it('emits a product and a binary target for a newly reserved product', () => { + jest.isolateModules(() => { + // Declared by KIND, the way a real edit adds one — spm-utils derives the + // flat list from the kind lists, so the mock mirrors that derivation. + jest.doMock('../spm-utils', () => { + const actual = jest.requireActual('../spm-utils'); + const xcframeworkProducts = Object.freeze([ + ...actual.REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + 'ReactBrandNewHeaders', + ]); + return { + ...actual, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS: xcframeworkProducts, + REACT_NATIVE_PRODUCTS: Object.freeze([ + actual.REACT_NATIVE_UMBRELLA_PRODUCT, + ...xcframeworkProducts, + ]), + }; + }); + const { + generateXCFrameworksPackageSwift: generate, + } = require('../generate-spm-package'); + const out = generate(); + expect(out).toContain( + '.library(name: "ReactBrandNewHeaders", targets: ["ReactBrandNewHeaders"])', + ); + expect(out).toContain('path: "ReactBrandNewHeaders.xcframework"'); + }); + }); + + it('emits one library product per REACT_NATIVE_PRODUCTS entry, in order', () => { + const {REACT_NATIVE_PRODUCTS} = require('../spm-utils'); + const libraries = [ + ...generateXCFrameworksPackageSwift().matchAll( + /\.library\(name: "([^"]+)"/g, + ), + ].map(m => m[1]); + expect(libraries).toEqual([...REACT_NATIVE_PRODUCTS]); + }); +}); + +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..93a443a5b00f --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -0,0 +1,443 @@ +/** + * 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, + readScriptPhasesManifest, +} = 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', + }, + ], +}; + +// The pbxproj product references must follow the shared name constants: a +// product added there has to reach the app target, or the app links against a +// package product Xcode never references. +describe('SPM product references derive from the shared name constants', () => { + // jest.doMock registers in the module registry beyond the isolateModules + // scope, so the mocked constants must be dropped before the next test. + afterEach(() => { + jest.dontMock('../spm-utils'); + jest.resetModules(); + }); + + it('includes a newly reserved React Native product', () => { + jest.isolateModules(() => { + jest.doMock('../spm-utils', () => { + const actual = jest.requireActual('../spm-utils'); + return { + ...actual, + REACT_NATIVE_PRODUCTS: Object.freeze([ + ...actual.REACT_NATIVE_PRODUCTS, + 'ReactBrandNewHeaders', + ]), + }; + }); + const {buildSpmDependencyGraph} = require('../generate-spm-xcodeproj'); + const graph = buildSpmDependencyGraph( + (section, id) => `${section}:${id}`, + ); + expect(graph.products.map(p => p.product)).toContain( + 'ReactBrandNewHeaders', + ); + }); + }); + + it('references every React Native, aggregator and codegen product exactly once', () => { + const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_NATIVE_PRODUCTS, + } = require('../spm-utils'); + const {buildSpmDependencyGraph} = require('../generate-spm-xcodeproj'); + const graph = buildSpmDependencyGraph((section, id) => `${section}:${id}`); + expect(graph.products.map(p => p.product)).toEqual([ + ...REACT_NATIVE_PRODUCTS, + AUTOLINKED_PACKAGE_NAME, + ...REACT_CODEGEN_APP_PRODUCTS, + ]); + }); +}); + +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'); + }); +}); + +describe('readScriptPhasesManifest', () => { + let appRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-script-phases-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + const write = contents => { + const dir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, '.spm-plugin-script-phases.json'), + contents, + 'utf8', + ); + }; + + it('is [] when the manifest does not exist (first `spm add`)', () => { + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('is [] for unparseable JSON, warning about it', () => { + write('{not json'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + expect( + logSpy.mock.calls.some(([msg]) => + /could not parse .*\.spm-plugin-script-phases\.json/.test(msg), + ), + ).toBe(true); + }); + + it('is [] for a non-array payload', () => { + write('{"id": "x"}'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('parses valid entries and defaults position to end', () => { + write( + JSON.stringify([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + it('skips malformed entries and duplicate ids, keeping the valid ones', () => { + write( + JSON.stringify([ + {name: 'No id', script: 'echo'}, + {id: 'a', name: 'A', script: 'echo one'}, + {id: 'a', name: 'A again', script: 'echo two'}, + null, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name: 'A', script: 'echo one', position: 'end'}, + ]); + }); + + // The same ids the plugin contract accepts: a scoped npm name is the natural + // stable key for a package-owned phase. + it.each([['@expo/log-box'], ['@expo/ui']])( + 'keeps the scoped npm name %s as an id', + id => { + write(JSON.stringify([{id, name: 'X', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it.each([ + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id with a colon', {id: 'a:b', name: 'X', script: 'echo'}], + ['an id with a space', {id: 'a b', name: 'X', script: 'echo'}], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + [ + 'an unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + // A line break is the one thing no Xcode phase name can carry. This reader + // is the only gate on a stale or hand-edited sidecar. + ['a name with a newline', {id: 'a', name: 'L1\nL2', script: 'echo'}], + [ + 'a name with a carriage return', + {id: 'a', name: 'L1\rL2', script: 'echo'}, + ], + ])('skips an entry with %s', (_label, entry) => { + write(JSON.stringify([entry, {id: 'keep', name: 'Keep', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'keep', name: 'Keep', script: 'echo', position: 'end'}, + ]); + }); + + // The injector normalizes the name for the `/* … */` comments and escapes it + // in the `name` field, so a pbxproj-hostile name needs no coercion here. + it.each([ + ['needs pbxproj quoting', 'Bundle "app.config"'], + ['closes a comment', 'Bad */ = { x'], + ['opens a comment', 'Bad /* x'], + ])('keeps a name that %s', (_label, name) => { + write(JSON.stringify([{id: 'a', name, script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name, script: 'echo', position: 'end'}, + ]); + }); + + it('drops non-string and empty input/output path entries', () => { + write( + JSON.stringify([ + { + id: 'a', + name: 'A', + script: 'echo', + inputPaths: ['$(SRCROOT)/in', '', 7, null, '$(SRCROOT)/in2'], + outputPaths: [{}, '$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', inputPaths: 'not-an-array'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'a', + name: 'A', + script: 'echo', + position: 'end', + inputPaths: ['$(SRCROOT)/in', '$(SRCROOT)/in2'], + outputPaths: ['$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', position: 'end'}, + ]); + }); +}); 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..f7ca39363552 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -0,0 +1,1169 @@ +/** + * 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 { + buildPhaseOrder, + injectSpmIntoPbxproj, + planInjection, +} = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); +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 = {', +); + +// The app target's two XCBuildConfiguration UUIDs in the fixture. +const APP_DEBUG_CONFIG = 'AA0000000000000000000901'; +const APP_RELEASE_CONFIG = 'AA00000000000000000000A2'; + +const DEBUG_CONFIG_HEAD = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + +// Seed the app target's Debug config with a SWIFT_ACTIVE_COMPILATION_CONDITIONS +// the user already had, in the scalar form Xcode and the app template write. +function withDebugCondition(text, value) { + return text.replace( + DEBUG_CONFIG_HEAD, + `${DEBUG_CONFIG_HEAD}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = ${value};`, + ); +} + +// One XCBuildConfiguration's buildSettings dict, by config UUID. Build settings +// hold only scalars and `( … )` arrays, so the first `};` closes the dict. +function buildSettingsOf(text, configUuid) { + const open = text.indexOf( + 'buildSettings = {', + text.indexOf(`${configUuid} /*`), + ); + return text.slice(open, text.indexOf('};', open)); +} +// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS, +// set to any valid pbxproj value: a plain scalar (which injection promotes to an +// array) or an array injection appends to. +function withHeaderSearchPaths(value) { + return PLAIN.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + `HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + ); +} + +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 = [], + scriptPhases = [], +) { + 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, + scriptPhases, + ); +} + +// The app target's buildPhases members, in order, by trailing comment. +function buildPhaseComments(text) { + const bp = text.slice(text.indexOf('buildPhases = (')); + const arr = bp.slice(0, bp.indexOf(');')); + return [...arr.matchAll(/\/\* ([^*]+) \*\//g)].map(m => m[1]); +} + +// Move the "Sync SPM Autolinking" membership line below the Sources one — what a +// user dragging the phase down in Xcode produces. RN never re-seats its own sync +// phase, so the move sticks. +function dragSyncBelowSources(text) { + const memberLine = comment => + new RegExp(`\\n[\\t ]*[0-9A-Fa-f]{24} /\\* ${comment} \\*/,`).exec(text)[0]; + const sync = memberLine('Sync SPM Autolinking'); + const sources = memberLine('Sources'); + return text.replace(sync, '').replace(sources, sources + sync); +} + +// 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', +}; + +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'); + }); + + // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the + // Metro URL — is gated by this setting alone. CocoaPods injects it at `pod + // install`; an SPM app has to get it here or a Debug build looks for a + // main.jsbundle it never built. + it('sets SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG on the debug config only', () => { + const {text} = inject(PLAIN); + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toMatch( + /SWIFT_ACTIVE_COMPILATION_CONDITIONS = \(\s*"\$\(inherited\)",\s*DEBUG,\s*\)/, + ); + expect(buildSettingsOf(text, APP_RELEASE_CONFIG)).not.toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', + ); + }); + + it('leaves a config that already sets DEBUG (scalar form) untouched', () => { + const {text} = inject(withDebugCondition(PLAIN, '"$(inherited) DEBUG"')); + // Not promoted to an array, not re-appended — DEBUG is already there. + expect(buildSettingsOf(text, APP_DEBUG_CONFIG)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + expect(text.match(/\bDEBUG\b/g)).toHaveLength(1); + }); + + it("adds DEBUG alongside the user's own compilation conditions", () => { + const {text} = inject( + withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + ); + // MY_DEBUG_UI must not be mistaken for DEBUG by a substring check. + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toContain('"$(inherited) MY_DEBUG_UI"'); + expect(debugSettings).toMatch(/^\s*DEBUG,$/m); + }); + + 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.each([ + [ + '"$(inherited)"', + ['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'], + ], + [ + '"$(inherited) $(SRCROOT)/vendor/include"', + [ + '"$(inherited)"', + '"$(inherited) $(SRCROOT)/vendor/include"', + '"$(SRCROOT)/build/generated/autolinking/headers"', + ], + ], + ])( + 'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)', + (scalar, expectedMembers) => { + const {text} = inject(withHeaderSearchPaths(scalar)); + const arrays = [ + ...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g), + ].map(m => + m[1] + .split('\n') + .map(line => line.trim().replace(/,$/, '')) + .filter(member => member.length > 0), + ); + // Both app-target configs (Debug + Release). + expect(arrays).toEqual([expectedMembers, expectedMembers]); + }, + ); + + it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => { + const {text} = inject(withHeaderSearchPaths('("$(inherited)", )')); + expect(isBalanced(text)).toBe(true); + const arrays = [ + ...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g), + ].map(m => m[1]); + // Both app-target configs, each keeping the one-line shape it was written in. + expect(arrays).toEqual( + Array(2).fill( + '"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ', + ), + ); + }); + + 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'); + }); +}); + +// A generated source's `name` (its basename) is plugin-derived, so it reaches +// the file reference's, the build file's and the Sources-membership comments +// under the same rules as a script-phase name: normalized, never raw. A `{` +// there makes findObjectByUuid read the next object's body as this one's, and a +// `,` makes removeArrayMembersByUuid chew the wrong line — corruption with no +// error. [label, filename, expected comment] +const HOSTILE_SOURCE_NAMES = [ + ['an opening brace', 'Weird{Name}.swift', 'Weird Name .swift'], + ['a comma', 'Weird,Name.swift', 'Weird Name.swift'], +]; + +describe.each(HOSTILE_SOURCE_NAMES)( + 'injectSpmIntoPbxproj — a generated source whose filename contains %s', + (_label, fileName, comment) => { + const src = { + path: `build/generated/autolinking/expo/${fileName}`, + name: fileName, + sourceTree: 'SOURCE_ROOT', + fileType: 'sourcecode.swift', + }; + + it('normalizes all three comments and keeps the project balanced', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [src]); + const [fileRefUuid, buildFileUuid] = generatedSourceUuids[src.path]; + expect(definitionComment(text, fileRefUuid)).toBe(comment); + expect(definitionComment(text, buildFileUuid)).toBe( + `${comment} in Sources`, + ); + expect(text).toContain(`fileRef = ${fileRefUuid} /* ${comment} */;`); + const sourcesPhase = text.slice( + text.indexOf('/* Begin PBXSourcesBuildPhase section */'), + ); + expect(sourcesPhase.slice(0, sourcesPhase.indexOf('/* End'))).toContain( + `${buildFileUuid} /* ${comment} in Sources */,`, + ); + expect(isBalanced(text)).toBe(true); + }); + + it('leaves the path and name VALUES verbatim', () => { + const {text} = inject(PLAIN, null, null, [src]); + expect(text).toContain(`path = "${src.path}";`); + expect(text).toContain(`name = "${fileName}";`); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [src]).text; + expect(inject(first, null, null, [src]).text).toBe(first); + }); + }, +); + +describe('injectSpmIntoPbxproj — an ordinary generated-source name', () => { + // Normalization must be invisible for every real-world filename, or every + // already-injected project churns on its next sync. + it('reaches all three comments byte-unchanged', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ + PROVIDER_SOURCE, + ]); + const [fileRefUuid, buildFileUuid] = + generatedSourceUuids[PROVIDER_SOURCE.path]; + expect(definitionComment(text, fileRefUuid)).toBe( + 'ExpoModulesProvider.swift', + ); + expect(definitionComment(text, buildFileUuid)).toBe( + 'ExpoModulesProvider.swift in Sources', + ); + expect(text).toContain( + `fileRef = ${fileRefUuid} /* ExpoModulesProvider.swift */;`, + ); + }); +}); + +// Plugin-declared build phases (the expo-constants case: write app.config into +// the app bundle after the JS bundle phase). +const APP_CONFIG_PHASE = { + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo ok > app.config', + position: 'end', + inputPaths: ['$(SRCROOT)/app.json'], + outputPaths: ['$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config'], +}; + +describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { + it('adds one shell script phase carrying the declared name, script and paths', () => { + const {text, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + [APP_CONFIG_PHASE], + ); + // Two RN-owned phases (sync + embed) plus this one. + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(text).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + expect(text).toContain('name = "Bundle Expo app.config";'); + expect(text).toContain('shellScript = "echo ok > app.config";'); + expect(text).toContain('\t\t\t\t"$(SRCROOT)/app.json",\n'); + expect(text).toContain( + '\t\t\t\t"$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config",\n', + ); + // Recorded so `deinit` reverses it and `update` reconciles it. + const {injectedUuids} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + expect(injectedUuids).toEqual(expect.arrayContaining([uuid])); + expect(isBalanced(text)).toBe(true); + }); + + it('emits an unquoted alwaysOutOfDate = 1 only when the phase asks for it', () => { + const withFlag = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}], + ).text; + expect(withFlag).toContain('alwaysOutOfDate = 1;'); + // Xcode writes it immediately after `isa` — match that to avoid churn. + expect(withFlag).toContain( + 'isa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;', + ); + + for (const phase of [ + APP_CONFIG_PHASE, + {...APP_CONFIG_PHASE, alwaysOutOfDate: false}, + ]) { + expect(inject(PLAIN, null, null, [], [phase]).text).not.toContain( + 'alwaysOutOfDate', + ); + } + }); + + it("places an 'end' phase last in buildPhases", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + const comments = buildPhaseComments(text); + expect(comments[comments.length - 1]).toBe('Bundle Expo app.config'); + // NOTE: the fixture target has only Sources/Frameworks/Resources — it has + // no "Bundle React Native code and images" phase, so the real requirement + // (an 'end' phase runs AFTER the JS bundle phase) is not asserted here. + // End-of-array position is what delivers it on a real app target. + }); + + it("places a 'beforeCompile' phase after the sync phase and before Sources", () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, position: 'beforeCompile'}], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'Bundle Expo app.config', + 'Sources', + ]); + }); + + it('preserves declared order within each position', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + const {text} = inject( + PLAIN, + null, + null, + [], + [ + phase('pre-a', 'beforeCompile'), + phase('post-a', 'end'), + phase('pre-b', 'beforeCompile'), + phase('post-b', 'end'), + ], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'pre-a', + 'pre-b', + 'Sources', + ]); + expect(comments.slice(-2)).toEqual(['post-a', 'post-b']); + }); + + it('is idempotent with script phases — a second run is byte-for-byte identical', () => { + const phases = [ + {...APP_CONFIG_PHASE, alwaysOutOfDate: true}, + {...APP_CONFIG_PHASE, id: 'other', name: 'Other', position: 'end'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + const second = inject(first, null, null, [], phases).text; + expect(second).toBe(first); + }); + + it('escapes a script carrying quotes, a backslash, a newline and a $(VAR)', () => { + const script = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const phases = [{...APP_CONFIG_PHASE, script}]; + const {text} = inject(PLAIN, null, null, [], phases); + expect(text).toContain( + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";', + ); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('quotes a name containing a double quote in the field, dropping it from the comments', () => { + const phases = [{...APP_CONFIG_PHASE, name: 'Bundle "app.config"'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(text).toContain('name = "Bundle \\"app.config\\"";'); + expect(text).toContain(`${uuid} /* Bundle app.config */ = {`); + expect(buildPhaseComments(text)).toContain('Bundle app.config'); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('a rename refreshes the name field AND both /* … */ comments', () => { + const first = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write App Config'}], + ).text; + const renamed = inject( + first, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write Expo Config'}], + ).text; + // Xcode normalizes comments on its next write, so a stale one is a spurious + // diff in the user's repo: nothing but the name may differ. + expect(renamed).not.toContain('Write App Config'); + expect(renamed).toBe( + first.split('Write App Config').join('Write Expo Config'), + ); + }); +}); + +// A declared `position` — and the declared order of two phases sharing one — is +// enforced on every sync, not only at first injection. The membership lines are +// rewritten ONLY when the actual order differs, which is what keeps an unchanged +// sync byte-identical. +describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + + it('moves a phase from end to beforeCompile and back again', () => { + const atEnd = inject(PLAIN, null, null, [], [phase('a', 'end')]).text; + expect(buildPhaseComments(atEnd).slice(-1)).toEqual(['a']); + + const beforeCompile = [phase('a', 'beforeCompile')]; + const moved = inject(atEnd, null, null, [], beforeCompile).text; + expect(buildPhaseComments(moved).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'Sources', + ]); + expect(isBalanced(moved)).toBe(true); + // Re-syncing the now-matching declaration changes nothing. + expect(inject(moved, null, null, [], beforeCompile).text).toBe(moved); + + // And back: a move is a pure reordering of the membership lines. + expect(inject(moved, null, null, [], [phase('a', 'end')]).text).toBe(atEnd); + }); + + it('reorders two phases sharing a position when their declared order swaps', () => { + const inOrder = inject( + PLAIN, + null, + null, + [], + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ).text; + expect(buildPhaseComments(inOrder).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b1', + 'b2', + 'Sources', + ]); + + const swapped = inject( + inOrder, + null, + null, + [], + [phase('b2', 'beforeCompile'), phase('b1', 'beforeCompile')], + ).text; + expect(buildPhaseComments(swapped).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b2', + 'b1', + 'Sources', + ]); + }); + + it('reseats only the phase whose position changed', () => { + const declared = [ + phase('a', 'beforeCompile'), + phase('b', 'beforeCompile'), + phase('c', 'beforeCompile'), + ]; + const first = inject(PLAIN, null, null, [], declared).text; + expect(buildPhaseComments(first).slice(0, 5)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'b', + 'c', + 'Sources', + ]); + + const {text} = inject( + first, + null, + null, + [], + [declared[0], {...declared[1], position: 'end'}, declared[2]], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'c', + 'Sources', + ]); + expect(comments[comments.length - 1]).toBe('b'); + }); + + it('moves a phase the user dragged in Xcode back to its declared position', () => { + const phases = [phase('a', 'end')]; + const {text: first, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + phases, + ); + const memberLine = `\n\t\t\t\t${scriptPhaseUuids.a} /* a */,`; + expect(first).toContain(memberLine); + const dragged = first + .replace(memberLine, '') + .replace('buildPhases = (\n', `buildPhases = (${memberLine}\n`); + expect(buildPhaseComments(dragged)[0]).toBe('a'); + + expect(inject(dragged, null, null, [], phases).text).toBe(first); + }); + + // RN never re-seats its own sync phase, so a user who drags it below Sources + // keeps it there — but `beforeCompile` means before Sources, which is the + // guarantee the plugin contract makes. + it('seats a beforeCompile phase before Sources even when the sync phase sits below it', () => { + const dragged = dragSyncBelowSources(inject(PLAIN).text); + expect(buildPhaseComments(dragged).slice(0, 2)).toEqual([ + 'Sources', + 'Sync SPM Autolinking', + ]); + + const declared = [phase('a', 'beforeCompile')]; + const {text} = inject(dragged, null, null, [], declared); + expect(buildPhaseComments(text)).toEqual([ + 'a', + 'Sources', + 'Sync SPM Autolinking', + 'Frameworks', + 'Embed React Native Flavored Frameworks', + 'Resources', + ]); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], declared).text).toBe(text); + }); + + it('falls back to the sync phase as the anchor when the target has no Sources phase', () => { + const noSources = PLAIN.replace( + /\/\* Begin PBXSourcesBuildPhase section \*\/[\s\S]*?\/\* End PBXSourcesBuildPhase section \*\/\n\n/, + '', + ); + const {text} = inject( + noSources, + null, + null, + [], + [phase('a', 'beforeCompile')], + ); + expect(buildPhaseComments(text).slice(0, 2)).toEqual([ + 'Sync SPM Autolinking', + 'a', + ]); + }); + + it.each([ + ['one end phase', [phase('a', 'end')]], + ['one beforeCompile phase', [phase('a', 'beforeCompile')]], + [ + 'two beforeCompile phases', + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ], + ['two end phases', [phase('e1', 'end'), phase('e2', 'end')]], + [ + 'mixed positions', + [ + phase('b1', 'beforeCompile'), + phase('e1', 'end'), + phase('b2', 'beforeCompile'), + phase('e2', 'end'), + ], + ], + ])('re-syncs %s byte-identically', (_label, phases) => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The membership order drives every re-seating decision, so it must list the +// members and nothing else: a `/* … */` comment is arbitrary plugin-supplied +// text, and a phase NAMED like a UUID would otherwise read as an extra member — +// making the actual order permanently disagree with the declared one. +describe('buildPhaseOrder', () => { + const PHANTOM = 'ABCDEF012345678901234567'; + + it('lists only line-leading UUIDs, never one inside a comment', () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: PHANTOM}], + ); + const plan = planInjection(text, {}); + const order = buildPhaseOrder(text, plan.target); + + expect(text).toContain(`/* ${PHANTOM} */,`); + expect(order).not.toContain(PHANTOM); + expect(order).toHaveLength(buildPhaseComments(text).length); + }); + + it('seats a phase named like a UUID normally, and re-syncs byte-identically', () => { + const phases = [ + {...APP_CONFIG_PHASE, name: PHANTOM, position: 'beforeCompile'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + expect(buildPhaseComments(first).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + PHANTOM, + 'Sources', + ]); + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The trailing comment beside a UUID on its object-definition line. +function definitionComment(text, uuid) { + const m = new RegExp(`\\n\\t*${uuid}(?: /\\* (.*?) \\*/)? = \\{`).exec(text); + return m == null ? null : (m[1] ?? null); +} + +// A pbxproj `/* … */` comment is cosmetic — Xcode regenerates it from the +// object's own `name` field — so a plugin-supplied name is NORMALIZED for the +// comment and kept verbatim (quoted) in the field. Nothing a scanner could read +// as structure may survive into a comment: findObjectByUuid takes the first `{` +// after the UUID as the object's body, and removeArrayMembersByUuid identifies a +// member line by its trailing comma — so a `{` or a `,` in a comment splices +// fields into the wrong object, or makes `deinit` chew the section header. +// [label, name, expected comment] +const HOSTILE_NAMES = [ + ['an opening brace', 'Bundle { app', 'Bundle app'], + ['a closing brace', 'Bundle } app', 'Bundle app'], + ['an opening paren', 'Bundle (app', 'Bundle app'], + ['a closing paren', 'Bundle app)', 'Bundle app'], + ['a comma', 'A , B', 'A B'], + ['a semicolon', 'A; B', 'A B'], + ['an equals sign', 'name = {', 'name'], + ['a comment terminator', 'Bad */ = { x', 'Bad x'], + ['a comment opener', 'Bad /* x', 'Bad x'], + ['a bare asterisk', 'A * B', 'A B'], + ['a bare slash', 'Copy A/B', 'Copy A B'], + ['an unbalanced double quote', 'He said "hi', 'He said hi'], + ['a balanced double-quote pair', 'Bundle "app.config"', 'Bundle app.config'], + ['a tab', 'A\tB', 'A B'], + ['non-ASCII characters', 'Générer la config 📦', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`, `Bundle ${'x'.repeat(300)}`], + // Nothing printable survives normalization — fall back to the phase id, + // itself normalized (see the scoped-id describe below). + ['only structural characters', '*/*', APP_CONFIG_PHASE.id], +]; + +describe.each(HOSTILE_NAMES)( + 'injectSpmIntoPbxproj — a plugin phase name containing %s', + (_label, name, comment) => { + const phases = [{...APP_CONFIG_PHASE, name}]; + + it('normalizes it in both comments and keeps the project balanced', () => { + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(definitionComment(text, uuid)).toBe(comment); + expect(buildPhaseComments(text)).toContain(comment); + expect(isBalanced(text)).toBe(true); + // The phase object is intact — the sanity check that nothing was spliced + // into a neighbouring object through a comment-borne `{`. + expect(text).toContain(`${uuid} /* ${comment} */ = {`); + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); + }, +); + +// A scoped npm package name is the natural stable id for a package-owned phase +// (`@expo/log-box` is a named consumer), so `@` and `/` are in the id charset. +// The id is also the comment's fallback, and it is normalized there exactly like +// a name — the comment must not depend on which characters the charset admits. +describe('injectSpmIntoPbxproj — a scoped-npm-name phase id', () => { + const SCOPED = {...APP_CONFIG_PHASE, id: '@expo/log-box'}; + + it('keys the phase UUID on the scoped id verbatim', () => { + const {scriptPhaseUuids} = inject(PLAIN, null, null, [], [SCOPED]); + expect(Object.keys(scriptPhaseUuids)).toEqual(['@expo/log-box']); + expect(scriptPhaseUuids['@expo/log-box']).toMatch(/^[0-9A-F]{24}$/); + }); + + it('normalizes the id when nothing in the name survives', () => { + const phases = [{...SCOPED, name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['@expo/log-box']; + expect(definitionComment(text, uuid)).toBe('@expo log-box'); + expect(buildPhaseComments(text)).toContain('@expo log-box'); + expect(text).toContain(`${uuid} /* @expo log-box */ = {`); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('writes no comment at all when neither the name nor the id survives', () => { + const phases = [{...APP_CONFIG_PHASE, id: '//', name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['//']; + expect(definitionComment(text, uuid)).toBe(null); + expect(text).toContain(`${uuid} = {`); + expect(text).toMatch(new RegExp(`\\n\\t+${uuid},`)); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); +}); + +describe('injectSpmIntoPbxproj — phase name field vs. comment', () => { + it('keeps the raw name in the escaped `name` field Xcode displays', () => { + const cases = [ + ['Bundle "app.config"', 'name = "Bundle \\"app.config\\"";'], + ['A\tB', 'name = "A\\tB";'], + ['name = {', 'name = "name = {";'], + ['*/*', 'name = "*/*";'], + ]; + for (const [name, expected] of cases) { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name}], + ); + expect(text).toContain(expected); + } + }); + + it("leaves React Native's own two phase comments byte-identical", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + for (const label of [ + 'Sync SPM Autolinking', + 'Embed React Native Flavored Frameworks', + ]) { + expect(text).toMatch( + new RegExp(`\\n\\t\\t[0-9A-F]{24} /\\* ${label} \\*/ = \\{`), + ); + expect(text).toMatch( + new RegExp(`\\n\\t\\t\\t\\t[0-9A-F]{24} /\\* ${label} \\*/,`), + ); + } + }); + + // Xcode's own comment convention for a package reference carries quotes and + // slashes. Normalizing those would rewrite bytes Xcode itself produces, so + // only untrusted labels go through commentSafe. + it("preserves Xcode's comment convention for the package references", () => { + const {text} = inject( + PLAIN, + null, + null, + [PROVIDER_SOURCE], + [APP_CONFIG_PHASE], + ); + expect(text).toContain( + '/* XCLocalSwiftPackageReference "build/generated/autolinking" */', + ); + expect(text).toContain('/* ExpoModulesProvider.swift in Sources */'); + expect(text).toContain('/* ReactHeaders in Frameworks */'); + }); +}); + +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__/pbxproj-oracles.js b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js new file mode 100644 index 000000000000..c7330315b41a --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js @@ -0,0 +1,33 @@ +/** + * 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'; + +// A simple balanced-delimiter check (an injected project 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; +} + +module.exports = {isBalanced}; 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..f8c4dbdfe2f3 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -0,0 +1,1379 @@ +/** + * 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, + readPinnedConfigCommand, + removeSpmInjection, +} = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); +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. +// Pre-existing values for an injected array setting (HEADER_SEARCH_PATHS), +// seeded into both app-target configs. The plain fixture has none, so it only +// ever exercises the create-from-absent path; deinit must restore each of +// these forms — a plain scalar is ordinary, valid pbxproj. +const PRE_EXISTING_HEADER_SEARCH_PATHS = { + 'a bare $(inherited) scalar': '"$(inherited)"', + 'a scalar with real content': '"$(inherited) $(SRCROOT)/vendor/include"', + 'an array': '(\n\t\t\t\t"$(inherited)",\n\t\t\t)', + // What hand edits and other generators (XcodeGen, Tuist) write. + 'a one-line array': '("$(inherited)", )', +}; + +// Seed a whole `KEY = value;` field (comments and stray whitespace included) +// into both app-target configs. +function withSetting(field /*: string */) { + return PLAIN.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + `${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + ); +} + +function withHeaderSearchPaths(value /*: string */) { + return withSetting(`HEADER_SEARCH_PATHS = ${value};`); +} + +function scaffoldApp(pbxproj /*: string */ = PLAIN) { + 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'), + pbxproj, + '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', + ); +} + +const SCRIPT_PHASES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', +); + +function writeScriptPhases(appRoot, phases) { + const manifestPath = path.join(appRoot, SCRIPT_PHASES_MANIFEST); + fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); + fs.writeFileSync(manifestPath, JSON.stringify(phases, null, 2), 'utf8'); +} + +const APP_CONFIG_PHASE = { + // Contract charset (/^[@A-Za-z0-9_./-]+$/) — the reader skips anything else. + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo v1', + position: 'end', +}; + +function markerTextOf(xcodeprojPath) { + return fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'); +} + +function readMarker(xcodeprojPath) { + return JSON.parse(markerTextOf(xcodeprojPath)); +} + +function schemePathOf(xcodeprojPath) { + return path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + 'MyApp.xcscheme', + ); +} + +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, + ); + }); + + // Both records the second run relies on — `createdArrayFields` and + // `scheme.created` — are carried forward for the reason spelled out on + // mergeCreatedArrayFields. Without that, this second marker forgets them and + // `deinit` leaves an empty `packageReferences` / `packageProductDependencies` + // behind and the generated scheme on disk. Zero script phases here: the defect + // is in the injector's reversal record, not in any one feature. + it('round-trips add → update → deinit byte-for-byte, scheme included', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + const schemePath = schemePathOf(xcodeprojPath); + expect(fs.existsSync(schemePath)).toBe(true); + + sync(); + // The re-sync changed neither the project… + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + // …nor the record of what has to be undone. + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toMatch(/packageReferences/); + expect(after).not.toMatch(/packageProductDependencies/); + expect(after).toBe(before); + expect(fs.existsSync(schemePath)).toBe(false); + }); + + // A Debug config that already carries DEBUG gets no edit at all, so there is + // nothing for the marker to record — and nothing left behind. Injecting into + // the scalar form regardless (addArrayStringValues dedupes by exact array + // member, which the scalar never matches) would promote it to an array the + // marker has no record of, and deinit would strand it. + it('leaves a Debug config that already sets DEBUG alone, add through deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const head = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + head, + `${head}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";`, + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Scheme ownership. `scheme.created` records that RN wrote the file, which is +// necessary but not sufficient to delete it on `deinit`: the user may have +// replaced its contents with a scheme of their own (same name, same target). +// Deleting that would destroy their work, so the file is only removed while its +// contents are still the ones RN generates. +// --------------------------------------------------------------------------- +describe('deinit — scheme ownership', () => { + // A scheme the user authored for the same target: same file name, same + // BlueprintIdentifier, no PreActions. + function userAuthoredScheme(targetUuid) { + return ` + + + + + + + + + + + + +`; + } + + function setUp() { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const deinit = () => removeSpmInjection({appRoot, xcodeprojPath}); + const schemePath = schemePathOf(xcodeprojPath); + const readScheme = () => fs.readFileSync(schemePath, 'utf8'); + return {xcodeprojPath, sync, deinit, schemePath, readScheme}; + } + + it('deletes the scheme it created while the file is still its own', () => { + const {sync, deinit, schemePath} = setUp(); + sync(); + sync(); + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); + + it("keeps the user's own scheme when they replaced the generated one", () => { + const {xcodeprojPath, sync, deinit, schemePath, readScheme} = setUp(); + sync(); + const mine = userAuthoredScheme(readMarker(xcodeprojPath).targetUuid); + fs.writeFileSync(schemePath, mine, 'utf8'); + + // The second sync re-adds the pre-action to the file the user now owns, and + // still records that RN originally created it. + sync(); + expect(readScheme()).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scheme.created).toBe(true); + + expect(deinit().status).toBe('removed'); + // Their file survives, with only RN's pre-action stripped back out. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).toBe(mine); + }); + + it('keeps its own scheme once the user has edited it', () => { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'buildConfiguration = "Release"\n revealArchiveInOrganizer', + 'buildConfiguration = "Debug"\n revealArchiveInOrganizer', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + // Leaking a scheme beats deleting an edit: the file stays, minus the + // pre-action. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).not.toContain('Sync SPM Autolinking'); + expect(readScheme()).toContain( + ' { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'scriptText = "set -euo pipefail', + 'scriptText = "echo hi', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createdArrayFields — "this array field did not exist before RN touched it". +// It licenses removing the field at `deinit`, but only once RN's own members are +// gone AND nothing else is left in it: a package the user added to the same +// field is theirs, and dropping the field would orphan it. +// --------------------------------------------------------------------------- +describe('deinit — array fields RN created', () => { + it('keeps a created field the user added their own package to', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + // The user adds their own remote package in Xcode, after injection. + const userRef = 'DEADBEEF0000000000000001'; + const userMember = `${userRef} /* XCRemoteSwiftPackageReference "swift-log" */,`; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxprojOf(xcodeprojPath).replace( + 'packageReferences = (\n', + `packageReferences = (\n\t\t\t\t${userMember}\n`, + ), + 'utf8', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + // Their member — and the field holding it — survive… + expect(after).toContain(userMember); + expect(after).toMatch(/packageReferences = \(/); + // …while RN's own members go, as does the field RN created and emptied. + expect(after).not.toMatch(/relativePath = build\/xcframeworks/); + expect(after).not.toMatch(/packageProductDependencies/); + }); + + it('restores a field that pre-existed but was empty byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + // An empty `packageReferences` the user already had (Xcode leaves one behind + // after removing the last package). RN never records it as created, so the + // field itself must outlive `deinit`. + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + '\t\t\tprojectDirPath = "";', + '\t\t\tpackageReferences = (\n\t\t\t);\n\t\t\tprojectDirPath = "";', + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).not.toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).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({}); + }); + + // A plugin-supplied filename reaches three `/* … */` comments. Whatever it + // contains, `deinit` must find the file reference, the build file and their + // memberships again and leave the file as it was — the comment-normalization + // contract these lean on lives in inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Weird{Name}.swift'], + ['a comma', 'Weird,Name.swift'], + ])( + 'deinits a generated source whose filename contains %s, leaving no residue', + (_label, fileName) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const rel = `build/generated/autolinking/expo/${fileName}`; + writeManifest(appRoot, [rel]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuids = readMarker(xcodeprojPath).generatedSources[rel]; + expect(uuids).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + for (const uuid of uuids) { + expect(after).not.toContain(uuid); + } + expect(after).not.toContain('SPM Generated Sources'); + expect(after).not.toContain(fileName); + expect(after).toBe(before); + }, + ); + + 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({}); + }); +}); + +describe('script-phases reconciliation on update', () => { + it('records an added phase in the marker keyed on its plugin id', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const {scriptPhases} = readMarker(xcodeprojPath); + expect(Object.keys(scriptPhases)).toEqual([APP_CONFIG_PHASE.id]); + const uuid = scriptPhases[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(pbxprojOf(xcodeprojPath)).toContain( + `${uuid} /* Bundle Expo app.config */ = {`, + ); + }); + + it('removes the phase object AND its buildPhases membership when the id leaves the manifest', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + + writeScriptPhases(appRoot, []); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('Bundle Expo app.config'); + // The RN-owned phases are untouched. + expect(after).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scriptPhases).toEqual({}); + }); + + it('refreshes a changed script in place, leaving every other byte alone', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + expect(first).toContain('shellScript = "echo v1";'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: 'echo v2'}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(pbxprojOf(xcodeprojPath)).toBe( + first.replace('"echo v1"', '"echo v2"'), + ); + }); + + // The declared position is enforced on every sync, not only at first + // injection — see inject-spm-xcodeproj-test.js for the ordering matrix. Here: + // it survives the sidecar/marker path, and `deinit` still reverses it. + it('re-seats a phase whose declared position changed, and still deinits cleanly', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + sync(); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + const memberLine = `${uuid} /* Bundle Expo app.config */,`; + const atEnd = pbxprojOf(xcodeprojPath); + const sourcesAt = atEnd.indexOf('Sources */,'); + expect(atEnd.indexOf(memberLine)).toBeGreaterThan(sourcesAt); + + writeScriptPhases(appRoot, [ + {...APP_CONFIG_PHASE, position: 'beforeCompile'}, + ]); + sync(); + const moved = pbxprojOf(xcodeprojPath); + expect(moved.indexOf(memberLine)).toBeLessThan( + moved.indexOf('Sources */,'), + ); + // Moved, not duplicated. + expect(moved.split(memberLine)).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('round-trips add → update → deinit byte-for-byte with unchanged phases', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [ + APP_CONFIG_PHASE, + { + ...APP_CONFIG_PHASE, + id: 'other', + name: 'Other', + position: 'beforeCompile', + alwaysOutOfDate: true, + inputPaths: ['$(SRCROOT)/app.json'], + }, + ]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain('Bundle Expo app.config'); + const marker = markerTextOf(xcodeprojPath); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(fs.existsSync(schemePathOf(xcodeprojPath))).toBe(false); + }); + + // A scoped npm name is a valid id, and it is also a JSON ledger key in the + // marker — the only handle `deinit` has on the phase it injected. + it('round-trips a scoped npm-name id through the marker and deinits byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, id: '@expo/log-box'}]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + expect(isBalanced(injected)).toBe(true); + expect(Object.keys(readMarker(xcodeprojPath).scriptPhases)).toEqual([ + '@expo/log-box', + ]); + expect(marker).toContain('"@expo/log-box"'); + const uuid = readMarker(xcodeprojPath).scriptPhases['@expo/log-box']; + expect(injected).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).toBe(before); + }); + + it('removes alwaysOutOfDate when a plugin flips it back off', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain('alwaysOutOfDate = 1;'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: false}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('alwaysOutOfDate'); + expect(after).toContain('Bundle Expo app.config'); + + // …and back on again, stably (the field is re-added to an existing object, + // so it lands ahead of `isa` rather than after it — order is not semantic + // in a pbxproj, and a further re-sync must be a no-op). + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const back = pbxprojOf(xcodeprojPath); + expect(back).toContain('alwaysOutOfDate = 1;'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(back); + }); + + // A shell body full of pbxproj-hostile characters: double quotes, a + // backslash, a real newline and an Xcode `$(VAR)`. + const AWKWARD_SCRIPT = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const AWKWARD_ESCAPED = + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";'; + + it('escapes an awkward script and refreshes it byte-identically on update', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain(AWKWARD_ESCAPED); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + }); + + it('deinit restores the pbxproj byte-for-byte after an awkward script', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain(AWKWARD_ESCAPED); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + // The hostile-name matrix, from the other side: whatever a plugin calls its + // phase, `deinit` must find the object and its membership again and leave the + // file as it was. The comment-normalization contract these lean on (and the + // per-name balance/idempotency assertions) lives in + // inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Bundle { app'], + ['a closing brace', 'Bundle } app'], + ['an opening paren', 'Bundle (app'], + ['a closing paren', 'Bundle app)'], + ['a comma', 'A , B'], + ['a semicolon', 'A; B'], + ['an equals sign', 'name = {'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* x'], + ['a bare asterisk', 'A * B'], + ['a bare slash', 'Copy A/B'], + ['an unbalanced double quote', 'He said "hi'], + ['a balanced double-quote pair', 'Bundle "app.config"'], + ['a tab', 'A\tB'], + ['non-ASCII characters', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`], + ['only structural characters', '*/*'], + ])( + 'deinits a phase whose name contains %s, leaving no residue', + (_label, name) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, name}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + expect(pbxprojOf(xcodeprojPath)).toContain(uuid); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).not.toContain(uuid); + expect(after).toBe(before); + }, + ); +}); + +// --------------------------------------------------------------------------- +// artifactsVersionOverride — the marker field persisting an explicit +// `spm add/update --version ` pin (see setup-apple-spm.js's +// determineVersion). 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 setup-apple-spm.js's +// determineVersion 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(); + }); +}); + +// --------------------------------------------------------------------------- +// configCommand — the marker field persisting an explicit `spm add/update +// --config-command ''`. Without the pin, the build-time `sync` +// re-derived autolinking.json with the default @react-native-community/cli +// command and failed the "Sync SPM Autolinking" build phase in apps (e.g. Expo +// apps) that replace it. +// --------------------------------------------------------------------------- +describe('configCommand marker field', () => { + const EXPO_COMMAND = [ + 'npx', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios', + ]; + + it('records an explicit config command into the marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('defaults to null when --config-command has never been given', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toBeNull(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('preserves the pin on a later run without --config-command', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('a later explicit --config-command overwrites the previous pin', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual([ + 'my-cli', + 'config', + ]); + expect(readPinnedConfigCommand(appRoot)).toEqual(['my-cli', 'config']); + }); + + it('deinit drops the pin along with the whole marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// readPinnedConfigCommand — pure fs read, used by setup-apple-spm.js (including +// the build-time `sync`) to reuse the config command an earlier `add`/`update` +// pinned. A hand-edited or corrupt marker must degrade to the env/default path +// instead of injecting a bogus argv or throwing mid-build. +// --------------------------------------------------------------------------- +describe('readPinnedConfigCommand', () => { + it('returns null when no xcodeproj has been injected yet', () => { + const {appRoot} = scaffoldApp(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('returns null (never throws) on a malformed marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + '{ not valid json', + 'utf8', + ); + expect(() => readPinnedConfigCommand(appRoot)).not.toThrow(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it.each([ + ['a bare string', '"npx expo-modules-autolinking"'], + ['an empty array', '[]'], + ['a non-string member', '["npx", 7]'], + ['an empty-string member', '["npx", ""]'], + ['an object', '{"command": "npx"}'], + ])('returns null for a pinned value that is %s', (_label, pinned) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + marker.configCommand = JSON.parse(pinned); + fs.writeFileSync(markerPath, JSON.stringify(marker), 'utf8'); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); + +describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))( + 'removeSpmInjection with HEADER_SEARCH_PATHS already set to %s', + (_label, value) => { + it('restores the pre-existing value byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(value), + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('re-syncing is byte-for-byte identical', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(value), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(first); + }); + }, +); + +// findField's token for a BARE scalar ends AT the `;`, so it includes any +// whitespace before it. Deinit must put those bytes back exactly, not a +// tidied-up version of them. +describe.each([ + 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */', + 'HEADER_SEARCH_PATHS = ;', +])('removeSpmInjection with the untrimmed scalar `%s`', field => { + it('restores it byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); + +describe('a scalar array setting injection has nothing to add to', () => { + const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";'; + const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";'; + + // The fixture's flavored-frameworks manifest is empty, so + // FRAMEWORK_SEARCH_PATHS is injected with no values at all. + it('is left untouched, unrecorded, and survives a later user edit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR)); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain(SCALAR); + expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/); + for (const change of readMarker(xcodeprojPath).buildSettingChanges) { + expect(change.promotedArrayScalars ?? {}).not.toHaveProperty( + 'FRAMEWORK_SEARCH_PATHS', + ); + } + + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + injected.replaceAll(SCALAR, EDITED), + 'utf8', + ); + removeSpmInjection({appRoot, xcodeprojPath}); + + const after = pbxprojOf(xcodeprojPath); + expect(after).toContain(EDITED); + expect(after).not.toContain(SCALAR); + }); +}); + +describe('a promoted array setting the user deleted after add', () => { + const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"'; + + it('is not resurrected by deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(SCALAR), + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const deleted = pbxprojOf(xcodeprojPath).replace( + /\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g, + '', + ); + expect(deleted).not.toContain('HEADER_SEARCH_PATHS'); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + deleted, + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + + // Everything else is back to its pre-injection bytes; only the setting the + // user deleted stays gone. + expect(pbxprojOf(xcodeprojPath)).toBe( + before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''), + ); + }); +}); + +// `deinit` removes appendedArrayValues before it restores promotedArrayScalars, +// so recording a key under both happens to come out right today: the scalar +// restore rewrites the whole value last. That makes the exclusivity below +// invisible to a round-trip test, which is why it is asserted on the marker +// directly — reversing those two loops would otherwise silently start removing +// array members from an already-restored scalar. +describe('a promoted scalar is recorded once, not twice', () => { + it('records promotedArrayScalars and not appendedArrayValues for the key', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths('"$(inherited) $(SRCROOT)/vendor/include"'), + ); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const changes = readMarker(xcodeprojPath).buildSettingChanges; + expect(changes.length).toBeGreaterThan(0); + for (const change of changes) { + expect(Object.keys(change.promotedArrayScalars ?? {})).toContain( + 'HEADER_SEARCH_PATHS', + ); + expect(Object.keys(change.appendedArrayValues ?? {})).not.toContain( + 'HEADER_SEARCH_PATHS', + ); + } + }); +}); 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..7b5ad779194b --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -0,0 +1,1321 @@ +/** + * 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 {RemoteVersionError} = require('../spm-utils'); +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('uses the resolved swiftName (spm.name override) so the manifest matches what the autolinker registers', () => { + const model = podspec({name: 'react-native-worklets'}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-worklets', swiftName: 'worklets'}), + ); + expect(spec.swiftName).toBe('worklets'); + }); + + it('falls back to toSwiftName when the dep carries no resolved name', () => { + const spec = translatePodspecToSpmTarget( + podspec(), + autolinkedDep({name: 'react-native-foo'}), + ); + expect(spec.swiftName).toBe('ReactNativeFoo'); + }); + + it('resolves each sibling through the same overrides, not through toSwiftName', () => { + const model = podspec({dependencies: ['RNWorklets']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated', swiftName: 'reanimated'}), + new Map([['RNWorklets', 'react-native-worklets']]), + new Map([['react-native-worklets', 'worklets']]), + ); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + expect(spec.siblingSwiftNames).toEqual({ + 'react-native-worklets': 'worklets', + }); + }); + + 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('emits the sibling override name for both the package and the product', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({ + siblingNames: ['react-native-worklets'], + siblingSwiftNames: {'react-native-worklets': 'worklets'}, + }), + ); + expect(out).toContain('.package(name: "worklets", path: "../worklets")'); + expect(out).toContain('.product(name: "worklets", package: "worklets")'); + expect(out).not.toContain('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("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('names the scaffolded package with the spm.name override the autolinker resolved', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep( + makeDep({swiftName: 'foo'}), + makeCtx(), + ); + expect(result.status).toBe('written'); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(content).toContain('name: "foo"'); + expect(content).not.toContain('ReactNativeFoo'); + }); + + 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', + ); + }); + + function writeAutolinkingJson(dependencies) { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + } + + it('propagates a Swift name collision instead of scaffolding anyway, plugin or not', () => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope to + // borrow. Degrading to the direct deps would scaffold manifests SPM rejects + // later, and a plugin buys no exemption — `spm scaffold` has no plugin code. + const depRoot = path.join(appRoot, 'node_modules', 'react-headers'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {autolinkingPlugin: './spm-plugin.js'}};\n", + ); + writeAutolinkingJson({ + 'react-headers': {root: depRoot, platforms: {ios: {}}}, + }); + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(/React Native reserves/); + }); + + it('still falls back to the direct deps when a transitive dep cannot be resolved', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-a'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {dependencies: ['ghost-dep-that-is-not-installed']}};\n", + ); + writeAutolinkingJson({ + 'react-native-a': {root: depRoot, platforms: {ios: {}}}, + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.depName)).toEqual(['react-native-a']); + expect(logSpy.mock.calls.map(call => call.join(' ')).join('\n')).toMatch( + /Transitive spm\.dependencies expansion failed/, + ); + } finally { + logSpy.mockRestore(); + } + }); + + it('emits the remote package reference for every dep it scaffolds', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), '// native\n'); + 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}"\n' + + ' s.dependency "React-Core"\n' + + 'end\n', + ); + writeAutolinkingJson({ + 'react-native-foo': {root: depRoot, platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/rn.git'; + process.env.RN_SPM_REMOTE_VERSION = '9.9.9'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.status)).toEqual(['written']); + const manifest = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(manifest).toContain( + '.package(url: "https://example.com/rn.git", exact: "9.9.9")', + ); + expect(manifest).not.toContain('.package(name: "ReactNative"'); + } finally { + logSpy.mockRestore(); + if (prevUrl == null) delete process.env.RN_SPM_REMOTE_URL; + else process.env.RN_SPM_REMOTE_URL = prevUrl; + if (prevVersion == null) delete process.env.RN_SPM_REMOTE_VERSION; + else process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + }); + + it('propagates a RemoteVersionError from the remote package config', () => { + writeAutolinkingJson({ + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/react-native-spm.git'; + delete process.env.RN_SPM_REMOTE_VERSION; + try { + // No react-native under the temp appRoot, so no version resolves — the + // author must see that, not have it degraded into "expansion failed". + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(RemoteVersionError); + } finally { + if (prevUrl == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = prevUrl; + } + if (prevVersion != null) { + process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + } + }); +}); + +// --------------------------------------------------------------------------- +// 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..0721c1b60b69 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -0,0 +1,652 @@ +/** + * 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, + determineVersion, + ensureBothArtifactFlavors, + findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, + parseArgs, + resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, + 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, markerFields = {}) { + 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: [], + ...markerFields, + }), + ); + 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); +} + +describe('parseArgs', () => { + it('parses --config-command as a JSON argv array', () => { + const args = parseArgs([ + 'update', + '--config-command', + '["a","b","config"]', + ]); + + expect(args.action).toBe('update'); + expect(args.configCommand).toEqual(['a', 'b', 'config']); + }); + + it('sets configCommand to null when --config-command is omitted', () => { + expect(parseArgs(['update']).configCommand).toBeNull(); + }); + + it('throws for an invalid --config-command value', () => { + expect(() => parseArgs(['update', '--config-command', 'not json'])).toThrow( + /--config-command/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// generateAutolinkingConfigOrFailClosed — the fail-closed policy main() applies +// to the autolinking config step. Swallowing a config-command error (the old +// behavior) let the build proceed with a silently-empty Autolinked package that +// only surfaced later as `unable to resolve module dependency`. A native- +// module-free app does NOT hit the error path: its command exits 0 with valid +// empty-dependency JSON and the generator returns normally. +// --------------------------------------------------------------------------- + +describe('generateAutolinkingConfigOrFailClosed', () => { + let prevExitCode; + let warnSpy; + + beforeEach(() => { + prevExitCode = process.exitCode; + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + process.exitCode = prevExitCode; + jest.restoreAllMocks(); + }); + + it('returns the config result and leaves the exit code untouched on success', () => { + const result = { + config: {}, + outputPath: '/app/ios/autolinking.json', + rawJson: '{}', + }; + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => result, + }); + + expect(out).toBe(result); + expect(process.exitCode).not.toBe(2); + }); + + it('passes projectRoot and configCommand through to the generator', () => { + let received; + generateAutolinkingConfigOrFailClosed({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + generate: opts => { + received = opts; + return {config: {}, outputPath: '', rawJson: ''}; + }, + }); + + expect(received).toEqual({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + }); + }); + + it('fails closed (null, exit 2, actionable error) when the config command errors', () => { + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => { + throw new Error("'my-cli config' exited with status 1"); + }, + }); + + expect(out).toBeNull(); + expect(process.exitCode).toBe(2); + const warnings = warnSpy.mock.calls.map(c => c.join(' ')).join('\n'); + // Names the override so the next person can fix it... + expect(warnings).toMatch(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + // ...and preserves the underlying cause. + expect(warnings).toMatch(/exited with status 1/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveExplicitConfigCommand — the autolinking config command every action +// (add/update/sync/scaffold) runs with: `--config-command` → +// RCT_SPM_AUTOLINKING_CONFIG_COMMAND → the value pinned in `.spm-injected.json` +// → the built-in default. undefined means "let generateAutolinkingConfig pick +// the env var or the default". +// --------------------------------------------------------------------------- + +describe('resolveExplicitConfigCommand', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const PINNED = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + let logSpy; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + function pin(configCommand) { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', {configCommand}); + } + + it('prefers an explicit --config-command over the env var and the pin', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect( + resolveExplicitConfigCommand( + {configCommand: ['flag', 'config']}, + tempDir, + ), + ).toEqual(['flag', 'config']); + }); + + it('lets the env var win over the pin (a stale pin must not shadow it)', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('uses the pin when neither the flag nor the env var is set', () => { + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + // Names the source, so a stale pin is diagnosable from the build log. + expect(logSpy.mock.calls.map(c => c.join(' ')).join('\n')).toMatch( + /\.spm-injected\.json/, + ); + }); + + it('ignores a blank env var and falls through to the pin', () => { + process.env[ENV] = ' '; + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + }); + + it('falls back to the default (undefined) with no flag, env var or pin', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when the pinned value is malformed', () => { + pin('npx expo-modules-autolinking'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when no project is injected yet', () => { + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); +}); + +// --------------------------------------------------------------------------- +// resolveConfigCommandToPin — what `add`/`update` records in the injection +// marker: the explicit `--config-command`, else the env override, since the +// Xcode build phase inherits neither. null pins nothing (and preserves any +// earlier pin). +// --------------------------------------------------------------------------- + +describe('resolveConfigCommandToPin', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const FROM_ENV = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-pin-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + it('pins the env-derived command when only the env var is set', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect(resolveConfigCommandToPin({configCommand: null})).toEqual(FROM_ENV); + }); + + it('pins the explicit --config-command over the env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect( + resolveConfigCommandToPin({configCommand: ['flag', 'config']}), + ).toEqual(['flag', 'config']); + }); + + it('pins nothing when the env var is blank', () => { + process.env[ENV] = ' \t '; + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('pins nothing when neither the flag nor the env var is set', () => { + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('fails loud rather than pinning garbage from an invalid env var', () => { + process.env[ENV] = 'npx expo-modules-autolinking'; + expect(() => resolveConfigCommandToPin({configCommand: null})).toThrow( + /RCT_SPM_AUTOLINKING_CONFIG_COMMAND/, + ); + }); + + it('is resolved back by a later run with neither flag nor env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', { + configCommand: resolveConfigCommandToPin({configCommand: null}), + }); + delete process.env[ENV]; + + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(FROM_ENV); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); +}); + +// --------------------------------------------------------------------------- +// determineVersion — which RN version the artifact slots are wired to: +// explicit --version → the `artifactsVersionOverride` pinned in the injection +// marker by a previous `--version` → node_modules/react-native/package.json. +// --------------------------------------------------------------------------- + +describe('determineVersion', () => { + let appRoot; + let reactNativeRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-app-')); + reactNativeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-rn-')); + fs.writeFileSync( + path.join(reactNativeRoot, 'package.json'), + JSON.stringify({name: 'react-native', version: '1000.0.0'}), + ); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(reactNativeRoot, {recursive: true, force: true}); + }); + + const logged = () => logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + + it('prefers an explicit --version over a pinned override', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect( + determineVersion({version: '0.81.0'}, reactNativeRoot, appRoot), + ).toBe('0.81.0'); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it('uses the pinned override when --version is omitted', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '0.80.0', + ); + }); + + it('names the marker in the log when the pin is the source', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + determineVersion({version: null}, reactNativeRoot, appRoot); + + expect(logged()).toMatch(/0\.80\.0/); + expect(logged()).toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json with no pin recorded", () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json when no project is injected", () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); + + it('falls back without throwing when the marker is corrupt', () => { + const xcodeproj = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeproj, {recursive: true}); + fs.writeFileSync(path.join(xcodeproj, SPM_INJECTED_MARKER), '{not json'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); +}); 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..6c109ce67dc5 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -0,0 +1,707 @@ +/** + * 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, + commentSafe, + 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', +); + +// The app target's Debug buildSettings dict, as a body range. +function targetDebugDict(text) { + const cfg = findObjectByUuid(text, 'AA0000000000000000000901'); + const bs = findField(text, cfg, 'buildSettings'); + return {uuid: 'x', bodyOpen: bs.valueStart, bodyClose: bs.tokenEnd - 1}; +} + +// Delimiter balance, checked with the module's own quote-aware scanner: the +// outermost `{` must close on the file's last `}`. +function isBalanced(text) { + return scanToClose(text, text.indexOf('{')) === text.lastIndexOf('}'); +} + +// --------------------------------------------------------------------------- +// 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"'], + ['', '""'], + ['a\nb', '"a\\nb"'], + // A literal CR or tab inside a quoted value is legal, but Xcode rewrites it + // to its escape on the next save — a spurious diff in the user's repo. A + // plugin `script` with CRLF line endings is the way one gets in. + ['a\r\nb', '"a\\r\\nb"'], + ['a\tb', '"a\\tb"'], + ])('quoteIfNeeded(%j) => %j', (input, expected) => { + expect(quoteIfNeeded(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// commentSafe — a pbxproj `/* … */` comment is cosmetic (Xcode regenerates it +// from the object's fields), so nothing a scanner could read as structure is +// allowed to reach one. +// --------------------------------------------------------------------------- + +describe('commentSafe', () => { + it.each([ + ['Sync SPM Autolinking', 'Sync SPM Autolinking'], + ['Générer la config 📦', 'Générer la config 📦'], + ['Bad */ = { x', 'Bad x'], + ['A , B', 'A B'], + ['He said "hi', 'He said hi'], + ['A\tB', 'A B'], + ['a\r\nb', 'a b'], + ['Copy A/B', 'Copy A B'], + ['*/*', ''], + [' padded ', 'padded'], + ])('commentSafe(%j) => %j', (input, expected) => { + expect(commentSafe(input)).toBe(expected); + }); + + it('is stable under repetition (so a re-sync stays byte-identical)', () => { + const once = commentSafe('Bad */ = { x, y'); + expect(commentSafe(once)).toBe(once); + }); +}); + +// --------------------------------------------------------------------------- +// 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', () => { + 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"'); + }); + + // Xcode writes the seed quoted, but the unquoted form is just as valid and + // appears in hand-edited projects. Both are the same value to the build + // system, so neither may be re-emitted alongside the seed. + it.each(['"$(inherited)"', '$(inherited)'])( + 'promotes a bare %s scalar without emitting the seed twice', + priorValue => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + `OTHER_LDFLAGS = ${priorValue}; PRODUCT_NAME = "$(TARGET_NAME)";`, + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + const members = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/ + .exec(out)[1] + .split('\n') + .map(line => line.trim().replace(/,$/, '')) + .filter(member => member.length > 0); + // The scalar's value IS the seed the array is created with. + expect(members).toEqual(['"$(inherited)"', '"-ObjC"']); + }, + ); + + it('promotes an empty scalar without emitting a bare `,` member', () => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = ; PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + const block = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/.exec(out)[1]; + // Asserted on the raw block: a member list filtered for emptiness (as the + // test above does) would hide the malformed element this guards against. + expect(block.split('\n').filter(line => /^\s*,$/.test(line))).toEqual([]); + expect(block).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); + }); +}); + +// Xcode writes array build settings multi-line, but hand-edited projects and +// other generators (XcodeGen, Tuist) emit compact one-line ones. Members must +// land INSIDE the array whatever its shape, and `deinit` must be able to take +// them back out again — hence the byte-identical add→remove round trip. +describe('array build settings of every written shape', () => { + const NEW = '"/new"'; + + // Seed `OTHER_LDFLAGS = ;` into the app target's Debug config. + function withValue(value) { + return PLAIN_PBXPROJ.replace( + '\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";', + `\t\t\t\tOTHER_LDFLAGS = ${value};\n\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";`, + ); + } + + function add(text, values) { + return addArrayStringValues( + text, + targetDebugDict(text), + 'OTHER_LDFLAGS', + values, + ); + } + + function remove(text, values) { + return removeArrayStringValues( + text, + targetDebugDict(text), + 'OTHER_LDFLAGS', + values, + ); + } + + describe.each([ + ['an empty array', '()'], + ['a lone member with no trailing comma', '("/a")'], + ['a trailing comma and space', '("/a", )'], + ['no space after the comma', '("/a","/b")'], + ['a space after the comma', '("/a", "/b")'], + ['a member whose quotes hold a comma and parens', '("$(FOO(x)),weird")'], + ['the multi-line shape Xcode writes', '(\n\t\t\t\t\t"/a",\n\t\t\t\t)'], + ])('%s', (_label, shape) => { + const input = withValue(shape); + + it('adds the value inside the array, leaving the file balanced', () => { + const out = add(input, [NEW]); + const field = findField(out, targetDebugDict(out), 'OTHER_LDFLAGS'); + expect(field.value.trimStart().startsWith('(')).toBe(true); + expect(field.value).toContain(NEW); + // Nothing was spliced ahead of the field — i.e. outside the array. + expect(out.slice(0, field.matchStart)).toBe( + input.slice(0, field.matchStart), + ); + expect(isBalanced(out)).toBe(true); + }); + + it.each([[[NEW]], [[NEW, '"/new2"']]])( + 'remove undoes add of %j byte-for-byte', + values => { + const added = add(input, values); + expect(added).not.toBe(input); + expect(remove(added, values)).toBe(input); + }, + ); + }); + + it.each([ + ['a value that is not there', '("/a", )', '"/zzz"'], + ['a member stripped of its quotes', '("$(inherited)", )', '$(inherited)'], + ])('removes nothing when asked for %s', (_label, shape, value) => { + const input = withValue(shape); + expect(remove(input, [value])).toBe(input); + }); + + it('is a no-op when a one-line array already holds the value', () => { + const input = withValue(`(${NEW})`); + expect(add(input, [NEW])).toBe(input); + }); + + it('dedupes a member whose quotes hold a comma', () => { + const weird = '"$(FOO(x)),weird"'; + const input = withValue(`(${weird}, )`); + expect(add(input, [weird])).toBe(input); + }); + + it('splices a multi-line array on its own line, before the closing `)`', () => { + const input = withValue('(\n\t\t\t\t\t"/a",\n\t\t\t\t)'); + expect(add(input, [NEW])).toBe( + input.replace( + '\t\t\t\t\t"/a",\n', + `\t\t\t\t\t"/a",\n\t\t\t\t\t${NEW},\n`, + ), + ); + }); +}); + +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); + }); + + // What makes it safe for the marker to carry a created-field record forward + // across syncs: a field the user has since deleted by hand costs nothing. + it('removeField is a no-op when the field is absent', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + expect(removeField(PLAIN_PBXPROJ, target, '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..e7d7aaac1c54 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -0,0 +1,715 @@ +/** + * 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 { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_HEADERS_TARGET_DIR, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + RemoteVersionError, + RESERVED_SWIFT_NAMES, + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Reserved Swift names — the one list the manifests and the guard both use +// --------------------------------------------------------------------------- + +describe('reserved Swift names', () => { + it('names the React Native package and the per-app codegen package', () => { + expect(REACT_NATIVE_PACKAGE_NAME).toBe('ReactNative'); + expect(REACT_CODEGEN_PACKAGE_NAME).toBe('React-GeneratedCode'); + }); + + it('pins each product list to its literal names', () => { + expect(REACT_NATIVE_PRODUCTS).toEqual([ + 'ReactHeaders', + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + expect(REACT_CODEGEN_PRODUCTS).toEqual(['ReactAppHeaders']); + expect(REACT_CODEGEN_APP_PRODUCTS).toEqual([ + 'ReactCodegen', + 'ReactAppDependencyProvider', + ]); + }); + + it('tags each React Native product by kind, so no consumer has to infer it from position', () => { + expect(REACT_NATIVE_UMBRELLA_PRODUCT).toBe('ReactHeaders'); + expect(REACT_NATIVE_HEADERS_PRODUCT).toBe('ReactNativeHeaders'); + expect(REACT_NATIVE_XCFRAMEWORK_PRODUCTS).toEqual([ + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + }); + + it('names the autolinking aggregator package (which shares its name with its product)', () => { + expect(AUTOLINKED_PACKAGE_NAME).toBe('Autolinked'); + }); + + it('names the invariant React headers target directory', () => { + expect(REACT_HEADERS_TARGET_DIR).toBe('ReactHeadersTarget'); + }); + + // The one test that pins the literal strings: every other check compares + // constants to constants, so this is what would catch a rename. + it('reserves exactly the names React Native puts in a manifest', () => { + expect([...RESERVED_SWIFT_NAMES].sort()).toEqual([ + 'Autolinked', + 'React-GeneratedCode', + 'ReactAppDependencyProvider', + 'ReactAppHeaders', + 'ReactCodegen', + 'ReactHeaders', + 'ReactNative', + 'ReactNativeDependenciesHeaders', + 'ReactNativeHeaders', + ]); + }); + + it('holds names that real generated manifests actually use', () => { + const { + generateXCFrameworksPackageSwift, + } = require('../generate-spm-package'); + const manifest = generateXCFrameworksPackageSwift(); + for (const name of [REACT_NATIVE_PACKAGE_NAME, ...REACT_NATIVE_PRODUCTS]) { + expect(manifest).toContain(`"${name}"`); + } + }); + + it('does NOT reserve the headers target dir — target names only have to be unique within their own package', () => { + expect(RESERVED_SWIFT_NAMES).not.toContain(REACT_HEADERS_TARGET_DIR); + }); + + it('freezes the lists so no caller can mutate the shared source of truth', () => { + for (const list of [ + REACT_NATIVE_PRODUCTS, + REACT_CODEGEN_PRODUCTS, + REACT_CODEGEN_APP_PRODUCTS, + RESERVED_SWIFT_NAMES, + ]) { + expect(Array.isArray(list)).toBe(true); + expect(Object.isFrozen(list)).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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..78eab0be9602 --- /dev/null +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -0,0 +1,411 @@ +/** + * 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'], + * // Build-time shell phases on the app target — SwiftPM has no + * // `script_phase`. Only id/name/script are required; `id` is the + * // ledger key and UUID seed, `position` defaults to 'end'. Recorded + * // to `.spm-plugin-script-phases.json`, from which the `spm add`/ + * // `update` injector emits a PBXShellScriptBuildPhase per entry. + * // Malformed entries and duplicate ids are fatal. + * scriptPhases: [{id, name, script, position, inputPaths, outputPaths, + * alwaysOutOfDate}], + * }; + * }; + * + * The plugin returns DATA; it never writes into RN's generated tree. RN owns + * the merge, so regeneration stays deterministic and idempotent. + */ + +const {isValidScriptPhaseId, isValidScriptPhaseName} = require('./spm-utils'); +const path = require('path'); + +/*:: import type { + AutolinkedDep, + PluginContext, + PluginResult, + PluginScriptPhase, + 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 */ = null; + 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; +} + +const isNonEmptyString = (value /*: unknown */) /*: boolean */ => + typeof value === 'string' && value.length > 0; + +const isOptionalStringList = (value /*: unknown */) /*: boolean */ => + value == null || (Array.isArray(value) && value.every(isNonEmptyString)); + +/** + * 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 scriptPhases /*: Array */ = []; + const seenPackages /*: Set */ = new Set(); + const seenProducts /*: Set */ = new Set(); + const seenFrameworkIds /*: Set */ = new Set(); + const seenFrameworkNames /*: Set */ = new Set(); + const seenScriptPhaseIds /*: Set */ = new Set(); + + for (const {depName, pluginPath, plugin} of plugins) { + let result /*: unknown */ = null; + 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 ?? []; + // $FlowFixMe[incompatible-use] + const rawScriptPhases = result.scriptPhases ?? []; + if (!Array.isArray(rawFrameworks)) { + throw new Error( + `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`, + ); + } + if (!Array.isArray(rawScriptPhases)) { + throw new Error( + `react-native spm: '${depName}' returned a non-array scriptPhases.`, + ); + } + // 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); + } + // Fatal like flavoredFrameworks, not dropped like watchPaths: a missing + // phase produces a GREEN build whose generated content was never written. + for (const phase of rawScriptPhases) { + if ( + phase == null || + !isValidScriptPhaseId(phase.id) || + !isNonEmptyString(phase.script) || + (phase.position != null && + phase.position !== 'beforeCompile' && + phase.position !== 'end') || + !isOptionalStringList(phase.inputPaths) || + !isOptionalStringList(phase.outputPaths) || + (phase.alwaysOutOfDate != null && + typeof phase.alwaysOutOfDate !== 'boolean') + ) { + const named = typeof phase?.id === 'string' ? ` '${phase.id}'` : ''; + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase${named} ` + + '(need {id (matching /^[@A-Za-z0-9_./-]+$/, and not __proto__, ' + + 'constructor or prototype), name, script} plus optional ' + + '{position: "beforeCompile" | "end", inputPaths, outputPaths, ' + + 'alwaysOutOfDate}).', + ); + } + if (!isValidScriptPhaseName(phase.name)) { + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase name for ` + + `'${phase.id}' (a name must be a non-empty single-line string — it is ` + + "the phase's display name in Xcode).", + ); + } + if (seenScriptPhaseIds.has(phase.id)) { + throw new Error( + `react-native spm: duplicate script phase id '${phase.id}'.`, + ); + } + seenScriptPhaseIds.add(phase.id); + const normalized /*: PluginScriptPhase */ = { + id: phase.id, + name: phase.name, + script: phase.script, + position: phase.position ?? 'end', + }; + if (phase.inputPaths != null) { + normalized.inputPaths = [...phase.inputPaths]; + } + if (phase.outputPaths != null) { + normalized.outputPaths = [...phase.outputPaths]; + } + if (phase.alwaysOutOfDate != null) { + normalized.alwaysOutOfDate = phase.alwaysOutOfDate; + } + scriptPhases.push(normalized); + } + } + + return { + packageDependencies, + productDependencies, + generatedSources, + flavoredFrameworks, + watchPaths, + scriptPhases, + }; +} + +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..c3ebc32d5935 --- /dev/null +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -0,0 +1,1467 @@ +/** + * 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}; +} + +/** + * Resolves the `hermes-compiler` npm package's version from THIS project's own + * node_modules — the exact same lookup generate-spm-xcodeproj.js's + * resolveHermesCliPathSetting() uses to find the hermesc binary that will + * compile the JS bundle, and the same one react-native-xcode.sh falls back to + * for SwiftPM builds. Returns null when the package isn't resolvable (e.g. + * USE_HERMES=false apps that never installed it) so the caller can fall back + * to the npm dist-tag lookup. + */ +function resolveLocalHermesCompilerVersion( + rnRoot /*: string */, +) /*: string | null */ { + try { + const pkgPath = require.resolve('hermes-compiler/package.json', { + paths: [rnRoot], + }); + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkg /*: {version: string} */ = JSON.parse( + fs.readFileSync(pkgPath, 'utf8'), + ); + assertSafeVersion(pkg.version, 'local hermes-compiler/package.json'); + return pkg.version; + } catch (error) { + // A MODULE_NOT_FOUND resolution failure is the expected case (e.g. + // USE_HERMES=false apps that never installed hermes-compiler) — fall back + // silently. Any other failure means hermes-compiler IS installed but its + // package.json is unreadable/malformed or carries an unsafe version; warn + // loudly rather than silently regressing to the live latest-v1 dist-tag, + // which would re-introduce the version-skew crash this resolves (#57917). + if (error.code !== 'MODULE_NOT_FOUND') { + log( + ` WARNING: hermes-compiler is installed but its version could not be resolved (${error.message}); falling back to the latest-v1 dist-tag, which may not match the pinned hermesc and can crash at launch with "Wrong bytecode version".`, + ); + } + return null; + } +} + +/** + * 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. + * + * HERMES_VERSION unset → version pinned by the locally installed + * hermes-compiler package (node_modules). + * This is the SAME source + * resolveHermesCliPathSetting() reads for + * HERMES_CLI_PATH, so the downloaded VM and + * the hermesc that compiles the JS bundle + * always agree — a mismatched pair crashes at + * launch with "Wrong bytecode version" (#57917). + * Falls back to the 'latest-v1' npm dist-tag + * (RN's CocoaPods prebuild default; see + * scripts/ios-prebuild/hermes.js) only when + * hermes-compiler isn't locally resolvable. + * HERMES_VERSION=latest-v1 → 'latest-v1' dist-tag (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 */, + rnRoot /*: string */, +) /*: Promise */ { + let version = process.env.HERMES_VERSION; + + if (version == null) { + const localVersion = resolveLocalHermesCompilerVersion(rnRoot); + if (localVersion != null) { + log(` Using locally pinned hermes-compiler: ${localVersion}`); + } + version = localVersion ?? '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, rnRoot), + 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, + resolveLocalHermesCompilerVersion, + 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..4aea9de5ee59 --- /dev/null +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -0,0 +1,435 @@ +/** + * 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 {RESERVED_SWIFT_NAMES, makeLogger, toSwiftName} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +const {warn} = makeLogger('expand-spm-dependencies'); + +/** + * 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, log) 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 Log = (message: string) => void; +// Keyed by lower case, valued with the canonical spelling: two names differing +// only in case are not distinct enough for the build to keep the two apart. +type ReservedNames = ReadonlyMap; +type Options = { + readConfig: ReadConfig, + resolveDep: ResolveDep, + // Names to reserve alongside RESERVED_SWIFT_NAMES, supplied by the caller + // (remote mode relabels the RN package) since this module reads no config. + extraReservedNames?: ?ReadonlyArray, + log?: ?Log, +}; +*/ + +/** + * A misconfiguration rather than a resolution failure: scaffoldAll degrades past + * a transitive dep it cannot find, but must still surface this. + */ +class SpmNameCollisionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'SpmNameCollisionError'; + } +} + +// The charset `spm.name` must satisfy — permissive on purpose, since it has to +// admit header-dir style (lowercase with hyphens) as well as Swift identifiers. +// Shared with the app's own `spm.modules` names. +function isValidSwiftName(name /*: unknown */) /*: boolean */ { + return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name); +} + +function reservedSwiftNames( + extraReservedNames /*: ?ReadonlyArray */, +) /*: ReservedNames */ { + return new Map( + [...RESERVED_SWIFT_NAMES, ...(extraReservedNames ?? [])].map(name => [ + name.toLowerCase(), + name, + ]), + ); +} + +// The scope-borrowed form of a name: `@powersync/react-native`'s `ReactNative` +// becomes `PowersyncReactNative`. +function scopeBorrowedName( + npmName /*: string */, + swiftName /*: string */, +) /*: ?string */ { + const scope = /^@([^/]+)\//.exec(npmName)?.[1]; + return scope == null ? null : `${toSwiftName(scope)}${swiftName}`; +} + +// The Swift target name for one dep, judged in isolation. `spm.name` is for +// libraries whose import prefix differs from the derived name: +// `react-native-worklets` ships headers as `` (podspec +// `s.header_dir`), so its target is `worklets`, not `ReactNativeWorklets`. A +// derived name that lands on a reserved one borrows the npm scope instead. +function resolveSwiftName( + npmName /*: string */, + config /*: ?RnConfig */, + reserved /*: ReservedNames */, + log /*:: ?: ?Log */, +) /*: string */ { + // $FlowFixMe[prop-missing] config has dynamic shape + const override = config?.spm?.name; + if (override != null) { + 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)}.`, + ); + } + if (!isValidSwiftName(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; + } + + const derived = toSwiftName(npmName); + if (!reserved.has(derived.toLowerCase())) { + return derived; + } + const disambiguated = scopeBorrowedName(npmName, derived); + if (disambiguated == null || reserved.has(disambiguated.toLowerCase())) { + return derived; + } + log?.( + `'${npmName}' would take React Native's reserved name '${derived}', so its npm scope is prepended: '${disambiguated}'. ` + + `Set 'spm.name' in ${npmName}'s react-native.config.js to choose the name yourself.`, + ); + return disambiguated; +} + +function assertNameNotReserved( + swiftName /*: string */, + reserved /*: ReservedNames */, + labels /*: {label: string, remedy: string} */, +) /*: void */ { + const reservedName = reserved.get(swiftName.toLowerCase()); + if (reservedName == null) { + return; + } + // Vaguer about the case clash than the dep-vs-dep message on purpose: this + // set spans package identities and product names, which collide differently. + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: ${labels.label} resolves to '${swiftName}', ` + + (reservedName === swiftName + ? `which React Native reserves for its own SPM package and products.` + : `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`) + + ` ${labels.remedy}`, + ); +} + +/** + * Throws when `swiftName` is one React Native's own manifests use. `remedy` is + * the fix: a library sets `spm.name`, an app renames its `spm.modules` entry. + */ +function assertSwiftNameNotReserved( + swiftName /*: string */, + options /*: { + label: string, + remedy: string, + extraReservedNames?: ?ReadonlyArray, + } */, +) /*: void */ { + const {label, remedy, extraReservedNames} = options; + assertNameNotReserved(swiftName, reservedSwiftNames(extraReservedNames), { + label, + remedy, + }); +} + +// Reserved-name backstop over the resolved set. Unconditional: a plugin-shipping +// library is checked like any other, so `spm scaffold` — which knows nothing +// about plugins — cannot disagree with the autolinker about the same dep. +function assertNoReservedSwiftNames( + deps /*: ReadonlyArray */, + reserved /*: ReservedNames */, +) /*: void */ { + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + assertNameNotReserved(swiftName, reserved, { + label: `'${dep.name}'`, + remedy: `Set a different 'spm.name' in ${dep.name}'s react-native.config.js.`, + }); + } +} + +// Pulls apart deps that resolved to the same name by borrowing their npm scopes. +// Every scoped member of a colliding group moves: there is no non-arbitrary +// winner to keep. Exactly one pass — retrying would trade a diagnosable error +// for a name nobody can predict. +function disambiguateSharedSwiftNames( + deps /*: ReadonlyArray */, + autoNamed /*: ReadonlySet */, + log /*: ?Log */, +) /*: void */ { + const groups /*: Map> */ = + new Map(); + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + const key = swiftName.toLowerCase(); + const group = groups.get(key); + if (group == null) { + groups.set(key, [{dep, swiftName}]); + } else { + group.push({dep, swiftName}); + } + } + + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } + for (const {dep, swiftName} of group) { + // A name we derived can borrow a second time (`AAReactNative`); the + // member whose name we did not derive is the incumbent and keeps it. + if (!autoNamed.has(dep.name)) { + continue; + } + const borrowed = scopeBorrowedName(dep.name, swiftName); + if (borrowed == null) { + continue; + } + const others = group + .filter(other => other.dep !== dep) + .map(other => `'${other.dep.name}'`) + .join(', '); + log?.( + `'${dep.name}' would share the name '${swiftName}' with ${others}, so its npm scope is prepended: '${borrowed}'. ` + + `Set 'spm.name' in ${dep.name}'s react-native.config.js to choose the name yourself.`, + ); + dep.swiftName = borrowed; + } + } +} + +function expandSpmDependencies( + directDeps /*: Array */, + options /*: Options */, +) /*: Array */ { + const {readConfig, resolveDep, extraReservedNames, log} = options; + const reserved = reservedSwiftNames(extraReservedNames); + const byName /*: Map */ = new Map(); + for (const dep of directDeps) { + byName.set(dep.name, {...dep, spmDependencies: []}); + } + const autoNamed /*: Set */ = new Set(); + const resolveName = ( + npmName /*: string */, + config /*: ?RnConfig */, + ) /*: string */ => { + // $FlowFixMe[prop-missing] config has dynamic shape + if (config?.spm?.name == null) { + autoNamed.add(npmName); + } + return resolveSwiftName(npmName, config, reserved, log); + }; + + 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 = resolveName(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: resolveName(transitiveName, transitiveConfig), + spmDependencies: [], + }); + queue.push(transitiveName); + } + currentSpmDeps.push(transitiveName); + } + current.spmDependencies = currentSpmDeps; + } + + const allDeps /*: Array */ = Array.from(byName.values()); + + disambiguateSharedSwiftNames(allDeps, autoNamed, log); + + // Both checks below validate the FINAL set, after that pass: a borrowed scope + // can land on a reserved name, or on one another dep already holds. + assertNoReservedSwiftNames(allDeps, reserved); + + // 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 allDeps) { + 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 SpmNameCollisionError( + `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 allDeps; +} + +// --------------------------------------------------------------------------- +// 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] + const mod = require(configPath); + // Read both export styles, because the community CLI's two loaders + // disagree with each other: its sync path (`loadConfig`) requires the + // module and sees named exports at top level, its async path + // (`loadConfigAsync`) takes the default export only. Merging covers both, + // with named exports winning — the shape the sync path already resolves. + // Every sibling key of the default export is preserved + // (`dependency.platforms.ios` is read from this result too). + // A function-style config (`module.exports = () => ({...})`) and other + // non-objects pass through untouched — there is no default export to + // unwrap, and nulling them would hide a config that used to be read. + if (mod == null || typeof mod !== 'object') { + return mod; + } + const dflt = mod.default; + if (dflt == null || typeof dflt !== 'object') { + return mod; + } + const {default: _unused, ...named} = mod; + return {...dflt, ...named}; + } catch (e) { + // A config can fail to load for reasons unrelated to SPM (it may import a + // devDependency absent in a consumer install), so this stays a warning — + // but a silent null turns a dropped `spm` block into a link error much + // later. + warn( + `Failed to load ${configPath}: ${e.message}. Any 'spm' settings in it are ignored.`, + ); + 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 = { + SpmNameCollisionError, + assertSwiftNameNotReserved, + expandSpmDependencies, + isValidSwiftName, + 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..22053ba5c8ef --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -0,0 +1,221 @@ +/** + * 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. + * The config command can be overridden by `--config-command` or + * `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`, in that order, before the default. + * + * 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 parseConfigCommandJson( + raw /*: string */, + source /*: string */, +) /*: Array */ { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + `${source}: config command must be a JSON array of strings. Example: '["npx","@react-native-community/cli","config"]'`, + ); + } + + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + !parsed.every(value => typeof value === 'string' && value.length > 0) + ) { + throw new Error( + `${source}: config command must be a non-empty JSON array of non-empty strings`, + ); + } + + return parsed; +} + +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; +} + +const ENV_CONFIG_COMMAND = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + +// The raw env override, or null when unset or blank. Exported so callers that +// only need to know whether the override is in play (setup-apple-spm.js) share +// this blankness rule instead of re-deriving it. +function readEnvConfigCommand() /*: ?string */ { + const raw = process.env[ENV_CONFIG_COMMAND]; + return typeof raw === 'string' && raw.trim().length > 0 ? raw : null; +} + +// The env override, parsed and validated, or null when unset or blank. Throws +// on a set-but-invalid value — never silently degrades to the default. +function resolveEnvConfigCommand() /*: ?Array */ { + const raw = readEnvConfigCommand(); + return raw == null ? null : parseConfigCommandJson(raw, ENV_CONFIG_COMMAND); +} + +// Env-var / default resolution for the autolinking config command. An explicit +// `configCommand` (e.g. from `--config-command`) is handled upstream by +// generateAutolinkingConfig's destructuring default, so it never reaches here — +// this only decides between the env-var override and the built-in default. +function resolveConfigCommand(projectRoot /*: string */) /*: Array */ { + return resolveEnvConfigCommand() ?? resolveDefaultConfigCommand(projectRoot); +} + +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 = resolveConfigCommand(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, + parseConfigCommandJson, + readEnvConfigCommand, + resolveConfigCommand, + resolveDefaultConfigCommand, + resolveEnvConfigCommand, +}; 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..b77decb60562 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -0,0 +1,2005 @@ +/** + * 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, + PluginScriptPhase, + 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 { + SpmNameCollisionError, + assertSwiftNameNotReserved, + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, + isValidSwiftName, +} = require('./expand-spm-dependencies'); +const {readPodspec} = require('./read-podspec'); +const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + RemoteVersionError, + findProjectRoot, + makeLogger, + remotePackageConfig, +} = 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 : REACT_NATIVE_PACKAGE_NAME; +} +// In remote mode the RN package is labelled with the remote identity, so that +// name is reserved for this run too. +function reservedNamesForRun() /*: ?Array */ { + return remoteCfg != null ? [remoteCfg.identity] : undefined; +} +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 [ + ...REACT_NATIVE_PRODUCTS.map(name => ({name, package: rn})), + ...REACT_CODEGEN_PRODUCTS.map(name => ({ + name, + package: REACT_CODEGEN_PACKAGE_NAME, + })), + ]; +} +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: REACT_NATIVE_PACKAGE_NAME, + path: toPosix(absXcframeworks), + relPath: + xcframeworksRelPath != null ? toPosix(xcframeworksRelPath) : undefined, + }; + } else { + return null; + } + const products = reactProducts().filter( + p => p.package !== REACT_CODEGEN_PACKAGE_NAME || 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 + * } + * ] + * } + * } + */ +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 []; + } +} + +/** + * Validates one app-local `spm.modules` name against the same rules a library's + * `spm.name` gets: a usable Swift identifier, not a name React Native reserves, + * and not one already taken by another module or an autolinked dep. + * `taken` maps lower-cased name → the name as written. + */ +function assertSpmModuleName( + name /*: unknown */, + taken /*: Map */, +) /*: void */ { + const remedy = + "Rename it in this app's react-native.config.js 'spm.modules'."; + if (typeof name !== 'string' || !isValidSwiftName(name)) { + throw new Error( + `react-native autolinking: invalid 'spm.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + const moduleName = name; + assertSwiftNameNotReserved(moduleName, { + label: `the 'spm.modules' entry '${moduleName}'`, + remedy, + extraReservedNames: reservedNamesForRun(), + }); + const clash = taken.get(moduleName.toLowerCase()); + if (clash != null) { + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` + + (clash === moduleName + ? `is already the name of another autolinked target.` + : `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) + + ` ${remedy}`, + ); + } +} + +/** + * 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, honoring the dep's `spm.name` + * config and scope disambiguation). Every name this function emits comes from + * there — see requireSwiftName. + */ +/** + * 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; +} + +/** + * The Swift name expandSpmDependencies resolved for `npmName`, or a hard error: + * re-deriving one here would emit a reference nothing in the graph matches. + */ +function requireSwiftName( + npmName /*: string */, + resolved /*: ?string */, +) /*: string */ { + if (resolved == null) { + throw new Error( + `react-native autolinking: no resolved Swift name for '${npmName}'. expandSpmDependencies must resolve every autolinked dep's name before SPM targets are generated.`, + ); + } + return resolved; +} + +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); + + const targetName = requireSwiftName(depName, dep.swiftName); + + // 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. + const spmDeps /*: Array */ = dep.spmDependencies ?? []; + const spmTargetDependencies = + spmDeps.length > 0 + ? spmDeps.map(n => requireSwiftName(n, swiftNameByNpm.get(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: "${REACT_NATIVE_PACKAGE_NAME}", 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_CODEGEN_PACKAGE_NAME}", 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"), .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_PACKAGE_NAME}", + platforms: [.iOS(.v15)], + products: [ + .library(name: "${AUTOLINKED_PACKAGE_NAME}", 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: "${REACT_NATIVE_PACKAGE_NAME}", path: "${reactNativePackagePath}")`, + ), + ); + // Per-app generated headers come from the ReactAppHeaders product in + // the codegen package. + packageDeps.push( + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", 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"), .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, + extraReservedNames: reservedNamesForRun(), + log, + }); + + // Map every autolinked npm name to its resolved Swift name so transitive + // references inside autolinkingDepToSpmTarget find the right target + // identifier. + 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), + ); + + // Skipped means no sibling package is created for the host either, so a + // dep declaring it in `spm.dependencies` gets a package reference to a + // path this run never writes — SPM then reports only the missing path. + // Only manifests React Native emits can carry that reference: a dep + // shipping its own Package.swift declares its package references itself, + // and the classification loop below would treat it as self-managed. + const pluginHostDependents /*: Map> */ = new Map(); + for (const dep of allDeps) { + const declaredHosts = (dep.spmDependencies ?? []).filter(name => + pluginHostDeps.has(name), + ); + if (declaredHosts.length === 0) { + continue; + } + const sourceDir = dep.platforms.ios.sourceDir ?? dep.root; + if (sourceDir == null || findSelfManagedPackageDir(sourceDir) != null) { + continue; + } + for (const host of declaredHosts) { + const dependents = pluginHostDependents.get(host) ?? []; + dependents.push(dep.name); + pluginHostDependents.set(host, dependents); + } + } + + for (const dep of allDeps) { + if (pluginHostDeps.has(dep.name)) { + const dependents = pluginHostDependents.get(dep.name); + if (dependents != null) { + throw new Error( + `react-native autolinking: '${dep.name}' ships an SPM autolinking plugin, which owns its native contribution — so React Native does not build it as a sibling target for anything to depend on. It is declared in 'spm.dependencies' by ${dependents.map(name => `'${name}'`).join(', ')}. Remove it there; nothing is lost. Its plugin links its products into the app and resolves its own ecosystem's dependencies, so a library that builds against it does not declare it here.`, + ); + } + 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); + // Module names land in the manifest exactly as written, so they get the same + // checks a dep's Swift name gets. Seeded with the dep target names already + // emitted so a module can't shadow an autolinked library either. + const takenSwiftNames /*: Map */ = new Map( + entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]), + ); + for (const mod of configModules) { + assertSpmModuleName(mod.name, takenSwiftNames); + takenSwiftNames.set(mod.name.toLowerCase(), mod.name); + 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 ?? [], + // The synth wrapper owns the module's public interface: it declares + // publicHeadersPath: "include", a symlink to the module's header tree. + 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, explicitly declared in react-native.config.js) keep their synth + // wrappers: an app-local dir has no npm identity, so there is no package for + // the aggregator to reference until one is written for it. + 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: the synth wrapper is the mechanism, not a fallback — an + // app-local dir has no npm identity, so the wrapper is the only package the + // aggregator can reference. No podspec is read on this route by design: + // app-local native code isn't required to carry one. (A hand-written + // Package.swift still wins — the self-managed check above claims it first.) + // 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 */ = []; + let pluginScriptPhases /*: 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; + pluginScriptPhases = result.scriptPhases; + log( + `SPM plugins contributed ${pluginPackageDeps.length} package(s), ` + + `${pluginProductDeps.length} product(s), ` + + `${pluginGeneratedSources.length} generated source(s), ` + + `${pluginFlavoredFrameworks.length} flavored framework(s), ` + + `${pluginScriptPhases.length} script phase(s)`, + ); + } + + // Plugin sidecars. All 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', + ); + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-script-phases.json'), + JSON.stringify(pluginScriptPhases, 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, + autolinkingDepToSpmTarget, + 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..83594d80b749 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-package.js @@ -0,0 +1,314 @@ +/** + * 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 { + REACT_HEADERS_TARGET_DIR, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + 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 */ { + // Each product's target follows from its KIND, not from its position in the + // list: the umbrella is a Clang target over the staged headers, and every + // xcframework-backed product gets a binaryTarget of the same name. + const products = REACT_NATIVE_PRODUCTS.map( + product => ` .library(name: "${product}", targets: ["${product}"]),`, + ); + const targets = [ + ` .target( + name: "${REACT_NATIVE_UMBRELLA_PRODUCT}", + dependencies: ["${REACT_NATIVE_HEADERS_PRODUCT}"], + path: "${REACT_HEADERS_TARGET_DIR}", + publicHeadersPath: "include" + ),`, + ...REACT_NATIVE_XCFRAMEWORK_PRODUCTS.map( + product => ` .binaryTarget( + name: "${product}", + path: "${product}.xcframework" + ),`, + ), + ]; + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-package.js – do not edit manually. +import PackageDescription + +let package = Package( + name: "${REACT_NATIVE_PACKAGE_NAME}", + products: [ +${products.join('\n')} + ], + targets: [ +${targets.join('\n')} + ] +) +`; +} + +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..fe4687a22101 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -0,0 +1,2790 @@ +/** + * 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 {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); +const { + addArrayMembers, + addArrayStringValues, + commentSafe, + ensureScalarField, + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, + insertObjectsIntoSection, + namespacedUUID, + quoteIfNeeded, + removeArrayMembersByUuid, + removeArrayStringValues, + removeDanglingJavaScriptCoreRef, + removeEmptyPodsGroup, + removeField, + removeObjectByUuid, + serializeEntry, + setScalarField, + uuidComment, +} = require('./spm-pbxproj'); +const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_CODEGEN_PACKAGE_NAME, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + isValidScriptPhaseId, + isValidScriptPhaseName, + makeLogger, + remotePackageConfig, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +/*:: import type { + FlavoredFrameworkManifestEntry, + PluginScriptPhase, + 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', +); + +// Manifest of plugin-contributed build-time shell phases for the app target — +// SwiftPM has no `script_phase`, so a framework that must generate content into +// the app bundle (expo-constants' `EXConstants.bundle/app.config`) declares one +// through the plugin contract. +const SPM_SCRIPT_PHASES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.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}> */ = + [ + ...REACT_NATIVE_PRODUCTS.map(product => ({ + product, + packagePath: 'build/xcframeworks', + packageName: REACT_NATIVE_PACKAGE_NAME, + })), + { + product: AUTOLINKED_PACKAGE_NAME, + packagePath: 'build/generated/autolinking', + packageName: AUTOLINKED_PACKAGE_NAME, + }, + ...REACT_CODEGEN_APP_PRODUCTS.map(product => ({ + product, + packagePath: 'build/generated/ios', + packageName: REACT_CODEGEN_PACKAGE_NAME, + })), + ]; + +/*:: +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}, + // Array settings that existed as a SCALAR and were promoted to a `( … )` + // array (key → the pre-injection raw value text, quotes included). Deinit + // restores that value verbatim; removing the injected members would leave + // the promoted array and its `"$(inherited)"` seed behind. + promotedArrayScalars?: {[string]: string}, +}; +// An array field injection CREATED (rather than appended to a pre-existing +// one), so deinit removes the whole field and lands byte-identical. +type CreatedArrayField = {container: 'project' | 'target', key: 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}; +} + +/** + * Build a PBXShellScriptBuildPhase entry. `inputPaths`/`outputPaths` accept + * either a plain path array or an already-serialized pbxproj list. + * `alwaysOutOfDate` emits Xcode's own `alwaysOutOfDate = 1;` (unquoted, right + * after `isa`, so a project Xcode rewrites stays diff-free) and is omitted + * entirely when false. `comment` overrides the cosmetic `/* … *​/` label, which + * otherwise derives from `name`. + */ +function shellScriptPhase( + phaseUUID /*: string */, + name /*: string */, + script /*: string */, + options /*: {inputPaths?: ?(string | ReadonlyArray), outputPaths?: ?(string | ReadonlyArray), alwaysOutOfDate?: ?boolean, comment?: string} */ = {}, +) /*: {uuid: string, comment: string, fields: {[string]: string}} */ { + const empty = '(\n\t\t\t)'; + const pathList = ( + value /*: ?(string | ReadonlyArray) */, + ) /*: string */ => { + if (value == null) { + return empty; + } + return typeof value === 'string' ? value : pbxPathList(value); + }; + return { + uuid: phaseUUID, + comment: options.comment ?? name, + fields: { + isa: 'PBXShellScriptBuildPhase', + ...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}), + buildActionMask: '2147483647', + files: empty, + inputFileListPaths: empty, + inputPaths: pathList(options.inputPaths), + name: quoteIfNeeded(name), + outputFileListPaths: empty, + outputPaths: pathList(options.outputPaths), + 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} +`; +} + +/** + * The app target's `buildPhases` members, in the order the file lists them. + * Line-leading UUIDs only: a member's trailing comment carries a plugin-supplied + * phase name, and one that happens to look like a UUID would otherwise read as an + * extra member — leaving the actual order permanently at odds with the declared + * one, and offering a non-member as a re-seating anchor. + */ +function buildPhaseOrder( + text /*: string */, + target /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: Array */ { + const field = findField(text, target, 'buildPhases'); + if (field == null) { + return []; + } + return [...field.value.matchAll(/^[\t ]*([0-9A-Fa-f]{24})\b/gm)].map( + m => m[1], + ); +} + +/** + * Rewrite the trailing comment Xcode keeps beside a UUID — on the object's own + * definition line and on every array-member line referencing it. Xcode + * normalizes those comments on its next write, so leaving a stale one behind + * (after a plugin renames a phase) plants a spurious diff in the user's repo. + * No-op when the comment already reads `comment`. + */ +function setUuidComment( + text /*: string */, + uuid /*: string */, + comment /*: string */, +) /*: string */ { + return text.replace( + new RegExp(`(\\n[\\t ]*${uuid})(?: /\\* [^\\n]*? \\*/)?( = \\{|,)`, 'g'), + (_match, head, tail) => `${head}${uuidComment(comment)}${tail}`, + ); +} + +/*:: type SeatedPhase = {uuid: string, comment: string, position: 'beforeCompile' | 'end'}; */ + +/** + * Seat the plugin phases in the order the manifest declares: `beforeCompile` + * ones directly after the Sync SPM Autolinking phase (which stays first — it + * regenerates the content everything else reads) and always before Sources, + * `end` ones at the true end of `buildPhases`, after the app's own JS-bundle + * phase. The other members keep their relative order. + * + * `beforeCompile` is anchored on Sources, not merely on the sync phase: RN never + * re-seats its own sync phase, so a user who drags it below Sources would + * otherwise have every `beforeCompile` phase seated after compilation — the one + * thing the position promises not to do. Without a Sources phase the sync phase + * is the anchor. + * + * Rewrites the membership lines ONLY when the actual order differs from that, + * which is what keeps an unchanged sync byte-identical — `addBuildPhaseAfter` + * and `addArrayMembers` both short-circuit on a UUID that is already a member, + * so re-placing has to be driven from here. A phase a user dragged elsewhere in + * Xcode is therefore moved back: the declared position wins. + */ +function seatScriptPhases( + input /*: string */, + targetUuid /*: string */, + syncPhaseUuid /*: string */, + phases /*: ReadonlyArray */, + sourcesPhaseUuid /*: ?string */, +) /*: string */ { + const pluginUuids = new Set(phases.map(p => p.uuid)); + const actual = buildPhaseOrder( + input, + findApplicationTargetByUuid(input, targetUuid), + ); + const others = actual.filter(uuid => !pluginUuids.has(uuid)); + const beforeCompile = phases.filter(p => p.position === 'beforeCompile'); + const atEnd = phases.filter(p => p.position !== 'beforeCompile'); + const sourcesAt = + sourcesPhaseUuid != null ? others.indexOf(sourcesPhaseUuid) : -1; + const afterSyncAt = others.indexOf(syncPhaseUuid) + 1; + // 0 — no sync phase member, or Sources ahead of it — makes the beforeCompile + // phases lead the array, matching addArrayMembers' prepend fallback below. + const insertAt = + sourcesAt >= 0 ? Math.min(afterSyncAt, sourcesAt) : afterSyncAt; + const desired = [ + ...others.slice(0, insertAt), + ...beforeCompile.map(p => p.uuid), + ...others.slice(insertAt), + ...atEnd.map(p => p.uuid), + ]; + if ( + desired.length === actual.length && + desired.every((uuid, i) => uuid === actual[i]) + ) { + return input; + } + // File-wide removal is safe for a shell-phase UUID: it appears on its own + // definition line (which ends `= {`, never matched) and on `buildPhases` + // member lines. The uncovered case is a user who copied the same phase object + // into a SECOND target's buildPhases — it is stripped there too. + let text = removeArrayMembersByUuid(input, [...pluginUuids]); + const target = () => findApplicationTargetByUuid(text, targetUuid); + let anchor = insertAt > 0 ? others[insertAt - 1] : null; + for (const member of beforeCompile) { + text = + anchor == null + ? addArrayMembers(text, target(), 'buildPhases', [member], { + prepend: true, + }) + : addBuildPhaseAfter(text, target(), anchor, member); + anchor = member.uuid; + } + for (const member of atEnd) { + text = addArrayMembers(text, target(), 'buildPhases', [member]); + } + return text; +} + +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}${uuidComment(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, '''); +} + +// The inverse of escapeXmlAttribute. `&` is expanded LAST so an entity that +// was itself escaped (`<` → `&lt;`) round-trips back to its own text +// rather than to `<`. +function unescapeXmlAttribute(s /*: string */) /*: string */ { + return s + .replace(/</g, '<') + .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"'], + }, +]; + +// Array build settings injected only into debug-flavored configurations. +// +// Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by +// GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app +// template does not commit the setting: CocoaPods injects it at `pod install` +// time (react_native_post_install → set_build_setting +// SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug). +// An SPM app never runs CocoaPods, so without this `#if DEBUG` is false even +// in a Debug build — AppDelegate.swift's `bundleURL()` skips the Metro URL, +// falls back to a main.jsbundle that a Debug build never produced, and the app +// dies at launch with "No script url provided … unsanitizedScriptURLString = +// (null)" while Metro is running right there. +// +// Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that +// links the debug xcframeworks also compiles its Swift with DEBUG. +const DEBUG_ARRAY_SETTINGS = [ + {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, +]; + +/** 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 */ = [], + scriptPhases /*: ReadonlyArray */ = [], +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { + 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); + + const createdArrayFields /*: Array */ = []; + + // 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]; + // The name/path VALUES stay verbatim (escaped) — only the cosmetic + // comments are normalized, falling back to the normalized path (the + // ledger key) and then to no comment at all, which is well-formed. + const label = commentSafe(src.name) || commentSafe(src.path); + const inSources = label === '' ? '' : `${label} in Sources`; + fileRefs.push({ + uuid: fileRefUuid, + comment: label, + fields: { + isa: 'PBXFileReference', + lastKnownFileType: src.fileType, + name: quoteIfNeeded(src.name), + path: quoteIfNeeded(src.path), + sourceTree: src.sourceTree, + }, + }); + buildFiles.push({ + uuid: buildFileUuid, + comment: inSources, + fields: { + isa: 'PBXBuildFile', + fileRef: `${fileRefUuid}${uuidComment(label)}`, + }, + }); + sourcesMembers.push({uuid: buildFileUuid, comment: inSources}); + groupChildren.push({uuid: fileRefUuid, comment: label}); + } + 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}, + ]); + } + } + } + + // 9. Plugin-declared build phases (SwiftPM has no `script_phase`). Each + // phase's UUID is keyed on its plugin id, so re-runs refresh in place and + // `deinit` reverses it. + const scriptPhaseUuids /*: {[string]: string} */ = {}; + if (scriptPhases.length > 0) { + const seated /*: Array */ = []; + for (const scriptPhase of scriptPhases) { + const uuid = mkUuid( + 'PBXShellScriptBuildPhase', + `plugin:${scriptPhase.id}`, + ); + scriptPhaseUuids[scriptPhase.id] = uuid; + // The full name goes into the `name` field (escaped) — that is what Xcode + // displays. The cosmetic comments get it normalized, falling back to the + // normalized id and then to no comment at all, which is well-formed. The + // fallback is sanitized rather than trusted to the id charset, so widening + // that charset can never reach a comment. + const comment = + commentSafe(scriptPhase.name) || commentSafe(scriptPhase.id); + const entry = shellScriptPhase( + uuid, + scriptPhase.name, + scriptPhase.script, + { + inputPaths: scriptPhase.inputPaths, + outputPaths: scriptPhase.outputPaths, + alwaysOutOfDate: scriptPhase.alwaysOutOfDate, + comment, + }, + ); + if (!text.includes(uuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry(entry), + ); + } else { + // Rewrite the fields we own unconditionally — byte-identical when the + // plugin's declaration hasn't changed, so no content hashing is needed. + // Each write shifts offsets, hence the re-lookup per field. + for (const key of [ + 'name', + 'shellScript', + 'inputPaths', + 'outputPaths', + ]) { + const current = findObjectByUuid(text, uuid); + if (current != null) { + text = setScalarField(text, current, key, entry.fields[key]); + } + } + // Flipping alwaysOutOfDate back off means REMOVING the field — + // setScalarField would only ever write a value. + const current = findObjectByUuid(text, uuid); + if (current != null) { + text = + scriptPhase.alwaysOutOfDate === true + ? setScalarField(text, current, 'alwaysOutOfDate', '1') + : removeField(text, current, 'alwaysOutOfDate'); + } + } + injectedUuids.push(uuid); + text = setUuidComment(text, uuid, comment); + seated.push({uuid, comment, position: scriptPhase.position}); + } + text = seatScriptPhases( + text, + plan.targetUuid, + syncPhaseUuid, + seated, + sourcesPhaseUuid, + ); + } + + return { + text, + injectedUuids, + createdArrayFields, + buildSettingChanges, + generatedSourceUuids, + scriptPhaseUuids, + }; +} + +/** 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; + } +} + +/** Strip the surrounding plist quotes from a build-setting token, if any. */ +function unquotePlist(s /*: string */) /*: string */ { + return s.replace(/^"/, '').replace(/"$/, ''); +} + +/** + * The individual values a build setting already carries, unquoted — for both + * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting + * (`("$(inherited)", DEBUG)`) and the scalar form the app template and + * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the + * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently + * skip the injection. + */ +function buildSettingValueTokens(value /*: string */) /*: Set */ { + return new Set( + value + .split(/[\s,()]+/) + .filter(Boolean) + .map(unquotePlist), + ); +} + +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 promotedArrayScalars /*: {[string]: string} */ = {}; + const createdScalars /*: Array */ = []; + const arraySettings = [ + ...INJECTED_ARRAY_SETTINGS, + ...(flavorForBuildConfiguration(configurationName) === 'debug' + ? DEBUG_ARRAY_SETTINGS + : []), + ...frameworkArrayBuildSettings(flavoredFrameworks), + ]; + for (const {key, values} of arraySettings) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + // Non-null only for a scalar addArrayStringValues would promote to an array + // (same array-vs-scalar test it uses). Kept RAW: findField's token for a + // bare scalar ends at the `;`, so it carries any whitespace before it, and + // deinit has to write those bytes back verbatim. + const priorScalar = + existing != null && !existing.value.trimStart().startsWith('(') + ? existing.value + : null; + if (existing == null) { + createdArrayKeys.push(key); + } else { + const present = buildSettingValueTokens(existing.value); + const fresh = values.filter(v => !present.has(unquotePlist(v))); + if (fresh.length === 0) { + // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by + // EXACT array member, so a value the user carries in the scalar form + // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would + // otherwise be promoted to an array and re-appended — an edit `deinit` + // has no record of and so could never reverse. + continue; + } + if (priorScalar == null) { + appendedArrayValues[key] = fresh; + } + } + const beforeAdd = text; + text = addArrayStringValues(text, d, key, values); + // Record only a promotion that actually happened: addArrayStringValues + // no-ops when `values` is empty or every value is already a member, and a + // recorded-but-untouched field would have deinit clobber whatever the user + // has there by then. Restoring the scalar subsumes removing the injected + // members, so the two records stay mutually exclusive per key. + if (priorScalar != null && text !== beforeAdd) { + promotedArrayScalars[key] = priorScalar; + } + } + 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, + ...(Object.keys(promotedArrayScalars).length > 0 + ? {promotedArrayScalars} + : {}), + }, + }; +} + +// 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 */ = null; + 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; +} + +const nonEmptyStrings = (value /*: unknown */) /*: ?Array */ => + Array.isArray(value) + ? value.filter(p => typeof p === 'string' && p.length > 0) + : null; + +/** + * Read the plugin script-phases manifest at + * `/build/generated/autolinking/.spm-plugin-script-phases.json`. + * Absent, unparseable, or malformed → `[]`. Lenient by design even though the + * plugin contract validates these entries fatally at invoke time: the sidecar + * legitimately does not exist yet on a first `spm add`, and a stale or + * hand-edited file must not break injection. This reader is the ONLY gate on + * such a file, so it applies the same id/name rules (from spm-utils) that + * invokePlugins enforces fatally. + */ +function readScriptPhasesManifest( + appRoot /*: string */, +) /*: Array */ { + const manifestPath = path.join(appRoot, SPM_SCRIPT_PHASES_MANIFEST); + let raw /*: string */ = ''; + try { + raw = fs.readFileSync(manifestPath, 'utf8'); + } catch { + return []; + } + let entries /*: unknown */ = null; + try { + entries = JSON.parse(raw); + } catch { + log( + `warning: could not parse ${SPM_SCRIPT_PHASES_MANIFEST}; ` + + 'skipping script phases.', + ); + return []; + } + if (!Array.isArray(entries)) { + return []; + } + const out /*: Array */ = []; + for (const entry of entries) { + if ( + entry == null || + typeof entry !== 'object' || + !isValidScriptPhaseId(entry.id) || + !isValidScriptPhaseName(entry.name) || + typeof entry.script !== 'string' || + entry.script.length === 0 || + // An unknown position is a malformed entry, not something to coerce: it + // would silently run somewhere the plugin didn't ask for. + (entry.position != null && + entry.position !== 'beforeCompile' && + entry.position !== 'end') || + // Dedupe by id — the id seeds the phase's UUID, so a duplicate would + // insert two objects with identical UUIDs. + out.some(phase => phase.id === entry.id) + ) { + continue; + } + const phase /*: PluginScriptPhase */ = { + id: entry.id, + name: entry.name, + script: entry.script, + position: entry.position ?? 'end', + }; + const inputPaths = nonEmptyStrings(entry.inputPaths); + const outputPaths = nonEmptyStrings(entry.outputPaths); + if (inputPaths != null) { + phase.inputPaths = inputPaths; + } + if (outputPaths != null) { + phase.outputPaths = outputPaths; + } + if (typeof entry.alwaysOutOfDate === 'boolean') { + phase.alwaysOutOfDate = entry.alwaysOutOfDate; + } + out.push(phase); + } + 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}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { + 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 +// marker readers below, and for callers that only locate the project (setup- +// apple-spm.js's action defaulting and `deinit`), to call without exercising +// any pbxproj-editing machinery. +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 — setup-apple-spm.js's + * determineVersion prefers the pinned version over the one derived from + * node_modules/react-native/package.json, so a later flagless `add`/`update` + * (and `download`) stays on 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; +} + +/** + * Read the autolinking config command a previous `spm add`/`update` pinned into + * the injected xcodeproj's `.spm-injected.json` marker, or null when nothing + * usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but + * this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it + * on add/update/scaffold and on the build-time `sync`. Re-validated through the + * same parseConfigCommandJson the flag goes through, and never throws, so a + * hand-edited marker degrades to the env-var/default command instead of + * injecting a bogus argv into the build. + */ +function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array */ { + const xcodeprojPath = findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return null; + } + const pinned = readMarker(xcodeprojPath)?.configCommand; + if (pinned == null) { + return null; + } + try { + return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER); + } catch { + return null; + } +} + +/** + * Union the array fields RN has created across syncs, deduped by container+key. + * + * THE CANONICAL STATEMENT of why anything RN created is recorded stickily (the + * marker's `scheme.created` carries it forward for the same reason): a re-sync + * re-injects from a baseline with only the BUILD SETTINGS reversed, so it finds + * whatever the first run created already there and reports creating nothing. Once + * RN has created something, that fact has to be carried forward, or the new + * marker forgets it and `deinit` leaves an empty `packageReferences` / + * `packageProductDependencies` behind and the generated scheme on disk. + * + * The record licenses removal; it does not order it. `deinit` still checks that + * what it is about to remove is RN's (see its step 1 and isGeneratedScheme), so + * carrying forward a field the user has since taken over — or deleted by hand — + * is safe. + */ +function mergeCreatedArrayFields( + previous /*: ReadonlyArray */, + current /*: ReadonlyArray */, +) /*: Array */ { + const merged = [...previous]; + for (const field of current) { + if ( + !merged.some( + seen => seen.container === field.container && seen.key === field.key, + ) + ) { + merged.push(field); + } + } + return merged; +} + +/** + * 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, configCommand?: ?Array} */, +) /*: {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 scriptPhases = readScriptPhasesManifest(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), + ); + } + // Same reconciliation for script phases, keyed on the plugin-supplied id. + const prevScriptPhases /*: {[string]: string} */ = + prevMarker?.scriptPhases ?? {}; + const currentPhaseIds = new Set(scriptPhases.map(p => p.id)); + for (const id of Object.keys(prevScriptPhases)) { + if (!currentPhaseIds.has(id)) { + staleUuids.push(prevScriptPhases[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, + scriptPhaseUuids, + } = injectSpmIntoPbxproj( + base, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + reactNativePath, + remote, + hermesCliPath, + generatedSources, + flavoredFrameworks, + scriptPhases, + ); + + 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 a later + // flagless `add`/`update`/`download` resolves to the SAME slot, even on a + // version-mismatched setup. + const artifactsVersionOverride = + opts.artifactsVersionOverride ?? + prevMarker?.artifactsVersionOverride ?? + null; + + // Same set-or-preserve contract as the version pin above, for the autolinking + // config command `add`/`update` resolved (`--config-command` or + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the + // flag nor the developer's shell environment, so without the pin it + // re-derives autolinking.json with the default @react-native-community/cli + // command and breaks apps that replace it. Read back by + // readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops + // the whole marker, this field with it. + const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? 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: mergeCreatedArrayFields( + prevMarker?.createdArrayFields ?? [], + createdArrayFields, + ), + buildSettingChanges, + // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next + // `update` to reconcile away entries that left the manifest. + generatedSources: generatedSourceUuids, + // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the + // same way. + scriptPhases: scriptPhaseUuids, + artifactsVersionOverride, + configCommand, + scheme: { + file: schemeResult.file, + // Sticky — see mergeCreatedArrayFields for why a later sync cannot + // observe this for itself. + created: + schemeResult.status === 'created' || + prevMarker?.scheme?.created === true, + }, + }, + null, + 2, + ) + '\n', + ); + + ensureStubPackages(appRoot); + return {status: 'injected', target: plan.target.name}; +} + +/** The sync pre-action's script, unescaped, or null when the scheme has none. */ +function schemePreActionScript(xml /*: string */) /*: ?string */ { + const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); + if (titleIdx < 0) { + return null; + } + const marker = 'scriptText = "'; + const start = xml.indexOf(marker, titleIdx); + if (start < 0) { + return null; + } + const valueStart = start + marker.length; + // escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always + // the closing delimiter. + const valueEnd = xml.indexOf('"', valueStart); + return valueEnd < 0 + ? null + : unescapeXmlAttribute(xml.slice(valueStart, valueEnd)); +} + +/** + * Whether `xml` is still, byte for byte, the scheme RN generates for this target + * — everything but the pre-action's script, which RN rewrites in place on every + * sync and which varies with the app's react-native path, so it cannot be part of + * an ownership test. + * + * `deinit` deletes a scheme the marker says RN created only while this holds. A + * user may replace a generated scheme with one of their own under the same name + * (same target, so `injectOrCreateScheme` finds and updates it, and the created + * record stays), and destroying that is unrecoverable where leaving a scheme + * behind is not — so the harmless way of being wrong wins: a generated scheme the + * user has since edited leaks, minus its pre-action. + */ +function isGeneratedScheme( + xml /*: string */, + appName /*: string */, + targetUuid /*: string */, + projName /*: string */, +) /*: boolean */ { + const script = schemePreActionScript(xml); + return ( + script != null && + xml === generateXcscheme(appName, targetUuid, projName, script) + ); +} + +/** + * 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], + ); + } + } + const promotedArrayScalars /*: {[string]: string} */ = + change.promotedArrayScalars ?? {}; + for (const key of Object.keys(promotedArrayScalars)) { + const current = dict(); + const originalValue = promotedArrayScalars[key]; + // A field that is gone was deleted by the user after injection; restoring + // it would resurrect it, at the top of the dict, matching neither state. + if ( + current != null && + typeof originalValue === 'string' && + findField(text, current, key) != null + ) { + // Rewriting the whole value is what makes the promotion reversible at + // all — its members and its `"$(inherited)"` seed are indistinguishable + // from the user's own once folded together. The tradeoff: members the + // user hand-added to the promoted array afterwards are discarded. + text = setScalarField(text, current, key, originalValue); + } + } + 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) { + continue; + } + // The record says the field did not exist before RN created it, which is + // necessary but not sufficient: anything still in it after our own members + // are gone is the user's (their own SPM package, added to the same field), + // and dropping the field would orphan it. + const field = findField(text, obj, f.key); + if (field != null && /^\(\s*\)$/.test(field.value)) { + 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 AND still owns its contents, + // else strip the pre-action and leave the file (see isGeneratedScheme). + const scheme = marker.scheme; + if (scheme != null && scheme.file != null) { + const schemePath = path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + scheme.file, + ); + if (fs.existsSync(schemePath)) { + const xml = fs.readFileSync(schemePath, 'utf8'); + const ours = + scheme.created === true && + isGeneratedScheme( + xml, + marker.target, + marker.targetUuid, + path.basename(xcodeprojPath, '.xcodeproj'), + ); + if (ours) { + fs.rmSync(schemePath, {force: true}); + } else { + 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, + buildPhaseOrder, + planInjection, + injectSpmIntoPbxproj, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, + cleanupLeftoverPodsGroup, + cleanupDanglingJavaScriptCoreRef, + addPreActionToScheme, + removePreActionFromScheme, + findInjectedXcodeproj, + readArtifactsVersionOverride, + readPinnedConfigCommand, + readScriptPhasesManifest, + 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..9360b012c420 --- /dev/null +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -0,0 +1,1247 @@ +/** + * 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 { + SpmNameCollisionError, + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, +} = require('./expand-spm-dependencies'); +const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); +const {readPodspec} = require('./read-podspec'); +const { + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + 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(), + // npm name → resolved Swift name for every autolinked dep, so a sibling + // reference honors that sibling's `spm.name` instead of re-deriving it. + swiftNameByNpm /*: Map */ = new Map(), +) /*: SpmScaffoldSpec */ { + const warnings = [...model.warnings]; + + // Swift target name: whatever the autolinker resolved for this dep — its + // `spm.name` override when set, else toSwiftName(npm-name). The autolinker + // registers the 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 it exactly — 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) are what `spm.name` is for. + const swiftName = dep.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.`, + ); + } + + const siblingSwiftNames /*: {[npmName: string]: string} */ = {}; + for (const npmName of siblingNames) { + const resolved = swiftNameByNpm.get(npmName); + if (resolved != null) { + siblingSwiftNames[npmName] = resolved; + } + } + + return { + swiftName, + sources: expandedSources, + headerSearchPaths, + preprocessorDefines: model.preprocessorDefines, + needsObjCPrefix, + coreReactNative, + siblingNames, + siblingSwiftNames, + 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', 'Foundation', 'CoreGraphics']; + const linkedFrameworks = Array.from( + new Set([...defaults, ...spec.extraFrameworks]), + ); + const linkerEntries = [ + ...linkedFrameworks.map(f => `.linkedFramework("${f}")`), + ...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 : REACT_NATIVE_PACKAGE_NAME; + 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: "${REACT_NATIVE_PACKAGE_NAME}", path: "${xcfwDir}")`, + ); + } + packageDeps.push( + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "${codegenDir}")`, + ); + for (const product of REACT_NATIVE_PRODUCTS) { + targetDeps.push(`.product(name: "${product}", package: "${rnLabel}")`); + } + for (const product of REACT_CODEGEN_PRODUCTS) { + targetDeps.push( + `.product(name: "${product}", package: "${REACT_CODEGEN_PACKAGE_NAME}")`, + ); + } + } + for (const siblingName of spec.siblingNames) { + const swiftSibling = + spec.siblingSwiftNames?.[siblingName] ?? 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, + // npm-name → resolved Swift name over all autolinked deps, so sibling + // references honor each sibling's `spm.name`. + swiftNameByNpm?: Map, + remote: ?{url: string, version: string, identity: string}, +}; +*/ + +/** + * 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; + 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(), + ctx.swiftNameByNpm ?? 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: ctx.remote, + 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}}); + } + + // Outside the try: a malformed remote config (RemoteVersionError) is a + // misconfiguration to surface, not an expansion failure to degrade past. + const remote = remotePackageConfig(appRoot); + + let allDeps /*: Array */ = []; + try { + allDeps = expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + extraReservedNames: remote != null ? [remote.identity] : undefined, + log, + }); + } catch (e) { + if (e instanceof SpmNameCollisionError) { + throw 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 swiftNameByNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + if (dep.swiftName != null) { + swiftNameByNpm.set(dep.name, dep.swiftName); + } + } + + const ctx /*: ScaffoldContext */ = { + appRoot, + projectRoot, + reactNativeRoot, + force: opts.force === true, + dryRun: opts.dryRun === true, + cacheSlotLabel: opts.cacheSlotLabel ?? null, + podToNpm, + swiftNameByNpm, + remote, + }; + 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..6501894440cb --- /dev/null +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -0,0 +1,784 @@ +/** + * 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. A literal CR or tab in + * a quoted value is legal but Xcode rewrites it to its escape on the next save, + * planting a spurious diff in the user's repo — so escape those too (a plugin + * `script` with CRLF line endings is how one gets in). + */ +function quoteIfNeeded(s /*: string */) /*: string */ { + if (/^[a-zA-Z0-9._/]+$/.test(s)) { + return s; + } + return `"${s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t')}"`; +} + +/** + * Normalize an UNTRUSTED label (a plugin's script-phase name, say) for use + * inside a pbxproj `/* … *​/` comment. Such a comment is purely cosmetic — Xcode + * regenerates it from the object's own fields — while the text AROUND it is + * scanned by delimiter, so every character that could be read as structure (or + * split the line) becomes a space, and runs of whitespace collapse: + * `findObjectByUuid` takes the first `{` after a UUID as that object's body, + * `removeArrayMembersByUuid` identifies a member line by its trailing comma, and + * `scanToClose` treats a `"` as opening a string. Stable (same input ⇒ same + * output), so re-syncing stays byte-identical. Returns '' when nothing survives; + * callers supply the fallback label. + * + * Comments RN writes to Xcode's OWN convention (`XCLocalSwiftPackageReference + * "build/xcframeworks"`, ` in Sources`) are NOT run through this: they are + * fixed strings, and normalizing them would rewrite bytes Xcode itself produces — + * a spurious diff in the user's repo on their next save. + */ +function commentSafe(s /*: string */) /*: string */ { + return s + .replace(/[{}(),;="*/\s]/g, ' ') + .replace(/ +/g, ' ') + .trim(); +} + +/** + * Format the ` /* … *​/` suffix pbxproj writes after a UUID, omitted entirely for + * an empty label (an uncommented UUID is well-formed — Xcode writes those itself + * for unnamed groups). Labels that did not originate in this repo must already + * have been through `commentSafe`. + */ +function uuidComment(comment /*: ?string */) /*: string */ { + return comment != null && comment !== '' ? ` /* ${comment} */` : ''; +} + +/** + * 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 */ { + let out = `\t\t${entry.uuid}${uuidComment(entry.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}${uuidComment(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); +} + +/** + * Indices of the `,` separators at an array's top level — a comma inside a + * quoted member (`"$(FOO),weird"`) separates nothing. + */ +function topLevelCommas(inner /*: string */) /*: Array */ { + const out = []; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === '"') { + i = scanString(inner, i); + } else if (inner[i] === ',') { + out.push(i); + } + } + return out; +} + +/** + * The members of an array's inner text (what sits between its parens), trimmed + * and with empty slots — e.g. the one a trailing comma leaves — dropped. A bare + * scalar value parses as its own single member. + */ +function arrayMembers(inner /*: string */) /*: Array */ { + const members = []; + let start = 0; + for (const comma of topLevelCommas(inner)) { + members.push(inner.slice(start, comma)); + start = comma + 1; + } + members.push(inner.slice(start)); + return members.map(m => m.trim()).filter(m => m !== ''); +} + +/** + * 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) { + const isArray = field.value.trimStart().startsWith('('); + const openParen = isArray + ? field.valueStart + field.value.indexOf('(') + : -1; + const closeParen = isArray ? scanToClose(text, openParen) : -1; + const inner = isArray ? text.slice(openParen + 1, closeParen) : field.value; + // Dedup by EXACT existing member, not substring — a substring check would + // treat `"-ObjC"` as already present when only `"-ObjCFoo"` is there (and + // vice-versa). + const existingMembers = new Set(arrayMembers(inner)); + const fresh = values.filter(v => !existingMembers.has(v)); + if (fresh.length === 0) { + return text; + } + if (isArray) { + if (inner.includes('\n')) { + // Multi-line array — one member per line, before the closing `)`. + const lineStart = text.lastIndexOf('\n', closeParen) + 1; + const lines = fresh.map(v => `${memberIndent}${v},\n`).join(''); + return text.slice(0, lineStart) + lines + text.slice(lineStart); + } + // One-line array (hand-edited projects, XcodeGen, Tuist) — splice the + // members in ahead of the `)`, in the separator style already there. + // Reformatting it multi-line instead would have to record the old shape + // to stay reversible on deinit. + const commas = topLevelCommas(inner); + const gapMatch = + commas.length > 0 ? /^[\t ]*/.exec(inner.slice(commas[0] + 1)) : null; + const gap = gapMatch != null ? gapMatch[0] : ' '; + const core = inner.replace(/[\t ]+$/, ''); + const joined = fresh.join(`,${gap}`); + const insertion = + core === '' + ? joined + : core.endsWith(',') + ? `${gap}${joined},` + : `,${gap}${joined}`; + const at = openParen + 1 + core.length; + return text.slice(0, at) + insertion + text.slice(at); + } + // Existing scalar — promote to an array preserving the prior value. Skip it + // when it IS the `"$(inherited)"` the array is seeded with (emitted twice), + // or when it is empty (a bare `,` is not a valid plist element). The seed + // test unquotes first: pbxproj accepts `$(inherited)` bare, and Xcode's own + // quoted form is the same value to the build system. + const prior = field.value.trim(); + const carriesPrior = + prior !== '' && prior.replace(/^"(.*)"$/s, '$1') !== '$(inherited)'; + const replacement = arrayBlock([ + '"$(inherited)"', + ...(carriesPrior ? [prior] : []), + ...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. The exception is a scalar that +// injection promoted to an array: reversing that rewrites the whole field (see +// removeRecordedBuildSettings), so members added to it afterwards are lost. +// That is not deinit-only — every re-sync reverts from the recorded baseline +// before re-injecting, so an `spm update` discards them just the same. +// 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 — which + * holds because no comment can contain a comma (see commentSafe). + */ +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); +} + +/** + * Drop one member from an array field's value text. The patterns mirror the + * forms addArrayStringValues inserts, tried in the order that makes the span + * removed exactly the span it added: a line of its own (multi-line array), then + * after a comma, before a comma, or alone ahead of the `)` (the one-line + * shapes). Each is anchored on a delimiter, so a value that is merely a prefix + * of a longer member is never mistaken for it. + */ +function removeArrayMember( + region /*: string */, + value /*: string */, +) /*: string */ { + const v = escapeRegExp(value); + for (const pattern of [ + `\\n[\\t ]*${v},`, + `,[\\t ]*${v}(?=[\\t ]*[,)])`, + `[\\t ]*${v},[\\t ]*`, + `[\\t ]*${v}(?=[\\t ]*\\))`, + ]) { + const shorter = region.replace(new RegExp(pattern), ''); + if (shorter !== region) { + return shorter; + } + } + return region; +} + +/** + * 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 = removeArrayMember(region, 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, + commentSafe, + uuidComment, + // 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..849fecff5637 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-types.js @@ -0,0 +1,546 @@ +/** + * 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, + // Overrides the autolinking config command; also settable via + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND. + configCommand: Array | null, + // 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, + // 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 build-time shell phase for the app target — SwiftPM's missing analog of +// CocoaPods' `script_phase`. `id` is the stable ledger key and deterministic +// UUID seed (charset /^[@A-Za-z0-9_./-]+$/, so a scoped npm name works; `:` is +// excluded because the seed is `plugin:`); `position` is normalized to 'end' +// by invokePlugins so consumers never re-derive the default. +export type PluginScriptPhase = { + id: string, + name: string, + script: string, + position: 'beforeCompile' | 'end', + inputPaths?: Array, + outputPaths?: Array, + alwaysOutOfDate?: boolean, +}; + +// 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, + // Build-time shell phases for the app target, recorded to + // `.spm-plugin-script-phases.json` by main() and injected by `spm add`/ + // `update`. + scriptPhases: Array, +}; + +export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ + packageDependencies?: Array, + productDependencies?: Array, + generatedSources?: Array, + flavoredFrameworks?: Array, + watchPaths?: Array, + // `position` may be omitted here; invokePlugins normalizes it to 'end'. + scriptPhases?: Array<{ + ...PluginScriptPhase, + position?: 'beforeCompile' | 'end', + }>, +}; + +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. + // `siblingSwiftNames` carries each one's resolved Swift name (honoring the + // sibling's `spm.name`); a sibling absent from it falls back to + // toSwiftName(npmName) at emit time. + coreReactNative: boolean, + siblingNames: Array, + siblingSwiftNames?: {[npmName: string]: string}, + // 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..638623010f77 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -0,0 +1,760 @@ +/** + * 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'); + +// The package and product names React Native's own generated manifests use. A +// dependency whose Swift name lands on one of them surfaces as a duplicate-name +// error from inside SPM's resolution, far from the react-native.config.js that +// caused it, so the autolinker rejects the collision up front instead. +// +// These cover the emitters in this directory and the collision guard. Adding a +// React Native SPM product also touches, depending on what the product is: +// - scripts/codegen/templates/Package.swift.spm-template — if the per-app +// codegen package must depend on it (a static Swift file, patched by +// string replacement, not generated from these constants) +// - flavored-frameworks.js INVARIANT_BINARY_TARGETS — if it is +// xcframework-backed +// - download-spm-artifacts.js REQUIRED_ARTIFACTS — if it ships as its own +// downloadable artifact (that list names ARTIFACTS, which overlap with +// product names without being the same set) +const REACT_NATIVE_PACKAGE_NAME /*: string */ = 'ReactNative'; +const REACT_CODEGEN_PACKAGE_NAME /*: string */ = 'React-GeneratedCode'; +// The autolinking aggregator package, whose single product shares its name. +const AUTOLINKED_PACKAGE_NAME /*: string */ = 'Autolinked'; +// React Native's products by KIND, so no consumer has to infer the kind from a +// position in a flat list. The umbrella is a Clang target over the staged +// headers that re-exports the pure-RN namespaces; the rest are each backed by +// an xcframework of the same name. +const REACT_NATIVE_UMBRELLA_PRODUCT /*: string */ = 'ReactHeaders'; +const REACT_NATIVE_HEADERS_PRODUCT /*: string */ = 'ReactNativeHeaders'; +const REACT_NATIVE_DEPENDENCIES_HEADERS_PRODUCT /*: string */ = + 'ReactNativeDependenciesHeaders'; +const REACT_NATIVE_XCFRAMEWORK_PRODUCTS /*: ReadonlyArray */ = + Object.freeze([ + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_DEPENDENCIES_HEADERS_PRODUCT, + ]); +// Derived, so a new product cannot be added without choosing a kind above. +const REACT_NATIVE_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_UMBRELLA_PRODUCT, + ...REACT_NATIVE_XCFRAMEWORK_PRODUCTS, +]); +// The codegen package product every autolinked target depends on for the app's +// generated headers. +const REACT_CODEGEN_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + 'ReactAppHeaders', +]); +// The codegen package products the APP target links, declared by the static +// template rather than by any emitter here. +const REACT_CODEGEN_APP_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + 'ReactCodegen', + 'ReactAppDependencyProvider', +]); +const REACT_HEADERS_TARGET_DIR /*: string */ = 'ReactHeadersTarget'; +// Target names only have to be unique within their own package, so +// REACT_HEADERS_TARGET_DIR is deliberately absent: a dependency named after it +// collides with nothing. +const RESERVED_SWIFT_NAMES /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + ...REACT_NATIVE_PRODUCTS, + ...REACT_CODEGEN_PRODUCTS, + ...REACT_CODEGEN_APP_PRODUCTS, +]); + +/** + * 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) { + const candidate = path.join(dir, 'node_modules', 'react-native'); + 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: "${REACT_NATIVE_PACKAGE_NAME}", path: "../../xcframeworks"),`, + `.package(url: "${remote.url}", exact: "${remote.version}"),`, + ) + .split(`package: "${REACT_NATIVE_PACKAGE_NAME}")`) + .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); + } +} + +// --------------------------------------------------------------------------- +// Autolinking-plugin script phases — the shared validation rules. Two gates +// apply them with different POLICIES (invokePlugins throws, the injector's +// sidecar reader skips the entry), so only the rules live here. See +// __doc__/spm-autolinking-plugins.md for the reasoning. +// --------------------------------------------------------------------------- + +// `@` and `/` are admitted so a package can use its own scoped npm name +// (`@expo/log-box`). `:` is not: the id is hashed into the UUID seed as +// `plugin:`, and keeping the separator out of the id keeps that seed +// unambiguous. +const SCRIPT_PHASE_ID_PATTERN = /^[@A-Za-z0-9_./-]+$/; + +// `ledger.__proto__ = uuid` sets the prototype instead of an own property, so a +// phase with one of these ids would look recorded, vanish through +// JSON.stringify, and never be removable by `deinit`. +const RESERVED_SCRIPT_PHASE_IDS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + +function isValidScriptPhaseId(value /*: unknown */) /*: boolean */ { + return ( + typeof value === 'string' && + SCRIPT_PHASE_ID_PATTERN.test(value) && + !RESERVED_SCRIPT_PHASE_IDS.has(value) + ); +} + +/** + * The name reaches the project file twice: verbatim in the escaped `name` field + * Xcode displays, and normalized (spm-pbxproj's commentSafe) in the cosmetic + * `/* … *​/` comments. Both are safe for any single-line string, so the only + * thing left to refuse is a line break — which no Xcode phase display name can + * carry anyway. + */ +function isValidScriptPhaseName(value /*: unknown */) /*: boolean */ { + return typeof value === 'string' && value.length > 0 && !/[\r\n]/.test(value); +} + +module.exports = { + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + REACT_NATIVE_PRODUCTS, + REACT_CODEGEN_PRODUCTS, + REACT_CODEGEN_APP_PRODUCTS, + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, + makeLogger, + displayPath, + sharedCacheDir, + defaultCacheDir, + toSwiftName, + deriveAppName, + readPackageJson, + findProjectRoot, + resolveReactNativeRoot, + buildPerAppHeaderTree, + remotePackageConfig, + resolveInstalledRnVersion, + isPublishableVersion, + RemoteVersionError, + installSpmCodegenTemplate, + runCodegenAndInstallTemplate, + isValidScriptPhaseId, + isValidScriptPhaseName, + 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/.hermesv1version b/packages/react-native/sdks/.hermesv1version index a9e3c2c23080..94c1145b3fc4 100644 --- a/packages/react-native/sdks/.hermesv1version +++ b/packages/react-native/sdks/.hermesv1version @@ -1 +1 @@ -hermes-v250829098.0.13 +hermes-v250829098.0.17 \ No newline at end of file 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/sdks/hermes-engine/version.properties b/packages/react-native/sdks/hermes-engine/version.properties index 05a54e3d4e77..ef882bc7a2e0 100644 --- a/packages/react-native/sdks/hermes-engine/version.properties +++ b/packages/react-native/sdks/hermes-engine/version.properties @@ -1 +1 @@ -HERMES_VERSION_NAME=250829098.0.13 +HERMES_VERSION_NAME=250829098.0.17 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/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index 7ba17f98b836..d5f5eeed620c 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -369,7 +369,7 @@ describe('Native Animated', () => { const value = new Animated.Value(0); value.__makeNative(); const listener = jest.fn(); - const event = Animated.event([{nativeEvent: {foo: value}}], { + const event = new Animated.Event([{nativeEvent: {foo: value}}], { useNativeDriver: true, listener, }); 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/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js index d0757fcdba75..63770292a848 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js @@ -20,13 +20,14 @@ import TextInputState from '../../../../../../Libraries/Components/TextInput/Tex import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {createRef} from 'react'; -import {ScrollView, Text, TextInput, View} from 'react-native'; +import {Modal, ScrollView, Text, TextInput, View} from 'react-native'; import { NativeText, NativeVirtualText, } from 'react-native/Libraries/Text/TextNativeComponent'; import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; import Event from 'react-native/src/private/webapis/dom/events/Event'; +import ReactNativeDocument from 'react-native/src/private/webapis/dom/nodes/ReactNativeDocument'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement'; import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; @@ -398,6 +399,47 @@ describe('ReactNativeElement', () => { expect(childNodeC.parentNode).toBe(null); expect(childNodeC.parentElement).toBe(null); }); + + it('returns the containing element as the parent of a modal host view, not the document', () => { + const parentRef = createRef(); + const modalRef = createRef(); + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const parentNode = ensureReactNativeElement(parentRef.current); + const modalNode = ensureReactNativeElement(modalRef.current); + const document = ensureInstance( + parentNode.ownerDocument, + ReactNativeDocument, + ); + + // Capture the relations before tearing down, so cleanup runs even if + // the assertions below fail. + const modalParentNode = modalNode.parentNode; + const modalParentElement = modalNode.parentElement; + + // Unmount and drain the queue so the modal's AppContainer passive + // effects (in __DEV__) don't trip the global "MessageQueue is not + // empty" validation hook. + root.destroy(); + Fantom.runWorkLoop(); + + // The host view is a root-kind shadow node, but its parent + // must still be its actual containing element, NOT the document. + // Two-phase event propagation (e.g. focus/blur bubbling to ancestors + // rendered above the modal) walks this parent chain, so returning the + // document here silently severs bubbling at the modal boundary. + expect(modalParentNode).toBe(parentNode); + expect(modalParentElement).toBe(parentNode); + expect(modalParentNode).not.toBe(document); + }); }); describe('compareDocumentPosition / contains', () => { 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/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/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/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/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm index 8f023f21f075..1d366e828d93 100644 --- a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm +++ b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm @@ -13,7 +13,11 @@ #import #import +#if __has_include() +#import +#else #import "RCTFabricComponentsPlugins.h" +#endif using namespace facebook::react; diff --git a/packages/rn-tester/Podfile.lock b/packages/rn-tester/Podfile.lock index 859b236e7497..4a969b502a30 100644 --- a/packages/rn-tester/Podfile.lock +++ b/packages/rn-tester/Podfile.lock @@ -2,17 +2,13 @@ PODS: - boost (1.84.0) - DoubleConversion (1.1.6) - fast_float (8.0.0) - - FBLazyVector (1000.0.0) + - FBLazyVector (0.87.0-rc.0) - fmt (12.1.0) - glog (0.3.5) - - hermes-engine (1000.0.0): - - hermes-engine/cdp (= 1000.0.0) - - hermes-engine/Hermes (= 1000.0.0) - - hermes-engine/Public (= 1000.0.0) - - hermes-engine/cdp (1000.0.0) - - hermes-engine/Hermes (1000.0.0) - - hermes-engine/Public (1000.0.0) - - MyNativeView (0.87.0-main): + - hermes-engine (250829098.0.17): + - hermes-engine/Pre-built (= 250829098.0.17) + - hermes-engine/Pre-built (250829098.0.17) + - MyNativeView (0.87.1): - boost - DoubleConversion - fast_float @@ -40,7 +36,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - NativeCxxModuleExample (0.87.0-main): + - NativeCxxModuleExample (0.87.1): - boost - DoubleConversion - fast_float @@ -88,31 +84,31 @@ PODS: - fast_float (= 8.0.0) - fmt (= 12.1.0) - glog - - RCTDeprecation (1000.0.0) - - RCTRequired (1000.0.0) - - RCTSwiftUI (1000.0.0): + - RCTDeprecation (0.87.0-rc.0) + - RCTRequired (0.87.0-rc.0) + - RCTSwiftUI (0.87.0-rc.0): - React-RCTUIKit - - RCTSwiftUIWrapper (1000.0.0): + - RCTSwiftUIWrapper (0.87.0-rc.0): - RCTSwiftUI - React-RCTUIKit - - RCTTypeSafety (1000.0.0): - - FBLazyVector (= 1000.0.0) - - RCTRequired (= 1000.0.0) - - React-Core (= 1000.0.0) - - React (1000.0.0): - - React-Core (= 1000.0.0) - - React-Core/DevSupport (= 1000.0.0) - - React-Core/RCTWebSocket (= 1000.0.0) - - React-RCTActionSheet (= 1000.0.0) - - React-RCTAnimation (= 1000.0.0) - - React-RCTBlob (= 1000.0.0) - - React-RCTImage (= 1000.0.0) - - React-RCTLinking (= 1000.0.0) - - React-RCTNetwork (= 1000.0.0) - - React-RCTSettings (= 1000.0.0) - - React-RCTText (= 1000.0.0) - - React-RCTVibration (= 1000.0.0) - - React-bridging (1000.0.0): + - RCTTypeSafety (0.87.0-rc.0): + - FBLazyVector (= 0.87.0-rc.0) + - RCTRequired (= 0.87.0-rc.0) + - React-Core (= 0.87.0-rc.0) + - React (0.87.0-rc.0): + - React-Core (= 0.87.0-rc.0) + - React-Core/DevSupport (= 0.87.0-rc.0) + - React-Core/RCTWebSocket (= 0.87.0-rc.0) + - React-RCTActionSheet (= 0.87.0-rc.0) + - React-RCTAnimation (= 0.87.0-rc.0) + - React-RCTBlob (= 0.87.0-rc.0) + - React-RCTImage (= 0.87.0-rc.0) + - React-RCTLinking (= 0.87.0-rc.0) + - React-RCTNetwork (= 0.87.0-rc.0) + - React-RCTSettings (= 0.87.0-rc.0) + - React-RCTText (= 0.87.0-rc.0) + - React-RCTVibration (= 0.87.0-rc.0) + - React-bridging (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -124,8 +120,8 @@ PODS: - React-jsi - React-timing - SocketRocket - - React-callinvoker (1000.0.0) - - React-Core (1000.0.0): + - React-callinvoker (0.87.0-rc.0) + - React-Core (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -135,7 +131,7 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - RCTDeprecation - - React-Core/Default (= 1000.0.0) + - React-Core/Default (= 0.87.0-rc.0) - React-cxxreact - React-featureflags - React-hermes @@ -151,7 +147,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/CoreModulesHeaders (1000.0.0): + - React-Core/CoreModulesHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -177,7 +173,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/Default (1000.0.0): + - React-Core/Default (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -202,7 +198,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/DevSupport (1000.0.0): + - React-Core/DevSupport (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -212,8 +208,8 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - RCTDeprecation - - React-Core/Default (= 1000.0.0) - - React-Core/RCTWebSocket (= 1000.0.0) + - React-Core/Default (= 0.87.0-rc.0) + - React-Core/RCTWebSocket (= 0.87.0-rc.0) - React-cxxreact - React-featureflags - React-hermes @@ -229,7 +225,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTActionSheetHeaders (1000.0.0): + - React-Core/RCTActionSheetHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -255,7 +251,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTAnimationHeaders (1000.0.0): + - React-Core/RCTAnimationHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -281,7 +277,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTBlobHeaders (1000.0.0): + - React-Core/RCTBlobHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -307,7 +303,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTImageHeaders (1000.0.0): + - React-Core/RCTImageHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -333,7 +329,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTLinkingHeaders (1000.0.0): + - React-Core/RCTLinkingHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -359,7 +355,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTNetworkHeaders (1000.0.0): + - React-Core/RCTNetworkHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -385,7 +381,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTPushNotificationHeaders (1000.0.0): + - React-Core/RCTPushNotificationHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -411,7 +407,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTSettingsHeaders (1000.0.0): + - React-Core/RCTSettingsHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -437,7 +433,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTTextHeaders (1000.0.0): + - React-Core/RCTTextHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -463,7 +459,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTVibrationHeaders (1000.0.0): + - React-Core/RCTVibrationHeaders (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -489,7 +485,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-Core/RCTWebSocket (1000.0.0): + - React-Core/RCTWebSocket (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -499,7 +495,7 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - RCTDeprecation - - React-Core/Default (= 1000.0.0) + - React-Core/Default (= 0.87.0-rc.0) - React-cxxreact - React-featureflags - React-hermes @@ -515,7 +511,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-CoreModules (1000.0.0): + - React-CoreModules (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -523,23 +519,23 @@ PODS: - glog - RCT-Folly - RCT-Folly/Fabric - - RCTTypeSafety (= 1000.0.0) - - React-Core/CoreModulesHeaders (= 1000.0.0) + - RCTTypeSafety (= 0.87.0-rc.0) + - React-Core/CoreModulesHeaders (= 0.87.0-rc.0) - React-debug - React-featureflags - - React-jsi (= 1000.0.0) + - React-jsi (= 0.87.0-rc.0) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - React-NativeModulesApple - React-RCTBlob - React-RCTFBReactNativeSpec - - React-RCTImage (= 1000.0.0) + - React-RCTImage (= 0.87.0-rc.0) - React-runtimeexecutor - React-utils - ReactCommon - SocketRocket - - React-cxxreact (1000.0.0): + - React-cxxreact (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -548,24 +544,24 @@ PODS: - hermes-engine - RCT-Folly - RCT-Folly/Fabric - - React-callinvoker (= 1000.0.0) - - React-debug (= 1000.0.0) - - React-jserrorhandler (= 1000.0.0) - - React-jsi (= 1000.0.0) + - React-callinvoker (= 0.87.0-rc.0) + - React-debug (= 0.87.0-rc.0) + - React-jserrorhandler (= 0.87.0-rc.0) + - React-jsi (= 0.87.0-rc.0) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - - React-logger (= 1000.0.0) - - React-perflogger (= 1000.0.0) + - React-logger (= 0.87.0-rc.0) + - React-perflogger (= 0.87.0-rc.0) - React-runtimeexecutor - - React-timing (= 1000.0.0) + - React-timing (= 0.87.0-rc.0) - React-utils - SocketRocket - - React-cxxstableapi (1000.0.0) - - React-debug (1000.0.0): - - React-debug/redbox (= 1000.0.0) - - React-debug/redbox (1000.0.0) - - React-defaultsnativemodule (1000.0.0): + - React-cxxstableapi (0.87.0-rc.0) + - React-debug (0.87.0-rc.0): + - React-debug/redbox (= 0.87.0-rc.0) + - React-debug/redbox (0.87.0-rc.0) + - React-defaultsnativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -589,7 +585,7 @@ PODS: - React-webperformancenativemodule - SocketRocket - Yoga - - React-domnativemodule (1000.0.0): + - React-domnativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -610,7 +606,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-Fabric (1000.0.0): + - React-Fabric (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -625,25 +621,25 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/animated (= 1000.0.0) - - React-Fabric/animationbackend (= 1000.0.0) - - React-Fabric/animations (= 1000.0.0) - - React-Fabric/attributedstring (= 1000.0.0) - - React-Fabric/bridging (= 1000.0.0) - - React-Fabric/componentregistry (= 1000.0.0) - - React-Fabric/componentregistrynative (= 1000.0.0) - - React-Fabric/components (= 1000.0.0) - - React-Fabric/consistency (= 1000.0.0) - - React-Fabric/core (= 1000.0.0) - - React-Fabric/dom (= 1000.0.0) - - React-Fabric/imagemanager (= 1000.0.0) - - React-Fabric/leakchecker (= 1000.0.0) - - React-Fabric/mounting (= 1000.0.0) - - React-Fabric/observers (= 1000.0.0) - - React-Fabric/scheduler (= 1000.0.0) - - React-Fabric/telemetry (= 1000.0.0) - - React-Fabric/uimanager (= 1000.0.0) - - React-Fabric/viewtransition (= 1000.0.0) + - React-Fabric/animated (= 0.87.0-rc.0) + - React-Fabric/animationbackend (= 0.87.0-rc.0) + - React-Fabric/animations (= 0.87.0-rc.0) + - React-Fabric/attributedstring (= 0.87.0-rc.0) + - React-Fabric/bridging (= 0.87.0-rc.0) + - React-Fabric/componentregistry (= 0.87.0-rc.0) + - React-Fabric/componentregistrynative (= 0.87.0-rc.0) + - React-Fabric/components (= 0.87.0-rc.0) + - React-Fabric/consistency (= 0.87.0-rc.0) + - React-Fabric/core (= 0.87.0-rc.0) + - React-Fabric/dom (= 0.87.0-rc.0) + - React-Fabric/imagemanager (= 0.87.0-rc.0) + - React-Fabric/leakchecker (= 0.87.0-rc.0) + - React-Fabric/mounting (= 0.87.0-rc.0) + - React-Fabric/observers (= 0.87.0-rc.0) + - React-Fabric/scheduler (= 0.87.0-rc.0) + - React-Fabric/telemetry (= 0.87.0-rc.0) + - React-Fabric/uimanager (= 0.87.0-rc.0) + - React-Fabric/viewtransition (= 0.87.0-rc.0) - React-featureflags - React-graphics - React-jsi @@ -655,7 +651,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/animated (1000.0.0): + - React-Fabric/animated (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -682,7 +678,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/animationbackend (1000.0.0): + - React-Fabric/animationbackend (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -708,7 +704,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/animations (1000.0.0): + - React-Fabric/animations (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -734,7 +730,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/attributedstring (1000.0.0): + - React-Fabric/attributedstring (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -760,7 +756,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/bridging (1000.0.0): + - React-Fabric/bridging (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -786,7 +782,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/componentregistry (1000.0.0): + - React-Fabric/componentregistry (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -812,7 +808,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/componentregistrynative (1000.0.0): + - React-Fabric/componentregistrynative (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -838,7 +834,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/components (1000.0.0): + - React-Fabric/components (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -853,10 +849,10 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/components/legacyviewmanagerinterop (= 1000.0.0) - - React-Fabric/components/root (= 1000.0.0) - - React-Fabric/components/scrollview (= 1000.0.0) - - React-Fabric/components/view (= 1000.0.0) + - React-Fabric/components/legacyviewmanagerinterop (= 0.87.0-rc.0) + - React-Fabric/components/root (= 0.87.0-rc.0) + - React-Fabric/components/scrollview (= 0.87.0-rc.0) + - React-Fabric/components/view (= 0.87.0-rc.0) - React-featureflags - React-graphics - React-jsi @@ -868,7 +864,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/components/legacyviewmanagerinterop (1000.0.0): + - React-Fabric/components/legacyviewmanagerinterop (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -894,7 +890,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/components/root (1000.0.0): + - React-Fabric/components/root (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -920,7 +916,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/components/scrollview (1000.0.0): + - React-Fabric/components/scrollview (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -946,7 +942,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/components/view (1000.0.0): + - React-Fabric/components/view (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -974,7 +970,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-Fabric/consistency (1000.0.0): + - React-Fabric/consistency (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1000,7 +996,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/core (1000.0.0): + - React-Fabric/core (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1026,7 +1022,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/dom (1000.0.0): + - React-Fabric/dom (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1052,7 +1048,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/imagemanager (1000.0.0): + - React-Fabric/imagemanager (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1078,7 +1074,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/leakchecker (1000.0.0): + - React-Fabric/leakchecker (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1104,7 +1100,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/mounting (1000.0.0): + - React-Fabric/mounting (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1131,7 +1127,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/observers (1000.0.0): + - React-Fabric/observers (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1146,9 +1142,9 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/observers/events (= 1000.0.0) - - React-Fabric/observers/intersection (= 1000.0.0) - - React-Fabric/observers/mutation (= 1000.0.0) + - React-Fabric/observers/events (= 0.87.0-rc.0) + - React-Fabric/observers/intersection (= 0.87.0-rc.0) + - React-Fabric/observers/mutation (= 0.87.0-rc.0) - React-featureflags - React-graphics - React-jsi @@ -1160,7 +1156,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/observers/events (1000.0.0): + - React-Fabric/observers/events (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1186,7 +1182,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/observers/intersection (1000.0.0): + - React-Fabric/observers/intersection (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1212,7 +1208,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/observers/mutation (1000.0.0): + - React-Fabric/observers/mutation (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1238,7 +1234,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/scheduler (1000.0.0): + - React-Fabric/scheduler (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1269,7 +1265,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/telemetry (1000.0.0): + - React-Fabric/telemetry (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1295,7 +1291,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/uimanager (1000.0.0): + - React-Fabric/uimanager (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1310,7 +1306,7 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/uimanager/consistency (= 1000.0.0) + - React-Fabric/uimanager/consistency (= 0.87.0-rc.0) - React-featureflags - React-graphics - React-jsi @@ -1323,7 +1319,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/uimanager/consistency (1000.0.0): + - React-Fabric/uimanager/consistency (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1350,7 +1346,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-Fabric/viewtransition (1000.0.0): + - React-Fabric/viewtransition (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1376,7 +1372,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - React-FabricComponents (1000.0.0): + - React-FabricComponents (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1391,8 +1387,8 @@ PODS: - React-cxxreact - React-debug - React-Fabric - - React-FabricComponents/components (= 1000.0.0) - - React-FabricComponents/textlayoutmanager (= 1000.0.0) + - React-FabricComponents/components (= 0.87.0-rc.0) + - React-FabricComponents/textlayoutmanager (= 0.87.0-rc.0) - React-featureflags - React-graphics - React-jsi @@ -1405,7 +1401,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components (1000.0.0): + - React-FabricComponents/components (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1420,16 +1416,16 @@ PODS: - React-cxxreact - React-debug - React-Fabric - - React-FabricComponents/components/inputaccessory (= 1000.0.0) - - React-FabricComponents/components/iostextinput (= 1000.0.0) - - React-FabricComponents/components/modal (= 1000.0.0) - - React-FabricComponents/components/rncore (= 1000.0.0) - - React-FabricComponents/components/safeareaview (= 1000.0.0) - - React-FabricComponents/components/switch (= 1000.0.0) - - React-FabricComponents/components/text (= 1000.0.0) - - React-FabricComponents/components/textinput (= 1000.0.0) - - React-FabricComponents/components/unimplementedview (= 1000.0.0) - - React-FabricComponents/components/virtualview (= 1000.0.0) + - React-FabricComponents/components/inputaccessory (= 0.87.0-rc.0) + - React-FabricComponents/components/iostextinput (= 0.87.0-rc.0) + - React-FabricComponents/components/modal (= 0.87.0-rc.0) + - React-FabricComponents/components/rncore (= 0.87.0-rc.0) + - React-FabricComponents/components/safeareaview (= 0.87.0-rc.0) + - React-FabricComponents/components/switch (= 0.87.0-rc.0) + - React-FabricComponents/components/text (= 0.87.0-rc.0) + - React-FabricComponents/components/textinput (= 0.87.0-rc.0) + - React-FabricComponents/components/unimplementedview (= 0.87.0-rc.0) + - React-FabricComponents/components/virtualview (= 0.87.0-rc.0) - React-featureflags - React-graphics - React-jsi @@ -1442,7 +1438,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/inputaccessory (1000.0.0): + - React-FabricComponents/components/inputaccessory (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1469,7 +1465,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/iostextinput (1000.0.0): + - React-FabricComponents/components/iostextinput (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1496,7 +1492,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/modal (1000.0.0): + - React-FabricComponents/components/modal (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1523,7 +1519,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/rncore (1000.0.0): + - React-FabricComponents/components/rncore (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1550,7 +1546,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/safeareaview (1000.0.0): + - React-FabricComponents/components/safeareaview (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1577,7 +1573,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/switch (1000.0.0): + - React-FabricComponents/components/switch (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1604,7 +1600,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/text (1000.0.0): + - React-FabricComponents/components/text (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1631,7 +1627,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/textinput (1000.0.0): + - React-FabricComponents/components/textinput (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1658,7 +1654,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/unimplementedview (1000.0.0): + - React-FabricComponents/components/unimplementedview (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1685,7 +1681,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/components/virtualview (1000.0.0): + - React-FabricComponents/components/virtualview (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1712,7 +1708,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricComponents/textlayoutmanager (1000.0.0): + - React-FabricComponents/textlayoutmanager (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1739,7 +1735,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-FabricImage (1000.0.0): + - React-FabricImage (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1748,21 +1744,21 @@ PODS: - hermes-engine - RCT-Folly - RCT-Folly/Fabric - - RCTRequired (= 1000.0.0) - - RCTTypeSafety (= 1000.0.0) + - RCTRequired (= 0.87.0-rc.0) + - RCTTypeSafety (= 0.87.0-rc.0) - React-Fabric - React-featureflags - React-graphics - React-ImageManager - React-jsi - - React-jsiexecutor (= 1000.0.0) + - React-jsiexecutor (= 0.87.0-rc.0) - React-logger - React-rendererdebug - React-utils - ReactCommon - SocketRocket - Yoga - - React-featureflags (1000.0.0): + - React-featureflags (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1771,7 +1767,7 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - SocketRocket - - React-featureflagsnativemodule (1000.0.0): + - React-featureflagsnativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1786,7 +1782,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - React-graphics (1000.0.0): + - React-graphics (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1802,7 +1798,7 @@ PODS: - React-rendererdebug - React-utils - SocketRocket - - React-hermes (1000.0.0): + - React-hermes (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1811,18 +1807,18 @@ PODS: - hermes-engine - RCT-Folly - RCT-Folly/Fabric - - React-cxxreact (= 1000.0.0) + - React-cxxreact (= 0.87.0-rc.0) - React-jsi - - React-jsiexecutor (= 1000.0.0) + - React-jsiexecutor (= 0.87.0-rc.0) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - React-jsitooling - React-oscompat - - React-perflogger (= 1000.0.0) + - React-perflogger (= 0.87.0-rc.0) - React-runtimeexecutor - SocketRocket - - React-idlecallbacksnativemodule (1000.0.0): + - React-idlecallbacksnativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1839,7 +1835,7 @@ PODS: - React-runtimescheduler - ReactCommon/turbomodule/core - SocketRocket - - React-ImageManager (1000.0.0): + - React-ImageManager (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1854,7 +1850,7 @@ PODS: - React-rendererdebug - React-utils - SocketRocket - - React-intersectionobservernativemodule (1000.0.0): + - React-intersectionobservernativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1876,7 +1872,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-jserrorhandler (1000.0.0): + - React-jserrorhandler (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1890,7 +1886,7 @@ PODS: - React-featureflags - React-jsi - SocketRocket - - React-jsi (1000.0.0): + - React-jsi (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1900,7 +1896,7 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - SocketRocket - - React-jsiexecutor (1000.0.0): + - React-jsiexecutor (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1921,7 +1917,7 @@ PODS: - React-runtimeexecutor - React-utils - SocketRocket - - React-jsinspector (1000.0.0): + - React-jsinspector (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1936,11 +1932,11 @@ PODS: - React-jsinspectornetwork - React-jsinspectortracing - React-oscompat - - React-perflogger (= 1000.0.0) + - React-perflogger (= 0.87.0-rc.0) - React-runtimeexecutor - React-utils - SocketRocket - - React-jsinspectorcdp (1000.0.0): + - React-jsinspectorcdp (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1949,7 +1945,7 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - SocketRocket - - React-jsinspectornetwork (1000.0.0): + - React-jsinspectornetwork (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1959,7 +1955,7 @@ PODS: - RCT-Folly/Fabric - React-jsinspectorcdp - SocketRocket - - React-jsinspectortracing (1000.0.0): + - React-jsinspectortracing (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1974,7 +1970,7 @@ PODS: - React-timing - React-utils - SocketRocket - - React-jsitooling (1000.0.0): + - React-jsitooling (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -1983,18 +1979,18 @@ PODS: - hermes-engine - RCT-Folly - RCT-Folly/Fabric - - React-cxxreact (= 1000.0.0) + - React-cxxreact (= 0.87.0-rc.0) - React-debug - - React-jsi (= 1000.0.0) + - React-jsi (= 0.87.0-rc.0) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - React-runtimeexecutor - React-utils - SocketRocket - - React-jsitracing (1000.0.0): + - React-jsitracing (0.87.0-rc.0): - React-jsi - - React-logger (1000.0.0): + - React-logger (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2003,7 +1999,7 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - SocketRocket - - React-Mapbuffer (1000.0.0): + - React-Mapbuffer (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2013,7 +2009,7 @@ PODS: - RCT-Folly/Fabric - React-debug - SocketRocket - - React-microtasksnativemodule (1000.0.0): + - React-microtasksnativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2027,7 +2023,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - React-mutationobservernativemodule (1000.0.0): + - React-mutationobservernativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2049,7 +2045,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-NativeModulesApple (1000.0.0): + - React-NativeModulesApple (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2070,7 +2066,7 @@ PODS: - React-runtimeexecutor - ReactCommon/turbomodule/core - SocketRocket - - React-networking (1000.0.0): + - React-networking (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2083,8 +2079,8 @@ PODS: - React-performancetimeline - React-timing - SocketRocket - - React-oscompat (1000.0.0) - - React-perflogger (1000.0.0): + - React-oscompat (0.87.0-rc.0) + - React-perflogger (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2093,7 +2089,7 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - SocketRocket - - React-performancecdpmetrics (1000.0.0): + - React-performancecdpmetrics (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2107,7 +2103,7 @@ PODS: - React-runtimeexecutor - React-timing - SocketRocket - - React-performancetimeline (1000.0.0): + - React-performancetimeline (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2121,9 +2117,9 @@ PODS: - React-perflogger - React-timing - SocketRocket - - React-RCTActionSheet (1000.0.0): - - React-Core/RCTActionSheetHeaders (= 1000.0.0) - - React-RCTAnimatedModuleProvider (1000.0.0): + - React-RCTActionSheet (0.87.0-rc.0): + - React-Core/RCTActionSheetHeaders (= 0.87.0-rc.0) + - React-RCTAnimatedModuleProvider (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2138,7 +2134,7 @@ PODS: - ReactCommon - SocketRocket - Yoga - - React-RCTAnimation (1000.0.0): + - React-RCTAnimation (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2155,7 +2151,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - SocketRocket - - React-RCTAppDelegate (1000.0.0): + - React-RCTAppDelegate (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2190,7 +2186,7 @@ PODS: - React-utils - ReactCommon - SocketRocket - - React-RCTBlob (1000.0.0): + - React-RCTBlob (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2209,7 +2205,7 @@ PODS: - React-RCTNetwork - ReactCommon - SocketRocket - - React-RCTFabric (1000.0.0): + - React-RCTFabric (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2246,7 +2242,7 @@ PODS: - React-utils - SocketRocket - Yoga - - React-RCTFBReactNativeSpec (1000.0.0): + - React-RCTFBReactNativeSpec (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2261,10 +2257,10 @@ PODS: - React-Core - React-jsi - React-NativeModulesApple - - React-RCTFBReactNativeSpec/components (= 1000.0.0) + - React-RCTFBReactNativeSpec/components (= 0.87.0-rc.0) - ReactCommon - SocketRocket - - React-RCTFBReactNativeSpec/components (1000.0.0): + - React-RCTFBReactNativeSpec/components (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2288,7 +2284,7 @@ PODS: - ReactCommon - SocketRocket - Yoga - - React-RCTImage (1000.0.0): + - React-RCTImage (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2304,14 +2300,14 @@ PODS: - React-RCTNetwork - ReactCommon - SocketRocket - - React-RCTLinking (1000.0.0): - - React-Core/RCTLinkingHeaders (= 1000.0.0) - - React-jsi (= 1000.0.0) + - React-RCTLinking (0.87.0-rc.0): + - React-Core/RCTLinkingHeaders (= 0.87.0-rc.0) + - React-jsi (= 0.87.0-rc.0) - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - ReactCommon/turbomodule/core (= 1000.0.0) - - React-RCTNetwork (1000.0.0): + - ReactCommon/turbomodule/core (= 0.87.0-rc.0) + - React-RCTNetwork (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2331,14 +2327,14 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - SocketRocket - - React-RCTPushNotification (1000.0.0): + - React-RCTPushNotification (0.87.0-rc.0): - RCTTypeSafety - React-Core/RCTPushNotificationHeaders - React-jsi - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - React-RCTRuntime (1000.0.0): + - React-RCTRuntime (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2360,7 +2356,7 @@ PODS: - React-RuntimeHermes - React-utils - SocketRocket - - React-RCTSettings (1000.0.0): + - React-RCTSettings (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2375,7 +2371,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - SocketRocket - - React-RCTTest (1000.0.0): + - React-RCTTest (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2383,16 +2379,16 @@ PODS: - glog - RCT-Folly - RCT-Folly/Fabric - - React-Core (= 1000.0.0) - - React-CoreModules (= 1000.0.0) - - React-jsi (= 1000.0.0) - - ReactCommon/turbomodule/core (= 1000.0.0) + - React-Core (= 0.87.0-rc.0) + - React-CoreModules (= 0.87.0-rc.0) + - React-jsi (= 0.87.0-rc.0) + - ReactCommon/turbomodule/core (= 0.87.0-rc.0) - SocketRocket - - React-RCTText (1000.0.0): - - React-Core/RCTTextHeaders (= 1000.0.0) + - React-RCTText (0.87.0-rc.0): + - React-Core/RCTTextHeaders (= 0.87.0-rc.0) - Yoga - - React-RCTUIKit (1000.0.0) - - React-RCTVibration (1000.0.0): + - React-RCTUIKit (0.87.0-rc.0) + - React-RCTVibration (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2406,11 +2402,11 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - SocketRocket - - React-rendererconsistency (1000.0.0) - - React-renderercss (1000.0.0): + - React-rendererconsistency (0.87.0-rc.0) + - React-renderercss (0.87.0-rc.0): - React-debug - React-utils - - React-rendererdebug (1000.0.0): + - React-rendererdebug (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2420,7 +2416,7 @@ PODS: - RCT-Folly/Fabric - React-debug - SocketRocket - - React-RuntimeApple (1000.0.0): + - React-RuntimeApple (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2449,7 +2445,7 @@ PODS: - React-runtimescheduler - React-utils - SocketRocket - - React-RuntimeCore (1000.0.0): + - React-RuntimeCore (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2471,7 +2467,7 @@ PODS: - React-runtimescheduler - React-utils - SocketRocket - - React-runtimeexecutor (1000.0.0): + - React-runtimeexecutor (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2481,10 +2477,10 @@ PODS: - RCT-Folly/Fabric - React-debug - React-featureflags - - React-jsi (= 1000.0.0) + - React-jsi (= 0.87.0-rc.0) - React-utils - SocketRocket - - React-RuntimeHermes (1000.0.0): + - React-RuntimeHermes (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2505,7 +2501,7 @@ PODS: - React-runtimeexecutor - React-utils - SocketRocket - - React-runtimescheduler (1000.0.0): + - React-runtimescheduler (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2528,9 +2524,9 @@ PODS: - React-timing - React-utils - SocketRocket - - React-timing (1000.0.0): + - React-timing (0.87.0-rc.0): - React-debug - - React-utils (1000.0.0): + - React-utils (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2540,9 +2536,9 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - React-debug - - React-jsi (= 1000.0.0) + - React-jsi (= 0.87.0-rc.0) - SocketRocket - - React-viewtransitionnativemodule (1000.0.0): + - React-viewtransitionnativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2560,7 +2556,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - React-webperformancenativemodule (1000.0.0): + - React-webperformancenativemodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2578,9 +2574,9 @@ PODS: - React-runtimeexecutor - ReactCommon/turbomodule/core - SocketRocket - - ReactAppDependencyProvider (1000.0.0): + - ReactAppDependencyProvider (0.87.0-rc.0): - ReactCodegen - - ReactCodegen (1000.0.0): + - ReactCodegen (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2606,7 +2602,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - SocketRocket - - ReactCommon (1000.0.0): + - ReactCommon (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2614,9 +2610,9 @@ PODS: - glog - RCT-Folly - RCT-Folly/Fabric - - ReactCommon/turbomodule (= 1000.0.0) + - ReactCommon/turbomodule (= 0.87.0-rc.0) - SocketRocket - - ReactCommon-Samples (1000.0.0): + - ReactCommon-Samples (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2633,7 +2629,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - SocketRocket - - ReactCommon/turbomodule (1000.0.0): + - ReactCommon/turbomodule (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2642,13 +2638,13 @@ PODS: - hermes-engine - RCT-Folly - RCT-Folly/Fabric - - React-callinvoker (= 1000.0.0) - - React-jsi (= 1000.0.0) - - React-logger (= 1000.0.0) - - React-perflogger (= 1000.0.0) - - ReactCommon/turbomodule/core (= 1000.0.0) + - React-callinvoker (= 0.87.0-rc.0) + - React-jsi (= 0.87.0-rc.0) + - React-logger (= 0.87.0-rc.0) + - React-perflogger (= 0.87.0-rc.0) + - ReactCommon/turbomodule/core (= 0.87.0-rc.0) - SocketRocket - - ReactCommon/turbomodule/core (1000.0.0): + - ReactCommon/turbomodule/core (0.87.0-rc.0): - boost - DoubleConversion - fast_float @@ -2658,16 +2654,16 @@ PODS: - RCT-Folly - RCT-Folly/Fabric - React-bridging - - React-callinvoker (= 1000.0.0) - - React-cxxreact (= 1000.0.0) - - React-debug (= 1000.0.0) - - React-featureflags (= 1000.0.0) - - React-jsi (= 1000.0.0) - - React-logger (= 1000.0.0) - - React-perflogger (= 1000.0.0) - - React-utils (= 1000.0.0) + - React-callinvoker (= 0.87.0-rc.0) + - React-cxxreact (= 0.87.0-rc.0) + - React-debug (= 0.87.0-rc.0) + - React-featureflags (= 0.87.0-rc.0) + - React-jsi (= 0.87.0-rc.0) + - React-logger (= 0.87.0-rc.0) + - React-perflogger (= 0.87.0-rc.0) + - React-utils (= 0.87.0-rc.0) - SocketRocket - - ScreenshotManager (0.87.0-main): + - ScreenshotManager (0.87.1): - boost - DoubleConversion - fast_float @@ -2812,7 +2808,7 @@ EXTERNAL SOURCES: :podspec: "../react-native/third-party-podspecs/glog.podspec" hermes-engine: :podspec: "../react-native/sdks/hermes-engine/hermes-engine.podspec" - :tag: hermes-v250829098.0.12 + :tag: hermes-v250829098.0.17 MyNativeView: :path: NativeComponentExample NativeCxxModuleExample: @@ -2982,94 +2978,94 @@ SPEC CHECKSUMS: boost: cea1d4f90a3a59537f3deb03ff5656489d7133dd DoubleConversion: d31b1eb37f6d6f456530c4fd9124b857d6889cab fast_float: 20817c22759af6ac8d4d67e6e059b8b499953656 - FBLazyVector: c2374c799daa208dabd7644bb46988206509cdfa + FBLazyVector: 77c6d52e07c3244868089f9339b533408d88863e fmt: 4cf0c5ec5864511c96d8d4bd7b03c3c69040af06 glog: 8fb6f603b9feab8ebedb130c6e0683bbeafa6fb0 - hermes-engine: b31af4df03103d006996baed689687ae884dc97d - MyNativeView: a375b47303122a6370f5525ee487d2bbf99921fc - NativeCxxModuleExample: f126739cc53d2c0db5b86574003d810654cb4f97 + hermes-engine: d0fbfceba80b8dd5585b16ac7b82318fc769e015 + MyNativeView: 141f787c73d215a1611732ff58fe87d7a9174a3f + NativeCxxModuleExample: c6b2a6f3d61dbc673694d990b79762d6285d9d7b OCMock: 589f2c84dacb1f5aaf6e4cec1f292551fe748e74 RCT-Folly: 4b76727f41b861e46e2214daabb8a335504c3b0f - RCTDeprecation: 3808e36294137f9ee5668f4df2e73dc079cd1dcf - RCTRequired: 80bd25e58b2af3a644729a5f116d4acbfef7a550 - RCTSwiftUI: 3f663e8f5b76fb667fee69689e010176193df20c - RCTSwiftUIWrapper: 5dcfe424c88d1b18ac45ea6755e551d221955f9c - RCTTypeSafety: 9d9eacc72c4fb7e7cefdeb91df6bb73a50396614 - React: 79dfabb43be7edb6a5ad484a591fb7bdac88916c - React-bridging: 96357198119596016a5d589fde76c690fc96e672 - React-callinvoker: 97b64a05ec040a916218a3418b000872977683a3 - React-Core: a170edc3eec503a130021fb68e67f26127daa806 - React-CoreModules: a0a435e2651362e40957f4adba9e7b9a1c2b7096 - React-cxxreact: 0e0869afaeceb045b47c6b8feb6a3a26c27f4482 - React-cxxstableapi: e21eaa16126d799c087cc78e21b6318318ac1642 - React-debug: 663372a6d14b31e78ddbcaebb4facfcc8488aa55 - React-defaultsnativemodule: bf564be62b7fbbfc3f5a58d8e59fbd3d7ec6752f - React-domnativemodule: 75d36924546cab2a27f6dfa38e2160374f2af300 - React-Fabric: e73c49b4e649915d969467b22903fb432aacb7a4 - React-FabricComponents: e0c374859e18e6166f9ef88c883dcf8c8eecdfae - React-FabricImage: 52ad8e8ff1636a76a60ccf68c6be4b725e16f1ae - React-featureflags: 6a6393d4dd30133ee3c74fa44c4ed867ff87dac6 - React-featureflagsnativemodule: 0df16dcd43a3d96017d41fe7c37e07836810968a - React-graphics: c9470ea68e3398ed845e96bd3790a40b36aee80e - React-hermes: 61b9c17ec1102088f839517eebe70da36c658a24 - React-idlecallbacksnativemodule: 448834507f68451335aab0498382bd2395d4cffa - React-ImageManager: 8eea1ea50245713a2c14a1234bb8457e25edf4dc - React-intersectionobservernativemodule: 235b856dd29165c15b83ecf7433e22b3e3ded7d8 - React-jserrorhandler: 28acbf24871c1f9ba0bbcf3ac1da78e9800abce7 - React-jsi: 10915051041f49b5eb9dbaf9edd78a4e3fc73584 - React-jsiexecutor: 00b60fa543d9468f4f8399620b41004b5c5064d2 - React-jsinspector: 9c99cc20d7465415bf6a5314994645846c3cdca4 - React-jsinspectorcdp: 0eaf03c623bc0fde367cfa93765aae25635e7521 - React-jsinspectornetwork: 241af6af062c646fa139e68d8e8cdcf339ac1fe4 - React-jsinspectortracing: 3db8ad02f6bdbe0a93b902eeb0288d98e198d00d - React-jsitooling: 3d958faeadcd296f5f0f83b6ecad3b6caa7589d7 - React-jsitracing: afed2d2431d638baed1d86a6a3ac25c87d2c4d85 - React-logger: 176173159a23947dd203b6fab3930382c99dd2ac - React-Mapbuffer: d85b8ff03ff36280539e1e19f1a0ea60ae0488d5 - React-microtasksnativemodule: c37a2c2ba3e9734775df8d2a92be2f125f489ca1 - React-mutationobservernativemodule: f885e0a98c2bbdb3bcbd75d8a1eb23008c0bf289 - React-NativeModulesApple: 368dc25e566d69e8646a58a2a9b4edc9c1978289 - React-networking: 8ad65d6b6d30452bea8ae7752490b62220bff964 - React-oscompat: e0675c5aed87c556bc673eda18c360d371058eb4 - React-perflogger: c3e6e46683158ef9aff3dff2657a36842ae4302f - React-performancecdpmetrics: c158e731458fa1f0bc24eb2b52cbb1aac0ea2ff5 - React-performancetimeline: c2252819d7ff69c2518d51ba167e88099cf0d46d - React-RCTActionSheet: 0a6f121ef2c1033ca358b62ddbac29065bb188ed - React-RCTAnimatedModuleProvider: 17e601f164290d55dd15793d7fe2e115367c2205 - React-RCTAnimation: de71b13bd00d747cde571d2a3cb8972b03ff8e93 - React-RCTAppDelegate: efd5dcd3387c84d3e72d283d074172104b4da3a9 - React-RCTBlob: 9498b46cde11ec16862f018df3cd22471508213f - React-RCTFabric: 3ac3cc30161acefd7b5147a4eb11e224de4ea207 - React-RCTFBReactNativeSpec: ecc900b84941787bf519960304ded415149601ff - React-RCTImage: e8e3f260f857012ac25bfd213717437663283ba9 - React-RCTLinking: 2c204fd4cd7b7e6f3ec0c152db96568561e6f28a - React-RCTNetwork: 0e16306ff544bebd318e77654f5a70bf852e065b - React-RCTPushNotification: 483999c7b5aa28e7d73c2f5e44f2b7de0c8b8547 - React-RCTRuntime: ee6f19688e9ab75630252ea8dbf5c97d473869ce - React-RCTSettings: cb0ac9770bc6862a3b30413c967f1dccd4437152 - React-RCTTest: a0f34da615ee8b2a7f532b8a24d689770feb228e - React-RCTText: b70675d991732b60b62d4f84a4d02e831e083f9e - React-RCTUIKit: 303ee4730febcd27ba1f0f9c1bcaf7316d3c8d69 - React-RCTVibration: 790ec31e5046e67bf26943b99b1bea75d25de5c7 - React-rendererconsistency: 5d2627148efd484fd9ec4b68fbd85e5c53c5139a - React-renderercss: 7cb1e9493ff5ca0f3ab20cbd10e96636272087e0 - React-rendererdebug: 3bd6f68616f04737d29c6869795d65b8508de446 - React-RuntimeApple: 9dc3e68723a7701cc41e221652c4a3ee9c097b84 - React-RuntimeCore: e3ac5247ba647d6cd10b1d4392e813285cf1e519 - React-runtimeexecutor: ae42a544c97af7c31bb6cd6471dca411fd39e32d - React-RuntimeHermes: 72c343cb221e7b155e300cec7dadb949870931f6 - React-runtimescheduler: ebb5c860644a67f114da27479d3cf278b4741340 - React-timing: 6380bdd56d2f84641e470d3e275f53ece5774535 - React-utils: 5a54df276fccf5dc532e53922c7cdf8b11093392 - React-viewtransitionnativemodule: 0ccc62281ebefc7c95ebfbc5b29a9c0901a99fc8 - React-webperformancenativemodule: 1b2e52aa69577dc953c700c9051a3c5c722b7f67 - ReactAppDependencyProvider: 95e56d1a824a86110a7cc6c228757e26b757770a - ReactCodegen: 9765a3d4c84e89ea7f0438ceeea31815e261767c - ReactCommon: 39c91e3cdfcb7aca6a230bd88187cf60542def40 - ReactCommon-Samples: 77ba8ded24703d02c91a0d72e08a08519c567457 - ScreenshotManager: ce1770eb65478f72a7d941562469a9eb1a8a65c2 + RCTDeprecation: 534cd867f984647bb0627a91c9e065c39e958782 + RCTRequired: 76b1b7ca897014b98067ddaf5a53bb20670191fa + RCTSwiftUI: 23a2854e4506222fe9e469001f8f9c30b3a01387 + RCTSwiftUIWrapper: 54e1269f7023a6c27771e9ba8df00eec7c86a2bd + RCTTypeSafety: 7d7c46e51926dcc5a85f11ea70abf28c24c975cf + React: 0fa5b2ddbb58528809545329288ec77bbe6aaeb5 + React-bridging: 869664130a145ca33bf4d76a4bea5dc72abfa973 + React-callinvoker: 768f26383d34f130fa99e62a9f231d2897cbdf7f + React-Core: 79c00ab186a2c7cd17905fb3c8db35e3414279ae + React-CoreModules: 57c26870d9a9ce80809b8b4465fcb60e7c0ff24c + React-cxxreact: 1fd2b216515aaa45540e98173b8e1938ad8525d5 + React-cxxstableapi: 33330a272d458dcd87e25ac5e64c91480948e648 + React-debug: c37098b62a7aafb061af9842455cfacde8e85905 + React-defaultsnativemodule: 9bad8107d06f189a1152e5de152e882dd6922197 + React-domnativemodule: 6d2356a248fa2e5d1e97b8a1084969703c17415d + React-Fabric: b92b4d69726d13fb842bd45ebaf95aaf380ce17f + React-FabricComponents: b9edb473760ff40e7282cc501feabe9630e773e6 + React-FabricImage: 7120a7fdf8f29edef86068c86a8fd8514ba26e06 + React-featureflags: db264ecfc262b2973815c9cbfa72bf92de75b3b5 + React-featureflagsnativemodule: b726028a8534b48ff3d06de97bddde0f2c5861ab + React-graphics: e2fc0cb047a3c64ca41fde9ea0efe42becd235fe + React-hermes: 5fa941a68122c6368da712b0c962a86a7151c630 + React-idlecallbacksnativemodule: 7d74425e65304bba291b61a42b6c56e37ad2dbc1 + React-ImageManager: 19a0c173cc054dac16b5776a59a377204af07dc9 + React-intersectionobservernativemodule: b653da263e9f42c520fe7cc0505f9d33565d6b83 + React-jserrorhandler: e4eda2b18d5565ba2c4fe2de7180ffb39522e1f0 + React-jsi: f67291f137e1d4a6b1f9ea5cec63c7783d3fa2bd + React-jsiexecutor: de772ca7923ece82282f65806853de147f1105b4 + React-jsinspector: 0e65415f74a494e42e75b9450d08893bba6de2d6 + React-jsinspectorcdp: 41a58387d918be349f65f4d236c40255a9ac601b + React-jsinspectornetwork: e0ca4f0cfcffa3da766b4ab99a51f2f0ed6ded37 + React-jsinspectortracing: c98bae6d985d64d40b7be0d84f6bed8934be8283 + React-jsitooling: 311ca8b75d908f89f2dce6e11c8948788b85b208 + React-jsitracing: f1f958182609b92fb1ea2b1c8791b80f7e518cac + React-logger: 943f65ce0000c0659da3498b3632fc47c42b7376 + React-Mapbuffer: 4f9cb7426877592e5e9067a7781ef07b3d675fcc + React-microtasksnativemodule: 439a6b9912bf9f3536ae3e1d33f85f9b012900d8 + React-mutationobservernativemodule: f294d7a9ce7ef590b7f1d22abbd34eeae5db9cca + React-NativeModulesApple: cce741796387ee3c1211d3e8ac0833c98568b705 + React-networking: abded2881f9a97ac6c91c7f70012ec06ac86c557 + React-oscompat: b6df13ad93d6ee04401fd3950a30d16a320944ef + React-perflogger: d24b326269815ff684c71928577928b36d7c0c46 + React-performancecdpmetrics: 70a2e227e940635a2a8d2c3d53c766781990f4d8 + React-performancetimeline: 3be31a7712bc1448603dab089f117734b28f4e49 + React-RCTActionSheet: 063c1500c5acf43d50bdd9d5ba441232b59f613a + React-RCTAnimatedModuleProvider: f321bc88784e43f64507bba6b581101ff76814e3 + React-RCTAnimation: 0820b1bf52947d045f1dd76430f12c3f6c9cf94e + React-RCTAppDelegate: c9381b79a492e0503b325d06e8e525ebc558cc1b + React-RCTBlob: e47f2882b1d6d5dbafb47d1de2efdbb003f905f4 + React-RCTFabric: 3ca41f0f6a74c6d1da4058bd10198f0baf3fe9a8 + React-RCTFBReactNativeSpec: a438f0641f279a44a4fc025aa6d9b5344691248d + React-RCTImage: c9e4f091cc194601e2437cfa708f6359d29ed689 + React-RCTLinking: 558822935ebbedca1fbc21fe7c19b6d927eb1dc3 + React-RCTNetwork: 62b42f47f77c7d365ea54b4e5e34aead70c97c33 + React-RCTPushNotification: 182300b7e6b875d9eb42c502f3c1cc5f0c8b0c23 + React-RCTRuntime: cccc80b283fdd0c80e73437d7fd7e9965b48b14d + React-RCTSettings: cdd1981df2c948e102cfd73c65e2ebafd45f8db4 + React-RCTTest: e5586f65708c679f4f1ed77cf5e55ca739667777 + React-RCTText: 600e10be930171879bba5849f0b82a33f1a5c6fc + React-RCTUIKit: 1a93d1868b39bb76916a31b21cdff26e6497a6c7 + React-RCTVibration: 2d22999fc9e49f8998f2ab22388afd03538a4642 + React-rendererconsistency: 38060d394974a372fc699e15f06eec923151557c + React-renderercss: a4fe0ab6a4e1ca7801ede4c71cc33bc954232835 + React-rendererdebug: a992206e42c2701dc0495731ea3c515496049181 + React-RuntimeApple: b28db40a0c9027986c8925121f3cbbaccaa0de90 + React-RuntimeCore: 8f37ec27225a4d9548f85d24b448767034b70ea5 + React-runtimeexecutor: 13b70ca2f8ffddb7ee393ff770e690fee4a112c2 + React-RuntimeHermes: 1742331a250a4a6ee781798ea102a1e7cbcb5adc + React-runtimescheduler: 9004c6921e42bf7563ea2c321d8d66a03545c08d + React-timing: a80860fabe3c8e2c9ccc001a5c07d4a299f40423 + React-utils: cf27d3350a6764aa4d7924d9db00f7da2357f414 + React-viewtransitionnativemodule: c6f98184d2c50c18b86b276724bc5f1eb069e8d2 + React-webperformancenativemodule: a3e82f688b7f20fdcec148b2239badde7b8034a3 + ReactAppDependencyProvider: 0f97709744e6d9ecafe3caa6027275a920a58c18 + ReactCodegen: f2c75aa7762a221edb8cd7a7d694b2423c9e4ecf + ReactCommon: eb48f1f649784b5bca60e372d49a3f8c95193e57 + ReactCommon-Samples: 75d305a3ab89d82fa241896bbf837b23b8e1a19e + ScreenshotManager: bff11f0a236d948764f63eb126a4dcbfb9d19c66 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: a03023f5aaf527d8a3fbfba2691d9329692bfdd9 + Yoga: 16f5b5688d92d1df3ccf32e3ff97225bbeed8d4f PODFILE CHECKSUM: 76dda25425afe238cbe0379633645d667998a038 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 => ( + + +