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..129c93b36d3b 100644 --- a/.github/actions/test-ios-rntester/action.yml +++ b/.github/actions/test-ios-rntester/action.yml @@ -15,9 +15,18 @@ inputs: required: false default: false use-frameworks: - description: Whether we have to build with Dynamic Frameworks. If this is set to true, it builds from source + description: Whether we have to build with Dynamic Frameworks. required: false default: false + use-prebuilds: + description: >- + Whether to consume the prebuilt ReactCore/ReactNativeDependencies + artifacts. 'auto' (default) keeps the historical coupling: prebuilds for + static, source for dynamic frameworks. Pass 'true' with + use-frameworks:true for the prebuilt + dynamic-frameworks lane (the + config of the 2026-07-03 SocketRocket dual-copy regression). + required: false + default: auto runs: using: composite @@ -41,24 +50,38 @@ 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 + else + echo "enabled=${{ inputs.use-prebuilds }}" >> "$GITHUB_OUTPUT" + fi - name: Download ReactNativeDependencies - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/third-party/ - name: Print third-party folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/third-party - name: Download React Native Prebuilds - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactCore${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/ReactCore - name: Print ReactCore folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/ReactCore - name: Install CocoaPods dependencies @@ -66,8 +89,8 @@ runs: run: | if [[ ${{ inputs.use-frameworks }} == "true" ]]; then export USE_FRAMEWORKS=dynamic - else - # If use-frameworks is false, let's use prebuilds + fi + if [[ "${{ steps.prebuilds.outputs.enabled }}" == "true" ]]; then export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz" export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ inputs.flavor }}.xcframework.tar.gz" fi diff --git a/.github/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/verifyArtifactsAreOnMaven.js b/.github/workflow-scripts/verifyArtifactsAreOnMaven.js index 1bb46163e0a2..3acc37023bed 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,49 @@ const ARTIFACT_URL = 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/'; const ARTIFACT_NAME = 'react-native-artifacts-'; +// The classifier-suffixed tarballs attached to the react-native-artifacts +// publication (external-artifacts/build.gradle.kts). 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, retries = MAX_RETRIES) { 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) { + const response = await fetch(url, {method: 'HEAD'}); + if (response.status !== 200) { + 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/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index 6809e7ffceff..8c466aa17863 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -167,9 +167,11 @@ jobs: if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' shell: bash run: | - # ReactNativeHeaders.xcframework (built by the compose step) folds in - # the third-party deps namespaces (folly/glog/boost/...), so the deps - # headers must be staged here too — not just in build-slices. + # 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 @@ -212,6 +214,15 @@ jobs: 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: @@ -222,6 +233,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 @@ -229,4 +245,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 + packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.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/**/*') }} diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index ab9322da3dd8..b4d4407ace87 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: v4-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.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: v4-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js') }} diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 422a90c712ae..4c353e965dc2 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -155,6 +155,12 @@ jobs: uses: ./.github/actions/test-ios-rntester with: use-frameworks: ${{ matrix.frameworks }} + # Consume the prebuilt artifacts in the dynamic-frameworks cells too: + # prebuilt + use_frameworks is the config of the 2026-07-03 + # SocketRocket dual-copy regression, previously covered by no lane + # (source-built dynamic frameworks stay covered by + # test_ios_rntester_dynamic_frameworks). + use-prebuilds: true flavor: ${{ matrix.flavor }} test_e2e_ios_rntester: diff --git a/packages/react-native/React-Core-prebuilt.podspec b/packages/react-native/React-Core-prebuilt.podspec index 36d191278a08..bc838fafef3a 100644 --- a/packages/react-native/React-Core-prebuilt.podspec +++ b/packages/react-native/React-Core-prebuilt.podspec @@ -22,11 +22,15 @@ Pod::Spec.new do |s| # - 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. Carries every other namespace - # (, , folly, glog, ...). Its headers are flattened into a - # top-level Headers/ (see prepare_command) and exposed via the standard pod - # header search path. ( is supplied by the hermes-engine pod here; - # it is folded into ReactNativeHeaders only on the SwiftPM consumer side.) + # - 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" diff --git a/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts b/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts index 1ed2851218df..e26ca9f61e62 100644 --- a/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts +++ b/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts @@ -53,6 +53,28 @@ 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 +111,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 +142,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/scripts/cocoapods/__docs__/prebuilt-deps.md b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md index f52ed738480a..354d7592e625 100644 --- a/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md +++ b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md @@ -13,17 +13,21 @@ 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 an -explicit `HEADER_SEARCH_PATHS` entry -(`$(PODS_ROOT)/ReactNativeDependencies/Headers`). The real source pods are -neither depended on nor searched. +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. -NOTE: this is a CocoaPods-level contract. The deps XCFRAMEWORK itself is NOT -self-serving: it is framework-type without `HeadersPath`, so its root `Headers/` -is invisible to SPM binaryTargets (verified 2026-07-04 — `HeadersPath` is -rejected on framework entries). In SPM the six C++ namespaces are served by -ReactNativeHeaders.xcframework; serving them from the deps side requires the -phase-2 headers-only library sidecar (see rn-deps-self-serving plan). +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 diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 1ff33b69c135..00b9327871e4 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -513,7 +513,9 @@ def self.get_nightly_npm_version() # only declares the React-Core-prebuilt dependency; it no longer touches xcconfigs.) # # `` resolves through the vendored React.framework; this adds the search - # path to the flattened ReactNativeHeaders headers (every other namespace). There is + # 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: diff --git a/packages/react-native/scripts/cocoapods/rndependencies.rb b/packages/react-native/scripts/cocoapods/rndependencies.rb index 20445517ed69..c78b4c1db8f2 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -150,6 +150,52 @@ def self.abort_if_use_local_rndeps_with_no_file() end end + # Single post-install injection site for the prebuilt deps header resolution. + # Adds the flattened ReactNativeDependencies/Headers search path to the + # aggregate (main app) target AND every pod target, mirroring + # ReactNativeCoreUtils.configure_aggregate_xcconfig. ReactNativeHeaders is + # pure-RN, so this path is the single global home of the third-party + # namespaces (folly/glog/boost/fmt/double-conversion/fast_float/ + # SocketRocket): pods that never call add_rn_third_party_dependencies (nor + # depend on a facade) still compile RN headers that textually reach + # . No module-map activation needed — the deps headers are + # served textually; modules come from the ReactNativeDependencies pod. + def self.configure_aggregate_xcconfig(installer) + return if @@build_from_source + + rndeps_log("Configuring xcconfig for prebuilt React Native Dependencies...") + headers_search_path = " \"$(PODS_ROOT)/ReactNativeDependencies/Headers\"" + + # Add the header search path to aggregate target xcconfigs (used by the main app target) + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.xcconfigs.each do |config_name, config_file| + ReactNativePodsUtils.add_flag_to_map_with_inheritance(config_file.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig_path = aggregate_target.xcconfig_path(config_name) + config_file.save_as(xcconfig_path) + end + end + + # Add the header search path to ALL pod targets (for pods that don't go + # through add_rn_third_party_dependencies) + installer.pod_targets.each do |pod_target| + pod_target.build_settings.each do |config_name, build_settings| + xcconfig_path = pod_target.xcconfig_path(config_name) + next unless File.exist?(xcconfig_path) + + xcconfig = Xcodeproj::Config.new(xcconfig_path) + + # Skip if the deps header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("ReactNativeDependencies/Headers") + + ReactNativePodsUtils.add_flag_to_map_with_inheritance(xcconfig.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig.save_as(xcconfig_path) + end + end + + rndeps_log("Prebuilt deps xcconfig configuration complete") + end + def self.podspec_source_download_prebuild_release_tarball() # Warn if @@react_native_path is not set if @@react_native_path == "" diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js index 167b3f47ea4f..2fc6f461f768 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js @@ -11,6 +11,7 @@ 'use strict'; const { + DEPS_NAMESPACES, planFromInventory, renderNamespaceModuleMap, renderReactModuleMap, @@ -218,6 +219,22 @@ describe('R11 redirect shims for dual-identity headers', () => { }); }); +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(planFromInventory(validManifest()).depsNamespaces).toEqual( + DEPS_NAMESPACES, + ); + }); +}); + describe('headers-verify gate pieces', () => { const { diffAgainstBaseline, 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..8cc6aa4f2f02 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js @@ -0,0 +1,59 @@ +/** + * 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} = require('../headers-xcframework'); +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 + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js index 1e1eec92b19d..b4feaf118027 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-compose.js +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -11,10 +11,13 @@ /** * 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. The prebuild path (xcframework.js) - * composes before signing (R7); `ensureHeadersLayout()` applies the same - * emission to an already-cached artifact. One projector, spec-driven, - * byte-identical output either way. + * 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 { @@ -25,12 +28,17 @@ const { const {computeInventory} = require('./headers-inventory'); const { DEPS_NAMESPACES, - DEPS_NAMESPACES_NOT_RELOCATED, planFromInventory, renderNamespaceModuleMap, renderReactModuleMap, renderUmbrellaHeader, } = require('./headers-spec'); +const { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, +} = require('./headers-xcframework'); const {execSync} = require('child_process'); const fs = require('fs'); const path = require('path'); @@ -148,95 +156,29 @@ function emitReactFrameworkHeaders( ); } -/*:: -type StubSlice = { - name: string, // human label - sdk: string, // xcrun --sdk name - targets: Array, // clang -target triples (lipo'd when > 1) -}; -*/ - -const DEFAULT_STUB_SLICES /*: Array */ = [ - {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, - { - name: 'ios-simulator', - sdk: 'iphonesimulator', - targets: [ - 'arm64-apple-ios15.0-simulator', - 'x86_64-apple-ios15.0-simulator', - ], - }, -]; - -// Mac Catalyst slice — used by the real compose (the cached-artifact -// repackage path skips it to stay fast; React.xcframework carries it). -const CATALYST_STUB_SLICE /*: StubSlice */ = { - name: 'mac-catalyst', - sdk: 'macosx', - targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], -}; - /** * Builds ReactNativeHeaders.xcframework (R2, R5): a headers-only LIBRARY * xcframework (stub static archives — nothing embeds in apps) whose Headers - * root carries every non-React namespace incl. the third-party deps - * namespaces, plus module.modulemap with the plain per-namespace modules. - * SPM serves its Headers automatically to dependents — no flags. + * 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 */, - depsHeaders /*: string */, rnRoot /*: string */, includeCatalyst /*: boolean */ = false, // Optional dir containing a `hermes/` namespace (Hermes public headers from // the hermes-ios tarball's destroot/include). Folded in as a textual - // namespace like folly/glog so `` resolves without per-library - // wiring. null when unstaged — then `` stays unavailable. + // namespace so `` resolves without per-library wiring. null + // when unstaged — then `` stays unavailable. hermesHeaders /*: ?string */ = null, ) /*: string */ { // ---- stage headers ---- const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); stageEntries(stage, plan.reactNativeHeaders, rnRoot); - for (const ns of plan.depsNamespaces) { - const src = path.join(depsHeaders, ns); - // Fail closed: a declared deps namespace (folly/glog/boost/...) missing - // from the staged ReactNativeDependencies headers means the artifact would - // ship WITHOUT those ``-style headers — a silently-broken - // ReactNativeHeaders.xcframework (consumers lose third-party header - // resolution). Refuse rather than emit it. Stage - // third-party/ReactNativeDependencies.xcframework/Headers (full prebuild or - // cache slot) before composing. - if (!fs.existsSync(src)) { - throw new Error( - `headers-compose: deps namespace '${ns}' missing under ${depsHeaders}. ` + - `ReactNativeDependencies headers are not staged — refusing to ship an ` + - `incomplete ReactNativeHeaders.xcframework.`, - ); - } - execSync(`/bin/cp -Rc "${src}" "${path.join(stage, ns)}"`); - } - // Set equality with the deps artifact: a namespace dir present in the - // artifact but neither declared for relocation (DEPS_NAMESPACES) nor - // explicitly excluded (DEPS_NAMESPACES_NOT_RELOCATED — namespaces a real - // consumer pod vends itself, e.g. SocketRocket) means a new third-party dep - // was added upstream — fail closed so a decision is made deliberately. - const foundDepsDirs = fs - .readdirSync(depsHeaders, {withFileTypes: true}) - .filter(e => e.isDirectory()) - .map(e => String(e.name)); - const undeclared = foundDepsDirs.filter( - d => - !plan.depsNamespaces.includes(d) && - !DEPS_NAMESPACES_NOT_RELOCATED.includes(d), - ); - if (undeclared.length > 0) { - throw new Error( - `headers-compose: deps artifact ships undeclared namespace(s): ` + - `${undeclared.join(', ')}. Add them to DEPS_NAMESPACES (relocated) or ` + - `DEPS_NAMESPACES_NOT_RELOCATED (vended by a real pod) in headers-spec.js.`, - ); - } // 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 @@ -264,54 +206,21 @@ function buildReactNativeHeadersXcframework( renderNamespaceModuleMap(plan.namespaceModules), ); - // ---- stub static archives per slice ---- - const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); - fs.writeFileSync( - path.join(work, 'stub.c'), - '// ReactNativeHeaders is headers-only; this stub satisfies xcframework tooling.\nstatic int RNHeadersStub __attribute__((unused)) = 0;\n', - ); + // ---- compose (stub archives + create-xcframework) ---- const slices = includeCatalyst ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] : DEFAULT_STUB_SLICES; - const libs = slices.map(slice => { - const sdkPath = execSync(`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`); - execSync( - `xcrun clang -c -target ${t} -isysroot "${sdkPath}" "${path.join(work, 'stub.c')}" -o "${obj}"`, - ); - const lib = path.join(work, `stub-${slice.name}-${i}.a`); - execSync(`xcrun libtool -static -o "${lib}" "${obj}" 2>/dev/null`); - return lib; - }); - const outLib = path.join(work, `libReactNativeHeaders-${slice.name}.a`); - if (thins.length === 1) { - fs.copyFileSync(thins[0], outLib); - } else { - execSync( - `xcrun lipo -create ${thins.map(l => `"${l}"`).join(' ')} -output "${outLib}"`, - ); - } - return outLib; - }); - - // ---- compose ---- - const outXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); - fs.rmSync(outXcfw, {recursive: true, force: true}); - execSync( - `xcodebuild -create-xcframework ` + - libs.map(l => `-library "${l}" -headers "${stage}"`).join(' ') + - ` -output "${outXcfw}"`, - {stdio: 'pipe'}, + const outXcfw = composeHeadersOnlyXcframework( + outDir, + 'ReactNativeHeaders', + stage, + slices, ); fs.rmSync(stage, {recursive: true, force: true}); - fs.rmSync(work, {recursive: true, force: true}); console.log( `headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + - `(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}` + - `${hermesFolded ? ', hermes' : ''}; ` + + `(${plan.reactNativeHeaders.length} RN headers, pure-RN` + + `${hermesFolded ? ' + hermes' : ''}; ` + `${Object.keys(plan.namespaceModules).length} namespace modules)`, ); return outXcfw; @@ -321,20 +230,21 @@ function buildReactNativeHeadersXcframework( * 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, and builds ReactNativeHeaders.xcframework from - * the plan + the slot's deps headers. + * 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 - * required. + * gets composed artifacts automatically — no published ReactNativeHeaders / + * ReactNativeDependenciesHeaders required. */ function ensureHeadersLayout( artifactsDir /*: string */, rnRoot /*: string */, outDir /*: string */, force /*: boolean */ = false, -) /*: {reactXcfw: string, headersXcfw: string} */ { +) /*: {reactXcfw: string, headersXcfw: string, depsHeadersXcfw: string} */ { const sourceXcfw = fs.realpathSync( path.join(artifactsDir, 'React.xcframework'), ); @@ -353,6 +263,10 @@ function ensureHeadersLayout( : 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')); @@ -364,10 +278,11 @@ function ensureHeadersLayout( !force && fs.existsSync(reactXcfw) && fs.existsSync(headersXcfw) && + fs.existsSync(depsHeadersXcfw) && fs.existsSync(markerPath) && fs.readFileSync(markerPath, 'utf8') === marker ) { - return {reactXcfw, headersXcfw}; + return {reactXcfw, headersXcfw, depsHeadersXcfw}; } console.log( @@ -387,13 +302,20 @@ function ensureHeadersLayout( buildReactNativeHeadersXcframework( outDir, plan, - depsHeaders, rnRoot, false, hermesHeaders, ); + // The deps sidecar (like ReactNativeHeaders here) skips the catalyst slice + // on the consumer repackage path to stay fast. + buildDepsHeadersXcframework( + outDir, + depsHeaders, + plan.depsNamespaces, + DEFAULT_STUB_SLICES, + ); fs.writeFileSync(markerPath, marker); - return {reactXcfw, headersXcfw}; + return {reactXcfw, headersXcfw, depsHeadersXcfw}; } module.exports = { diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js index 980c447c5092..aac50945009c 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-inventory.js +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -31,10 +31,7 @@ const {getHeaderFilesFromPodspecs} = require('./headers'); // headers-spec.js requires only fs/path, so this cannot cycle. -const { - DEPS_NAMESPACES, - DEPS_NAMESPACES_NOT_RELOCATED, -} = require('./headers-spec'); +const {DEPS_NAMESPACES} = require('./headers-spec'); const fs = require('fs'); const path = require('path'); @@ -77,13 +74,10 @@ type HeaderEntry = { */ // 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 namespaces relocated into -// ReactNativeHeaders) — a new third-party dep is declared ONCE and both the -// include classifier and the compose step follow. -const THIRD_PARTY_LIBS /*: Set */ = new Set([ - ...DEPS_NAMESPACES, - ...DEPS_NAMESPACES_NOT_RELOCATED, -]); +// 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. diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js index 62afc12a7cb4..a804d3142787 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-spec.js +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -25,12 +25,14 @@ * 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 namespace (incl. `react/`) ships in ONE headers-only library - * xcframework ("ReactNativeHeaders"), namespace dirs at its Headers root, - * INCLUDING the third-party deps namespaces (folly/glog/boost/fmt/ - * double-conversion/fast_float, sourced from the deps artifact) — making - * ReactNativeDependencies binary-only. Served by exact header-search-path - * lookup, so resolution is filesystem-case-independent. + * 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 @@ -109,10 +111,15 @@ export type HeadersSpecPlan = { }; */ -// R2: third-party namespaces relocated from the deps artifact. Kept in exact -// sync with the artifact's Headers/ dirs — compose fails closed on a missing -// OR an undeclared namespace, and the include classifier -// (headers-inventory.js THIRD_PARTY_LIBS) derives from this same list. +// 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', @@ -120,20 +127,9 @@ const DEPS_NAMESPACES = [ 'fmt', 'double-conversion', 'fast_float', + 'SocketRocket', ]; -// Namespaces the deps artifact ships but which must NOT be relocated into -// ReactNativeHeaders: a REAL CocoaPods pod of the same name exists in consumer -// graphs and vends these headers itself (with its own module). Relocating -// textual copies puts them on every pod's HEADER_SEARCH_PATHS via the -// flattened React-Core-prebuilt/Headers and collides with the pod's own -// headers — duplicate @interface definitions compiling the SocketRocket pod, -// and a poisoned module graph in use_frameworks/explicit-modules apps -// (Expo regression, 2026-07-03). The compose set-equality guard treats these -// as declared (so only genuinely NEW namespaces fail closed), and the headers -// gate asserts they stay ABSENT from the artifact. -const DEPS_NAMESPACES_NOT_RELOCATED = ['SocketRocket']; - // R4/R5 umbrella exclusion: C extern-inline definitions. const EXTERN_INLINE_RE /*: RegExp */ = /\b(RCT_EXTERN\s+inline|extern\s+inline)\b/; @@ -469,7 +465,6 @@ function renderNamespaceModuleMap( module.exports = { planFromInventory, - DEPS_NAMESPACES_NOT_RELOCATED, renderReactModuleMap, renderUmbrellaHeader, renderNamespaceModuleMap, diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index a18143bb6262..79bebc800348 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -39,7 +39,6 @@ const {computeInventory} = require('./headers-inventory'); const { - DEPS_NAMESPACES_NOT_RELOCATED, planFromInventory, renderNamespaceModuleMap, renderReactModuleMap, @@ -227,18 +226,16 @@ function verifyStructural( `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 missing from artifact: ${ns}`); - } - } - // Excluded namespaces must stay ABSENT: relocating them collides with the - // real pod's own headers (SocketRocket / Expo use_frameworks regression). - for (const ns of DEPS_NAMESPACES_NOT_RELOCATED) { if (fs.existsSync(path.join(rnhHeaders, ns))) { throw new Error( - `excluded deps namespace '${ns}' found in the artifact — it must NOT ` + - `be relocated (a real pod vends it; textual copies collide).`, + `deps namespace '${ns}' found in ReactNativeHeaders — it must NOT be ` + + `relocated (it ships in ReactNativeDependenciesHeaders; textual ` + + `copies collide with the real pods' headers).`, ); } } @@ -319,6 +316,7 @@ function runCompileGates( plan /*: HeadersSpecPlan */, reactSlice /*: string */, rnhHeaders /*: string */, + depsHeaders /*: string */, ) /*: void */ { const sdk = execFileSync( 'xcrun', @@ -327,6 +325,10 @@ function runCompileGates( ).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', @@ -337,6 +339,8 @@ function runCompileGates( reactSlice, '-I', rnhHeaders, + '-I', + depsHeaders, ]; const objc = path.join(tmp, 'gate-modules.m'); @@ -394,6 +398,8 @@ function runCompileGates( reactSlice, '-I', rnhHeaders, + '-I', + depsHeaders, swift, ], 'Swift gate (import React + RCTBridge.moduleRegistry)', @@ -459,7 +465,23 @@ function main(argv /*:: ?: Array */) /*: void */ { if (args.skipCompile) { log('compile gates skipped (--skip-compile).'); } else { - runCompileGates(plan, reactSlice, rnhHeaders); + // 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.'); } 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..461bbc6c3949 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -0,0 +1,241 @@ +/** + * 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 {execSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: +export type StubSlice = { + name: string, // human label + sdk: string, // xcrun --sdk name + targets: Array, // clang -target triples (lipo'd when > 1) +}; +*/ + +const DEFAULT_STUB_SLICES /*: Array */ = [ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, +]; + +// Mac Catalyst slice — used by the real compose (the cached-artifact +// repackage path skips it to stay fast; React.xcframework carries it). +const CATALYST_STUB_SLICE /*: StubSlice */ = { + name: 'mac-catalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], +}; + +// SupportedPlatform(+variant) from an xcframework Info.plist -> stub recipe. +// The min OS version in the triple only shapes the stub object file; slice +// identity (what create-xcframework groups by) comes from platform + variant +// + archs. +const PLATFORM_STUB_RECIPES /*: { + [key: string]: {sdk: string, os: string, suffix: string}, +} */ = { + ios: {sdk: 'iphoneos', os: 'ios15.0', suffix: ''}, + 'ios-simulator': { + sdk: 'iphonesimulator', + os: 'ios15.0', + suffix: '-simulator', + }, + 'ios-maccatalyst': {sdk: 'macosx', os: 'ios15.0', suffix: '-macabi'}, + macos: {sdk: 'macosx', os: 'macosx11.0', suffix: ''}, + tvos: {sdk: 'appletvos', os: 'tvos15.1', suffix: ''}, + 'tvos-simulator': { + sdk: 'appletvsimulator', + os: 'tvos15.1', + suffix: '-simulator', + }, + xros: {sdk: 'xros', os: 'xros1.0', suffix: ''}, + 'xros-simulator': {sdk: 'xrsimulator', os: 'xros1.0', suffix: '-simulator'}, +}; + +/** + * Derives stub slices matching an existing (binary) xcframework's slice set, + * so a headers-only sidecar resolves for every platform the binary does. + */ +function stubSlicesFromXcframework( + xcfwPath /*: string */, +) /*: Array */ { + const plist = JSON.parse( + execSync( + `plutil -convert json -o - "${path.join(xcfwPath, 'Info.plist')}"`, + ).toString(), + ); + return plist.AvailableLibraries.map(lib => { + const key = + lib.SupportedPlatformVariant != null + ? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}` + : lib.SupportedPlatform; + const recipe = PLATFORM_STUB_RECIPES[key]; + if (recipe == null) { + throw new Error( + `headers-xcframework: no stub recipe for slice '${key}' of ` + + `${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`, + ); + } + return { + name: key, + sdk: recipe.sdk, + targets: lib.SupportedArchitectures.map( + a => `${a}-apple-${recipe.os}${recipe.suffix}`, + ), + }; + }); +} + +/** + * Composes `.xcframework` under `outDir` from an already-populated + * Headers stage dir: one stub static archive per slice, then + * `xcodebuild -create-xcframework` pairing every archive with the stage. + * The caller owns (and cleans) the stage dir. + */ +function composeHeadersOnlyXcframework( + outDir /*: string */, + name /*: string */, + stage /*: string */, + slices /*: Array */, +) /*: string */ { + const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); + 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 = execSync(`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`); + execSync( + `xcrun clang -c -target ${t} -isysroot "${sdkPath}" "${path.join(work, 'stub.c')}" -o "${obj}"`, + ); + const lib = path.join(work, `stub-${slice.name}-${i}.a`); + execSync(`xcrun libtool -static -o "${lib}" "${obj}" 2>/dev/null`); + return lib; + }); + const outLib = path.join(work, `lib${name}-${slice.name}.a`); + if (thins.length === 1) { + fs.copyFileSync(thins[0], outLib); + } else { + execSync( + `xcrun lipo -create ${thins.map(l => `"${l}"`).join(' ')} -output "${outLib}"`, + ); + } + return outLib; + }); + + const outXcfw = path.join(outDir, `${name}.xcframework`); + fs.rmSync(outXcfw, {recursive: true, force: true}); + execSync( + `xcodebuild -create-xcframework ` + + libs.map(l => `-library "${l}" -headers "${stage}"`).join(' ') + + ` -output "${outXcfw}"`, + {stdio: 'pipe'}, + ); + fs.rmSync(work, {recursive: true, force: true}); + return outXcfw; +} + +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-')); + for (const ns of namespaces) { + execSync( + `/bin/cp -Rc "${path.join(depsHeaders, ns)}" "${path.join(stage, ns)}"`, + ); + } + const outXcfw = composeHeadersOnlyXcframework( + outDir, + DEPS_HEADERS_XCFRAMEWORK_NAME, + stage, + slices, + ); + fs.rmSync(stage, {recursive: true, force: true}); + console.log( + `headers-xcframework: ${DEPS_HEADERS_XCFRAMEWORK_NAME}.xcframework ` + + `(${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(namespaces: ${namespaces.join(', ')})`, + ); + return outXcfw; +} + +module.exports = { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + DEPS_HEADERS_XCFRAMEWORK_NAME, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, + stubSlicesFromXcframework, +}; diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index 43c40d9e51bd..c58ae0fc37d8 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -92,6 +92,28 @@ async function prepareReactNativeDependenciesArtifactsAsync( stdio: 'inherit', }); + // 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. + const headersSidecarSource = path.join( + path.dirname(xcframeworkSource), + 'ReactNativeDependenciesHeaders.xcframework', + ); + if (fs.existsSync(headersSidecarSource)) { + execSync(`cp -R "${headersSidecarSource}" "${artifactsPath}"`, { + stdio: 'inherit', + }); + } else { + dependencyLog( + 'ReactNativeDependenciesHeaders.xcframework not present in the tarball ' + + '(pre-sidecar artifact) — continuing with the binary xcframework only.', + 'warning', + ); + } + // Delete the tarball after extraction if (!process.env.HERMES_ENGINE_TARBALL_PATH) { fs.unlinkSync(localPath); diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index f021edbf68cc..c7cc2fd3b347 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -86,22 +86,19 @@ function buildXCFrameworks( computeSpecPlan, emitReactFrameworkHeaders, } = require('./headers-compose'); - const depsHeaders = path.join( - rootFolder, - 'third-party', - 'ReactNativeDependencies.xcframework', - 'Headers', - ); const plan = computeSpecPlan(rootFolder); emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // ReactNativeHeaders is PURE-RN — the third-party deps namespaces ship in + // the ReactNativeDependenciesHeaders sidecar built by the deps prebuild + // (scripts/releases/ios-prebuild), so the core compose no longer needs the + // deps artifact's headers. // NOTE: Hermes public headers (``) are folded into // ReactNativeHeaders on the consumer side by ensureHeadersLayout. When this // publish path is productionized, pass the prebuild's hermes destroot/include - // as the 6th arg so the PUBLISHED ReactNativeHeaders carries hermes too. + // as the 5th arg so the PUBLISHED ReactNativeHeaders carries hermes too. const headersXcfw = buildReactNativeHeadersXcframework( path.dirname(outputPath), plan, - depsHeaders, rootFolder, true, // include the mac-catalyst slice in the real compose ); diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 4214e23b20da..3b6d3142b38e 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -621,6 +621,15 @@ def react_native_post_install( 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/scripts/releases/ios-prebuild/compose-framework.js b/scripts/releases/ios-prebuild/compose-framework.js index fd0e1287d1f3..12f026148550 100644 --- a/scripts/releases/ios-prebuild/compose-framework.js +++ b/scripts/releases/ios-prebuild/compose-framework.js @@ -8,6 +8,15 @@ * @format */ +// Shared with the core prebuild (kept dependency-light for this cross-package +// require): the headers-only xcframework recipe and the deps namespace spec. +const { + DEPS_NAMESPACES, +} = require('../../../packages/react-native/scripts/ios-prebuild/headers-spec'); +const { + buildDepsHeadersXcframework, + stubSlicesFromXcframework, +} = require('../../../packages/react-native/scripts/ios-prebuild/headers-xcframework'); const {HEADERS_FOLDER, TARGET_FOLDER} = require('./constants'); const {execSync} = require('child_process'); const fs = require('fs'); @@ -70,8 +79,23 @@ async function createFramework( // Copy headers to the framework - start by building the Header folder copyHeaders(scheme, dependencies, rootFolder); + // Emit the headers-only ReactNativeDependenciesHeaders.xcframework sidecar + // from the root Headers/ just assembled. The binary xcframework is + // FRAMEWORK-type, so its root Headers/ is invisible to SwiftPM + // (`HeadersPath` is rejected on framework entries) — the LIBRARY-type + // sidecar is what auto-serves the deps namespaces. Root Headers/ is + // slice-uniform (copied once after create-xcframework), so the sidecar + // stages it per slice, with slice parity derived from the binary artifact. + const headersXcfw = buildDepsHeadersXcframework( + rootFolder, + path.join(output, 'Headers'), + DEPS_NAMESPACES, + stubSlicesFromXcframework(output), + ); + if (identity) { signXCFramework(identity, output); + signXCFramework(identity, headersXcfw); } }