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..2b62f554daa1 100644 --- a/.github/actions/test-ios-rntester/action.yml +++ b/.github/actions/test-ios-rntester/action.yml @@ -15,9 +15,18 @@ inputs: required: false default: false use-frameworks: - description: Whether we have to build with Dynamic Frameworks. If this is set to true, it builds from source + description: Whether we have to build with Dynamic Frameworks. required: false default: false + use-prebuilds: + description: >- + Whether to consume the prebuilt ReactCore/ReactNativeDependencies + artifacts. 'auto' (default) keeps the historical coupling: prebuilds for + static, source for dynamic frameworks. Pass 'true' with + use-frameworks:true for the prebuilt + dynamic-frameworks lane (the + config of the 2026-07-03 SocketRocket dual-copy regression). + required: false + default: auto runs: using: composite @@ -41,24 +50,44 @@ runs: - name: Prepare IOS Tests if: ${{ inputs.run-unit-tests == 'true' }} uses: ./.github/actions/prepare-ios-tests + - name: Resolve prebuilds mode + id: prebuilds + shell: bash + run: | + if [[ "${{ inputs.use-prebuilds }}" == "auto" ]]; then + # Historical coupling: prebuilds for static, source for dynamic frameworks. + if [[ "${{ inputs.use-frameworks }}" == "true" ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + elif [[ "${{ inputs.use-prebuilds }}" == "true" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + elif [[ "${{ inputs.use-prebuilds }}" == "false" ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + # Don't silently treat a typo as 'disabled' — surface it. + echo "::warning::Unexpected use-prebuilds value '${{ inputs.use-prebuilds }}' (expected auto/true/false); treating as disabled." + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi - name: Download ReactNativeDependencies - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/third-party/ - name: Print third-party folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/third-party - name: Download React Native Prebuilds - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactCore${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/ReactCore - name: Print ReactCore folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/ReactCore - name: Install CocoaPods dependencies @@ -66,8 +95,8 @@ runs: run: | if [[ ${{ inputs.use-frameworks }} == "true" ]]; then export USE_FRAMEWORKS=dynamic - else - # If use-frameworks is false, let's use prebuilds + fi + if [[ "${{ steps.prebuilds.outputs.enabled }}" == "true" ]]; then export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz" export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ inputs.flavor }}.xcframework.tar.gz" fi diff --git a/.github/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..b67349037225 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,52 @@ const ARTIFACT_URL = 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/'; const ARTIFACT_NAME = 'react-native-artifacts-'; +// 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, 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 ad47de482fef..36b2e13aada2 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -133,7 +133,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/.build/output/xcframeworks - key: v2-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} - name: Setup node.js if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/setup-node @@ -157,6 +157,24 @@ jobs: pattern: prebuild-ios-core-headers-${{ matrix.flavor }}-* path: packages/react-native/.build/headers merge-multiple: true + - name: Download ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + path: /tmp/third-party/ + - name: Extract ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + shell: bash + run: | + # ReactNativeHeaders.xcframework is pure-RN (the deps namespaces ship + # in the ReactNativeDependenciesHeaders sidecar built by the deps + # prebuild), but the headers-verify compile gates still need the deps + # headers on their include path (folly/glog/... reached from RN's + # public headers), so the deps artifact is staged here too. + tar -xzf /tmp/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz -C /tmp/third-party/ + mkdir -p packages/react-native/third-party/ + mv /tmp/third-party/packages/react-native/third-party/ReactNativeDependencies.xcframework packages/react-native/third-party/ReactNativeDependencies.xcframework - name: Setup Keychain if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} uses: apple-actions/import-codesign-certs@v3 # https://github.com/marketplace/actions/import-code-signing-certificates @@ -173,16 +191,38 @@ jobs: run: | cd packages/react-native node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" + - name: Verify composed headers + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + run: | + # Generator-time header gate: include-health ratchet, structural + # byte-compare of module maps/umbrellas against the spec render, and + # compile smokes (React module + every namespace module + the + # privileged-consumer/Expo fixtures). Catches consumer-facing header + # regressions here instead of in downstream builds. + cd packages/react-native + node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" - name: Compress and Rename XCFramework if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | cd packages/react-native/.build/output/xcframeworks/${{matrix.flavor}} - tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework + # Ship BOTH xcframeworks: React-Core-prebuilt's prepare_command flattens + # ReactNativeHeaders.xcframework's Headers (incl. module.modulemap) into the + # pod. Omitting it leaves consumers without React-Core-prebuilt/Headers/module.modulemap. + tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework ReactNativeHeaders.xcframework - name: Compress and Rename dSYM if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | 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 +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 @@ -200,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 - 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: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index ab9322da3dd8..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/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 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 63eb78aeb59a..88115a1790ec 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" @@ -122,7 +121,15 @@ Pod::Spec.new do |s| s.dependency "React-hermes" end - s.resource_bundles = {'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy'} + # Both bundles in one declaration: a second `resource_bundle(s) =` would replace + # (not merge) the first. RCTI18nStrings holds React-Core's localized strings + # (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest. + # (Prebuilt/SwiftPM get both from inside React.xcframework instead — see + # scripts/ios-prebuild/framework-resources.js — but source builds ship them here.) + s.resource_bundles = { + 'RCTI18nStrings' => ['React/I18n/strings/*.lproj'], + 'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy', + } add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern') diff --git a/packages/react-native/React/I18n/RCTLocalizedString.mm b/packages/react-native/React/I18n/RCTLocalizedString.mm index 6a09613b77a0..df87ea02ea5a 100644 --- a/packages/react-native/React/I18n/RCTLocalizedString.mm +++ b/packages/react-native/React/I18n/RCTLocalizedString.mm @@ -7,8 +7,45 @@ #import "RCTLocalizedString.h" +#import + #if !defined(WITH_FBI18N) || !(WITH_FBI18N) +// Anchors resource lookups to the bundle that contains this code: React.framework +// when React Native is consumed prebuilt / via SwiftPM, or the app's main bundle +// for static source builds. +@interface RCTI18nStringsAnchor : NSObject +@end +@implementation RCTI18nStringsAnchor +@end + +// Resolves RCTI18nStrings.bundle wherever it ships: the code's own bundle first +// (prebuilt/SwiftPM embed it inside React.framework), then the app's main bundle +// (source builds copy it there via the podspec resource_bundles). Returns nil +// when absent, so the caller falls back to the untranslated default value. +static NSBundle *RCTI18nStringsBundle(void) +{ + NSBundle *codeBundle = [NSBundle bundleForClass:[RCTI18nStringsAnchor class]]; + NSURL *url = [codeBundle URLForResource:@"RCTI18nStrings" withExtension:@"bundle"]; + if (url != nil) { + return [NSBundle bundleWithURL:url]; + } + NSString *mainPath = [[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" ofType:@"bundle"]; + if (mainPath != nil) { + return [NSBundle bundleWithPath:mainPath]; + } +#if RCT_DEV + // Missing resources are otherwise silent (every lookup falls back to the + // untranslated default and the privacy manifest quietly drops out of the + // app's aggregated privacy report). Called once — the caller caches. + RCTLogWarn( + @"RCTI18nStrings.bundle not found in React.framework or the app bundle. Localized strings will use their " + @"untranslated defaults, and React's PrivacyInfo.xcprivacy may be missing from the app's privacy report. " + @"When consuming the prebuilt React.framework, verify it is embedded into the app with its resources intact."); +#endif + return nil; +} + extern "C" { static NSString *FBTStringByConvertingIntegerToBase64(uint64_t number) @@ -33,8 +70,7 @@ NSString *RCTLocalizedStringFromKey(uint64_t key, NSString *defaultValue) { - static NSBundle *bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" - ofType:@"bundle"]]; + static NSBundle *bundle = RCTI18nStringsBundle(); if (bundle == nil) { return defaultValue; } else { diff --git a/packages/react-native/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 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/fabric.rb b/packages/react-native/scripts/cocoapods/fabric.rb index b6c8264a3546..ccb10438940b 100644 --- a/packages/react-native/scripts/cocoapods/fabric.rb +++ b/packages/react-native/scripts/cocoapods/fabric.rb @@ -11,7 +11,7 @@ def setup_fabric!(react_native_path: "../node_modules/react-native") pod 'React-Fabric', :path => "#{react_native_path}/ReactCommon" pod 'React-FabricComponents', :path => "#{react_native_path}/ReactCommon" pod 'React-graphics', :path => "#{react_native_path}/ReactCommon/react/renderer/graphics" - pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true + rncore_pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true pod 'React-ImageManager', :path => "#{react_native_path}/ReactCommon/react/renderer/imagemanager/platform/ios" pod 'React-FabricImage', :path => "#{react_native_path}/ReactCommon" end diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 252588c98432..00b9327871e4 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -11,30 +11,16 @@ ### Adds ReactNativeCore-prebuilt as a dependency to the given podspec if we're not ### building ReactNativeCore from source (then this function does nothing). +### +### `` resolves through the vendored React.framework; every other namespace +### (``, ``, ``, ...) resolves through the flattened +### ReactNativeHeaders headers that React-Core-prebuilt exposes. The header search path +### and the ReactNativeHeaders module-map activation are NOT added here: they are applied +### post-install by configure_aggregate_xcconfig, which covers aggregate, third-party AND +### these pods from a single injection site. No clang VFS overlay. def add_rncore_dependency(s) if !ReactNativeCoreUtils.build_rncore_from_source() - # Add the dependency s.dependency "React-Core-prebuilt" - - current_pod_target_xcconfig = s.to_hash["pod_target_xcconfig"] || {} - current_pod_target_xcconfig = current_pod_target_xcconfig.to_h unless current_pod_target_xcconfig.is_a?(Hash) - - # Add VFS overlay flags for both Objective-C and Swift - # The VFS overlay file is pre-resolved at pod install time for each platform slice. - # We reference it directly in the xcframework using the React-VFS.yaml file that - # is written to the React-Core-prebuilt folder during setup_vfs_overlay. - # See scripts/ios-prebuild/__docs__/README.md for more details on VFS overlays. - vfs_overlay_flag = "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - current_pod_target_xcconfig["OTHER_CFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CFLAGS"] += " #{vfs_overlay_flag}" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] += " #{vfs_overlay_flag}" - # For Swift, we need to use -Xcc to pass flags to the underlying Clang compiler - # Both the flag and its argument need separate -Xcc prefixes - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] += " -Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - - s.pod_target_xcconfig = current_pod_target_xcconfig end end @@ -521,71 +507,36 @@ def self.get_nightly_npm_version() return latest_nightly end - # Processes the VFS overlay file from the React.xcframework to resolve the ${ROOT_PATH} placeholder. - # This method should be called from react_native_post_install after pod install completes. + # Single post-install injection site for the prebuilt header resolution. Adds the + # ReactNativeHeaders search path + module-map activation to the aggregate (main app) + # target AND every pod target — RN core pods, third-party pods alike. (add_rncore_dependency + # only declares the React-Core-prebuilt dependency; it no longer touches xcconfigs.) # - # The VFS overlay file maps header import paths to their actual locations within the xcframework. - # Since the xcframework contains platform-specific slices, we generate a resolved VFS file for each - # slice and also create a default VFS file that can be used immediately (before script phases run). - def self.process_vfs_overlay() - return if @@build_from_source - - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - xcframework_path = File.join(prebuilt_path, "React.xcframework") - vfs_template_path = File.join(xcframework_path, "React-VFS-template.yaml") - - unless File.exist?(vfs_template_path) - rncore_log("VFS overlay template not found at #{vfs_template_path}", :error) - exit 1 - end - - rncore_log("Processing VFS overlay file...") - - # Read the template content - vfs_template_content = File.read(vfs_template_path) - - # Write the VFS file - use the top-level xcframework path - # so that ${ROOT_PATH}/Headers points to the xcframework's Headers folder - resolved_vfs_content = vfs_template_content.gsub('${ROOT_PATH}', xcframework_path) - resolved_vfs_path = File.join(prebuilt_path, "React-VFS.yaml") - File.write(resolved_vfs_path, resolved_vfs_content) - rncore_log(" Created VFS overlay at #{resolved_vfs_path}") - - rncore_log("VFS overlay setup complete") - end - - # Configures the xcconfig files for aggregate (main app) targets to enable VFS overlay for React Native Core. - # This is needed because the main app target does not go through podspec processing, - # so it won't get the VFS overlay flags from add_rncore_dependency. + # `` resolves through the vendored React.framework; this adds the search + # path to the flattened ReactNativeHeaders headers (every other RN namespace — + # the third-party deps namespaces are served by the ReactNativeDependencies pod, + # see ReactNativeDependenciesUtils.configure_aggregate_xcconfig). There is + # no clang VFS overlay. # # Parameters: # - installer: The CocoaPods installer object def self.configure_aggregate_xcconfig(installer) return if @@build_from_source - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - vfs_overlay_path = File.join(prebuilt_path, "React-VFS.yaml") - - unless File.exist?(vfs_overlay_path) - rncore_log("VFS overlay not found at #{vfs_overlay_path}, skipping prebuilt xcconfig configuration", :error) - exit 1 - end - rncore_log("Configuring xcconfig for prebuilt React Native Core...") - vfs_overlay_flag = " -ivfsoverlay \"#{vfs_overlay_path}\"" - swift_vfs_overlay_flag = " -Xcc -ivfsoverlay -Xcc \"#{vfs_overlay_path}\"" + headers_search_path = " \"$(PODS_ROOT)/React-Core-prebuilt/Headers\"" - # Add flags to aggregate target xcconfigs (these are used by the main app target) + # Add the header search path to aggregate target xcconfigs (used by the main app target) installer.aggregate_targets.each do |aggregate_target| aggregate_target.xcconfigs.each do |config_name, config_file| - add_vfs_overlay_flags(config_file.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(config_file.attributes, headers_search_path) xcconfig_path = aggregate_target.xcconfig_path(config_name) config_file.save_as(xcconfig_path) end end - # Add flags to ALL pod targets (for third-party pods that don't call add_rncore_dependency) + # Add the header search path to ALL pod targets (for third-party pods that don't call add_rncore_dependency) installer.pod_targets.each do |pod_target| pod_target.build_settings.each do |config_name, build_settings| xcconfig_path = pod_target.xcconfig_path(config_name) @@ -593,11 +544,11 @@ def self.configure_aggregate_xcconfig(installer) xcconfig = Xcodeproj::Config.new(xcconfig_path) - # Check if VFS overlay is already present - other_cflags = xcconfig.attributes["OTHER_CFLAGS"] || "" - next if other_cflags.include?("ivfsoverlay") + # Skip if the prebuilt header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("React-Core-prebuilt/Headers") - add_vfs_overlay_flags(xcconfig.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(xcconfig.attributes, headers_search_path) xcconfig.save_as(xcconfig_path) end end @@ -605,12 +556,18 @@ def self.configure_aggregate_xcconfig(installer) rncore_log("Prebuilt xcconfig configuration complete") end - # Helper method to add VFS overlay flags to an xcconfig attributes map - def self.add_vfs_overlay_flags(attributes, vfs_overlay_flag, swift_vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", swift_vfs_overlay_flag) + # Helper method to add the prebuilt ReactNativeHeaders header search path to an xcconfig attributes map + def self.add_prebuilt_header_search_paths(attributes, headers_search_path) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "HEADER_SEARCH_PATHS", headers_search_path) # Suppress incomplete umbrella warnings for the prebuilt frameworks (it is expected, as our umbrella headers do not include all headers) ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc -Wno-incomplete-umbrella") + # Activate the ReactNativeHeaders module map so the relocated namespaces + # (`yoga`, `RCTDeprecation`, `ReactNativeHeaders_react`, ...) are modular — + # otherwise the React framework's clang explicit-module precompile trips + # -Wnon-modular-include-in-framework-module on `` / ``. + # Quoted so a $(PODS_ROOT) containing spaces stays a single clang argument. + module_map_flag = " \"-fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap\"" + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", module_map_flag) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc" + module_map_flag) end end diff --git a/packages/react-native/scripts/cocoapods/rncore_facades.rb b/packages/react-native/scripts/cocoapods/rncore_facades.rb new file mode 100644 index 000000000000..2016cff9c1e0 --- /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, ios_version) + @@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" => { "ios" => ios_version }, + # 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..1309cbc28600 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -47,10 +47,24 @@ def add_rn_third_party_dependencies(s) header_search_paths << "$(PODS_ROOT)/SocketRocket" header_search_paths << "$(PODS_ROOT)/RCT-Folly" - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths + # uniq so a second call on the same spec can't duplicate entries. + current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq else + # Prebuilt-deps mode: this pod SELF-SERVES the third-party headers from its + # own xcframework (incl. SocketRocket - sole supplier in this mode). See + # scripts/cocoapods/__docs__/prebuilt-deps.md for the full contract. s.dependency "ReactNativeDependencies" - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] ||= [] << "$(PODS_ROOT)/ReactNativeDependencies" + + header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] + if header_search_paths.is_a?(String) + header_search_paths = header_search_paths.split(" ") + end + # Artifact headers are flattened into the pod-local Headers/ by the podspec + # prepare_command (see __docs__/prebuilt-deps.md). + header_search_paths << "$(PODS_ROOT)/ReactNativeDependencies/Headers" + + # uniq so a second call on the same spec can't duplicate entries. + current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq end s.pod_target_xcconfig = current_pod_target_xcconfig @@ -139,6 +153,52 @@ def self.abort_if_use_local_rndeps_with_no_file() end end + # Single post-install injection site for the prebuilt deps header resolution. + # Adds the flattened ReactNativeDependencies/Headers search path to the + # aggregate (main app) target AND every pod target, mirroring + # ReactNativeCoreUtils.configure_aggregate_xcconfig. ReactNativeHeaders is + # pure-RN, so this path is the single global home of the third-party + # namespaces (folly/glog/boost/fmt/double-conversion/fast_float/ + # SocketRocket): pods that never call add_rn_third_party_dependencies (nor + # depend on a facade) still compile RN headers that textually reach + # . No module-map activation needed — the deps headers are + # served textually; modules come from the ReactNativeDependencies pod. + def self.configure_aggregate_xcconfig(installer) + return if @@build_from_source + + rndeps_log("Configuring xcconfig for prebuilt React Native Dependencies...") + headers_search_path = " \"$(PODS_ROOT)/ReactNativeDependencies/Headers\"" + + # Add the header search path to aggregate target xcconfigs (used by the main app target) + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.xcconfigs.each do |config_name, config_file| + ReactNativePodsUtils.add_flag_to_map_with_inheritance(config_file.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig_path = aggregate_target.xcconfig_path(config_name) + config_file.save_as(xcconfig_path) + end + end + + # Add the header search path to ALL pod targets (for pods that don't go + # through add_rn_third_party_dependencies) + installer.pod_targets.each do |pod_target| + pod_target.build_settings.each do |config_name, build_settings| + xcconfig_path = pod_target.xcconfig_path(config_name) + next unless File.exist?(xcconfig_path) + + xcconfig = Xcodeproj::Config.new(xcconfig_path) + + # Skip if the deps header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("ReactNativeDependencies/Headers") + + ReactNativePodsUtils.add_flag_to_map_with_inheritance(xcconfig.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig.save_as(xcconfig_path) + end + end + + rndeps_log("Prebuilt deps xcconfig configuration complete") + end + def self.podspec_source_download_prebuild_release_tarball() # Warn if @@react_native_path is not set if @@react_native_path == "" diff --git a/packages/react-native/scripts/cocoapods/rndeps_facades.rb b/packages/react-native/scripts/cocoapods/rndeps_facades.rb new file mode 100644 index 000000000000..09d30a2d71f0 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/rndeps_facades.rb @@ -0,0 +1,196 @@ +# 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") + + @@install_root = nil + + # 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, ios_version) + @@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| + dir = File.join(abs_base, name) + FileUtils.mkdir_p(dir) + + if podspec_rel_path == :synthesized + spec = synthesized_spec(name, ios_version) + 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, ios_version) + 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, ios_version) + { + "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" => { "ios" => ios_version }, + # 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, ios_version) + spec = base_spec(name, real.version.to_s, ios_version) + + 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, ios_version) + version = synthesized_version(name) + base_spec(name, version, ios_version) + 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/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 4d2786314714..c411934c1742 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -111,123 +111,48 @@ The build process uses specific `xcodebuild` flags: - Build times vary depending on the target platform and configuration - XCFrameworks support multiple architectures in a single bundle -## Known Issues - -The generated XCFrameworks currently use CocoaPods-style header structures -rather than standard framework header conventions. This may cause modularity -issues when: - -- Consuming the XCFrameworks in projects that expect standard framework headers -- Building dependent frameworks that rely on proper module boundaries -- Integrating with Swift Package Manager projects expecting modular headers - -## VFS Overlay System - -The prebuilt XCFrameworks use Clang's Virtual File System (VFS) overlay -mechanism to enable header imports without modifying the actual header file -structure. This is necessary because React Native's headers are organized -differently than standard framework conventions. - -### Overview - -The VFS overlay creates a virtual mapping between the import paths used in code -(e.g., `#import `) and the actual physical -locations of headers within the XCFramework. This allows the prebuilt frameworks -to work seamlessly while maintaining the original import syntax. - -### Build-Time VFS Generation (`vfs.js`) - -The `vfs.js` script creates a VFS overlay template during the prebuild process: - -1. **Header Collection** (`headers.js`): Scans all podspec files in the React - Native package to discover header files and their target import paths. - -2. **VFS Structure Building**: The `buildVFSStructure()` function creates a - hierarchical directory tree representation from the header mappings. Clang's - VFS overlay requires directories to contain their children in a tree - structure. - -3. **YAML Generation**: The `generateVFSOverlayYAML()` function converts the VFS - structure into Clang's expected YAML format. - -4. **Template Creation**: The generated overlay uses `${ROOT_PATH}` as a - placeholder for the actual installation path. This template is included in - the XCFramework as `React-VFS-template.yaml`. - -#### Key Functions - -- `createVFSOverlay(rootFolder)`: Main entry point that generates the complete - VFS overlay YAML string -- `createVFSOverlayContents(rootFolder)`: Creates the VFS overlay object - structure -- `buildVFSStructure(mappings)`: Builds the hierarchical directory tree from - flat mappings -- `resolveVFSOverlay(vfsTemplate, rootPath)`: Replaces `${ROOT_PATH}` with the - actual path - -### Runtime VFS Processing (CocoaPods) - -When consuming prebuilt frameworks via CocoaPods, the VFS overlay is processed -at pod install time by `rncore.rb`: - -#### `process_vfs_overlay()` - -Called during `react_native_post_install`, this method: - -1. Reads the `React-VFS-template.yaml` from the XCFramework -2. Resolves the `${ROOT_PATH}` placeholder with the actual XCFramework path -3. Writes the resolved overlay to - `$(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml` - -#### `add_rncore_dependency(s)` - -Adds VFS overlay compiler flags to podspecs that depend on React Native: - -```ruby -# For C/C++ compilation -OTHER_CFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -OTHER_CPLUSPLUSFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - -# For Swift compilation (flags passed to underlying Clang) -OTHER_SWIFT_FLAGS += "-Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -``` - -#### `configure_aggregate_xcconfig(installer)` - -Configures VFS overlay flags for: - -- **Aggregate targets**: Main app targets that don't go through podspec - processing -- **All pod targets**: Third-party pods that don't explicitly call - `add_rncore_dependency` - -This ensures all compilation units in the project can resolve React Native -headers through the VFS overlay. - -### VFS Overlay Format - -The VFS overlay uses Clang's hierarchical YAML format: - -```yaml -version: 0 -case-sensitive: false -roots: - - name: '${ROOT_PATH}/Headers' - type: 'directory' - contents: - - name: 'react' - type: 'directory' - contents: - - name: 'renderer' - type: 'directory' - contents: - - name: 'Size.h' - type: 'file' - external-contents: '${ROOT_PATH}/Headers/React/react/renderer/Size.h' -``` - -The structure maps virtual paths (what the compiler sees) to physical paths -(where the files actually exist in the XCFramework). +## Header Resolution (headers-spec layout) + +The prebuilt XCFrameworks ship a **headers-spec layout** so that header imports +resolve through plain header/framework search paths — there is **no clang VFS +overlay**. The layout contract is defined and validated in code: + +- `headers-spec.js`: the executable layout contract (rules R1–R8) — which + namespaces are hoisted, which carry module maps, and how collisions are + rejected. +- `headers-inventory.js`: scans the source tree to build the live header + inventory that feeds the spec. +- `headers-compose.js`: emits the layout. `emitReactFrameworkHeaders()` writes + the `React/` and bare-aliased headers into every slice's + `React.framework/Headers`, and `buildReactNativeHeadersXcframework()` + assembles the headers-only `ReactNativeHeaders.xcframework` carrying every + other namespace (incl. `react/`) plus the third-party dependency namespaces + (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, `fast_float`). The + Hermes public headers (``) are folded in only on the SwiftPM + consumer side (`ensureHeadersLayout`); the published prebuild artifact does + not yet carry them (TODO in `xcframework.js`). + +### Artifacts + +The prebuild (`xcframework.js`) always produces: + +- `React.xcframework` — the compiled React core. Each slice's `React.framework` + carries the headers-spec layout (every `` header + the framework + module map), which is what both CocoaPods and SwiftPM consume. +- `ReactNativeHeaders.xcframework` — headers-only; carries every other + namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the + `React-Core-prebuilt` pod (headers flattened onto the header search path). + +### CocoaPods consumption + +The `React-Core-prebuilt` pod vends `React.xcframework` (so `` and +`@import React;` resolve through the framework module via +`FRAMEWORK_SEARCH_PATHS`) and flattens `ReactNativeHeaders.xcframework`'s +headers into a top-level `Headers/` exposed on the pod header search path (so +``, ``, `` resolve). `rncore.rb` adds the +`HEADER_SEARCH_PATHS` entry to `React-Core-prebuilt/Headers` for podspec, +aggregate (main app), and third-party pod targets. No `-ivfsoverlay` flags are +added. ## Integrating in your project with Cocoapods diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md new file mode 100644 index 000000000000..3c4de65e246a --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md @@ -0,0 +1,388 @@ +# How the prebuilt header layout is created — rules and rationale + +This documents the header-generation system introduced by the VFS-overlay +removal PR (#57285): how every header that ships in the prebuilt artifacts is +discovered, classified, placed, and made modular. The system lives in three +scripts under `scripts/ios-prebuild/`: + +| Script | Role | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `headers-inventory.js` | **Discover + classify** every shipped header (the facts) | +| `headers-spec.js` | **The rules** (R1–R11) — turns the inventory into a layout plan + module maps | +| `headers-compose.js` | **Emit** — projects the plan into `React.xcframework` and `ReactNativeHeaders.xcframework` | +| `headers-verify.js` | **Gate** — generator-time verification: include-health ratchet, structural byte-compare, consumer-shaped compile smokes (runs in the prebuild CI compose job) | + +One principle drives the whole design: **content authority = the source files, +layout authority = the spec.** No header is ever edited; only _where it goes_ +and _which module owns it_ is computed. + +## Why this exists (what replaced the VFS overlay) + +Previously a clang VFS overlay synthesized a virtual header tree at build time +so includes like ``, ``, `` +would resolve against the prebuilt artifact. That approach broke down under +clang **explicit modules** (the overlay shadowed the framework's own module), +made code-signature stability awkward, and required consumer-side machinery. + +The replacement is purely physical: two artifacts whose on-disk layout makes +every include form resolve through standard mechanisms — +`FRAMEWORK_SEARCH_PATHS` for `` and a plain header search path for +everything else. No overlay, no include rewriting, no consumer flags. + +## The pipeline + +```text +podspecs ──► headers-inventory.js ──► inventory (facts per header) + │ + ▼ + headers-spec.js (rules R1–R10) + │ plan: what goes where + module maps + ▼ + headers-compose.js (emission) + │ │ + ▼ ▼ + React.xcframework ReactNativeHeaders.xcframework + (binary + React/ ns) (headers-only, all other ns) +``` + +## Stage 1 — Inventory: discover and classify (headers-inventory.js) + +### Discovery + +Headers are enumerated through the **same podspec-driven discovery the prebuild +itself uses** (`headers.js`), so the inventory cannot drift from the shipped +set. Each header gets: + +- a **natural path** — the include path consumers actually write + (`React/RCTBridge.h`, `react/renderer/core/ShadowNode.h`, `yoga/Yoga.h`). This + is the canonical identity; the podspec `header_dir` supplies the namespace, + and header_dir-less pods get their pod name as prefix. +- one or more **identities** — the physical source file plus the pod-namespaced + path. `React_RCTAppDelegate` headers get a **second, synthetic bare-root + identity** (`RCTAppDelegate.h` with no prefix) because app templates + historically wrote `#import `. + +### Language classification + +`scanHeader()` does a guard-aware scan of each header's text: + +- Tracks a `#if/#ifdef __cplusplus` **stack**, so C++ constructs and includes + that only exist under a C++ guard don't taint the ObjC surface + (`cxxGuarded: true` edges). +- ObjC markers: `@interface/@protocol/@implementation/@class/@end`, + `NS_ASSUME_NONNULL_BEGIN`. +- C++ markers: `namespace`, `template <`, `extern "C++"`, `enum class`, + `constexpr`, `using namespace/alias` — plus one non-obvious heuristic: a **C++ + default member initializer inside a struct/class aggregate** + (`struct X { CGFloat size = NAN; };`), which is illegal in C/ObjC but has no + keyword the line scan would catch. + +Result: `lang ∈ {objc, objcxx, cxx, c}`. + +### Include classification + +Every `#include/#import` is resolved and classified: `internal` (another shipped +header — quoted includes are resolved against the source dir and mapped back to +a natural path), `thirdParty` (folly/glog/boost/fmt/ +double-conversion/fast_float), `hermes`, `system` (Apple SDK), `std`, +`metaInternal` (FB-internal, never resolvable in OSS), `otherPlatform` +(android/jni), `notShipped` (our namespace but not in the shipped set — a +flattening mismatch), `unresolved`. + +### The bucket — "can this header live in a clang module?" + +The key output. A **fixpoint over unguarded include edges** propagates two facts +through the internal include graph: _does this header transitively reach C++?_ +and _which third-party libs does it reach?_ — considering only edges an ObjC +consumer would actually follow (cxx-guarded edges are skipped). + +| Bucket | Meaning | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `objc-modular-candidate` | Pure ObjC surface; reaches no C++ and no third-party lib unguarded → **can** be a module member | +| `objc-blocked` | ObjC header, but transitively reaches C++/third-party (e.g. Fabric headers importing ``) → cannot be a clean module member | +| `objcxx` | Mixed ObjC++ in the header itself | +| `cxx` | Pure C++ (also anything `.hpp`) | + +Only `objc-modular-candidate` headers can enter module maps; everything else +stays **textual** (resolved by search path at the consumer's use site — exactly +the semantics the old VFS overlay provided). + +## Stage 2 — The rules (headers-spec.js) + +The spec's docblock is the contract; each rule exists because of a concrete +failure mode: + +**R1 — `React.framework/Headers` root = the `React/` namespace, hoisted.** The +framework _name_ supplies the `React/` prefix, so `#import ` +resolves verbatim through `FRAMEWORK_SEARCH_PATHS`. Bare root aliases (R6) also +live here. The lowercase `react/` namespace is deliberately **not** here: +resolving `` through `React.framework` requires case-folding +`react.framework` → `React.framework`, which only works on case-insensitive +filesystems. The header-search-path route (R2) is exact everywhere. + +**R2 — every other namespace ships in ONE headers-only xcframework, +`ReactNativeHeaders`.** Namespace dirs at its `Headers/` root: `react/`, +`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … **including the +third-party deps namespaces** (folly/glog/boost/fmt/double-conversion/ +fast_float, copied out of the ReactNativeDependencies artifact — which thereby +becomes binary-only). SPM/Xcode auto-serve a binaryTarget's `Headers/` on the +consumer's search path, so everything resolves with zero flags. + +**R3 — NO include rewriting, anywhere.** Shipped headers are byte-identical to +the repo. If a header's includes don't work in the packaged layout, the fix is +in the _source header_ or the _rules_ — never a packaging-time patch. This is +what keeps source builds and prebuilt builds semantically identical. + +**R4 — the React framework module map is an umbrella over the _safe_ ObjC +surface.** A header enters the umbrella iff `isUmbrellaSafe`: + +```text +bucket == objc-modular-candidate +∧ React/-namespace +∧ no '+' in the filename (category headers, e.g. +Private — see R9) +∧ no C extern-inline definition +``` + +The extern-inline exclusion is empirical: a C99 `extern inline` definition emits +a **strong symbol in every importing translation unit** → duplicate symbols at +link (found via `RCTTextInputNativeCommands.h`). In practice this yields ~225 +umbrella headers out of ~317 in the framework. + +**R5 — every ReactNativeHeaders namespace with modular candidates gets a plain +(non-framework) module** declaring exactly those candidates. Why: a framework +module (React) may not _textually_ include non-modular headers under +`-Wnon-modular-include-in-framework-module` — so when a React.framework header +imports ``, yoga's header must itself belong to a module (found +empirically via yoga + RCTDeprecation). Details: + +- Module _names_ are internal to clang's module graph — consumers never + `@import yoga`; they `#import ` and clang maps the header to its + owning module. +- The `react` namespace's module is renamed `ReactNativeHeaders_react` so it can + never alias the `React` framework module on a case-insensitive filesystem. + (Header paths are unchanged; only the module name differs.) +- Namespaces whose name isn't a valid module identifier (`jsinspector-modern`, + `double-conversion`) are exempt — they currently have no modular candidates; + the verifier asserts that stays true. + +**R6 — bare includes migrate to ``.** Bare root aliases +(`RCTAppDelegate.h` etc.) are physically placed at the framework Headers root, +so `` works; the bare angle form +(`#import `) has no framework spelling and is the one +accepted, measured ecosystem migration (~4 lines total). + +**R7 — sign AFTER composing.** The code signature pins the header manifest; +composing after signing would invalidate it. + +**R8 — collisions are hard errors.** Two different source files may never +project to the same destination path. `computeSpecPlan` throws; the artifact is +not produced. + +**R9 — private React headers, exposed in the default `React` module via a +curated allowlist.** Privileged framework consumers (Expo) import headers the +public umbrella excludes. Rather than a `React.Private` submodule (which would +force a Swift `import React.Private` in consumers) they are appended to the +module map, split by bucket: + +```text +framework module React { + umbrella header "React-umbrella.h" + header "RCTBridge+Private.h" // objc-modular-candidate → real member + textual header "RCTComponentViewFactory.h" // objc-blocked → textual only + textual header "RCTMountingManager.h" // (…6 Fabric headers total) + ... + export * + module * { export * } +} +``` + +An `objc-blocked` header **must** be `textual` — a real member would re-trip the +non-modular-include error that got it excluded from the umbrella in the first +place. Textual works because its C++ includes resolve at the consumer's use +site. "Private" is by convention (naming), not enforcement: a single binary +artifact cannot hard-gate an app from headers a framework legitimately needs. +Validation **fails closed**: an allowlisted header missing from the inventory, +or a `modular` entry whose bucket drifted, aborts the build with a targeted +message. + +**R10 — per-namespace umbrella headers, derived, for namespaces consumers +probe.** Expo's `RCTAppDelegateUmbrella.h` does +`__has_include()` — a +CocoaPods-era artifact filename. The flattened layout ships the individual +headers but no umbrella, so the probe silently failed. The fix emits +`/-umbrella.h` for each namespace in `UMBRELLA_NAMESPACES` (currently +just `React_RCTAppDelegate`), with content **derived from the namespace's +modular header set** — never hand-listed, so it can't drift (it correctly omits +e.g. the since-removed `RCTArchConfiguratorProtocol.h`). The umbrella is also +added to that namespace's R5 module so importing it stays modular. Fails closed +if the namespace loses all modular headers. + +**R11 — one source file, one content location (redirect shims).** Some sources +ship under several spellings: `React/X.h` plus a legacy pod-namespace form +(`CoreModules/X.h`, `RCTAnimation/X.h`, `RCTImage/X.h`, …), or a bare root alias +plus `React_RCTAppDelegate/X.h` — 116 sources at the time of writing. Under the +VFS overlay every spelling mapped to _one physical file_, so `#import`-once and +module ownership were coherent. A flattened layout that _copies_ the content +breaks both: any `-fmodules` consumer touching two spellings — even +transitively, e.g. importing legacy `` whose header pulls a +modular `` — hits **redefinition errors** (found by the headers gate +on its first run). The rule: the **module-owned spelling keeps the content** +(the `React/` form when it exists — umbrella/module-React owner or canonical +textual home; else the R5-module namespaced form), and every other spelling is +emitted as a one-line redirect shim (`#import `). Shims that are +namespace-module members are fine: they import the owning module, so +declarations stay single-owned. + +## Stage 3 — Emission (headers-compose.js) + +`computeSpecPlan(rnRoot)` = inventory → plan, throwing on R8 collisions. Then: + +### `emitReactFrameworkHeaders` (per slice of React.xcframework) + +1. Stage all R1 entries (byte-copies of the source files) + the generated + `React-umbrella.h`. +2. For each slice (`ios-arm64`, `ios-arm64_x86_64-simulator`, …): replace + `React.framework/Headers` with the stage, replace `Modules/` with the + generated module map (R4 + R9). +3. (Stacked PR #57305 adds resource embedding here — `PrivacyInfo.xcprivacy` and + `RCTI18nStrings.bundle` per slice — orthogonal to headers.) + +### `buildReactNativeHeadersXcframework` + +1. Stage all R2 entries, then copy the six deps namespaces from + `third-party/ReactNativeDependencies.xcframework/Headers`. A declared deps + namespace that is missing is a **hard error** — previously a warn-and-ship, + which once produced a silently deps-less artifact (1.6 MB instead of 11 MB). +2. Optionally fold in the `hermes/` public headers (consumer-side compose path). +3. Write the R10 umbrella files, then the R5 module map. +4. Compile a stub static archive per slice (headers-only artifacts still need a + library for `xcodebuild -create-xcframework`) and compose the xcframework. + +### `ensureHeadersLayout` (consumer-side) + +Applies the same emission to an already-downloaded cache slot (so any consumer +with a cached `React.xcframework` gets composed artifacts without a published +`ReactNativeHeaders`). Idempotent via a freshness marker (source realpath + +mtime + hermes presence). + +## How consumers resolve headers (why the rules are shaped this way) + +| Include form | Resolved by | Rule | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ------ | +| `#import ` | `React.framework/Headers` via FRAMEWORK_SEARCH_PATHS; modular via the umbrella | R1, R4 | +| `#import ` | same, modular via the R9 `header` entry | R9 | +| `#import ` | same, **textual** via the R9 entry | R9 | +| `#import ` (C++) | `ReactNativeHeaders/Headers` search path, textual | R2 | +| `#import ` | same, **modular** via the yoga R5 module | R2, R5 | +| `#import ` | same (deps namespaces relocated here) | R2 | +| `` | same, modular | R10 | +| `#import "RCTFabricComponentsPlugins.h"` (quoted, community Fabric pods) | CocoaPods only: re-vended by the `React-RCTFabric` facade into the pod header map (`rncore_facades.rb`, `FACADE_REEXPOSED_HEADERS`) | — | + +- **SwiftPM**: both xcframeworks are plain `.binaryTarget`s; Xcode auto-serves + `React.framework`'s `Headers/`+`Modules/` and `ReactNativeHeaders`' `Headers/` + (incl. its `module.modulemap`) to dependents. Zero flags. +- **CocoaPods**: `React-Core-prebuilt`'s `prepare_command` flattens + `ReactNativeHeaders`' Headers (incl. the module map) into the pod, and the + React-core pods are installed as dependency-only **facades** + (`rncore_facades.rb`) so no source headers shadow the artifact. + +## Fail-closed invariants (the "can't silently regress" list) + +| Guard | Trips when | Where | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| R8 collision check | two sources project to one destination | `computeSpecPlan` | +| R9 allowlist validation | private header removed/renamed, or bucket drifted | `validatePrivateReactHeaders` | +| R10 umbrella check | a probed namespace loses all modular headers | `planFromInventory` | +| R5 exemption assert | an invalid-module-identifier namespace gains a modular-candidate header | `planFromInventory` | +| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildReactNativeHeadersXcframework` | +| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildReactNativeHeadersXcframework` | +| Include-health ratchet | a shipped header gains a `notShipped`/`unresolved`/quoted-unresolvable include not in the committed baseline | `headers-verify.js` | +| Structural gate | composed module maps/umbrellas differ from the spec render; R9 headers or deps dirs absent | `headers-verify.js` | +| Compile gates | the React module, any R5 namespace module, the R10 umbrella, the R9 textual (Expo-shape) surface, or Swift `RCTBridge.moduleRegistry` fails to compile | `headers-verify.js` (CI: prebuild compose job) | +| Facade re-vend glob | `RCTFabricComponentsPlugins.h` glob matches nothing | `rncore_facades.rb` | + +`DEPS_NAMESPACES` (headers-spec.js) is the single source of truth for +third-party namespaces — the inventory's include classifier derives from it, and +compose enforces set-equality with the deps artifact in both directions. + +The unit tests (`__tests__/headers-spec-test.js`) exercise R9/R10/R11, the R5 +exemption assert, the ratchet diff, and the gate fixtures red/green. + +## Resilience against new headers (drift analysis) + +The system is **derived-by-construction for the common case** — a new ordinary +header requires zero maintenance — but its consumer-facing contracts are +**allowlist-maintained** and only fail downstream. Grouped by _when_ a change +surfaces (daily CI = prebuild compose + rn-tester prebuilt consumer lanes): + +### A. Auto-adapts — nothing to do + +- **New header in an existing pod**: podspec-glob discovery → inventory → placed + by R1/R2; joins the umbrella (R4) or its namespace module (R5) automatically + iff `isUmbrellaSafe`. +- **New pod/namespace** (valid module identifier): gets an R5 module + automatically. +- **New header in `React_RCTAppDelegate`**: joins the derived R10 umbrella. +- **New `+Category` / extern-inline / objc-blocked header**: auto-excluded from + the modular surface — the _safe_ default for app consumers. + +### B. Fails loudly at compose time (the fail-closed guards) + +R8 collisions, R9 allowlist drift (rename/removal, modular→blocked), R10 +total-loss, missing deps namespaces, facade re-vend glob. These protect the +spec's _own_ invariants and cannot silently regress. + +### C. Fails loudly at the GENERATOR — the headers gate (`headers-verify.js`) + +These classes previously failed late (consumer CI lane) or not at all; the gate +— run in the prebuild compose job — moved them to compose time: + +- **A new umbrella member that doesn't compile standalone** (its include chain + hits a `notShipped` header, or the language scanner misbucketed a C++-only + header as C): the gate's ObjC TU precompiles the `React` module — compiling + _every_ umbrella header — at generator time. +- **A broken R5 namespace module**: the gate imports one header from every + namespace module, precompiling each (previously lazy — a module rn-tester + never imported was latent). +- **A public header gaining a quoted `#import "Private.h"` of a non-shipped + header**: recorded by the inventory (`quotedNotShipped`) and caught by the + include-health ratchet against `headers-include-baseline.json` — no compile + coverage needed. +- **The privileged-consumer (Expo) contract**: the gate compiles an Expo-shaped + ObjC++ TU (every R9 textual Fabric header) and a Swift TU (`import React` + + `RCTBridge.moduleRegistry`), plus `__has_include` asserts for the R9/R10 + surfaces — a tested invariant instead of a downstream discovery. + +Proof the gate earns its keep — its FIRST run found two real shipping defects: +the dual-identity redefinitions that became R11, and an undeclared +`SocketRocket` namespace in the deps artifact (caught by the set-equality guard +the moment it was added). SocketRocket now lives in `DEPS_NAMESPACES` alongside +the other third-party deps namespaces, with a single physical home in the +ReactNativeDependenciesHeaders sidecar: relocating a second textual copy into +ReactNativeHeaders collided with the real pod's own headers under +`use_frameworks` (the duplicate-`@interface` / poisoned-module-graph Expo +regression, 2026-07-03), so the set-equality gate asserts the declared namespace +set — `DEPS_NAMESPACES` — matches the deps artifact's namespaces exactly, in +both directions. + +### D. Remaining silent gaps — allowlist maintenance (by design) + +The contracts describing what _external_ consumers need are still curated lists; +a new consumer need shows up downstream first, then becomes a one-line allowlist +addition (now protected by the gate once added): + +1. **New privileged-consumer headers**: a new `+Private.h` or Fabric-class + objc-blocked header an Expo-class consumer starts importing — + `PRIVATE_REACT_HEADERS` is manual by design. +2. **New umbrella-probe namespaces**: a consumer probing `` + for a namespace not in `UMBRELLA_NAMESPACES`. +3. **Partial R10 shrink**: a `React_RCTAppDelegate` header flipping + modular→blocked drops out of the derived umbrella (fail-closed triggers only + on _total_ loss). Mitigated: the Expo probe surface is compile-tested, so a + shrink that breaks the umbrella itself is caught. + +### Remaining recommendation (not yet implemented) + +- **Artifact snapshot metrics** (per-namespace header counts, size bounds) as a + coarse tripwire for large silent drops beyond what the structural gate + byte-compares. diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js new file mode 100644 index 000000000000..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..abda751ccd28 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const { + COMPOSE_TOOLING_FILES, + composeToolingHash, +} = require('../headers-compose'); +const fs = require('fs'); +const path = require('path'); + +describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', () => { + test('every local sibling require is covered by the hashed file list', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', 'headers-compose.js'), + 'utf8', + ); + const requireRe = /require\('\.\/([\w-]+)'\)/g; + const required = new Set(); + for (const match of source.matchAll(requireRe)) { + required.add(`${match[1]}.js`); + } + for (const name of required) { + expect(COMPOSE_TOOLING_FILES).toContain(name); + } + }); +}); + +describe('composeToolingHash', () => { + test('returns a 64-char hex sha256 digest', () => { + const hash = composeToolingHash(); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); +}); 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..8f6b53a16cb7 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-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. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {scanHeader} = require('../headers-inventory'); + +describe('scanHeader include classification', () => { + test('an unguarded include is not cxx-guarded', () => { + const r = scanHeader('#import \n'); + expect(r.includes).toEqual([ + {token: 'React/RCTBridge.h', cxxGuarded: false}, + ]); + }); + + test('an include under #ifdef __cplusplus is cxx-guarded', () => { + const r = scanHeader( + '#ifdef __cplusplus\n#include \n#endif\n', + ); + expect(r.includes).toEqual([{token: 'folly/dynamic.h', cxxGuarded: true}]); + }); + + test('#else flips the __cplusplus guard', () => { + const src = [ + '#ifdef __cplusplus', + '#include ', + '#else', + '#include ', + '#endif', + '', + ].join('\n'); + expect(scanHeader(src).includes).toEqual([ + {token: 'cpp/only.h', cxxGuarded: true}, + {token: 'c/only.h', cxxGuarded: false}, + ]); + }); + + test('#elif __cplusplus enters a cxx-only region', () => { + const src = [ + '#if SOMETHING', + '#include ', + '#elif __cplusplus', + '#include ', + '#endif', + '', + ].join('\n'); + expect(scanHeader(src).includes).toEqual([ + {token: 'a.h', cxxGuarded: false}, + {token: 'b.h', cxxGuarded: true}, + ]); + }); +}); + +describe('scanHeader C++ / ObjC surface detection', () => { + test('an unguarded namespace is unguarded C++', () => { + const r = scanHeader('namespace facebook { struct X; }\n'); + expect(r.hasUnguardedCxx).toBe(true); + expect(r.hasGuardedCxx).toBe(false); + }); + + test('a namespace under __cplusplus is guarded, not unguarded', () => { + const r = scanHeader('#ifdef __cplusplus\nnamespace facebook {}\n#endif\n'); + expect(r.hasGuardedCxx).toBe(true); + expect(r.hasUnguardedCxx).toBe(false); + }); + + test('a named aggregate with a member initializer is ObjC++', () => { + const r = scanHeader('struct RCTFontProperties { CGFloat size = NAN; };\n'); + expect(r.hasUnguardedCxx).toBe(true); + }); + + test('an ANONYMOUS aggregate with a member initializer is ObjC++', () => { + // Regression: the tag name is optional, so a typedef'd anonymous struct + // carrying a C++ default member initializer is still detected as ObjC++. + const r = scanHeader('typedef struct { CGFloat x = NAN; } Foo;\n'); + expect(r.hasUnguardedCxx).toBe(true); + }); + + test('@interface marks the header as ObjC', () => { + const r = scanHeader('@interface RCTBridge : NSObject\n@end\n'); + expect(r.hasObjC).toBe(true); + }); +}); + +describe('scanHeader comment handling', () => { + test('a multi-line block comment mentioning C++ keywords does not trip the detector', () => { + const src = [ + '/*', + ' * namespace foo is documented here', + ' * template and constexpr too', + ' */', + '@interface RCTFoo', + '@end', + '', + ].join('\n'); + const r = scanHeader(src); + expect(r.hasUnguardedCxx).toBe(false); + expect(r.hasGuardedCxx).toBe(false); + expect(r.hasObjC).toBe(true); + }); + + test('an inline block comment does not trip the detector', () => { + expect(scanHeader('int x; /* namespace y */\n').hasUnguardedCxx).toBe( + false, + ); + }); + + test('a // line comment mentioning a keyword does not trip the detector', () => { + expect(scanHeader('// using namespace std;\n').hasUnguardedCxx).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js new file mode 100644 index 000000000000..2fc6f461f768 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js @@ -0,0 +1,273 @@ +/** + * 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'); + +// 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. +jest.spyOn(fs, 'readFileSync').mockReturnValue(''); + +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'), + ], +}); + +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 = planFromInventory(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(() => planFromInventory(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(() => planFromInventory(m)).toThrow(/not 'objc-modular-candidate'/); + }); +}); + +describe('R10 per-namespace umbrella (React_RCTAppDelegate)', () => { + test('emits a derived umbrella for the namespace', () => { + const plan = planFromInventory(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__'); + // No CocoaPods version boilerplate. + expect(u.content).not.toContain('FOUNDATION_EXPORT'); + }); + + test('module map lists the umbrella so the import stays modular', () => { + const plan = planFromInventory(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(() => planFromInventory(m)).toThrow( + /umbrella namespace 'React_RCTAppDelegate'/, + ); + }); +}); + +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(() => planFromInventory(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(() => planFromInventory(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 = planFromInventory(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 = planFromInventory(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 = planFromInventory(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(planFromInventory(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 = planFromInventory(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 = planFromInventory(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..522675feaf2d --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const { + buildDepsHeadersXcframework, + stubSlicesFromXcframework, +} = require('../headers-xcframework'); +const childProcess = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('buildDepsHeadersXcframework set-equality gate', () => { + let tmp /*: string */; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-headers-test-')); + }); + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + const mkHeaders = (namespaces /*: Array */) => { + const dir = path.join(tmp, 'Headers'); + fs.mkdirSync(dir, {recursive: true}); + for (const ns of namespaces) { + fs.mkdirSync(path.join(dir, ns), {recursive: true}); + } + return dir; + }; + + // Both gates throw BEFORE any staging or xcodebuild invocation, so these + // tests run without macOS tooling. + test('fails closed when a declared namespace is missing from the artifact', () => { + const headers = mkHeaders(['folly']); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []), + ).toThrow(/missing from .*Headers: glog/); + }); + + test('fails closed when the artifact ships an undeclared namespace', () => { + const headers = mkHeaders(['folly', 'brand-new-dep']); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly'], []), + ).toThrow(/undeclared in DEPS_NAMESPACES.*brand-new-dep/); + }); + + test('ignores loose files at the Headers root (directories are the namespace set)', () => { + const headers = mkHeaders(['folly']); + fs.writeFileSync(path.join(headers, 'stray.h'), ''); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []), + ).toThrow(/missing from .*Headers: glog/); // throws for glog, not stray.h + }); +}); + +describe('stubSlicesFromXcframework', () => { + // The plist shape is a pure function of plutil's JSON; mock it so the + // SupportedPlatform/Variant -> key mapping and the unknown-slice guard can be + // tested without a real xcframework or macOS tooling. + const mockPlist = (obj /*: unknown */) => + jest + .spyOn(childProcess, 'execFileSync') + .mockReturnValue(Buffer.from(JSON.stringify(obj) ?? '', 'utf8')); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('maps ios / ios-simulator slices to their stub recipes', () => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'ios', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ], + }); + const slices = stubSlicesFromXcframework('/fake.xcframework'); + expect(slices).toEqual([ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, + ]); + }); + + test('throws for an unknown slice, pointing at PLATFORM_STUB_RECIPES', () => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'watchos', SupportedArchitectures: ['arm64']}, + ], + }); + expect(() => stubSlicesFromXcframework('/fake.xcframework')).toThrow( + /no stub recipe for slice 'watchos'[\s\S]*PLATFORM_STUB_RECIPES/, + ); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/framework-resources.js b/packages/react-native/scripts/ios-prebuild/framework-resources.js new file mode 100644 index 000000000000..41e70bc1389b --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/framework-resources.js @@ -0,0 +1,306 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Non-header resources that the prebuild embeds into React.framework so the + * prebuilt artifact is self-describing for both CocoaPods-prebuilt and SwiftPM. + * + * In source builds each pod ships these via its podspec `resource_bundles`. In + * the prebuilt path the source pods aren't installed (CocoaPods facades) / not + * present (SwiftPM), so we reproduce them from the source tree at compose time: + * + * - Privacy manifest: the pods baked into React.framework each ship a + * PrivacyInfo.xcprivacy; we merge them into ONE manifest at the framework + * root, where Xcode's privacy-report aggregation picks it up (no runtime). + * - RCTI18nStrings: React-Core's localized strings (React/I18n/strings/*.lproj) + * rebuilt as RCTI18nStrings.bundle inside the framework, where the + * framework-aware RCTLocalizedString loader resolves them via bundleForClass:. + */ + +const fs = require('fs'); +const path = require('path'); +const plist = require('plist'); + +// Source roots whose pods compile into React.framework. third-party-podspecs is +// intentionally excluded — those (boost/glog/RCT-Folly) live in +// ReactNativeDependencies.xcframework and are aggregated there. +const REACT_PRIVACY_ROOTS = ['React', 'ReactCommon', 'Libraries', 'ReactApple']; + +// The privacy manifests of the pods that compile INTO React.framework — +// an EXPLICIT allowlist, not a glob: a PrivacyInfo.xcprivacy that appears +// under REACT_PRIVACY_ROOTS without being listed here fails the prebuild, +// forcing a conscious decision. A pod whose sources compile into +// React.framework belongs on this list; a pod that ships as its OWN framework +// must NOT have its manifest folded into React.framework's aggregate +// (over-declaration in the app's privacy report). +const REACT_PRIVACY_MANIFESTS = [ + path.join('React', 'Resources', 'PrivacyInfo.xcprivacy'), + path.join('ReactCommon', 'cxxreact', 'PrivacyInfo.xcprivacy'), + path.join('ReactCommon', 'react', 'timing', 'PrivacyInfo.xcprivacy'), +]; + +// Where React-Core's localized strings live, relative to the package root. +const STRINGS_REL = path.join('React', 'I18n', 'strings'); + +/*:: +type AccessedAPIType = { + NSPrivacyAccessedAPIType: string, + NSPrivacyAccessedAPITypeReasons?: Array, + ... +}; +type PrivacyManifest = { + NSPrivacyAccessedAPITypes?: Array, + NSPrivacyCollectedDataTypes?: Array, + NSPrivacyTracking?: boolean, + NSPrivacyTrackingDomains?: Array, + ... +}; +*/ + +// --------------------------------------------------------------------------- +// Privacy manifest +// --------------------------------------------------------------------------- + +// Recursively sorts object keys so structurally-equal values serialize +// identically regardless of source key order (arrays keep their order). +function canonicalize(value /*: unknown */) /*: unknown */ { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value != null && typeof value === 'object') { + const out /*: {[string]: unknown} */ = {}; + for (const k of Object.keys(value).sort()) { + out[k] = canonicalize(value[k]); + } + return out; + } + return value; +} + +/** + * Merges Apple privacy manifests into one. Pure; operates on parsed plist + * objects. Semantics: + * - NSPrivacyAccessedAPITypes: keyed by category; reasons unioned (deduped). + * - NSPrivacyCollectedDataTypes: unioned, deduped structurally. + * - NSPrivacyTrackingDomains: unioned (deduped); omitted when empty. + * - NSPrivacyTracking: logical OR. + */ +function mergePrivacyManifests( + manifests /*: Array */, +) /*: PrivacyManifest */ { + const reasonsByType /*: Map> */ = new Map(); + // Categories where at least one source actually declared the reasons key, so + // a source that omitted it doesn't get a fabricated empty-array key on the way + // out (keeps "a single manifest passes through unchanged" honest). + const typeHadReasonsKey /*: Set */ = new Set(); + const typeOrder /*: Array */ = []; + const trackingDomains /*: Set */ = new Set(); + const collected /*: Array */ = []; + const collectedSeen /*: Set */ = new Set(); + let tracking = false; + + for (const manifest of manifests) { + if (manifest == null) { + continue; + } + for (const entry of manifest.NSPrivacyAccessedAPITypes ?? []) { + const category = entry.NSPrivacyAccessedAPIType; + if (!reasonsByType.has(category)) { + reasonsByType.set(category, []); + typeOrder.push(category); + } + const entryReasons = entry.NSPrivacyAccessedAPITypeReasons; + if (entryReasons != null) { + typeHadReasonsKey.add(category); + const reasons = reasonsByType.get(category); + if (reasons != null) { + for (const reason of entryReasons) { + if (!reasons.includes(reason)) { + reasons.push(reason); + } + } + } + } + } + for (const domain of manifest.NSPrivacyTrackingDomains ?? []) { + trackingDomains.add(domain); + } + for (const dataType of manifest.NSPrivacyCollectedDataTypes ?? []) { + // Canonicalize (sort object keys recursively) before keying so two pods + // declaring the same data-type dict in different key order still dedup. + // The `?? ''` is unreachable at runtime (canonicalize of a plist dict + // never yields undefined) — it exists purely because Flow types + // JSON.stringify as `string | void`. + const key = JSON.stringify(canonicalize(dataType)) ?? ''; + if (!collectedSeen.has(key)) { + collectedSeen.add(key); + collected.push(dataType); + } + } + if (manifest.NSPrivacyTracking === true) { + tracking = true; + } + } + + const merged /*: PrivacyManifest */ = { + NSPrivacyAccessedAPITypes: typeOrder.map(category => { + const entry /*: AccessedAPIType */ = { + NSPrivacyAccessedAPIType: category, + }; + if (typeHadReasonsKey.has(category)) { + entry.NSPrivacyAccessedAPITypeReasons = + reasonsByType.get(category) ?? []; + } + return entry; + }), + NSPrivacyCollectedDataTypes: collected, + NSPrivacyTracking: tracking, + }; + if (trackingDomains.size > 0) { + merged.NSPrivacyTrackingDomains = Array.from(trackingDomains); + } + return merged; +} + +/** Parses a single `PrivacyInfo.xcprivacy` (plist) file into an object. */ +function readPrivacyManifest(filePath /*: string */) /*: PrivacyManifest */ { + // $FlowFixMe[incompatible-return] plist.parse returns a loose PlistValue. + return plist.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * Finds every `PrivacyInfo.xcprivacy` under the React-core source roots of + * `reactNativePath` (excluding third-party deps). Sorted for deterministic output. + */ +function collectReactPrivacyManifestPaths( + reactNativePath /*: string */, +) /*: Array */ { + const found /*: Array */ = []; + for (const root of REACT_PRIVACY_ROOTS) { + const dir = path.join(reactNativePath, root); + if (!fs.existsSync(dir)) { + continue; + } + for (const rel of fs.readdirSync(dir, {recursive: true})) { + if (path.basename(String(rel)) === 'PrivacyInfo.xcprivacy') { + found.push(path.join(root, String(rel))); + } + } + } + const unlisted = found.filter(rel => !REACT_PRIVACY_MANIFESTS.includes(rel)); + if (unlisted.length > 0) { + throw new Error( + 'React.framework privacy-manifest drift: found PrivacyInfo.xcprivacy ' + + 'file(s) under the React privacy roots that are not allowlisted:\n' + + unlisted.map(rel => ` ${rel}`).join('\n') + + '\nIf the owning pod compiles into React.framework, add the path to ' + + 'REACT_PRIVACY_MANIFESTS in framework-resources.js. If it ships as ' + + 'its own framework, its manifest must NOT be folded into ' + + "React.framework's aggregate — relocate it out of the privacy roots " + + 'or exclude it explicitly.', + ); + } + // A listed-but-absent manifest is legal (partial fixture trees; upstream + // deletions surface as under-declaration exactly like source builds would). + // A MOVED manifest cannot slip through: its new location is unlisted. + return found.map(rel => path.join(reactNativePath, rel)).sort(); +} + +/** + * Builds the aggregated React.framework privacy manifest from the source pods, + * or null when there are none. + */ +function buildReactPrivacyManifest( + reactNativePath /*: string */, +) /*: ?PrivacyManifest */ { + const paths = collectReactPrivacyManifestPaths(reactNativePath); + if (paths.length === 0) { + return null; + } + return mergePrivacyManifests(paths.map(readPrivacyManifest)); +} + +/** Serializes a manifest object back to a plist XML string. */ +function serializePrivacyManifest( + manifest /*: PrivacyManifest */, +) /*: string */ { + return plist.build(manifest); +} + +// --------------------------------------------------------------------------- +// RCTI18nStrings bundle +// --------------------------------------------------------------------------- + +/** The Info.plist contents that make the copied .lproj dirs load as an NSBundle. */ +function i18nBundleInfoPlist() /*: {[string]: string} */ { + return { + CFBundleDevelopmentRegion: 'en', + CFBundleIdentifier: 'org.reactnative.RCTI18nStrings', + CFBundleInfoDictionaryVersion: '6.0', + CFBundleName: 'RCTI18nStrings', + CFBundlePackageType: 'BNDL', + // Present so Apple validation tooling doesn't warn on a version-less bundle. + CFBundleShortVersionString: '1.0', + CFBundleVersion: '1', + }; +} + +/** Absolute paths of the React i18n `.lproj` locale dirs, sorted. */ +function collectLprojDirs(reactNativePath /*: string */) /*: Array */ { + const stringsDir = path.join(reactNativePath, STRINGS_REL); + if (!fs.existsSync(stringsDir)) { + return []; + } + return fs + .readdirSync(stringsDir, {withFileTypes: true}) + .filter(e => e.isDirectory() && String(e.name).endsWith('.lproj')) + .map(e => path.join(stringsDir, String(e.name))) + .sort(); +} + +/** + * Builds `RCTI18nStrings.bundle` at `outBundlePath` from the React i18n .lproj + * dirs + an Info.plist. Returns the number of locales copied (0 when there are + * none, in which case nothing is written). + */ +function buildI18nStringsBundle( + reactNativePath /*: string */, + outBundlePath /*: string */, +) /*: number */ { + const lprojDirs = collectLprojDirs(reactNativePath); + if (lprojDirs.length === 0) { + return 0; + } + fs.rmSync(outBundlePath, {recursive: true, force: true}); + fs.mkdirSync(outBundlePath, {recursive: true}); + for (const lproj of lprojDirs) { + fs.cpSync(lproj, path.join(outBundlePath, path.basename(lproj)), { + recursive: true, + }); + } + fs.writeFileSync( + path.join(outBundlePath, 'Info.plist'), + plist.build(i18nBundleInfoPlist()), + ); + return lprojDirs.length; +} + +module.exports = { + // privacy manifest + mergePrivacyManifests, + readPrivacyManifest, + collectReactPrivacyManifestPaths, + buildReactPrivacyManifest, + serializePrivacyManifest, + // RCTI18nStrings bundle + i18nBundleInfoPlist, + collectLprojDirs, + buildI18nStringsBundle, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js new file mode 100644 index 000000000000..60651c69dd29 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -0,0 +1,400 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers compose — emits the headers-spec layout (rules R1–R8 in + * headers-spec.js) into a React.xcframework and builds the headers-only + * ReactNativeHeaders.xcframework beside it (pure-RN: the third-party deps + * namespaces ship in the ReactNativeDependenciesHeaders sidecar instead — + * see headers-xcframework.js). The prebuild path (xcframework.js) composes + * before signing (R7); `ensureHeadersLayout()` applies the same emission to + * an already-cached artifact and builds the deps sidecar from the slot's + * deps headers. One projector, spec-driven, byte-identical output either + * way. + */ + +const { + buildI18nStringsBundle, + buildReactPrivacyManifest, + collectLprojDirs, + serializePrivacyManifest, +} = require('./framework-resources'); +const {computeInventory} = require('./headers-inventory'); +const { + DEPS_NAMESPACES, + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, +} = require('./headers-xcframework'); +const {execFileSync} = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// APFS clonefile (-c) is a macOS-only cp flag; plain -R elsewhere (Linux CI +// exercises these paths through the jest integration tests). +const CP_FLAGS = process.platform === 'darwin' ? '-Rc' : '-R'; + +// Hash of the compose tooling itself. Folded into the freshness marker so a +// local edit to any of these scripts (which changes the composed output) +// forces a recompose even when the cached source xcframework is untouched — +// the Info.plist mtime alone can't catch that. Stable across fresh checkouts +// (content-based, not mtime-based). sha256 to avoid weak-hash lint. +// +// This list must contain every local sibling module headers-compose.js +// requires (i.e. every `require('./*.js')` above) — the guard test in +// __tests__/headers-compose-test.js enforces that by parsing this file's +// source and diffing it against this list, so an added require without a +// matching entry here fails the test instead of silently going stale. +const COMPOSE_TOOLING_FILES /*: Array */ = [ + 'framework-resources.js', + 'headers-inventory.js', + 'headers-spec.js', + 'headers-xcframework.js', + 'headers-compose.js', +]; + +function composeToolingHash() /*: string */ { + const hash = crypto.createHash('sha256'); + for (const name of COMPOSE_TOOLING_FILES) { + hash.update(fs.readFileSync(path.join(__dirname, name))); + } + return hash.digest('hex'); +} + +/*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */ + +/** + * Computes the spec plan from the live source tree. Throws on collisions + * (R8) — a collision means the spec and the source tree disagree and the + * artifact must not be produced. + */ +function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ { + const inventory = computeInventory(rnRoot); + // R8, part 1: two distinct sources mapping to the same natural Headers/ path. + // addIdentity merges these and planFromInventory keeps only identities[0], so + // the second source would be silently dropped. Fail closed instead. + if (inventory.collisions.length > 0) { + const detail = inventory.collisions + .map(c => `${c.naturalPath} <- ${c.sources.join(', ')}`) + .join('\n '); + throw new Error( + `header-inventory natural-path collisions (R8):\n ${detail}`, + ); + } + const plan = planFromInventory(inventory, rnRoot); + // R8, part 2: two natural paths colliding on the same xcframework destination. + if (plan.collisions.length > 0) { + throw new Error( + `headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`, + ); + } + return plan; +} + +/** + * Copies spec entries (each `{relPath, source}`) into a staging dir, creating + * parent dirs. Shared by the React.framework and ReactNativeHeaders emission. + */ +function stageEntries( + stage /*: string */, + entries /*: Array */, + rnRoot /*: string */, +) /*: void */ { + for (const e of entries) { + const dest = path.join(stage, e.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + if (e.redirectTo != null) { + // R11: duplicate spelling of a source that lives elsewhere — emit a + // redirect shim so the declarations exist in exactly one file (module + // ownership and #import-once stay coherent across spellings). + fs.writeFileSync( + dest, + `// @generated by headers-compose (R11): this spelling redirects to\n` + + `// the content's single home so clang modules stay coherent.\n` + + `#import <${e.redirectTo}>\n`, + ); + } else { + fs.copyFileSync(path.join(rnRoot, e.source), dest); + } + } +} + +/** + * Emits the React.framework side of the spec (R1, R4, R6) into every slice + * of an xcframework: Headers root = React/ hoisted to root + bare aliases, + * generated umbrella + framework module map. Replaces each slice's Headers + * and Modules. The xcframework's ROOT Headers/ (the CocoaPods header surface) + * is left untouched. + */ +function emitReactFrameworkHeaders( + xcfwPath /*: string */, + plan /*: HeadersSpecPlan */, + rnRoot /*: string */, +) /*: void */ { + const stage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.react-stage-'), + ); + stageEntries(stage, plan.react, rnRoot); + fs.writeFileSync( + path.join(stage, 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + ); + + // A slice is any entry carrying a React.framework. The framework as built by + // xcodebuild -create-xcframework ships no Headers/ dir of its own — this + // emission creates it (and replaces Modules), so detect by the framework, not + // by a pre-existing Headers/. + const slices = fs + .readdirSync(xcfwPath) + .filter(d => fs.existsSync(path.join(xcfwPath, d, 'React.framework'))); + + // Aggregate the privacy manifests of the pods baked into React.framework into + // one root-level PrivacyInfo.xcprivacy, so the prebuilt artifact carries them + // for both CocoaPods-prebuilt and SwiftPM (source builds get them from the + // podspecs instead). Built once, embedded per slice. + const privacyManifest = buildReactPrivacyManifest(rnRoot); + + // Build RCTI18nStrings.bundle ONCE into a temp stage, then clone it into each + // slice below — mirrors the privacy manifest (computed once, embedded per + // slice) instead of rebuilding the bundle inside the slice loop. The stage is + // only created when there are locales to bundle. + let i18nStage = null; + let i18nBundleStage = null; + let i18nLocales = 0; + if (collectLprojDirs(rnRoot).length > 0) { + i18nStage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.i18n-stage-'), + ); + i18nBundleStage = path.join(i18nStage, 'RCTI18nStrings.bundle'); + i18nLocales = buildI18nStringsBundle(rnRoot, i18nBundleStage); + } + + for (const slice of slices) { + const fwk = path.join(xcfwPath, slice, 'React.framework'); + 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 */, + includeCatalyst /*: boolean */ = false, + // Optional dir containing a `hermes/` namespace (Hermes public headers from + // the hermes-ios tarball's destroot/include). Folded in as a textual + // namespace so `` resolves without per-library wiring. null + // when unstaged — then `` stays unavailable. + hermesHeaders /*: ?string */ = null, +) /*: string */ { + // ---- stage headers ---- + const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); + stageEntries(stage, plan.reactNativeHeaders, rnRoot); + // Hermes public headers (separate source from the deps namespaces — they + // come from the hermes-ios tarball, not ReactNativeDependencies). Vend only + // the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying + // it here would double-vend. + let hermesFolded = false; + if (hermesHeaders != null) { + const src = path.join(hermesHeaders, 'hermes'); + if (fs.existsSync(src)) { + execFileSync('/bin/cp', [CP_FLAGS, src, path.join(stage, 'hermes')]); + hermesFolded = true; + } else { + console.warn( + `headers-compose: hermes headers missing at ${src} — the composed ` + + 'ReactNativeHeaders will NOT resolve ; a downstream ' + + 'build that imports Hermes headers will fail. Ensure the hermes-ios ' + + "tarball's destroot/include was staged into the slot.", + ); + } + } + // R10: per-namespace umbrella headers (e.g. React_RCTAppDelegate-umbrella.h) + // that consumers like Expo probe via __has_include. Must be staged before the + // module map references them. + for (const u of plan.namespaceUmbrellas) { + const dest = path.join(stage, u.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.writeFileSync(dest, u.content); + } + fs.writeFileSync( + path.join(stage, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + ); + + // ---- compose (stub archives + create-xcframework) ---- + const slices = includeCatalyst + ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] + : DEFAULT_STUB_SLICES; + const outXcfw = composeHeadersOnlyXcframework( + outDir, + 'ReactNativeHeaders', + stage, + slices, + ); + fs.rmSync(stage, {recursive: true, force: true}); + console.log( + `headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(${plan.reactNativeHeaders.length} RN headers, pure-RN` + + `${hermesFolded ? ' + hermes' : ''}; ` + + `${Object.keys(plan.namespaceModules).length} namespace modules)`, + ); + return outXcfw; +} + +/** + * Ensures the headers-spec layout exists at `outDir`, composed from the cache + * slot's artifacts: clones React.xcframework (APFS clonefile), strips the + * stale signature (R7 — production signs after compose), emits the spec + * layout into every slice, builds the pure-RN ReactNativeHeaders.xcframework + * from the plan, and builds the ReactNativeDependenciesHeaders sidecar from + * the slot's deps headers. + * + * Skips when the freshness marker matches the source artifact (same + * realpath + Info.plist mtime) unless `force`. Any consumer with a cache slot + * gets composed artifacts automatically — no published ReactNativeHeaders / + * ReactNativeDependenciesHeaders required. + */ +function ensureHeadersLayout( + artifactsDir /*: string */, + rnRoot /*: string */, + outDir /*: string */, + force /*: boolean */ = false, +) /*: {reactXcfw: string, headersXcfw: string, depsHeadersXcfw: string} */ { + const sourceXcfw = fs.realpathSync( + path.join(artifactsDir, 'React.xcframework'), + ); + const depsHeaders = path.join( + artifactsDir, + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + // Hermes public headers staged into the slot by download-spm-artifacts + // (the hermes-ios tarball ships them in destroot/include, which the + // xcframework extraction otherwise discards). null when absent — then + // ReactNativeHeaders composes without the hermes namespace. + const hermesHeadersDir = path.join(artifactsDir, 'hermes-headers'); + const hermesHeaders = fs.existsSync(path.join(hermesHeadersDir, 'hermes')) + ? hermesHeadersDir + : null; + const reactXcfw = path.join(outDir, 'React.xcframework'); + const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + const depsHeadersXcfw = path.join( + outDir, + 'ReactNativeDependenciesHeaders.xcframework', + ); + const markerPath = path.join(outDir, '.composed-from'); + + const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist')); + // Fold the hermes-headers presence into the marker so a slot that gains + // staged hermes headers (e.g. after a tooling upgrade re-downloads them) + // recomposes instead of reusing a hermes-less ReactNativeHeaders. The + // compose-tooling hash makes a local edit to headers-{inventory,spec,compose} + // recompose too (the source xcframework's Info.plist mtime can't detect that). + const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\ntooling:${composeToolingHash()}\n`; + if ( + !force && + fs.existsSync(reactXcfw) && + fs.existsSync(headersXcfw) && + fs.existsSync(depsHeadersXcfw) && + fs.existsSync(markerPath) && + fs.readFileSync(markerPath, 'utf8') === marker + ) { + return {reactXcfw, headersXcfw, depsHeadersXcfw}; + } + + console.log( + `headers-compose: composing layout from ${path.basename(artifactsDir)} slot...`, + ); + fs.rmSync(reactXcfw, {recursive: true, force: true}); + fs.rmSync(markerPath, {force: true}); + fs.mkdirSync(outDir, {recursive: true}); + execFileSync('/bin/cp', [CP_FLAGS, sourceXcfw, reactXcfw]); + fs.rmSync(path.join(reactXcfw, '_CodeSignature'), { + recursive: true, + force: true, + }); + + const plan = computeSpecPlan(rnRoot); + emitReactFrameworkHeaders(reactXcfw, plan, rnRoot); + buildReactNativeHeadersXcframework( + outDir, + plan, + rnRoot, + false, + hermesHeaders, + ); + // 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, 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..9221b6945ba3 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json @@ -0,0 +1,29 @@ +[ + "notShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> react/renderer/observers/intersection/IntersectionObserverManager.h", + "notShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> react/renderer/observers/mutation/MutationObserverManager.h", + "notShipped react/renderer/animated/InterpolationAnimatedNode.h -> react/renderer/animated/internal/primitives.h", + "notShipped react/renderer/animated/NativeAnimatedNodesManager.h -> react/renderer/animated/event_drivers/EventAnimationDriver.h", + "notShipped react/renderer/animated/PropsAnimatedNode.h -> react/renderer/animated/internal/primitives.h", + "notShipped react/renderer/mounting/MountingCoordinator.h -> react/renderer/mounting/stubs/stubs.h", + "notShipped react/renderer/mounting/StubViewTree.h -> react/renderer/mounting/stubs/StubView.h", + "notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubView.h", + "notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubViewTree.h", + "quotedNotShipped RCTAnimation/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped RCTAnimation/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped RCTAnimation/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped React/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped React/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped React/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"", + "quotedNotShipped react/nativemodule/dom/NativeDOM.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/idlecallbacks/NativeIdleCallbacks.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/microtasks/NativeMicrotasks.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/viewtransition/NativeViewTransition.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"rncoreJSI.h\"", + "quotedNotShipped react/renderer/animated/AnimatedModule.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/renderer/animated/NativeAnimatedNodesManager.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped reactperflogger/FuseboxTracer.h -> \"folly/json/dynamic.h\"" +] diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js new file mode 100644 index 000000000000..4b514c35286c --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -0,0 +1,624 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Inventory and classify every header the React xcframework ships — the input + * to the headers spec (headers-spec.js). + * + * Enumerates headers through the SAME podspec-driven discovery the prebuild + * uses (headers.js), so the inventory cannot drift from the shipped set. For + * each header it records: + * + * - both identities: the pod-namespaced layout path (`Headers//`) + * and the natural path (the include path consumers write) + * - language surface: objc | objcxx | cxx | c (with `#ifdef __cplusplus` + * guard awareness, so ObjC headers that only reach C++ behind guards are + * not misclassified) + * - a modularizability bucket (can this header live in a Clang module?) + * + * `computeInventory()` returns the classified set in-memory for the prebuild + * compose step; the CLI (`node scripts/ios-prebuild/headers-inventory.js`) + * writes the same set as a JSON manifest. Read-only: never touches the trees + * it describes. + */ + +const {getHeaderFilesFromPodspecs} = require('./headers'); +// headers-spec.js requires only fs/path, so this cannot cycle. +const {DEPS_NAMESPACES} = require('./headers-spec'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type Identity = { + pod: string, // pod folder name in Headers/ (specName with '-' -> '_') + spec: string, // (sub)spec name the header came from + namespacedPath: string, // path inside the xcframework Headers/ dir + source: string, // repo-relative path to the physical file + bareAlias?: boolean, // synthetic root-level alias (React_RCTAppDelegate rule) +}; + +type IncludeRef = { + token: string, // text between <> or "" + cxxGuarded: boolean, // true when only reachable under #ifdef __cplusplus +}; + +type HeaderEntry = { + naturalPath: string, + identities: Array, + lang: 'objc' | 'objcxx' | 'cxx' | 'c', + bucket: 'objc-modular-candidate' | 'objc-blocked' | 'objcxx' | 'cxx', + includes: { + internal: Array<{naturalPath: string, cxxGuarded: boolean}>, + thirdParty: Array<{lib: string, token: string, cxxGuarded: boolean}>, + hermes: Array, + system: Array, + std: Array<{token: string, cxxGuarded: boolean}>, + metaInternal: Array, + otherPlatform: Array, + notShipped: Array, + unresolved: Array, + // Quoted includes that do not resolve to a shipped header. Fine inside the + // framework binary's own compilation, but a consumer compiling the shipped + // header hits "file not found" (source builds mask this via pod header + // maps). Gated by the include-health ratchet (headers-verify.js). + quotedNotShipped: Array, + }, +}; +*/ +// Third-party C++ libraries that RN's public headers re-expose (Tier 3 of the +// modularization doc). Keyed by the first include-path segment. Single source +// of truth: the spec's DEPS_NAMESPACES (the ReactNativeDependenciesHeaders +// sidecar's namespace set) — a new third-party dep is declared ONCE and the +// include classifier, the sidecar emitter, and the headers gate all follow. +const THIRD_PARTY_LIBS /*: Set */ = new Set(DEPS_NAMESPACES); + +// Apple SDK / platform include roots (first path segment). Includes resolving +// here are "system": always modular or always available, never our problem. +const SDK_PREFIXES = new Set([ + 'Accelerate', + 'Accessibility', + 'AVFoundation', + 'AVKit', + '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); + + // The merged ReactCoreHeaders tree ALSO exposes React_RCTAppDelegate + // headers bare at the root (hosts write #import ). + // Model that second identity explicitly. + if (podName === 'React_RCTAppDelegate') { + addIdentity( + path.basename(header.target), + { + ...identity, + bareAlias: true, + }, + header.source, + ); + } + } + } + } + + const collisions = []; + for (const [naturalPath, sources] of naturalToSources) { + if (sources.size > 1) { + collisions.push({ + naturalPath, + sources: Array.from(sources) + .map(s => path.relative(rootFolder, s)) + .sort(), + }); + } + } + collisions.sort((a, b) => a.naturalPath.localeCompare(b.naturalPath)); + + return {entries, sourceToNatural, collisions}; +} + +function classifyEntries( + entries /*: Map */, + sourceToNatural /*: Map> */, + rootFolder /*: string */, +) /*: void */ { + // RN's own top-level include namespaces, derived from the shipped set, so + // "in our namespace but not shipped" is detectable. + const ownNamespaces = new Set( + Array.from(entries.keys()) + .map(p => p.split('/')[0]) + .filter(p => p.includes('.') === false), + ); + + // Scan each entry's primary source once. + for (const entry of entries.values()) { + const absSource = path.join(rootFolder, entry.identities[0].source); + let text; + try { + text = fs.readFileSync(absSource, 'utf8'); + } catch { + entry.includes.unresolved.push(''); + continue; + } + const scan = scanHeader(text); + const isHpp = absSource.endsWith('.hpp'); + if (scan.hasObjC && scan.hasUnguardedCxx) { + entry.lang = 'objcxx'; + } else if (scan.hasObjC) { + entry.lang = 'objc'; + } else if (scan.hasUnguardedCxx || isHpp) { + entry.lang = 'cxx'; + } else { + entry.lang = 'c'; + } + + for (const inc of scan.includes) { + let token = inc.token; + // Quoted include: resolve against the source dir and map back to a + // natural path if the resolved file is itself a shipped header. + if (token.startsWith('"')) { + const 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 { + // A quoted include in a SHIPPED header that doesn't land on another + // shipped header: works in source builds (pod header maps / sibling + // files) but has no resolution target in the packaged layout when a + // consumer compiles this header. Recorded for the include-health + // ratchet rather than silently dropped. + entry.includes.quotedNotShipped.push(token); + } + continue; + } + if (entries.has(token)) { + entry.includes.internal.push({ + naturalPath: token, + cxxGuarded: inc.cxxGuarded, + }); + continue; + } + const kind = classifyExternal(token, ownNamespaces, rootFolder); + if (kind === 'thirdParty') { + entry.includes.thirdParty.push({ + lib: token.split('/')[0], + token, + cxxGuarded: inc.cxxGuarded, + }); + } else if (kind === 'hermes') { + entry.includes.hermes.push(token); + } else if (kind === 'system') { + entry.includes.system.push(token); + } else if (kind === 'std') { + entry.includes.std.push({token, cxxGuarded: inc.cxxGuarded}); + } else if (kind === 'metaInternal') { + entry.includes.metaInternal.push(token); + } else if (kind === 'otherPlatform') { + entry.includes.otherPlatform.push(token); + } else if (kind === 'notShipped') { + entry.includes.notShipped.push(token); + } else { + entry.includes.unresolved.push(token); + } + } + } + + // Fixpoint over UNGUARDED edges only: what an Obj-C (non-C++) consumer of + // this header actually pulls in. Decides modularizability of the ObjC surface. + const reachesCxx /*: Map */ = new Map(); + const reachesTp /*: Map> */ = new Map(); + for (const [naturalPath, entry] of entries) { + reachesCxx.set( + naturalPath, + entry.lang === 'cxx' || + entry.lang === 'objcxx' || + entry.includes.std.some(s => !s.cxxGuarded), + ); + reachesTp.set( + naturalPath, + new Set( + entry.includes.thirdParty.filter(t => !t.cxxGuarded).map(t => t.lib), + ), + ); + } + let changed = true; + while (changed) { + changed = false; + for (const [naturalPath, entry] of entries) { + let cxx = reachesCxx.get(naturalPath) ?? false; + const tp = reachesTp.get(naturalPath) ?? new Set(); + const beforeCxx = cxx; + const beforeTp = tp.size; + for (const dep of entry.includes.internal) { + if (dep.cxxGuarded) { + continue; + } + cxx = cxx || (reachesCxx.get(dep.naturalPath) ?? false); + for (const lib of reachesTp.get(dep.naturalPath) ?? []) { + tp.add(lib); + } + } + if (cxx !== beforeCxx || tp.size !== beforeTp) { + reachesCxx.set(naturalPath, cxx); + reachesTp.set(naturalPath, tp); + changed = true; + } + } + } + + for (const [naturalPath, entry] of entries) { + if (entry.lang === 'cxx') { + entry.bucket = 'cxx'; + } else if (entry.lang === 'objcxx') { + entry.bucket = 'objcxx'; + } else { + const cxx = reachesCxx.get(naturalPath) ?? false; + const tp = Array.from(reachesTp.get(naturalPath) ?? []).sort(); + if (!cxx && tp.length === 0) { + entry.bucket = 'objc-modular-candidate'; + } else { + entry.bucket = 'objc-blocked'; + } + } + } +} + +function main() /*: void */ { + const argv = process.argv.slice(2); + const getFlag = (name /*: string */) /*: ?string */ => { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; + }; + const rootFolder = path.resolve( + getFlag('--root') ?? path.join(__dirname, '..', '..'), + ); + const outPath = path.resolve( + getFlag('--out') ?? path.join(rootFolder, 'build', 'header-inventory.json'), + ); + + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + const headers = Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ); + + const manifest = { + formatVersion: 1, + generatedBy: 'scripts/ios-prebuild/headers-inventory.js', + root: rootFolder, + totals: {headers: headers.length}, + collisions, + headers, + }; + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + + console.log(`Wrote ${headers.length} headers to ${outPath}`); +} + +if (require.main === module) { + main(); +} + +/** + * In-memory inventory for tooling that needs the classified header set + * without going through the JSON manifest on disk (e.g. the prebuild compose + * step feeding headers-spec.planFromInventory). + */ +function computeInventory(rootFolder /*: string */) /*: { + headers: Array, + collisions: Array<{naturalPath: string, sources: Array}>, +} */ { + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + return { + headers: Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ), + // Natural-path collisions (two distinct sources mapping to the same + // Headers/ path) are silently merged by addIdentity — planFromInventory + // only keeps identities[0].source, so the second source is dropped. Surface + // them so the compose gate can fail closed (R8) instead of regressing. + collisions, + }; +} + +module.exports = { + buildInventory, + classifyEntries, + computeInventory, + scanHeader, + THIRD_PARTY_LIBS, + META_INTERNAL_RE, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js new file mode 100644 index 000000000000..ae22c53341f0 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -0,0 +1,479 @@ +/** + * 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_]*$/; + +// 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); + } +} + +function isUmbrellaSafe(h /*: any */, rnRoot /*: string */) /*: boolean */ { + if (h.bucket !== 'objc-modular-candidate' || h.naturalPath.includes('+')) { + 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__\n#import \n#endif\n\n${imports}\n`; +} + +/** + * Computes the full layout plan from the header inventory manifest + * (build/header-inventory.json — regenerate with header-inventory.js). + * `rnRoot` is the tree the inventory's relative `source` paths resolve + * against; defaults to the manifest's recorded root, then to the package + * hosting this script. + */ +function planFromInventory( + manifest /*: any */, + rnRoot /*:: ?: string */, +) /*: HeadersSpecPlan */ { + const root = rnRoot ?? manifest.root ?? RN_ROOT; + validatePrivateReactHeaders(manifest); // R9: fail closed on allowlist drift + const react /*: Array */ = []; + const reactNativeHeaders /*: Array */ = []; + const umbrella /*: Array */ = []; + const namespaceModules /*: {[string]: Array} */ = {}; + const collisions /*: Array */ = []; + const seen /*: Map */ = new Map(); + + for (const h of manifest.headers) { + const np = h.naturalPath; + const source = h.identities[0].source; + let bucketKey; + let entryList; + let relPath; + if (np.startsWith('React/')) { + relPath = np.slice(6); // R1: hoist React/ to the framework Headers root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else if (!np.includes('/')) { + relPath = np; // R1/R6: bare alias at root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else { + // R2: every other namespace (incl. react/) keeps its prefix and is + // served from ReactNativeHeaders via the header search path. + relPath = np; + bucketKey = `ReactNativeHeaders/${relPath}`; + entryList = reactNativeHeaders; + } + const prev = seen.get(bucketKey); + if (prev != null) { + if (prev !== source) { + collisions.push(`${bucketKey}: ${prev} vs ${source}`); // R8 + } + continue; + } + seen.set(bucketKey, source); + entryList.push({relPath, source, naturalPath: np}); + + // R4: React umbrella membership. + if (np.startsWith('React/') && isUmbrellaSafe(h, root)) { + umbrella.push(np); + } + // R5: namespace modules (only for ReactNativeHeaders namespaces). Every + // namespace with modular candidates gets a module so that React.framework's + // modular headers can `#import ` as a MODULAR include (otherwise + // clang's -Wnon-modular-include-in-framework-module rejects it). `react/` is + // included here too — its module is renamed in renderNamespaceModuleMap so a + // `react` module never aliases the `React` framework module on a + // case-insensitive filesystem. + if (entryList === reactNativeHeaders) { + const ns = np.split('/')[0]; + if (isUmbrellaSafe(h, root)) { + // R5 exemption assert: a namespace whose name is not a valid module + // identifier cannot get a module, so a modular-candidate header in it + // would be silently non-modular — consumers importing it from a + // framework-module context hit -Wnon-modular-include downstream. + // Fail here instead: rename the namespace or keep the header out of + // the modular surface. + if (!MODULE_IDENT_RE.test(ns)) { + throw new Error( + `R5: namespace '${ns}' is not a valid module identifier but ` + + `ships a modular-candidate header (${np}). It cannot get a ` + + `namespace module, so the header would be silently ` + + `non-modular for consumers.`, + ); + } + if (!namespaceModules[ns]) { + namespaceModules[ns] = []; + } + namespaceModules[ns].push(np); + } + } + } + + umbrella.sort(); + for (const ns of Object.keys(namespaceModules)) { + namespaceModules[ns].sort(); + } + + // R11: assign redirect shims for duplicate spellings of one source. + // Owner precedence: the React/ form (module React / canonical textual home) + // when the source ships one; else the RNH namespaced form (the R5-module + // owner — bare root aliases redirect INTO it, since content must live at + // the module-owned spelling or dual-module/dual-copy redefinitions return). + const reactBySource /*: Map */ = new Map(); + for (const e of react) { + if (e.naturalPath.startsWith('React/')) { + reactBySource.set(e.source, e.relPath); + } + } + const rnhBySource /*: Map */ = new Map(); + for (const e of reactNativeHeaders) { + if (!rnhBySource.has(e.source)) { + rnhBySource.set(e.source, e.naturalPath); + } + } + for (const e of reactNativeHeaders) { + const reactForm = reactBySource.get(e.source); + if (reactForm != null) { + e.redirectTo = `React/${reactForm}`; + } else { + // Two RNH spellings of one source with no React/ form: the first is + // the owner, later ones shim to it. + const owner = rnhBySource.get(e.source); + if (owner != null && owner !== e.naturalPath) { + e.redirectTo = owner; + } + } + } + for (const e of react) { + if (e.naturalPath.includes('/')) { + continue; // real React/ content, never a shim + } + // Bare root alias: prefer the React/ owner (three-spelling case), else + // the RNH namespaced sibling (the React_RCTAppDelegate rule). + const reactForm = reactBySource.get(e.source); + const nsForm = rnhBySource.get(e.source); + if (reactForm != null) { + e.redirectTo = `React/${reactForm}`; + } else if (nsForm != null) { + e.redirectTo = nsForm; + } + } + + // R10: fail closed if a probed umbrella namespace lost all its modular + // headers (removed/renamed) — the umbrella would silently vanish and + // re-break consumers like Expo. + const namespaceUmbrellas = UMBRELLA_NAMESPACES.map(ns => { + const headers = namespaceModules[ns]; + if (headers == null || headers.length === 0) { + throw new Error( + `R10: umbrella namespace '${ns}' has no modular headers in the ` + + `inventory (removed/renamed?). Update UMBRELLA_NAMESPACES.`, + ); + } + return { + relPath: `${ns}/${ns}-umbrella.h`, + content: renderNamespaceUmbrella(ns, headers), + }; + }); + + return { + react, + reactNativeHeaders, + depsNamespaces: DEPS_NAMESPACES, + umbrella, + namespaceModules, + namespaceUmbrellas, + privateReactHeaders: PRIVATE_REACT_HEADERS, + collisions, + }; +} + +/** + * Renders React.framework's module map (R4 + R9). The umbrella covers the + * public modular surface; the allowlisted private headers (R9) are appended as + * explicit `header` (modular) / `textual header` (objc-blocked) entries so + * `#import ` of them stays modular without polluting the umbrella. + */ +function renderReactModuleMap( + privateReactHeaders /*:: ?: {modular: Array, textual: Array} */, +) /*: string */ { + const pv = privateReactHeaders ?? {modular: [], textual: []}; + const extra = [ + ...pv.modular.map(h => ` header "${h}"`), + ...pv.textual.map(h => ` textual header "${h}"`), + ]; + const extraBlock = extra.length > 0 ? '\n' + extra.join('\n') : ''; + return `framework module React { + umbrella header "React-umbrella.h"${extraBlock} + export * + module * { export * } +} +`; +} + +/** Renders the umbrella header content (R4). */ +function renderUmbrellaHeader(umbrella /*: Array */) /*: string */ { + return umbrella.map(u => `#import <${u}>`).join('\n') + '\n'; +} + +/** + * Renders ReactNativeHeaders' module.modulemap (R5): PLAIN (non-framework) + * modules, one per namespace with modular candidates — discovered implicitly + * by clang via the auto-added header search path. Headers are referenced by + * their path relative to the Headers root (= the modulemap's directory). + */ +function renderNamespaceModuleMap( + namespaceModules /*: {[string]: Array} */, +) /*: string */ { + // The module NAME is internal to clang's module graph (consumers never + // `@import` these; they `#import ` and clang maps the header to its + // module). It only has to be unique and must not alias the `React` framework + // module on a case-insensitive filesystem — so the lowercase `react` + // namespace is given a distinct module name. Header paths are unchanged, so + // `` still resolves and is now a modular include. + const moduleNameFor = (ns /*: string */) /*: string */ => + ns === 'react' ? 'ReactNativeHeaders_react' : ns; + const blocks = []; + for (const ns of Object.keys(namespaceModules).sort()) { + const headerLines = namespaceModules[ns].map(hh => ` header "${hh}"`); + // R10: the per-namespace umbrella is itself a module member, so importing + // it stays modular (otherwise it re-trips -Wnon-modular-include inside the + // consumer's framework module). + if (UMBRELLA_NAMESPACES.includes(ns)) { + headerLines.push(` header "${ns}/${ns}-umbrella.h"`); + } + blocks.push( + `module ${moduleNameFor(ns)} {\n` + + headerLines.join('\n') + + `\n export *\n}`, + ); + } + return blocks.join('\n\n') + '\n'; +} + +module.exports = { + planFromInventory, + renderReactModuleMap, + renderUmbrellaHeader, + renderNamespaceModuleMap, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js new file mode 100644 index 000000000000..79bebc800348 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -0,0 +1,499 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers gate — verifies the composed prebuilt header layout at GENERATOR + * time, so consumer-facing regressions fail the prebuild instead of a + * downstream (rn-tester / Expo / community) build. Three stages: + * + * 1. INCLUDE HEALTH (no artifact needed): every shipped header's + * notShipped / unresolved / quotedNotShipped includes are ratcheted + * against a committed baseline (headers-include-baseline.json). A NEW + * offender fails; a resolved one is reported so the baseline can shrink. + * Update intentionally with --update-baseline. + * 2. STRUCTURAL: the composed artifact's module maps / umbrellas are + * byte-identical to what the current spec renders, and the R9 private + * headers + deps namespaces are physically present. + * 3. COMPILE (simulator SDK, syntax-only): + * a. An ObjC TU that __has_include-asserts the R9/R10 surfaces, then + * imports under -fmodules (precompiling the React + * module = compiling EVERY umbrella header + the R9 modular member), + * one header from every R5 namespace module (precompiling each), and + * the R10 per-namespace umbrella. + * b. A privileged-consumer ObjC++ TU (the Expo shape): textually imports + * every R9 textual Fabric header against ReactNativeHeaders. + * c. A Swift TU: `import React` + `RCTBridge.moduleRegistry` — the Expo + * Swift case, proving the R9 modular header is module-visible. + * + * Usage: + * node scripts/ios-prebuild/headers-verify.js [--flavor Debug|Release] + * [--artifacts ] [--skip-compile] [--update-baseline] + */ + +const {computeInventory} = require('./headers-inventory'); +const { + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/*:: import type {HeadersSpecPlan} from './headers-spec'; */ + +const RN_ROOT = path.join(__dirname, '..', '..'); +const BASELINE_PATH = path.join(__dirname, 'headers-include-baseline.json'); + +// Mirrors the folly compiler flags RN itself builds with (helpers.rb). +const FOLLY_DEFINES = [ + '-DFOLLY_MOBILE=1', + '-DFOLLY_USE_LIBCPP=1', + '-DFOLLY_CFG_NO_COROUTINES=1', + '-DFOLLY_HAVE_CLOCK_GETTIME=1', +]; +const SIM_TARGET = 'arm64-apple-ios15.0-simulator'; + +function log(msg /*: string */) { + console.log(`[headers-verify] ${msg}`); +} + +// --------------------------------------------------------------------------- +// Stage 1 — include health ratchet +// --------------------------------------------------------------------------- + +/** + * Flattens an inventory's include-health offenders to stable, sorted strings: + * " -> ". + */ +function collectIncludeHealth(inventory /*: any */) /*: Array */ { + const out = []; + for (const h of inventory.headers) { + for (const t of h.includes.notShipped) { + out.push(`notShipped ${h.naturalPath} -> ${t}`); + } + for (const t of h.includes.unresolved) { + out.push(`unresolved ${h.naturalPath} -> ${t}`); + } + for (const t of h.includes.quotedNotShipped) { + out.push(`quotedNotShipped ${h.naturalPath} -> ${t}`); + } + } + return out.sort(); +} + +/** + * Ratchet compare. New offenders (not in baseline) are failures; baseline + * entries no longer present are reported so the baseline can shrink. + */ +function diffAgainstBaseline( + current /*: Array */, + baseline /*: Array */, +) /*: {newOffenders: Array, resolved: Array} */ { + const base = new Set(baseline); + const cur = new Set(current); + return { + newOffenders: current.filter(x => !base.has(x)), + resolved: baseline.filter(x => !cur.has(x)), + }; +} + +function verifyIncludeHealth( + inventory /*: any */, + updateBaseline /*: boolean */, +) /*: void */ { + const current = collectIncludeHealth(inventory); + if (updateBaseline) { + fs.writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n'); + log(`include-health baseline updated (${current.length} entries).`); + return; + } + if (!fs.existsSync(BASELINE_PATH)) { + throw new Error( + `include-health baseline missing at ${BASELINE_PATH}. ` + + `Generate it with --update-baseline.`, + ); + } + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); + const {newOffenders, resolved} = diffAgainstBaseline(current, baseline); + if (resolved.length > 0) { + log( + `include-health: ${resolved.length} baseline entr(ies) resolved — ` + + `shrink the baseline with --update-baseline:\n ${resolved.join('\n ')}`, + ); + } + if (newOffenders.length > 0) { + throw new Error( + `include-health ratchet: ${newOffenders.length} NEW unresolvable ` + + `include(s) in shipped headers (work in source builds via pod header ` + + `maps, break the packaged layout):\n ${newOffenders.join('\n ')}\n` + + `Fix the include (ship the target header / guard it), or — only if ` + + `knowingly acceptable — add it to the baseline with --update-baseline.`, + ); + } + log(`include-health: OK (${current.length} baselined, 0 new).`); +} + +// --------------------------------------------------------------------------- +// Stage 2 — structural checks against the composed artifact +// --------------------------------------------------------------------------- + +function findSimSlice( + xcfwPath /*: string */, + probe /*: string */, +) /*: string */ { + const slices = fs + .readdirSync(xcfwPath) + .filter( + d => + d.includes('simulator') && fs.existsSync(path.join(xcfwPath, d, probe)), + ); + if (slices.length === 0) { + throw new Error(`No simulator slice with ${probe} under ${xcfwPath}`); + } + return path.join(xcfwPath, slices[0]); +} + +function assertFileEquals( + filePath /*: string */, + expected /*: string */, + what /*: string */, +) { + if (!fs.existsSync(filePath)) { + throw new Error(`${what} missing: ${filePath}`); + } + const actual = fs.readFileSync(filePath, 'utf8'); + if (actual !== expected) { + throw new Error( + `${what} does not match the current spec render: ${filePath}\n` + + `The artifact is stale or was composed from a different tree — ` + + `recompose (node scripts/ios-prebuild -c) and re-verify.`, + ); + } +} + +function verifyStructural( + plan /*: HeadersSpecPlan */, + artifactsDir /*: string */, +) /*: {reactSlice: string, rnhHeaders: string} */ { + const reactSlice = findSimSlice( + path.join(artifactsDir, 'React.xcframework'), + 'React.framework', + ); + const fwk = path.join(reactSlice, 'React.framework'); + assertFileEquals( + path.join(fwk, 'Modules', 'module.modulemap'), + renderReactModuleMap(plan.privateReactHeaders), + 'React module map', + ); + assertFileEquals( + path.join(fwk, 'Headers', 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + 'React umbrella header', + ); + for (const name of [ + ...plan.privateReactHeaders.modular, + ...plan.privateReactHeaders.textual, + ]) { + if (!fs.existsSync(path.join(fwk, 'Headers', name))) { + throw new Error(`R9 private header missing from artifact: ${name}`); + } + } + + const rnhSlice = findSimSlice( + path.join(artifactsDir, 'ReactNativeHeaders.xcframework'), + 'Headers', + ); + const rnhHeaders = path.join(rnhSlice, 'Headers'); + assertFileEquals( + path.join(rnhHeaders, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + 'ReactNativeHeaders module map', + ); + for (const u of plan.namespaceUmbrellas) { + assertFileEquals( + path.join(rnhHeaders, u.relPath), + u.content, + `R10 umbrella ${u.relPath}`, + ); + } + // ReactNativeHeaders is PURE-RN: every deps namespace must stay ABSENT. + // They ship in the ReactNativeDependenciesHeaders sidecar (one physical + // home per namespace) — relocated copies collide with the real pods' own + // headers (SocketRocket / Expo use_frameworks regression, 2026-07-03). + for (const ns of plan.depsNamespaces) { + if (fs.existsSync(path.join(rnhHeaders, ns))) { + throw new Error( + `deps namespace '${ns}' found in ReactNativeHeaders — it must NOT be ` + + `relocated (it ships in ReactNativeDependenciesHeaders; textual ` + + `copies collide with the real pods' headers).`, + ); + } + } + log('structural: OK (module maps + umbrellas byte-match the spec render).'); + return {reactSlice, rnhHeaders}; +} + +// --------------------------------------------------------------------------- +// Stage 3 — compile gates +// --------------------------------------------------------------------------- + +/** ObjC TU: __has_include asserts + React module + every namespace module. */ +function renderObjcFixture(plan /*: HeadersSpecPlan */) /*: string */ { + const lines = []; + for (const name of [ + ...plan.privateReactHeaders.modular, + ...plan.privateReactHeaders.textual, + ]) { + lines.push( + `#if !__has_include()`, + `#error "R9 private header not servable: "`, + `#endif`, + ); + } + for (const u of plan.namespaceUmbrellas) { + lines.push( + `#if !__has_include(<${u.relPath}>)`, + `#error "R10 umbrella not servable: <${u.relPath}>"`, + `#endif`, + ); + } + // Precompiles the React framework module (every umbrella header + the R9 + // modular member compile as module members). + lines.push('#import '); + for (const name of plan.privateReactHeaders.modular) { + lines.push(`#import `); + } + // One header per R5 namespace module precompiles the whole module. + for (const ns of Object.keys(plan.namespaceModules).sort()) { + lines.push(`#import <${plan.namespaceModules[ns][0]}>`); + } + for (const u of plan.namespaceUmbrellas) { + lines.push(`#import <${u.relPath}>`); + } + return lines.join('\n') + '\n'; +} + +/** Privileged-consumer ObjC++ TU (Expo shape): the R9 textual surface. */ +function renderPrivilegedFixture(plan /*: HeadersSpecPlan */) /*: string */ { + return ( + plan.privateReactHeaders.textual + .map(name => `#import `) + .join('\n') + '\n' + ); +} + +/** Swift fixture: the Expo Swift private-API case. */ +function renderSwiftFixture() /*: string */ { + return `import React +func _headersVerifyProbe(_ bridge: RCTBridge) { + // RCTBridge.moduleRegistry is declared in RCTBridge+Private.h (an R9 + // modular member) — visible through \`import React\` iff R9 holds. + _ = bridge.moduleRegistry +} +`; +} + +function xcrun(args /*: Array */, what /*: string */) { + try { + execFileSync('xcrun', args, {stdio: ['ignore', 'pipe', 'pipe']}); + } catch (e) { + const stderr = e.stderr != null ? String(e.stderr) : String(e); + throw new Error(`${what} FAILED:\n${stderr}`); + } +} + +function runCompileGates( + plan /*: HeadersSpecPlan */, + reactSlice /*: string */, + rnhHeaders /*: string */, + depsHeaders /*: string */, +) /*: void */ { + const sdk = execFileSync( + 'xcrun', + ['--sdk', 'iphonesimulator', '--show-sdk-path'], + {encoding: 'utf8'}, + ).trim(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'headers-verify-')); + try { + // ReactNativeHeaders is pure-RN, so the deps headers (folly/glog/... — + // reached textually from RN's public headers) come from the deps + // artifact's Headers dir, exactly as consumers get them from the + // ReactNativeDependencies pod / ReactNativeDependenciesHeaders sidecar. + const common = [ + '-fsyntax-only', + '-target', + SIM_TARGET, + '-isysroot', + sdk, + '-F', + reactSlice, + '-I', + rnhHeaders, + '-I', + depsHeaders, + ]; + + const objc = path.join(tmp, 'gate-modules.m'); + fs.writeFileSync(objc, renderObjcFixture(plan)); + xcrun( + [ + 'clang', + '-x', + 'objective-c', + '-fobjc-arc', + '-fmodules', + `-fmodules-cache-path=${path.join(tmp, 'mc')}`, + ...common, + objc, + ], + 'ObjC module gate (React module + namespace modules + R10 umbrella)', + ); + log( + `compile: React module + ${Object.keys(plan.namespaceModules).length} ` + + `namespace modules precompile OK.`, + ); + + const objcxx = path.join(tmp, 'gate-privileged.mm'); + fs.writeFileSync(objcxx, renderPrivilegedFixture(plan)); + xcrun( + [ + 'clang++', + '-x', + 'objective-c++', + '-std=c++20', + '-fobjc-arc', + ...FOLLY_DEFINES, + '-Wno-comma', + '-Wno-shorten-64-to-32', + ...common, + objcxx, + ], + 'Privileged-consumer gate (R9 textual Fabric headers)', + ); + log('compile: privileged-consumer (Expo-shape) ObjC++ fixture OK.'); + + const swift = path.join(tmp, 'gate-swift.swift'); + fs.writeFileSync(swift, renderSwiftFixture()); + xcrun( + [ + 'swiftc', + '-typecheck', + '-sdk', + sdk, + '-target', + SIM_TARGET, + '-module-cache-path', + path.join(tmp, 'mc-swift'), + '-F', + reactSlice, + '-I', + rnhHeaders, + '-I', + depsHeaders, + swift, + ], + 'Swift gate (import React + RCTBridge.moduleRegistry)', + ); + log('compile: Swift moduleRegistry fixture OK.'); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv /*: Array */) /*: { + flavor: string, + artifacts: ?string, + skipCompile: boolean, + updateBaseline: boolean, +} */ { + let flavor = 'Debug'; + let artifacts /*: ?string */ = null; + let skipCompile = false; + let updateBaseline = false; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--flavor') { + flavor = argv[++i]; + } else if (argv[i] === '--artifacts') { + artifacts = argv[++i]; + } else if (argv[i] === '--skip-compile') { + skipCompile = true; + } else if (argv[i] === '--update-baseline') { + updateBaseline = true; + } + } + return {flavor, artifacts, skipCompile, updateBaseline}; +} + +function main(argv /*:: ?: Array */) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + const inventory = computeInventory(RN_ROOT); + const plan = planFromInventory(inventory, RN_ROOT); + if (plan.collisions.length > 0) { + throw new Error(`R8 collisions:\n ${plan.collisions.join('\n ')}`); + } + + verifyIncludeHealth(inventory, args.updateBaseline); + if (args.updateBaseline) { + return; + } + + const artifactsDir = + args.artifacts ?? + path.join(RN_ROOT, '.build', 'output', 'xcframeworks', args.flavor); + if (!fs.existsSync(artifactsDir)) { + throw new Error( + `No composed artifacts at ${artifactsDir} — run the compose first ` + + `(node scripts/ios-prebuild -c -f ${args.flavor}).`, + ); + } + const {reactSlice, rnhHeaders} = verifyStructural(plan, artifactsDir); + + if (args.skipCompile) { + log('compile gates skipped (--skip-compile).'); + } else { + // ReactNativeHeaders is pure-RN — the compile gates additionally need the + // deps headers, served from the staged deps artifact (the same content + // the ReactNativeDependenciesHeaders sidecar ships). + const depsHeaders = path.join( + RN_ROOT, + 'third-party', + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + if (!fs.existsSync(depsHeaders)) { + throw new Error( + `deps headers missing at ${depsHeaders} — stage the ` + + `ReactNativeDependencies artifact before running the compile gates ` + + `(or pass --skip-compile).`, + ); + } + runCompileGates(plan, reactSlice, rnhHeaders, depsHeaders); + } + log('ALL GATES PASSED.'); +} + +if (require.main === module) { + main(); +} + +module.exports = { + collectIncludeHealth, + diffAgainstBaseline, + renderObjcFixture, + renderPrivilegedFixture, + main, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js new file mode 100644 index 000000000000..f4a14c843441 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -0,0 +1,274 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers-only xcframework emitter — the shared recipe behind + * ReactNativeHeaders.xcframework (headers-compose.js) and the + * ReactNativeDependenciesHeaders.xcframework sidecar (the deps prebuild's + * compose-framework.js). A headers-only artifact is a LIBRARY-type + * xcframework: stub static archives (nothing embeds in apps) paired with a + * staged Headers dir. The per-slice Headers/ layout and the Info.plist + * `HeadersPath` key — what makes SwiftPM auto-serve the headers with zero + * flags — are produced by `xcodebuild -create-xcframework -library ... + * -headers ...` itself and are never hand-written (framework-type entries + * hard-reject `HeadersPath`; verified 2026-07-04). + * + * This module must stay dependency-light (fs/path/child_process only): the + * deps prebuild under scripts/releases requires it across the package + * boundary. + */ + +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// APFS clonefile (-c) is a macOS-only cp flag; plain -R elsewhere (Linux CI +// exercises these paths through the jest integration tests). +const CP_FLAGS = process.platform === 'darwin' ? '-Rc' : '-R'; + +/*:: +export type StubSlice = { + name: string, // human label + sdk: string, // xcrun --sdk name + targets: Array, // clang -target triples (lipo'd when > 1) +}; +*/ + +const DEFAULT_STUB_SLICES /*: Array */ = [ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, +]; + +// Mac Catalyst slice — used by the real compose (the cached-artifact +// repackage path skips it to stay fast; React.xcframework carries it). +const CATALYST_STUB_SLICE /*: StubSlice */ = { + name: 'mac-catalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], +}; + +// SupportedPlatform(+variant) from an xcframework Info.plist -> stub recipe. +// The min OS version in the triple only shapes the stub object file; slice +// identity (what create-xcframework groups by) comes from platform + variant +// + archs. +const PLATFORM_STUB_RECIPES /*: { + [key: string]: {sdk: string, os: string, suffix: string}, +} */ = { + ios: {sdk: 'iphoneos', os: 'ios15.0', suffix: ''}, + 'ios-simulator': { + sdk: 'iphonesimulator', + os: 'ios15.0', + suffix: '-simulator', + }, + 'ios-maccatalyst': {sdk: 'macosx', os: 'ios15.0', suffix: '-macabi'}, + macos: {sdk: 'macosx', os: 'macosx11.0', suffix: ''}, + tvos: {sdk: 'appletvos', os: 'tvos15.1', suffix: ''}, + 'tvos-simulator': { + sdk: 'appletvsimulator', + os: 'tvos15.1', + suffix: '-simulator', + }, + xros: {sdk: 'xros', os: 'xros1.0', suffix: ''}, + 'xros-simulator': {sdk: 'xrsimulator', os: 'xros1.0', suffix: '-simulator'}, +}; + +/** + * Derives stub slices matching an existing (binary) xcframework's slice set, + * so a headers-only sidecar resolves for every platform the binary does. + */ +function stubSlicesFromXcframework( + xcfwPath /*: string */, +) /*: Array */ { + const plist = JSON.parse( + execFileSync('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-')); + // try/finally so an xcrun/xcodebuild failure mid-compose doesn't leave the + // .stub-work-* staging dir behind in outDir. + try { + fs.writeFileSync( + path.join(work, 'stub.c'), + `// ${name} is headers-only; this stub satisfies xcframework tooling.\n` + + `static int ${name}Stub __attribute__((unused)) = 0;\n`, + ); + const libs = slices.map(slice => { + const sdkPath = execFileSync('xcrun', [ + '--sdk', + slice.sdk, + '--show-sdk-path', + ]) + .toString() + .trim(); + const thins = slice.targets.map((t, i) => { + const obj = path.join(work, `stub-${slice.name}-${i}.o`); + execFileSync('xcrun', [ + 'clang', + '-c', + '-target', + t, + '-isysroot', + sdkPath, + path.join(work, 'stub.c'), + '-o', + obj, + ]); + const lib = path.join(work, `stub-${slice.name}-${i}.a`); + execFileSync('xcrun', ['libtool', '-static', '-o', lib, obj], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + return lib; + }); + const outLib = path.join(work, `lib${name}-${slice.name}.a`); + if (thins.length === 1) { + fs.copyFileSync(thins[0], outLib); + } else { + execFileSync('xcrun', ['lipo', '-create', ...thins, '-output', outLib]); + } + return outLib; + }); + + const outXcfw = path.join(outDir, `${name}.xcframework`); + fs.rmSync(outXcfw, {recursive: true, force: true}); + const xcframeworkArgs = ['-create-xcframework']; + for (const l of libs) { + xcframeworkArgs.push('-library', l, '-headers', stage); + } + xcframeworkArgs.push('-output', outXcfw); + execFileSync('xcodebuild', xcframeworkArgs, {stdio: 'pipe'}); + return outXcfw; + } finally { + fs.rmSync(work, {recursive: true, force: true}); + } +} + +const DEPS_HEADERS_XCFRAMEWORK_NAME = 'ReactNativeDependenciesHeaders'; + +/** + * Builds ReactNativeDependenciesHeaders.xcframework: the headers-only sidecar + * serving the third-party deps namespaces (folly/glog/boost/fmt/ + * double-conversion/fast_float/SocketRocket). The binary + * ReactNativeDependencies.xcframework is FRAMEWORK-type, so its root Headers/ + * dir is invisible to SwiftPM — this LIBRARY-type sidecar is what makes the + * deps headers auto-served, keeping ReactNativeHeaders pure-RN. + * + * Set-equality with `namespaces` (headers-spec.js DEPS_NAMESPACES) is + * enforced fail-closed in BOTH directions: a declared namespace missing from + * `depsHeaders` would ship a silently-broken sidecar; an undeclared dir means + * a new third-party dep was added without a spec decision. + */ +function buildDepsHeadersXcframework( + outDir /*: string */, + depsHeaders /*: string */, + namespaces /*: Array */, + slices /*: Array */, +) /*: string */ { + const found = fs + .readdirSync(depsHeaders, {withFileTypes: true}) + .filter(e => e.isDirectory()) + .map(e => String(e.name)); + const missing = namespaces.filter(ns => !found.includes(ns)); + const undeclared = found.filter(d => !namespaces.includes(d)); + if (missing.length > 0 || undeclared.length > 0) { + throw new Error( + `headers-xcframework: deps namespaces out of sync with the spec.\n` + + (missing.length > 0 + ? ` missing from ${depsHeaders}: ${missing.join(', ')}\n` + : '') + + (undeclared.length > 0 + ? ` undeclared in DEPS_NAMESPACES (headers-spec.js): ${undeclared.join(', ')}\n` + : '') + + `Declare new deps deliberately — the sidecar and the spec must agree.`, + ); + } + + const stage = fs.mkdtempSync(path.join(outDir, '.deps-headers-stage-')); + // try/finally so a cp/compose failure doesn't leave the .deps-headers-stage-* + // dir behind in outDir (= third-party/ for the deps path). + let outXcfw; + try { + for (const ns of namespaces) { + execFileSync('/bin/cp', [ + CP_FLAGS, + path.join(depsHeaders, ns), + path.join(stage, ns), + ]); + } + outXcfw = composeHeadersOnlyXcframework( + outDir, + DEPS_HEADERS_XCFRAMEWORK_NAME, + stage, + slices, + ); + } finally { + fs.rmSync(stage, {recursive: true, force: true}); + } + console.log( + `headers-xcframework: ${DEPS_HEADERS_XCFRAMEWORK_NAME}.xcframework ` + + `(${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(namespaces: ${namespaces.join(', ')})`, + ); + return outXcfw; +} + +module.exports = { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + DEPS_HEADERS_XCFRAMEWORK_NAME, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, + stubSlicesFromXcframework, +}; diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index f497338bef8b..92ca219a68cf 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -92,6 +92,36 @@ 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. + // + // Slice coverage: this tarball sidecar carries ALL slices the deps prebuild + // produced. The ensureHeadersLayout fallback (headers-compose.js) rebuilds it + // from DEFAULT_STUB_SLICES (ios + ios-simulator only) — sufficient for a + // SwiftPM iOS build, which is the only supported SPM target today. The two + // paths therefore agree on the iOS slices; the fallback simply omits the + // extra slices (catalyst/tvos/...) that no SPM consumer needs yet. Headers + // are slice-uniform, so a consumer never sees divergent content per slice. + const headersSidecarSource = path.join( + path.dirname(xcframeworkSource), + 'ReactNativeDependenciesHeaders.xcframework', + ); + if (fs.existsSync(headersSidecarSource)) { + execSync(`cp -R "${headersSidecarSource}" "${artifactsPath}"`, { + stdio: 'inherit', + }); + } else { + dependencyLog( + 'ReactNativeDependenciesHeaders.xcframework not present in the tarball ' + + '(pre-sidecar artifact) — continuing with the binary xcframework only.', + 'warning', + ); + } + // Delete the tarball after extraction if (!process.env.HERMES_ENGINE_TARBALL_PATH) { fs.unlinkSync(localPath); @@ -161,6 +191,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 56cad1f9ab18..663ebfa77a91 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -22,24 +22,6 @@ export type Destination = export type BuildFlavor = 'Debug' | 'Release'; export type MavenSubGroup = 'hermes' | 'react'; - -export type VFSEntry = { - name: string, - type: 'file' | 'directory', - 'external-contents'?: string, - contents?: Array, -}; - -export type VFSOverlay = { - version: number, - 'case-sensitive': boolean, - roots: Array, -}; - -export type HeaderMapping = { - key: string, - path: string, -}; */ module.exports = {}; diff --git a/packages/react-native/scripts/ios-prebuild/vfs.js b/packages/react-native/scripts/ios-prebuild/vfs.js deleted file mode 100644 index 13e2cb233d5f..000000000000 --- a/packages/react-native/scripts/ios-prebuild/vfs.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -/*:: import type {HeaderMapping, VFSEntry, VFSOverlay} from './types'; */ - -const headers = require('./headers'); - -const {getHeaderFilesFromPodspecs} = headers; - -const ROOT_PATH_PLACEHOLDER = '${ROOT_PATH}'; - -/** - * Builds a hierarchical VFS directory structure from a list of header mappings. - * Clang's VFS overlay requires a tree structure where directories contain their children. - */ -function buildVFSStructure( - mappings /*: Array */, -) /*: Array */ { - // Group files by their directory structure - const dirTree /*: Map> */ = new Map(); - - for (const mapping of mappings) { - const parts = mapping.key.split('/'); - const fileName = parts[parts.length - 1]; - const dirPath = parts.slice(0, -1).join('/'); - - if (!dirTree.has(dirPath)) { - dirTree.set(dirPath, new Map()); - } - const filesMap = dirTree.get(dirPath); - if (filesMap) { - filesMap.set(fileName, mapping.path); - } - } - - // Build the root-level entries (files at root + top-level directories) - const rootDirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - const topLevel = dirPath.split('/')[0]; - if (topLevel) { - rootDirs.add(topLevel); - } - } - - const roots /*: Array */ = []; - - // Add files that live at the root (e.g. key === 'RCTAppDelegate.h') - const rootFiles = dirTree.get(''); - if (rootFiles) { - for (const [fileName, sourcePath] of Array.from( - rootFiles.entries(), - ).sort()) { - roots.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - for (const rootDir of Array.from(rootDirs).sort()) { - const dirEntry = buildDirectoryEntry(rootDir, '', dirTree); - roots.push(dirEntry); - } - - return roots; -} - -/** - * Recursively builds a directory entry for the VFS - */ -function buildDirectoryEntry( - dirName /*: string */, - parentPath /*: string */, - dirTree /*: Map> */, -) /*: VFSEntry */ { - const currentPath = parentPath ? `${parentPath}/${dirName}` : dirName; - const contents /*: Array */ = []; - - // Add files in this directory - const filesInDir = dirTree.get(currentPath); - if (filesInDir) { - for (const [fileName, sourcePath] of Array.from( - filesInDir.entries(), - ).sort()) { - contents.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - // Add subdirectories - const subdirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - if (dirPath.startsWith(currentPath + '/')) { - const remainder = dirPath.slice(currentPath.length + 1); - const nextDir = remainder.split('/')[0]; - if (nextDir) { - subdirs.add(nextDir); - } - } - } - - for (const subdir of Array.from(subdirs).sort()) { - contents.push(buildDirectoryEntry(subdir, currentPath, dirTree)); - } - - return { - name: dirName, - type: 'directory', - contents, - }; -} - -/** - * Simple YAML generator for VFS overlay structure (hierarchical format) - */ -function generateVFSOverlayYAML(overlay /*: VFSOverlay */) /*: string */ { - let yaml = ''; - - yaml += `version: ${String(overlay.version)}\n`; - yaml += `case-sensitive: ${String(overlay['case-sensitive'])}\n`; - yaml += `roots:\n`; - - for (const root of overlay.roots) { - yaml += generateEntryYAML(root, 1); - } - - return yaml; -} - -/** - * Recursively generates YAML for a VFS entry - */ -function generateEntryYAML( - entry /*: VFSEntry */, - indent /*: number */, -) /*: string */ { - const spaces = ' '.repeat(indent); - let yaml = ''; - - yaml += `${spaces}- name: '${entry.name}'\n`; - yaml += `${spaces} type: '${entry.type}'\n`; - - if (entry['external-contents']) { - yaml += `${spaces} external-contents: '${entry['external-contents']}'\n`; - } - - if (entry.contents && entry.contents.length > 0) { - yaml += `${spaces} contents:\n`; - for (const child of entry.contents) { - yaml += generateEntryYAML(child, indent + 2); - } - } - - return yaml; -} - -/** - * Creates a VFS overlay object from the header files in podspecs. - * The source paths use ${ROOT_PATH} as a placeholder for later replacement - * with the actual root path on the end user's machine. - * - * The VFS overlay wraps all header mappings under a single root at - * ${ROOT_PATH}/Headers, which matches the HEADER_SEARCH_PATHS configured - * in rncore.rb. This allows the compiler to find headers like - * by looking up ${ROOT_PATH}/Headers/yoga/style/Style.h - * which the VFS redirects to the flat location in the xcframework. - * - * @param rootFolder The root folder of the React Native package - * @returns A VFS overlay object that can be serialized to YAML - */ -function createVFSOverlayContents(rootFolder /*: string */) /*: VFSOverlay */ { - // Get header files from podspecs (disable testing since we just need the mappings) - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - const mappings /*: Array */ = []; - - // Process each podspec and its header files - Object.keys(podSpecsWithHeaderFiles).forEach(podspecPath => { - const headerMaps = podSpecsWithHeaderFiles[podspecPath]; - - // Use the first podspec spec name as the podspec name (this is the root spec) - const podSpecName = headerMaps[0].specName.replace('-', '_'); - - headerMaps.forEach(headerMap => { - headerMap.headers.forEach(header => { - // The key is just the target path (the import path) - // e.g., 'react/renderer/graphics/Size.h' for #import - let key = header.target; - - // If the podspec doesn't specify a header_dir, CocoaPods exposes public headers under - // (and umbrella headers typically use quoted imports resolved relative - // to the pod's public headers directory). To mirror that layout and avoid collisions - // between pods, prefix root-level header targets with the pod spec name. - if ( - !key.includes('/') && - (!headerMap.headerDir || headerMap.headerDir === '') - ) { - key = `${podSpecName}/${key}`; - } - - // The external-contents path is always podSpecName + header.target because - // xcframework.js copies headers to: outputHeadersPath/podSpecName/headerFile.target - // So the VFS must point to that same location. - const sourcePath = `${ROOT_PATH_PLACEHOLDER}/Headers/${podSpecName}/${header.target}`; - - mappings.push({ - key, - path: sourcePath, - }); - }); - }); - }); - - // Build the hierarchical VFS structure from mappings - const innerRoots = buildVFSStructure(mappings); - - // Wrap all roots under a single ${ROOT_PATH}/Headers root. - // This is required because Clang's VFS overlay needs absolute paths for root entries. - // The compiler will have -I${ROOT_PATH}/Headers in its include paths, so when it - // searches for , it looks for ${ROOT_PATH}/Headers/yoga/style/Style.h. - // The VFS overlay intercepts this and maps it to the actual flat location. - const wrappedRoot /*: VFSEntry */ = { - name: `${ROOT_PATH_PLACEHOLDER}/Headers`, - type: 'directory', - contents: innerRoots, - }; - - return { - version: 0, - 'case-sensitive': false, - roots: [wrappedRoot], - }; -} - -/** - * Creates a VFS overlay YAML file from the header files in podspecs. - * This is a convenience function that combines createVFSOverlayContents and - * generateVFSOverlayYAML into a single call. - * - * @param rootFolder The root folder of the React Native package - * @returns The VFS overlay as a YAML string ready to be written to a file - */ -function createVFSOverlay(rootFolder /*: string */) /*: string */ { - const overlay = createVFSOverlayContents(rootFolder); - return generateVFSOverlayYAML(overlay); -} - -/** - * Resolves a VFS overlay template by replacing the ${ROOT_PATH} placeholder - * with the actual root path. This is the equivalent of the Ruby create_vfs_overlay - * function in rncore.rb. - * - * The VFS overlay template contains ${ROOT_PATH} placeholders that need to be - * replaced with the actual path to the xcframework on the end user's machine - * (e.g., the path to React.xcframework in the Pods folder). - * - * @param vfsTemplate The VFS overlay template content (YAML string with ${ROOT_PATH} placeholders) - * @param rootPath The actual root path to substitute for ${ROOT_PATH} - * @returns The resolved VFS overlay YAML string with absolute paths - */ -function resolveVFSOverlay( - vfsTemplate /*: string */, - rootPath /*: string */, -) /*: string */ { - return vfsTemplate.split(ROOT_PATH_PLACEHOLDER).join(rootPath); -} - -module.exports = { - createVFSOverlay, - resolveVFSOverlay, -}; diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 1f7d473046e5..f370ec768d47 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,46 +13,16 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); -const headers = require('./headers'); const utils = require('./utils'); -const vfs = require('./vfs'); const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); -const {execSync} = childProcess; -const {getHeaderFilesFromPodspecs} = headers; -const {createFolderIfNotExists, createLogger} = utils; -const {createVFSOverlay} = vfs; +const {execSync, execFileSync} = childProcess; +const {createLogger} = utils; const frameworkLog = createLogger('XCFramework'); -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const REACT_CORE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React-umbrella.h', -); - -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React_RCTAppDelegate-umbrella.h', -); - -const RN_MODULEMAP_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'module.modulemap', -); - function buildXCFrameworks( rootFolder /*: string */, buildFolder /*: string */, @@ -70,7 +40,7 @@ function buildXCFrameworks( buildType, 'React.xcframework', ); - // Delete all target platform folders (everything but the Headers and Modules folders) + // Delete any previous output try { fs.rmSync(outputPath, {recursive: true, force: true}); } catch (error) { @@ -104,98 +74,38 @@ function buildXCFrameworks( return; } - // Use the header files from podspecs - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - // Delete header files to the output path - const outputHeadersPath = path.join(outputPath, 'Headers'); - - // Store umbrella headers keyed on podspec names - const umbrellaHeaders /*: {[key: string]: string} */ = {}; - const copiedHeaderFilesWithPodspecNames /*: {[key: string]: string[]} */ = {}; - - // Enumerate podspecs and copy headers, create umbrella headers and module map file - Object.keys(podSpecsWithHeaderFiles).forEach(podspec => { - const headerFiles = podSpecsWithHeaderFiles[podspec] - .map(h => h.headers) - .flat(); - - // Use the first podspec spec name as the podspec name (this is the root spec in the podspec file) - const podSpecName = podSpecsWithHeaderFiles[podspec][0].specName.replace( - '-', - '_', - ); - - if (headerFiles.length > 0) { - // Create a folder for the podspec in the output headers path - const podSpecTargetFolder = path.join(outputHeadersPath, podSpecName); - - // Copy each header file to the podspec folder - copiedHeaderFilesWithPodspecNames[podSpecName] = headerFiles.map( - headerFile => { - const headerFileTargetPath = path.join( - podSpecTargetFolder, - headerFile.target, - ); - createFolderIfNotExists(path.dirname(headerFileTargetPath)); - fs.copyFileSync(headerFile.source, headerFileTargetPath); - return headerFileTargetPath; - }, - ); - - // Create umbrella header file for the podspec - const umbrellaHeaderFilename = path.join( - podSpecTargetFolder, - podSpecName + '-umbrella.h', - ); - - if ( - podSpecName === 'React_Core' || - podSpecName === 'React_RCTAppDelegate' - ) { - if (podSpecName === 'React_Core') { - // Copy the React-umbrella.h file to the umbrella header filename - fs.copyFileSync( - REACT_CORE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } else { - fs.copyFileSync( - RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } - - // Store the umbrella header filename in the umbrellaHeaders object - umbrellaHeaders[podSpecName] = umbrellaHeaderFilename; - } - } - }); - - // Create the module map file using the header files in podSpecsWithHeaderFiles - const moduleMapFile = createModuleMapFile(outputPath); - if (!moduleMapFile) { - frameworkLog( - 'Failed to create module map file. The XCFramework may not work correctly. Stopping.', - 'error', - ); - return; - } + // Copy Symbols to symbols folder + copySymbols(outputPath, frameworkFolders); - // Copy header files and module map file to each platform slice in the XCFramework - copyHeaderFilesToSlices( + // Emit the headers-spec layout into every slice's React.framework and build + // the ReactNativeHeaders headers-only xcframework beside it. This is the only + // header surface consumers compile against — no root Headers/, no clang VFS + // overlay. MUST run before signing (spec R7: the signature pins the manifest). + const { + buildReactNativeHeadersXcframework, + computeSpecPlan, + emitReactFrameworkHeaders, + } = require('./headers-compose'); + const plan = computeSpecPlan(rootFolder); + emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // ReactNativeHeaders is PURE-RN — the third-party deps namespaces ship in + // the ReactNativeDependenciesHeaders sidecar built by the deps prebuild + // (scripts/releases/ios-prebuild), so the core compose no longer needs the + // deps artifact's headers. + // 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 5th arg so the PUBLISHED ReactNativeHeaders carries hermes too. + const headersXcfw = buildReactNativeHeadersXcframework( + path.dirname(outputPath), + plan, rootFolder, - outputPath, - moduleMapFile, - umbrellaHeaders, - copiedHeaderFilesWithPodspecNames, + true, // include the mac-catalyst slice in the real compose ); - // 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 @@ -208,11 +118,21 @@ function buildXCFrameworks( ); frameworkLog('Creating tar file: ' + tarFilePath); try { - execSync( - `tar -czf ${tarFilePath} -C ${path.dirname(outputPath)} React.xcframework`, - { - stdio: 'inherit', - }, + // 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', + [ + '-czf', + tarFilePath, + '-C', + path.dirname(outputPath), + 'React.xcframework', + path.basename(headersXcfw), + ], + {stdio: 'inherit'}, ); } catch (error) { frameworkLog( @@ -220,6 +140,34 @@ function buildXCFrameworks( '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( @@ -277,134 +225,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 { return execSync(`vtool -show-build ${frameworkPath}|grep platform`) diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 78ddc33661b9..3b6d3142b38e 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' @@ -52,6 +54,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 @@ -123,19 +147,25 @@ 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_ios_version_supported) + 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-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" @@ -146,7 +176,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" @@ -164,7 +194,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" @@ -197,7 +227,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) @@ -214,6 +244,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_ios_version_supported) + 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 @@ -569,18 +610,26 @@ def react_native_post_install( ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) if !ReactNativeCoreUtils.build_rncore_from_source() - # In XCode 26 we need to revert the new setting SWIFT_ENABLE_EXPLICIT_MODULES when building - # with precompiled binaries. - ReactNativePodsUtils.set_build_setting(installer, build_setting: "SWIFT_ENABLE_EXPLICIT_MODULES", value: "NO") - - # Process the VFS overlay for prebuilt React Native Core - this is done as part of the post install so - # that we can update paths based on the final location of the Pods installation. - ReactNativeCoreUtils.process_vfs_overlay() - - # Configure xcconfig for prebuilt usage (VFS overlay, header paths, cleanup redundant paths) + # The Xcode-26 SWIFT_ENABLE_EXPLICIT_MODULES=NO workaround (#53457) is removed: + # the modular ReactNativeHeaders layout + React-Core-prebuilt module-map + # activation + header-less facades let the React module precompile cleanly with + # explicit modules ON (verified cold-DD green), so the override is unnecessary. + + # Make the prebuilt React.xcframework headers resolvable from aggregate (main app) + # and third-party pod targets that don't go through add_rncore_dependency. The headers + # are served directly from the xcframework's headers-spec layout — no clang VFS overlay. ReactNativeCoreUtils.configure_aggregate_xcconfig(installer) end + if !ReactNativeDependenciesUtils.build_react_native_deps_from_source() + # Prebuilt-deps mode: make the deps artifact headers (folly/glog/...) + # resolvable from the aggregate and every pod target, mirroring the rncore + # injection above. ReactNativeHeaders is pure-RN, so the flattened + # ReactNativeDependencies/Headers dir is the single global home of the + # third-party namespaces (see scripts/cocoapods/__docs__/prebuilt-deps.md). + ReactNativeDependenciesUtils.configure_aggregate_xcconfig(installer) + end + SPM.apply_on_post_install(installer) if privacy_file_aggregation_enabled diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index 2684d3250b5d..b3d960890dfe 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -99,17 +99,19 @@ function replaceRNCoreConfiguration( throw new Error(`tar extraction failed with exit code ${result.status}`); } - // Verify extraction produced the expected xcframework structure + // Verify extraction produced the expected xcframework structure. The + // module map now lives per-slice inside React.framework, so check the + // xcframework's Info.plist instead of a root Modules/module.modulemap. const xcfwPath = path.join(tmpExtractDir, 'React.xcframework'); - const modulemapPath = path.join(xcfwPath, 'Modules', 'module.modulemap'); - if (!fs.existsSync(modulemapPath)) { + const infoPlistPath = path.join(xcfwPath, 'Info.plist'); + if (!fs.existsSync(infoPlistPath)) { throw new Error( - `Extraction verification failed: ${modulemapPath} not found`, + `Extraction verification failed: ${infoPlistPath} not found`, ); } - // Delete all directories in finalLocation - not files, since we want to - // keep the React-VFS.yaml file + // Delete only directories in finalLocation (e.g. the React.xcframework) - + // not files, so any sibling files written during pod install are preserved. const dirs = fs .readdirSync(finalLocation, {withFileTypes: true}) .filter(dirent => dirent.isDirectory()); @@ -144,6 +146,51 @@ function replaceRNCoreConfiguration( } } } + + // The podspec prepare_command flattens ReactNativeHeaders' headers into a + // top-level Headers/ dir, but it does not re-run on a config swap. Mirror + // it here: re-flatten the headers (identical across slices) and drop the + // now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers + // keeps resolving , , etc. + // + // Fail closed when the swapped-in tarball lacks ReactNativeHeaders: the + // directory purge above already deleted the previous Headers/, so + // continuing silently would leave the injected -fmodule-map-file flag + // dangling and break every include only on a config switch — + // with no pointer to the version-skewed artifact that caused it. + const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework'); + if (!fs.existsSync(rnhXcfw)) { + throw new Error( + `ReactNativeHeaders.xcframework not found in the extracted tarball at ${finalLocation}. ` + + 'The downloaded artifact predates the headers-spec layout (or is incomplete); ' + + 'use a prebuilt tarball matching this react-native version.', + ); + } + const slice = fs + .readdirSync(rnhXcfw, {withFileTypes: true}) + .find( + dirent => + dirent.isDirectory() && + fs.existsSync(path.join(rnhXcfw, dirent.name.toString(), 'Headers')), + ); + if (!slice) { + throw new Error( + `No slice with a Headers directory found inside ${rnhXcfw}.`, + ); + } + const headersDest = path.join(finalLocation, 'Headers'); + fs.rmSync(headersDest, {force: true, recursive: true}); + const cpHeaders = spawnSync( + 'cp', + ['-R', path.join(rnhXcfw, slice.name.toString(), 'Headers'), headersDest], + {stdio: 'inherit'}, + ); + if (cpHeaders.status !== 0) { + throw new Error( + `Flattening ReactNativeHeaders failed with exit code ${cpHeaders.status}`, + ); + } + fs.rmSync(rnhXcfw, {force: true, recursive: true}); } finally { // Clean up temp directory fs.rmSync(tmpDir, {force: true, recursive: true}); diff --git a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec index e08e6c2b0995..a006f2c1e4ca 100644 --- a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec +++ b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec @@ -53,13 +53,13 @@ Pod::Spec.new do |spec| # Check if XCFRAMEWORK_PATH is empty if [ -z "$XCFRAMEWORK_PATH" ]; then echo "ERROR: XCFRAMEWORK_PATH is empty." - exit 0 + exit 1 fi # Check if HEADERS_PATH is empty if [ -z "$HEADERS_PATH" ]; then echo "ERROR: HEADERS_PATH is empty." - exit 0 + exit 1 fi cp -R "$HEADERS_PATH/." Headers diff --git a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm index 2eb7bff28e67..be4ed093ea16 100644 --- a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm +++ b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm @@ -13,7 +13,7 @@ #import #import -#import "RCTFabricComponentsPlugins.h" +#import using namespace facebook::react; 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); } } diff --git a/scripts/releases/ios-prebuild/configuration.js b/scripts/releases/ios-prebuild/configuration.js index 8bf6f5db12aa..3259ac632974 100644 --- a/scripts/releases/ios-prebuild/configuration.js +++ b/scripts/releases/ios-prebuild/configuration.js @@ -136,17 +136,27 @@ const dependencies /*: ReadonlyArray */ = [ }, }, { - name: 'socket-rocket', + name: 'SocketRocket', version: '0.7.1', + // Xcode 26's SwiftPM rejects a public-headers dir where the umbrella + // header (SocketRocket.h) has sibling directories (SocketRocket/Internal): + // "target 'SocketRocket' has invalid header layout". Stage the flat public + // headers into include/ and point publicHeaderFiles there. The artifact's + // Headers/SocketRocket namespace (files.headers) is unaffected, and + // Xcode 16 accepts both layouts. + prepareScript: 'mkdir -p include && cp SocketRocket/*.h include/', url: new URL( 'https://github.com/facebookincubator/SocketRocket/archive/refs/tags/0.7.1.tar.gz', ), files: { - sources: ['SocketRocket/**/*.{h,m}'], + sources: ['SocketRocket/**/*.{h,m}', 'include/*.h'], headers: ['SocketRocket/*.h'], + resources: [ + '../../../scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy', + ], }, settings: { - publicHeaderFiles: './SocketRocket', + publicHeaderFiles: './include', headerSearchPaths: [ './', 'SocketRocket', @@ -254,7 +264,7 @@ const dependencies /*: ReadonlyArray */ = [ 'fmt', 'boost', 'fast_float', - 'socket-rocket', + 'SocketRocket', ], settings: { publicHeaderFiles: './', diff --git a/scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy b/scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy new file mode 100644 index 000000000000..23a9697f383d --- /dev/null +++ b/scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy @@ -0,0 +1,12 @@ + + + + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + +