diff --git a/.github/workflow-scripts/__tests__/microsoft-prebuild-macos-core-test.rb b/.github/workflow-scripts/__tests__/microsoft-prebuild-macos-core-test.rb new file mode 100644 index 000000000000..aa6e7fd6a645 --- /dev/null +++ b/.github/workflow-scripts/__tests__/microsoft-prebuild-macos-core-test.rb @@ -0,0 +1,180 @@ +# 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 'yaml' +require 'minitest/autorun' +require 'tmpdir' +require 'fileutils' +require 'open3' + +class MicrosoftPrebuildMacOSCoreTest < Minitest::Test + ROOT = File.expand_path('../..', __dir__) + WORKFLOW = YAML.load_file(File.join(ROOT, 'workflows/microsoft-prebuild-macos-core.yml')) + TOOLCHAIN = YAML.load_file(File.join(ROOT, 'actions/microsoft-setup-toolchain/action.yml')) + + def steps(job) + WORKFLOW.fetch('jobs').fetch(job).fetch('steps') + end + + def step(job, name) + steps(job).find { |s| s['name'] == name } || raise("Missing step: #{name}") + end + + # Evaluate the workflow's actual expressions for successful jobs. actionlint + # separately validates GitHub expression syntax and context availability. + def expression(value, platform: 'ios-simulator', hit: false, ref: 'refs/heads/main', family: nil) + source = value.sub(/\A\$\{\{\s*/, '').sub(/\s*\}\}\z/, '') + { + 'matrix.platform' => platform, + 'inputs.platform' => family, + 'github.ref' => ref, + 'steps.cache-slice.outputs.cache-hit' => hit.to_s, + 'steps.cache-xcframework.outputs.cache-hit' => hit.to_s + }.each { |token, replacement| source = source.gsub(token, replacement.inspect) } + source = source.gsub('startsWith(', 'starts_with(').gsub('endsWith(', 'ends_with(') + eval(source, binding) # Only checked-in workflow expressions, never external input. + end + + def starts_with(value, prefix) + value.start_with?(prefix) + end + + def ends_with(value, suffix) + value.end_with?(suffix) + end + + def enabled?(step, **context) + !step.key?('if') || !!expression(step['if'], **context) + end + + def test_build_cache_paths_and_header_transfers + platforms = WORKFLOW['jobs']['build']['strategy']['matrix']['platform'] + assert_equal %w[ios ios-simulator macos visionos visionos-simulator], platforms + restore = step('build', 'Restore slice cache')['with'] + save = step('build', 'Save slice cache')['with'] + assert_equal restore['path'], save['path'] + assert_equal '${{ steps.cache-slice.outputs.cache-primary-key }}', save['key'] + assert_match(/\Av2-/, restore['key']) + refute restore.key?('restore-keys') + + %w[Hermes dependency].each do |kind| + upload = step('build', "Upload #{kind} headers") + download = step('compose-xcframework', "Download #{kind} headers") + assert_equal upload['with']['name'], download['with']['name'] + assert_equal upload['with']['path'], download['with']['path'] + assert_includes restore['path'].lines.map(&:strip), upload['with']['path'] + assert_equal 'error', upload['with']['if-no-files-found'] + platforms.product([true, false]).each do |platform, hit| + assert_equal platform == 'ios-simulator', enabled?(upload, platform: platform, hit: hit) + end + end + + platforms.product([true, false]).each do |platform, hit| + %w[Setup\ toolchain Install\ npm\ dependencies Download\ Hermes\ artifacts Setup\ workspace\ (using\ prebuilt\ Hermes)].each do |name| + assert_equal !hit, enabled?(step('build', name), platform: platform, hit: hit) + end + assert enabled?(step('build', 'Upload headers'), platform: platform, hit: hit) + assert enabled?(step('build', 'Upload slice artifacts'), platform: platform, hit: hit) + end + end + + def test_toolchain_family_conditions_cover_simulators_and_compose_sdks + setup = step('build', 'Setup toolchain') + xcode = TOOLCHAIN['runs']['steps'].find { |s| s['name'] == 'Set up Xcode' } + vision = TOOLCHAIN['runs']['steps'].find { |s| s['name'] == 'Download visionOS SDK' } + { 'ios' => 'ios', 'ios-simulator' => 'ios', 'macos' => 'macos', + 'visionos' => 'visionos', 'visionos-simulator' => 'visionos' }.each do |platform, expected| + family = expression(setup['with']['platform'], platform: platform) + assert_equal expected, family + assert enabled?(xcode, family: family) + assert_equal expected == 'visionos', enabled?(vision, family: family) + end + family = step('compose-xcframework', 'Setup toolchain')['with']['platform'] + assert enabled?(xcode, family: family) + assert enabled?(vision, family: family) + script = step('compose-xcframework', 'Verify compose SDKs')['run'] + output, status = Open3.capture2('bash', '-euc', 'xcrun() { printf "%s\n" "$2"; }; ' + script) + assert status.success? + assert_equal %w[iphoneos iphonesimulator macosx xros xrsimulator], output.lines.map(&:strip) + end + + def test_compose_cold_and_cache_hit_paths + job = 'compose-xcframework' + restore = step(job, 'Restore compose cache')['with'] + save = step(job, 'Save compose cache')['with'] + assert_equal restore['path'], save['path'] + assert_equal '${{ steps.cache-xcframework.outputs.cache-primary-key }}', save['key'] + assert_match(/\Av2-/, restore['key']) + refute restore.key?('restore-keys') + %w[.github/workflows/microsoft-prebuild-macos-core.yml headers-include-baseline.json version.properties yarn.lock].each do |input| + assert_includes restore['key'], input + assert_includes step('build', 'Restore slice cache')['with']['key'], input + end + uploads = steps(job).select { |s| s['uses'] == 'actions/upload-artifact@v4' } + assert_equal 3, uploads.length + assert_equal restore['path'].lines.map(&:strip).sort, uploads.map { |s| s['with']['path'] }.sort + [true, false].each do |hit| + steps(job).each do |s| + next if s['uses']&.start_with?('actions/checkout', 'actions/cache/') + expected = s['name'] == 'Verify archive payloads' || uploads.include?(s) || !hit + assert_equal expected, enabled?(s, hit: hit), "#{s['name']}, hit=#{hit}" + end + end + assert_includes step(job, 'Create XCFramework')['run'], '--require-hermes' + assert_equal 'node scripts/ios-prebuild/headers-verify.js --flavor Debug', step(job, 'Verify composed headers (iOS Simulator)')['run'] + names = steps(job).map { |s| s['name'] } + assert_operator names.index('Verify composed headers (iOS Simulator)'), :<, names.index('Name XCFramework archives') + assert_operator names.index('Verify archive payloads'), :<, names.index('Save compose cache') + uploads.each { |s| assert_equal 'error', s['with']['if-no-files-found'] } + end + + def test_cache_save_conditions + { 'build' => 'Save slice cache', 'compose-xcframework' => 'Save compose cache' }.each do |job, name| + %w[refs/heads/main refs/heads/0.87-stable refs/heads/topic refs/pull/1/merge].product([true, false]).each do |ref, hit| + expected = !hit && %w[refs/heads/main refs/heads/0.87-stable].include?(ref) + assert_equal expected, enabled?(step(job, name), hit: hit, ref: ref) + end + end + end + + def test_actual_archive_commands_reject_the_old_payload + Dir.mktmpdir('prebuild-payload-test-') do |dir| + %w[React ReactNativeHeaders].each do |name| + FileUtils.mkdir_p(File.join(dir, "Debug/#{name}.xcframework")) + File.write(File.join(dir, "Debug/#{name}.xcframework/Info.plist"), 'fixture') + end + FileUtils.mkdir_p(File.join(dir, 'Debug/Symbols')) + File.write(File.join(dir, 'Debug/Symbols/fixture.dSYM'), 'fixture') + run = lambda do |script, cwd| + Open3.capture3('bash', '-euc', script, chdir: cwd) + end + assert run.call('tar -czf React.xcframework.tar.gz React.xcframework ReactNativeHeaders.xcframework; tar -czf ReactNativeHeaders.xcframework.tar.gz ReactNativeHeaders.xcframework', File.join(dir, 'Debug')).last.success? + assert run.call(step('compose-xcframework', 'Name XCFramework archives')['run'], dir).last.success? + assert run.call('tar -czf ../../ReactCoreDebug.framework.dSYM.tar.gz .', File.join(dir, 'Debug/Symbols')).last.success? + check = step('compose-xcframework', 'Verify archive payloads')['run'] + assert run.call(check, dir).last.success? + FileUtils.mv(File.join(dir, 'ReactNativeHeadersDebug.xcframework.tar.gz'), File.join(dir, 'headers.saved')) + refute run.call(check, dir).last.success?, 'A missing standalone archive must fail' + FileUtils.mv(File.join(dir, 'headers.saved'), File.join(dir, 'ReactNativeHeadersDebug.xcframework.tar.gz')) + assert run.call('tar -czf ../ReactCoreDebug.xcframework.tar.gz React.xcframework', File.join(dir, 'Debug')).last.success? + refute run.call(check, dir).last.success?, 'The old core-only payload must fail on cache hits too' + end + end + + def test_actual_compose_input_checks_require_hermes_headers + Dir.mktmpdir('prebuild-input-test-') do |dir| + script = step('compose-xcframework', 'Verify downloaded artifacts')['run'] + %w[ + packages/react-native/.build/output/spm/Debug/Build/Products + packages/react-native/.build/headers + packages/react-native/.build/artifacts/hermes/destroot/include/hermes + packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + ].each { |entry| FileUtils.mkdir_p(File.join(dir, entry)) } + refute Open3.capture3('bash', '-euc', script, chdir: dir).last.success? + File.write(File.join(dir, 'packages/react-native/.build/artifacts/hermes/destroot/include/hermes/hermes.h'), 'fixture') + assert Open3.capture3('bash', '-euc', script, chdir: dir).last.success? + end + end +end diff --git a/.github/workflow-scripts/__tests__/prebuild-ios-sidecars-test.rb b/.github/workflow-scripts/__tests__/prebuild-ios-sidecars-test.rb new file mode 100644 index 000000000000..d9e953338100 --- /dev/null +++ b/.github/workflow-scripts/__tests__/prebuild-ios-sidecars-test.rb @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license in the root LICENSE file. + +require 'yaml' +require 'minitest/autorun' +require 'tmpdir' +require 'fileutils' +require 'open3' + +class PrebuildIOSSidecarsTest < Minitest::Test + ROOT = File.expand_path('../..', __dir__) + + def workflow(name) + YAML.load_file(File.join(ROOT, 'workflows', name)) + end + + def steps(name) + workflow(name).fetch('jobs').values.flat_map { |job| job.fetch('steps', []) } + end + + def step(name, title) + steps(name).find { |s| s['name'] == title } || raise(title) + end + + def expand(text, flavor) + text.gsub(/\$\{\{\s*matrix.flavor\s*\}\}/, flavor) + end + + def run_step(name, title, root, flavor) + Open3.capture3('bash', '-euc', expand(step(name, title).fetch('run'), flavor), chdir: root) + end + + def test_standalone_sidecars_are_cached_and_uploaded_for_both_flavors + [ + ['prebuild-ios-core.yml', 'Rename ReactNativeHeaders XCFramework tarball', 'Upload ReactNativeHeaders XCFramework Artifact', 'Save cache if present'], + ['prebuild-ios-dependencies.yml', 'Compress Headers Sidecar XCFramework', 'Upload Headers Sidecar XCFramework Artifact', 'Save XCFramework in Cache'], + ].each do |name, create, upload, save| + assert_includes(step(name, create).fetch('if'), "cache-hit != 'true'") + refute(step(name, upload).key?('if'), 'cache hits must upload too') + upload_path = step(name, upload).fetch('with').fetch('path') + assert_includes(step(name, save).fetch('with').fetch('path').lines.map(&:strip), upload_path) + %w[Debug Release].each do |flavor| + Dir.mktmpdir('ios-sidecar-payload-') do |dir| + if name.include?('core') + stage = File.join(dir, "packages/react-native/.build/output/xcframeworks/#{flavor}") + %w[React ReactNativeHeaders].each do |framework| + FileUtils.mkdir_p(File.join(stage, "#{framework}.xcframework")) + File.write(File.join(stage, "#{framework}.xcframework/Info.plist"), 'fixture') + end + assert(Open3.capture3('tar', '-czf', 'ReactNativeHeaders.xcframework.tar.gz', 'ReactNativeHeaders.xcframework', chdir: stage).last.success?) + assert(run_step(name, 'Compress and Rename XCFramework', dir, flavor).last.success?) + else + stage = File.join(dir, 'packages/react-native/third-party') + %w[ReactNativeDependencies ReactNativeDependenciesHeaders].each do |framework| + FileUtils.mkdir_p(File.join(stage, "#{framework}.xcframework")) + File.write(File.join(stage, "#{framework}.xcframework/Info.plist"), 'fixture') + end + assert(run_step(name, 'Compress and Rename XCFramework', dir, flavor).last.success?) + end + stdout, stderr, status = run_step(name, create, dir, flavor) + assert(status.success?, stdout + stderr) + archive = File.join(dir, expand(upload_path, flavor)) + listing, status = Open3.capture2('tar', '-tzf', archive) + assert(status.success?) + assert_match(/Headers\.xcframework\/Info.plist/, listing) + combined = step(name, 'Upload XCFramework Artifact').fetch('with').fetch('path') + listing, status = Open3.capture2('tar', '-tzf', File.join(dir, expand(combined, flavor))) + assert(status.success?) + assert_match(/Headers\.xcframework\/Info.plist/, listing) + end + end + end + end + + def test_old_incomplete_cache_keys_are_invalidated_consistently + {'prebuild-ios-core.yml' => 'v5-ios-core-xcframework-', 'prebuild-ios-dependencies.yml' => 'v6-ios-dependencies-xcframework-'}.each do |name, prefix| + keys = steps(name).filter_map { |s| s.dig('with', 'key') }.select { |key| key.start_with?(prefix) } + assert_equal(2, keys.length) + assert_equal(keys[0], keys[1]) + end + end +end diff --git a/.github/workflows/microsoft-prebuild-macos-core.yml b/.github/workflows/microsoft-prebuild-macos-core.yml index 2477eb9bc17a..c0ad3f4cd941 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -27,17 +27,20 @@ jobs: id: cache-slice uses: actions/cache/restore@v4 with: - key: v1-macos-core-${{ matrix.platform }}-Debug-${{ 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/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v2-macos-core-${{ matrix.platform }}-Debug-${{ hashFiles('.github/workflows/microsoft-prebuild-macos-core.yml', '.github/workflows/microsoft-resolve-hermes.yml', '.github/actions/microsoft-setup-toolchain/action.yml', '.github/scripts/resolve-hermes.mts', 'yarn.lock', 'packages/react-native/package.json', 'packages/react-native/Package.swift', 'packages/react-native/**/*.podspec', 'packages/react-native/sdks/.hermes*version', 'packages/react-native/sdks/hermes-engine/version.properties', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild/headers-include-baseline.json', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} path: | packages/react-native/.build/output/spm/Debug/Build/Products packages/react-native/.build/headers + packages/react-native/.build/artifacts/hermes/destroot/include + packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers - name: Setup toolchain if: steps.cache-slice.outputs.cache-hit != 'true' uses: ./.github/actions/microsoft-setup-toolchain with: node-version: '22' - platform: ${{ matrix.platform }} + # The shared action selects Xcode/SDKs by platform family. + platform: ${{ startsWith(matrix.platform, 'visionos') && 'visionos' || startsWith(matrix.platform, 'ios') && 'ios' || matrix.platform }} - name: Install npm dependencies if: steps.cache-slice.outputs.cache-hit != 'true' @@ -69,25 +72,47 @@ jobs: run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }} - name: Save slice cache - if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable') }} + if: ${{ steps.cache-slice.outputs.cache-hit != 'true' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable')) }} uses: actions/cache/save@v4 with: - key: v1-macos-core-${{ matrix.platform }}-Debug-${{ 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/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: ${{ steps.cache-slice.outputs.cache-primary-key }} path: | packages/react-native/.build/output/spm/Debug/Build/Products packages/react-native/.build/headers + packages/react-native/.build/artifacts/hermes/destroot/include + packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + + # Transfer the exact headers used by the iOS compile-gate slice, including + # on cache hits. Do not resolve a potentially different dependency here. + - name: Upload Hermes headers + if: matrix.platform == 'ios-simulator' + uses: actions/upload-artifact@v4 + with: + name: prebuild-macos-core-hermes-headers-Debug + path: packages/react-native/.build/artifacts/hermes/destroot/include + if-no-files-found: error + + - name: Upload dependency headers + if: matrix.platform == 'ios-simulator' + uses: actions/upload-artifact@v4 + with: + name: prebuild-macos-core-dependency-headers-Debug + path: packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + if-no-files-found: error - name: Upload headers uses: actions/upload-artifact@v4 with: name: prebuild-macos-core-headers-Debug-${{ matrix.platform }} path: packages/react-native/.build/headers + if-no-files-found: error - name: Upload slice artifacts uses: actions/upload-artifact@v4 with: name: prebuild-macos-core-slice-Debug-${{ matrix.platform }} path: packages/react-native/.build/output/spm/Debug/Build/Products + if-no-files-found: error compose-xcframework: name: 'Compose XCFramework (Debug)' @@ -104,17 +129,25 @@ jobs: id: cache-xcframework uses: actions/cache/restore@v4 with: - key: v1-macos-core-xcframework-Debug-${{ 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/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v2-macos-core-xcframework-Debug-${{ hashFiles('.github/workflows/microsoft-prebuild-macos-core.yml', '.github/workflows/microsoft-resolve-hermes.yml', '.github/actions/microsoft-setup-toolchain/action.yml', '.github/scripts/resolve-hermes.mts', 'yarn.lock', 'packages/react-native/package.json', 'packages/react-native/Package.swift', 'packages/react-native/**/*.podspec', 'packages/react-native/sdks/.hermes*version', 'packages/react-native/sdks/hermes-engine/version.properties', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild/headers-include-baseline.json', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} path: | packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz + packages/react-native/.build/output/xcframeworks/ReactNativeHeadersDebug.xcframework.tar.gz - name: Setup toolchain if: steps.cache-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/microsoft-setup-toolchain with: node-version: '22' - platform: ios + platform: visionos + + - name: Verify compose SDKs + if: steps.cache-xcframework.outputs.cache-hit != 'true' + run: | + for sdk in iphoneos iphonesimulator macosx xros xrsimulator; do + xcrun --sdk "$sdk" --show-sdk-path + done - name: Install npm dependencies if: steps.cache-xcframework.outputs.cache-hit != 'true' @@ -136,24 +169,45 @@ jobs: path: packages/react-native/.build/headers merge-multiple: true + - name: Download Hermes headers + if: steps.cache-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v4 + with: + name: prebuild-macos-core-hermes-headers-Debug + path: packages/react-native/.build/artifacts/hermes/destroot/include + + - name: Download dependency headers + if: steps.cache-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v4 + with: + name: prebuild-macos-core-dependency-headers-Debug + path: packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + - name: Verify downloaded artifacts if: steps.cache-xcframework.outputs.cache-hit != 'true' run: | - echo "=== Products directory ===" - ls -R packages/react-native/.build/output/spm/Debug/Build/Products/ | head -40 - echo "=== Headers directory ===" - ls packages/react-native/.build/headers/ | head -20 + test -d packages/react-native/.build/output/spm/Debug/Build/Products + test -d packages/react-native/.build/headers + test -f packages/react-native/.build/artifacts/hermes/destroot/include/hermes/hermes.h + test -d packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers - name: Create XCFramework if: steps.cache-xcframework.outputs.cache-hit != 'true' working-directory: packages/react-native - run: node scripts/ios-prebuild -c -f Debug + run: node scripts/ios-prebuild -c -f Debug --require-hermes + + - name: Verify composed headers (iOS Simulator) + if: steps.cache-xcframework.outputs.cache-hit != 'true' + working-directory: packages/react-native + run: node scripts/ios-prebuild/headers-verify.js --flavor Debug - - name: Compress XCFramework + - name: Name XCFramework archives if: steps.cache-xcframework.outputs.cache-hit != 'true' + working-directory: packages/react-native/.build/output/xcframeworks run: | - cd packages/react-native/.build/output/xcframeworks/Debug - tar -cz -f ../ReactCoreDebug.xcframework.tar.gz React.xcframework + # The composer already packages React AND ReactNativeHeaders together. + cp Debug/React.xcframework.tar.gz ReactCoreDebug.xcframework.tar.gz + cp Debug/ReactNativeHeaders.xcframework.tar.gz ReactNativeHeadersDebug.xcframework.tar.gz - name: Compress dSYMs if: steps.cache-xcframework.outputs.cache-hit != 'true' @@ -161,14 +215,23 @@ jobs: cd packages/react-native/.build/output/xcframeworks/Debug/Symbols tar -cz -f ../../ReactCoreDebug.framework.dSYM.tar.gz . + - name: Verify archive payloads + working-directory: packages/react-native/.build/output/xcframeworks + run: | + # Also check cache hits before publishing the restored archives. + tar -tzf ReactCoreDebug.xcframework.tar.gz React.xcframework/Info.plist ReactNativeHeaders.xcframework/Info.plist + tar -tzf ReactNativeHeadersDebug.xcframework.tar.gz ReactNativeHeaders.xcframework/Info.plist + tar -tzf ReactCoreDebug.framework.dSYM.tar.gz > /dev/null + - name: Save compose cache - if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable') }} + if: ${{ steps.cache-xcframework.outputs.cache-hit != 'true' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable')) }} uses: actions/cache/save@v4 with: - key: v1-macos-core-xcframework-Debug-${{ 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/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: ${{ steps.cache-xcframework.outputs.cache-primary-key }} path: | packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz + packages/react-native/.build/output/xcframeworks/ReactNativeHeadersDebug.xcframework.tar.gz - name: Upload XCFramework uses: actions/upload-artifact@v4 @@ -176,6 +239,7 @@ jobs: name: ReactCoreDebug.xcframework.tar.gz path: packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz retention-days: 14 + if-no-files-found: error - name: Upload dSYMs uses: actions/upload-artifact@v4 @@ -183,3 +247,12 @@ jobs: name: ReactCoreDebug.framework.dSYM.tar.gz path: packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz retention-days: 14 + if-no-files-found: error + + - name: Upload ReactNativeHeaders + uses: actions/upload-artifact@v4 + with: + name: ReactNativeHeadersDebug.xcframework.tar.gz + path: packages/react-native/.build/output/xcframeworks/ReactNativeHeadersDebug.xcframework.tar.gz + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index db271c4e246d..2d2063615602 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -43,14 +43,8 @@ jobs: - name: Set Hermes version shell: bash run: | - # Non-stable RN builds resolve Hermes from npm's latest-v1 dist-tag. - # TODO: rename 'latest-v1' to 'latest' once V1 is the only Hermes on npm. - # Stable builds use the version pinned in version.properties. - if [ "${{ inputs.use-hermes-prebuilt }}" == "true" ]; then - HERMES_VERSION="latest-v1" - else - HERMES_VERSION=$(sed -n 's/^HERMES_VERSION_NAME=//p' packages/react-native/sdks/hermes-engine/version.properties) - fi + # [macOS] Both slice and compose jobs use the fork's exact SDK metadata. + HERMES_VERSION=$(node -p "require('./packages/react-native/scripts/ios-prebuild/hermes-version').readHermesMetadata('single').version") echo "Using Hermes version: $HERMES_VERSION" echo "HERMES_VERSION=$HERMES_VERSION" >> $GITHUB_ENV - name: Set React Native version @@ -133,7 +127,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/.build/output/xcframeworks - 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/**/*') }} + key: v5-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 @@ -175,6 +169,21 @@ jobs: tar -xzf /tmp/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz -C /tmp/third-party/ mkdir -p packages/react-native/third-party/ mv /tmp/third-party/packages/react-native/third-party/ReactNativeDependencies.xcframework packages/react-native/third-party/ReactNativeDependencies.xcframework + - name: Set Hermes version + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + shell: bash + run: | + # [macOS] Match the slice job's SDK metadata, not a live npm dist-tag. + HERMES_VERSION=$(node -p "require('./packages/react-native/scripts/ios-prebuild/hermes-version').readHermesMetadata('single').version") + echo "Using Hermes version: $HERMES_VERSION" + echo "HERMES_VERSION=$HERMES_VERSION" >> $GITHUB_ENV + - name: Stage Hermes headers + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + working-directory: packages/react-native + env: + FLAVOR: ${{ matrix.flavor }} + run: | + node -e "require('./scripts/ios-prebuild/hermes').prepareHermesArtifactsAsync(require('./package.json').version, process.env.FLAVOR).then(()=>process.exit(0)).catch(e=>{console.error(e);process.exit(1)})" - name: Setup Keychain if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} uses: apple-actions/import-codesign-certs@v3 # https://github.com/marketplace/actions/import-code-signing-certificates @@ -185,12 +194,12 @@ jobs: if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT == '' }} run: | cd packages/react-native - node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" + node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-hermes' || '' }} - name: Create and Sign XCFramework if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} run: | cd packages/react-native - node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" + node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" ${{ inputs.version-type != '' && '--require-hermes' || '' }} - name: Verify composed headers if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | @@ -200,7 +209,7 @@ jobs: # 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 }}" + node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-stamped-version' || '' }} - name: Compress and Rename XCFramework if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | @@ -214,6 +223,16 @@ jobs: run: | cd packages/react-native/.build/output/xcframeworks/${{matrix.flavor}}/Symbols tar -cz -f ../../ReactCore${{ matrix.flavor }}.framework.dSYM.tar.gz . + - name: Rename ReactNativeHeaders XCFramework tarball + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + run: | + 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 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: Upload XCFramework Artifact uses: actions/upload-artifact@v6 with: @@ -231,4 +250,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: 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/**/*') }} + packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz + key: v5-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 a61426b0feb3..deb0a4fdb501 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: 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') }} + key: v6-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' @@ -166,6 +166,16 @@ jobs: 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/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: 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: Show Symbol folder content if: steps.restore-xcframework.outputs.cache-hit != 'true' run: ls -lR packages/react-native/third-party/Symbols @@ -192,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: 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') }} + key: v6-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/__docs__/README.md b/__docs__/README.md index dd25a3fd248d..6ce5cdddef3e 100644 --- a/__docs__/README.md +++ b/__docs__/README.md @@ -80,6 +80,7 @@ TODO: Explain the different components of React Native at a high level. - Build system - Android - iOS + - [SwiftPM](../packages/react-native/scripts/spm/__docs__/README.md) - C++ - JavaScript - Metro diff --git a/packages/react-native/Libraries/Animated/AnimatedEvent.js b/packages/react-native/Libraries/Animated/AnimatedEvent.js index 3f1245e3fd27..29647ce76051 100644 --- a/packages/react-native/Libraries/Animated/AnimatedEvent.js +++ b/packages/react-native/Libraries/Animated/AnimatedEvent.js @@ -199,7 +199,7 @@ export class AnimatedEvent { this._attachedEvent && this._attachedEvent.detach(); } - __getHandler(): any | ((...args: any) => void) { + __getHandler(): (...args: any) => void { if (this.__isNative) { if (__DEV__) { let validatedMapping = false; diff --git a/packages/react-native/Libraries/Animated/AnimatedImplementation.js b/packages/react-native/Libraries/Animated/AnimatedImplementation.js index 4a9d76a4806a..7fc01bb27249 100644 --- a/packages/react-native/Libraries/Animated/AnimatedImplementation.js +++ b/packages/react-native/Libraries/Animated/AnimatedImplementation.js @@ -576,10 +576,14 @@ function unforkEventImpl( } } -const eventImpl = function ( +// NOTE: With `useNativeDriver: true` this returns an `AnimatedEvent` instance +// rather than a callable handler. That object is only ever meant to be handed +// straight back to an animated component's event prop, so the declared type +// describes the handler shape both branches are consumed as. +const eventImpl: ( argMapping: ReadonlyArray, config: EventConfig, -): any { +) => (...args: Array) => void = function (argMapping, config): any { const animatedEvent = new AnimatedEvent(argMapping, config); if (animatedEvent.__isNative) { return animatedEvent; diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js index b10fe9da8bee..d4e2d7db8d8b 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js @@ -13,7 +13,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; import invariant from 'invariant'; -type ValueListenerCallback = (state: {value: number, ...}) => unknown; +export type ValueListenerCallback = (state: {value: number}) => unknown; export type AnimatedNodeConfig = Readonly<{ debugID?: string, diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index 76dba2196f48..c393e27cf3ea 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -17,7 +17,7 @@ import type { InterpolationConfigType, } from './AnimatedInterpolation'; import type AnimatedNode from './AnimatedNode'; -import type {AnimatedNodeConfig} from './AnimatedNode'; +import type {AnimatedNodeConfig, ValueListenerCallback} from './AnimatedNode'; import type AnimatedTracking from './AnimatedTracking'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; @@ -138,7 +138,7 @@ export default class AnimatedValue extends AnimatedWithChildren { } } - addListener(callback: (value: any) => unknown): string { + addListener(callback: ValueListenerCallback): string { const id = super.addListener(callback); this._listenerCount++; if (this.__isNative) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt index 0288966c6fa7..b767f1657e8f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt @@ -87,11 +87,15 @@ public open class IntentModule(reactContext: ReactApplicationContext) : override fun onHostResume() { reactApplicationContext.removeLifecycleEventListener(this) synchronized(this@IntentModule) { - for (pendingPromise in pendingOpenURLPromises) { + // getInitialURL can re-enter and re-add to pendingOpenURLPromises when the activity + // is still null at resume, so drain a snapshot (after clearing the list and listener) + // to avoid mutating the list being iterated (ConcurrentModificationException). + val pendingPromises = ArrayList(pendingOpenURLPromises) + pendingOpenURLPromises.clear() + initialURLListener = null + for (pendingPromise in pendingPromises) { getInitialURL(pendingPromise) } - initialURLListener = null - pendingOpenURLPromises.clear() } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt new file mode 100644 index 000000000000..6528449969bd --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.modules.intent + +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IntentModuleTest { + + private lateinit var context: ReactApplicationContext + private lateinit var intentModule: IntentModule + + @Before + fun setUp() { + context = mock() + intentModule = IntentModule(context) + } + + /** + * Regression test for the ConcurrentModificationException thrown from onHostResume. When the + * current activity is still null at resume time, draining pendingOpenURLPromises re-enters + * getInitialURL -> waitForActivityAndGetInitialURL, which adds back into the same list. Before + * the fix this mutated the list mid-iteration and crashed. The synchronized guard does not help: + * the re-entrancy is on the same thread and already holds the lock. + */ + @Test + fun getInitialURL_onHostResumeWithNullActivity_doesNotThrowAndPreservesPromise() { + // No current activity: getInitialURL queues the promise and registers a lifecycle listener. + whenever(context.currentActivity).thenReturn(null) + + val promise = SimplePromise() + intentModule.getInitialURL(promise) + + val listenerCaptor = argumentCaptor() + verify(context).addLifecycleEventListener(listenerCaptor.capture()) + + // Resume while the activity is still null (e.g. a deep link landing mid activity-transition). + // The drain re-queues the promise; before the fix this threw ConcurrentModificationException. + assertThatCode { listenerCaptor.firstValue.onHostResume() }.doesNotThrowAnyException() + + // The promise was re-queued rather than silently dropped: a fresh listener is registered for + // the next resume, and the promise is left pending (neither resolved nor rejected). + verify(context, times(2)).addLifecycleEventListener(any()) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.rejected).isEqualTo(0) + } + + internal class SimplePromise : Promise { + companion object { + private const val ERROR_DEFAULT_CODE = "EUNSPECIFIED" + private const val ERROR_DEFAULT_MESSAGE = "Error not specified." + } + + var resolved = 0 + private set + + var rejected = 0 + private set + + var value: Any? = null + private set + + var errorCode: String? = null + private set + + var errorMessage: String? = null + private set + + override fun resolve(value: Any?) { + resolved++ + this.value = value + } + + override fun reject(code: String?, message: String?) { + reject(code, message, null, null) + } + + override fun reject(code: String?, throwable: Throwable?) { + reject(code, null, throwable, null) + } + + override fun reject(code: String?, message: String?, throwable: Throwable?) { + reject(code, message, throwable, null) + } + + override fun reject(throwable: Throwable) { + reject(null, null, throwable, null) + } + + override fun reject(throwable: Throwable, userInfo: WritableMap) { + reject(null, null, throwable, userInfo) + } + + override fun reject(code: String?, userInfo: WritableMap) { + reject(code, null, null, userInfo) + } + + override fun reject(code: String?, throwable: Throwable?, userInfo: WritableMap) { + reject(code, null, throwable, userInfo) + } + + override fun reject(code: String?, message: String?, userInfo: WritableMap) { + reject(code, message, null, userInfo) + } + + override fun reject( + code: String?, + message: String?, + throwable: Throwable?, + userInfo: WritableMap?, + ) { + rejected++ + + errorCode = code ?: ERROR_DEFAULT_CODE + errorMessage = message ?: throwable?.message ?: ERROR_DEFAULT_MESSAGE + } + + @Deprecated("Method deprecated", ReplaceWith("reject(code, message)")) + override fun reject(message: String) { + reject(null, message, null, null) + } + } +} diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp index a6e2d5da63f6..f3022e786838 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp @@ -64,13 +64,16 @@ tracing::HostTracingProfile HostTargetTraceRecording::stop() { auto startTime = *startTime_; startTime_.reset(); - return tracing::HostTracingProfile{ - .processId = oscompat::getCurrentProcessId(), - .startTime = startTime, - .frameTimings = frameTimings_.pruneExpiredAndExtract(), - .instanceTracingProfiles = std::move(state.instanceTracingProfiles), - .runtimeSamplingProfiles = std::move(state.runtimeSamplingProfiles), - }; + // Member-wise assignment instead of designated initializers: + // HostTracingProfile declares its special members (move-only) and is no + // longer an aggregate. + tracing::HostTracingProfile profile; + profile.processId = oscompat::getCurrentProcessId(); + profile.startTime = startTime; + profile.frameTimings = frameTimings_.pruneExpiredAndExtract(); + profile.instanceTracingProfiles = std::move(state.instanceTracingProfiles); + profile.runtimeSamplingProfiles = std::move(state.runtimeSamplingProfiles); + return profile; } void HostTargetTraceRecording::recordFrameTimings( diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h index fd32cb3c2257..aa37579fc696 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h @@ -23,9 +23,22 @@ namespace facebook::react::jsinspector_modern::tracing { * messages. */ struct HostTracingProfile { + HostTracingProfile() = default; + + // Explicitly move-only: FrameTimingSequence and RuntimeSamplingProfile are + // not copyable, so the implicit copy constructor is ill-formed the moment it + // is instantiated. Plain C++ never instantiates it, but Swift's C++ interop + // does when these headers are reached from an imported module, turning it + // into a hard compile error (Xcode 26.3). + HostTracingProfile(const HostTracingProfile &) = delete; + HostTracingProfile &operator=(const HostTracingProfile &) = delete; + HostTracingProfile(HostTracingProfile &&) = default; + HostTracingProfile &operator=(HostTracingProfile &&) = default; + ~HostTracingProfile() = default; + // The ID of the OS-level process that this Trace Recording is associated // with. - ProcessId processId; + ProcessId processId{}; // The timestamp at which this Trace Recording started. HighResTimeStamp startTime; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h index db0acc40b76c..2a3187c7610a 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h @@ -31,6 +31,17 @@ struct TraceRecordingState { { } + // Explicitly move-only: RuntimeSamplingProfile is not copyable, so the + // implicit copy constructor is ill-formed the moment it is instantiated. + // Plain C++ never instantiates it, but Swift's C++ interop (ClangImporter) + // does when a consumer imports these headers as part of a module, turning + // it into a hard compile error (Xcode 26.3). + TraceRecordingState(const TraceRecordingState &) = delete; + TraceRecordingState &operator=(const TraceRecordingState &) = delete; + TraceRecordingState(TraceRecordingState &&) = default; + TraceRecordingState &operator=(TraceRecordingState &&) = default; + ~TraceRecordingState() = default; + // The mode of this Trace Recording. tracing::Mode mode; diff --git a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp index da40ea3b7899..603bd0d69d5a 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp @@ -198,10 +198,6 @@ jsi::Value NativeDOM::getParentNode( } auto shadowNode = getShadowNode(rt, nativeNodeReference); - if (isRootShadowNode(*shadowNode)) { - // The parent of the root node is the document. - return jsi::Value{shadowNode->getSurfaceId()}; - } auto currentRevision = getCurrentShadowTreeRevision(rt, shadowNode->getSurfaceId()); @@ -209,6 +205,16 @@ jsi::Value NativeDOM::getParentNode( return jsi::Value::undefined(); } + // The parent of the surface's root node is the document. Only the actual + // root node qualifies: nested nodes that carry the `RootNodeKind` trait + // (e.g. , portals/overlays) still have a real parent in the shadow + // tree and must report it. Otherwise capture/bubble event propagation is + // silently severed at that boundary (a listener on an ancestor rendered + // above the modal would never receive descendant focus/blur, etc.). + if (ShadowNode::sameFamily(*currentRevision, *shadowNode)) { + return jsi::Value{shadowNode->getSurfaceId()}; + } + auto parentShadowNode = dom::getParentNode(currentRevision, *shadowNode); if (parentShadowNode == nullptr) { return jsi::Value::undefined(); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 166c49668f6b..2a8bd38e8887 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<0c587b3473ab5141a7ef4b6302b1f98a>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -249,7 +249,7 @@ declare const event: typeof $$AnimatedImplementation.event declare const eventImpl: ( argMapping: ReadonlyArray, config: EventConfig, -) => any +) => (...args: Array) => void declare const findNodeHandle: typeof $$ReactFabric.findNodeHandle declare const flatten: typeof $$flattenStyle declare const forkEvent: typeof $$AnimatedImplementation.forkEvent @@ -1487,7 +1487,7 @@ declare class AnimatedTracking_default extends AnimatedNode_default { update(): void } declare class AnimatedValue_default extends AnimatedWithChildren_default { - addListener(callback: (value: any) => unknown): string + addListener(callback: ValueListenerCallback): string animate( animation: Animation_default, callback: EndCallback | null | undefined, @@ -1759,11 +1759,11 @@ declare interface ButtonProps { readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onBlur?: (e: BlurEvent) => void readonly onFocus?: (e: FocusEvent_2) => void + readonly onPress?: (event?: GestureResponderEvent) => unknown readonly testID?: string readonly title: string readonly tooltip?: string readonly touchSoundDisabled?: boolean - readonly onPress?: (event?: GestureResponderEvent) => unknown } declare function cancelHeadlessTask(taskId: number, taskKey: string): void declare type Category = string @@ -1790,9 +1790,9 @@ declare type CellRendererProps = { readonly children: React_2.ReactNode readonly index: number readonly item: ItemT - readonly style: StyleProp_2 readonly onFocusCapture?: (event: FocusEvent_3) => void readonly onLayout?: (event: LayoutChangeEvent_2) => void + readonly style: StyleProp_2 } declare class CellRenderMask { addCells(cells: { first: number; last: number }): void @@ -2886,12 +2886,12 @@ declare type InterpolationConfigType< OutputT extends InterpolationConfigSupportedOutputType, > = Readonly< AnimatedNodeConfig & { + easing?: (input: number) => number extrapolate?: ExtrapolateType extrapolateLeft?: ExtrapolateType extrapolateRight?: ExtrapolateType inputRange: ReadonlyArray outputRange: ReadonlyArray - easing?: (input: number) => number } > declare type IOSKeyboardEvent = Readonly< @@ -3275,10 +3275,10 @@ declare type MacOSViewProps = { readonly enableFocusRing?: boolean readonly inverted?: boolean readonly mouseDownCanMoveWindow?: boolean - readonly tooltip?: string readonly onDragEnter?: (event: DragEvent_3) => void readonly onDragLeave?: (event: DragEvent_3) => void readonly onDrop?: (event: DragEvent_3) => void + readonly tooltip?: string } declare type Mapping = | AnimatedValue_default @@ -3677,6 +3677,14 @@ declare type OptionalFlatListProps = { end: number start: number } + getItemLayout?: ( + data: Readonly> | undefined, + index: number, + ) => { + index: number + length: number + offset: number + } horizontal?: boolean initialNumToRender?: number initialScrollIndex?: number @@ -3687,14 +3695,6 @@ declare type OptionalFlatListProps = { removeClippedSubviews?: boolean renderItem?: ListRenderItem strictMode?: boolean - getItemLayout?: ( - data: Readonly> | undefined, - index: number, - ) => { - index: number - length: number - offset: number - } } declare type OptionalPlatformSelectSpec = { [key in PlatformOSType]?: T } declare type OptionalSectionListProps = { @@ -3711,6 +3711,14 @@ declare type OptionalVirtualizedListProps = { debug?: boolean disableVirtualization?: boolean extraData?: any + getItemLayout?: ( + data: any, + index: number, + ) => { + index: number + length: number + offset: number + } horizontal?: boolean initialNumToRender?: number initialScrollIndex?: number @@ -3744,33 +3752,17 @@ declare type OptionalVirtualizedListProps = { refreshing?: boolean removeClippedSubviews?: boolean renderItem?: ListRenderItem + renderScrollComponent?: (props: ScrollViewProps_2) => React_2.JSX.Element updateCellsBatchingPeriod?: number viewabilityConfig?: ViewabilityConfig viewabilityConfigCallbackPairs?: Array windowSize?: number - getItemLayout?: ( - data: any, - index: number, - ) => { - index: number - length: number - offset: number - } - renderScrollComponent?: (props: ScrollViewProps_2) => React_2.JSX.Element } declare type OptionalVirtualizedSectionListProps< ItemT, SectionT = DefaultVirtualizedSectionT, > = { onEndReached?: ($$PARAM_0$$: { distanceFromEnd: number }) => void - renderSectionFooter?: (info: { - section: SectionT - }) => null | React_2.ReactNode - renderSectionHeader?: (info: { - section: SectionT - }) => null | React_2.ReactNode - SectionSeparatorComponent?: React_2.ComponentType - stickySectionHeadersEnabled?: boolean renderItem?: (info: { index: number isSelected: boolean | undefined @@ -3782,6 +3774,14 @@ declare type OptionalVirtualizedSectionListProps< updateProps: (select: "leading" | "trailing", newProps: Object) => void } }) => null | React_2.ReactNode + renderSectionFooter?: (info: { + section: SectionT + }) => null | React_2.ReactNode + renderSectionHeader?: (info: { + section: SectionT + }) => null | React_2.ReactNode + SectionSeparatorComponent?: React_2.ComponentType + stickySectionHeadersEnabled?: boolean } declare type OrientationChangeEvent = { readonly orientation: "landscape" | "portrait" @@ -4046,15 +4046,15 @@ declare type PressabilityConfig = { readonly pressRectOffset?: RectOrSize } declare type PressabilityEventHandlers = { - readonly onBlur: (event: BlurEvent) => void - readonly onClick: (event: GestureResponderEvent) => void - readonly onFocus: (event: FocusEvent_2) => void readonly onKeyDown?: (event: KeyDownEvent) => void readonly onKeyUp?: (event: KeyUpEvent) => void readonly onMouseEnter?: (event: MouseEvent_2) => void readonly onMouseLeave?: (event: MouseEvent_2) => void readonly onPointerEnter?: (event: PointerEvent_2) => void readonly onPointerLeave?: (event: PointerEvent_2) => void + readonly onBlur: (event: BlurEvent) => void + readonly onClick: (event: GestureResponderEvent) => void + readonly onFocus: (event: FocusEvent_2) => void readonly onResponderGrant: (event: GestureResponderEvent) => boolean | void readonly onResponderMove: (event: GestureResponderEvent) => void readonly onResponderRelease: (event: GestureResponderEvent) => void @@ -4091,6 +4091,9 @@ declare type PressableBaseProps = { readonly keyUpEvents?: Array readonly mouseDownCanMoveWindow?: boolean readonly onBlur?: (event: BlurEvent) => void + readonly onDragEnter?: (event: DragEvent_3) => void + readonly onDragLeave?: (event: DragEvent_3) => void + readonly onDrop?: (event: DragEvent_3) => void readonly onFocus?: (event: FocusEvent_2) => void readonly onHoverIn?: (event: MouseEvent_2) => unknown readonly onHoverOut?: (event: MouseEvent_2) => unknown @@ -4112,9 +4115,6 @@ declare type PressableBaseProps = { readonly tooltip?: string readonly validKeysDown?: ReadonlyArray readonly validKeysUp?: ReadonlyArray - readonly onDragEnter?: (event: DragEvent_3) => void - readonly onDragLeave?: (event: DragEvent_3) => void - readonly onDrop?: (event: DragEvent_3) => void } declare type PressableInstance = HostInstance declare interface PressableProps @@ -4805,6 +4805,14 @@ declare type ScrollViewBaseProps = { readonly autoscrollToTopThreshold?: number readonly minIndexForVisible: number } + readonly onContentSizeChange?: ( + contentWidth: number, + contentHeight: number, + ) => void + readonly onKeyboardDidHide?: (event: KeyboardEvent_2) => void + readonly onKeyboardDidShow?: (event: KeyboardEvent_2) => void + readonly onKeyboardWillHide?: (event: KeyboardEvent_2) => void + readonly onKeyboardWillShow?: (event: KeyboardEvent_2) => void readonly onMomentumScrollBegin?: (event: ScrollEvent) => void readonly onMomentumScrollEnd?: (event: ScrollEvent) => void readonly onScroll?: (event: ScrollEvent) => void @@ -4825,14 +4833,6 @@ declare type ScrollViewBaseProps = { readonly StickyHeaderComponent?: StickyHeaderComponentType readonly stickyHeaderHiddenOnScroll?: boolean readonly stickyHeaderIndices?: ReadonlyArray - readonly onContentSizeChange?: ( - contentWidth: number, - contentHeight: number, - ) => void - readonly onKeyboardDidHide?: (event: KeyboardEvent_2) => void - readonly onKeyboardDidShow?: (event: KeyboardEvent_2) => void - readonly onKeyboardWillHide?: (event: KeyboardEvent_2) => void - readonly onKeyboardWillShow?: (event: KeyboardEvent_2) => void } declare type ScrollViewComponentStatics = { readonly Context: typeof $$ScrollViewContext @@ -4913,13 +4913,13 @@ declare type ScrollViewPropsIOS = { readonly indicatorStyle?: "black" | "default" | "white" readonly maximumZoomScale?: number readonly minimumZoomScale?: number + readonly onScrollToTop?: (event: ScrollEvent) => void readonly pinchGestureEnabled?: boolean readonly scrollIndicatorInsets?: EdgeInsetsProp readonly scrollsToTop?: boolean readonly scrollToOverflowEnabled?: boolean readonly showsHorizontalScrollIndicator?: boolean readonly zoomScale?: number - readonly onScrollToTop?: (event: ScrollEvent) => void } declare interface ScrollViewScrollToOptions { animated?: boolean @@ -4943,6 +4943,10 @@ declare type SectionBase< data: ReadonlyArray ItemSeparatorComponent?: React_2.ComponentType | React_2.JSX.Element key?: string + keyExtractor?: ( + item: SectionItemT | undefined, + index?: number | undefined, + ) => string renderItem?: (info: { index: number item: SectionItemT @@ -4953,10 +4957,6 @@ declare type SectionBase< updateProps: (select: "leading" | "trailing", newProps: Object) => void } }) => null | React_2.JSX.Element - keyExtractor?: ( - item: SectionItemT | undefined, - index?: number | undefined, - ) => string } declare class SectionList< ItemT = any, @@ -5107,6 +5107,17 @@ declare type ShareOptions = { } declare interface Spec extends TurboModule { readonly blur?: (reactTag: number) => void + readonly focus?: (reactTag: number) => void + readonly getConstantsForViewManager?: ( + viewManagerName: string, + ) => Object | undefined + readonly getDefaultEventTypes?: () => Array + readonly lazilyLoadView?: (name: string) => Object + readonly sendAccessibilityEvent?: ( + reactTag: number, + eventType: number, + ) => void + readonly setLayoutAnimationEnabledExperimental?: (enabled: boolean) => void readonly clearJSResponder: () => void readonly configureNextLayoutAnimation: ( config: Object, @@ -5135,13 +5146,7 @@ declare interface Spec extends TurboModule { height: number, ) => void, ) => void - readonly focus?: (reactTag: number) => void readonly getConstants: () => Object - readonly getConstantsForViewManager?: ( - viewManagerName: string, - ) => Object | undefined - readonly getDefaultEventTypes?: () => Array - readonly lazilyLoadView?: (name: string) => Object readonly manageChildren: ( containerTag: number, moveFromIndices: Array, @@ -5174,16 +5179,11 @@ declare interface Spec extends TurboModule { height: number, ) => void, ) => void - readonly sendAccessibilityEvent?: ( - reactTag: number, - eventType: number, - ) => void readonly setChildren: (containerTag: number, reactTags: Array) => void readonly setJSResponder: ( reactTag: number, blockNativeResponder: boolean, ) => void - readonly setLayoutAnimationEnabledExperimental?: (enabled: boolean) => void readonly updateView: ( reactTag: number, viewName: string, @@ -5755,11 +5755,11 @@ declare type TextInputMacOSProps_2 = { readonly onGrammarCheckChange?: (e: SettingChangeEvent) => unknown readonly onKeyDown?: (e: KeyDownEvent) => unknown readonly onKeyUp?: (e: KeyUpEvent) => unknown + readonly onPaste?: (event: PasteEvent_2) => void readonly onSpellCheckChange?: (e: SettingChangeEvent) => unknown readonly pastedTypes?: PastedTypesType readonly submitKeyEvents?: ReadonlyArray readonly tooltip?: string - readonly onPaste?: (event: PasteEvent_2) => void } declare interface TextInputProps extends Readonly< @@ -5857,6 +5857,7 @@ declare type TimingAnimationConfig = Readonly< AnimationConfig & { delay?: number duration?: number + easing?: (value: number) => number toValue: | AnimatedColor_default | AnimatedInterpolation_default @@ -5868,7 +5869,6 @@ declare type TimingAnimationConfig = Readonly< readonly x: number readonly y: number } - easing?: (value: number) => number } > declare type ToastAndroid = typeof ToastAndroid @@ -6021,12 +6021,12 @@ declare type TouchableWithoutFeedbackPropsIOS = { acceptsFirstMouse?: boolean draggedTypes?: DraggedTypesType enableFocusRing?: boolean - tooltip?: string onDragEnter?: (event: DragEvent_3) => void onDragLeave?: (event: DragEvent_3) => void onDrop?: (event: DragEvent_3) => void onMouseEnter?: (event: MouseEvent_2) => void onMouseLeave?: (event: MouseEvent_2) => void + tooltip?: string } declare type TouchEventProps = { readonly onTouchCancel?: (e: GestureResponderEvent) => void @@ -6095,6 +6095,7 @@ declare type UTFSequence = typeof UTFSequence declare type Value = null | { horizontal: boolean } +declare type ValueListenerCallback = (state: { value: number }) => unknown declare type ValueOfUnion = T extends any ? K extends keyof T ? T[K] @@ -6496,22 +6497,22 @@ export { AccessibilityActionEvent, // a0d4daa0 AccessibilityActionInfo, // db47a917 AccessibilityInfo, // 489ed53e - AccessibilityProps, // afa87704 + AccessibilityProps, // 9f493a69 AccessibilityRole, // 3cf8c6c5 AccessibilityState, // b0c2b3f7 AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // c5864b68 + ActivityIndicator, // 01c581b6 ActivityIndicatorInstance, // a82dd4e7 - ActivityIndicatorProps, // dd6f004c - Alert, // 24958ab5 + ActivityIndicatorProps, // 07ba31c5 + Alert, // 8968797d AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 - AlertOptions, // 39b16cfa + AlertOptions, // 702d616d AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 43096550 + Animated, // 49261655 AppConfig, // e4e0157b AppRegistry, // b1f0909a AppState, // 12012be5 @@ -6525,9 +6526,9 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // 4ba4f941 BoxShadowValue, // b679703f - Button, // 8e70b924 - ButtonInstance, // 5983c48f - ButtonProps, // 69a911f2 + Button, // dbbcc9b2 + ButtonInstance, // 5f7f9dc3 + ButtonProps, // d0ebae51 Clipboard, // 41addb89 CodegenTypes, // ab4986cc ColorSchemeName, // 6615edd6 @@ -6547,9 +6548,9 @@ export { DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb DragEvent_2 as DragEvent, // e161690c - DrawerLayoutAndroid, // 80ee3edb + DrawerLayoutAndroid, // 15c5cf79 DrawerLayoutAndroidInstance, // c0694352 - DrawerLayoutAndroidProps, // 5884eb5e + DrawerLayoutAndroidProps, // ae1d2b83 DrawerSlideEvent, // c4ab8fba DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -6567,39 +6568,39 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // 51c07d46 - FlatListInstance, // af242689 - FlatListProps, // 94c0827c + FlatList, // c8e5b2dc + FlatListInstance, // 587fff05 + FlatListProps, // 399083a0 FocusEvent_2 as FocusEvent, // 30b0dd35 FontVariant, // 7c7558bb GestureResponderEvent, // 3bd4697e - GestureResponderHandlers, // 3f4b3d6f + GestureResponderHandlers, // a4627cef HostComponent, // 74c10001 HostInstance, // f78dcaf8 I18nManager, // f9870e00 IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // 2a05118e - ImageBackground, // 43405e02 - ImageBackgroundInstance, // 4b6f798d - ImageBackgroundProps, // fdf47df6 + Image, // b95813c0 + ImageBackground, // 3e7207b2 + ImageBackgroundInstance, // e529f671 + ImageBackgroundProps, // 4451775e ImageErrorEvent, // 978933f4 ImageInstance, // 9a100753 ImageLoadEvent, // 77f0b718 ImageProgressEventIOS, // 445331a4 - ImageProps, // cea58952 - ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // 13761fda - ImagePropsIOS, // 4a080668 + ImageProps, // 579bd2be + ImagePropsAndroid, // ee00e1d5 + ImagePropsBase, // c84d4a43 + ImagePropsIOS, // 9e19c85d ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 ImageResolvedAssetSource, // f3060931 ImageSize, // 1c47cf88 - ImageSource, // 48c7f316 - ImageSourcePropType, // bfb5e5c6 + ImageSource, // ea31cf4a + ImageSourcePropType, // f522e093 ImageStyle, // ccf7055c - ImageURISource, // 016eb083 + ImageURISource, // 443d047c InputAccessoryView, // 29eefece InputAccessoryViewProps, // 2aa4ba24 InputModeOptions, // 4e8581b9 @@ -6608,9 +6609,9 @@ export { KeyEvent, // 2a18a436 KeyUpEvent, // ef64af4c Keyboard, // 12be6986 - KeyboardAvoidingView, // 84c12855 - KeyboardAvoidingViewInstance, // 5fcd1820 - KeyboardAvoidingViewProps, // a5e139b7 + KeyboardAvoidingView, // 05fed9b6 + KeyboardAvoidingViewInstance, // d72b03f8 + KeyboardAvoidingViewProps, // fc8095fc KeyboardEvent_2 as KeyboardEvent, // da6563cf KeyboardEventEasing, // af4091c8 KeyboardEventName, // 5564dd77 @@ -6635,12 +6636,12 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // eee9076c - ModalBaseProps, // 0de93395 + Modal, // f13bc1b4 + ModalBaseProps, // 0590b6e9 ModalInstance, // d466ce77 - ModalProps, // e855ab04 + ModalProps, // c215d9be ModalPropsAndroid, // 515fb173 - ModalPropsIOS, // 0e13cfcc + ModalPropsIOS, // 664ecb7e ModeChangeEvent, // f64bf69d MouseEvent_2 as MouseEvent, // 8bc148bf NativeAppEventEmitter, // 08d4c47d @@ -6659,10 +6660,10 @@ export { Networking, // bbc5be42 OpaqueColorValue, // 25f3fa5b PackagerAsset, // d1c88cf4 - PanResponder, // 17de9d78 - PanResponderCallbacks, // 4ee3d101 + PanResponder, // 7a455cf3 + PanResponderCallbacks, // 47ced9e9 PanResponderGestureState, // 54baf558 - PanResponderInstance, // b3afacbe + PanResponderInstance, // 90afdf2e PasteEvent, // c030c607 Permission, // 08f1c82f PermissionStatus, // 4b7de97b @@ -6674,17 +6675,17 @@ export { PlatformSelectSpec, // 09ed7758 PointValue, // 69db075f PointerEvent_2 as PointerEvent, // f47675d8 - PressabilityConfig, // 8df202e3 - PressabilityEventHandlers, // e7987b00 - Pressable, // 1ba7d4c7 + PressabilityConfig, // 73a23e9b + PressabilityEventHandlers, // c32b063c + Pressable, // b85e2d3a PressableAndroidRippleConfig, // ee32eaca PressableInstance, // eebfe911 - PressableProps, // 6f3e9ea7 + PressableProps, // 660a956d PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // debc6d5f + ProgressBarAndroid, // 48ba17d4 ProgressBarAndroidInstance, // ab545ef1 - ProgressBarAndroidProps, // eba62243 + ProgressBarAndroidProps, // b57747f8 PublicRootInstance, // 8040afd7 PublicTextInstance, // 6937c7bf PushNotificationEventName, // 84e7e150 @@ -6692,10 +6693,10 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 83571873 - RefreshControlInstance, // 92a44a91 - RefreshControlProps, // 28551311 - RefreshControlPropsAndroid, // 99f64c97 + RefreshControl, // 14791bbc + RefreshControlInstance, // 7b174fa4 + RefreshControlProps, // db5daf37 + RefreshControlPropsAndroid, // 8ac931ca RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d ResponderSyntheticEvent, // ebfa7f48 @@ -6706,26 +6707,26 @@ export { RootViewStyleProvider, // 6a15dcfc Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 1d65b30b + SafeAreaView, // 4db3c6d4 SafeAreaViewInstance, // 21dba39c ScaledSize, // 07e417c7 ScrollEvent, // 10f01ee3 - ScrollResponderType, // 51719b64 + ScrollResponderType, // d9133bdb ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // 32aa0aec - ScrollViewImperativeMethods, // efff3f67 - ScrollViewInstance, // 7421bd9f - ScrollViewProps, // a23b9a17 - ScrollViewPropsAndroid, // 44210553 - ScrollViewPropsIOS, // 19a1147f + ScrollView, // c63f4e1f + ScrollViewImperativeMethods, // 288bc3d3 + ScrollViewInstance, // 970f388f + ScrollViewProps, // a3eabe47 + ScrollViewPropsAndroid, // 02f3df2e + ScrollViewPropsIOS, // aa75a790 ScrollViewScrollToOptions, // 3313411e - SectionBase, // 59bc8181 - SectionList, // dfab96ef - SectionListData, // 1cc17c56 - SectionListInstance, // 3762c418 - SectionListProps, // 3ed37362 - SectionListRenderItem, // ed5edaa8 - SectionListRenderItemInfo, // e9d32915 + SectionBase, // 3e9b3640 + SectionList, // 8a3e7d8a + SectionListData, // 06038ecb + SectionListInstance, // 844cdabe + SectionListProps, // 56833392 + SectionListRenderItem, // 97cbfdbc + SectionListRenderItemInfo, // 66827d30 Separators, // 6a45f7e3 Settings, // 2be0c61e Share, // e4591b32 @@ -6734,75 +6735,75 @@ export { ShareActionSheetIOSOptions, // eff574f5 ShareContent, // 7c627896 ShareOptions, // 800c3a4e - StatusBar, // abc3fd90 + StatusBar, // c1aafc4e StatusBarAnimation, // 7fd047e6 - StatusBarInstance, // 31445769 - StatusBarProps, // bfedeab1 + StatusBarInstance, // ac2ee2c6 + StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // 3145ad8d + StyleSheet, // 3d36407f SubmitBehavior, // c4ddf490 - Switch, // 7b1086fa + Switch, // d22e6b0f SwitchChangeEvent, // 899635b1 SwitchInstance, // 3c50eec5 - SwitchProps, // 64074d79 + SwitchProps, // 19335708 SystemEffectMacOS, // d026159b Systrace, // 626d178c TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text_2 as Text, // af6f8150 + Text_2 as Text, // 327ffb24 TextContentType, // 239b3ecc - TextInput, // b7828daa - TextInputAndroidProps, // 7109938a + TextInput, // 58423a21 + TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 TextInputContentSizeChangeEvent, // a27cd32a TextInputEndEditingEvent, // e690b56b TextInputFocusEvent, // 51668a1e - TextInputIOSProps, // e8905f3e + TextInputIOSProps, // 0b56e3e3 TextInputInstance, // 9d5cf2e6 TextInputKeyPressEvent, // 546c5d07 - TextInputMacOSProps, // 4a7a4b02 - TextInputProps, // 2bf4840f + TextInputMacOSProps, // e1585d56 + TextInputProps, // 8356fd8c TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 TextLayoutEvent, // 3f54186f - TextProps, // 96b3535c + TextProps, // 646ae33e TextStyle, // 694c5ffb ToastAndroid, // 88a8969a - TouchableHighlight, // d2d261ca + TouchableHighlight, // bc72f77a TouchableHighlightInstance, // b510c0eb - TouchableHighlightProps, // 9abae613 - TouchableNativeFeedback, // 205c3d42 - TouchableNativeFeedbackInstance, // 284d8dc6 - TouchableNativeFeedbackProps, // e2bb1c8e - TouchableOpacity, // 13b593b5 + TouchableHighlightProps, // 631c5cec + TouchableNativeFeedback, // 406004d7 + TouchableNativeFeedbackInstance, // 1319896f + TouchableNativeFeedbackProps, // 0b603dd4 + TouchableOpacity, // 6e8ffc07 TouchableOpacityInstance, // b186055b - TouchableOpacityProps, // 69b78d84 - TouchableWithoutFeedback, // 5647bbf2 - TouchableWithoutFeedbackProps, // b36f07bb + TouchableOpacityProps, // 18ef30ce + TouchableWithoutFeedback, // 8234d9b8 + TouchableWithoutFeedbackProps, // 0c126762 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 - UIManager, // a1a7cc01 + UIManager, // afbcdf05 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 467d00f2 + View, // a0da551d ViewInstance, // ffde5573 - ViewProps, // 2a62eb65 - ViewPropsAndroid, // ca64ec97 + ViewProps, // e5a9d4c1 + ViewPropsAndroid, // 6d1dbdcd ViewPropsIOS, // 58ee19bf - ViewPropsMacOS, // a39358a0 + ViewPropsMacOS, // 9f0f0ce7 ViewStyle, // 2fc81116 VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e VirtualizedListInstance, // f900740a - VirtualizedListProps, // 37d62457 + VirtualizedListProps, // 320d789e VirtualizedSectionList, // 9fd9cd61 VirtualizedSectionListInstance, // d7fa75b4 - VirtualizedSectionListProps, // 8e1a346d + VirtualizedSectionListProps, // b24dcb2a WrapperComponentProvider, // 147416ab codegenNativeCommands, // 628a7c0a codegenNativeComponent, // cf7eb9a8 @@ -6810,10 +6811,10 @@ export { processColor, // 6e877698 registerCallableModule, // 839c8cfe requireNativeComponent, // f133b4a3 - useAnimatedColor, // e3511f81 - useAnimatedValue, // b18adb63 - useAnimatedValueXY, // c7ee2332 + useAnimatedColor, // 31a919f9 + useAnimatedValue, // 3eb9d3c0 + useAnimatedValueXY, // b434ca0f useColorScheme, // d585efdb - usePressability, // a33fa4bf + usePressability, // 60418f41 useWindowDimensions, // bb4b683f } diff --git a/packages/react-native/__typetests__/macos-public-api.ts b/packages/react-native/__typetests__/macos-public-api.ts index 7c3f1a7e5f03..2397c0fc7f2b 100644 --- a/packages/react-native/__typetests__/macos-public-api.ts +++ b/packages/react-native/__typetests__/macos-public-api.ts @@ -8,6 +8,7 @@ */ // Use the actual package export conditions, without the react-native test alias. +import {Animated} from 'react-native-macos'; import type { SystemEffectMacOS, TextInputMacOSProps, @@ -28,6 +29,22 @@ import type { } from '../Libraries/Types/CoreEventTypes'; type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type AnimatedEventIsNotAny = Assert< + IsAny> extends false ? true : false +>; +const animatedValue = new Animated.Value(0); +const animatedHandler: (...args: any[]) => void = Animated.event( + [{nativeEvent: {value: animatedValue}}], + {useNativeDriver: false}, +); +animatedValue.addListener(state => { + const value: number = state.value; + // @ts-expect-error The callback payload is typed rather than any. + const invalidValue: string = state.value; + // @ts-expect-error The callback exposes only its numeric value. + state.missing; +}); type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true diff --git a/packages/react-native/package.json b/packages/react-native/package.json index fe3db74bffb0..5ddc8b9ca9a7 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -43,7 +43,10 @@ "types": null, "default": "./Libraries/*.js" }, - "./scripts/*": "./scripts/*", + "./scripts/*": "./scripts/*.js", + "./scripts/codegen/generate-artifacts-executor": "./scripts/codegen/generate-artifacts-executor/index.js", + "./scripts/*.sh": "./scripts/*.sh", + "./scripts/*.rb": "./scripts/*.rb", "./asset-registry": { "types": null, "default": "./src/asset-registry.js" diff --git a/packages/react-native/react-native.config.js b/packages/react-native/react-native.config.js index 17b48efd225e..0528de4431f8 100644 --- a/packages/react-native/react-native.config.js +++ b/packages/react-native/react-native.config.js @@ -192,6 +192,11 @@ const spmCommand /*: Command */ = { name: '--skipCodegen', description: '[advanced] Skip the react-native codegen step.', }, + { + name: '--configCommand ', + description: + '[advanced] JSON argv array for the autolinking config command; persisted for later syncs.', + }, ], func: async (argv, _config, args) => { const passthrough /*: Array */ = []; @@ -204,6 +209,7 @@ const spmCommand /*: Command */ = { ['xcodeproj', '--xcodeproj'], ['artifacts', '--artifacts'], ['download', '--download'], + ['configCommand', '--config-command'], ]; for (const [key, flag] of stringOpts) { if (args[key] != null) { diff --git a/packages/react-native/scripts/__tests__/replace-rncore-version-test.js b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js new file mode 100644 index 000000000000..ad077aa93ddf --- /dev/null +++ b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js @@ -0,0 +1,220 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {replaceRNCoreConfiguration} = require('../replace-rncore-version'); +const {execFileSync} = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const VERSION = '0.87.0-test'; +const SLICE = 'ios-arm64_x86_64-simulator'; +const BINARY = path.join(SLICE, 'React.framework', 'React'); +const SCRIPT = require.resolve('../replace-rncore-version.js'); +const MARKER = path.join('React-Core-prebuilt', '.last_build_configuration'); + +// Runs the script the way the "[RNCore] Replace React Native Core for the right +// configuration" build phase does, from Pods/ and through the CLI entry point. +function runScriptPhase(podsRoot, configuration) { + return execFileSync( + process.execPath, + [SCRIPT, '-c', configuration, '-r', VERSION, '-p', podsRoot], + {cwd: podsRoot, encoding: 'utf8'}, + ); +} + +function writeFile(filePath, contents) { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, contents); +} + +function buildTarball(podsRoot, configuration) { + const stage = fs.mkdtempSync(path.join(podsRoot, `stage-${configuration}-`)); + writeFile(path.join(stage, 'React.xcframework', 'Info.plist'), ''); + writeFile( + path.join(stage, 'React.xcframework', BINARY), + `binary-${configuration}`, + ); + // The tarball must also ship ReactNativeHeaders: without it the pre-fix code + // throws its fail-closed error before reaching the module map, so the + // regression test below would pass for the wrong reason. + writeFile( + path.join( + stage, + 'ReactNativeHeaders.xcframework', + SLICE, + 'Headers', + 'module.modulemap', + ), + 'module yoga {}\n', + ); + const artifacts = path.join(podsRoot, 'ReactNativeCore-artifacts'); + fs.mkdirSync(artifacts, {recursive: true}); + execFileSync('tar', [ + '-czf', + path.join( + artifacts, + `reactnative-core-${VERSION.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`, + ), + '-C', + stage, + '.', + ]); + fs.rmSync(stage, {recursive: true, force: true}); +} + +describe('replaceRNCoreConfiguration', () => { + let podsRoot; + let pod; + let cwd; + + beforeEach(() => { + podsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rncore-test-')); + pod = path.join(podsRoot, 'React-Core-prebuilt'); + // What the podspec prepare_command leaves behind after `pod install`. + writeFile( + path.join(pod, 'Headers', 'module.modulemap'), + 'module yoga {}\n', + ); + writeFile(path.join(pod, 'React.xcframework', 'Info.plist'), ''); + writeFile(path.join(pod, 'React.xcframework', BINARY), 'binary-Debug'); + buildTarball(podsRoot, 'Release'); + cwd = process.cwd(); + // The script phase runs with Pods/ as its working directory. + process.chdir(podsRoot); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(podsRoot, {recursive: true, force: true}); + }); + + it('installs the framework for the requested configuration', () => { + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect( + fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'), + ).toBe('binary-Release'); + }); + + // Regression test for #57803: recreating the module map mid-build lets a + // concurrent dependency scan miss it, and the React module then precompiles + // without -fmodule-map-file and fails on non-modular includes. + it('leaves Headers/module.modulemap untouched', () => { + const moduleMap = path.join(pod, 'Headers', 'module.modulemap'); + const before = fs.statSync(moduleMap).ino; + const contentsBefore = fs.readFileSync(moduleMap, 'utf8'); + + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect(fs.statSync(moduleMap).ino).toBe(before); + expect(fs.readFileSync(moduleMap, 'utf8')).toBe(contentsBefore); + }); + + // The swap used to purge every directory and restore this file by hand. It + // now replaces React.xcframework alone, so the file is never disturbed. + it('leaves an Expo-generated React-use-frameworks.modulemap in place', () => { + const expoModuleMap = path.join(pod, 'React-use-frameworks.modulemap'); + writeFile(expoModuleMap, 'module React {}\n'); + + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect(fs.readFileSync(expoModuleMap, 'utf8')).toBe('module React {}\n'); + }); + + // Regression tests for #57598. The marker used to be written only after the + // framework had already been replaced, so a build cancelled in between left it + // naming a flavor that was no longer on disk. Every later build for that + // flavor then took the "nothing to do" path and linked against the other + // configuration's core, which fails with undefined C++ symbols and which a + // clean does not undo because the pod directory survives it. + describe('marker bookkeeping', () => { + const marker = () => path.join(podsRoot, MARKER); + const binary = () => + fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'); + + it('records the configuration when it skips a fresh install', () => { + // `pod install` leaves the debug flavor and no marker, so a Debug build + // has nothing to swap. It still has to write down what is on disk, + // otherwise the state stays implicit and stays unverifiable. + expect(fs.existsSync(marker())).toBe(false); + + runScriptPhase(podsRoot, 'Debug'); + + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + expect(binary()).toBe('binary-Debug'); + }); + + it('invalidates the marker before it touches the framework', () => { + fs.writeFileSync(marker(), 'Debug'); + // Drop the tarball so the Release run fails once it is already under way, + // standing in for a build cancelled part way through the swap. + fs.rmSync( + path.join( + podsRoot, + 'ReactNativeCore-artifacts', + `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`, + ), + ); + + expect(() => runScriptPhase(podsRoot, 'Release')).toThrow(); + + // The marker must no longer claim Debug: the framework may already have + // been swapped, and a Debug build that trusts it would silently skip. + expect(fs.readFileSync(marker(), 'utf8')).not.toBe('Debug'); + }); + + it('replaces the framework when the marker shows an unfinished swap', () => { + buildTarball(podsRoot, 'Debug'); + // A swap that was interrupted: the Release flavor is on disk and the + // marker never got its final value. + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + fs.writeFileSync(marker(), 'in-progress'); + expect(binary()).toBe('binary-Release'); + + runScriptPhase(podsRoot, 'Debug'); + + expect(binary()).toBe('binary-Debug'); + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + }); + + it('still skips when the marker already matches the configuration', () => { + fs.writeFileSync(marker(), 'Release'); + const before = binary(); + + const output = runScriptPhase(podsRoot, 'Release'); + + expect(output).toContain('No need to replace React-Core-prebuilt'); + expect(binary()).toBe(before); + }); + }); + + it('fails when the tarball has no React.xcframework', () => { + const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-')); + writeFile(path.join(stage, 'unrelated.txt'), 'nope'); + execFileSync('tar', [ + '-czf', + path.join( + podsRoot, + 'ReactNativeCore-artifacts', + `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`, + ), + '-C', + stage, + '.', + ]); + + expect(() => + replaceRNCoreConfiguration('Release', VERSION, podsRoot), + ).toThrow(/Extraction verification failed/); + }); +}); diff --git a/packages/react-native/scripts/__tests__/script-exports-test.js b/packages/react-native/scripts/__tests__/script-exports-test.js new file mode 100644 index 000000000000..073f4d77f388 --- /dev/null +++ b/packages/react-native/scripts/__tests__/script-exports-test.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +const {execFileSync} = require('child_process'); +const path = require('path'); + +test('Node resolves extensionless script exports, including the fork codegen directory', () => { + // Test native Node package conditions rather than the Jest resolver's map. + execFileSync(process.execPath, [ + '-e', + ` + const assert = require('assert/strict'); + const req = require('module').createRequire(${JSON.stringify(path.resolve(__dirname, '../../package.json'))}); + for (const [name, suffix] of [ + ['setup-apple-spm', '/setup-apple-spm.js'], + ['codegen/generate-artifacts-executor', '/codegen/generate-artifacts-executor/index.js'], + ['react-native-xcode.sh', '/react-native-xcode.sh'], + ['react_native_pods.rb', '/react_native_pods.rb'], + ]) { + assert(req.resolve('react-native-macos/scripts/' + name).endsWith(suffix)); + } + assert.throws(() => req.resolve('react-native-macos/scripts/setup-apple-spm.js')); + `, + ]); +}); diff --git a/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb b/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb new file mode 100644 index 000000000000..854a3ecefa60 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb @@ -0,0 +1,89 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "test/unit" +require "shellwords" +require_relative "../rndependencies.rb" +require_relative "./test_utils/SpecMock.rb" + +class RNDependenciesTests < Test::Unit::TestCase + + # A pod that exports a Swift compatibility header ships this path, and the + # directory name contains spaces. + SWIFT_HEADER = "${PODS_CONFIGURATION_BUILD_DIR}/MyPod/Swift Compatibility Header" + + def teardown + ReactNativeDependenciesUtils.class_variable_set(:@@build_from_source, true) + end + + # Xcode joins an array setting with spaces, then splits it back on + # whitespace while honouring quotes. This is what the compiler ends up with. + def resolved_paths(xcconfig) + value = xcconfig["HEADER_SEARCH_PATHS"] + Shellwords.shellsplit(value.is_a?(Array) ? value.join(" ") : value) + end + + # ================================== # + # TEST - append_header_search_paths # + # ================================== # + + def test_appendHeaderSearchPaths_whenUnset_quotesTheAddedPaths + xcconfig = {} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["\"$(PODS_ROOT)/glog\""], xcconfig["HEADER_SEARCH_PATHS"]) + end + + def test_appendHeaderSearchPaths_whenStringHasQuotedPathWithSpaces_keepsItIntact + xcconfig = {"HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/DoubleConversion\" \"#{SWIFT_HEADER}\""} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["$(PODS_ROOT)/DoubleConversion", SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + def test_appendHeaderSearchPaths_whenArrayHasQuotedPathWithSpaces_keepsItIntact + xcconfig = {"HEADER_SEARCH_PATHS" => ["\"$(PODS_ROOT)/DoubleConversion\"", "\"#{SWIFT_HEADER}\""]} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["$(PODS_ROOT)/DoubleConversion", SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + def test_appendHeaderSearchPaths_whenCalledTwice_doesNotDuplicateEntries + xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal([SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + # ======================================= # + # TEST - add_rn_third_party_dependencies # + # ======================================= # + + def test_addRNThirdPartyDependencies_whenBuildingFromSource_keepsQuotedPathWithSpaces + spec = SpecMock.new + spec.pod_target_xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + add_rn_third_party_dependencies(spec) + + paths = resolved_paths(spec.pod_target_xcconfig) + assert_equal(SWIFT_HEADER, paths.first) + assert(paths.include?("$(PODS_ROOT)/RCT-Folly")) + end + + def test_addRNThirdPartyDependencies_whenUsingPrebuiltDeps_keepsQuotedPathWithSpaces + ReactNativeDependenciesUtils.class_variable_set(:@@build_from_source, false) + spec = SpecMock.new + spec.pod_target_xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + add_rn_third_party_dependencies(spec) + + assert_equal([SWIFT_HEADER, "$(PODS_ROOT)/ReactNativeDependencies/Headers"], resolved_paths(spec.pod_target_xcconfig)) + end +end diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 00b9327871e4..d8586b10f9cf 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -568,6 +568,7 @@ def self.add_prebuilt_header_search_paths(attributes, headers_search_path) # Quoted so a $(PODS_ROOT) containing spaces stays a single clang argument. module_map_flag = " \"-fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap\"" ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", module_map_flag) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", module_map_flag) ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc" + module_map_flag) end end diff --git a/packages/react-native/scripts/cocoapods/rndependencies.rb b/packages/react-native/scripts/cocoapods/rndependencies.rb index a88fa6e483f9..e5ce5a6551fa 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -6,7 +6,6 @@ require "json" require 'net/http' require 'rexml/document' -require 'shellwords' require_relative './utils.rb' @@ -34,38 +33,26 @@ def add_rn_third_party_dependencies(s) s.dependency "RCT-Folly/Fabric" end - header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] - - if header_search_paths.is_a?(String) - header_search_paths = Shellwords.shellsplit(header_search_paths) - end - - header_search_paths << "$(PODS_ROOT)/glog" - header_search_paths << "$(PODS_ROOT)/boost" - header_search_paths << "$(PODS_ROOT)/DoubleConversion" - header_search_paths << "$(PODS_ROOT)/fast_float/include" - header_search_paths << "$(PODS_ROOT)/fmt/include" - header_search_paths << "$(PODS_ROOT)/SocketRocket" - header_search_paths << "$(PODS_ROOT)/RCT-Folly" - - # uniq so a second call on the same spec can't duplicate entries. - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq + ReactNativeDependenciesUtils.append_header_search_paths(current_pod_target_xcconfig, [ + "$(PODS_ROOT)/glog", + "$(PODS_ROOT)/boost", + "$(PODS_ROOT)/DoubleConversion", + "$(PODS_ROOT)/fast_float/include", + "$(PODS_ROOT)/fmt/include", + "$(PODS_ROOT)/SocketRocket", + "$(PODS_ROOT)/RCT-Folly", + ]) else # Prebuilt-deps mode: this pod SELF-SERVES the third-party headers from its # own xcframework (incl. SocketRocket - sole supplier in this mode). See # scripts/cocoapods/__docs__/prebuilt-deps.md for the full contract. s.dependency "ReactNativeDependencies" - header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] - if header_search_paths.is_a?(String) - header_search_paths = Shellwords.shellsplit(header_search_paths) - end # Artifact headers are flattened into the pod-local Headers/ by the podspec # prepare_command (see __docs__/prebuilt-deps.md). - header_search_paths << "$(PODS_ROOT)/ReactNativeDependencies/Headers" - - # uniq so a second call on the same spec can't duplicate entries. - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq + ReactNativeDependenciesUtils.append_header_search_paths(current_pod_target_xcconfig, [ + "$(PODS_ROOT)/ReactNativeDependencies/Headers", + ]) end s.pod_target_xcconfig = current_pod_target_xcconfig @@ -148,6 +135,25 @@ def self.setup_react_native_dependencies(react_native_path, react_native_version end end + # Xcode splits HEADER_SEARCH_PATHS on whitespace, so every path we add is + # quoted - PODS_ROOT can expand to a directory with spaces in its name. + # Paths already in the xcconfig are left untouched: they carry the podspec + # author's own quoting, and re-quoting them would break it. + def self.append_header_search_paths(xcconfig, paths) + quoted = paths.map { |path| "\"#{path}\"" } + existing = xcconfig["HEADER_SEARCH_PATHS"] + + # reject so a second call on the same spec can't duplicate entries. + case existing + when nil + xcconfig["HEADER_SEARCH_PATHS"] = quoted + when Array + xcconfig["HEADER_SEARCH_PATHS"] = existing + quoted.reject { |path| existing.include?(path) } + else + xcconfig["HEADER_SEARCH_PATHS"] = ([existing] + quoted.reject { |path| existing.include?(path) }).join(" ") + end + end + def self.abort_if_use_local_rndeps_with_no_file() if !File.exist?(ENV["RCT_USE_LOCAL_RN_DEP"]) abort("RCT_USE_LOCAL_RN_DEP is set to #{ENV["RCT_USE_LOCAL_RN_DEP"]} but the file does not exist!") diff --git a/packages/react-native/scripts/ios-prebuild.js b/packages/react-native/scripts/ios-prebuild.js index 1109b99df299..7079821cc2e9 100644 --- a/packages/react-native/scripts/ios-prebuild.js +++ b/packages/react-native/scripts/ios-prebuild.js @@ -77,6 +77,7 @@ async function main() { frameworkPaths, buildType, cli.identity, + cli.requireHermes, ); } diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index c411934c1742..70411582212b 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -138,7 +138,13 @@ 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. + module map). CocoaPods consumes that layout directly, through + `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly: the + XCFramework is not a member of the Swift package graph, so the consumer side + stages a copy of `React.framework/Headers` into + `ReactHeadersTarget/include/React` and rewrites `framework module React` to a + plain `module React`, vended as the `ReactHeaders` target (see + `spm-header-paths-contract.md` in the SwiftPM docs). - `ReactNativeHeaders.xcframework` — headers-only; carries every other namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the `React-Core-prebuilt` pod (headers flattened onto the header search path). 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 index 80ff6d3253b6..868019d04e42 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -10,11 +10,22 @@ 'use strict'; +import type {HeadersSpecPlan} from '../headers-spec'; + +const resources = require('../framework-resources'); const { COMPOSE_TOOLING_FILES, + buildReactNativeHeadersXcframework, composeToolingHash, + emitReactFrameworkHeaders, + ensureHeadersLayout, } = require('../headers-compose'); +const inventory = require('../headers-inventory'); +const spec = require('../headers-spec'); +const xcframework = require('../headers-xcframework'); +const childProcess = require('child_process'); const fs = require('fs'); +const os = require('os'); const path = require('path'); describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', () => { @@ -40,6 +51,81 @@ describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', }); }); +test('only the version header uses the built overlay on iOS and macOS slices', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stamped-headers-')); + const rnRoot = path.join(root, 'source'); + const overlay = path.join(root, 'built'); + const xcfw = path.join(root, 'React.xcframework'); + const versionSource = 'React/Base/ReactNativeVersion.h'; + const tracingSource = + 'ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h'; + const sentinel = '#define REACT_NATIVE_VERSION_MAJOR 1000\n'; + const stamped = + '#define REACT_NATIVE_VERSION_MAJOR 0\n#define REACT_NATIVE_VERSION_MINOR 87\n'; + for (const [dir, name, text] of [ + [rnRoot, versionSource, sentinel], + [overlay, versionSource, stamped], + [rnRoot, tracingSource, '// current move-only tracing definition\n'], + [overlay, tracingSource, '// stale copyable tracing definition\n'], + ]) { + fs.mkdirSync(path.dirname(path.join(dir, name)), {recursive: true}); + fs.writeFileSync(path.join(dir, name), text); + } + const slices = ['ios-arm64', 'macos-arm64_x86_64']; + for (const slice of slices) { + fs.mkdirSync(path.join(xcfw, slice, 'React.framework'), {recursive: true}); + } + const plan: HeadersSpecPlan = { + react: [ + { + relPath: 'ReactNativeVersion.h', + source: versionSource, + naturalPath: 'React/ReactNativeVersion.h', + }, + { + relPath: 'TraceRecordingState.h', + source: tracingSource, + naturalPath: 'jsinspector-modern/tracing/TraceRecordingState.h', + }, + ], + reactNativeHeaders: [], + depsNamespaces: [], + umbrella: [], + namespaceModules: {}, + namespaceUmbrellas: [], + privateReactHeaders: {modular: [], textual: []}, + collisions: [], + }; + try { + emitReactFrameworkHeaders(xcfw, plan, rnRoot, overlay); + for (const slice of slices) { + const headers = path.join(xcfw, slice, 'React.framework', 'Headers'); + expect( + fs.readFileSync(path.join(headers, 'ReactNativeVersion.h'), 'utf8'), + ).toBe(stamped); + expect( + fs.readFileSync(path.join(headers, 'TraceRecordingState.h'), 'utf8'), + ).toBe('// current move-only tracing definition\n'); + } + expect(fs.readFileSync(path.join(rnRoot, versionSource), 'utf8')).toBe( + sentinel, + ); + emitReactFrameworkHeaders(xcfw, plan, rnRoot); + expect( + fs.readFileSync( + path.join( + xcfw, + slices[0], + 'React.framework/Headers/ReactNativeVersion.h', + ), + 'utf8', + ), + ).toBe(sentinel); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +}); + describe('composeToolingHash', () => { test('returns a 64-char hex sha256 digest', () => { const hash = composeToolingHash(); @@ -49,7 +135,6 @@ describe('composeToolingHash', () => { test('header sidecar composition uses the binary macOS slice instead of iOS defaults', () => { const emitter = require('../headers-xcframework'); - const os = require('os'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'macos-headers-compose-')); const slices = [ {name: 'macos', sdk: 'macosx', targets: ['arm64-apple-macosx11.0']}, @@ -62,18 +147,20 @@ test('header sidecar composition uses the binary macOS slice instead of iOS defa .mockReturnValue(path.join(root, 'ReactNativeHeaders.xcframework')); // No compiler or xcodebuild is needed: this checks the platform handoff. try { - const {buildReactNativeHeadersXcframework} = require('../headers-compose'); buildReactNativeHeadersXcframework( root, { + react: [], reactNativeHeaders: [], + depsNamespaces: [], + umbrella: [], namespaceUmbrellas: [], namespaceModules: {}, + privateReactHeaders: {modular: [], textual: []}, + collisions: [], }, root, - false, - null, - '/binary/React.xcframework', + emitter.stubSlicesFromXcframework('/binary/React.xcframework'), ); expect(recipe).toHaveBeenCalledWith('/binary/React.xcframework'); expect(compose).toHaveBeenCalledWith( @@ -88,3 +175,138 @@ test('header sidecar composition uses the binary macOS slice instead of iOS defa fs.rmSync(root, {recursive: true, force: true}); } }); + +describe('binary-derived header sidecars', () => { + let tmp = ''; + const plan: HeadersSpecPlan = { + react: [], + reactNativeHeaders: [], + umbrella: [], + namespaceUmbrellas: [], + namespaceModules: {}, + depsNamespaces: [], + collisions: [], + privateReactHeaders: {modular: [], textual: []}, + }; + const writeBinary = (name: string, platform: string) => { + const dir = path.join(tmp, name + '.xcframework'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'Info.plist'), + JSON.stringify({ + AvailableLibraries: [ + {SupportedPlatform: platform, SupportedArchitectures: ['arm64']}, + ], + }), + ); + return dir; + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'header-slices-test-')); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest + .spyOn(inventory, 'computeInventory') + .mockReturnValue({headers: [], collisions: []}); + jest.spyOn(spec, 'planFromInventory').mockReturnValue(plan); + jest.spyOn(resources, 'buildReactPrivacyManifest').mockReturnValue(null); + jest.spyOn(resources, 'collectLprojDirs').mockReturnValue([]); + jest + .spyOn(childProcess, 'execFileSync') + .mockImplementation((command, args) => { + if (command === 'plutil') { + return fs.readFileSync(args[args.length - 1]); + } + if (command === '/bin/cp') { + fs.cpSync(args[1], args[2], {recursive: true}); + return Buffer.from(''); + } + throw new Error(`Unexpected native command: ${command}`); + }); + jest + .spyOn(xcframework, 'composeHeadersOnlyXcframework') + .mockImplementation((out, name) => { + const dir = path.join(out, name + '.xcframework'); + fs.mkdirSync(dir, {recursive: true}); + return dir; + }); + jest + .spyOn(xcframework, 'buildDepsHeadersXcframework') + .mockImplementation(out => { + const dir = path.join( + out, + 'ReactNativeDependenciesHeaders.xcframework', + ); + fs.mkdirSync(dir, {recursive: true}); + return dir; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + test('passes the supplied slices unchanged to the RN emitter', () => { + const slices = [ + {name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}, + ]; + buildReactNativeHeadersXcframework(tmp, plan, tmp, slices); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenCalledWith( + tmp, + 'ReactNativeHeaders', + expect.any(String), + slices, + ); + }); + + test('consumer derives each sidecar from its own binary and refreshes changed deps slices', () => { + writeBinary('React', 'macos'); + const deps = writeBinary('ReactNativeDependencies', 'xros'); + const out = path.join(tmp, 'composed'); + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenLastCalledWith( + out, + 'ReactNativeHeaders', + expect.any(String), + [{name: 'macos', sdk: 'macosx', targets: ['arm64-apple-macosx11.0']}], + ); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenLastCalledWith( + out, + path.join(deps, 'Headers'), + [], + [{name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}], + ); + + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenCalledTimes(1); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenCalledTimes(1); + + writeBinary('ReactNativeDependencies', 'ios'); + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenLastCalledWith( + out, + path.join(deps, 'Headers'), + [], + [{name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}], + ); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenCalledTimes(2); + }); + + test.each(['React', 'ReactNativeDependencies'])( + 'rejects invalid %s metadata before emitting either sidecar', + name => { + writeBinary('React', 'macos'); + writeBinary('ReactNativeDependencies', 'xros'); + fs.writeFileSync( + path.join(tmp, name + '.xcframework', 'Info.plist'), + '{}', + ); + expect(() => + ensureHeadersLayout(tmp, tmp, path.join(tmp, 'composed')), + ).toThrow(/non-empty AvailableLibraries/); + expect(xcframework.composeHeadersOnlyXcframework).not.toHaveBeenCalled(); + expect(xcframework.buildDepsHeadersXcframework).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-conditional-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-conditional-test.js new file mode 100644 index 000000000000..0893a6875451 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-conditional-test.js @@ -0,0 +1,365 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license in the root LICENSE file. + * @format + * @noflow + */ + +const { + combineSchemasInFileList, +} = require('../../../../react-native-codegen/src/cli/combine/combine-js-to-schema'); +const { + generate, +} = require('../../../../react-native-codegen/src/generators/RNCodegen'); +const headers = require('../headers'); +const { + buildInventory, + computeInventory, + scanHeader, +} = require('../headers-inventory'); +const { + collectIncludeHealth, + diffAgainstBaseline, +} = require('../headers-verify'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const root = '/header-inventory-fixture'; + +function classify(text, targets = []) { + jest.spyOn(headers, 'getHeaderFilesFromPodspecs').mockReturnValue({ + fixture: [ + { + specName: 'Fixture', + headerDir: '', + headers: [ + {source: path.join(root, 'source/A.h'), target: 'ns/A.h'}, + ...targets.map(([target, source]) => ({ + source: path.join(root, source), + target, + })), + ], + }, + ], + }); + jest + .spyOn(fs, 'readFileSync') + .mockImplementation(file => + file === path.join(root, 'source/A.h') ? text : '', + ); + return computeInventory(root); +} + +afterEach(() => jest.restoreAllMocks()); + +const excluded = [ + '#ifdef __ANDROID__', + '#ifdef ANDROID', + '#if defined(__ANDROID__)', + '#if defined __ANDROID__', + '#if (__ANDROID__)', + '#if defined(ANDROID)', + '#if defined(__ANDROID__) && UNKNOWN_FEATURE', + '#if UNKNOWN_FEATURE && (defined(__ANDROID__))', + '#if !(!defined(__ANDROID__))', + '#if defined /* comment */ (__ANDROID__)', + '#if defined(__ANDROID__) && \\\n UNKNOWN_FEATURE', +]; +const eligible = [ + '#if !defined(__ANDROID__)', + '#ifndef __ANDROID__', + '#if !__ANDROID__', + '#if UNKNOWN_PLATFORM', + '#if defined(__ANDROID__) || UNKNOWN_FEATURE', + '#if defined(__ANDROID__) == UNKNOWN_FEATURE', + '#if defined(__ANDROID__) ? UNKNOWN_FEATURE : OTHER_FEATURE', + '#if defined(__ANDROID__) + UNKNOWN_FEATURE', + '#if defined(__ANDROID__)_SUFFIX', + '#if defined(__APPLE__) && TARGET_OS_OSX', + '#if defined(TARGET_OS_OSX) && TARGET_OS_OSX', + '#if !TARGET_OS_OSX', +]; + +test.each(excluded)('excludes only a proven non-Apple branch: %s', guard => { + const inventory = classify( + `${guard}\n#include "platform/android/Missing.h"\n#include \n#endif`, + ); + expect(collectIncludeHealth(inventory)).toEqual([]); + expect(inventory.headers[0].includes.otherPlatform).toEqual([ + '"platform/android/Missing.h"', + 'ns/Missing.h', + ]); +}); + +test.each(eligible)('keeps a possibly Apple branch visible: %s', guard => { + expect( + collectIncludeHealth( + classify(`${guard}\n#include "platform/android/Missing.h"\n#endif`), + ), + ).toEqual(['quotedNotShipped ns/A.h -> "platform/android/Missing.h"']); +}); + +test('does not hide unguarded Android-spelled imports', () => { + expect( + collectIncludeHealth(classify('#include "platform/android/Missing.h"')), + ).toHaveLength(1); +}); + +test.each(['if', 'elif'])( + 'reads complete #%s expressions across multiline comments', + directive => { + const prefix = directive === 'elif' ? '#if defined(__ANDROID__)\n' : ''; + const text = `${prefix}#${directive} defined(__ANDROID__) /* split\n comment */ || defined(__APPLE__)\n#include "platform/macos/Missing.h"\n#endif`; + expect(collectIncludeHealth(classify(text))).toEqual([ + 'quotedNotShipped ns/A.h -> "platform/macos/Missing.h"', + ]); + }, +); + +test.each(['\r\n', '\r', 'mixed'])( + 'normalizes line endings and directive comments: %p', + newline => { + const guards = [ + ...[ + '__ANDROID__', + 'ANDROID', + '__APPLE__', + 'TARGET_OS_OSX', + 'UNKNOWN_PLATFORM', + '__cplusplus', + ].flatMap(macro => [ + `#ifdef ${macro}`, + `#ifndef ${macro}`, + `#if ${macro}`, + `#if !${macro}`, + `#if defined(${macro})`, + `#if !defined ${macro}`, + ]), + '#if defined(__APPLE__) && TARGET_OS_OSX', + '#if defined(TARGET_OS_OSX) && TARGET_OS_OSX', + '#if defined(__ANDROID__) && UNKNOWN_FEATURE', + '#if defined(__ANDROID__) || UNKNOWN_FEATURE', + '#if defined(__ANDROID__) == UNKNOWN_FEATURE', + '#if defined(__ANDROID__) ? UNKNOWN_FEATURE : OTHER_FEATURE', + '#if defined(__ANDROID__) /* split\n comment */ || defined(__APPLE__)', + '#if defined(__ANDROID__) && \\\n UNKNOWN_FEATURE', + ].flatMap(guard => [ + guard, + '#if UNKNOWN_FEATURE // first branch\n' + + guard + .replace(/^#ifdef (.*)$/, '#elif defined($1)') + .replace(/^#ifndef (.*)$/, '#elif !defined($1)') + .replace(/^#if\b/, '#elif'), + ]); + for (const guard of guards) { + for (let mask = 0; mask < 16; mask++) { + const comment = bit => + Math.floor(mask / 2 ** bit) % 2 ? ' // comment' : ''; + const lf = `${guard}${comment(0)}\n#include "Branch.h"\n#elif defined(__ANDROID__)${comment(1)}\n#include "Android.h"\n#else${comment(2)}\n#ifdef __cplusplus\n#include \n#endif\n#endif${comment(3)}\n#include "platform/macos/Missing.h"\n`; + let i = 0; + const converted = lf.replace(/\n/g, () => + newline === 'mixed' ? ['\n', '\r\n', '\r'][i++ % 3] : newline, + ); + expect(scanHeader(converted)).toEqual(scanHeader(lf)); + const expected = collectIncludeHealth(classify(lf)); + expect(expected).toContain( + 'quotedNotShipped ns/A.h -> "platform/macos/Missing.h"', + ); + expect(collectIncludeHealth(classify(converted))).toEqual(expected); + } + } + }, +); + +test('restores outer conditions and preserves independent C++ guards', () => { + const text = + '#ifdef __cplusplus\n#if defined(__ANDROID__)\n#include "Android.h"\n#else\n#include \n#endif\n#endif\n#include "Apple.h"'; + expect(scanHeader(text).includes).toEqual([ + {token: '"Android.h"', cxxGuarded: true, appleExcluded: true}, + {token: 'folly/dynamic.h', cxxGuarded: true}, + {token: '"Apple.h"', cxxGuarded: false}, + ]); +}); + +test.each(['\n', '\r\n'])( + 'splices newlines before recognizing block delimiters: %p', + newline => { + const text = [ + '#if defined(__ANDROID__) /\\', + '* split', + '#endif // inside block', + 'comment *\\', + '/ || UNKNOWN_FEATURE', + '#include "Apple.h"', + '#endif', + ].join(newline); + expect(scanHeader(text).includes).toEqual([ + {token: '"Apple.h"', cxxGuarded: false}, + ]); + }, +); + +test('line comments and quoted include tokens do not open block comments', () => { + const text = [ + '// /* not a block', + '#include "path//Apple.h"', + '// continued \\', + '#if defined(__ANDROID__)', + '#include "Apple.h"', + ].join('\n'); + expect(scanHeader(text).includes).toEqual([ + {token: '"path//Apple.h"', cxxGuarded: false}, + {token: '"Apple.h"', cxxGuarded: false}, + ]); +}); + +test.each(['if', 'elif'])( + 'real-source #%s regression remains visible with multiline comments', + directive => { + const rnRoot = path.resolve(__dirname, '../../..'); + const before = collectIncludeHealth(computeInventory(rnRoot)); + const wrapper = 'react/renderer/components/view/HostPlatformViewProps.h'; + const file = path.join(rnRoot, 'ReactCommon', wrapper); + const read = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((name, ...args) => { + const text = read(name, ...args); + return name === file + ? `${text}\n${directive === 'elif' ? '#if defined(__ANDROID__)\n' : ''}#${directive} defined(__ANDROID__) /* split\n comment */ || defined(__APPLE__)\n#include "platform/macos/Missing.h"\n#endif\n` + : text; + }); + expect( + diffAgainstBaseline( + collectIncludeHealth(computeInventory(rnRoot)), + before, + ), + ).toEqual({ + newOffenders: [ + `quotedNotShipped ${wrapper} -> "platform/macos/Missing.h"`, + ], + resolved: [], + }); + }, +); + +test.each(['sub/B.h', './sub/B.h', 'sub/../sub/B.h', '../ns/sub/B.h'])( + 'resolves packaged relative path %s', + token => { + const inventory = classify(`#include "${token}"`, [ + ['ns/sub/B.h', 'elsewhere/B.h'], + ]); + expect( + inventory.headers.find(h => h.naturalPath === 'ns/A.h').includes.internal, + ).toEqual([{naturalPath: 'ns/sub/B.h', cxxGuarded: false}]); + }, +); + +test('prefers packaged siblings over root and source spellings', () => { + const inventory = classify('#ifdef __cplusplus\n#include "sub/B.h"\n#endif', [ + ['ns/sub/B.h', 'elsewhere/B.h'], + ['sub/B.h', 'root/B.h'], + ['relocated/B.h', 'source/sub/B.h'], + ]); + expect( + inventory.headers.find(h => h.naturalPath === 'ns/A.h').includes.internal, + ).toEqual([{naturalPath: 'ns/sub/B.h', cxxGuarded: true}]); +}); + +test.each(['missing/B.h', '../../other/B.h', '/other/B.h'])( + 'does not invent a packaged target for %s', + token => { + expect( + collectIncludeHealth( + classify(`#include "${token}"`, [['other/B.h', 'elsewhere/B.h']]), + ), + ).toEqual([`quotedNotShipped ns/A.h -> "${token}"`]); + }, +); + +test.each([2, 3])('never suppresses %s conflicting physical sources', count => { + const sources = [ + 'ReactCommon/react/renderer/components/view/HostPlatformTouch.h', + 'ReactCommon/react/renderer/components/view/platform/cxx/react/renderer/components/view/HostPlatformTouch.h', + 'unexpected/Header.h', + ].slice(0, count); + jest.spyOn(headers, 'getHeaderFilesFromPodspecs').mockReturnValue({ + fixture: [ + { + specName: 'React-Fabric', + headerDir: '', + headers: sources.map(source => ({ + source: path.join(root, source), + target: 'react/renderer/components/view/HostPlatformTouch.h', + })), + }, + ], + }); + const inventory = buildInventory(root); + expect(inventory.collisions).toEqual([ + { + naturalPath: 'react/renderer/components/view/HostPlatformTouch.h', + sources: [...sources].sort(), + }, + ]); + expect(inventory.entries.size).toBe(1); // No donor-only physical aliases. +}); + +test('real inventory retains 1154 entries with generated FBReactNativeSpec headers and no include-health drift', () => { + const rnRoot = path.resolve(__dirname, '../../..'); + const generatedRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'header-codegen-'), + ); + try { + // Prebuild generates these eight ignored headers, but a clean Jest checkout + // does not. Generate from this head's schema in isolation, even when a local + // native build has left older FBReactNativeSpec outputs in the source tree. + const config = + require('../../../package.json').codegenConfig.libraries.find( + library => library.name === 'FBReactNativeSpec', + ); + expect( + generate( + { + libraryName: config.name, + schema: combineSchemasInFileList( + [path.join(rnRoot, config.jsSrcsDir)], + 'ios', + ), + outputDirectory: path.join(generatedRoot, 'React/FBReactNativeSpec'), + packageName: 'com.facebook.fbreact.specs', + assumeNonnull: true, + useLocalIncludePaths: false, + includeGetDebugPropsImplementation: true, + }, + {generators: ['componentsIOS', 'modulesIOS', 'modulesCxx']}, + ), + ).toBe(true); + const spec = 'React/React-RCTFBReactNativeSpec.podspec'; + fs.copyFileSync(path.join(rnRoot, spec), path.join(generatedRoot, spec)); + const discover = headers.getHeaderFilesFromPodspecs; + const generatedMaps = + discover(generatedRoot)[path.join(generatedRoot, spec)]; + expect(generatedMaps.flatMap(map => map.headers)).toHaveLength(8); + jest + .spyOn(headers, 'getHeaderFilesFromPodspecs') + .mockImplementation(folder => ({ + ...discover(folder), + [path.join(folder, spec)]: generatedMaps, + })); + const inventory = computeInventory(rnRoot); + const baseline = JSON.parse( + fs.readFileSync( + path.join(__dirname, '../headers-include-baseline.json'), + 'utf8', + ), + ); + expect(inventory.headers).toHaveLength(1154); + expect(inventory.collisions).toEqual([]); + expect(baseline).toHaveLength(21); + expect( + diffAgainstBaseline(collectIncludeHealth(inventory), baseline), + ).toEqual({newOffenders: [], resolved: []}); + } finally { + fs.rmSync(generatedRoot, {recursive: true, force: true}); + } +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js index 300a559e1f5b..9c1e889dab00 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js @@ -24,7 +24,7 @@ test('fork inventory retains stable dispatcher paths and macOS headers without c 'HostPlatformViewProps', 'HostPlatformViewTraitsInitializer', ]) { - expect(byPath.get(`${base}${name}.h`).identities[0].source).toBe( + expect(byPath.get(`${base}${name}.h`)?.identities[0].source).toBe( `ReactCommon/${base}${name}.h`, ); expect(byPath.has(`${base}platform/macos/${base}${name}.h`)).toBe(true); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js new file mode 100644 index 000000000000..a637189ce866 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license in the root LICENSE file. + * @format + * @noflow + */ + +const inventory = require('../headers-inventory'); +const spec = require('../headers-spec'); +const {main} = require('../headers-verify'); +const fs = require('fs'); + +afterEach(() => jest.restoreAllMocks()); + +test.each([[], ['--skip-compile'], ['--update-baseline']].map(argv => [argv]))( + 'rejects physical-source collisions before plan or baseline writes: %p', + argv => { + jest.spyOn(inventory, 'computeInventory').mockReturnValue({ + headers: [], + collisions: [ + {naturalPath: 'ns/A.h', sources: ['first/A.h', 'extra/A.h']}, + ], + }); + const plan = jest.spyOn(spec, 'planFromInventory'); + const write = jest.spyOn(fs, 'writeFileSync'); + expect(() => main(argv)).toThrow(/natural-path collisions \(R8\)/); + expect(plan).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }, +); + +test('retains the destination collision gate after inventory succeeds', () => { + jest + .spyOn(inventory, 'computeInventory') + .mockReturnValue({headers: [], collisions: []}); + jest + .spyOn(spec, 'planFromInventory') + .mockReturnValue({collisions: ['destination conflict']}); + expect(() => main([])).toThrow(/R8 collisions:\n {2}destination conflict/); +}); 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 index 6fc781504218..98a64a69bc00 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js @@ -12,6 +12,7 @@ const { buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, stubSlicesFromXcframework, } = require('../headers-xcframework'); const childProcess = require('child_process'); @@ -110,4 +111,221 @@ describe('stubSlicesFromXcframework', () => { /no stub recipe for slice 'watchos'[\s\S]*PLATFORM_STUB_RECIPES/, ); }); + + const iosLibraries = [ + {SupportedPlatform: 'ios', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ]; + + test('matches all five Microsoft platforms without adding Catalyst or architectures', () => { + const exec = mockPlist({ + AvailableLibraries: [ + ...iosLibraries, + { + SupportedPlatform: 'macos', + SupportedArchitectures: ['x86_64', 'arm64'], + }, + {SupportedPlatform: 'xros', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'xros', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64'], + }, + ], + }); + expect(stubSlicesFromXcframework('/React.xcframework')).toEqual([ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, + { + name: 'macos', + sdk: 'macosx', + targets: ['x86_64-apple-macosx11.0', 'arm64-apple-macosx11.0'], + }, + {name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}, + { + name: 'xros-simulator', + sdk: 'xrsimulator', + targets: ['arm64-apple-xros1.0-simulator'], + }, + ]); + expect(exec).toHaveBeenCalledWith('plutil', [ + '-convert', + 'json', + '-o', + '-', + '/React.xcframework/Info.plist', + ]); + }); + + test('matches the three upstream platforms including Catalyst', () => { + mockPlist({ + AvailableLibraries: [ + ...iosLibraries, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'maccatalyst', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ], + }); + const slices = stubSlicesFromXcframework('/React.xcframework'); + expect(slices.map(s => s.name)).toEqual([ + 'ios', + 'ios-simulator', + 'ios-maccatalyst', + ]); + expect(slices[2]).toEqual({ + name: 'ios-maccatalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], + }); + }); + + test.each([ + ['macos', undefined, 'macosx', 'macosx11.0'], + ['ios', 'simulator', 'iphonesimulator', 'ios15.0-simulator'], + ['xros', undefined, 'xros', 'xros1.0'], + ['tvos', undefined, 'appletvos', 'tvos15.1'], + ['tvos', 'simulator', 'appletvsimulator', 'tvos15.1-simulator'], + ])('preserves a single %s / %s slice', (platform, variant, sdk, target) => { + mockPlist({ + AvailableLibraries: [ + { + SupportedPlatform: platform, + SupportedPlatformVariant: variant, + SupportedArchitectures: ['arm64'], + }, + ], + }); + expect(stubSlicesFromXcframework('/single.xcframework')).toEqual([ + { + name: variant == null ? platform : `${platform}-${variant}`, + sdk, + targets: [`arm64-apple-${target}`], + }, + ]); + }); + + test.each([ + null, + {}, + {AvailableLibraries: null}, + {AvailableLibraries: {}}, + {AvailableLibraries: []}, + ])('rejects missing or empty libraries: %p', plist => { + mockPlist(plist); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /non-empty AvailableLibraries/, + ); + }); + + test.each([ + null, + {}, + {SupportedPlatform: ''}, + {SupportedPlatform: 1}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: ''}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: null}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: 1}, + ])('rejects malformed platform metadata: %p', lib => { + mockPlist({AvailableLibraries: [lib]}); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /invalid platform metadata/, + ); + }); + + test.each(['watchos', 'visionos', 'toString', '__proto__'])( + 'rejects unknown platform %s', + platform => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: platform, SupportedArchitectures: ['arm64']}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /no stub recipe/, + ); + }, + ); + + test('rejects an unknown variant', () => { + mockPlist({ + AvailableLibraries: [ + {...iosLibraries[0], SupportedPlatformVariant: 'unknown'}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /no stub recipe for slice 'ios-unknown'/, + ); + }); + + test.each( + [ + undefined, + null, + [], + 'arm64', + [null], + [1], + [''], + ['arm64-apple-ios'], + ['arm64', 'arm64'], + ].map(archs => [archs]), + )( + 'rejects malformed architectures instead of inventing a default: %p', + archs => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'ios', SupportedArchitectures: archs}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /invalid SupportedArchitectures/, + ); + }, + ); + + test('rejects duplicate platform/variant entries', () => { + mockPlist({AvailableLibraries: [iosLibraries[0], iosLibraries[0]]}); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /duplicate slice 'ios'/, + ); + }); + + test('reports invalid JSON with the artifact path', () => { + jest + .spyOn(childProcess, 'execFileSync') + .mockReturnValue(Buffer.from('not JSON')); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /failed to parse Info.plist of \/bad.xcframework/, + ); + }); + + test('reports a missing or unreadable plist', () => { + jest.spyOn(childProcess, 'execFileSync').mockImplementation(() => { + throw new Error('missing plist'); + }); + expect(() => stubSlicesFromXcframework('/missing.xcframework')).toThrow( + /failed to parse Info.plist.*missing plist/, + ); + }); + + test('rejects empty emitter input before invoking native tools', () => { + const exec = jest.spyOn(childProcess, 'execFileSync'); + expect(() => + composeHeadersOnlyXcframework('/unused', 'Headers', '/unused', []), + ).toThrow(/requires non-empty slices/); + expect(exec).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js index a65224451638..ec7034cd0b9f 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js @@ -144,24 +144,3 @@ test('the checked-in compiler and source tag match the single Hermes metadata', ).toBe(`hermes-v${selected.version}`); } }); - -test('the default runtime, compiler, and source tag versions agree', () => { - const previous = process.env.RCT_HERMES_V1_ENABLED; - try { - delete process.env.RCT_HERMES_V1_ENABLED; - // 0.87 uses a single V1 pin; the shared helper defaults to legacy. - const {version, tagFile} = readHermesMetadata('single'); - const {dependencies} = require('../../../package.json'); - expect(dependencies['hermes-compiler']).toBe(version); - const tag = fs - .readFileSync(path.resolve(__dirname, '../../../sdks', tagFile), 'utf8') - .trim(); - expect(tag).toBe(`hermes-v${version}`); - } finally { - if (previous == null) { - delete process.env.RCT_HERMES_V1_ENABLED; - } else { - process.env.RCT_HERMES_V1_ENABLED = previous; - } - } -}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js new file mode 100644 index 000000000000..91e6e2f2c306 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js @@ -0,0 +1,155 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const codegen = require('../../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); +const compose = require('../headers-compose'); +const {buildXCFrameworks, resolveHermesHeaders} = require('../xcframework'); +const childProcess = require('child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('resolveHermesHeaders', () => { + let tmp /*: string */ = ''; + let buildFolder /*: string */ = ''; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xcframework-test-')); + buildFolder = path.join(tmp, '.build'); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + test('returns the base include directory when hermes/hermes.h exists', () => { + const includeDir = path.join( + buildFolder, + 'artifacts', + 'hermes', + 'destroot', + 'include', + ); + fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true}); + fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), ''); + + expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir); + }); + + test('finds a non-standard nested include directory', () => { + const includeDir = path.join( + buildFolder, + 'artifacts', + 'hermes', + 'nested', + 'archive', + 'destroot', + 'include', + ); + fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true}); + fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), ''); + + expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir); + }); + + test('returns null when Hermes headers are absent and not required', () => { + expect(resolveHermesHeaders(buildFolder, false)).toBeNull(); + }); + + test('throws when Hermes headers are absent and required', () => { + expect(() => resolveHermesHeaders(buildFolder, true)).toThrow( + /ReactNativeHeaders[\s\S]*[\s\S]*destroot\/include/, + ); + }); +}); + +describe('producer header slices', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each(['macos', 'xros', 'unknown'])( + 'derives the RN sidecar from the composed %s binary', + platform => { + jest + .spyOn(codegen, 'generateFBReactNativeSpecIOS') + .mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(fs, 'rmSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const plan = {}; + jest.spyOn(compose, 'computeSpecPlan').mockReturnValue(plan); + jest + .spyOn(compose, 'emitReactFrameworkHeaders') + .mockImplementation(() => {}); + const emit = jest + .spyOn(compose, 'buildReactNativeHeadersXcframework') + .mockReturnValue( + '/build/output/xcframeworks/Debug/ReactNativeHeaders.xcframework', + ); + const exec = jest + .spyOn(childProcess, 'execFileSync') + .mockImplementation(command => { + if (command === 'plutil') { + return Buffer.from( + JSON.stringify({ + AvailableLibraries: [ + { + SupportedPlatform: platform, + SupportedArchitectures: ['arm64'], + }, + ], + }), + ); + } + return Buffer.from(''); + }); + if (platform === 'unknown') { + expect(() => + buildXCFrameworks('/root', '/build', [], 'Debug', null), + ).toThrow(/no stub recipe/); + expect(emit).not.toHaveBeenCalled(); + expect(exec.mock.calls.some(([command]) => command === 'tar')).toBe( + false, + ); + return; + } + buildXCFrameworks('/root', '/build', [], 'Debug', null); + expect(exec).toHaveBeenCalledWith('plutil', [ + '-convert', + 'json', + '-o', + '-', + '/build/output/xcframeworks/Debug/React.xcframework/Info.plist', + ]); + expect(emit).toHaveBeenCalledWith( + '/build/output/xcframeworks/Debug', + plan, + '/root', + [ + { + name: platform, + sdk: platform === 'macos' ? 'macosx' : 'xros', + targets: [ + platform === 'macos' + ? 'arm64-apple-macosx11.0' + : 'arm64-apple-xros1.0', + ], + }, + ], + null, + null, + ); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index ebb3ba1dfc65..03125f39d52e 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -72,6 +72,11 @@ const cli = yargs describe: 'Specify the code signing identity to use for signing the frameworks.', }) + .option('require-hermes', { + type: 'boolean', + describe: + 'Require Hermes headers when composing the ReactNativeHeaders XCFramework.', + }) .help(); /** @@ -86,6 +91,7 @@ async function getCLIConfiguration() /*: Promise, identity: ?string, + requireHermes: boolean, |}> */ { // Run input parsing const argv = await cli.argv; @@ -131,6 +137,7 @@ async function getCLIConfiguration() /*: Promise */, rnRoot /*: string */, + overlayDir /*: ?string */ = null, ) /*: void */ { for (const e of entries) { const dest = path.join(stage, e.relPath); @@ -130,7 +130,24 @@ function stageEntries( `#import <${e.redirectTo}>\n`, ); } else { - fs.copyFileSync(path.join(rnRoot, e.source), dest); + // ReactNativeVersion.h is the one shipped header STAMPED at build time: + // the slice job runs set-rn-artifacts-version before building, while the + // source tree keeps the 1000.0.0 dev sentinel. Take just this file's + // content from the built header tree (`overlayDir`, i.e. `.build/headers`) + // when present, so the compose ships the real version without re-stamping + // its own checkout. Every OTHER header is authoritative in the source + // tree (only the layout is spec-derived), so it always copies from + // source — never from a build tree that could be stale relative to it. + const isStamped = path.basename(e.source) === 'ReactNativeVersion.h'; + const overlaySource = + isStamped && overlayDir != null + ? path.join(overlayDir, e.source) + : null; + const src = + overlaySource != null && fs.existsSync(overlaySource) + ? overlaySource + : path.join(rnRoot, e.source); + fs.copyFileSync(src, dest); } } } @@ -146,11 +163,12 @@ function emitReactFrameworkHeaders( xcfwPath /*: string */, plan /*: HeadersSpecPlan */, rnRoot /*: string */, + overlayDir /*: ?string */ = null, ) /*: void */ { const stage = fs.mkdtempSync( path.join(path.dirname(xcfwPath), '.react-stage-'), ); - stageEntries(stage, plan.react, rnRoot); + stageEntries(stage, plan.react, rnRoot, overlayDir); fs.writeFileSync( path.join(stage, 'React-umbrella.h'), renderUmbrellaHeader(plan.umbrella), @@ -240,18 +258,17 @@ function buildReactNativeHeadersXcframework( outDir /*: string */, plan /*: HeadersSpecPlan */, rnRoot /*: string */, - includeCatalyst /*: boolean */ = false, + slices /*: Array */, // Optional dir containing a `hermes/` namespace (Hermes public headers from // the hermes-ios tarball's destroot/include). Folded in as a textual // namespace so `` resolves without per-library wiring. null // when unstaged — then `` stays unavailable. hermesHeaders /*: ?string */ = null, - // [macOS] Derive sidecar platforms from the binary whenever one is available. - binaryXcfw /*: ?string */ = null, + overlayDir /*: ?string */ = null, ) /*: string */ { // ---- stage headers ---- const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); - stageEntries(stage, plan.reactNativeHeaders, rnRoot); + stageEntries(stage, plan.reactNativeHeaders, rnRoot, overlayDir); // Hermes public headers (separate source from the deps namespaces — they // come from the hermes-ios tarball, not ReactNativeDependencies). Vend only // the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying @@ -285,12 +302,6 @@ function buildReactNativeHeadersXcframework( ); // ---- compose (stub archives + create-xcframework) ---- - const slices = - binaryXcfw != null - ? stubSlicesFromXcframework(binaryXcfw) - : includeCatalyst - ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] - : DEFAULT_STUB_SLICES; const outXcfw = composeHeadersOnlyXcframework( outDir, 'ReactNativeHeaders', @@ -329,11 +340,13 @@ function ensureHeadersLayout( const sourceXcfw = fs.realpathSync( path.join(artifactsDir, 'React.xcframework'), ); - const depsHeaders = path.join( + const depsXcfw = path.join( artifactsDir, 'ReactNativeDependencies.xcframework', - 'Headers', ); + const depsHeaders = path.join(depsXcfw, 'Headers'); + const reactSlices = stubSlicesFromXcframework(sourceXcfw); + const depsSlices = stubSlicesFromXcframework(depsXcfw); // Hermes public headers staged into the slot by download-spm-artifacts // (the hermes-ios tarball ships them in destroot/include, which the // xcframework extraction otherwise discards). null when absent — then @@ -356,7 +369,9 @@ function ensureHeadersLayout( // 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`; + // Include both binary slice sets so a changed deps platform set also + // invalidates the cached sidecars, even when React itself is unchanged. + const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\ntooling:${composeToolingHash()}\nslices:${JSON.stringify([reactSlices, depsSlices])}\n`; if ( !force && fs.existsSync(reactXcfw) && @@ -386,18 +401,15 @@ function ensureHeadersLayout( outDir, plan, rnRoot, - false, + reactSlices, hermesHeaders, - sourceXcfw, // [macOS] ); - // [macOS] Match each sidecar to its own binary's actual platform slices. + // Each sidecar matches its own binary, which may carry a different slice set. buildDepsHeadersXcframework( outDir, depsHeaders, plan.depsNamespaces, - stubSlicesFromXcframework( - path.join(artifactsDir, 'ReactNativeDependencies.xcframework'), - ), + depsSlices, ); fs.writeFileSync(markerPath, marker); return {reactXcfw, headersXcfw, depsHeadersXcfw}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json index 9221b6945ba3..94fcea3de137 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json +++ b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json @@ -8,12 +8,6 @@ "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\"", diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js index b1aa586c384e..875cabcea009 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-inventory.js +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -47,6 +47,7 @@ type Identity = { type IncludeRef = { token: string, // text between <> or "" cxxGuarded: boolean, // true when only reachable under #ifdef __cplusplus + appleExcluded?: boolean, // proven unreachable in the Apple payload }; type HeaderEntry = { @@ -128,12 +129,145 @@ const SDK_PREFIXES = new Set([ 'sys', ]); +// Three-valued logic: unknown feature conditions must keep both branches. +function conditionNot(value /*: ?boolean */) /*: ?boolean */ { + return value == null ? null : !value; +} + +function conditionAnd(a /*: ?boolean */, b /*: ?boolean */) /*: ?boolean */ { + return a === false || b === false + ? false + : a === true && b === true + ? true + : null; +} + +/** + * Evaluate only Boolean platform guards, not arbitrary preprocessor syntax. + * Android macros are false for every Apple slice. Leave all other macros + * unknown, including TARGET_OS_OSX: both macOS and generic C++ paths ship. + * Unsupported expressions remain unknown rather than hiding dependencies. + */ +function appleCondition(expression /*: string */) /*: ?boolean */ { + const tokens = + expression.match(/defined\b|[A-Za-z_]\w*|&&|\|\||[!()]|\S/g) ?? []; + let index = 0; + let valid = true; + const macroValue = (name /*: ?string */) /*: ?boolean */ => + name === '__ANDROID__' || name === 'ANDROID' ? false : null; + const unary = () /*: ?boolean */ => { + const token = tokens[index++]; + if (token === '!') { + return conditionNot(unary()); + } + if (token === '(') { + const value = or(); + valid = tokens[index++] === ')' && valid; + return value; + } + if (token === 'defined') { + const parenthesized = tokens[index] === '('; + if (parenthesized) { + index++; + } + const name = tokens[index++]; + valid = /^[A-Za-z_]\w*$/.test(name ?? '') && valid; + if (parenthesized) { + valid = tokens[index++] === ')' && valid; + } + return macroValue(name); + } + if (/^[A-Za-z_]\w*$/.test(token ?? '')) { + return macroValue(token); + } + valid = false; + return null; + }; + const and = () /*: ?boolean */ => { + let value = unary(); + while (tokens[index] === '&&') { + index++; + value = conditionAnd(value, unary()); + } + return value; + }; + const or = () /*: ?boolean */ => { + let value = and(); + while (tokens[index] === '||') { + index++; + value = conditionNot( + conditionAnd(conditionNot(value), conditionNot(and())), + ); + } + return value; + }; + const value = or(); + return valid && index === tokens.length ? value : null; +} + +/** + * Normalize CRLF and standalone CR before splicing escaped newlines and + * recognizing comments. A block comment is one space, even across physical + * lines. Only a newline outside that comment ends the directive. Keep line + * comments and quoted tokens separate so their delimiters cannot change the + * comment state. + */ +function headerLogicalLines(text /*: string */) /*: Array */ { + const source = text.replace(/\r\n?/g, '\n').replace(/\\\n/g, ''); + const lines = []; + let line = ''; + let inBlockComment = false; + let inLineComment = false; + let quote = ''; + for (let i = 0; i < source.length; i++) { + const char = source[i]; + const next = source[i + 1]; + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + i++; + } + } else if (char === '\n') { + lines.push(line); + line = ''; + inLineComment = false; + quote = ''; + } else if (inLineComment) { + continue; + } else if (quote !== '') { + line += char; + if (char === quote) { + quote = ''; + } else if (char === '\\' && next != null && quote !== '>') { + line += next; + i++; + } + } else if (char === '/' && next === '*') { + line += ' '; + inBlockComment = true; + i++; + } else if (char === '/' && next === '/') { + inLineComment = true; + i++; + } else { + if (char === '"' || char === "'") { + quote = char; + } else if (char === '<' && /^\s*#\s*(?:include|import)\s*$/.test(line)) { + quote = '>'; + } + line += char; + } + } + lines.push(line); + return lines; +} + /** - * Scans a header's text line by line, tracking the preprocessor-conditional + * Scans a header's logical lines, 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. + * Includes retain an exclusion flag when their branch cannot run on Apple. + * Unknown conditions remain conservatively eligible for include resolution. */ function scanHeader(text /*: string */) /*: { includes: Array, @@ -149,6 +283,8 @@ function scanHeader(text /*: string */) /*: { // Stack frames: 'cpp' (only under __cplusplus), 'notcpp', 'other'. const stack /*: Array<'cpp' | 'notcpp' | 'other'> */ = []; const inCxxOnly = () => stack.includes('cpp'); + const platformStack /*: Array<{active: ?boolean, remaining: ?boolean}> */ = + []; const includeRe = /^\s*#\s*(?:include|import)\s+(?:<([^>]+)>|"([^"]+)")/; const objcRe = @@ -156,33 +292,30 @@ function scanHeader(text /*: string */) /*: { 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); - } + for (const line of headerLogicalLines(text)) { const cond = line.match(/^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$/); if (cond) { const [, directive, rest] = cond; + if ( + directive === 'if' || + directive === 'ifdef' || + directive === 'ifndef' + ) { + const expression = + directive === 'if' ? rest : `defined(${rest.trim()})`; + const value = appleCondition(expression); + const active = directive === 'ifndef' ? conditionNot(value) : value; + platformStack.push({active, remaining: conditionNot(active)}); + } else if (directive === 'endif') { + platformStack.pop(); + } else { + const frame = platformStack[platformStack.length - 1]; + if (frame != null) { + const value = directive === 'else' ? true : appleCondition(rest); + frame.active = conditionAnd(frame.remaining, value); + frame.remaining = conditionAnd(frame.remaining, conditionNot(value)); + } + } const mentionsCpp = /__cplusplus/.test(rest); if (directive === 'ifdef' || directive === 'if') { stack.push( @@ -212,6 +345,9 @@ function scanHeader(text /*: string */) /*: { includes.push({ token: inc[1] != null ? inc[1] : `"${inc[2]}"`, cxxGuarded: inCxxOnly(), + ...(platformStack.some(frame => frame.active === false) + ? {appleExcluded: true} + : {}), }); } if (objcRe.test(line)) { @@ -442,13 +578,32 @@ function classifyEntries( 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 (inc.appleExcluded) { + // Keep the edge visible without resolving a non-Apple dependency. + entry.includes.otherPlatform.push(token); + continue; + } + // Quoted includes search the packaged sibling first, then the include + // root. Normalize subdirectories and dot segments in both spellings. if (token.startsWith('"')) { const quotedToken = token.slice(1, -1); - // [macOS] Stable dispatch headers contain inactive Android branches. - if (quotedToken.startsWith('platform/android/')) { - entry.includes.otherPlatform.push(quotedToken); + const packagedPaths = path.posix.isAbsolute(quotedToken) + ? [] + : [ + path.posix.join( + path.posix.dirname(entry.naturalPath), + quotedToken, + ), + path.posix.normalize(quotedToken), + ]; + const packagedPath = packagedPaths.find( + candidate => !candidate.startsWith('../') && entries.has(candidate), + ); + if (packagedPath != null) { + entry.includes.internal.push({ + naturalPath: packagedPath, + cxxGuarded: inc.cxxGuarded, + }); continue; } const resolved = path.resolve(path.dirname(absSource), quotedToken); diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index 79bebc800348..48b4e8286791 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -35,6 +35,7 @@ * Usage: * node scripts/ios-prebuild/headers-verify.js [--flavor Debug|Release] * [--artifacts ] [--skip-compile] [--update-baseline] + * [--require-stamped-version] */ const {computeInventory} = require('./headers-inventory'); @@ -410,6 +411,49 @@ function runCompileGates( } } +// --------------------------------------------------------------------------- +// Version stamp gate +// --------------------------------------------------------------------------- + +/** + * Release/nightly artifacts must not ship ReactNativeVersion.h with the + * 1000.0.0 dev sentinel: the compose step copies headers from the source + * tree, so a compose job that forgot to run set-rn-artifacts-version.js + * would silently publish a sentinel header, breaking every library that + * gates code on REACT_NATIVE_VERSION_MAJOR/MINOR. + */ +function verifyVersionStamp(artifactsDir /*: string */) /*: void */ { + const copies = []; + const walk = (dir /*: string */) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const name = String(entry.name); + const full = path.join(dir, name); + if (entry.isDirectory()) { + walk(full); + } else if (name === 'ReactNativeVersion.h') { + copies.push(full); + } + } + }; + walk(artifactsDir); + if (copies.length === 0) { + throw new Error( + `no ReactNativeVersion.h found under ${artifactsDir} — cannot verify the version stamp.`, + ); + } + const unstamped = copies.filter(f => + /REACT_NATIVE_VERSION_MAJOR\s+1000\b/.test(fs.readFileSync(f, 'utf8')), + ); + if (unstamped.length > 0) { + throw new Error( + `ReactNativeVersion.h still contains the 1000.0.0 dev sentinel — run ` + + `scripts/releases/set-rn-artifacts-version.js before composing:\n ` + + unstamped.join('\n '), + ); + } + log(`version stamp OK (${copies.length} copies checked).`); +} + // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- @@ -419,11 +463,13 @@ function parseArgs(argv /*: Array */) /*: { artifacts: ?string, skipCompile: boolean, updateBaseline: boolean, + requireStampedVersion: boolean, } */ { let flavor = 'Debug'; let artifacts /*: ?string */ = null; let skipCompile = false; let updateBaseline = false; + let requireStampedVersion = false; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--flavor') { flavor = argv[++i]; @@ -433,14 +479,31 @@ function parseArgs(argv /*: Array */) /*: { skipCompile = true; } else if (argv[i] === '--update-baseline') { updateBaseline = true; + } else if (argv[i] === '--require-stamped-version') { + requireStampedVersion = true; } } - return {flavor, artifacts, skipCompile, updateBaseline}; + return { + flavor, + artifacts, + skipCompile, + updateBaseline, + requireStampedVersion, + }; } function main(argv /*:: ?: Array */) /*: void */ { const args = parseArgs(argv ?? process.argv.slice(2)); const inventory = computeInventory(RN_ROOT); + // [macOS] Reject physical-source collisions before plan selection or baseline writes. + 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, RN_ROOT); if (plan.collisions.length > 0) { throw new Error(`R8 collisions:\n ${plan.collisions.join('\n ')}`); @@ -460,6 +523,10 @@ function main(argv /*:: ?: Array */) /*: void */ { `(node scripts/ios-prebuild -c -f ${args.flavor}).`, ); } + if (args.requireStampedVersion) { + verifyVersionStamp(artifactsDir); + } + const {reactSlice, rnhHeaders} = verifyStructural(plan, artifactsDir); if (args.skipCompile) { diff --git a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js index 2cdd85487f50..3bdb8f611a4e 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -41,26 +41,6 @@ export type StubSlice = { }; */ -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 @@ -110,24 +90,60 @@ function stubSlicesFromXcframework( `headers-xcframework: failed to parse Info.plist of ${xcfwPath}: ${message}`, ); } + if ( + !Array.isArray(plist?.AvailableLibraries) || + plist.AvailableLibraries.length === 0 + ) { + throw new Error( + `headers-xcframework: ${xcfwPath} must have a non-empty AvailableLibraries array.`, + ); + } + const seen /*: Set */ = new Set(); return plist.AvailableLibraries.map(lib => { + if ( + lib == null || + typeof lib.SupportedPlatform !== 'string' || + lib.SupportedPlatform.length === 0 || + (lib.SupportedPlatformVariant !== undefined && + (typeof lib.SupportedPlatformVariant !== 'string' || + lib.SupportedPlatformVariant.length === 0)) + ) { + throw new Error( + `headers-xcframework: invalid platform metadata in ${xcfwPath}.`, + ); + } const key = lib.SupportedPlatformVariant != null ? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}` : lib.SupportedPlatform; - const recipe = PLATFORM_STUB_RECIPES[key]; - if (recipe == null) { + if (!Object.hasOwn(PLATFORM_STUB_RECIPES, key)) { throw new Error( `headers-xcframework: no stub recipe for slice '${key}' of ` + `${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`, ); } + const recipe = PLATFORM_STUB_RECIPES[key]; + const archs = lib.SupportedArchitectures; + if ( + !Array.isArray(archs) || + archs.length === 0 || + archs.some(a => typeof a !== 'string' || !/^[A-Za-z0-9_]+$/.test(a)) || + new Set(archs).size !== archs.length + ) { + throw new Error( + `headers-xcframework: invalid SupportedArchitectures for slice '${key}' of ${xcfwPath}.`, + ); + } + if (seen.has(key)) { + throw new Error( + `headers-xcframework: duplicate slice '${key}' of ${xcfwPath}.`, + ); + } + seen.add(key); return { name: key, sdk: recipe.sdk, - targets: lib.SupportedArchitectures.map( - a => `${a}-apple-${recipe.os}${recipe.suffix}`, - ), + targets: archs.map(a => `${a}-apple-${recipe.os}${recipe.suffix}`), }; }); } @@ -144,6 +160,9 @@ function composeHeadersOnlyXcframework( stage /*: string */, slices /*: Array */, ) /*: string */ { + if (!Array.isArray(slices) || slices.length === 0) { + throw new Error(`headers-xcframework: ${name} requires non-empty slices.`); + } const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); // try/finally so an xcrun/xcodebuild failure mid-compose doesn't leave the // .stub-work-* staging dir behind in outDir. @@ -273,8 +292,6 @@ function buildDepsHeadersXcframework( } module.exports = { - CATALYST_STUB_SLICE, - DEFAULT_STUB_SLICES, DEPS_HEADERS_XCFRAMEWORK_NAME, buildDepsHeadersXcframework, composeHeadersOnlyXcframework, diff --git a/packages/react-native/scripts/ios-prebuild/utils.js b/packages/react-native/scripts/ios-prebuild/utils.js index d1c60f838b52..99a98c65e34b 100644 --- a/packages/react-native/scripts/ios-prebuild/utils.js +++ b/packages/react-native/scripts/ios-prebuild/utils.js @@ -12,6 +12,7 @@ const {execSync} = require('child_process'); const fs = require('fs'); +const path = require('path'); /** * Creates a folder if it does not exist @@ -28,6 +29,31 @@ function createFolderIfNotExists(folderPath /*:string*/) /*: string */ { return folderPath; } +function findFirst( + dir /*: string */, + predicate /*: (name: string) => boolean */, + depth /*: number */, +) /*: string | null */ { + if (depth <= 0 || !fs.existsSync(dir)) { + return null; + } + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + const full /*: string */ = path.join(dir, entry.name); + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + if (predicate(entry.name)) { + return full; + } + if (entry.isDirectory()) { + const hit = findFirst(full, predicate, depth - 1); + if (hit != null) { + return hit; + } + } + } + return null; +} + function throwIfOnEden() { try { execSync('eden info', {stdio: 'ignore'}); @@ -104,6 +130,7 @@ async function computeNightlyTarballURL( module.exports = { createFolderIfNotExists, + findFirst, throwIfOnEden, createLogger, computeNightlyTarballURL, diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 1dcb2408a99f..c1fb1c788c4f 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,22 +13,52 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); +const {stubSlicesFromXcframework} = require('./headers-xcframework'); const utils = require('./utils'); const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); const {execFileSync} = childProcess; // [macOS] -const {createLogger} = utils; +const {createLogger, findFirst} = utils; const frameworkLog = createLogger('XCFramework'); +function resolveHermesHeaders( + buildFolder /*: string */, + required /*: boolean */, +) /*: ?string */ { + const hermesArtifacts = path.join(buildFolder, 'artifacts', 'hermes'); + const baseInclude = path.join(hermesArtifacts, 'destroot', 'include'); + if (fs.existsSync(path.join(baseInclude, 'hermes', 'hermes.h'))) { + return baseInclude; + } + + const includeDir = findFirst(hermesArtifacts, name => name === 'include', 8); + if ( + includeDir != null && + fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h')) + ) { + return includeDir; + } + + if (required) { + throw new Error( + 'Cannot compose ReactNativeHeaders: will not resolve because ' + + "hermes/hermes.h is missing. Stage the hermes-ios tarball's " + + 'destroot/include into .build/artifacts/hermes before composing.', + ); + } + return null; +} + function buildXCFrameworks( rootFolder /*: string */, buildFolder /*: string */, frameworkFolders /*: Array */, buildType /*: BuildFlavor */, identity /*: ?string */, + requireHermes /*: boolean */ = false, ) { // Let's run codegen for FBReactNativeSpec otherwise some headers will be missing generateFBReactNativeSpecIOS('.'); @@ -89,7 +119,14 @@ function buildXCFrameworks( emitReactFrameworkHeaders, } = require('./headers-compose'); const plan = computeSpecPlan(rootFolder); - emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // Built header tree from the slice jobs (downloaded to `.build/headers`). + // When present, the compose sources header CONTENT from here (see + // stageEntries) so build-time generated/stamped headers — notably the + // version-stamped ReactNativeVersion.h — ship without re-stamping this + // checkout. Absent (e.g. a local compose with no prior build) → source tree. + const builtHeadersDir = path.join(buildFolder, 'headers'); + const overlayDir = fs.existsSync(builtHeadersDir) ? builtHeadersDir : null; + emitReactFrameworkHeaders(outputPath, plan, rootFolder, overlayDir); // ReactNativeHeaders is PURE-RN — the third-party deps namespaces ship in // the ReactNativeDependenciesHeaders sidecar built by the deps prebuild // (scripts/releases/ios-prebuild), so the core compose no longer needs the @@ -98,26 +135,16 @@ function buildXCFrameworks( // ReactNativeHeaders so consumers resolve `` out of the box // (same fold ensureHeadersLayout does consumer-side). The hermes-ios tarball // is staged at .build/artifacts/hermes/destroot/include by the hermes prebuild - // step; pass it when its `hermes/` namespace is present, else null (then - // `` stays consumer-composed, as before). - const hermesInclude = path.resolve( - process.cwd(), - '.build', - 'artifacts', - 'hermes', - 'destroot', - 'include', - ); - const hermesHeaders = fs.existsSync(path.join(hermesInclude, 'hermes')) - ? hermesInclude - : null; + // step; pass it when its `hermes/` namespace is present, else null for local + // development. CI release composition requires it and fails closed. + const hermesHeaders = resolveHermesHeaders(buildFolder, requireHermes); const headersXcfw = buildReactNativeHeadersXcframework( path.dirname(outputPath), plan, rootFolder, - true, // include the mac-catalyst slice in the real compose + stubSlicesFromXcframework(outputPath), hermesHeaders, - outputPath, // [macOS] Match the binary, including its macOS slice. + overlayDir, ); if (identity) { @@ -280,4 +307,5 @@ function signXCFramework( module.exports = { buildXCFrameworks, + resolveHermesHeaders, }; diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index b3d960890dfe..0d0e93da8927 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -18,6 +18,9 @@ const yargs = require('yargs'); const LAST_BUILD_FILENAME = 'React-Core-prebuilt/.last_build_configuration'; +// Not a valid configuration, so finding it means the swap did not finish. +const REPLACEMENT_IN_PROGRESS = 'in-progress'; + function validateBuildConfiguration(configuration /*: string */) { if (!['Debug', 'Release'].includes(configuration)) { throw new Error(`Invalid configuration ${configuration}`); @@ -42,10 +45,12 @@ function shouldReplaceRnCoreConfiguration(configuration /*: string */) { ); return false; } + return true; } - // Assumption: if there is no stored last build, we assume that it was build for debug. - if (!fileExists && configuration === 'Debug') { + // With no marker the on-disk flavor is Debug: the podspec installs the debug + // tarball (see resolve_podspec_source in scripts/cocoapods/rncore.rb). + if (configuration === 'Debug') { console.log( 'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt', ); @@ -59,7 +64,7 @@ function replaceRNCoreConfiguration( configuration /*: string */, version /*: string */, podsRoot /*: string */, -) { +) /*: void */ { // Filename comes from rncore.rb const tarballURLPath = `${podsRoot}/ReactNativeCore-artifacts/reactnative-core-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`; @@ -73,18 +78,6 @@ function replaceRNCoreConfiguration( const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt'); fs.mkdirSync(tmpExtractDir, {recursive: true}); - // Preserve Expo-generated modulemap before replacing directories - const useFrameworksModulemapName = 'React-use-frameworks.modulemap'; - const useFrameworksModulemapPath = path.join( - finalLocation, - useFrameworksModulemapName, - ); - let savedModulemap = null; - if (fs.existsSync(useFrameworksModulemapPath)) { - console.log('Preserving', useFrameworksModulemapName); - savedModulemap = fs.readFileSync(useFrameworksModulemapPath); - } - try { console.log('Extracting the tarball to temp dir', tarballURLPath); const result = spawnSync( @@ -110,98 +103,30 @@ function replaceRNCoreConfiguration( ); } - // 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()); - for (const dirent of dirs) { - const direntName = - typeof dirent.name === 'string' ? dirent.name : dirent.name.toString(); - const dirPath = `${finalLocation}/${direntName}`; - console.log('Removing directory', dirPath); - fs.rmSync(dirPath, {force: true, recursive: true}); - } - - // Move extracted directories from temp to final location - const extractedEntries = fs - .readdirSync(tmpExtractDir, {withFileTypes: true}) - .filter(dirent => dirent.isDirectory()); - for (const dirent of extractedEntries) { - const direntName = - typeof dirent.name === 'string' ? dirent.name : dirent.name.toString(); - const src = path.join(tmpExtractDir, direntName); - const dst = path.join(finalLocation, direntName); - const mvResult = spawnSync('mv', [src, dst], {stdio: 'inherit'}); - if (mvResult.status !== 0) { - // Fallback: copy recursively then remove source - console.log(`mv failed for ${direntName}, falling back to cp -R`); - const cpResult = spawnSync('cp', ['-R', src, dst], { - stdio: 'inherit', - }); - if (cpResult.status !== 0) { - throw new Error( - `cp fallback failed with exit code ${cpResult.status}`, - ); - } + // Replace only the compiled framework. Headers/ is flattened from + // ReactNativeHeaders by the podspec prepare_command, and the prebuild + // compose job emits one set of those headers for both configurations, so a + // config switch leaves them identical. Leaving them alone keeps + // Headers/module.modulemap — which consumers activate through + // -fmodule-map-file — in place for the whole build; deleting and recreating + // it mid-build lets a concurrent dependency scan miss it, and the React + // module then precompiles without it (#57803). + const dest = path.join(finalLocation, 'React.xcframework'); + console.log('Replacing', dest); + fs.rmSync(dest, {force: true, recursive: true}); + const mvResult = spawnSync('mv', [xcfwPath, dest], {stdio: 'inherit'}); + if (mvResult.status !== 0) { + // Fallback: copy recursively then remove source + console.log('mv failed for React.xcframework, falling back to cp -R'); + const cpResult = spawnSync('cp', ['-R', xcfwPath, dest], { + stdio: 'inherit', + }); + if (cpResult.status !== 0) { + throw new Error(`cp fallback failed with exit code ${cpResult.status}`); } } - - // 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}); - - // Restore Expo-generated modulemap after directory replacement. - // Runs in finally so it is not skipped if mv/cp partially fails. - if (savedModulemap != null) { - const restoredPath = path.join(finalLocation, useFrameworksModulemapName); - fs.writeFileSync(restoredPath, savedModulemap); - console.log('Restored', useFrameworksModulemapName); - } } } @@ -210,6 +135,10 @@ function updateLastBuildConfiguration(configuration /*: string */) { fs.writeFileSync(LAST_BUILD_FILENAME, configuration); } +function markReplacementInProgress() /*: void */ { + fs.writeFileSync(LAST_BUILD_FILENAME, REPLACEMENT_IN_PROGRESS); +} + function main( configuration /*: string */, version /*: string */, @@ -219,37 +148,48 @@ function main( validateVersion(version); if (!shouldReplaceRnCoreConfiguration(configuration)) { + // A fresh install leaves no marker; record the flavor we skipped on. + if (!fs.existsSync(LAST_BUILD_FILENAME)) { + updateLastBuildConfiguration(configuration); + } return; } + // Invalidate before touching the framework so an interrupted swap is + // detectable on the next run. + markReplacementInProgress(); replaceRNCoreConfiguration(configuration, version, podsRoot); updateLastBuildConfiguration(configuration); console.log('Done replacing React Native prebuilt'); } -// This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok -const argv = yargs - .option('c', { - alias: 'configuration', - description: - 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".', - }) - .option('r', { - alias: 'reactNativeVersion', - description: - 'The Version of React Native associated with the React-Core prebuilt tarball.', - }) - .option('p', { - alias: 'podsRoot', - description: 'The path to the Pods root folder', - }) - .usage('Usage: $0 -c Debug -r -p ').argv; - -// $FlowFixMe[prop-missing] -const configuration = argv.configuration; -// $FlowFixMe[prop-missing] -const version = argv.reactNativeVersion; -// $FlowFixMe[prop-missing] -const podsRoot = argv.podsRoot; - -main(configuration, version, podsRoot); +if (require.main === module) { + // This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok + const argv = yargs + .option('c', { + alias: 'configuration', + description: + 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".', + }) + .option('r', { + alias: 'reactNativeVersion', + description: + 'The Version of React Native associated with the React-Core prebuilt tarball.', + }) + .option('p', { + alias: 'podsRoot', + description: 'The path to the Pods root folder', + }) + .usage('Usage: $0 -c Debug -r -p ').argv; + + // $FlowFixMe[prop-missing] + const configuration = argv.configuration; + // $FlowFixMe[prop-missing] + const version = argv.reactNativeVersion; + // $FlowFixMe[prop-missing] + const podsRoot = argv.podsRoot; + + main(configuration, version, podsRoot); +} + +module.exports = {replaceRNCoreConfiguration}; diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 578ebc5c63da..0ad73872420f 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -44,8 +44,10 @@ * directing you to `--deintegrate`). * * Options: - * --version React Native version (default: the resolved - * node_modules/react-native version). + * --version React Native version. Pinned into + * .spm-injected.json and reused by later runs + * until a new one is passed (default: the + * resolved node_modules/react-native version). * --yes Skip the dirty-pbxproj confirmation prompt. * [add] --xcodeproj Which .xcodeproj to inject into (when several). * [add] --product-name Which app target to inject into (when several). @@ -55,6 +57,7 @@ * must contain debug/ and release/ cache slots. * [advanced] --download Artifact policy (default: auto). * [advanced] --skip-codegen Skip the react-native codegen step. + * [advanced] --config-command Override the autolinking config command. * * Steps performed (add/update): * 1. react-native codegen → build/generated/ios/ + install SPM codegen template @@ -83,14 +86,20 @@ const { } = require('./spm/generate-spm-autolinking'); const { generateAutolinkingConfig, + parseConfigCommandJson, + readEnvConfigCommand, + resolveEnvConfigCommand, } = require('./spm/generate-spm-autolinking-config'); const {main: generatePackage} = require('./spm/generate-spm-package'); const {findSourcePath} = require('./spm/generate-spm-package'); const { + SPM_INJECTED_MARKER, cleanupDanglingJavaScriptCoreRef, cleanupLeftoverPodsGroup, findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, + readArtifactsVersionOverride, + readPinnedConfigCommand, removeSpmInjection, } = require('./spm/generate-spm-xcodeproj'); const {scaffoldAll} = require('./spm/scaffold-package-swift'); @@ -147,7 +156,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { .option('version', { type: 'string', describe: - 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json', + 'React Native version (e.g. 0.80.0). Sticks: later runs reuse it until you pass a new one. Defaults to the version in node_modules/react-native/package.json', }) .option('yes', { type: 'boolean', @@ -187,6 +196,11 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { default: false, describe: '[advanced] Skip the react-native codegen step', }) + .option('config-command', { + type: 'string', + describe: + '[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Either way `add`/`update` remembers the value in .spm-injected.json, so later runs and Xcode builds reuse it. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'', + }) .usage( 'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.', ) @@ -214,6 +228,10 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { version: parsed.version ?? null, artifacts: parsed.artifacts ?? null, skipCodegen: parsed['skip-codegen'], + configCommand: + parsed['config-command'] != null + ? parseConfigCommandJson(parsed['config-command'], '--config-command') + : null, downloadPolicy: parsed.download, productName: parsed['product-name'] ?? null, xcodeprojPath: parsed.xcodeproj ?? null, @@ -362,19 +380,31 @@ function resolveReactNativeRoot( return reactNativeRoot; } +// Explicit `--version` → the version an earlier `--version` pinned into the +// injection marker → node_modules/react-native/package.json. The pin makes +// `--version` stick for later flagless runs, which would otherwise re-point the +// project at a different artifact slot than the one it was wired to. function determineVersion( args /*: SetupArgs */, reactNativeRoot /*: string */, + appRoot /*: string */, ) /*: string */ { - let version = args.version; - if (version == null) { - // $FlowFixMe[incompatible-type] JSON.parse returns any - const pkgJson /*: {version: string} */ = JSON.parse( - fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + if (args.version != null) { + return args.version; + } + const pinned = readArtifactsVersionOverride(appRoot); + if (pinned != null) { + log( + `Using version ${pinned} pinned in ${SPM_INJECTED_MARKER} by an earlier ` + + '--version. Pass --version to change it.', ); - version = pkgJson.version; + return pinned; } - return version; + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkgJson /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + ); + return pkgJson.version; } function runCodegenStep( @@ -427,7 +457,7 @@ async function runScaffold( // a comment — that's how SPM's manifest hash bumps on slot transitions. let cacheSlotLabel /*: ?string */ = null; try { - const rawVersion = args.version ?? determineVersion(args, reactNativeRoot); + const rawVersion = determineVersion(args, reactNativeRoot, appRoot); const slotVersion = await resolveCacheSlotVersion(rawVersion); cacheSlotLabel = `${slotVersion}/dual-flavor`; } catch { @@ -833,6 +863,7 @@ async function setupXcodeproj( // (injectSpmIntoExistingXcodeproj preserves it — see // generate-spm-xcodeproj.js). artifactsVersionOverride: args.version ?? null, + configCommand: resolveConfigCommandToPin(args), }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); @@ -892,6 +923,88 @@ function logNextSteps( log('To remove SPM later: `npx react-native spm deinit`'); } +// The autolinking config command for this run: an explicit `--config-command` +// first, then the value a previous `add`/`update` pinned into the injection +// marker. undefined means "no explicit command", which is what makes +// generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and +// then to the built-in default — so the pin has to be WITHHELD while the env +// var is set, or a stale pin would outrank a developer's env override. +function resolveExplicitConfigCommand( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: Array | void */ { + if (args.configCommand != null) { + return args.configCommand; + } + if (readEnvConfigCommand() != null) { + return undefined; + } + const pinned = readPinnedConfigCommand(appRoot); + if (pinned == null) { + return undefined; + } + log( + `Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` + + pinned.join(' '), + ); + return pinned; +} + +// The command to record in the injection marker. The env var is resolved here +// too, because the Xcode build phase inherits neither the flag nor the shell +// that set it — an env-only override that went unpinned would leave the build +// re-deriving autolinking.json with the default command. null pins nothing and +// preserves any earlier pin. An invalid env value throws, as the flag does, +// though `add` has already failed closed on it by this point. +function resolveConfigCommandToPin( + args /*: SetupArgs */, +) /*: ?Array */ { + return args.configCommand ?? resolveEnvConfigCommand(); +} + +// Generate autolinking.json, failing closed on a config-command error. +// +// generateAutolinkingConfig throws ONLY when the config command itself fails — +// a non-zero exit, unparseable output, or a config missing +// project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed +// and emit an empty Autolinked package, which only surfaced much later as an +// inscrutable `unable to resolve module dependency` at build time. Instead we +// set process.exitCode = 2 (a hard Xcode build-phase error, matching the +// RemoteVersionError path) and return null so the caller stops. +// +// A genuinely native-module-free app does NOT reach the error path: its command +// exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig +// returns normally and the empty-package path downstream stays valid. +function generateAutolinkingConfigOrFailClosed( + opts /*: { + projectRoot: string, + configCommand?: Array, + generate?: typeof generateAutolinkingConfig, + } */, +) /*: ?AutolinkingConfigResult */ { + const generate = opts.generate ?? generateAutolinkingConfig; + try { + return generate({ + projectRoot: opts.projectRoot, + configCommand: opts.configCommand, + }); + } catch (e) { + logError( + `Failed to generate autolinking.json: ${e.message}\n` + + 'The autolinking config command failed. If this app replaces ' + + '@react-native-community/cli autolinking (e.g. an Expo app), set ' + + 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' + + 'JSON argv array whose command prints the React Native CLI config, ' + + 'e.g. \'["npx","expo-modules-autolinking","react-native-config",' + + '"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' + + `have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` + + '--config-command to replace a stale one.', + ); + process.exitCode = 2; + return null; + } +} + async function main(argv /*:: ?: Array */) /*: Promise */ { let appRoot = process.cwd(); const projectRoot = findProjectRoot(appRoot); @@ -981,22 +1094,22 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null; if (needsCliConfig) { log('Generating autolinking.json (CLI config)...'); - try { - autolinkingConfigResult = generateAutolinkingConfig({projectRoot}); - log( - `Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`, - ); - } catch (e) { - logError( - `generate-spm-autolinking-config failed: ${e.message}. External native modules may not be discovered.`, - ); + autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({ + projectRoot, + configCommand: resolveExplicitConfigCommand(args, appRoot), + }); + if (autolinkingConfigResult == null) { + // Fail closed: the config command errored and the helper already set + // process.exitCode = 2. Stop rather than emit an empty Autolinked package. + return; } + log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`); } const reactNativeRoot = resolveReactNativeRoot( autolinkingConfigResult, projectRoot, ); - const version = determineVersion(args, reactNativeRoot); + const version = determineVersion(args, reactNativeRoot, appRoot); log(`React Native version: ${version}`); // Resolve remote SPM mode ONCE up front. remotePackageConfig throws @@ -1158,8 +1271,13 @@ if (require.main === module) { module.exports = { main, detectStandardRnLayoutRedirect, + determineVersion, findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, + parseArgs, resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, shouldAutoDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md deleted file mode 100644 index 86116637e3f7..000000000000 --- a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md +++ /dev/null @@ -1,707 +0,0 @@ ---- -title: Swift Package Manager Support for React Native iOS -author: -- Christian Falch -date: 2026-03-17 ---- - -# RFC: Swift Package Manager Support for React Native iOS - -## Summary - -Add Swift Package Manager (SPM) as an officially supported build system for -React Native iOS apps, alongside CocoaPods. The approach uses **prebuilt -XCFrameworks** published to Maven, eliminating the need for source compilation -of React Native internals and enabling fast, reproducible builds. - -## Basic example - -### New project - -```bash -npx react-native init MyApp -cd MyApp -npx react-native spm # auto-detects first-run → init; prompts to rename legacy CocoaPods xcodeproj -npm run ios -``` - -A future CLI integration (e.g., an `--ios-build-system spm` flag on -`react-native init`) could run `react-native spm init` automatically as part -of project creation, eliminating the manual step. - -### Existing project - -```bash -cd MyApp -npx react-native spm -# Prompted: rename CocoaPods MyApp.xcodeproj → MyApp.xcodeproj.legacy? -# Accept (Y) — the SPM xcodeproj writes to the now-free MyApp.xcodeproj slot, -# `npm run ios` resolves to it unambiguously. The legacy stays on disk -# (git mv tracks the rename cleanly) for rollback via `spm clean --project`. -``` - -After initial setup, day-to-day development requires no extra commands. Adding -or removing JS dependencies that include native code is handled automatically -by a build-phase sync step (see [Auto-sync build phase](#auto-sync-build-phase)). - -## Motivation - -### Apple is moving away from CocoaPods - -SPM is Apple's endorsed dependency manager. Xcode's SPM integration improves -with every release — package resolution, build caching, and IDE features all -assume SPM as the primary workflow. CocoaPods is community-maintained and has -been officially sunsetted — the CocoaPods trunk will become permanently -read-only on **December 2, 2026**, after which no new pods or updates can be -published ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)). -Existing builds will continue to work, but the ecosystem is moving on. - -### Build speed - -Prebuilt XCFrameworks skip compilation of ~2000 C++/Objective-C files. A clean -SPM build of rn-tester compiles only app sources and codegen output. This is a -significant improvement for CI pipelines and developer iteration speed. - -### Reduced onboarding friction - -CocoaPods requires Ruby, Bundler, and a working gem environment — a frequent -source of setup issues, especially on new machines or in CI. SPM requires only -Xcode. Removing the Ruby toolchain dependency simplifies onboarding and reduces -the surface area for environment-related build failures. - -### Adoption barrier - -Many organizations mandate SPM for iOS dependencies. Teams in these -environments are currently blocked from adopting React Native, or must maintain -custom workarounds. First-class SPM support might help overcoming this barrier. - -### Compatibility - -The SPM workflow generates an `AppName.xcodeproj` that takes the same -filename slot as the legacy CocoaPods xcodeproj. On `init`, the script -prompts to rename the existing CocoaPods project to `AppName.xcodeproj.legacy` -— preserving it for rollback while letting the community CLI's -`findXcodeProject` resolve `npm run ios` to the SPM project unambiguously. -Teams can migrate at their own pace before CocoaPods trunk goes read-only -in December 2026, and `spm clean --project` reverses the migration when -needed. - -## Detailed design - -### Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ Maven (artifacts) │ -│ ├── React.xcframework (~200 MB, debug) │ -│ ├── ReactNativeDependencies.xcframework │ -│ └── hermes-engine.xcframework │ -└──────────────────┬──────────────────────────────┘ - │ download + cache - ▼ -┌─────────────────────────────────────────────────┐ -│ ~/Library/Caches/com.facebook.ReactNative/ │ -│ └── spm-artifacts/{version}/{flavor}/ │ -└──────────────────┬──────────────────────────────┘ - │ symlink - ▼ -┌──────────────────────────────────────────────────┐ -│ App ios/ │ -│ ├── AppName.xcodeproj/ (committed) │ -│ │ └── .spm-managed (marker file) │ -│ ├── AppName.xcodeproj.legacy/ (committed if │ -│ │ rename was │ -│ │ accepted) │ -│ └── build/ │ -│ ├── generated/ │ -│ │ ├── autolinking/ (generated) │ -│ │ │ ├── Package.swift │ -│ │ │ ├── autolinking.json │ -│ │ │ ├── packages/ (synth wrappers) │ -│ │ │ └── libs/ (alias symlinks │ -│ │ │ for self-managed│ -│ │ │ deps; basename │ -│ │ │ = SwiftName) │ -│ │ └── ios/ (codegen) │ -│ └── xcframeworks/ (symlinks) │ -│ ├── Package.swift │ -│ ├── React.xcframework -> cache │ -│ ├── ReactNativeDependencies.xcframework │ -│ └── hermes-engine.xcframework │ -└──────────────────────────────────────────────────┘ -``` - -### Pipeline - -`react-native spm` orchestrates six steps (the underlying script is -`scripts/setup-apple-spm.js`): - -| # | Step | Script | Output | -|---|------|--------|--------| -| 1 | CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | -| 2 | Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | -| 3 | Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` + source symlinks | -| 4 | Download | `spm/download-spm-artifacts.js` | Cached xcframeworks | -| 5 | Package | `spm/generate-spm-package.js` | `build/xcframeworks/Package.swift` + symlinks | -| 6 | Xcodeproj | `spm/generate-spm-xcodeproj.js` | `AppName.xcodeproj` + `.spm-managed` marker (`init` only; create-if-missing on subsequent runs) | -| — | Sync (build-time) | `spm/sync-spm-autolinking.js` | Re-runs steps 1–5 when inputs change (downloads artifacts if missing) | - -The `init` action additionally (a) prompts to rename any existing -CocoaPods `.xcodeproj` to `.xcodeproj.legacy` before step 6, and -(b) appends SPM-specific entries to `.gitignore` -(`build/generated/`, `build/xcframeworks/`, `.build/`, `Package.resolved`). -Existing entries are not duplicated. - -### Auto-sync build phase - -After initial setup, developers shouldn't need to re-run `react-native spm` -manually when dependencies change. The generated `.xcodeproj` includes a -**Sync SPM Autolinking** pre-build phase (ordered first, before VFS overlay) -that: - -1. Checks whether xcframework artifacts are missing (`artifacts.json` or - `React.xcframework` absent). This covers fresh clones where no setup - script has been run yet. -2. Compares timestamps of `package.json`, `react-native.config.js`, and the - `node_modules` directory against `autolinked/.spm-sync-stamp`. In - monorepos where `node_modules` is hoisted, the parent directory is also - checked. -3. If any check triggers (or the stamp is missing): sources `with-environment.sh` - for node PATH, then runs `spm/sync-spm-autolinking.js` which re-executes - codegen, artifact download (if needed), autolinking, and package generation. -4. If all inputs are fresh: exits immediately (~1ms shell check). - -Failures emit `warning:` and exit 0 — the existing autolinking may still be -valid. The stamp file is written on successful sync. - -The sync step handles React Native version changes automatically: after -`npm install` pulls a new version, the `node_modules` mtime changes, the sync -step regenerates autolinking and recreates xcframework symlinks pointing to the -new version's cache directory. - -The sync step is **self-healing**: if xcframework artifacts are missing (e.g., -the local cache at `~/Library/Caches/com.facebook.ReactNative/` was deleted, -or the project was freshly cloned), it automatically downloads them before -proceeding with autolinking and package generation. This means `react-native spm` -is only strictly required for initial project scaffolding (`init`); subsequent -builds recover automatically. - -### Cleaning generated SPM state - -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch the project's `build/` or `.build/` directories. Xcode provides no -hook to run custom scripts during GUI clean actions. - -`react-native spm clean` is scoped by opt-in flags. The default removes only -generated dirs under `appRoot`: - -```bash -react-native spm clean # build/xcframeworks/, build/generated/, .build/ -react-native spm clean --project # also: delete SPM xcodeproj, restore .legacy backup -react-native spm clean --derived-data # also: this app's Xcode DerivedData entries -react-native spm clean --cache # also: cached xcframework slot for current version -react-native spm clean --all # = --project --derived-data --cache -``` - -Destructive scopes (`--project`, `--derived-data`, `--cache`, `--all`) prompt -for confirmation (bypass with `--yes`). `--project` is the reverse of the -init-time rename migration — deleting the SPM xcodeproj and restoring -`.xcodeproj.legacy` to its original filename if a backup exists. - -After a plain `clean`, run `react-native spm update` (or open the checked-in -`.xcodeproj` and build) to regenerate state. SPM package resolution is locked -for the duration of a build — if only stubs were left in place, Xcode would -resolve stubs and never pick up the real packages generated by the sync build -phase. - -### Stub packages for fresh clones - -Xcode resolves SPM packages **before** any build phase runs. On a fresh clone, -the referenced package directories (`build/xcframeworks`, `autolinked`, -`build/generated/ios`) may not exist yet, causing package resolution to fail. - -To solve this, `generate-spm-xcodeproj.js` writes **stub `Package.swift` -files** into each referenced sub-package directory that doesn't already have -one. Each stub defines the expected library products backed by a minimal -placeholder target (`.stub/Stub.swift`). This lets Xcode resolve packages -successfully even before the first build. On the first build, the auto-sync -build phase overwrites the stubs with real Package.swift files generated from -downloaded artifacts and autolinking output. - -### Caching and CI - -Xcframeworks are cached at -`~/Library/Caches/com.facebook.ReactNative/spm-artifacts/{version}/{flavor}/` -by default. The download step accepts a `--output` flag to write xcframeworks -to an explicit directory. - -For CI pipelines (GitHub Actions, CircleCI, etc.), cache the default path -keyed by the React Native version and flavor to avoid re-downloading -xcframeworks on every build. - -The Maven base URL can be overridden via the `ENTERPRISE_REPOSITORY` -environment variable for teams that mirror artifacts to an internal registry. - -**Planned:** A `RN_SPM_CACHE_DIR` environment variable to override the default -cache directory. This is not yet implemented in the current POC but is needed -for CI environments where a specific path must be persisted across builds. - -### Package graph - -The generated `.xcodeproj` references three local packages directly -via `XCLocalSwiftPackageReference` — no app-level `Package.swift` is required: - -``` -AppName.xcodeproj - ├── XCLocalSwiftPackageReference → build/xcframeworks/Package.swift - │ ├── ReactNative (product, wraps React binaryTarget) - │ ├── ReactNativeDependencies (binaryTarget) - │ └── hermes-engine (binaryTarget) - ├── XCLocalSwiftPackageReference → build/generated/ios/Package.swift - │ ├── ReactCodegen (target — codegen output) - │ └── ReactAppDependencyProvider (target) - └── XCLocalSwiftPackageReference → build/generated/autolinking/Package.swift - └── ... (targets — symlinked sources) -``` - -These three sub-package paths are **stable**: adding or removing community -deps changes the contents of `build/generated/autolinking/Package.swift` -(gitignored) but never the xcodeproj's references. That's why the -`.xcodeproj` is committed once and not regenerated on subsequent runs. - -The xcodeproj generation is **create-if-missing** on `update` (use -`--force-xcodeproj` for an explicit overwrite). This protects user-side -Xcode edits — signing, capabilities, Build Phases, scheme settings — from -being clobbered. Teammates can clone the repo and open Xcode immediately: -stub `Package.swift` files in each sub-package directory let SPM resolution -succeed before the first build, and the auto-sync build phase downloads -artifacts and writes the real sub-packages on first compile. - -### Header resolution - -React Native uses CocoaPods-style imports (`#import `) that -SPM does not natively support. Two mechanisms solve this: - -1. **XCFramework `Headers/` layout.** The prebuild step organizes headers by - `header_dir` (e.g., `Headers/React/`, `Headers/react/renderer/core/`). - Adding `-I Headers` to search paths resolves most imports directly. - -2. **VFS overlay.** A Clang virtual filesystem overlay (`React-VFS.yaml`) - remaps remaining edge cases — headers that appear in multiple pods or have - platform variants. The overlay is generated as a template at prebuild time - and resolved with local paths at setup time. - -### Local native modules - -Modules not discovered via autolinking (e.g., app-specific native modules) are -declared in `react-native.config.js`: - -```js -// react-native.config.js -module.exports = { - spmModules: [ - { - name: 'MyNativeModule', // SPM target name - path: 'ios/MyNativeModule', // path to source files - exclude: ['*.podspec'], // files to exclude from the target - publicHeadersPath: '.', // header search path for consumers - }, - ], -}; -``` - -Each entry becomes a target in `autolinked/Package.swift`. Sources outside the -autolinked directory are mirrored with **file-level symlinks** (SPM rejects -directory symlinks that resolve outside the package root). - -### Self-managed deps and package identity - -A community library that ships its own `Package.swift` (instead of being -wrapped by the autolinker) is referenced directly. SPM derives the package -identity for a `.package(path:)` dependency from the path's basename — and -a common convention is to ship the manifest inside an `ios/` subdir -(`/ios/Package.swift`). Two libs following that convention would both -have identity `"ios"`, and SPM rejects with `Conflicting identity for ios`. - -To make every reference globally unique by construction, the autolinker -materializes each self-managed dep as a symlink at -`build/generated/autolinking/libs//` pointing at the dep's real -manifest dir. The aggregator `Package.swift` then references the symlink -(`path: "libs/"`), and SPM uses the symlink basename — the -library's Swift module name — as the package identity. Swift module names -are already unique per dep (deriving from the npm package name), so this -sidesteps the collision in all cases, including against the codegen -package at `build/generated/ios/`. - -The `libs/` directory is wiped and recreated on every autolinker run, so -stale aliases for uninstalled deps disappear automatically. - -### Third-party library support - -The current implementation handles React Native's own frameworks and app-local -native modules. The primary goal for third-party libraries is to **build using -SPM**. Shipping prebuilt xcframeworks is the recommended approach for faster -builds, but it is not a requirement — libraries can also be compiled from -source via SPM targets. This ensures that library authors with limited -resources can support SPM without needing to set up a prebuild CI pipeline. - -#### Library metadata in `react-native.config.js` - -`react-native.config.js` is the canonical place for library SPM metadata. The -autolinking pipeline already scans `node_modules` for this file to discover -iOS and Android native modules. Adding SPM config alongside the existing -`dependency.platforms.ios` keeps a single source of truth, requires no new -discovery mechanism, and can express things `Package.swift` cannot — such as -Maven URL templates with version and flavor placeholders for downloading -prebuilt xcframeworks. Libraries may still ship a `Package.swift` for direct -SPM consumers outside the React Native ecosystem, but React Native autolinking -reads `react-native.config.js`. - -#### Prebuilt xcframeworks (primary path) - -React Native already prebuilds its core into xcframeworks and publishes them to -Maven. This is the model we want every library to follow. Libraries declare SPM -metadata in `react-native.config.js`: - -```js -// react-native-maps/react-native.config.js -module.exports = { - dependency: { - platforms: { - ios: { /* existing autolinking config */ }, - }, - }, - spm: { - // Primary: prebuilt xcframework (downloaded at setup time) - xcframework: { - name: 'ReactNativeMaps', - // URL template — {version}, {rn-version}, {flavor} resolved at download time - url: 'https://maven.example.com/.../react-native-maps-{version}-xcframework-{flavor}.tar.gz', - }, - // Fallback: source compilation (used during local development or when - // xcframework is unavailable) - source: { - name: 'ReactNativeMaps', - path: 'ios', - publicHeadersPath: '.', - exclude: ['*.podspec', 'Tests/**'], - dependencies: ['MapKit'], - resources: ['ios/Resources/**'], - }, - }, -}; -``` - -**Planned (Phase 2):** When `react-native spm` gains third-party library -support, it will: -1. If `spm.xcframework` is declared, download the prebuilt binary (fast path). -2. If the download fails or the `--source` flag is passed, fall back to - `spm.source` and compile from symlinked sources. -3. If neither is declared, the library requires a manual `spmModules` entry. - -Currently, only `spmModules` entries (see [Local native modules](#local-native-modules)) -are supported. The `spm.xcframework` and `spm.source` config fields — including -the `dependencies` field shown above — are not yet implemented. - -#### Source compilation (fallback) - -Source-level autolinking (`spmModules` / `spm.source`) remains available for: -- **Local development** — library authors iterating on native code -- **Libraries without prebuilt xcframeworks** — transitional state -- **App-specific native modules** — code that lives in the app repo - -This reuses the existing `spmModules` mechanism: sources are mirrored with -file-level symlinks into `autolinked/`, compiled as SPM targets with -appropriate header search paths. - -#### `react-native-prebuild` CLI - -React Native already has a mature prebuild pipeline (`scripts/ios-prebuild/`) -that produces signed, packaged xcframeworks published to Maven. Rather than -asking library authors to reinvent this, we can expose the same tooling as a -reusable CLI: - -```bash -npx react-native-prebuild \ - --podspec ios/MyLibrary.podspec \ - --react-native-version 0.80.0 \ - --platforms ios,ios-simulator \ - --flavor release \ - --output dist/ - -# Output: -# dist/MyLibrary.xcframework.tar.gz -# dist/MyLibrary.framework.dSYM.tar.gz -``` - -The tool would: - -1. **Download React Native xcframeworks** for the specified version. -2. **Parse the library's podspec** to discover source files, headers, - `header_dir`, dependencies, and compiler flags. -3. **Generate a temporary Package.swift** declaring the library as a target - with dependencies on the RN xcframeworks. -4. **Build** using `xcodebuild` for each platform slice. -5. **Compose** the xcframework with organized headers, module map, and - optional VFS overlay. -6. **Sign** the xcframework with the developer's code signing identity. -7. **Package** as `.tar.gz` with dSYM symbols. - -The tool includes code signing as a built-in step. Library authors provide -their own signing identity (Apple Developer certificate); the tool handles -the `codesign` invocation. Unsigned xcframeworks trigger macOS Gatekeeper -warnings, so signing is strongly recommended for distributed artifacts. -Documentation will cover how to create and manage a signing identity for -this purpose. - -Library authors can integrate this into CI to publish prebuilt artifacts on -every release, targeting a matrix of React Native versions and build flavors. - -#### Version compatibility - -A library's xcframework must be built against a compatible React Native -version. The prebuild tool embeds metadata (React Native version, library -version, build flavor, minimum iOS version) inside the xcframework. The -download step verifies compatibility at setup time, warning if a library was -built against a different React Native version than the app is using. - -## Drawbacks - -### Transition period: supporting both CocoaPods and SPM - -With CocoaPods trunk going read-only in December 2026, the migration to SPM is -necessary rather than optional. During the transition period, both build -systems must be supported in parallel. Bug fixes, new features, and build-phase -changes need to be tested against both CocoaPods and SPM until CocoaPods -support is eventually removed. - -### Download size - -Prebuilt xcframeworks for React Native core are compressed as tar.gz archives. -Individual library xcframeworks are typically 1–15 MB in debug mode including -dSYM bundles. Both debug and release flavors are needed, which doubles the -total. While artifacts are cached locally after the first download, CI -environments without persistent caches will re-download on every build. - -### Ecosystem adoption takes time - -Third-party libraries must opt in to the prebuild workflow. During the -transition period, many libraries will only support CocoaPods. Apps that depend -on these libraries cannot fully migrate to SPM until the libraries catch up. -This creates a chicken-and-egg problem that may slow adoption. - -### SPM limitations require `.xcodeproj` generation - -SPM does not support build script phases, `post_install` hooks, or the kind of -build-time customization that CocoaPods provides via its Podfile DSL. The -current design works around this by generating an `.xcodeproj` with explicit -build phases for JS bundling, Hermes engine copying, VFS overlay setup, and -autolinking sync. This is a known limitation of the current approach. If Apple -expands SPM's plugin API to support arbitrary script execution with file I/O -and network access, the `.xcodeproj` could be eliminated in favor of a purely -SPM-native workflow — but this is a future direction that depends on Apple's -roadmap, not something this proposal can resolve. - -### Committed xcodeproj edits - -The generated `.xcodeproj` is committed and may carry user edits — -signing, capabilities, Build Phases, custom schemes. The `update` action is -**create-if-missing** to protect those edits, which means the script does -not propagate generator improvements into existing projects automatically. -Bug fixes that change the emitted pbxproj need an explicit -`--force-xcodeproj` run to take effect. A future improvement could -preserve user-side edits through a merge step rather than full overwrite — -see "Hardening `update --force-xcodeproj`" in unresolved questions. - -## Alternatives - -### Compile React Native from source as SPM targets - -Compiling React Native's C++/Objective-C sources from source as SPM targets is -not the default path due to the ~2000 source files and complex header layout, -which makes clean build times significantly longer. However, source -compilation support is a goal for specific use cases: - -- **Debugging React Native internals** — developers investigating bugs or - contributing fixes to React Native itself need to build from source with - debug symbols. -- **Apps requiring source patches** — projects like Expo Go that need to modify - React Native source code to build successfully, or apps that apply patches - via tools like `patch-package`. - -The source compilation path would reuse the same SPM package structure but -replace binary xcframework targets with source targets. This is planned as a -`--source` flag to `react-native spm`. - -### SPM build tool plugins - -SPM plugins were evaluated as a way to eliminate the `.xcodeproj` (see -[SPM Plugins Assessment](spm-plugins-assessment.md) for details). The key -findings: - -- **Post-build phases are impossible.** JS bundling and Hermes engine copying - run after linking to place artifacts in the `.app` bundle. SPM has no - post-build plugin capability — this is a deliberate design choice for build - reproducibility. -- **Sandbox restrictions.** Build tool plugins cannot write to the source tree, - run `node`, or access `node_modules`. Pre-build phases like autolinking sync - require all of these. -- **No Xcode build settings.** SPM plugins do not receive `CONFIGURATION`, - `BUILT_PRODUCTS_DIR`, or other settings that the JS bundling script relies on. - -A hybrid approach (some SPM plugins + some Xcode build phases) would be harder -to reason about than the current uniform approach of all Xcode build phases. -SPM plugins are not a viable alternative today. - -## Adoption strategy - -This proposal introduces SPM as an **additional** build system. It is not a -breaking change. CocoaPods continues to work exactly as before. The two -workflows coexist — an app can have both `Podfile` and `Package.swift` in the -same directory. - -### Phase 1: React Native core (current) - -SPM works for React Native core frameworks and app-local native modules -declared as `spmModules` in `react-native.config.js`. No third-party library -support. This phase validates the architecture and developer experience with -rn-tester and the helloworld template. - -### Phase 2: Library ecosystem tooling - -Ship the `react-native-prebuild` CLI. Library authors can prebuild and publish -xcframeworks for their libraries. The autolinking step reads `spm.xcframework` -from installed libraries and downloads artifacts automatically. Libraries -without xcframeworks fall back to `spm.source` (source compilation) or manual -`spmModules` entries. - -### Phase 3: Ecosystem-wide adoption - -Popular libraries ship prebuilt xcframeworks from CI. App developers get -near-zero-compilation iOS builds — only app code and codegen output are -compiled. React Native provides clear documentation and tooling -(`react-native-prebuild`) to help library authors build and publish -xcframeworks — for example, CI workflow templates and guidance on publishing to -Maven or GitHub Releases. Prebuilt xcframeworks are recommended but not -required; libraries that don't provide them fall back to source compilation. - -### Migration path for existing apps - -1. Run `npx react-native spm` from the project root (auto-redirects into - `ios/`). -2. Accept the rename prompt — your existing `AppName.xcodeproj` becomes - `AppName.xcodeproj.legacy` (preserved for rollback). -3. Commit the new `AppName.xcodeproj/` (SPM-managed) and the renamed - `AppName.xcodeproj.legacy/`. `git mv` tracks the rename cleanly. -4. Run `npm run ios` and verify the SPM build. -5. Once validated, optionally delete the `.legacy` backup, `Podfile`, - `Pods/`, and `.xcworkspace`. - -To roll back: `npx react-native spm clean --project` deletes the SPM -xcodeproj and renames `.legacy` back to the canonical filename. - -No changes to JavaScript code, Metro configuration, or Android setup are -required. - -### Upgrading React Native - -After upgrading `react-native` in `package.json` and running `npm install`, -the auto-sync build phase detects the `node_modules` mtime change on the next -Xcode build and re-runs the sync step automatically. This downloads the new -version's xcframeworks, regenerates the sub-packages, and updates autolinking. -No manual edits are needed — the xcodeproj's sub-package references are -stable, and those sub-packages are fully regenerated each run. Developers -can also run `react-native spm` manually to trigger the update before -building. - -## How we teach this - -### Documentation - -- Add a **"Building with SPM"** guide to the React Native docs, parallel to the - existing CocoaPods setup guide. -- Update the **"Getting Started"** guide to present SPM as an option alongside - CocoaPods, with SPM as the recommended path for new projects once Phase 2 is - stable. -- Add a **library author guide** explaining how to use `react-native-prebuild` - and publish xcframeworks. - -### CLI discoverability - -- `react-native spm --help` should provide clear usage instructions and - explain each step. -- Error messages should include actionable suggestions (e.g., "Run - `react-native spm init` for first-time setup"). -- The auto-sync build phase should surface warnings in Xcode's issue navigator - when autolinking state is stale. - -### Community template - -- The `react-native init` template should include SPM as an option (e.g., - `--pm spm` flag or interactive prompt). -- The template should generate the initial `Package.swift` and `.xcodeproj` so - that new projects work with SPM out of the box. - -### Naming and terminology - -- **"SPM build"** or **"Swift Package Manager build"** to distinguish from the - CocoaPods-based workflow. -- **"xcframeworks"** when referring to the prebuilt binary artifacts. -- Avoid the term "pods" when discussing the SPM workflow to prevent confusion. - -## Unresolved questions - -1. **How should version compatibility be enforced?** A library's xcframework - must be built against a compatible React Native version. Should the download - step enforce strict version matching, accept semver-compatible ranges, or - simply warn on mismatch? - -2. **Where should library xcframeworks be hosted?** Maven Central (consistent - with React Native core), GitHub Releases (simpler for library authors), or a - dedicated registry (better discovery and compatibility metadata). Each has - different trade-offs for discoverability, reliability, and maintenance - burden. - -3. **Debug symbol (dSYM) distribution.** The prebuild pipeline produces dSYM - bundles alongside xcframeworks, but the best way to distribute and consume - them is not yet defined. Open questions include: should dSYMs be downloaded - alongside xcframeworks automatically or on demand? How should they integrate - with crash reporting services (Sentry, Crashlytics) that need dSYM UUIDs - for symbolication? Should the download step place dSYMs in a location that - Xcode's archive workflow picks up automatically? - -4. **How should library authors validate SPM compatibility?** A validation - command (`react-native-prebuild --validate`) could verify that a library's - sources compile as an SPM target without producing a full release artifact. - This would be useful for CI checks on pull requests. - -5. **Hardening `update --force-xcodeproj`.** The default `update` is - create-if-missing, which preserves user edits but means generator - improvements don't propagate to existing projects automatically. Passing - `--force-xcodeproj` clobbers everything. A future improvement could read - the existing pbxproj, merge changes (signing, capabilities, custom Build - Phases), and write back — likely via a proper Xcode project - parser/generator (e.g., `@bacons/xcode`) rather than the current - template-based approach. Planned work for production readiness. - -6. **Auto-sync failure visibility.** The sync build phase currently emits - `warning:` and exits 0 on failure, which means a broken autolinking state - can persist silently across builds. Planned improvements include a strict - mode (e.g., `RN_SPM_STRICT_SYNC=1` that exits non-zero on failure) and - generating a `#warning` directive in a source file when sync fails, so - Xcode surfaces the issue in the issue navigator even when build log - warnings are missed. - -7. **Monorepo and package manager compatibility.** The auto-sync build phase - uses `node_modules` mtime to detect dependency changes. This has been - tested with npm in the React Native monorepo but not yet with Yarn - workspaces (hoisted or PnP), pnpm (symlinked `node_modules`), or Bun. - These package managers structure `node_modules` differently and may require - adjustments to the mtime detection logic. Validating and fixing - compatibility across package managers is planned work. - -## References - -- [RFC0508: Out-of-NPM Artifacts](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0508-out-of-npm-artifacts.md) — established the Maven-based artifact distribution pattern this proposal builds on -- [Apple: Creating Swift Packages](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode) -- [SE-0272: Package Manager Binary Dependencies](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0272-swiftpm-binary-dependencies.md) diff --git a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md deleted file mode 100644 index c8ac204ca06d..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md +++ /dev/null @@ -1,244 +0,0 @@ -# SwiftPM Autolinking Plugins (Preview) - -> **Preview / unstable contract.** The discovery mechanism and the plugin -> function's context/return shape may change while the first consumers (Expo) -> validate it. Pin to a React Native version if you depend on it. - -How a framework with its own module system — Expo is the first consumer — -contributes to the SwiftPM autolinking graph that `npx react-native spm` -generates. See [spm-scripts.md](./spm-scripts.md) for the base tool. - -## Why a plugin (not a static list or a post-process) - -The documented extension points don't cover a framework: - -- `spm.modules` in `react-native.config.js` is a **static** list of simple - source modules. A framework discovers its modules **dynamically** (scanning - `node_modules`), generates a **module registry**, and ships mixed - Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't - handle. -- A one-shot **post-process** of the generated `Package.swift` is **clobbered - on the next sync**: the Xcode [auto-sync build phase](./spm-scripts.md#auto-sync-build-phase) - re-runs autolinking on every dependency change. A framework's contribution - must run *whenever autolinking runs*. - -A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s -`main()` — the single function that both `add` / `update` **and** the -build-time `sync` call — so the contribution is regenerated on every build and -never goes stale. - -(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, -`use_expo_modules!`, and `react_native_post_install` hooks.) - -## Discovery — transitive, zero app config - -A dependency opts in from its **own** `react-native.config.js`, so installing -the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!` -transitively): - -```js -// node_modules/expo/react-native.config.js -module.exports = { - spm: {autolinkingPlugin: './spm/autolinking-plugin.js'}, -}; -``` - -The autolinker already walks every dependency's `react-native.config.js`; any -that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level -registration or allowlist is required. - -**Opt-out escape hatch.** An app can exclude a plugin from its own -`react-native.config.js`: - -```js -module.exports = { - spm: {denyPlugins: ['some-framework']}, // npm names to skip -}; -``` - -## The contract - -A plugin is a function exported from the module named above -(`module.exports = fn`, or `default` / `plugin` named exports also work): - -```js -module.exports = function plugin(context) { - return { - packageDependencies: [ - // Local package (e.g. a scanned module dir) … - {name: 'ExpoModulesCore', path: '../../../node_modules/expo-modules-core/ios'}, - // … or a remote/published package: - // {name: 'SomePkg', url: 'https://…/SomePkg.git', version: '1.2.3'}, - ], - productDependencies: [ - // Linked by the app's AutolinkedAggregate target: - {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, - ], - generatedSources: [ - // e.g. the generated module registry, registered with codegen: - {path: 'build/generated/expo/ExpoModulesProvider.swift'}, - ], - flavoredFrameworks: [ - // Precompiled dynamic XCFrameworks that come in mandatory Debug/Release - // pairs. RN validates and stages both outside the SwiftPM graph. - { - id: 'expo-modules-core', - frameworkName: 'ExpoModulesCore', - linkage: 'dynamic', - flavors: { - debug: '/…/output/debug/xcframeworks/ExpoModulesCore.xcframework', - release: '/…/output/release/xcframeworks/ExpoModulesCore.xcframework', - }, - }, - ], - watchPaths: [ - // Inputs whose edits must re-trigger the auto-sync — the plugin's own - // manifest and per-module config (absolute paths, dirs or files): - '/…/node_modules/expo/Package.swift', - '/…/node_modules/expo/expo-module.config.json', - ], - }; -}; -``` - -#### `flavoredFrameworks` — per-configuration precompiled frameworks - -Each entry is -`{id, frameworkName, linkage: 'dynamic', flavors: {debug, release}}`. -Both flavor paths must be absolute and present when `spm add` or `spm update` -runs. The framework and executable names, public headers, and platform slices -must agree across flavors. Static binaries, nested frameworks, duplicate IDs, -and duplicate embedded framework names are fatal. - -The declarations are recorded to -`/.spm-plugin-flavored-frameworks.json`, normalized into the same -immutable app-local slots as React Native, and added to Xcode's exact linker and -embed settings. They are not emitted as SwiftPM product dependencies. Adding or -removing one requires `spm update`; the build-time `spm sync` intentionally does -not mutate runtime framework settings. - -#### `watchPaths` — plugin staleness inputs - -`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode -auto-sync build phase watches to decide whether it must re-sync. RN already -watches each module's source dir plus every npm dep's checked-in `Package.swift` -and `.react-native/` dir; a plugin adds the inputs only it knows about — e.g. -`packages/expo/Package.swift`, `expo-module.config.json`, and per-module -manifests. On the next build the phase re-syncs when a watched **file** is newer -than the last sync, a watched **dir** has a newer child, or a watched path has -**vanished** (a rename forces a re-sync so the config error surfaces). - -Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored with a warning -(never fatal), and each non-string / empty / **relative** entry is dropped with a -warning. Absolute-only, because the generated phase tests these paths with no cwd -context. The kept paths are folded into `/.spm-sync-watch-paths` -alongside RN's own, then deduped and sorted. - -### Context (input) - -| Field | Meaning | -|---|---| -| `appRoot` | The Xcode project directory (`/ios`) being injected — **not** the app package root. Deriving package-root-relative paths from it (e.g. `path.join(appRoot, 'node_modules')`) silently breaks; use `projectRoot` for that. | -| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. | -| `reactNativeRoot` | Resolved `react-native` package root. | -| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. | -| `outputDir` | `build/generated/autolinking` — where generated artifacts land. | -| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. | - -#### `context.react` — depending on React - -A plugin that emits its own `Package.swift` must declare React as a dependency. -Rather than re-deriving React Native's package path, identity, and product -names — which differ between local and remote mode and **move as RN -repackages** — take them from `context.react`: - -```js -react: { - packageRef: - {name: 'ReactNative', path: '', relPath: ''} // local - | {name: '', url: '', version: ''}, // remote (SPM-resolved) - products: [ - {name: 'ReactHeaders', package: 'ReactNative'}, - {name: 'ReactNativeHeaders', package: 'ReactNative'}, - {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, - {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, // ← separate, per-app package - ], -} -``` - -Local vs remote is signalled by which `packageRef` keys are present (`path` xor -`url`+`version`). `packageRef.path` is **absolute** — always correct no matter -which subdirectory of `outputDir` the plugin writes its own manifest into (the -generated manifests are gitignored and regenerated every sync, so there's no -portability cost); `relPath` (relative to `outputDir`) is provided as a -convenience. `products` is the set React Native wires into **its own** autolinked -targets (so a plugin's target compiles against exactly RN's React surface), -filtered to those resolvable this run — every listed product is safe to -reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in -the separate `React-GeneratedCode` package (per-app codegen), which a -hand-rolled plugin would miss, and which is omitted when that package is absent. -Because RN derives this list from one source of truth alongside its own product -wiring, it stays correct across repackaging. - -### Return (contributions, all optional) - -| Field | Merged into | -|---|---| -| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). | -| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | -| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | -| `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | - -The plugin returns **data** — it never writes into React Native's generated -tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` -byte-for-byte (idempotent). Package and product contributions are **deduped by -name** across plugins. - -## Lifecycle - -``` -react-native spm add / update ─┐ - ├─► generate-spm-autolinking main() -Xcode "Sync SPM Autolinking" ──┘ │ -(build phase, every build) ├─ 1. discover plugins (dep configs) - ├─ 2. RN builds its own dep graph - ├─ 3. invoke plugins (context in) - └─ 4. merge results → aggregator Package.swift -``` - -Because steps 1–4 run in the one `main()`, everything above shares the same -seam — there is no separate hook to wire for the build-time path. - -## Failure behavior - -Fail-closed and **named**: a plugin that fails to load, doesn't export a -function, throws, or returns a malformed contribution aborts the run with a -message identifying the framework. A framework silently dropping its modules -(a green build missing native code) is worse than a loud stop. - -## Status & open items (Preview) - -- **Implemented & tested:** discovery (transitive + deny-list), invocation, - package + product merge, fail-closed validation, and dual-flavor framework - normalization/link/embed outside SwiftPM. -- **Implemented & tested:** `generatedSources` **app-target wiring**. The - merge writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` - xcodeproj injector (generate-spm-xcodeproj.js) reads it and wires each source - **into the app target** — a `PBXFileReference` + `PBXBuildFile` + a - Sources-build-phase entry, parented under one "SPM Generated Sources" - navigator group. This is what makes an `@objc` class (e.g. Expo's - `ExpoModulesProvider`) reach the ObjC classlist: a class inside the static - Autolinked aggregate never does, so `NSClassFromString` discovery would fail. - Paths are stored SRCROOT-relative when under the app root (the usual - `build/generated/…` case), else absolute (`sourceTree = ""`). All - UUIDs are namespaced on the normalized path (deterministic/idempotent) and - recorded in the `.spm-injected.json` marker's `generatedSources` map, so - `deinit` reverts them and `update` reconciles entries that left the manifest. - A target without a Sources phase logs loudly and skips the wiring (injection - otherwise succeeds). v1 targets only the injected app target and assumes - `.swift` in practice (`.m`/`.mm` are mapped as future-proofing). -- **Co-design with Expo (not final):** codegen **provider ordering** — codegen - must consume the same discovered module set the plugin contributes — is - intentionally left for the first real plugin to drive to a stable shape. -- Contract to be ratified via RFC once Expo's plugin proves it (framed as a - generic hook, not Expo-specific code in RN). diff --git a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md deleted file mode 100644 index c62c46e0b781..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md +++ /dev/null @@ -1,97 +0,0 @@ -# SPM headers & package references — how they resolve - -React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search -paths and no `unsafeFlags` in any generated manifest. Headers are served by -SPM products/binary targets, and every generated `Package.swift` references -the React Native + codegen packages with plain, fixed-relative paths computed -at generation time (no runtime discovery). This document is the single source -of truth for how that resolves. - -> History: earlier iterations materialized two header trees and fed them to -> consumers as `-I` flags read from `spm-paths.json` / `.react-native/paths.json` -> via an inlined Swift loader. That whole mechanism (the loader -> `renderRNPathsLoader`, the `writeAppPathsJson` / `writeSharedPathsJson` -> writers, and both JSON files) has been **deleted** — manifests are now -> declarative. If you find a reference to those files, it is stale. - -## How headers resolve (no search paths) - -| Namespace | Served by | Mechanism | -|-----------|-----------|-----------| -| Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | -| Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | -| ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | - -The one remaining materialized header tree is the per-app farm at -`/build/generated/ios/ReactAppHeaders` (built by -`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It -is vended as the `ReactAppHeaders` SPM target — consumers reach it through a -product dependency, never through `-I`. - -`autolinking.json` (the `@react-native-community/cli config` output) is an -INPUT used to generate the manifests; it is never read by a manifest. - -## How each manifest references the React + codegen packages - -Every generated manifest sits at a known depth inside the app and is -regenerated on every `react-native spm` run, so package references are plain -fixed-relative paths — no walk-up, no JSON, no `import Foundation`. - -| Manifest | Location | How it references the React + codegen packages | -|----------|----------|-------------------------------------------------| -| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | -| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | -| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | -| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | -| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | - -## Remote-package mode - -Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted -`url`). When set, the whole app graph flips to a single remote React Native -package identity: `.package(path: build/xcframeworks)` becomes -`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), -and the local artifact download + compose is skipped. SPM's -one-version-per-package rule then unifies app + every library on one resolved -React Native. The package identity is derived from the URL tail (swift-tools 6 -dropped `.package(name:url:)`) — nothing hardcodes a repo name. - -**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version -is not a free parameter: the SPM graph must compile against the same React -Native the JS/native code uses, so the app (graph root) pins EXACT to the -*installed* RN version, read from `node_modules/react-native/package.json`. -`RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, -not the source of truth — they're only needed when the installed version isn't -publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote -tag). A *derived* version is never persisted, so an `npm install` that upgrades -RN auto-re-pins the SPM graph on the next `spm` run; an *override* is persisted -as `versionOverride` so it survives Xcode-phase re-syncs without the env. - -Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still -read, with `version` honored as an override (back-compat). If remote mode is on -but no usable version can be resolved — react-native isn't installed, or it's a -non-publishable dev placeholder and no override is set — the tooling errors -(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` -or install a released react-native, rather than silently pinning an unpublished -tag. - -## Hand-authored community library contract - -A library that ships its own `Package.swift` (no scaffolder/autolinker marker) -is left untouched by the tooling. It needs only two things, and **no discovery -code**: - -1. Depend on the React Native SPM package and its products — in remote mode - `.package(url: "", exact: "")` + `.product(name: "ReactNative", …)` - and `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a - version RANGE in production; the consuming app pins EXACT.) -2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` - and generate with `generate-codegen-artifacts.js --path . --targetPlatform - ios --source library`. Output lands at - `/build/generated/ios/ReactCodegen/`, reachable from the manifest - with one safe `.headerSearchPath(...)` into the library's own tree. The - app-side codegen then skips the lib's spec (no duplicate symbols). - -This makes the library self-contained — it carries no app-layout knowledge and -needs no per-app codegen headers from the consuming app. Proven with -`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). diff --git a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md deleted file mode 100644 index e20e4c93f672..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md +++ /dev/null @@ -1,128 +0,0 @@ -# SPM Build Plugins Assessment - -An evaluation of whether Swift Package Manager plugins can replace the Xcode build -phase scripts currently injected by `generate-spm-xcodeproj.js`. - -## Current Build Phases (6 total) - -| # | Phase | Timing | SPM Plugin Feasible? | -|---|-------|--------|----------------------| -| 1 | Sync SPM Autolinking | Pre-build | Partially | -| 2 | Prepare VFS Overlay | Pre-build | Partially | -| 3 | Sources (compile) | Build | N/A (standard) | -| 4 | Frameworks (link) | Build | N/A (standard) | -| 5 | Resources (copy) | Build | N/A (standard) | -| 6 | Build JS Bundle | **Post-build** | **See below** | - -> **Removed:** The "Copy Hermes Framework" phase was removed — it was a no-op. -> The underlying `copy-hermes-xcode.sh` script has been empty since Dec 2022. -> Hermes is already properly linked as an xcframework SPM dependency. - -## SPM Plugin Types - -SPM offers two plugin types: - -1. **Build Tool Plugins** (`BuildToolPlugin`) — run pre-build, can generate source - files/resources via `prebuildCommands` or per-file `buildCommands`. -2. **Command Plugins** (`CommandPlugin`) — run on-demand via - `swift package `. - -## Key Constraints - -### Sandbox restrictions - -SPM plugins run sandboxed by default — no network access, limited filesystem access. -The current scripts need to: - -- Run `node` (not on the sandbox-allowed path) -- Write to the source tree (`autolinked/`, `build/`) -- Access `node_modules/` -- Read git state - -Command plugins can request `--allow-writing-to-package-directory`, but build tool -plugins can only write to a designated plugin work directory, not the source tree. - -### No Xcode build settings - -SPM plugins do not receive Xcode build settings such as `CONFIGURATION`, -`PLATFORM_NAME`, `BUILT_PRODUCTS_DIR`, or `DERIVED_FILE_DIR`. The JS bundling script -relies heavily on these to decide debug-vs-release behavior and output paths. - -## JS Bundle Phase — Could Move to Pre-build - -The JS bundle has no dependency on native compilation. It only needs JS source files, -Metro, and knowledge of debug vs release. The current post-build placement is -historical — the script writes directly into `BUILT_PRODUCTS_DIR`. - -A potential restructuring: - -1. **Generate the bundle pre-build** into a known location (e.g. `build/jsbundle/`) -2. **Declare it as an SPM resource** so it gets copied into the app automatically - -Challenges: -- **Debug builds skip bundling** (app loads from Metro dev server). The script checks - `CONFIGURATION == Debug`, which is unavailable to SPM plugins. -- **Hermes bytecode compilation** also happens in this phase for release builds. -- Making it a command plugin (`swift package bundle-js --configuration release`) would - lose the automatic behavior — developers would need to run it explicitly. - -## What Could Theoretically Work - -### Codegen as a Command Plugin - -A Swift command plugin could shell out to `node` to run codegen: - -```swift -@main struct CodegenPlugin: CommandPlugin { - func performCommand(context: PluginContext, arguments: [String]) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["node", "scripts/codegen/generate-codegen-artifacts.js"] - try process.run() - process.waitUntilExit() - } -} -``` - -Invoked as `swift package codegen`. This is essentially wrapping a shell script in -Swift with no real benefit over the current approach. - -### Autolinking sync as a Prebuild Command - -A `prebuildCommand` runs before every build, similar to Phase 1. But: - -- Output can only go to the plugin work directory (not `autolinked/`) -- Would need to restructure the package graph to consume generated files from the - plugin work directory -- Still needs to shell out to `node` - -This is a significant architectural rework for marginal benefit. - -## Recommendation - -**Do not invest in SPM plugins for this use case.** Reasons: - -1. **Pre-build phases already work well** as Xcode build phase scripts. Moving them - to SPM plugins adds Swift boilerplate around `Process()` calls to `node`, while - losing access to Xcode build settings. - -2. **The ROI is poor** — a hybrid (some SPM plugins + some Xcode build phases) is - harder to reason about than the current uniform approach of all Xcode build phases. - -3. **SPM plugins shine for pure Swift source generation** (SwiftGen, SwiftProtobuf) - where the plugin generates `.swift` files that feed into compilation. React - Native's build steps are fundamentally different — they orchestrate a JS toolchain - and copy runtime artifacts. - -4. **The JS bundle phase could move pre-build** but would lose automatic - debug/release detection without Xcode build settings. Worth revisiting if SPM - gains access to build configuration in a future Swift version. - -## Alternatives Worth Exploring - -- **Xcode Build Tool Plug-ins** (the Xcode-specific variant, not SPM) have access to - build settings and can run post-build, but require a different packaging model. -- **Move auto-sync to a `prepare` script** in `package.json` so it runs at - `yarn install` time instead of every build, reducing build-time overhead. -- **Pre-build JS bundling** with the bundle declared as an SPM resource, removing the - need for a post-build phase entirely (release builds only). diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md deleted file mode 100644 index d0d0f9cbf7a0..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ /dev/null @@ -1,451 +0,0 @@ -# SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) - -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and -> it is not yet recommended for production. Feedback is welcome. CocoaPods -> remains the supported default. - -Build React Native iOS apps using **Swift Package Manager** with prebuilt -XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — -CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` -in place and is fully reversible. - -## Quick Start - -```bash -cd ios - -# First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, -# in place. `npx react-native spm` with no action auto-resolves to `add` (or -# `update` once injected); on a fresh CocoaPods app it converts in one command -# (implies --deintegrate). To do it explicitly: -npx react-native spm add --deintegrate - -# Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. -open MyApp.xcodeproj -``` - -After the initial run, the `.xcodeproj` includes an **auto-sync build phase** -that detects dependency changes and re-runs autolinking before compilation -(see [Auto-Sync](#auto-sync-build-phase)) — you don't re-invoke -`react-native spm` manually for day-to-day dependency changes. **On a fresh -clone or CI checkout, run `npx react-native spm` once before building** (see -[Fresh clones & CI](#fresh-clones--ci)). - -> **Note:** `react-native spm` is a thin wrapper over -> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI -> alias is unavailable in your environment, invoke the script directly with -> the same actions and the kebab-case flag equivalents (e.g. -> `--skip-codegen`). - -## CocoaPods → SwiftPM migration - -`spm add` injects into a project that is **not** CocoaPods-integrated. On a -CocoaPods app it fails loud and points you at `--deintegrate`, which: - -1. runs `pod deintegrate` — removes CocoaPods integration from the - `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your - `Podfile` is left on disk. -2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — - every other line, **including your own `pod '…'` entries, is preserved**. -3. injects SwiftPM into the `.xcodeproj`. - -React Native now comes from SwiftPM; no pods are linked yet (deintegrate -removed the integration). - -### Keeping non-RN pods - -Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile -still lists them (only the RN directives were removed) — re-integrate them -with a normal install: - -```bash -pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace -``` - -Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes -the SwiftPM-injected project, so React Native resolves through SwiftPM and your -other pods through CocoaPods, together. - -> **Do not re-add `use_react_native!`.** React Native must be provided by -> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, -> so a dual-managed RN does not build. `spm add` refuses to run while the -> Podfile still declares `use_react_native!`. - -The migration is fully reversible — see -[Removing / resetting](#removing--resetting). - -## Brownfield apps - -`spm add` injects into your existing `.xcodeproj` in place, so an app that -embeds React Native works the same way — point it at the right project and -target: - -```bash -npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp -``` - -**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** -— i.e. the app's `package.json` is a parent directory of the project. Both -setup and the build-time sync locate React Native by walking up from the -project to the nearest `package.json`. The common "native project at the repo -root with the RN JS in a sibling/child subfolder" layout is **not supported -yet** — there is no way to point at a JS root outside the project's ancestors. - -Brownfield apps that keep CocoaPods for their other native dependencies follow -the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, -everything else from CocoaPods, and no `use_react_native!` in the Podfile. - -## CLI Actions - -```bash -react-native spm [action] [options] -``` - -With no action, the command **auto-resolves**: if SwiftPM has been injected -(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. -On a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the -zero-arg path additionally implies `--deintegrate` (the safe-gate), so -`npx react-native spm` converts a brand-new app to SwiftPM in one command. - -When invoked from the JS root of a standard RN app (sibling `ios/` subdir), -the command auto-redirects into `ios/` with a banner. - -| Action | Description | -|---|---| -| `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | -| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The exact inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. | -| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | -| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync build phase. Regenerates invariant codegen and autolinking output only. Not for humans. | -| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | -| `download` (advanced) | Download/check xcframework artifacts only. | - -## CLI Options - -Flags below use the `react-native spm` (camelCase) form. The raw script -accepts kebab-case equivalents (e.g. `--skip-codegen`). - -| Option | Description | -|---|---| -| `--version ` | RN version (default: from package.json) | -| `--yes` | Skip the dirty-pbxproj confirmation prompt | -| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | -| `--productName ` | [add] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | -| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | -| `--download ` | [advanced] Artifact download policy (default: auto) | -| `--skipCodegen` | [advanced] Skip the codegen step | - -### Debug/Release flavor is automatic - -React Native ships **flavored** prebuilt binaries: the *debug* `React.framework` -(and `hermesvm` / `ReactNativeDependencies`) carry the dev experience — dev menu, -assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while *release* strips them for -production. A Debug build must embed the debug binaries and a Release/archive the -release ones. - -SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime -frameworks are deliberately kept out of the package graph. `spm add` downloads -and validates **both** flavors into immutable app-local slots. It injects -SDK/architecture-qualified Xcode settings that link the exact selected binaries, -plus one phase that copies and signs the selected frameworks into the app. -Configurations containing `debug` or `development` select Debug; every other -configuration selects Release. Selection uses only generated build settings and -standard macOS tools: builds do not run Node, mutate symlinks, regenerate the -package graph, or require a second build. - -## What to commit - -| Path | Commit? | Why | -|------|---------|-----| -| `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. | -| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. | -| `build/generated/` | No | Codegen/autolinking output; regenerated | -| `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache | -| `Package.resolved` | No | SwiftPM resolution file; machine-specific | - -Injection is **purely additive** and **idempotent**: `add`/`update` insert only -SwiftPM package refs, the React build settings, the Sync build phase, and a scheme -pre-action — every other byte (your signing / capabilities / Build Phases) -stays untouched, and a re-run is a no-op. The injected refs point at three -stable sub-package paths under `build/`; adding or removing community deps -changes the sub-package contents (gitignored) and never re-injects. `deinit` -removes exactly what was injected (using the marker), leaving the project -byte-identical to its pre-`add` state. - -Because everything under `build/` is gitignored, a clean checkout has no -resolvable Swift packages until they are regenerated — see the next section. - -## Fresh clones & CI - -Xcode resolves the Swift package graph **before any build phase runs**, so on a -clean checkout (where the gitignored `build/` packages don't exist yet) the -auto-sync build phase can't regenerate them in time — a bare `xcodebuild` -fails at *"Resolve Package Graph … build/generated/autolinking doesn't exist"*. - -Run the setup command once after cloning, before building — the SwiftPM analog -of `pod install`: - -```bash -npx react-native spm # downloads artifacts (if missing) + regenerates build/ -``` - -On an already-injected project this routes to `update`: it fetches the -xcframework artifacts into the shared cache if they aren't present and -regenerates `build/xcframeworks` + `build/generated`. After this first run, -incremental dependency changes are picked up automatically by the auto-sync -build phase. - -**Automate it** so nobody has to remember — add a `postinstall` hook, which -runs as part of the `npm install` / `yarn install` your CI already does before -`xcodebuild`: - -```json -{ - "scripts": { - "postinstall": "react-native spm" - } -} -``` - -`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook -works from the app root; in CI (non-interactive) it proceeds without prompting. -It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op -when nothing changed), so it is slightly heavier than the internal `sync` the -build phase calls — a fine trade for not having to remember a command. - -> A future remote-package distribution (a tagged `Package.swift` repo + -> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves and -> fetches the artifacts itself during normal package resolution. Until then, -> the one-time setup run is required on clean machines. - -## Local Native Modules - -Modules not discovered via autolinking can be declared in `react-native.config.js`: - -```js -module.exports = { - spm: { - modules: [ - { - name: 'MyNativeModule', - path: 'ios/MyNativeModule', // relative to app root - exclude: ['*.podspec'], // optional - publicHeadersPath: '.', // optional - }, - ], - }, -}; -``` - -Each entry becomes a target in `build/generated/autolinking/Package.swift`. -Sources outside `build/generated/autolinking/` are automatically mirrored with -file-level symlinks. - -## Self-managed community packages - -A community library that ships its own `Package.swift` is referenced -directly by the autolinker instead of being wrapped. To keep SwiftPM's -package identity (which it derives from the path basename) unique across -deps — even when several libs put their manifest inside an `ios/` subdir -— each self-managed dep is exposed through a uniquely-named symlink at -`build/generated/autolinking/libs//`. The aggregator -`Package.swift` references that path, so two libs both shipping -`/ios/Package.swift` never collide on identity `"ios"`. - -The `libs/` directory is wiped and recreated on every autolinker run, -so deleting a dep via `npm uninstall` cleans up the alias automatically -on the next build. - -## Community packages without a Package.swift - -If an autolinked library ships **no `Package.swift`**, the build fails with a -clear per-dep error (`Package.swift is missing for library ""`). Generate -one from the library's podspec: - -```bash -npx react-native spm scaffold # writes Package.swift into node_modules// -``` - -Because `node_modules/` isn't committed, persist it so it survives the next -install: - -```bash -npx patch-package # then commit the generated patch -``` - -**Better: contribute the manifest upstream.** The generated `Package.swift` is -a normal, committable manifest — the ideal fix is for the library to ship it -itself, so every consumer gets SwiftPM support without a local patch. Please -**file an issue or open a PR on the library** with the scaffolded -`Package.swift` (mention it was generated by `react-native spm scaffold` for -React Native SwiftPM support). Until it lands upstream, the `patch-package` -workaround keeps your app building. - -> A library whose sources mix Swift **and** Objective-C/C++ in one target, or -> that ships neither a `Package.swift` nor a podspec, can't be scaffolded -> automatically — the error says so. Opt it out via `react-native.config.js` -> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. - -## Framework plugins (Preview) - -Frameworks with their own module system (e.g. Expo) contribute to the -autolinking graph through a **plugin** — a function invoked on every -regeneration (including the build-time sync) that adds SwiftPM package refs, -product dependencies, and generated sources. Discovery is transitive -(installing the framework is enough), and the plugin returns data that RN -merges idempotently. - -See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the -discovery mechanism, the full context/return contract, lifecycle, and failure -behavior. - -## Removing / resetting - -To remove SwiftPM entirely, use `deinit` (the inverse of `add`): - -```bash -react-native spm deinit # surgically removes everything `add` injected -pod install # then, to restore CocoaPods -``` - -To reset the regenerable build state (without un-injecting), just delete the -gitignored dirs and re-run: - -```bash -rm -rf build/xcframeworks build/generated .build -react-native spm update -``` - -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch SwiftPM-generated directories. The cached xcframework slot is shared -across apps; refresh it with `react-native spm update --download force`. - -## Troubleshooting - -| Problem | Fix | -|---------|-----| -| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | -| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | -| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | -| Missing headers | Re-run `react-native spm` | -| "not contained in target" | Re-run setup (regenerates file-level symlinks) | -| Codegen fails | Use `--skipCodegen` to iterate on other parts | -| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | -| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | -| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | - ---- - -# Reference / internals - -## Pipeline - -`react-native spm add` and `react-native spm update` orchestrate these steps: - -| Step | Script | Output | -|------|--------|--------| -| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | -| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | -| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | -| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | -| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | -| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | -| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | - -## Directory Layout - -``` -my-app/ios/ - MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) - Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) - build/ - generated/ - autolinking/ <-- gitignored (regenerated at build time) - Package.swift - autolinking.json - packages/ <-- synth wrappers for autolinker-managed deps - libs/ <-- symlinks to self-managed deps' Package.swift - dirs, named by Swift module so SwiftPM - package identity stays unique - headers/ <-- generated header symlinks - ios/ <-- gitignored, codegen output - xcframeworks/ <-- gitignored, immutable runtime flavor slots + invariant package - debug/ - React.xcframework -> ~/Library/Caches/.../debug/React.xcframework - ReactNativeDependencies.xcframework -> ... - hermes-engine.xcframework -> ... - release/ - React.xcframework -> ~/Library/Caches/.../release/React.xcframework - ReactNativeDependencies.xcframework -> ... - hermes-engine.xcframework -> ... - ReactHeadersTarget/ <-- canonical Objective-C React headers + module map - ReactNativeHeaders.xcframework -> ... - ReactNativeDependenciesHeaders.xcframework -> ... - flavored-frameworks.json - .artifact-stamp -``` - -## Header Resolution - -React Native uses CocoaPods-style imports (`#import `) that -SwiftPM doesn't natively support. The prebuilt artifacts serve them through SwiftPM -package products — no `-I` search-path flags, and no clang VFS overlay: - -1. **`` and `import React`** resolve through the invariant - **`ReactHeaders` Clang target**. It stages one canonical header copy after - proving Debug and Release expose identical public headers, and uses a plain - `module React` module map with `React/`-prefixed paths. -2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, - `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a - headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM - auto-serves to dependents. -3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, - `double-conversion/`, `fast_float/`, `SocketRocket/`) come from - **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only - sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` - is framework-type and can't expose those headers to SwiftPM). - -Targets that compile against React take these as product dependencies -(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus the -app's `ReactAppHeaders`), so all of the above resolve with zero search-path -flags. - -## Auto-Sync Build Phase - -The generated `.xcodeproj` includes a **Sync SPM Autolinking** shell script -build phase. It keeps `build/generated/autolinking/Package.swift` up to date -without requiring manual re-runs of `react-native spm` for incremental -dependency changes. (It cannot bootstrap a fresh clone — Xcode resolves the -package graph before any phase runs; see [Fresh clones & CI](#fresh-clones--ci).) - -**How it works:** - -1. Compares timestamps of staleness inputs against `build/generated/autolinking/.spm-sync-stamp`: - - `package.json` — dependency declarations - - `react-native.config.js` — `spm.modules` config - - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups - - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks stale -2. If any input is newer (or the stamp is missing): runs `npx react-native spm sync`, - which re-executes autolinking + package generation (downloading artifacts if - the cache slot is incomplete) and writes the stamp file. -3. If all inputs are fresh: exits immediately (~1ms). - -**Build phase ordering:** - -| # | Phase | -|---|-------| -| 0 | Resolve Package Graph (Xcode — runs before all build phases) | -| 1 | Sync SPM Autolinking | -| 2 | Sources (compile) | -| 3 | Frameworks (link) | -| 4 | Embed React Native Flavored Frameworks | -| 5 | Resources (copy) | -| 6 | Build JS Bundle | - -Failures in the sync phase are non-fatal — it emits a `warning:` and exits 0, -so an already-generated package graph can still produce a successful build. diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md new file mode 100644 index 000000000000..038c36078c37 --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -0,0 +1,109 @@ +# SwiftPM (Apple platforms) — Preview + +[🏠 Home](../../../../../__docs__/README.md) + +> **Preview.** SwiftPM support is an early preview: the commands, flags, +> generated layout, and distribution model may change in future releases, and it +> is not yet recommended for production. CocoaPods remains the supported +> default. + +The scripts in `scripts/spm/` let a React Native iOS app consume React Native +through **Swift Package Manager** instead of CocoaPods, using prebuilt +XCFrameworks. Support is opt-in and additive: `npx react-native spm` injects +package references into the app's existing `.xcodeproj` in place, and `deinit` +reverses exactly what it injected. + +The motivation, staged migration plan, and open questions live in +[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md). +The documents here describe how the implementation actually works. + +### macOS fork integration + +The fork preserves the `RCTUIKit` module and its React compatibility imports. +Prebuilt core mode uses a dependency-only `React-RCTUIKit` facade: the header +sidecar owns its headers, and the React binary product includes its +implementation. Header sidecars follow the binary's platform slices. Generated +linker defaults select AppKit on macOS and UIKit on the supported iOS-family +platforms. + +These foundations do not yet make the app setup flow platform-neutral. The +project injector still selects mobile/Catalyst slices and uses iOS codegen +directories; end-to-end macOS app setup requires the later platform integration. + +The React Native CLI accepts `--configCommand`; direct invocation of +`scripts/setup-apple-spm.js` uses `--config-command`. Both reach the same JSON +argv parser and persisted command. + +## 🚀 Usage + +```bash +cd ios +npx react-native spm # add on first run, update thereafter +``` + +**If any autolinked dependency ships no `Package.swift`, this stops with +`error: Package.swift is missing for library ""` and exit code 2.** That +is deliberate — `add` and `update` never scaffold silently, so a missing +manifest is visible and fixed on purpose. Generate the manifests first, then +re-run setup: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +npx react-native spm # then inject as usual +``` + +Because `node_modules` isn't committed, persist each scaffolded manifest with +`npx patch-package ` and commit the patch — otherwise the same error +returns on every fresh install and in CI. Better still, contribute the manifest +upstream. See +[Community packages without a Package.swift](./spm-scripts.md#community-packages-without-a-packageswift). + +See **[spm-scripts.md](./spm-scripts.md)** for the CLI actions and flags, +CocoaPods migration, brownfield apps, what to commit, fresh clones and CI, and +troubleshooting. + +## 📐 Design + +Three documents cover the design, each owning one area: + +| Document | Covers | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [spm-scripts.md](./spm-scripts.md) | The tool itself: CLI surface, the six-step pipeline, [every file it creates or modifies](./spm-scripts.md#files-the-tool-touches), the two auto-sync hooks, and how Debug/Release flavor selection works. | +| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no header search paths and no `unsafeFlags` in any generated manifest. Also covers remote mode. | +| [spm-autolinking-plugins.md](./spm-autolinking-plugins.md) | The extension seam for frameworks with their own module system (Expo is the first consumer): discovery, the full context/return contract including `flavoredFrameworks`, `watchPaths` and `scriptPhases`, and failure behavior. | + +Two ideas explain most of the architecture: + +- **Headers go through SwiftPM; runtime binaries do not.** A `binaryTarget` + cannot vary by build configuration, but React Native ships flavored binaries + (a debug `React.framework` carries the dev menu and assertions; release strips + them). So the package graph vends headers only, and the flavored frameworks + are linked and embedded through generated Xcode build settings instead. +- **Generated state is regenerable, and the injection is reversible.** + Everything under `build/` is gitignored and rebuilt from the app's + `package.json`; everything written into the `.xcodeproj` is recorded in a + `.spm-injected.json` marker so `deinit` can undo precisely that. + +## 🔗 Relationship with other systems + +### Part of + +- iOS build system — the alternative to the CocoaPods integration in + [`scripts/cocoapods/`](../../cocoapods). + +### Used by this + +- **Prebuilt XCFrameworks** from [`scripts/ios-prebuild/`](../../ios-prebuild) — + produces the `React`, `ReactNativeDependencies`, `hermes-engine`, and + headers-only artifacts that these scripts download, stage, and link. +- **Codegen** (`generate-codegen-artifacts.js`) — its output is installed as a + local `React-GeneratedCode` package rather than a Pod. +- **`@react-native-community/cli config`** — supplies the autolinking metadata + (`autolinking.json`) that the SwiftPM autolinker turns into a `Package.swift`. + Overridable via `--configCommand`. + +### Uses this + +- Apps opting into SwiftPM, via the `spm` React Native CLI command. +- Frameworks layering their own module system on top of React Native, via + [autolinking plugins](./spm-autolinking-plugins.md). diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md new file mode 100644 index 000000000000..3a23c378f91e --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -0,0 +1,359 @@ +# SwiftPM Autolinking Plugins (Preview) + +> **Preview / unstable contract.** The discovery mechanism and the plugin +> function's context/return shape may change while the first consumers (Expo) +> validate it. Pin to a React Native version if you depend on it. + +How a framework with its own module system — Expo is the first consumer — +contributes to the SwiftPM autolinking graph that `npx react-native spm` +generates. See [spm-scripts.md](./spm-scripts.md) for the base tool. + +## Why a plugin (not a static list or a post-process) + +The documented extension points don't cover a framework: + +- `spm.modules` in `react-native.config.js` is a **static** list of simple + source modules. A framework discovers its modules **dynamically** (scanning + `node_modules`), generates a **module registry**, and ships mixed + Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't + handle. +- A one-shot **post-process** of the generated `Package.swift` is **clobbered on + the next sync**: the Xcode [auto-sync hooks](./spm-scripts.md#auto-sync) + re-run autolinking on every dependency change. A framework's contribution must + run _whenever autolinking runs_. + +A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s +`main()` — the single function that both `add` / `update` **and** the build-time +`sync` call — so the contribution is regenerated on every build and never goes +stale. + +(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, +`use_expo_modules!`, and `react_native_post_install` hooks.) + +## Discovery — transitive, zero app config + +A dependency opts in from its **own** `react-native.config.js`, so installing +the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!` +transitively): + +```js +// node_modules/expo/react-native.config.js +module.exports = { + spm: {autolinkingPlugin: './spm/autolinking-plugin.js'}, +}; +``` + +The autolinker already walks every dependency's `react-native.config.js`; any +that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level +registration or allowlist is required. + +**Opt-out escape hatch.** An app can exclude a plugin from its own +`react-native.config.js`: + +```js +module.exports = { + spm: {denyPlugins: ['some-framework']}, // npm names to skip +}; +``` + +## The contract + +A plugin is a function exported from the module named above +(`module.exports = fn`, or `default` / `plugin` named exports also work): + +```js +module.exports = function plugin(context) { + return { + packageDependencies: [ + // Local package (e.g. a scanned module dir) … + { + name: 'ExpoModulesCore', + path: '../../../node_modules/expo-modules-core/ios', + }, + // … or a remote/published package: + // {name: 'SomePkg', url: 'https://…/SomePkg.git', version: '1.2.3'}, + ], + productDependencies: [ + // Linked by the app's AutolinkedAggregate target: + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ], + generatedSources: [ + // e.g. the generated module registry, registered with codegen: + {path: 'build/generated/expo/ExpoModulesProvider.swift'}, + ], + flavoredFrameworks: [ + // Precompiled dynamic XCFrameworks that come in mandatory Debug/Release + // pairs. RN validates and stages both outside the SwiftPM graph. + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/…/output/debug/xcframeworks/ExpoModulesCore.xcframework', + release: '/…/output/release/xcframeworks/ExpoModulesCore.xcframework', + }, + }, + ], + watchPaths: [ + // Inputs whose edits must re-trigger the auto-sync — the plugin's own + // manifest and per-module config (absolute paths, dirs or files): + '/…/node_modules/expo/Package.swift', + '/…/node_modules/expo/expo-module.config.json', + ], + scriptPhases: [ + // Build-time shell phases on the app target — SwiftPM's missing + // `script_phase`: + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: '"$NODE_BINARY" .../createExpoConfig.js', + position: 'beforeCompile', // default: 'end' + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/EXConstants.bundle/app.config'], + alwaysOutOfDate: true, + }, + ], + }; +}; +``` + +### `flavoredFrameworks` — per-configuration precompiled frameworks + +Each entry is +`{id, frameworkName, linkage: 'dynamic', flavors: {debug, release}}`. Both +flavor paths must be absolute and present when `spm add` or `spm update` runs. +The framework and executable names, public headers, and platform slices must +agree across flavors. Static binaries, nested frameworks, duplicate IDs, and +duplicate embedded framework names are fatal. + +The declarations are recorded to +`/.spm-plugin-flavored-frameworks.json`, normalized into the same +immutable app-local slots as React Native, and added to Xcode's exact linker and +embed settings. They are not emitted as SwiftPM product dependencies. Adding or +removing one requires `spm update`; the build-time `spm sync` intentionally does +not mutate runtime framework settings. + +### `watchPaths` — plugin staleness inputs + +`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode +auto-sync hooks watch to decide whether they must re-sync. RN already watches +each module's source dir plus every npm dep's checked-in `Package.swift` and +`.react-native/` dir; a plugin adds the inputs only it knows about — e.g. +`packages/expo/Package.swift`, `expo-module.config.json`, and per-module +manifests. On the next build the phase re-syncs when a watched **file** is newer +than the last sync, a watched **dir** has a newer child, or a watched path has +**vanished** (a rename forces a re-sync so the config error surfaces). + +Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored +with a warning (never fatal), and each non-string / empty / **relative** entry +is dropped with a warning. Absolute-only, because the generated phase tests +these paths with no cwd context. The kept paths are folded into +`/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted. + +### `scriptPhases` — build-time shell phases on the app target + +SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must +run a script during the app's build — `expo-constants` writing +`EXConstants.bundle/app.config` is the first consumer — declares it here. Each +entry is recorded to `/.spm-plugin-script-phases.json`, which +`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry +on the injected app target: + +| Key | Meaning | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | **Stable key** — the ledger entry and the deterministic UUID seed. Charset `/^[@A-Za-z0-9_./-]+$/`, so a scoped npm name like `@expo/log-box` is a valid id; a `:` is not, because the id is hashed into the UUID seed as `plugin:` and the separator must stay unambiguous. Renaming it is a _remove + add_, not a rename. | +| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. | +| `script` | The shell body. | +| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. | +| `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. | +| `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. | + +**Placement.** `'end'` appends at the true end of the target's `buildPhases` — +after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the +"Sync SPM Autolinking" phase, which stays first because it regenerates the +content everything else reads, and always **before Sources**: React Native never +re-seats its own sync phase, so if you have dragged that below Sources your +`beforeCompile` phases are seated ahead of Sources instead of following it. +Phases sharing a position keep their declared order. + +**Position is enforced on every sync.** `add`/`update` compares where the plugin +phases actually sit in `buildPhases` against the declared placement and, **only +when the two differ**, lifts their membership lines and re-seats them in +declared order. So changing `position` — or swapping two phases that share one — +takes effect on the next `spm add`/`update`, with no remove + re-add. When they +agree nothing is rewritten, which is what keeps an unchanged declaration +re-syncing to a byte-identical project. The consequence worth knowing: a phase +you **drag somewhere else in Xcode is moved back** to its declared position on +the next sync, because the plugin's declaration is the source of truth. Only the +`id` behaves differently — it is a key, not a label, so renaming it is a remove + +- add. + +Phases are injected by `spm add` / `spm update` **only**. The build-time `sync` +rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared +phase appears on the next `add`/`update`, not on the next build. Each phase's +UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's +`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path +lists, `alwaysOutOfDate` and placement in place, `update` removes phases that +left the sidecar, and `deinit` reverts all of them. + +Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a +non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one +plugin or across plugins) aborts the run. A silently dropped phase would produce +a green build whose generated content was never written — a runtime failure with +no build-time signal — and two phases sharing an `id` would collapse onto one +ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even +though the charset admits them: as keys of that ledger they never become own +properties, so the phase would look recorded, disappear when the marker is +serialized, and be unremovable by `deinit`. + +**Hostile names.** A `name` reaches the project file twice. In the `name` field +— what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any +single-line string is expressible. Beside the phase's UUID, on the object's +definition line and on its `buildPhases` member line, it also becomes a +`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the +`name` field) but the text around them is scanned by delimiter, so the name is +**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single +spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` — +normalized the same way — and then to no comment at all if nothing survives +either. Without that, a `{` in a comment would make the injector read the next +object's body as this one's, and a `,` would make `deinit` delete the wrong line +— corruption with no error. Only a line break is therefore rejected outright; a +name is a display name, and no Xcode phase name spans lines. + +The injector's read of the sidecar is deliberately lenient — the file does not +exist yet on a first `spm add`, and a stale or hand-edited copy must not break +injection. An absent file yields no phases silently, an unparseable one warns, +and a single entry failing the same checks (bad or reserved `id`, empty or +multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is +**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it +enforces exactly the rules the plugin contract does. + +**Gating is the script's job.** The phase runs for every configuration and +platform the target builds; if it should be a no-op for some of them (Release +only, simulator only, …), the script must check `$CONFIGURATION` / +`$PLATFORM_NAME` and exit early. + +### Context (input) + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `appRoot` | The Xcode project directory (`/ios`) being injected — **not** the app package root. Deriving package-root-relative paths from it (e.g. `path.join(appRoot, 'node_modules')`) silently breaks; use `projectRoot` for that. | +| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. | +| `reactNativeRoot` | Resolved `react-native` package root. | +| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. | +| `outputDir` | `build/generated/autolinking` — where generated artifacts land. | +| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. | + +#### `context.react` — depending on React + +A plugin that emits its own `Package.swift` must declare React as a dependency. +Rather than re-deriving React Native's package path, identity, and product names +— which differ between local and remote mode and **move as RN repackages** — +take them from `context.react`: + +```js +react: { + packageRef: + {name: 'ReactNative', path: '', relPath: ''} // local + | {name: '', url: '', version: ''}, // remote (SPM-resolved) + products: [ + {name: 'ReactHeaders', package: 'ReactNative'}, + {name: 'ReactNativeHeaders', package: 'ReactNative'}, + {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, + {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, // ← separate, per-app package + ], +} +``` + +Local vs remote is signalled by which `packageRef` keys are present (`path` xor +`url`+`version`). `packageRef.path` is **absolute** — always correct no matter +which subdirectory of `outputDir` the plugin writes its own manifest into (the +generated manifests are gitignored and regenerated every sync, so there's no +portability cost); `relPath` (relative to `outputDir`) is provided as a +convenience. `products` is the set React Native wires into **its own** +autolinked targets (so a plugin's target compiles against exactly RN's React +surface), filtered to those resolvable this run — every listed product is safe +to reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in +the separate `React-GeneratedCode` package (per-app codegen), which a +hand-rolled plugin would miss, and which is omitted when that package is absent. +Because RN derives this list from one source of truth alongside its own product +wiring, it stays correct across repackaging. + +### Return (contributions, all optional) + +| Field | Merged into | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). | +| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | +| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | +| `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | +| `scriptPhases` | Recorded for `spm add` / `update` to emit one `PBXShellScriptBuildPhase` per entry on the app target. Malformed entries and duplicate `id`s are fatal. | + +The plugin returns **data** — it never writes into React Native's generated +tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` +byte-for-byte (idempotent). Package and product contributions are **deduped by +name** across plugins. + +## Lifecycle + +```text +react-native spm add / update ─┐ + ├─► generate-spm-autolinking main() +Xcode "Sync SPM Autolinking" ──┘ │ +(build phase, every build) ├─ 1. discover plugins (dep configs) + ├─ 2. RN builds its own dep graph + ├─ 3. invoke plugins (context in) + └─ 4. merge results → aggregator Package.swift +``` + +Because steps 1–4 run in the one `main()`, everything above shares the same seam +— there is no separate hook to wire for the build-time path. + +## Failure behavior + +Fail-closed and **named**: a plugin that fails to load, doesn't export a +function, throws, or returns a malformed contribution aborts the run with a +message identifying the framework. A framework silently dropping its modules (a +green build missing native code) is worse than a loud stop. + +## Status & open items (Preview) + +- **Implemented & tested:** discovery (transitive + deny-list), invocation, + package + product merge, fail-closed validation, and dual-flavor framework + normalization/link/embed outside SwiftPM. +- **Implemented & tested:** `generatedSources` **app-target wiring**. The merge + writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` xcodeproj + injector (generate-spm-xcodeproj.js) reads it and wires each source **into the + app target** — a `PBXFileReference` + `PBXBuildFile` + a Sources-build-phase + entry, parented under one "SPM Generated Sources" navigator group. This is + what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach the ObjC + classlist: a class inside the static Autolinked aggregate never does, so + `NSClassFromString` discovery would fail. Paths are stored SRCROOT-relative + when under the app root (the usual `build/generated/…` case), else absolute + (`sourceTree = ""`). All UUIDs are namespaced on the normalized path + (deterministic/idempotent) and recorded in the `.spm-injected.json` marker's + `generatedSources` map, so `deinit` reverts them and `update` reconciles + entries that left the manifest. A target without a Sources phase logs loudly + and skips the wiring (injection otherwise succeeds). v1 targets only the + injected app target and assumes `.swift` in practice (`.m`/`.mm` are mapped as + future-proofing). +- **Implemented & tested:** `scriptPhases`, contract through injection. + `invokePlugins` validates every entry fatally (`id` charset plus the reserved + `__proto__`/`constructor`/`prototype` names, a single-line `name`, a required + `script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus + duplicate `id`s), and the merge always rewrites + `.spm-plugin-script-phases.json` — `[]` when no plugin declares any, so + removing a plugin clears stale entries. The `spm add`/`update` xcodeproj + injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry + on the app target at the requested position, recording the id→UUID map in the + `.spm-injected.json` marker so a re-run refreshes each phase's content in + place, re-seats it when its declared position or order changed, `update` + removes phases that left the sidecar, and `deinit` reverts them. Like + `flavoredFrameworks`, the build-time `sync` only rewrites the sidecar; it + never mutates the project. +- **Co-design with Expo (not final):** codegen **provider ordering** — codegen + must consume the same discovered module set the plugin contributes — is + intentionally left for the first real plugin to drive to a stable shape. +- Contract to be ratified via RFC once Expo's plugin proves it (framed as a + generic hook, not Expo-specific code in RN). diff --git a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md new file mode 100644 index 000000000000..ff467c249b92 --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -0,0 +1,100 @@ +# SPM headers & package references — how they resolve + +React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search paths +and no `unsafeFlags` in any generated manifest. Headers are served by SPM +products/binary targets, and every generated `Package.swift` references the +React Native + codegen packages with plain, fixed-relative paths computed at +generation time (no runtime discovery). This document is the single source of +truth for how that resolves. + +> History: earlier iterations materialized two header trees and fed them to +> consumers as `-I` flags read from `spm-paths.json` / +> `.react-native/paths.json` via an inlined Swift loader. That whole mechanism +> (the loader `renderRNPathsLoader`, the `writeAppPathsJson` / +> `writeSharedPathsJson` writers, and both JSON files) has been **deleted** — +> manifests are now declarative. If you find a reference to those files, it is +> stale. + +## How headers resolve (no search paths) + +| Namespace | Served by | Mechanism | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | +| Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | +| ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | + +The one remaining materialized header tree is the per-app farm at +`/build/generated/ios/ReactAppHeaders` (built by +`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It is +vended as the `ReactAppHeaders` SPM target — consumers reach it through a +product dependency, never through `-I`. + +`autolinking.json` (the `@react-native-community/cli config` output) is an INPUT +used to generate the manifests; it is never read by a manifest. + +## How each manifest references the React + codegen packages + +Every generated manifest sits at a known depth inside the app and is regenerated +on every `react-native spm` run, so package references are plain fixed-relative +paths — no walk-up, no JSON, no `import Foundation`. + +| Manifest | Location | How it references the React + codegen packages | +| ------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | +| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | +| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | +| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | +| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | + +## Remote-package mode + +Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted +`url`). When set, the whole app graph flips to a single remote React Native +package identity: `.package(path: build/xcframeworks)` becomes +`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), +and the local artifact download + compose is skipped. SPM's +one-version-per-package rule then unifies app + every library on one resolved +React Native. The package identity is derived from the URL tail (swift-tools 6 +dropped `.package(name:url:)`) — nothing hardcodes a repo name. + +**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version +is not a free parameter: the SPM graph must compile against the same React +Native the JS/native code uses, so the app (graph root) pins EXACT to the +_installed_ RN version, read from `node_modules/react-native/package.json`. +`RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, +not the source of truth — they're only needed when the installed version isn't +publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote +tag). A _derived_ version is never persisted, so an `npm install` that upgrades +RN auto-re-pins the SPM graph on the next `spm` run; an _override_ is persisted +as `versionOverride` so it survives Xcode-phase re-syncs without the env. + +Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still +read, with `version` honored as an override (back-compat). If remote mode is on +but no usable version can be resolved — react-native isn't installed, or it's a +non-publishable dev placeholder and no override is set — the tooling errors +(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` +or install a released react-native, rather than silently pinning an unpublished +tag. + +## Hand-authored community library contract + +A library that ships its own `Package.swift` (no scaffolder/autolinker marker) +is left untouched by the tooling. It needs only two things, and **no discovery +code**: + +1. Depend on the React Native SPM package and its products — in remote mode + `.package(url: "", exact: "")` + + `.product(name: "ReactNative", …)` and + `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a + version RANGE in production; the consuming app pins EXACT.) +2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` + and generate with + `generate-codegen-artifacts.js --path . --targetPlatform ios --source library`. + Output lands at `/build/generated/ios/ReactCodegen/`, reachable + from the manifest with one safe `.headerSearchPath(...)` into the library's + own tree. The app-side codegen then skips the lib's spec (no duplicate + symbols). + +This makes the library self-contained — it carries no app-layout knowledge and +needs no per-app codegen headers from the consuming app. Proven with +`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md new file mode 100644 index 000000000000..af1c6e8a463c --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -0,0 +1,646 @@ +# SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) + +> **Preview.** SwiftPM support is an early preview: the commands, flags, +> generated layout, and distribution model may change in future releases, and it +> is not yet recommended for production. Feedback is welcome. CocoaPods remains +> the supported default. + +Build React Native iOS apps using **Swift Package Manager** with prebuilt +XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — +CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` in +place and is fully reversible. + +## Quick Start + +```bash +cd ios + +# First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, +# in place. `npx react-native spm` with no action auto-resolves to `add` (or +# `update` once injected); on a fresh CocoaPods app it converts in one command +# (implies --deintegrate). To do it explicitly: +npx react-native spm add --deintegrate + +# Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. +open MyApp.xcodeproj +``` + +After the initial run, the project carries **auto-sync hooks** that detect +dependency changes and re-run autolinking before compilation (see +[Auto-Sync](#auto-sync)) — you don't re-invoke `react-native spm` manually for +day-to-day dependency changes. **On a fresh clone or CI checkout, run +`npx react-native spm` once before building** (see +[Fresh clones & CI](#fresh-clones--ci)). + +> **Note:** `react-native spm` is a thin wrapper over +> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI alias +> is unavailable in your environment, invoke the script directly with the same +> actions and the kebab-case flag equivalents (e.g. `--skip-codegen`). + +## CocoaPods → SwiftPM migration + +`spm add` injects into a project that is **not** CocoaPods-integrated. On a +CocoaPods app it fails loud and points you at `--deintegrate`, which: + +1. runs `pod deintegrate` — removes CocoaPods integration from the `.xcodeproj` + (Pods references, `[CP]` build phases, xcconfig links). Your `Podfile` is + left on disk. +2. strips **only** the React Native directives (`use_react_native!`, + `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — + every other line, **including your own `pod '…'` entries, is preserved**. +3. injects SwiftPM into the `.xcodeproj`. + +React Native now comes from SwiftPM; no pods are linked yet (deintegrate removed +the integration). + +### Keeping non-RN pods + +Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile +still lists them (only the RN directives were removed) — re-integrate them with +a normal install: + +```bash +pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace +``` + +Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes +the SwiftPM-injected project, so React Native resolves through SwiftPM and your +other pods through CocoaPods, together. + +> **Do not re-add `use_react_native!`.** React Native must be provided by +> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, +> so a dual-managed RN does not build. `spm add` refuses to run while the +> Podfile still declares `use_react_native!`. + +The migration is fully reversible — see +[Removing / resetting](#removing--resetting). + +## Brownfield apps + +`spm add` injects into your existing `.xcodeproj` in place, so an app that +embeds React Native works the same way — point it at the right project and +target: + +```bash +npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp +``` + +**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** +— i.e. the app's `package.json` is a parent directory of the project. Both setup +and the build-time sync locate React Native by walking up from the project to +the nearest `package.json`. The common "native project at the repo root with the +RN JS in a sibling/child subfolder" layout is **not supported yet** — there is +no way to point at a JS root outside the project's ancestors. + +Brownfield apps that keep CocoaPods for their other native dependencies follow +the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, +everything else from CocoaPods, and no `use_react_native!` in the Podfile. + +## CLI Actions + +```bash +react-native spm [action] [options] +``` + +With no action, the command **auto-resolves**: if SwiftPM has been injected +(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. On +a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the +zero-arg path additionally implies `--deintegrate` (the safe-gate), so +`npx react-native spm` converts a brand-new app to SwiftPM in one command. + +When invoked from the JS root of a standard RN app (sibling `ios/` subdir), the +command auto-redirects into `ios/` with a banner. + +| Action | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | +| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Three things it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | +| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | +| `download` (advanced) | Download/check xcframework artifacts only. | + +## CLI Options + +Flags below use the `react-native spm` (camelCase) form. The raw script accepts +kebab-case equivalents (e.g. `--skip-codegen`). + +| Option | Description | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--version ` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | +| `--yes` | Skip the dirty-pbxproj confirmation prompt | +| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | +| `--productName ` | [add] Which app target to inject into (when several exist) | +| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | +| `--download ` | [advanced] Artifact download policy (default: auto) | +| `--skipCodegen` | [advanced] Skip the codegen step | +| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | + +### The autolinking config command is remembered + +An app that replaces `@react-native-community/cli` autolinking (an Expo app, for +example) has to tell `spm` how to produce `autolinking.json`. Pass the command +once, on `add` or `update`: + +```bash +npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +``` + +Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and +the build-time `sync` — resolves the command in this order: + +1. `--configCommand` +2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` +3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an + earlier `add`/`update` +4. the default `@react-native-community/cli config` + +`add`/`update` pin whichever of the first two routes supplied the command, +validated as an argv array; a later run that passes neither keeps the existing +pin, and passing `--configCommand` again replaces it. The pin exists because the +**Sync SPM Autolinking** build phase inherits neither your flag nor the shell +that exported the env var — without it, a successful `add` is followed by +failing builds, because the phase re-derives `autolinking.json` with the default +command. A pin never shadows the env var, so an override in your shell still +takes effect, and a pin that no longer parses is ignored in favor of the +default. + +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` +therefore falls back to the default command unless you pass `--configCommand` +(or export the env var) again. + +### Pinning the React Native version + +The resolved version selects **which artifact slots the project is wired to**, +so it has to stay the same from one run to the next. `--version` is therefore +recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and +read back by later runs, which resolve the version in this order: + +1. an explicit `--version `, +2. the version a previous `--version` pinned into the marker, +3. `node_modules/react-native/package.json`. + +So you pass the flag once, and a later flagless `add`/`update` stays on the +slots it selected. Without the pin, that flagless run falls back to +`package.json` and re-points the project at different artifact slots while the +marker still advertises the pinned version. + +`deinit` deletes the marker, and with it the pin — a later `add` resolves +`node_modules/react-native/package.json` again unless you pass `--version`. + +### Debug/Release flavor is automatic + +React Native ships **flavored** prebuilt binaries: the _debug_ `React.framework` +(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience — dev +menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while _release_ strips them +for production. A Debug build must embed the debug binaries and a +Release/archive the release ones. + +SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime +frameworks are deliberately kept out of the package graph. `spm add` downloads +and validates **both** flavors into immutable app-local slots. It injects +SDK/architecture-qualified Xcode settings that link the exact selected binaries, +plus one phase that copies and signs the selected frameworks into the app. +Configurations containing `debug` or `development` select Debug; every other +configuration selects Release. Selection uses only generated build settings and +standard macOS tools: builds do not run Node, mutate symlinks, regenerate the +package graph, or require a second build. + +Those same debug-flavored configurations also get +`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"` — the only thing +that makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches +C/ObjC/C++ only), and what `AppDelegate.swift`'s `bundleURL()` branches on to +load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at +`pod install` time, so this keeps SwiftPM apps at parity. An existing value is +left alone. + +## Files the tool touches + +Paths are relative to the Xcode project directory (`ios/`) unless noted. + +### In your repo — committed + +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — the scheme is deleted if `add` created it, otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | + +The two pinned settings are the `--version` pin (`artifactsVersionOverride`, see +[Pinning the React Native version](#pinning-the-react-native-version)) and the +[autolinking config command](#the-autolinking-config-command-is-remembered). +Because `deinit` drops the marker, it drops both. + +### In your repo — generated, gitignored + +| Path | Written by | Contents | +| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | +| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. | +| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | +| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | + +`deinit` leaves all of the above in place — it is regenerable, and removing it +is `rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). + +### Outside your repo + +| Path | Written by | Notes | +| ---------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | +| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `sync`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | +| `~/Library/Caches/ReactNative/` | `download` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | + +Injection is **purely additive** and **idempotent**: every other byte of your +project — signing, capabilities, your own Build Phases — stays untouched, and a +re-run is a no-op. The injected refs point at three stable sub-package paths +under `build/`, so adding or removing community deps changes the sub-package +contents (gitignored) and never re-injects. `deinit` removes exactly what was +injected, leaving the project byte-identical to its pre-`add` state — with the +exceptions called out above, and one more described next. + +**Build settings that already exist** are edited in place. The four array +settings `add` merges into — `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, +`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS` — keep the shape they were +written in: Xcode's multi-line form as well as the compact one-line form hand +edits and other generators (XcodeGen, Tuist) emit. One that exists as a plain +_scalar_ is promoted to a `( … )` array — the shape an Xcode-authored target can +carry, e.g. a +`LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` written +as a scalar rather than a list. `add` records the pre-injection value in the +marker and `deinit` restores it by rewriting the whole field — once folded +together, the injected members and your own are indistinguishable — so **members +you add to a promoted array by hand afterwards are lost**. That applies to +`update` too, which reverts to the recorded baseline before re-injecting. + +## Fresh clones & CI + +Everything under `build/` is gitignored, so a clean checkout has no resolvable +Swift packages until they are regenerated. Xcode resolves the package graph +before build phases **and** before scheme pre-actions, so neither +[auto-sync hook](#auto-sync) can rescue this: with `build/generated/autolinking` +missing, the build stops at _"Resolve Package Graph … doesn't exist"_ having run +neither hook. + +Verified on Xcode 26.6 against a freshly-injected app with `build/` deleted: +`xcodebuild -scheme … build` fails in nine lines of log, with +`Resolve Package Graph` as the first step and no trace of the pre-action; +`xcodebuild -resolvePackageDependencies` fails identically. Opening the project +in Xcode also resolves the graph on load, before you press Build. + +So run the setup command once after cloning, before building — the SwiftPM +analog of `pod install`: + +```bash +npx react-native spm # downloads artifacts (if missing) + regenerates build/ +``` + +On an already-injected project this routes to `update`: it fetches the +xcframework artifacts into the shared cache if they aren't present and +regenerates `build/xcframeworks` + `build/generated`. After this first run, +incremental dependency changes are picked up automatically by the auto-sync +hooks. + +**Automate it** so nobody has to remember — add a `postinstall` hook, which runs +as part of the `npm install` / `yarn install` your CI already does before +`xcodebuild`: + +```json +{ + "scripts": { + "postinstall": "react-native spm" + } +} +``` + +`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook +works from the app root; in CI (non-interactive) it proceeds without prompting. +It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op +when nothing changed), so it is slightly heavier than the internal `sync` the +build phase calls — a fine trade for not having to remember a command. + +> A future remote-package distribution (a tagged `Package.swift` repo + +> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves +> and fetches the artifacts itself during normal package resolution. Until then, +> the one-time setup run is required on clean machines. + +## Local Native Modules + +Modules not discovered via autolinking can be declared in +`react-native.config.js`: + +```js +module.exports = { + spm: { + modules: [ + { + name: 'MyNativeModule', + path: 'ios/MyNativeModule', // relative to app root + exclude: ['*.podspec'], // optional + }, + ], + }, +}; +``` + +Each entry becomes a target in `build/generated/autolinking/Package.swift`. +Sources outside `build/generated/autolinking/` are automatically mirrored with +file-level symlinks. + +## Dependencies between libraries + +SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs +another native library declares it explicitly with `spm.dependencies` in its +**own** `react-native.config.js` — a list of npm names: + +```js +// react-native-reanimated/react-native.config.js +module.exports = { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['react-native-worklets']}, +}; +``` + +The autolinker starts from the directly-autolinked deps, follows each one's +`spm.dependencies` **recursively**, and dedupes the result, so a transitive +dependency is pulled into the package graph even when the app never depends on +it directly. Declared names are mapped to Swift target names, so the dependent +library's target can import it. + +This is a **library-author** surface, like the podspec dependency it replaces — +apps don't normally set it. + +### Config module format + +`react-native.config.js` may be CommonJS or ESM, and both named and default +exports are read. A key defined twice — as a named export and on the default +export — resolves to the named one. Avoid that shape anyway: the Community CLI +has two loaders that disagree about it, a sync one that sees named exports and +an async one that takes only the default export. For maximum compatibility, +prefer the one-line CommonJS form: + +```js +module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}}; +``` + +If the config fails to load, a warning names the file and the reason — the `spm` +settings in it are ignored rather than silently applied. + +## Self-managed community packages + +A community library that ships its own `Package.swift` is referenced directly by +the autolinker instead of being wrapped. To keep SwiftPM's package identity +(which it derives from the path basename) unique across deps — even when several +libs put their manifest inside an `ios/` subdir — each self-managed dep is +exposed through a uniquely-named symlink at +`build/generated/autolinking/libs//`. The aggregator `Package.swift` +references that path, so two libs both shipping `/ios/Package.swift` never +collide on identity `"ios"`. + +The `libs/` directory is wiped and recreated on every autolinker run, so +deleting a dep via `npm uninstall` cleans up the alias automatically on the next +build. + +## Community packages without a Package.swift + +If an autolinked library ships **no `Package.swift`**, `spm add`/`update` stops +with a per-dep error (`Package.swift is missing for library ""`) and exits +**2** — a distinct code from a generic failure, so CI and the Xcode sync hooks +can treat it as a hard error while staying lenient about transient sync +failures. + +`add` and `update` deliberately **never** scaffold on your behalf: +auto-scaffolding would hide a real gap in the dependency's SPM support. Generate +the manifest from the library's podspec explicitly, then re-run setup: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +npx react-native spm # then inject/update as usual +``` + +(`scaffold` also runs codegen and regenerates the autolinking package, but it +does not inject into the `.xcodeproj` — so on a first-time setup you still +follow it with `npx react-native spm`.) + +Because `node_modules/` isn't committed, persist it so it survives the next +install: + +```bash +npx patch-package # then commit the generated patch +``` + +**Better: contribute the manifest upstream.** The generated `Package.swift` is a +normal, committable manifest — the ideal fix is for the library to ship it +itself, so every consumer gets SwiftPM support without a local patch. Please +**file an issue or open a PR on the library** with the scaffolded +`Package.swift` (mention it was generated by `react-native spm scaffold` for +React Native SwiftPM support). Until it lands upstream, the `patch-package` +workaround keeps your app building. + +> A library whose sources mix Swift **and** Objective-C/C++ in one target, or +> that ships neither a `Package.swift` nor a podspec, can't be scaffolded +> automatically — the error says so. Opt it out via `react-native.config.js` +> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. + +## Framework plugins (Preview) + +Frameworks with their own module system (e.g. Expo) contribute to the +autolinking graph through a **plugin** — a function invoked on every +regeneration (including the build-time sync) that adds SwiftPM package refs, +product dependencies, and generated sources. Discovery is transitive (installing +the framework is enough), and the plugin returns data that RN merges +idempotently. + +See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the +discovery mechanism, the full context/return contract, lifecycle, and failure +behavior. + +## Removing / resetting + +To remove SwiftPM entirely, use `deinit` (the inverse of `add`): + +```bash +react-native spm deinit # surgically removes everything `add` injected +pod install # then, to restore CocoaPods +``` + +To reset the regenerable build state (without un-injecting), just delete the +gitignored dirs and re-run: + +```bash +rm -rf build/xcframeworks build/generated .build +react-native spm update +``` + +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does +not touch SwiftPM-generated directories. The cached xcframework slot is shared +across apps; refresh it with `react-native spm update --download force`. + +## Troubleshooting + +| Problem | Fix | +| ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | +| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | +| `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | +| Missing headers | Re-run `react-native spm` | +| "not contained in target" | Re-run setup (regenerates file-level symlinks) | +| Codegen fails | Use `--skipCodegen` to iterate on other parts | +| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | +| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | +| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | + +--- + +## Reference / internals + +### Pipeline + +`react-native spm add` and `react-native spm update` orchestrate these steps: + +| Step | Script | Output | +| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | +| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | +| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | +| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | +| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | + +### Directory Layout + +```text +my-app/ios/ + MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) + Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) + build/ + generated/ + autolinking/ <-- gitignored (regenerated at build time) + Package.swift + autolinking.json + packages/ <-- synth wrappers for autolinker-managed deps + libs/ <-- symlinks to self-managed deps' Package.swift + dirs, named by Swift module so SwiftPM + package identity stays unique + headers/ <-- generated header symlinks + ios/ <-- gitignored, codegen output + xcframeworks/ <-- gitignored, immutable runtime flavor slots + invariant package + debug/ + React.xcframework -> ~/Library/Caches/.../debug/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + release/ + React.xcframework -> ~/Library/Caches/.../release/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + ReactHeadersTarget/ <-- canonical Objective-C React headers + module map + ReactNativeHeaders.xcframework -> ... + ReactNativeDependenciesHeaders.xcframework -> ... + flavored-frameworks.json + .artifact-stamp +``` + +### Header Resolution + +React Native uses CocoaPods-style imports (`#import `) that +SwiftPM doesn't natively support. The prebuilt artifacts serve them through +SwiftPM package products — no `-I` search-path flags, and no clang VFS overlay: + +1. **`` and `import React`** resolve through the invariant + **`ReactHeaders` Clang target**. It stages one canonical header copy after + proving Debug and Release expose identical public headers, and uses a plain + `module React` module map with `React/`-prefixed paths. +2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, + `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a + headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM + auto-serves to dependents. +3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, + `double-conversion/`, `fast_float/`, `SocketRocket/`) come from + **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only + sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` is + framework-type and can't expose those headers to SwiftPM). + +Targets that compile against React take these as product dependencies +(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus +the app's `ReactAppHeaders`), so all of the above resolve with zero search-path +flags. + +### Auto-Sync + +Autolinking is kept up to date without manual re-runs of `react-native spm` by +**two hooks running the same sync script**, injected by `add`/`update`: + +| Hook | Where | Role | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Scheme pre-action | The app's **shared** scheme (`xcshareddata/xcschemes/`), under `BuildAction` → `PreActions` | Fires earlier in the build than a build phase can, so it is the one that normally does the re-sync. | +| `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | + +Neither hook can bootstrap a clean checkout. Xcode resolves the Swift package +graph before build phases **and** before scheme pre-actions, so if the generated +packages are missing entirely, resolution fails and the build stops before +either hook runs — see [Fresh clones & CI](#fresh-clones--ci). The hooks keep an +_existing_ set of generated packages current; they do not create the first one. + +**How the sync script works:** + +1. Compares timestamps of staleness inputs against + `build/generated/autolinking/.spm-sync-stamp`: + - `package.json` — dependency declarations + - `react-native.config.js` — `spm.modules` config + - `node_modules/` directory mtime — updated by any package manager (npm, + yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups + - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks + stale + - every path in `.spm-sync-watch-paths` — RN's own inputs plus any + [plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs) + `watchPaths`; a watched file that is newer, a watched dir with a newer + child, or a watched path that has **vanished** all mark stale +2. If any input is newer (or the stamp is missing): runs + `npx react-native spm sync`, which re-executes autolinking + package + generation (downloading artifacts if the cache slot is incomplete) and writes + the stamp file. +3. If all inputs are fresh: exits immediately (~1ms). + +**Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: + +| Step | Owner | +| ----------------------------------------------- | ----------------- | +| Resolve Package Graph | Xcode | +| **Sync SPM Autolinking** | scheme pre-action | +| Prepare packages / ComputeTargetDependencyGraph | Xcode | +| CreateBuildDescription | Xcode | +| **Sync SPM Autolinking** (safety net) | build phase 1 | +| Sources (compile) | build phase 2 | +| Frameworks (link) | build phase 3 | +| Embed React Native Flavored Frameworks | build phase 4 | +| Resources (copy) | build phase 5 | +| Build JS Bundle | build phase 6 | + +Resolution coming first is what makes the one-time setup run necessary on a +clean checkout; it is not something either hook can work around. + +A sync failure is lenient by default but **not unconditionally**. The generated +script branches on the exit code: + +- **Exit 2** — an autolinked dependency ships no `Package.swift`. This **fails + the build** (`exit 1`), deliberately: the autolinker has already printed an + `error:` line per dep, and the fix needs a terminal (see + [Community packages without a Package.swift](#community-packages-without-a-packageswift)). +- **Any other non-zero exit** — emits + `warning: SPM sync failed — build may use stale codegen/autolinking` and lets + the build continue, so an already-generated package graph can still produce a + successful build. + +That split is the whole reason the missing-manifest case has its own exit code: +a transient sync hiccup should not break a build that could still succeed, while +a genuinely missing manifest should not pass silently. diff --git a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js index f45cc5b04a81..5671fbeb7c1e 100644 --- a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js +++ b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js @@ -340,4 +340,204 @@ describe('invokePlugins', () => { expect(res.watchPaths).toEqual([]); expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true); }); + + it('merges scriptPhases across plugins, preserving optional fields', () => { + const res = invokePlugins( + [ + mk('expo-constants', () => ({ + scriptPhases: [ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + ], + })), + mk('b', () => ({ + scriptPhases: [{id: 'b.stamp', name: 'Stamp', script: 'echo hi'}], + })), + ], + ctx, + ); + expect(res.scriptPhases).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + // A package's own npm name is the obvious stable id, and the scoped form is + // the common one (`@expo/log-box` is a named consumer). + it.each([['@expo/log-box'], ['@expo/ui']])( + 'accepts the scoped npm name %s as a scriptPhase id', + id => { + const res = invokePlugins( + [mk('expo', () => ({scriptPhases: [{id, name: 'X', script: 'echo'}]}))], + ctx, + ); + expect(res.scriptPhases).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it('defaults scriptPhases to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.scriptPhases).toEqual([]); + }); + + it('rejects a non-array scriptPhases declaration', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({scriptPhases: {id: 'x', name: 'X', script: 'y'}}))], + ctx, + ), + ).toThrow(/non-array scriptPhases/); + }); + + it.each([ + ['missing id', {name: 'X', script: 'echo'}], + ['empty id', {id: '', name: 'X', script: 'echo'}], + ['non-string id', {id: 7, name: 'X', script: 'echo'}], + ['an id containing a space', {id: 'a b', name: 'X', script: 'echo'}], + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id containing a colon', {id: 'expo:phase', name: 'X', script: 'echo'}], + ['missing name', {id: 'a', script: 'echo'}], + ['empty name', {id: 'a', name: '', script: 'echo'}], + ['missing script', {id: 'a', name: 'X'}], + ['empty script', {id: 'a', name: 'X', script: ''}], + [ + 'unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + [ + 'non-array inputPaths', + {id: 'a', name: 'X', script: 'echo', inputPaths: '/in'}, + ], + [ + 'non-string inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: [7]}, + ], + [ + 'empty inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: ['']}, + ], + [ + 'non-array outputPaths', + {id: 'a', name: 'X', script: 'echo', outputPaths: '/out'}, + ], + [ + 'non-string outputPaths entry', + {id: 'a', name: 'X', script: 'echo', outputPaths: [null]}, + ], + [ + 'non-boolean alwaysOutOfDate', + {id: 'a', name: 'X', script: 'echo', alwaysOutOfDate: 'yes'}, + ], + ['null entry', null], + ['a number instead of an entry', 42], + ['a string instead of an entry', 'echo'], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + ])('rejects a scriptPhase with %s', (_label, entry) => { + expect(() => + invokePlugins([mk('expo', () => ({scriptPhases: [entry]}))], ctx), + ).toThrow(/invalid scriptPhase/); + }); + + it('names the offending id in the invalid-scriptPhase error', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'ok.phase', name: 'Fine', script: 'echo'}, + {id: 'bad:phase', name: 'Bad', script: 'echo'}, + ], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase 'bad:phase'/); + }); + + it('rejects a duplicate scriptPhase id within a single plugin', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'dup.phase', name: 'One', script: 'echo one'}, + {id: 'dup.phase', name: 'Two', script: 'echo two'}, + ], + })), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + it('rejects a duplicate scriptPhase id across plugins, naming it', () => { + const phase = () => ({id: 'dup.phase', name: 'Dup', script: 'echo'}); + expect(() => + invokePlugins( + [ + mk('a', () => ({scriptPhases: [phase()]})), + mk('b', () => ({scriptPhases: [phase()]})), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + // A line break is the one thing an Xcode phase display name can never carry. + // Every other hostile character is safe by construction: the injector + // normalizes the name for the `/* … */` comments and escapes it in the `name` + // field, so nothing structural reaches the project text. + it.each([ + ['a newline', 'Line one\nLine two'], + ['a carriage return', 'Line one\rLine two'], + ])('rejects a scriptPhase name containing %s', (_label, name) => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase name for 'expo\.phase'/); + }); + + it.each([ + ['pbxproj quoting', 'Bundle "app.config"'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* opener'], + ])('keeps a scriptPhase name needing %s verbatim', (_label, name) => { + const res = invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ); + expect(res.scriptPhases[0].name).toBe(name); + }); }); diff --git a/packages/react-native/scripts/spm/__tests__/cli-config-command-test.js b/packages/react-native/scripts/spm/__tests__/cli-config-command-test.js new file mode 100644 index 000000000000..e2300cd3fe5e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/cli-config-command-test.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +jest.mock('../../setup-apple-spm', () => ({main: jest.fn()})); + +test('the React Native CLI forwards the documented configCommand without splitting its argv', async () => { + const {commands} = require('../../../react-native.config'); + const {main} = require('../../setup-apple-spm'); + const command = commands.find(candidate => candidate.name === 'spm [action]'); + expect(command.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({name: '--configCommand '}), + ]), + ); + const argv = JSON.stringify([ + 'node', + '/path with spaces/config.js', + '--json', + ]); + await command.func(['update'], {}, {configCommand: argv}); + expect(main).toHaveBeenCalledWith(['update', '--config-command', argv]); +}); diff --git a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js index 113bbee91606..f3caadf5e0b3 100644 --- a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -20,6 +20,7 @@ const { resolveCacheSlotVersion, resolveHermesArtifact, resolveLatestV1Version, + resolveLocalHermesCompilerVersion, resolveNightlyVersion, resolveRNCoreArtifact, resolveRNDepsArtifact, @@ -65,6 +66,67 @@ function routerFetch(routes /*: {[string]: any} */) { // artifact at the RN nightly version (which won't exist on Maven). // --------------------------------------------------------------------------- +// Creates a scratch dir with (optionally) a `node_modules/hermes-compiler` +// package inside it, mimicking a real project root for +// resolveLocalHermesCompilerVersion()'s require.resolve({paths: [rnRoot]}). +function makeFakeRnRoot(hermesCompilerVersion /*: ?string */) /*: string */ { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-root-')); + if (hermesCompilerVersion != null) { + const pkgDir = path.join(root, 'node_modules', 'hermes-compiler'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({name: 'hermes-compiler', version: hermesCompilerVersion}), + ); + } + return root; +} + +// Forces resolveLocalHermesCompilerVersion() to behave as if hermes-compiler +// isn't installed, regardless of any workspace-hoisted hermes-compiler the host +// environment provides. Jest's require.resolve ignores the {paths: [rnRoot]} +// scoping and still finds the hoisted package, so stubbing module resolution is +// ineffective here; instead we stub fs.readFileSync to throw MODULE_NOT_FOUND +// for the resolved hermes-compiler/package.json — the exact signal a genuine +// resolution miss produces — which drives the function down its "not installed" +// branch. Reads of any other file fall through to the real implementation. +// Undo with jest.restoreAllMocks(). +function mockHermesCompilerUnresolvable() { + const realReadFileSync = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { + if (String(file).includes(`${path.sep}hermes-compiler${path.sep}`)) { + const error = new Error( + "Cannot find module 'hermes-compiler/package.json'", + ); + error.code = 'MODULE_NOT_FOUND'; + throw error; + } + return realReadFileSync.call(fs, file, ...rest); + }); +} + +describe('resolveLocalHermesCompilerVersion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reads the version from the locally installed hermes-compiler package', () => { + const root = makeFakeRnRoot('0.13.7'); + expect(resolveLocalHermesCompilerVersion(root)).toBe('0.13.7'); + }); + + it('returns null when no hermes-compiler is resolvable from the given root', () => { + // Resolve from a real, isolated scratch root that has no hermes-compiler + // installed (same setup as the fallback test below) so require.resolve + // stays scoped to that root and misses, rather than falling back to + // whatever hermes-compiler the host environment happens to hoist. The + // function must report absence rather than fabricating a version. + const root = makeFakeRnRoot(null); + mockHermesCompilerUnresolvable(); + expect(resolveLocalHermesCompilerVersion(root)).toBeNull(); + }); +}); + describe('resolveHermesArtifact', () => { let origFetch; let origHermesEnv; @@ -82,6 +144,7 @@ describe('resolveHermesArtifact', () => { } else { delete process.env.HERMES_VERSION; } + jest.restoreAllMocks(); }); // Mock fetch with a router: each entry's key is a URL substring; the value @@ -92,43 +155,74 @@ describe('resolveHermesArtifact', () => { } describe('default behavior (no HERMES_VERSION set)', () => { - it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => { + it('uses the locally pinned hermes-compiler version, without hitting npm', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - // Pretend the release URL exists once we ask for 0.13.0. - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, }); const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', null, + rnRoot, + ); + expect(result.version).toBe('0.13.7'); + expect(result.url).toContain('/0.13.7/'); + // Must resolve straight from node_modules — no npm registry round trip. + expect(globalThis.fetch).not.toHaveBeenCalledWith( + expect.stringContaining('registry.npmjs.org'), + expect.anything(), ); - expect(result.version).toBe('0.13.0'); - expect(result.url).toContain('/0.13.0/'); - // The RN nightly hash MUST NOT leak into the hermes URL. - expect(result.url).not.toContain('20260519'); }); it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, }); // Caller passes the original RN --version verbatim; hermes should - // still default to latest-v1 instead of using this. + // still use the locally pinned version instead of using this. const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', '0.87.0-nightly-20260519-58cd1bf58', + rnRoot, ); - expect(result.version).toBe('0.13.0'); + expect(result.version).toBe('0.13.7'); expect(result.url).not.toContain('20260519'); }); + + it('falls back to the latest-v1 npm dist-tag when hermes-compiler is not locally installed', async () => { + const rnRoot = makeFakeRnRoot(null); + // Stub require.resolve to fail so the local lookup is guaranteed to miss, + // even in environments that hoist a workspace hermes-compiler. The + // resolver must then fall through to the latest-v1 dist-tag (0.13.0). + mockHermesCompilerUnresolvable(); + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + rnRoot, + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).toContain('/0.13.0/'); + // Confirm the dist-tag lookup actually ran — the fallback path, not a + // locally pinned version, produced this result. + const hitLatestV1 = globalThis.fetch.mock.calls.some(([url]) => + String(url).includes('hermes-compiler/latest-v1'), + ); + expect(hitLatestV1).toBe(true); + }); }); describe('HERMES_VERSION escape hatches', () => { - it('HERMES_VERSION= uses it verbatim', async () => { + it('HERMES_VERSION= uses it verbatim, even with a local package installed', async () => { process.env.HERMES_VERSION = '0.13.5'; + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, }); @@ -136,13 +230,15 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.13.5'); expect(result.url).toContain('/0.13.5/'); }); - it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => { + it('HERMES_VERSION=latest-v1 resolves via npm dist-tag, even with a local package installed', async () => { process.env.HERMES_VERSION = 'latest-v1'; + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, @@ -151,12 +247,14 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.13.0'); }); it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => { process.env.HERMES_VERSION = 'nightly'; + const rnRoot = makeFakeRnRoot(null); mockFetch({ 'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}}, 'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': { @@ -167,12 +265,14 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.14.0-nightly-abc'); }); it('falls back to the hermes snapshot URL when the release is missing', async () => { process.env.HERMES_VERSION = '0.13.5'; + const rnRoot = makeFakeRnRoot(null); globalThis.fetch = jest.fn(async (url, opts) => { if (opts && opts.method === 'HEAD') { return {status: 404}; @@ -185,7 +285,12 @@ describe('resolveHermesArtifact', () => { '2', }; }); - const result = await resolveHermesArtifact('0.87.0', 'debug', null); + const result = await resolveHermesArtifact( + '0.87.0', + 'debug', + null, + rnRoot, + ); expect(result.url).toContain('maven-snapshots'); expect(result.url).toContain('hermes-ios-debug.tar.gz'); }); diff --git a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js index 49e91fdd6404..1e4848930ca1 100644 --- a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -34,16 +34,29 @@ */ const { + SpmNameCollisionError, + defaultReadConfig, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, } = require('../expand-spm-dependencies'); -const {toSwiftName} = require('../spm-utils'); +const { + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, + toSwiftName, +} = require('../spm-utils'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); function makeReadConfig(configs /*: {[string]: ?Object} */) { return (root /*: string */) => Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; } +// The reserved map its caller builds; these cases only exercise `spm.name`. +const NONE = new Map(); + function makeResolveDep(resolutions /*: {[string]: ?string} */) { return (name /*: string */) => Object.prototype.hasOwnProperty.call(resolutions, name) @@ -326,6 +339,22 @@ describe('expandSpmDependencies', () => { ).toThrow(/ReactNativeWorklets/); }); + it('throws SpmNameCollisionError on a dep-vs-dep collision too', () => { + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-package', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {}, + '/o': {spm: {name: 'ReactNativeWorklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(SpmNameCollisionError); + }); + it('throws on a CASE-INSENSITIVE swiftName collision (worklets vs Worklets)', () => { // Distinct as exact strings, but collide as directories on the default // case-insensitive macOS filesystem. @@ -365,29 +394,566 @@ describe('expandSpmDependencies', () => { }); it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { - expect(() => resolveSwiftName('a', {spm: {name: 'foo bar'}})).toThrow( - /invalid 'spm.name'/, + const resolve = name => () => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('foo bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo/bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo.bar')).toThrow(/invalid 'spm.name'/); + }); + + it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + const resolve = name => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('reanimated')).toBe('reanimated'); + expect(resolve('hermes-engine')).toBe('hermes-engine'); + expect(resolve('RNWorklets')).toBe('RNWorklets'); + expect(resolve('react_native_foo')).toBe('react_native_foo'); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation: a derived name that lands on one React Native reserves. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('prepends the scope when the derived name is reserved', () => { + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + ); + expect(dep.swiftName).toBe('PowersyncReactNative'); + }); + + it('logs one line naming the package, the reserved name and the name it got', () => { + const log = jest.fn(); + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {log}, ); - expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow( - /invalid 'spm.name'/, + expect(log).toHaveBeenCalledTimes(1); + const [line] = log.mock.calls[0]; + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + expect(line).toContain("'PowersyncReactNative'"); + }); + + it('says nothing when no disambiguation happens', () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/common', root: '/c', platforms: {ios: {}}}], + {'/c': {}}, + {log}, ); - expect(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow( - /invalid 'spm.name'/, + expect(dep.swiftName).toBe('Common'); + expect(log).not.toHaveBeenCalled(); + }); + + it('title-cases a hyphenated scope', () => { + const [dep] = expand( + [{name: '@my-org/react-native', root: '/o', platforms: {ios: {}}}], + {'/o': {}}, ); + expect(dep.swiftName).toBe('MyOrgReactNative'); }); - it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { - expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe( - 'reanimated', + it('disambiguates a name that matches a reserved one only in case', () => { + // toSwiftName('@scope/reactcodegen') === 'Reactcodegen' — distinct from + // 'ReactCodegen' as a string, the same directory on a case-insensitive + // filesystem. + const [dep] = expand( + [{name: '@scope/reactcodegen', root: '/s', platforms: {ios: {}}}], + {'/s': {}}, + ); + expect(dep.swiftName).toBe('ScopeReactcodegen'); + }); + + it('disambiguates a transitive dep too', () => { + const result = expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['@scope/react-native']}}, + '/s': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@scope/react-native': '/s'}), + }, + ); + expect(result.map(d => d.swiftName)).toEqual(['Top', 'ScopeReactNative']); + }); + + it('disambiguates against a caller-supplied reserved name (remote identity)', () => { + const [dep] = expand( + [{name: '@acme/my-fork', root: '/f', platforms: {ios: {}}}], + {'/f': {}}, + {extraReservedNames: ['MyFork']}, + ); + expect(dep.swiftName).toBe('AcmeMyFork'); + }); + + it("leaves an explicit 'spm.name' alone on a package that would have collided", () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {spm: {name: 'PowerSync'}}}, + {log}, + ); + expect(dep.swiftName).toBe('PowerSync'); + expect(log).not.toHaveBeenCalled(); + }); + + it('throws when the disambiguated name is reserved as well', () => { + const run = () => + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {extraReservedNames: ['PowersyncReactNative']}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('gives two scoped packages that would take the same reserved name distinct names', () => { + const result = expand( + [ + {name: '@a/react-native', root: '/a', platforms: {ios: {}}}, + {name: '@b/react-native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual([ + 'AReactNative', + 'BReactNative', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation across deps: two libraries deriving one name. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation across deps)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + const scoped = (name, root) => ({name, root, platforms: {ios: {}}}); + + it('pulls two scoped deps apart with their scopes', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); + + it('logs one line per rewritten dep, naming the shared name and the new one', () => { + const log = jest.fn(); + expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + { + '/a': {}, + '/b': {}, + }, + {log}, + ); + expect(log).toHaveBeenCalledTimes(2); + const lines = log.mock.calls.map(([line]) => line); + expect(lines[0]).toContain('@a/foo'); + expect(lines[0]).toContain("'Foo'"); + expect(lines[0]).toContain("'AFoo'"); + expect(lines[1]).toContain('@b/foo'); + expect(lines[1]).toContain("'BFoo'"); + }); + + it('leaves an unscoped member alone — it has no scope to borrow', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('foo', '/f')], { + '/a': {}, + '/f': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'Foo']); + }); + + it("leaves a member's explicit 'spm.name' alone and moves the others around it", () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + {'/a': {spm: {name: 'Foo'}}, '/b': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'BFoo']); + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain('@b/foo'); + }); + + it('groups case-insensitively, so a lowercase override still moves the others', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {spm: {name: 'foo'}}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['foo', 'BFoo']); + }); + + it('rewrites every scoped member of a three-way collision', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('@c/foo', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'CFoo']); + }); + + it('rewrites the scoped members of a three-way collision and keeps the unscoped one', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('foo', '/f')], + {'/a': {}, '/b': {}, '/f': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'Foo']); + }); + + it('throws when a borrowed scope lands on a third package instead of producing two of the same name', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const run = () => + expand( + [ + scoped('@a/foo', '/a'), + scoped('@b/foo', '/b'), + scoped('a-foo', '/af'), + ], + {'/a': {}, '/b': {}, '/af': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/both resolve to 'AFoo'/); + }); + + it('throws when a borrowed scope lands on a name React Native reserves', () => { + // Both derive 'Native'; the borrow takes '@react/native' to 'ReactNative'. + const run = () => + expand([scoped('@react/native', '/r'), scoped('@other/native', '/o')], { + '/r': {}, + '/o': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('still throws for two unscoped deps deriving the same name', () => { + const run = () => + expand( + [scoped('react-native-foo', '/a'), scoped('react_native_foo', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-native-foo' \('ReactNativeFoo'\) and 'react_native_foo' \('ReactNativeFoo'\) both resolve to 'ReactNativeFoo'\./, + ); + expect(run).toThrow(/Set a distinct 'spm\.name'/); + }); + + it('changes nothing, and says nothing, for a set with no collisions', () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/bar', '/b'), scoped('baz', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'Bar', 'Baz']); + expect(log).not.toHaveBeenCalled(); + }); + + it('borrows a second time when an already-borrowed name collides, and the incumbent keeps its name', () => { + // Both land on 'AReactNative': one by borrowing, one by derivation. + const result = expand( + [scoped('@a/react-native', '/a'), scoped('a-react-native', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual([ + 'AAReactNative', + 'AReactNative', + ]); + }); + + it('disambiguates a transitive dep against a direct one', () => { + const result = expandSpmDependencies([scoped('@a/foo', '/a')], { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['@b/foo']}}, + '/b': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@b/foo': '/b'}), + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); +}); + +// --------------------------------------------------------------------------- +// Reserved React Native names — the backstop for what a scope cannot resolve. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (reserved React Native names)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('throws when an unscoped dep auto-derives a reserved product name', () => { + const run = () => + expand([{name: 'react-headers', root: '/rh', platforms: {ios: {}}}], { + '/rh': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-headers' resolves to 'ReactHeaders', which React Native reserves/, ); - expect(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe( - 'hermes-engine', + expect(run).toThrow( + /Set a different 'spm\.name' in react-headers's react-native\.config\.js\./, ); - expect(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe( - 'RNWorklets', + }); + + it('throws when an explicit spm.name override lands on a reserved name', () => { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'ReactAppHeaders'}}, + }), + ).toThrow( + /'some-lib' resolves to 'ReactAppHeaders', which React Native reserves/, ); - expect(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe( - 'react_native_foo', + }); + + it('throws when a transitive dep lands on a reserved name', () => { + expect(() => + expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['react-native-headers']}}, + '/rnh': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'react-native-headers': '/rnh'}), + }, + ), + ).toThrow( + /'react-native-headers' resolves to 'ReactNativeHeaders', which React Native reserves/, + ); + }); + + it('reserves the caller-supplied extraReservedNames (remote package identity)', () => { + const direct = [{name: 'my-fork', root: '/f', platforms: {ios: {}}}]; + expect(() => + expand(direct, {'/f': {}}, {extraReservedNames: ['MyFork']}), + ).toThrow(/'my-fork' resolves to 'MyFork', which React Native reserves/); + }); + + it('accepts that same name when no extraReservedNames are supplied', () => { + const [dep] = expand( + [{name: 'my-fork', root: '/f', platforms: {ios: {}}}], + { + '/f': {}, + }, + ); + expect(dep.swiftName).toBe('MyFork'); + }); + + it('leaves a non-colliding dep untouched', () => { + const [dep] = expand( + [{name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}], + {'/w': {spm: {name: 'worklets'}}}, + {extraReservedNames: ['SomeRemoteIdentity']}, + ); + expect(dep.swiftName).toBe('worklets'); + }); + + it('reports the reserved-name diagnosis in preference to the dep-vs-dep one', () => { + // Both unscoped deps derive 'ReactNative', so neither can borrow a scope. + expect(() => + expand( + [ + {name: 'react-native', root: '/a', platforms: {ios: {}}}, + {name: 'react_native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ), + ).toThrow(/React Native reserves/); + }); + + it('rejects every name in RESERVED_SWIFT_NAMES', () => { + expect(RESERVED_SWIFT_NAMES.length).toBeGreaterThan(0); + for (const reserved of RESERVED_SWIFT_NAMES) { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: reserved}}, + }), + ).toThrow(/React Native reserves/); + } + }); + + it('rejects the autolinking aggregator package name', () => { + expect(() => + expand([{name: 'autolinked', root: '/a', platforms: {ios: {}}}], { + '/a': {}, + }), + ).toThrow( + /'autolinked' resolves to 'Autolinked', which React Native reserves/, + ); + }); + + it('accepts the React headers TARGET dir name — it is not a package or product, so nothing collides', () => { + const [dep] = expand( + [{name: 'some-lib', root: '/s', platforms: {ios: {}}}], + { + '/s': {spm: {name: REACT_HEADERS_TARGET_DIR}}, + }, + ); + expect(dep.swiftName).toBe(REACT_HEADERS_TARGET_DIR); + }); + + it('reports a case-only match against a reserved name, naming both spellings', () => { + const run = () => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'reactnative'}}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'some-lib' resolves to 'reactnative', which differs from React Native's reserved 'ReactNative' only in case/, + ); + expect(run).toThrow(/spm\.name/); + }); +}); + +// --------------------------------------------------------------------------- +// isValidSwiftName — the charset rule `spm.name` enforces. +// --------------------------------------------------------------------------- + +describe('isValidSwiftName', () => { + it.each(['worklets', 'ReactNativeFoo', 'hermes-engine', 'react_native_foo'])( + 'accepts %j', + name => { + expect(isValidSwiftName(name)).toBe(true); + }, + ); + + it.each(['', 'foo bar', 'foo/bar', 'foo.bar', '9lives', 42, null])( + 'rejects %j', + name => { + expect(isValidSwiftName(name)).toBe(false); + }, + ); +}); +// defaultReadConfig +// +// The community CLI's own loaders disagree — sync reads named exports, async +// reads the default one — so a config that sets only `export default` must not +// be invisible here. Fixtures are transpiled by babel, so they present the +// `__esModule`/`default` interop shape; a Node namespace object from +// `require(ESM)` has no `__esModule` but exposes `.default` alongside +// enumerable named keys the same way, which is what the merge reads. +// --------------------------------------------------------------------------- + +describe('defaultReadConfig', () => { + let tmpRoot; + + beforeAll(() => { + tmpRoot = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), 'spm-read-config-'), + ); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + }); + + function writeConfig(name, source) { + const root = path.join(tmpRoot, name); + fs.mkdirSync(root, {recursive: true}); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({name})); + fs.writeFileSync(path.join(root, 'react-native.config.js'), source); + return root; + } + + it('returns null when the library ships no config', () => { + const root = path.join(tmpRoot, 'no-config'); + fs.mkdirSync(root, {recursive: true}); + expect(defaultReadConfig(root)).toBeNull(); + }); + + it('reads a CommonJS config', () => { + const root = writeConfig('cjs', "module.exports = {spm: {name: 'Cjs'}};\n"); + expect(defaultReadConfig(root).spm.name).toBe('Cjs'); + }); + + it('unwraps an ESM config that only has a default export', () => { + const root = writeConfig( + 'esm-default', + "export default {spm: {name: 'EsmDefault'}};\n", + ); + expect(defaultReadConfig(root).spm.name).toBe('EsmDefault'); + }); + + it('reads an ESM config that only has named exports', () => { + const root = writeConfig( + 'esm-named', + "export const spm = {name: 'EsmNamed'};\n", + ); + expect(defaultReadConfig(root).spm.name).toBe('EsmNamed'); + }); + + it('prefers the named export when a config ships both (the PowerSync shape)', () => { + const root = writeConfig( + 'esm-both', + "export const spm = {name: 'Named'};\n" + + "export default {spm: {name: 'Default'}, dependency: {platforms: {ios: {}}}};\n", + ); + const config = defaultReadConfig(root); + expect(config.spm.name).toBe('Named'); + // Only the merge satisfies this: `dependency` exists on the default export + // alone, so reading the module raw would miss it. + expect(config.dependency.platforms.ios).toEqual({}); + }); + + it('keeps sibling keys of the default export (dependency.platforms.ios)', () => { + const root = writeConfig( + 'esm-siblings', + "export default {dependency: {platforms: {ios: {}}}, spm: {name: 'Siblings'}};\n", + ); + const config = defaultReadConfig(root); + expect(config.dependency.platforms.ios).toEqual({}); + expect(config.spm.name).toBe('Siblings'); + }); + + it('passes a function-style config through unchanged (module.exports = () => ({...}))', () => { + const root = writeConfig( + 'fn-style', + "module.exports = () => ({spm: {name: 'FnStyle'}});\n", + ); + const config = defaultReadConfig(root); + expect(typeof config).toBe('function'); + expect(config().spm.name).toBe('FnStyle'); + }); + + it('warns with the config path and the reason when the config fails to load, and returns null', () => { + const root = writeConfig( + 'broken', + "require('a-dev-dependency-that-is-not-installed');\n", ); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(defaultReadConfig(root)).toBeNull(); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).toContain(path.join(root, 'react-native.config.js')); + expect(message).toContain('a-dev-dependency-that-is-not-installed'); + } finally { + warnSpy.mockRestore(); + } }); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js index 6e56cca8171f..5118a5e09854 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js @@ -36,6 +36,20 @@ const os = require('os'); const path = require('path'); let tmpProjects = []; +let originalConfigCommandEnv; + +beforeEach(() => { + originalConfigCommandEnv = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; +}); + +afterEach(() => { + if (originalConfigCommandEnv == null) { + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + } else { + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = originalConfigCommandEnv; + } +}); function makeTmpProject() { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-')); @@ -256,4 +270,95 @@ describe('generateAutolinkingConfig', () => { rawJson: raw, }); }); + + describe('config command override', () => { + it('uses the config command from RCT_SPM_AUTOLINKING_CONFIG_COMMAND', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'my-cli', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['my-cli', 'config']); + }); + + it('prefers an explicit configCommand over the environment variable', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'environment', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + configCommand: ['explicit', 'config'], + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['explicit', 'config']); + }); + + it('throws when the environment variable is not JSON', () => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = 'not json'; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }); + + it.each(['[]', '[1,2]'])( + 'throws when the environment variable is not a non-empty string array: %s', + rawConfigCommand => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = rawConfigCommand; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }, + ); + + it('falls back to the default command when the environment variable is unset', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual([ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', + ]); + }); + }); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index e8e790c9335d..bf3ba5ef9b49 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -10,9 +10,11 @@ 'use strict'; +const {SpmNameCollisionError} = require('../expand-spm-dependencies'); const { AUTOGEN_MARKER, MissingManifestError, + autolinkingDepToSpmTarget, collectSpmSources, expandSpmSourceGlobs, findSelfManagedPackageDir, @@ -1087,7 +1089,7 @@ describe('main() — autolinking plugin host exemption', () => { // Builds a minimal app fixture whose ONLY autolinked iOS dep is `expo`, which // ships NO Package.swift. When `withPlugin` is set, expo declares an // autolinking plugin in its own react-native.config.js (transitive opt-in). - function buildFixture({withPlugin}) { + function buildFixture({withPlugin, depName = 'expo'}) { const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-host-')); created.push(appRoot); // rnRoot only needs to exist (main() existence-checks it, then passes it @@ -1100,7 +1102,7 @@ describe('main() — autolinking plugin host exemption', () => { JSON.stringify({name: 'app'}), ); // The plugin-host dep: native sources present, but NO Package.swift. - const expoDir = path.join(appRoot, 'node_modules', 'expo'); + const expoDir = path.join(appRoot, 'node_modules', ...depName.split('/')); fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); fs.writeFileSync( path.join(expoDir, 'ios', 'Expo.mm'), @@ -1126,7 +1128,9 @@ describe('main() — autolinking plugin host exemption', () => { fs.writeFileSync( path.join(autolinkDir, 'autolinking.json'), JSON.stringify({ - dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + dependencies: { + [depName]: {root: expoDir, platforms: {ios: {}}}, + }, }), ); return {appRoot, rnRoot}; @@ -1159,6 +1163,212 @@ describe('main() — autolinking plugin host exemption', () => { main(['--app-root', appRoot, '--react-native-root', rnRoot]), ).toThrow(MissingManifestError); }); + + // Adds a dep that declares the plugin host in its own `spm.dependencies`. + // `selfManaged` gives it a Package.swift of its own — which is what decides + // whether React Native emits its package references or the dep does. + function addDependentOfExpo(appRoot, name, {selfManaged = false} = {}) { + const depDir = path.join(appRoot, 'node_modules', name); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'Dep.mm'), '// native source\n'); + if (selfManaged) { + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version: 6.0\n', + ); + } + fs.writeFileSync( + path.join(depDir, 'react-native.config.js'), + 'module.exports = {dependency: {platforms: {ios: {}}}, ' + + "spm: {dependencies: ['expo']}};\n", + ); + const jsonPath = path.join( + appRoot, + 'build/generated/autolinking/autolinking.json', + ); + const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + json.dependencies[name] = {root: depDir, platforms: {ios: {}}}; + fs.writeFileSync(jsonPath, JSON.stringify(json)); + } + + it('fails when a dep whose manifest RN generates declares the plugin host in its spm.dependencies', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + addDependentOfExpo(appRoot, 'react-native-y'); + const run = () => + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + // Both packages, so the reader knows which config to edit and why. + expect(run).toThrow(/'react-native-y'/); + expect(run).toThrow(/'expo'/); + expect(run).toThrow(/autolinking plugin/); + expect(run).toThrow(/spm\.dependencies/); + }); + + it('leaves a self-managed dependent alone — its own Package.swift declares its package references, so RN emits none', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + addDependentOfExpo(appRoot, 'react-native-y', {selfManaged: true}); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).not.toThrow(); + }); + + it('leaves the same pair alone when the host ships no plugin — a plain spm.dependency is not a plugin host', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: false}); + addDependentOfExpo(appRoot, 'react-native-y'); + // Both deps ship no manifest, so the missing-manifest error is the expected + // one — the plugin-host diagnosis must not fire for a plain dependency. + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(MissingManifestError); + }); + // `spm scaffold` has no plugin knowledge, so exempting the autolinker alone + // left the two commands disagreeing about the same dep. + it.each([[true], [false]])( + 'rejects a dep deriving a reserved name whether or not it ships a plugin (withPlugin=%s)', + withPlugin => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope. + const {appRoot, rnRoot} = buildFixture({ + withPlugin, + depName: 'react-headers', + }); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(/'react-headers'.*React Native reserves/s); + }, + ); +}); + +// --------------------------------------------------------------------------- +// main() — spm.modules name validation +// +// App-local module names land in the manifest exactly as written, so they need +// the checks an autolinked dep's Swift name gets: a valid identifier, not one +// of React Native's reserved names, and unique across modules and deps. +// --------------------------------------------------------------------------- + +describe('main() — spm.modules names', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // App fixture whose react-native.config.js declares `spm.modules`, plus an + // optional autolinked dep (for the module-vs-dep collision case). + function buildApp({modules, dep}) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-modules-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + for (const mod of modules) { + const modDir = path.join(appRoot, mod.path); + fs.mkdirSync(modDir, {recursive: true}); + fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n'); + } + fs.writeFileSync( + path.join(appRoot, 'react-native.config.js'), + `module.exports = ${JSON.stringify({spm: {modules}})};\n`, + ); + const dependencies = {}; + if (dep != null) { + const depDir = path.join(appRoot, 'node_modules', dep.name); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'ios', 'Dep.mm'), + '// native source\n', + ); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version: 6.0\n', + ); + dependencies[dep.name] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + const run = ({appRoot, rnRoot}) => + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + it('accepts a normal module name', () => { + const app = buildApp({ + modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).not.toThrow(); + }); + + it('rejects a module named after a reserved React Native name', () => { + const app = buildApp({ + modules: [{name: 'ReactNative', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/, + ); + expect(() => run(app)).toThrow(/'spm\.modules'\.$/); + }); + + it('rejects a reserved product name in any casing', () => { + const app = buildApp({ + modules: [{name: 'reactheaders', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/, + ); + }); + + it('rejects a module name that is not a valid Swift identifier', () => { + const app = buildApp({ + modules: [{name: 'My Module', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/); + }); + + it('rejects two modules resolving to the same name', () => { + const app = buildApp({ + modules: [ + {name: 'Shared', path: 'ios/one'}, + {name: 'shared', path: 'ios/two'}, + ], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/, + ); + }); + + it('rejects a module colliding with an autolinked dep', () => { + const app = buildApp({ + modules: [{name: 'ReactNativeFoo', path: 'ios/MyNativeModule'}], + dep: {name: 'react-native-foo'}, + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/, + ); + }); }); // --------------------------------------------------------------------------- @@ -1296,6 +1506,125 @@ describe('main() — flavoredFrameworks sidecar', () => { }); }); +// --------------------------------------------------------------------------- +// main() — plugin scriptPhases sidecar +// +// `.spm-plugin-script-phases.json` records the phases a plugin wants injected +// into the app target (SwiftPM has no `script_phase`). Like the other sidecars +// it is ALWAYS rewritten — `[]` when no plugin declares any — so removing a +// plugin clears stale entries. +// --------------------------------------------------------------------------- + +describe('main() — scriptPhases sidecar', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + const sidecarPath = appRoot => + path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', + ); + + // An app whose only autolinked dep is `expo`; when `pluginReturn` is given, + // expo declares a plugin returning that literal. + function scaffold(pluginReturn) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scriptphase-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + if (pluginReturn != null) { + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); + fs.writeFileSync( + path.join(expoDir, 'react-native.config.js'), + "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", + ); + fs.writeFileSync( + path.join(expoDir, 'spm-plugin.js'), + `module.exports = function () { return ${pluginReturn}; };\n`, + ); + } + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: + pluginReturn != null + ? {expo: {root: expoDir, platforms: {ios: {}}}} + : {}, + }), + ); + return {appRoot, rnRoot}; + } + + it('records plugin-declared script phases, normalized', () => { + const {appRoot, rnRoot} = scaffold(`{ + scriptPhases: [{ + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + }], + }`); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, + { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + position: 'end', + }, + ]); + }); + + it('writes [] when no plugin declares any (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold(null); + fs.writeFileSync( + sidecarPath(appRoot), + JSON.stringify([{id: 'stale', name: 'Stale', script: 'echo'}]), + ); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual( + [], + ); + }); +}); + // --------------------------------------------------------------------------- // main() — .spm-sync-watch-paths emission (mixed dirs + files) // @@ -1418,3 +1747,219 @@ describe('main() — .spm-sync-watch-paths emission', () => { expect([...lines].sort()).toEqual(lines); }); }); + +// --------------------------------------------------------------------------- +// main() — scope disambiguation: the borrowed name reaching a real manifest. +// --------------------------------------------------------------------------- + +describe('main() — scope disambiguation', () => { + let created = []; + let spies = []; + let logSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + spies.push(logSpy); + for (const m of ['warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // Each dep ships a Package.swift, so it reaches the aggregator as self-managed. + function buildFixture(...depNames) { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scope-disambig-')), + ); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const dependencies = {}; + for (const depName of depNames) { + const depDir = path.join(appRoot, 'node_modules', ...depName.split('/')); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version:6.0\n// hand-authored\n', + ); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.h'), '// header\n'); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.mm'), '// src\n'); + dependencies[depName] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + it('emits the disambiguated name as the package ref, the product ref and the header slice', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + expect(pkg).toContain( + '.package(name: "PowersyncReactNative", path: "libs/PowersyncReactNative")', + ); + expect(pkg).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + // Nothing is referenced under the name the derivation would have taken. + expect(pkg).not.toContain('"ReactNative", path: "libs/'); + expect(pkg).not.toContain('package: "ReactNative"'); + + // So `#import ` resolves for consumers. + expect( + fs.existsSync( + path.join(outDir, 'headers/PowersyncReactNative/ios/Lib.h'), + ), + ).toBe(true); + expect(fs.existsSync(path.join(outDir, 'libs/PowersyncReactNative'))).toBe( + true, + ); + expect(fs.existsSync(path.join(outDir, 'headers/ReactNative'))).toBe(false); + }); + + it('tells the developer which name it took and why', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const line = logSpy.mock.calls + .map(call => call.join(' ')) + .find(l => l.includes('PowersyncReactNative')); + expect(line).toBeDefined(); + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + }); + + it('still rejects an unscoped dep deriving a reserved name — it has no scope to borrow', () => { + const {appRoot, rnRoot} = buildFixture('react-headers'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); + + it('emits both names of a dep-vs-dep collision as package refs, product refs and header slices', () => { + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + for (const name of ['AFoo', 'BFoo']) { + expect(pkg).toContain(`.package(name: "${name}", path: "libs/${name}")`); + expect(pkg).toContain(`.product(name: "${name}", package: "${name}")`); + expect( + fs.existsSync(path.join(outDir, `headers/${name}/ios/Lib.h`)), + ).toBe(true); + } + expect(pkg).not.toContain('"Foo", path: "libs/'); + expect(pkg).not.toContain('package: "Foo"'); + expect(fs.existsSync(path.join(outDir, 'headers/Foo'))).toBe(false); + }); + + it('still rejects a collision the scopes cannot resolve', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo', 'a-foo'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); +}); + +// --------------------------------------------------------------------------- +// autolinkingDepToSpmTarget — resolved names only, never re-derived ones. +// --------------------------------------------------------------------------- + +describe('autolinkingDepToSpmTarget', () => { + const dep = (extra = {}) => ({ + name: '@powersync/react-native', + root: '/dep', + platforms: {ios: {sourceDir: '/dep/ios'}}, + ...extra, + }); + + it('carries a resolved sibling name into the emitted sibling refs', () => { + const target = autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map([['@powersync/react-native', 'PowersyncReactNative']]), + ); + const manifest = generateSynthPackageSwift({ + swiftName: target.name, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep: false, + targetPath: '.', + }); + expect(manifest).toContain( + '.package(name: "PowersyncReactNative", path: "../PowersyncReactNative")', + ); + expect(manifest).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + expect(manifest).not.toContain('ReactNative", package: "ReactNative"'); + }); + + it('fails loudly instead of re-deriving a dep with no resolved name', () => { + expect(() => + autolinkingDepToSpmTarget( + '@powersync/react-native', + dep(), + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); + + it('fails loudly instead of re-deriving an unmapped spm.dependency', () => { + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/@powersync\/react-native/); + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js index 0d96db1a5a7f..29ed28e7e843 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js @@ -23,6 +23,59 @@ const path = require('path'); // generateXCFrameworksPackageSwift // --------------------------------------------------------------------------- +// Adding a product must be one edit to REACT_NATIVE_PRODUCTS (plus the static +// codegen template): the manifest's products AND targets both derive from it. +// ReactHeaders is the Clang umbrella target; every other product is served by +// an xcframework of the same name. +describe('generateXCFrameworksPackageSwift derives from the shared name constants', () => { + // jest.doMock registers in the module registry beyond the isolateModules + // scope, so the mocked constants must be dropped before the next test. + afterEach(() => { + jest.dontMock('../spm-utils'); + jest.resetModules(); + }); + + it('emits a product and a binary target for a newly reserved product', () => { + jest.isolateModules(() => { + // Declared by KIND, the way a real edit adds one — spm-utils derives the + // flat list from the kind lists, so the mock mirrors that derivation. + jest.doMock('../spm-utils', () => { + const actual = jest.requireActual('../spm-utils'); + const xcframeworkProducts = Object.freeze([ + ...actual.REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + 'ReactBrandNewHeaders', + ]); + return { + ...actual, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS: xcframeworkProducts, + REACT_NATIVE_PRODUCTS: Object.freeze([ + actual.REACT_NATIVE_UMBRELLA_PRODUCT, + ...xcframeworkProducts, + ]), + }; + }); + const { + generateXCFrameworksPackageSwift: generate, + } = require('../generate-spm-package'); + const out = generate(); + expect(out).toContain( + '.library(name: "ReactBrandNewHeaders", targets: ["ReactBrandNewHeaders"])', + ); + expect(out).toContain('path: "ReactBrandNewHeaders.xcframework"'); + }); + }); + + it('emits one library product per REACT_NATIVE_PRODUCTS entry, in order', () => { + const {REACT_NATIVE_PRODUCTS} = require('../spm-utils'); + const libraries = [ + ...generateXCFrameworksPackageSwift().matchAll( + /\.library\(name: "([^"]+)"/g, + ), + ].map(m => m[1]); + expect(libraries).toEqual([...REACT_NATIVE_PRODUCTS]); + }); +}); + describe('generateXCFrameworksPackageSwift', () => { it('exposes only invariant compile-time products', () => { const result = generateXCFrameworksPackageSwift(); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index 459804f421f5..93a443a5b00f 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -18,6 +18,7 @@ const { flavorForBuildConfiguration, frameworkConditionalSettings, generateXcscheme, + readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); const {execFileSync} = require('child_process'); const fs = require('fs'); @@ -60,6 +61,55 @@ const FRAMEWORK = { ], }; +// The pbxproj product references must follow the shared name constants: a +// product added there has to reach the app target, or the app links against a +// package product Xcode never references. +describe('SPM product references derive from the shared name constants', () => { + // jest.doMock registers in the module registry beyond the isolateModules + // scope, so the mocked constants must be dropped before the next test. + afterEach(() => { + jest.dontMock('../spm-utils'); + jest.resetModules(); + }); + + it('includes a newly reserved React Native product', () => { + jest.isolateModules(() => { + jest.doMock('../spm-utils', () => { + const actual = jest.requireActual('../spm-utils'); + return { + ...actual, + REACT_NATIVE_PRODUCTS: Object.freeze([ + ...actual.REACT_NATIVE_PRODUCTS, + 'ReactBrandNewHeaders', + ]), + }; + }); + const {buildSpmDependencyGraph} = require('../generate-spm-xcodeproj'); + const graph = buildSpmDependencyGraph( + (section, id) => `${section}:${id}`, + ); + expect(graph.products.map(p => p.product)).toContain( + 'ReactBrandNewHeaders', + ); + }); + }); + + it('references every React Native, aggregator and codegen product exactly once', () => { + const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_NATIVE_PRODUCTS, + } = require('../spm-utils'); + const {buildSpmDependencyGraph} = require('../generate-spm-xcodeproj'); + const graph = buildSpmDependencyGraph((section, id) => `${section}:${id}`); + expect(graph.products.map(p => p.product)).toEqual([ + ...REACT_NATIVE_PRODUCTS, + AUTOLINKED_PACKAGE_NAME, + ...REACT_CODEGEN_APP_PRODUCTS, + ]); + }); +}); + describe('scheme pre-action', () => { it('contains the sync script and target-scoped build environment', () => { const result = generateXcscheme( @@ -226,3 +276,168 @@ describe('embed framework phase script', () => { expect(script).not.toContain('SourcePackages'); }); }); + +describe('readScriptPhasesManifest', () => { + let appRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-script-phases-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + const write = contents => { + const dir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, '.spm-plugin-script-phases.json'), + contents, + 'utf8', + ); + }; + + it('is [] when the manifest does not exist (first `spm add`)', () => { + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('is [] for unparseable JSON, warning about it', () => { + write('{not json'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + expect( + logSpy.mock.calls.some(([msg]) => + /could not parse .*\.spm-plugin-script-phases\.json/.test(msg), + ), + ).toBe(true); + }); + + it('is [] for a non-array payload', () => { + write('{"id": "x"}'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('parses valid entries and defaults position to end', () => { + write( + JSON.stringify([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + it('skips malformed entries and duplicate ids, keeping the valid ones', () => { + write( + JSON.stringify([ + {name: 'No id', script: 'echo'}, + {id: 'a', name: 'A', script: 'echo one'}, + {id: 'a', name: 'A again', script: 'echo two'}, + null, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name: 'A', script: 'echo one', position: 'end'}, + ]); + }); + + // The same ids the plugin contract accepts: a scoped npm name is the natural + // stable key for a package-owned phase. + it.each([['@expo/log-box'], ['@expo/ui']])( + 'keeps the scoped npm name %s as an id', + id => { + write(JSON.stringify([{id, name: 'X', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it.each([ + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id with a colon', {id: 'a:b', name: 'X', script: 'echo'}], + ['an id with a space', {id: 'a b', name: 'X', script: 'echo'}], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + [ + 'an unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + // A line break is the one thing no Xcode phase name can carry. This reader + // is the only gate on a stale or hand-edited sidecar. + ['a name with a newline', {id: 'a', name: 'L1\nL2', script: 'echo'}], + [ + 'a name with a carriage return', + {id: 'a', name: 'L1\rL2', script: 'echo'}, + ], + ])('skips an entry with %s', (_label, entry) => { + write(JSON.stringify([entry, {id: 'keep', name: 'Keep', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'keep', name: 'Keep', script: 'echo', position: 'end'}, + ]); + }); + + // The injector normalizes the name for the `/* … */` comments and escapes it + // in the `name` field, so a pbxproj-hostile name needs no coercion here. + it.each([ + ['needs pbxproj quoting', 'Bundle "app.config"'], + ['closes a comment', 'Bad */ = { x'], + ['opens a comment', 'Bad /* x'], + ])('keeps a name that %s', (_label, name) => { + write(JSON.stringify([{id: 'a', name, script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name, script: 'echo', position: 'end'}, + ]); + }); + + it('drops non-string and empty input/output path entries', () => { + write( + JSON.stringify([ + { + id: 'a', + name: 'A', + script: 'echo', + inputPaths: ['$(SRCROOT)/in', '', 7, null, '$(SRCROOT)/in2'], + outputPaths: [{}, '$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', inputPaths: 'not-an-array'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'a', + name: 'A', + script: 'echo', + position: 'end', + inputPaths: ['$(SRCROOT)/in', '$(SRCROOT)/in2'], + outputPaths: ['$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', position: 'end'}, + ]); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index 445b4c6ef31a..f7ca39363552 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -11,9 +11,11 @@ 'use strict'; const { + buildPhaseOrder, injectSpmIntoPbxproj, planInjection, } = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); const fs = require('fs'); const path = require('path'); @@ -29,6 +31,41 @@ const PODS = PLAIN.replace( 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {', ); +// The app target's two XCBuildConfiguration UUIDs in the fixture. +const APP_DEBUG_CONFIG = 'AA0000000000000000000901'; +const APP_RELEASE_CONFIG = 'AA00000000000000000000A2'; + +const DEBUG_CONFIG_HEAD = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + +// Seed the app target's Debug config with a SWIFT_ACTIVE_COMPILATION_CONDITIONS +// the user already had, in the scalar form Xcode and the app template write. +function withDebugCondition(text, value) { + return text.replace( + DEBUG_CONFIG_HEAD, + `${DEBUG_CONFIG_HEAD}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = ${value};`, + ); +} + +// One XCBuildConfiguration's buildSettings dict, by config UUID. Build settings +// hold only scalars and `( … )` arrays, so the first `};` closes the dict. +function buildSettingsOf(text, configUuid) { + const open = text.indexOf( + 'buildSettings = {', + text.indexOf(`${configUuid} /*`), + ); + return text.slice(open, text.indexOf('};', open)); +} +// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS, +// set to any valid pbxproj value: a plain scalar (which injection promotes to an +// array) or an array injection appends to. +function withHeaderSearchPaths(value) { + return PLAIN.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + `HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + ); +} + const RN_PATH = '../node_modules/react-native'; // Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through @@ -69,6 +106,7 @@ function inject( remote = null, hermesCliPath = TEST_HERMES_CLI_PATH, generatedSources = [], + scriptPhases = [], ) { const plan = planInjection(text, {}); expect(plan.ok).toBe(true); @@ -86,9 +124,28 @@ function inject( hermesCliPath, generatedSources, TEST_FRAMEWORKS, + scriptPhases, ); } +// The app target's buildPhases members, in order, by trailing comment. +function buildPhaseComments(text) { + const bp = text.slice(text.indexOf('buildPhases = (')); + const arr = bp.slice(0, bp.indexOf(');')); + return [...arr.matchAll(/\/\* ([^*]+) \*\//g)].map(m => m[1]); +} + +// Move the "Sync SPM Autolinking" membership line below the Sources one — what a +// user dragging the phase down in Xcode produces. RN never re-seats its own sync +// phase, so the move sticks. +function dragSyncBelowSources(text) { + const memberLine = comment => + new RegExp(`\\n[\\t ]*[0-9A-Fa-f]{24} /\\* ${comment} \\*/,`).exec(text)[0]; + const sync = memberLine('Sync SPM Autolinking'); + const sources = memberLine('Sources'); + return text.replace(sync, '').replace(sources, sources + sync); +} + // A normalized generated source under the app root (the Expo case: // build/generated/autolinking/expo/ExpoModulesProvider.swift). `path` is // SRCROOT-relative, so `sourceTree = SOURCE_ROOT`. @@ -99,26 +156,6 @@ const PROVIDER_SOURCE = { fileType: 'sourcecode.swift', }; -// A simple balanced-delimiter check (the injected file must stay well-formed). -function isBalanced(text) { - let depth = 0; - for (let i = 0; i < text.length; i++) { - const c = text[i]; - if (c === '"') { - i++; - while (i < text.length && text[i] !== '"') { - if (text[i] === '\\') i++; - i++; - } - } else if (c === '{' || c === '(') { - depth++; - } else if (c === '}' || c === ')') { - depth--; - } - } - return depth === 0; -} - describe('planInjection', () => { it('accepts a plain SPM-only app and resolves its anchors', () => { const plan = planInjection(PLAIN, {}); @@ -216,6 +253,40 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(text).not.toContain('HERMES_CLI_PATH'); }); + // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the + // Metro URL — is gated by this setting alone. CocoaPods injects it at `pod + // install`; an SPM app has to get it here or a Debug build looks for a + // main.jsbundle it never built. + it('sets SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG on the debug config only', () => { + const {text} = inject(PLAIN); + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toMatch( + /SWIFT_ACTIVE_COMPILATION_CONDITIONS = \(\s*"\$\(inherited\)",\s*DEBUG,\s*\)/, + ); + expect(buildSettingsOf(text, APP_RELEASE_CONFIG)).not.toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', + ); + }); + + it('leaves a config that already sets DEBUG (scalar form) untouched', () => { + const {text} = inject(withDebugCondition(PLAIN, '"$(inherited) DEBUG"')); + // Not promoted to an array, not re-appended — DEBUG is already there. + expect(buildSettingsOf(text, APP_DEBUG_CONFIG)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + expect(text.match(/\bDEBUG\b/g)).toHaveLength(1); + }); + + it("adds DEBUG alongside the user's own compilation conditions", () => { + const {text} = inject( + withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + ); + // MY_DEBUG_UI must not be mistaken for DEBUG by a substring check. + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toContain('"$(inherited) MY_DEBUG_UI"'); + expect(debugSettings).toMatch(/^\s*DEBUG,$/m); + }); + it('prepends the Sync SPM Autolinking build phase', () => { const {text} = inject(PLAIN); expect(text).toContain('Sync SPM Autolinking'); @@ -227,6 +298,50 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(syncIdx).toBeLessThan(sourcesIdx); }); + it.each([ + [ + '"$(inherited)"', + ['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'], + ], + [ + '"$(inherited) $(SRCROOT)/vendor/include"', + [ + '"$(inherited)"', + '"$(inherited) $(SRCROOT)/vendor/include"', + '"$(SRCROOT)/build/generated/autolinking/headers"', + ], + ], + ])( + 'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)', + (scalar, expectedMembers) => { + const {text} = inject(withHeaderSearchPaths(scalar)); + const arrays = [ + ...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g), + ].map(m => + m[1] + .split('\n') + .map(line => line.trim().replace(/,$/, '')) + .filter(member => member.length > 0), + ); + // Both app-target configs (Debug + Release). + expect(arrays).toEqual([expectedMembers, expectedMembers]); + }, + ); + + it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => { + const {text} = inject(withHeaderSearchPaths('("$(inherited)", )')); + expect(isBalanced(text)).toBe(true); + const arrays = [ + ...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g), + ].map(m => m[1]); + // Both app-target configs, each keeping the one-line shape it was written in. + expect(arrays).toEqual( + Array(2).fill( + '"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ', + ), + ); + }); + it('adds one generated embed phase immediately after Frameworks', () => { const {text} = inject(PLAIN); expect(text).not.toContain('Fix SPM Embedded Flavor'); @@ -359,6 +474,616 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { }); }); +// A generated source's `name` (its basename) is plugin-derived, so it reaches +// the file reference's, the build file's and the Sources-membership comments +// under the same rules as a script-phase name: normalized, never raw. A `{` +// there makes findObjectByUuid read the next object's body as this one's, and a +// `,` makes removeArrayMembersByUuid chew the wrong line — corruption with no +// error. [label, filename, expected comment] +const HOSTILE_SOURCE_NAMES = [ + ['an opening brace', 'Weird{Name}.swift', 'Weird Name .swift'], + ['a comma', 'Weird,Name.swift', 'Weird Name.swift'], +]; + +describe.each(HOSTILE_SOURCE_NAMES)( + 'injectSpmIntoPbxproj — a generated source whose filename contains %s', + (_label, fileName, comment) => { + const src = { + path: `build/generated/autolinking/expo/${fileName}`, + name: fileName, + sourceTree: 'SOURCE_ROOT', + fileType: 'sourcecode.swift', + }; + + it('normalizes all three comments and keeps the project balanced', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [src]); + const [fileRefUuid, buildFileUuid] = generatedSourceUuids[src.path]; + expect(definitionComment(text, fileRefUuid)).toBe(comment); + expect(definitionComment(text, buildFileUuid)).toBe( + `${comment} in Sources`, + ); + expect(text).toContain(`fileRef = ${fileRefUuid} /* ${comment} */;`); + const sourcesPhase = text.slice( + text.indexOf('/* Begin PBXSourcesBuildPhase section */'), + ); + expect(sourcesPhase.slice(0, sourcesPhase.indexOf('/* End'))).toContain( + `${buildFileUuid} /* ${comment} in Sources */,`, + ); + expect(isBalanced(text)).toBe(true); + }); + + it('leaves the path and name VALUES verbatim', () => { + const {text} = inject(PLAIN, null, null, [src]); + expect(text).toContain(`path = "${src.path}";`); + expect(text).toContain(`name = "${fileName}";`); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [src]).text; + expect(inject(first, null, null, [src]).text).toBe(first); + }); + }, +); + +describe('injectSpmIntoPbxproj — an ordinary generated-source name', () => { + // Normalization must be invisible for every real-world filename, or every + // already-injected project churns on its next sync. + it('reaches all three comments byte-unchanged', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ + PROVIDER_SOURCE, + ]); + const [fileRefUuid, buildFileUuid] = + generatedSourceUuids[PROVIDER_SOURCE.path]; + expect(definitionComment(text, fileRefUuid)).toBe( + 'ExpoModulesProvider.swift', + ); + expect(definitionComment(text, buildFileUuid)).toBe( + 'ExpoModulesProvider.swift in Sources', + ); + expect(text).toContain( + `fileRef = ${fileRefUuid} /* ExpoModulesProvider.swift */;`, + ); + }); +}); + +// Plugin-declared build phases (the expo-constants case: write app.config into +// the app bundle after the JS bundle phase). +const APP_CONFIG_PHASE = { + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo ok > app.config', + position: 'end', + inputPaths: ['$(SRCROOT)/app.json'], + outputPaths: ['$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config'], +}; + +describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { + it('adds one shell script phase carrying the declared name, script and paths', () => { + const {text, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + [APP_CONFIG_PHASE], + ); + // Two RN-owned phases (sync + embed) plus this one. + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(text).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + expect(text).toContain('name = "Bundle Expo app.config";'); + expect(text).toContain('shellScript = "echo ok > app.config";'); + expect(text).toContain('\t\t\t\t"$(SRCROOT)/app.json",\n'); + expect(text).toContain( + '\t\t\t\t"$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config",\n', + ); + // Recorded so `deinit` reverses it and `update` reconciles it. + const {injectedUuids} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + expect(injectedUuids).toEqual(expect.arrayContaining([uuid])); + expect(isBalanced(text)).toBe(true); + }); + + it('emits an unquoted alwaysOutOfDate = 1 only when the phase asks for it', () => { + const withFlag = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}], + ).text; + expect(withFlag).toContain('alwaysOutOfDate = 1;'); + // Xcode writes it immediately after `isa` — match that to avoid churn. + expect(withFlag).toContain( + 'isa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;', + ); + + for (const phase of [ + APP_CONFIG_PHASE, + {...APP_CONFIG_PHASE, alwaysOutOfDate: false}, + ]) { + expect(inject(PLAIN, null, null, [], [phase]).text).not.toContain( + 'alwaysOutOfDate', + ); + } + }); + + it("places an 'end' phase last in buildPhases", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + const comments = buildPhaseComments(text); + expect(comments[comments.length - 1]).toBe('Bundle Expo app.config'); + // NOTE: the fixture target has only Sources/Frameworks/Resources — it has + // no "Bundle React Native code and images" phase, so the real requirement + // (an 'end' phase runs AFTER the JS bundle phase) is not asserted here. + // End-of-array position is what delivers it on a real app target. + }); + + it("places a 'beforeCompile' phase after the sync phase and before Sources", () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, position: 'beforeCompile'}], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'Bundle Expo app.config', + 'Sources', + ]); + }); + + it('preserves declared order within each position', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + const {text} = inject( + PLAIN, + null, + null, + [], + [ + phase('pre-a', 'beforeCompile'), + phase('post-a', 'end'), + phase('pre-b', 'beforeCompile'), + phase('post-b', 'end'), + ], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'pre-a', + 'pre-b', + 'Sources', + ]); + expect(comments.slice(-2)).toEqual(['post-a', 'post-b']); + }); + + it('is idempotent with script phases — a second run is byte-for-byte identical', () => { + const phases = [ + {...APP_CONFIG_PHASE, alwaysOutOfDate: true}, + {...APP_CONFIG_PHASE, id: 'other', name: 'Other', position: 'end'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + const second = inject(first, null, null, [], phases).text; + expect(second).toBe(first); + }); + + it('escapes a script carrying quotes, a backslash, a newline and a $(VAR)', () => { + const script = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const phases = [{...APP_CONFIG_PHASE, script}]; + const {text} = inject(PLAIN, null, null, [], phases); + expect(text).toContain( + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";', + ); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('quotes a name containing a double quote in the field, dropping it from the comments', () => { + const phases = [{...APP_CONFIG_PHASE, name: 'Bundle "app.config"'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(text).toContain('name = "Bundle \\"app.config\\"";'); + expect(text).toContain(`${uuid} /* Bundle app.config */ = {`); + expect(buildPhaseComments(text)).toContain('Bundle app.config'); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('a rename refreshes the name field AND both /* … */ comments', () => { + const first = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write App Config'}], + ).text; + const renamed = inject( + first, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write Expo Config'}], + ).text; + // Xcode normalizes comments on its next write, so a stale one is a spurious + // diff in the user's repo: nothing but the name may differ. + expect(renamed).not.toContain('Write App Config'); + expect(renamed).toBe( + first.split('Write App Config').join('Write Expo Config'), + ); + }); +}); + +// A declared `position` — and the declared order of two phases sharing one — is +// enforced on every sync, not only at first injection. The membership lines are +// rewritten ONLY when the actual order differs, which is what keeps an unchanged +// sync byte-identical. +describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + + it('moves a phase from end to beforeCompile and back again', () => { + const atEnd = inject(PLAIN, null, null, [], [phase('a', 'end')]).text; + expect(buildPhaseComments(atEnd).slice(-1)).toEqual(['a']); + + const beforeCompile = [phase('a', 'beforeCompile')]; + const moved = inject(atEnd, null, null, [], beforeCompile).text; + expect(buildPhaseComments(moved).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'Sources', + ]); + expect(isBalanced(moved)).toBe(true); + // Re-syncing the now-matching declaration changes nothing. + expect(inject(moved, null, null, [], beforeCompile).text).toBe(moved); + + // And back: a move is a pure reordering of the membership lines. + expect(inject(moved, null, null, [], [phase('a', 'end')]).text).toBe(atEnd); + }); + + it('reorders two phases sharing a position when their declared order swaps', () => { + const inOrder = inject( + PLAIN, + null, + null, + [], + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ).text; + expect(buildPhaseComments(inOrder).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b1', + 'b2', + 'Sources', + ]); + + const swapped = inject( + inOrder, + null, + null, + [], + [phase('b2', 'beforeCompile'), phase('b1', 'beforeCompile')], + ).text; + expect(buildPhaseComments(swapped).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b2', + 'b1', + 'Sources', + ]); + }); + + it('reseats only the phase whose position changed', () => { + const declared = [ + phase('a', 'beforeCompile'), + phase('b', 'beforeCompile'), + phase('c', 'beforeCompile'), + ]; + const first = inject(PLAIN, null, null, [], declared).text; + expect(buildPhaseComments(first).slice(0, 5)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'b', + 'c', + 'Sources', + ]); + + const {text} = inject( + first, + null, + null, + [], + [declared[0], {...declared[1], position: 'end'}, declared[2]], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'c', + 'Sources', + ]); + expect(comments[comments.length - 1]).toBe('b'); + }); + + it('moves a phase the user dragged in Xcode back to its declared position', () => { + const phases = [phase('a', 'end')]; + const {text: first, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + phases, + ); + const memberLine = `\n\t\t\t\t${scriptPhaseUuids.a} /* a */,`; + expect(first).toContain(memberLine); + const dragged = first + .replace(memberLine, '') + .replace('buildPhases = (\n', `buildPhases = (${memberLine}\n`); + expect(buildPhaseComments(dragged)[0]).toBe('a'); + + expect(inject(dragged, null, null, [], phases).text).toBe(first); + }); + + // RN never re-seats its own sync phase, so a user who drags it below Sources + // keeps it there — but `beforeCompile` means before Sources, which is the + // guarantee the plugin contract makes. + it('seats a beforeCompile phase before Sources even when the sync phase sits below it', () => { + const dragged = dragSyncBelowSources(inject(PLAIN).text); + expect(buildPhaseComments(dragged).slice(0, 2)).toEqual([ + 'Sources', + 'Sync SPM Autolinking', + ]); + + const declared = [phase('a', 'beforeCompile')]; + const {text} = inject(dragged, null, null, [], declared); + expect(buildPhaseComments(text)).toEqual([ + 'a', + 'Sources', + 'Sync SPM Autolinking', + 'Frameworks', + 'Embed React Native Flavored Frameworks', + 'Resources', + ]); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], declared).text).toBe(text); + }); + + it('falls back to the sync phase as the anchor when the target has no Sources phase', () => { + const noSources = PLAIN.replace( + /\/\* Begin PBXSourcesBuildPhase section \*\/[\s\S]*?\/\* End PBXSourcesBuildPhase section \*\/\n\n/, + '', + ); + const {text} = inject( + noSources, + null, + null, + [], + [phase('a', 'beforeCompile')], + ); + expect(buildPhaseComments(text).slice(0, 2)).toEqual([ + 'Sync SPM Autolinking', + 'a', + ]); + }); + + it.each([ + ['one end phase', [phase('a', 'end')]], + ['one beforeCompile phase', [phase('a', 'beforeCompile')]], + [ + 'two beforeCompile phases', + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ], + ['two end phases', [phase('e1', 'end'), phase('e2', 'end')]], + [ + 'mixed positions', + [ + phase('b1', 'beforeCompile'), + phase('e1', 'end'), + phase('b2', 'beforeCompile'), + phase('e2', 'end'), + ], + ], + ])('re-syncs %s byte-identically', (_label, phases) => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The membership order drives every re-seating decision, so it must list the +// members and nothing else: a `/* … */` comment is arbitrary plugin-supplied +// text, and a phase NAMED like a UUID would otherwise read as an extra member — +// making the actual order permanently disagree with the declared one. +describe('buildPhaseOrder', () => { + const PHANTOM = 'ABCDEF012345678901234567'; + + it('lists only line-leading UUIDs, never one inside a comment', () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: PHANTOM}], + ); + const plan = planInjection(text, {}); + const order = buildPhaseOrder(text, plan.target); + + expect(text).toContain(`/* ${PHANTOM} */,`); + expect(order).not.toContain(PHANTOM); + expect(order).toHaveLength(buildPhaseComments(text).length); + }); + + it('seats a phase named like a UUID normally, and re-syncs byte-identically', () => { + const phases = [ + {...APP_CONFIG_PHASE, name: PHANTOM, position: 'beforeCompile'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + expect(buildPhaseComments(first).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + PHANTOM, + 'Sources', + ]); + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The trailing comment beside a UUID on its object-definition line. +function definitionComment(text, uuid) { + const m = new RegExp(`\\n\\t*${uuid}(?: /\\* (.*?) \\*/)? = \\{`).exec(text); + return m == null ? null : (m[1] ?? null); +} + +// A pbxproj `/* … */` comment is cosmetic — Xcode regenerates it from the +// object's own `name` field — so a plugin-supplied name is NORMALIZED for the +// comment and kept verbatim (quoted) in the field. Nothing a scanner could read +// as structure may survive into a comment: findObjectByUuid takes the first `{` +// after the UUID as the object's body, and removeArrayMembersByUuid identifies a +// member line by its trailing comma — so a `{` or a `,` in a comment splices +// fields into the wrong object, or makes `deinit` chew the section header. +// [label, name, expected comment] +const HOSTILE_NAMES = [ + ['an opening brace', 'Bundle { app', 'Bundle app'], + ['a closing brace', 'Bundle } app', 'Bundle app'], + ['an opening paren', 'Bundle (app', 'Bundle app'], + ['a closing paren', 'Bundle app)', 'Bundle app'], + ['a comma', 'A , B', 'A B'], + ['a semicolon', 'A; B', 'A B'], + ['an equals sign', 'name = {', 'name'], + ['a comment terminator', 'Bad */ = { x', 'Bad x'], + ['a comment opener', 'Bad /* x', 'Bad x'], + ['a bare asterisk', 'A * B', 'A B'], + ['a bare slash', 'Copy A/B', 'Copy A B'], + ['an unbalanced double quote', 'He said "hi', 'He said hi'], + ['a balanced double-quote pair', 'Bundle "app.config"', 'Bundle app.config'], + ['a tab', 'A\tB', 'A B'], + ['non-ASCII characters', 'Générer la config 📦', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`, `Bundle ${'x'.repeat(300)}`], + // Nothing printable survives normalization — fall back to the phase id, + // itself normalized (see the scoped-id describe below). + ['only structural characters', '*/*', APP_CONFIG_PHASE.id], +]; + +describe.each(HOSTILE_NAMES)( + 'injectSpmIntoPbxproj — a plugin phase name containing %s', + (_label, name, comment) => { + const phases = [{...APP_CONFIG_PHASE, name}]; + + it('normalizes it in both comments and keeps the project balanced', () => { + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(definitionComment(text, uuid)).toBe(comment); + expect(buildPhaseComments(text)).toContain(comment); + expect(isBalanced(text)).toBe(true); + // The phase object is intact — the sanity check that nothing was spliced + // into a neighbouring object through a comment-borne `{`. + expect(text).toContain(`${uuid} /* ${comment} */ = {`); + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); + }, +); + +// A scoped npm package name is the natural stable id for a package-owned phase +// (`@expo/log-box` is a named consumer), so `@` and `/` are in the id charset. +// The id is also the comment's fallback, and it is normalized there exactly like +// a name — the comment must not depend on which characters the charset admits. +describe('injectSpmIntoPbxproj — a scoped-npm-name phase id', () => { + const SCOPED = {...APP_CONFIG_PHASE, id: '@expo/log-box'}; + + it('keys the phase UUID on the scoped id verbatim', () => { + const {scriptPhaseUuids} = inject(PLAIN, null, null, [], [SCOPED]); + expect(Object.keys(scriptPhaseUuids)).toEqual(['@expo/log-box']); + expect(scriptPhaseUuids['@expo/log-box']).toMatch(/^[0-9A-F]{24}$/); + }); + + it('normalizes the id when nothing in the name survives', () => { + const phases = [{...SCOPED, name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['@expo/log-box']; + expect(definitionComment(text, uuid)).toBe('@expo log-box'); + expect(buildPhaseComments(text)).toContain('@expo log-box'); + expect(text).toContain(`${uuid} /* @expo log-box */ = {`); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('writes no comment at all when neither the name nor the id survives', () => { + const phases = [{...APP_CONFIG_PHASE, id: '//', name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['//']; + expect(definitionComment(text, uuid)).toBe(null); + expect(text).toContain(`${uuid} = {`); + expect(text).toMatch(new RegExp(`\\n\\t+${uuid},`)); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); +}); + +describe('injectSpmIntoPbxproj — phase name field vs. comment', () => { + it('keeps the raw name in the escaped `name` field Xcode displays', () => { + const cases = [ + ['Bundle "app.config"', 'name = "Bundle \\"app.config\\"";'], + ['A\tB', 'name = "A\\tB";'], + ['name = {', 'name = "name = {";'], + ['*/*', 'name = "*/*";'], + ]; + for (const [name, expected] of cases) { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name}], + ); + expect(text).toContain(expected); + } + }); + + it("leaves React Native's own two phase comments byte-identical", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + for (const label of [ + 'Sync SPM Autolinking', + 'Embed React Native Flavored Frameworks', + ]) { + expect(text).toMatch( + new RegExp(`\\n\\t\\t[0-9A-F]{24} /\\* ${label} \\*/ = \\{`), + ); + expect(text).toMatch( + new RegExp(`\\n\\t\\t\\t\\t[0-9A-F]{24} /\\* ${label} \\*/,`), + ); + } + }); + + // Xcode's own comment convention for a package reference carries quotes and + // slashes. Normalizing those would rewrite bytes Xcode itself produces, so + // only untrusted labels go through commentSafe. + it("preserves Xcode's comment convention for the package references", () => { + const {text} = inject( + PLAIN, + null, + null, + [PROVIDER_SOURCE], + [APP_CONFIG_PHASE], + ); + expect(text).toContain( + '/* XCLocalSwiftPackageReference "build/generated/autolinking" */', + ); + expect(text).toContain('/* ExpoModulesProvider.swift in Sources */'); + expect(text).toContain('/* ReactHeaders in Frameworks */'); + }); +}); + describe('injectSpmIntoPbxproj — invariants', () => { it('produces a balanced (well-formed) pbxproj', () => { const {text} = inject(PLAIN); diff --git a/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js new file mode 100644 index 000000000000..c7330315b41a --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// A simple balanced-delimiter check (an injected project must stay well-formed). +function isBalanced(text) { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i++; + i++; + } + } else if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + } + } + return depth === 0; +} + +module.exports = {isBalanced}; diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 0282e4bf57fc..f8c4dbdfe2f3 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -14,8 +14,10 @@ const { SPM_INJECTED_MARKER, injectSpmIntoExistingXcodeproj, readArtifactsVersionOverride, + readPinnedConfigCommand, removeSpmInjection, } = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -37,12 +39,41 @@ afterEach(() => { // Build a throwaway app dir: /MyApp.xcodeproj/project.pbxproj seeded with // the plain (SPM-only) fixture, and a node_modules/react-native sibling so the // relative reactNativePath resolves. -function scaffoldApp() { +// Pre-existing values for an injected array setting (HEADER_SEARCH_PATHS), +// seeded into both app-target configs. The plain fixture has none, so it only +// ever exercises the create-from-absent path; deinit must restore each of +// these forms — a plain scalar is ordinary, valid pbxproj. +const PRE_EXISTING_HEADER_SEARCH_PATHS = { + 'a bare $(inherited) scalar': '"$(inherited)"', + 'a scalar with real content': '"$(inherited) $(SRCROOT)/vendor/include"', + 'an array': '(\n\t\t\t\t"$(inherited)",\n\t\t\t)', + // What hand edits and other generators (XcodeGen, Tuist) write. + 'a one-line array': '("$(inherited)", )', +}; + +// Seed a whole `KEY = value;` field (comments and stray whitespace included) +// into both app-target configs. +function withSetting(field /*: string */) { + return PLAIN.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + `${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + ); +} + +function withHeaderSearchPaths(value /*: string */) { + return withSetting(`HEADER_SEARCH_PATHS = ${value};`); +} + +function scaffoldApp(pbxproj /*: string */ = PLAIN) { const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deinit-')); scaffoldedAppRoots.push(appRoot); const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); fs.mkdirSync(xcodeprojPath, {recursive: true}); - fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8'); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxproj, + 'utf8', + ); const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); fs.mkdirSync(rnRoot, {recursive: true}); const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); @@ -87,9 +118,41 @@ function writeManifest(appRoot, relPaths) { ); } +const SCRIPT_PHASES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', +); + +function writeScriptPhases(appRoot, phases) { + const manifestPath = path.join(appRoot, SCRIPT_PHASES_MANIFEST); + fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); + fs.writeFileSync(manifestPath, JSON.stringify(phases, null, 2), 'utf8'); +} + +const APP_CONFIG_PHASE = { + // Contract charset (/^[@A-Za-z0-9_./-]+$/) — the reader skips anything else. + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo v1', + position: 'end', +}; + +function markerTextOf(xcodeprojPath) { + return fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'); +} + function readMarker(xcodeprojPath) { - return JSON.parse( - fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'), + return JSON.parse(markerTextOf(xcodeprojPath)); +} + +function schemePathOf(xcodeprojPath) { + return path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + 'MyApp.xcscheme', ); } @@ -120,6 +183,74 @@ describe('removeSpmInjection — the surgical inverse of add', () => { ); }); + // Both records the second run relies on — `createdArrayFields` and + // `scheme.created` — are carried forward for the reason spelled out on + // mergeCreatedArrayFields. Without that, this second marker forgets them and + // `deinit` leaves an empty `packageReferences` / `packageProductDependencies` + // behind and the generated scheme on disk. Zero script phases here: the defect + // is in the injector's reversal record, not in any one feature. + it('round-trips add → update → deinit byte-for-byte, scheme included', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + const schemePath = schemePathOf(xcodeprojPath); + expect(fs.existsSync(schemePath)).toBe(true); + + sync(); + // The re-sync changed neither the project… + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + // …nor the record of what has to be undone. + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toMatch(/packageReferences/); + expect(after).not.toMatch(/packageProductDependencies/); + expect(after).toBe(before); + expect(fs.existsSync(schemePath)).toBe(false); + }); + + // A Debug config that already carries DEBUG gets no edit at all, so there is + // nothing for the marker to record — and nothing left behind. Injecting into + // the scalar form regardless (addArrayStringValues dedupes by exact array + // member, which the scalar never matches) would promote it to an array the + // marker has no record of, and deinit would strand it. + it('leaves a Debug config that already sets DEBUG alone, add through deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const head = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + head, + `${head}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";`, + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + it('preserves an unrelated edit made to the pbxproj after add', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); @@ -189,6 +320,204 @@ describe('removeSpmInjection — the surgical inverse of add', () => { }); }); +// --------------------------------------------------------------------------- +// Scheme ownership. `scheme.created` records that RN wrote the file, which is +// necessary but not sufficient to delete it on `deinit`: the user may have +// replaced its contents with a scheme of their own (same name, same target). +// Deleting that would destroy their work, so the file is only removed while its +// contents are still the ones RN generates. +// --------------------------------------------------------------------------- +describe('deinit — scheme ownership', () => { + // A scheme the user authored for the same target: same file name, same + // BlueprintIdentifier, no PreActions. + function userAuthoredScheme(targetUuid) { + return ` + + + + + + + + + + + + +`; + } + + function setUp() { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const deinit = () => removeSpmInjection({appRoot, xcodeprojPath}); + const schemePath = schemePathOf(xcodeprojPath); + const readScheme = () => fs.readFileSync(schemePath, 'utf8'); + return {xcodeprojPath, sync, deinit, schemePath, readScheme}; + } + + it('deletes the scheme it created while the file is still its own', () => { + const {sync, deinit, schemePath} = setUp(); + sync(); + sync(); + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); + + it("keeps the user's own scheme when they replaced the generated one", () => { + const {xcodeprojPath, sync, deinit, schemePath, readScheme} = setUp(); + sync(); + const mine = userAuthoredScheme(readMarker(xcodeprojPath).targetUuid); + fs.writeFileSync(schemePath, mine, 'utf8'); + + // The second sync re-adds the pre-action to the file the user now owns, and + // still records that RN originally created it. + sync(); + expect(readScheme()).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scheme.created).toBe(true); + + expect(deinit().status).toBe('removed'); + // Their file survives, with only RN's pre-action stripped back out. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).toBe(mine); + }); + + it('keeps its own scheme once the user has edited it', () => { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'buildConfiguration = "Release"\n revealArchiveInOrganizer', + 'buildConfiguration = "Debug"\n revealArchiveInOrganizer', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + // Leaking a scheme beats deleting an edit: the file stays, minus the + // pre-action. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).not.toContain('Sync SPM Autolinking'); + expect(readScheme()).toContain( + ' { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'scriptText = "set -euo pipefail', + 'scriptText = "echo hi', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createdArrayFields — "this array field did not exist before RN touched it". +// It licenses removing the field at `deinit`, but only once RN's own members are +// gone AND nothing else is left in it: a package the user added to the same +// field is theirs, and dropping the field would orphan it. +// --------------------------------------------------------------------------- +describe('deinit — array fields RN created', () => { + it('keeps a created field the user added their own package to', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + // The user adds their own remote package in Xcode, after injection. + const userRef = 'DEADBEEF0000000000000001'; + const userMember = `${userRef} /* XCRemoteSwiftPackageReference "swift-log" */,`; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxprojOf(xcodeprojPath).replace( + 'packageReferences = (\n', + `packageReferences = (\n\t\t\t\t${userMember}\n`, + ), + 'utf8', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + // Their member — and the field holding it — survive… + expect(after).toContain(userMember); + expect(after).toMatch(/packageReferences = \(/); + // …while RN's own members go, as does the field RN created and emptied. + expect(after).not.toMatch(/relativePath = build\/xcframeworks/); + expect(after).not.toMatch(/packageProductDependencies/); + }); + + it('restores a field that pre-existed but was empty byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + // An empty `packageReferences` the user already had (Xcode leaves one behind + // after removing the last package). RN never records it as created, so the + // field itself must outlive `deinit`. + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + '\t\t\tprojectDirPath = "";', + '\t\t\tpackageReferences = (\n\t\t\t);\n\t\t\tprojectDirPath = "";', + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).not.toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); + describe('generated-sources reconciliation on update', () => { it('removes exactly the UUIDs of an entry dropped from the manifest, keeping the rest', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); @@ -269,6 +598,42 @@ describe('generated-sources reconciliation on update', () => { expect(readMarker(xcodeprojPath).generatedSources).toEqual({}); }); + // A plugin-supplied filename reaches three `/* … */` comments. Whatever it + // contains, `deinit` must find the file reference, the build file and their + // memberships again and leave the file as it was — the comment-normalization + // contract these lean on lives in inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Weird{Name}.swift'], + ['a comma', 'Weird,Name.swift'], + ])( + 'deinits a generated source whose filename contains %s, leaving no residue', + (_label, fileName) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const rel = `build/generated/autolinking/expo/${fileName}`; + writeManifest(appRoot, [rel]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuids = readMarker(xcodeprojPath).generatedSources[rel]; + expect(uuids).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + for (const uuid of uuids) { + expect(after).not.toContain(uuid); + } + expect(after).not.toContain('SPM Generated Sources'); + expect(after).not.toContain(fileName); + expect(after).toBe(before); + }, + ); + it('injects nothing generated-source-related when no manifest exists', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); injectSpmIntoExistingXcodeproj({ @@ -282,10 +647,315 @@ describe('generated-sources reconciliation on update', () => { }); }); +describe('script-phases reconciliation on update', () => { + it('records an added phase in the marker keyed on its plugin id', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const {scriptPhases} = readMarker(xcodeprojPath); + expect(Object.keys(scriptPhases)).toEqual([APP_CONFIG_PHASE.id]); + const uuid = scriptPhases[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(pbxprojOf(xcodeprojPath)).toContain( + `${uuid} /* Bundle Expo app.config */ = {`, + ); + }); + + it('removes the phase object AND its buildPhases membership when the id leaves the manifest', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + + writeScriptPhases(appRoot, []); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('Bundle Expo app.config'); + // The RN-owned phases are untouched. + expect(after).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scriptPhases).toEqual({}); + }); + + it('refreshes a changed script in place, leaving every other byte alone', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + expect(first).toContain('shellScript = "echo v1";'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: 'echo v2'}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(pbxprojOf(xcodeprojPath)).toBe( + first.replace('"echo v1"', '"echo v2"'), + ); + }); + + // The declared position is enforced on every sync, not only at first + // injection — see inject-spm-xcodeproj-test.js for the ordering matrix. Here: + // it survives the sidecar/marker path, and `deinit` still reverses it. + it('re-seats a phase whose declared position changed, and still deinits cleanly', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + sync(); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + const memberLine = `${uuid} /* Bundle Expo app.config */,`; + const atEnd = pbxprojOf(xcodeprojPath); + const sourcesAt = atEnd.indexOf('Sources */,'); + expect(atEnd.indexOf(memberLine)).toBeGreaterThan(sourcesAt); + + writeScriptPhases(appRoot, [ + {...APP_CONFIG_PHASE, position: 'beforeCompile'}, + ]); + sync(); + const moved = pbxprojOf(xcodeprojPath); + expect(moved.indexOf(memberLine)).toBeLessThan( + moved.indexOf('Sources */,'), + ); + // Moved, not duplicated. + expect(moved.split(memberLine)).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('round-trips add → update → deinit byte-for-byte with unchanged phases', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [ + APP_CONFIG_PHASE, + { + ...APP_CONFIG_PHASE, + id: 'other', + name: 'Other', + position: 'beforeCompile', + alwaysOutOfDate: true, + inputPaths: ['$(SRCROOT)/app.json'], + }, + ]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain('Bundle Expo app.config'); + const marker = markerTextOf(xcodeprojPath); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(fs.existsSync(schemePathOf(xcodeprojPath))).toBe(false); + }); + + // A scoped npm name is a valid id, and it is also a JSON ledger key in the + // marker — the only handle `deinit` has on the phase it injected. + it('round-trips a scoped npm-name id through the marker and deinits byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, id: '@expo/log-box'}]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + expect(isBalanced(injected)).toBe(true); + expect(Object.keys(readMarker(xcodeprojPath).scriptPhases)).toEqual([ + '@expo/log-box', + ]); + expect(marker).toContain('"@expo/log-box"'); + const uuid = readMarker(xcodeprojPath).scriptPhases['@expo/log-box']; + expect(injected).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).toBe(before); + }); + + it('removes alwaysOutOfDate when a plugin flips it back off', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain('alwaysOutOfDate = 1;'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: false}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('alwaysOutOfDate'); + expect(after).toContain('Bundle Expo app.config'); + + // …and back on again, stably (the field is re-added to an existing object, + // so it lands ahead of `isa` rather than after it — order is not semantic + // in a pbxproj, and a further re-sync must be a no-op). + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const back = pbxprojOf(xcodeprojPath); + expect(back).toContain('alwaysOutOfDate = 1;'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(back); + }); + + // A shell body full of pbxproj-hostile characters: double quotes, a + // backslash, a real newline and an Xcode `$(VAR)`. + const AWKWARD_SCRIPT = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const AWKWARD_ESCAPED = + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";'; + + it('escapes an awkward script and refreshes it byte-identically on update', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain(AWKWARD_ESCAPED); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + }); + + it('deinit restores the pbxproj byte-for-byte after an awkward script', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain(AWKWARD_ESCAPED); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + // The hostile-name matrix, from the other side: whatever a plugin calls its + // phase, `deinit` must find the object and its membership again and leave the + // file as it was. The comment-normalization contract these lean on (and the + // per-name balance/idempotency assertions) lives in + // inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Bundle { app'], + ['a closing brace', 'Bundle } app'], + ['an opening paren', 'Bundle (app'], + ['a closing paren', 'Bundle app)'], + ['a comma', 'A , B'], + ['a semicolon', 'A; B'], + ['an equals sign', 'name = {'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* x'], + ['a bare asterisk', 'A * B'], + ['a bare slash', 'Copy A/B'], + ['an unbalanced double quote', 'He said "hi'], + ['a balanced double-quote pair', 'Bundle "app.config"'], + ['a tab', 'A\tB'], + ['non-ASCII characters', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`], + ['only structural characters', '*/*'], + ])( + 'deinits a phase whose name contains %s, leaving no residue', + (_label, name) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, name}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + expect(pbxprojOf(xcodeprojPath)).toContain(uuid); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).not.toContain(uuid); + expect(after).toBe(before); + }, + ); +}); + // --------------------------------------------------------------------------- // artifactsVersionOverride — the marker field persisting an explicit -// `spm add/update --version ` pin (see setup-apple-spm.js / -// sync-spm-autolinking.js). SETS on an explicit override; PRESERVES a +// `spm add/update --version ` pin (see setup-apple-spm.js's +// determineVersion). SETS on an explicit override; PRESERVES a // previously-recorded value when the caller omits one; deinit drops it along // with the rest of the marker. // --------------------------------------------------------------------------- @@ -366,9 +1036,9 @@ describe('artifactsVersionOverride marker field', () => { }); // --------------------------------------------------------------------------- -// readArtifactsVersionOverride — pure fs read, used by the build-time sync -// (sync-spm-autolinking.js) to prefer a pinned version over the one derived -// from node_modules/react-native/package.json. +// readArtifactsVersionOverride — pure fs read, used by setup-apple-spm.js's +// determineVersion to prefer a pinned version over the one derived from +// node_modules/react-native/package.json. // --------------------------------------------------------------------------- describe('readArtifactsVersionOverride', () => { it('returns null when no xcodeproj has been injected yet', () => { @@ -393,3 +1063,317 @@ describe('readArtifactsVersionOverride', () => { expect(readArtifactsVersionOverride(appRoot)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// configCommand — the marker field persisting an explicit `spm add/update +// --config-command ''`. Without the pin, the build-time `sync` +// re-derived autolinking.json with the default @react-native-community/cli +// command and failed the "Sync SPM Autolinking" build phase in apps (e.g. Expo +// apps) that replace it. +// --------------------------------------------------------------------------- +describe('configCommand marker field', () => { + const EXPO_COMMAND = [ + 'npx', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios', + ]; + + it('records an explicit config command into the marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('defaults to null when --config-command has never been given', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toBeNull(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('preserves the pin on a later run without --config-command', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('a later explicit --config-command overwrites the previous pin', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual([ + 'my-cli', + 'config', + ]); + expect(readPinnedConfigCommand(appRoot)).toEqual(['my-cli', 'config']); + }); + + it('deinit drops the pin along with the whole marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// readPinnedConfigCommand — pure fs read, used by setup-apple-spm.js (including +// the build-time `sync`) to reuse the config command an earlier `add`/`update` +// pinned. A hand-edited or corrupt marker must degrade to the env/default path +// instead of injecting a bogus argv or throwing mid-build. +// --------------------------------------------------------------------------- +describe('readPinnedConfigCommand', () => { + it('returns null when no xcodeproj has been injected yet', () => { + const {appRoot} = scaffoldApp(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('returns null (never throws) on a malformed marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + '{ not valid json', + 'utf8', + ); + expect(() => readPinnedConfigCommand(appRoot)).not.toThrow(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it.each([ + ['a bare string', '"npx expo-modules-autolinking"'], + ['an empty array', '[]'], + ['a non-string member', '["npx", 7]'], + ['an empty-string member', '["npx", ""]'], + ['an object', '{"command": "npx"}'], + ])('returns null for a pinned value that is %s', (_label, pinned) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + marker.configCommand = JSON.parse(pinned); + fs.writeFileSync(markerPath, JSON.stringify(marker), 'utf8'); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); + +describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))( + 'removeSpmInjection with HEADER_SEARCH_PATHS already set to %s', + (_label, value) => { + it('restores the pre-existing value byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(value), + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('re-syncing is byte-for-byte identical', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(value), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(first); + }); + }, +); + +// findField's token for a BARE scalar ends AT the `;`, so it includes any +// whitespace before it. Deinit must put those bytes back exactly, not a +// tidied-up version of them. +describe.each([ + 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */', + 'HEADER_SEARCH_PATHS = ;', +])('removeSpmInjection with the untrimmed scalar `%s`', field => { + it('restores it byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); + +describe('a scalar array setting injection has nothing to add to', () => { + const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";'; + const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";'; + + // The fixture's flavored-frameworks manifest is empty, so + // FRAMEWORK_SEARCH_PATHS is injected with no values at all. + it('is left untouched, unrecorded, and survives a later user edit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR)); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain(SCALAR); + expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/); + for (const change of readMarker(xcodeprojPath).buildSettingChanges) { + expect(change.promotedArrayScalars ?? {}).not.toHaveProperty( + 'FRAMEWORK_SEARCH_PATHS', + ); + } + + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + injected.replaceAll(SCALAR, EDITED), + 'utf8', + ); + removeSpmInjection({appRoot, xcodeprojPath}); + + const after = pbxprojOf(xcodeprojPath); + expect(after).toContain(EDITED); + expect(after).not.toContain(SCALAR); + }); +}); + +describe('a promoted array setting the user deleted after add', () => { + const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"'; + + it('is not resurrected by deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(SCALAR), + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const deleted = pbxprojOf(xcodeprojPath).replace( + /\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g, + '', + ); + expect(deleted).not.toContain('HEADER_SEARCH_PATHS'); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + deleted, + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + + // Everything else is back to its pre-injection bytes; only the setting the + // user deleted stays gone. + expect(pbxprojOf(xcodeprojPath)).toBe( + before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''), + ); + }); +}); + +// `deinit` removes appendedArrayValues before it restores promotedArrayScalars, +// so recording a key under both happens to come out right today: the scalar +// restore rewrites the whole value last. That makes the exclusivity below +// invisible to a round-trip test, which is why it is asserted on the marker +// directly — reversing those two loops would otherwise silently start removing +// array members from an already-restored scalar. +describe('a promoted scalar is recorded once, not twice', () => { + it('records promotedArrayScalars and not appendedArrayValues for the key', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths('"$(inherited) $(SRCROOT)/vendor/include"'), + ); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const changes = readMarker(xcodeprojPath).buildSettingChanges; + expect(changes.length).toBeGreaterThan(0); + for (const change of changes) { + expect(Object.keys(change.promotedArrayScalars ?? {})).toContain( + 'HEADER_SEARCH_PATHS', + ); + expect(Object.keys(change.appendedArrayValues ?? {})).not.toContain( + 'HEADER_SEARCH_PATHS', + ); + } + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index 7e18c6b1e043..63de6c82feb0 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -18,6 +18,7 @@ const { scaffoldPackageSwiftForDep, translatePodspecToSpmTarget, } = require('../scaffold-package-swift'); +const {RemoteVersionError} = require('../spm-utils'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -140,6 +141,37 @@ describe('translatePodspecToSpmTarget', () => { expect(spec.coreReactNative).toBe(true); }); + it('uses the resolved swiftName (spm.name override) so the manifest matches what the autolinker registers', () => { + const model = podspec({name: 'react-native-worklets'}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-worklets', swiftName: 'worklets'}), + ); + expect(spec.swiftName).toBe('worklets'); + }); + + it('falls back to toSwiftName when the dep carries no resolved name', () => { + const spec = translatePodspecToSpmTarget( + podspec(), + autolinkedDep({name: 'react-native-foo'}), + ); + expect(spec.swiftName).toBe('ReactNativeFoo'); + }); + + it('resolves each sibling through the same overrides, not through toSwiftName', () => { + const model = podspec({dependencies: ['RNWorklets']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated', swiftName: 'reanimated'}), + new Map([['RNWorklets', 'react-native-worklets']]), + new Map([['react-native-worklets', 'worklets']]), + ); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + expect(spec.siblingSwiftNames).toEqual({ + 'react-native-worklets': 'worklets', + }); + }); + it('does not self-wire when a pod dependency maps back to the dep itself', () => { const model = podspec({dependencies: ['RNReanimated']}); const spec = translatePodspecToSpmTarget( @@ -604,6 +636,18 @@ describe('emitScaffoldedPackageSwift', () => { ); }); + it('emits the sibling override name for both the package and the product', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({ + siblingNames: ['react-native-worklets'], + siblingSwiftNames: {'react-native-worklets': 'worklets'}, + }), + ); + expect(out).toContain('.package(name: "worklets", path: "../worklets")'); + expect(out).toContain('.product(name: "worklets", package: "worklets")'); + expect(out).not.toContain('ReactNativeWorklets'); + }); + it('-includes the ObjC prefix header in c/cxx settings when needsObjCPrefix is set', () => { const withPrefix = emitScaffoldedPackageSwift( baseSpec({needsObjCPrefix: true}), @@ -747,6 +791,21 @@ end }; } + it('names the scaffolded package with the spm.name override the autolinker resolved', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep( + makeDep({swiftName: 'foo'}), + makeCtx(), + ); + expect(result.status).toBe('written'); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(content).toContain('name: "foo"'); + expect(content).not.toContain('ReactNativeFoo'); + }); + it('writes Package.swift into the dep root on the happy path', () => { makePodspec(); const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); @@ -1019,6 +1078,130 @@ describe('scaffoldAll', () => { 'skipped-no-podspec', ); }); + + function writeAutolinkingJson(dependencies) { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + } + + it('propagates a Swift name collision instead of scaffolding anyway, plugin or not', () => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope to + // borrow. Degrading to the direct deps would scaffold manifests SPM rejects + // later, and a plugin buys no exemption — `spm scaffold` has no plugin code. + const depRoot = path.join(appRoot, 'node_modules', 'react-headers'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {autolinkingPlugin: './spm-plugin.js'}};\n", + ); + writeAutolinkingJson({ + 'react-headers': {root: depRoot, platforms: {ios: {}}}, + }); + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(/React Native reserves/); + }); + + it('still falls back to the direct deps when a transitive dep cannot be resolved', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-a'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {dependencies: ['ghost-dep-that-is-not-installed']}};\n", + ); + writeAutolinkingJson({ + 'react-native-a': {root: depRoot, platforms: {ios: {}}}, + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.depName)).toEqual(['react-native-a']); + expect(logSpy.mock.calls.map(call => call.join(' ')).join('\n')).toMatch( + /Transitive spm\.dependencies expansion failed/, + ); + } finally { + logSpy.mockRestore(); + } + }); + + it('emits the remote package reference for every dep it scaffolds', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), '// native\n'); + fs.writeFileSync( + path.join(depRoot, 'react-native-foo.podspec'), + 'Pod::Spec.new do |s|\n' + + ' s.name = "react-native-foo"\n' + + ' s.version = "1.0"\n' + + ' s.source_files = "ios/**/*.{h,m,mm}"\n' + + ' s.dependency "React-Core"\n' + + 'end\n', + ); + writeAutolinkingJson({ + 'react-native-foo': {root: depRoot, platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/rn.git'; + process.env.RN_SPM_REMOTE_VERSION = '9.9.9'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.status)).toEqual(['written']); + const manifest = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(manifest).toContain( + '.package(url: "https://example.com/rn.git", exact: "9.9.9")', + ); + expect(manifest).not.toContain('.package(name: "ReactNative"'); + } finally { + logSpy.mockRestore(); + if (prevUrl == null) delete process.env.RN_SPM_REMOTE_URL; + else process.env.RN_SPM_REMOTE_URL = prevUrl; + if (prevVersion == null) delete process.env.RN_SPM_REMOTE_VERSION; + else process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + }); + + it('propagates a RemoteVersionError from the remote package config', () => { + writeAutolinkingJson({ + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/react-native-spm.git'; + delete process.env.RN_SPM_REMOTE_VERSION; + try { + // No react-native under the temp appRoot, so no version resolves — the + // author must see that, not have it degraded into "expansion failed". + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(RemoteVersionError); + } finally { + if (prevUrl == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = prevUrl; + } + if (prevVersion != null) { + process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + } + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index abdaaf823364..0721c1b60b69 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -12,9 +12,14 @@ const { detectStandardRnLayoutRedirect, + determineVersion, ensureBothArtifactFlavors, findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, + parseArgs, resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, shouldAutoDeintegrate, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); @@ -26,12 +31,17 @@ const path = require('path'); // Create an in-place-injected xcodeproj fixture: a directory carrying the // `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). -function mkInjectedXcodeproj(appRoot, name) { +function mkInjectedXcodeproj(appRoot, name, markerFields = {}) { const dir = path.join(appRoot, name); fs.mkdirSync(dir, {recursive: true}); fs.writeFileSync( path.join(dir, SPM_INJECTED_MARKER), - JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}), + JSON.stringify({ + rootUuid: 'X', + target: 'MyApp', + injectedUuids: [], + ...markerFields, + }), ); return dir; } @@ -59,6 +69,268 @@ function gitInitAndCommit(dir) { execFileSync('git', ['commit', '-m', 'init'], opts); } +describe('parseArgs', () => { + it('parses --config-command as a JSON argv array', () => { + const args = parseArgs([ + 'update', + '--config-command', + '["a","b","config"]', + ]); + + expect(args.action).toBe('update'); + expect(args.configCommand).toEqual(['a', 'b', 'config']); + }); + + it('sets configCommand to null when --config-command is omitted', () => { + expect(parseArgs(['update']).configCommand).toBeNull(); + }); + + it('throws for an invalid --config-command value', () => { + expect(() => parseArgs(['update', '--config-command', 'not json'])).toThrow( + /--config-command/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// generateAutolinkingConfigOrFailClosed — the fail-closed policy main() applies +// to the autolinking config step. Swallowing a config-command error (the old +// behavior) let the build proceed with a silently-empty Autolinked package that +// only surfaced later as `unable to resolve module dependency`. A native- +// module-free app does NOT hit the error path: its command exits 0 with valid +// empty-dependency JSON and the generator returns normally. +// --------------------------------------------------------------------------- + +describe('generateAutolinkingConfigOrFailClosed', () => { + let prevExitCode; + let warnSpy; + + beforeEach(() => { + prevExitCode = process.exitCode; + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + process.exitCode = prevExitCode; + jest.restoreAllMocks(); + }); + + it('returns the config result and leaves the exit code untouched on success', () => { + const result = { + config: {}, + outputPath: '/app/ios/autolinking.json', + rawJson: '{}', + }; + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => result, + }); + + expect(out).toBe(result); + expect(process.exitCode).not.toBe(2); + }); + + it('passes projectRoot and configCommand through to the generator', () => { + let received; + generateAutolinkingConfigOrFailClosed({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + generate: opts => { + received = opts; + return {config: {}, outputPath: '', rawJson: ''}; + }, + }); + + expect(received).toEqual({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + }); + }); + + it('fails closed (null, exit 2, actionable error) when the config command errors', () => { + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => { + throw new Error("'my-cli config' exited with status 1"); + }, + }); + + expect(out).toBeNull(); + expect(process.exitCode).toBe(2); + const warnings = warnSpy.mock.calls.map(c => c.join(' ')).join('\n'); + // Names the override so the next person can fix it... + expect(warnings).toMatch(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + // ...and preserves the underlying cause. + expect(warnings).toMatch(/exited with status 1/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveExplicitConfigCommand — the autolinking config command every action +// (add/update/sync/scaffold) runs with: `--config-command` → +// RCT_SPM_AUTOLINKING_CONFIG_COMMAND → the value pinned in `.spm-injected.json` +// → the built-in default. undefined means "let generateAutolinkingConfig pick +// the env var or the default". +// --------------------------------------------------------------------------- + +describe('resolveExplicitConfigCommand', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const PINNED = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + let logSpy; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + function pin(configCommand) { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', {configCommand}); + } + + it('prefers an explicit --config-command over the env var and the pin', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect( + resolveExplicitConfigCommand( + {configCommand: ['flag', 'config']}, + tempDir, + ), + ).toEqual(['flag', 'config']); + }); + + it('lets the env var win over the pin (a stale pin must not shadow it)', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('uses the pin when neither the flag nor the env var is set', () => { + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + // Names the source, so a stale pin is diagnosable from the build log. + expect(logSpy.mock.calls.map(c => c.join(' ')).join('\n')).toMatch( + /\.spm-injected\.json/, + ); + }); + + it('ignores a blank env var and falls through to the pin', () => { + process.env[ENV] = ' '; + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + }); + + it('falls back to the default (undefined) with no flag, env var or pin', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when the pinned value is malformed', () => { + pin('npx expo-modules-autolinking'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when no project is injected yet', () => { + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); +}); + +// --------------------------------------------------------------------------- +// resolveConfigCommandToPin — what `add`/`update` records in the injection +// marker: the explicit `--config-command`, else the env override, since the +// Xcode build phase inherits neither. null pins nothing (and preserves any +// earlier pin). +// --------------------------------------------------------------------------- + +describe('resolveConfigCommandToPin', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const FROM_ENV = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-pin-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + it('pins the env-derived command when only the env var is set', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect(resolveConfigCommandToPin({configCommand: null})).toEqual(FROM_ENV); + }); + + it('pins the explicit --config-command over the env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect( + resolveConfigCommandToPin({configCommand: ['flag', 'config']}), + ).toEqual(['flag', 'config']); + }); + + it('pins nothing when the env var is blank', () => { + process.env[ENV] = ' \t '; + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('pins nothing when neither the flag nor the env var is set', () => { + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('fails loud rather than pinning garbage from an invalid env var', () => { + process.env[ENV] = 'npx expo-modules-autolinking'; + expect(() => resolveConfigCommandToPin({configCommand: null})).toThrow( + /RCT_SPM_AUTOLINKING_CONFIG_COMMAND/, + ); + }); + + it('is resolved back by a later run with neither flag nor env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', { + configCommand: resolveConfigCommandToPin({configCommand: null}), + }); + delete process.env[ENV]; + + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(FROM_ENV); + }); +}); + // --------------------------------------------------------------------------- // resolveAction — zero-arg default. Explicit action wins; otherwise `update` // when an injection marker exists, else `add` (first run). @@ -290,3 +562,91 @@ describe('shouldAutoDeintegrate', () => { expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// determineVersion — which RN version the artifact slots are wired to: +// explicit --version → the `artifactsVersionOverride` pinned in the injection +// marker by a previous `--version` → node_modules/react-native/package.json. +// --------------------------------------------------------------------------- + +describe('determineVersion', () => { + let appRoot; + let reactNativeRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-app-')); + reactNativeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-rn-')); + fs.writeFileSync( + path.join(reactNativeRoot, 'package.json'), + JSON.stringify({name: 'react-native', version: '1000.0.0'}), + ); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(reactNativeRoot, {recursive: true, force: true}); + }); + + const logged = () => logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + + it('prefers an explicit --version over a pinned override', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect( + determineVersion({version: '0.81.0'}, reactNativeRoot, appRoot), + ).toBe('0.81.0'); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it('uses the pinned override when --version is omitted', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '0.80.0', + ); + }); + + it('names the marker in the log when the pin is the source', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + determineVersion({version: null}, reactNativeRoot, appRoot); + + expect(logged()).toMatch(/0\.80\.0/); + expect(logged()).toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json with no pin recorded", () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json when no project is injected", () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); + + it('falls back without throwing when the marker is corrupt', () => { + const xcodeproj = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeproj, {recursive: true}); + fs.writeFileSync(path.join(xcodeproj, SPM_INJECTED_MARKER), '{not json'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js index ee5ee1e1c881..6c109ce67dc5 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -13,6 +13,7 @@ const { addArrayMembers, addArrayStringValues, + commentSafe, ensureScalarField, findApplicationTargets, findField, @@ -41,6 +42,19 @@ const PLAIN_PBXPROJ = fs.readFileSync( 'utf8', ); +// The app target's Debug buildSettings dict, as a body range. +function targetDebugDict(text) { + const cfg = findObjectByUuid(text, 'AA0000000000000000000901'); + const bs = findField(text, cfg, 'buildSettings'); + return {uuid: 'x', bodyOpen: bs.valueStart, bodyClose: bs.tokenEnd - 1}; +} + +// Delimiter balance, checked with the module's own quote-aware scanner: the +// outermost `{` must close on the file's last `}`. +function isBalanced(text) { + return scanToClose(text, text.indexOf('{')) === text.lastIndexOf('}'); +} + // --------------------------------------------------------------------------- // generateUUID // --------------------------------------------------------------------------- @@ -71,11 +85,45 @@ describe('quoteIfNeeded', () => { ['a\\b', '"a\\\\b"'], ['a"b', '"a\\"b"'], ['', '""'], + ['a\nb', '"a\\nb"'], + // A literal CR or tab inside a quoted value is legal, but Xcode rewrites it + // to its escape on the next save — a spurious diff in the user's repo. A + // plugin `script` with CRLF line endings is the way one gets in. + ['a\r\nb', '"a\\r\\nb"'], + ['a\tb', '"a\\tb"'], ])('quoteIfNeeded(%j) => %j', (input, expected) => { expect(quoteIfNeeded(input)).toBe(expected); }); }); +// --------------------------------------------------------------------------- +// commentSafe — a pbxproj `/* … */` comment is cosmetic (Xcode regenerates it +// from the object's fields), so nothing a scanner could read as structure is +// allowed to reach one. +// --------------------------------------------------------------------------- + +describe('commentSafe', () => { + it.each([ + ['Sync SPM Autolinking', 'Sync SPM Autolinking'], + ['Générer la config 📦', 'Générer la config 📦'], + ['Bad */ = { x', 'Bad x'], + ['A , B', 'A B'], + ['He said "hi', 'He said hi'], + ['A\tB', 'A B'], + ['a\r\nb', 'a b'], + ['Copy A/B', 'Copy A B'], + ['*/*', ''], + [' padded ', 'padded'], + ])('commentSafe(%j) => %j', (input, expected) => { + expect(commentSafe(input)).toBe(expected); + }); + + it('is stable under repetition (so a re-sync stays byte-identical)', () => { + const once = commentSafe('Bad */ = { x, y'); + expect(commentSafe(once)).toBe(once); + }); +}); + // --------------------------------------------------------------------------- // Surgical-edit toolkit (in-place injection primitives) // --------------------------------------------------------------------------- @@ -161,12 +209,6 @@ describe('addArrayMembers', () => { }); describe('addArrayStringValues', () => { - function targetDebugDict(text) { - const cfg = findObjectByUuid(text, 'AA0000000000000000000901'); - const bs = findField(text, cfg, 'buildSettings'); - return {uuid: 'x', bodyOpen: bs.valueStart, bodyClose: bs.tokenEnd - 1}; - } - it('creates an array seeded with $(inherited)', () => { const out = addArrayStringValues( PLAIN_PBXPROJ, @@ -195,6 +237,50 @@ describe('addArrayStringValues', () => { expect(out).toContain('"-ObjC"'); }); + // Xcode writes the seed quoted, but the unquoted form is just as valid and + // appears in hand-edited projects. Both are the same value to the build + // system, so neither may be re-emitted alongside the seed. + it.each(['"$(inherited)"', '$(inherited)'])( + 'promotes a bare %s scalar without emitting the seed twice', + priorValue => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + `OTHER_LDFLAGS = ${priorValue}; PRODUCT_NAME = "$(TARGET_NAME)";`, + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + const members = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/ + .exec(out)[1] + .split('\n') + .map(line => line.trim().replace(/,$/, '')) + .filter(member => member.length > 0); + // The scalar's value IS the seed the array is created with. + expect(members).toEqual(['"$(inherited)"', '"-ObjC"']); + }, + ); + + it('promotes an empty scalar without emitting a bare `,` member', () => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = ; PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + const block = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/.exec(out)[1]; + // Asserted on the raw block: a member list filtered for emptiness (as the + // test above does) would hide the malformed element this guards against. + expect(block.split('\n').filter(line => /^\s*,$/.test(line))).toEqual([]); + expect(block).toContain('"-ObjC"'); + }); + it('dedups by EXACT token, not substring (adds "-ObjC" even when "-ObjCFoo" is present)', () => { const withArray = PLAIN_PBXPROJ.replace( 'PRODUCT_NAME = "$(TARGET_NAME)";', @@ -226,6 +312,102 @@ describe('addArrayStringValues', () => { }); }); +// Xcode writes array build settings multi-line, but hand-edited projects and +// other generators (XcodeGen, Tuist) emit compact one-line ones. Members must +// land INSIDE the array whatever its shape, and `deinit` must be able to take +// them back out again — hence the byte-identical add→remove round trip. +describe('array build settings of every written shape', () => { + const NEW = '"/new"'; + + // Seed `OTHER_LDFLAGS = ;` into the app target's Debug config. + function withValue(value) { + return PLAIN_PBXPROJ.replace( + '\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";', + `\t\t\t\tOTHER_LDFLAGS = ${value};\n\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";`, + ); + } + + function add(text, values) { + return addArrayStringValues( + text, + targetDebugDict(text), + 'OTHER_LDFLAGS', + values, + ); + } + + function remove(text, values) { + return removeArrayStringValues( + text, + targetDebugDict(text), + 'OTHER_LDFLAGS', + values, + ); + } + + describe.each([ + ['an empty array', '()'], + ['a lone member with no trailing comma', '("/a")'], + ['a trailing comma and space', '("/a", )'], + ['no space after the comma', '("/a","/b")'], + ['a space after the comma', '("/a", "/b")'], + ['a member whose quotes hold a comma and parens', '("$(FOO(x)),weird")'], + ['the multi-line shape Xcode writes', '(\n\t\t\t\t\t"/a",\n\t\t\t\t)'], + ])('%s', (_label, shape) => { + const input = withValue(shape); + + it('adds the value inside the array, leaving the file balanced', () => { + const out = add(input, [NEW]); + const field = findField(out, targetDebugDict(out), 'OTHER_LDFLAGS'); + expect(field.value.trimStart().startsWith('(')).toBe(true); + expect(field.value).toContain(NEW); + // Nothing was spliced ahead of the field — i.e. outside the array. + expect(out.slice(0, field.matchStart)).toBe( + input.slice(0, field.matchStart), + ); + expect(isBalanced(out)).toBe(true); + }); + + it.each([[[NEW]], [[NEW, '"/new2"']]])( + 'remove undoes add of %j byte-for-byte', + values => { + const added = add(input, values); + expect(added).not.toBe(input); + expect(remove(added, values)).toBe(input); + }, + ); + }); + + it.each([ + ['a value that is not there', '("/a", )', '"/zzz"'], + ['a member stripped of its quotes', '("$(inherited)", )', '$(inherited)'], + ])('removes nothing when asked for %s', (_label, shape, value) => { + const input = withValue(shape); + expect(remove(input, [value])).toBe(input); + }); + + it('is a no-op when a one-line array already holds the value', () => { + const input = withValue(`(${NEW})`); + expect(add(input, [NEW])).toBe(input); + }); + + it('dedupes a member whose quotes hold a comma', () => { + const weird = '"$(FOO(x)),weird"'; + const input = withValue(`(${weird}, )`); + expect(add(input, [weird])).toBe(input); + }); + + it('splices a multi-line array on its own line, before the closing `)`', () => { + const input = withValue('(\n\t\t\t\t\t"/a",\n\t\t\t\t)'); + expect(add(input, [NEW])).toBe( + input.replace( + '\t\t\t\t\t"/a",\n', + `\t\t\t\t\t"/a",\n\t\t\t\t\t${NEW},\n`, + ), + ); + }); +}); + describe('ensureScalarField', () => { it('adds a scalar only when absent', () => { const project = findProjectObject(PLAIN_PBXPROJ); @@ -377,6 +559,15 @@ describe('surgical removal (deinit inverse)', () => { expect(removeField(added, target2, 'SPM_TEST_FLAG')).toBe(PLAIN_PBXPROJ); }); + // What makes it safe for the marker to carry a created-field record forward + // across syncs: a field the user has since deleted by hand costs nothing. + it('removeField is a no-op when the field is absent', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + expect(removeField(PLAIN_PBXPROJ, target, 'SPM_TEST_FLAG')).toBe( + PLAIN_PBXPROJ, + ); + }); + it('removeArrayStringValues removes only the named values', () => { const [target] = findApplicationTargets(PLAIN_PBXPROJ); const seeded = addArrayStringValues(PLAIN_PBXPROJ, target, 'SPM_TEST_ARR', [ diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js index 87e116d32d51..e7d7aaac1c54 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -11,7 +11,18 @@ 'use strict'; const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_HEADERS_TARGET_DIR, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, RemoteVersionError, + RESERVED_SWIFT_NAMES, buildPerAppHeaderTree, defaultCacheDir, displayPath, @@ -46,6 +57,89 @@ describe('toSwiftName', () => { }); }); +// --------------------------------------------------------------------------- +// Reserved Swift names — the one list the manifests and the guard both use +// --------------------------------------------------------------------------- + +describe('reserved Swift names', () => { + it('names the React Native package and the per-app codegen package', () => { + expect(REACT_NATIVE_PACKAGE_NAME).toBe('ReactNative'); + expect(REACT_CODEGEN_PACKAGE_NAME).toBe('React-GeneratedCode'); + }); + + it('pins each product list to its literal names', () => { + expect(REACT_NATIVE_PRODUCTS).toEqual([ + 'ReactHeaders', + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + expect(REACT_CODEGEN_PRODUCTS).toEqual(['ReactAppHeaders']); + expect(REACT_CODEGEN_APP_PRODUCTS).toEqual([ + 'ReactCodegen', + 'ReactAppDependencyProvider', + ]); + }); + + it('tags each React Native product by kind, so no consumer has to infer it from position', () => { + expect(REACT_NATIVE_UMBRELLA_PRODUCT).toBe('ReactHeaders'); + expect(REACT_NATIVE_HEADERS_PRODUCT).toBe('ReactNativeHeaders'); + expect(REACT_NATIVE_XCFRAMEWORK_PRODUCTS).toEqual([ + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + }); + + it('names the autolinking aggregator package (which shares its name with its product)', () => { + expect(AUTOLINKED_PACKAGE_NAME).toBe('Autolinked'); + }); + + it('names the invariant React headers target directory', () => { + expect(REACT_HEADERS_TARGET_DIR).toBe('ReactHeadersTarget'); + }); + + // The one test that pins the literal strings: every other check compares + // constants to constants, so this is what would catch a rename. + it('reserves exactly the names React Native puts in a manifest', () => { + expect([...RESERVED_SWIFT_NAMES].sort()).toEqual([ + 'Autolinked', + 'React-GeneratedCode', + 'ReactAppDependencyProvider', + 'ReactAppHeaders', + 'ReactCodegen', + 'ReactHeaders', + 'ReactNative', + 'ReactNativeDependenciesHeaders', + 'ReactNativeHeaders', + ]); + }); + + it('holds names that real generated manifests actually use', () => { + const { + generateXCFrameworksPackageSwift, + } = require('../generate-spm-package'); + const manifest = generateXCFrameworksPackageSwift(); + for (const name of [REACT_NATIVE_PACKAGE_NAME, ...REACT_NATIVE_PRODUCTS]) { + expect(manifest).toContain(`"${name}"`); + } + }); + + it('does NOT reserve the headers target dir — target names only have to be unique within their own package', () => { + expect(RESERVED_SWIFT_NAMES).not.toContain(REACT_HEADERS_TARGET_DIR); + }); + + it('freezes the lists so no caller can mutate the shared source of truth', () => { + for (const list of [ + REACT_NATIVE_PRODUCTS, + REACT_CODEGEN_PRODUCTS, + REACT_CODEGEN_APP_PRODUCTS, + RESERVED_SWIFT_NAMES, + ]) { + expect(Array.isArray(list)).toBe(true); + expect(Object.isFrozen(list)).toBe(true); + } + }); +}); + // --------------------------------------------------------------------------- // defaultCacheDir // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index aa001e4a1cb1..78eab0be9602 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -69,6 +69,14 @@ * // dir add/remove there re-triggers the sync. Relative/empty/non-string * // entries are dropped with a warning; a non-array is ignored. * watchPaths: ['/abs/path/Package.swift', '/abs/dir'], + * // Build-time shell phases on the app target — SwiftPM has no + * // `script_phase`. Only id/name/script are required; `id` is the + * // ledger key and UUID seed, `position` defaults to 'end'. Recorded + * // to `.spm-plugin-script-phases.json`, from which the `spm add`/ + * // `update` injector emits a PBXShellScriptBuildPhase per entry. + * // Malformed entries and duplicate ids are fatal. + * scriptPhases: [{id, name, script, position, inputPaths, outputPaths, + * alwaysOutOfDate}], * }; * }; * @@ -76,12 +84,14 @@ * the merge, so regeneration stays deterministic and idempotent. */ +const {isValidScriptPhaseId, isValidScriptPhaseName} = require('./spm-utils'); const path = require('path'); /*:: import type { AutolinkedDep, PluginContext, PluginResult, + PluginScriptPhase, DiscoveredPlugin, } from './spm-types'; */ @@ -117,7 +127,7 @@ function discoverPlugins( ); } const pluginPath = path.resolve(dep.root, rel); - let fn: unknown; + let fn /*: unknown */ = null; try { // $FlowFixMe[unsupported-syntax] dynamic require by computed path fn = require(pluginPath); @@ -149,6 +159,12 @@ function discoverPlugins( return found; } +const isNonEmptyString = (value /*: unknown */) /*: boolean */ => + typeof value === 'string' && value.length > 0; + +const isOptionalStringList = (value /*: unknown */) /*: boolean */ => + value == null || (Array.isArray(value) && value.every(isNonEmptyString)); + /** * Invoke discovered plugins and merge their results. Each plugin gets the same * context. Fail-closed: a throwing plugin aborts (named), and a malformed @@ -169,13 +185,15 @@ function invokePlugins( const flavoredFrameworks /*: Array<{id: string, frameworkName: string, linkage: 'dynamic', flavors: {debug: string, release: string}}> */ = []; const watchPaths /*: Array */ = []; + const scriptPhases /*: Array */ = []; const seenPackages /*: Set */ = new Set(); const seenProducts /*: Set */ = new Set(); const seenFrameworkIds /*: Set */ = new Set(); const seenFrameworkNames /*: Set */ = new Set(); + const seenScriptPhaseIds /*: Set */ = new Set(); for (const {depName, pluginPath, plugin} of plugins) { - let result: unknown; + let result /*: unknown */ = null; try { result = plugin(context); } catch (e) { @@ -203,11 +221,18 @@ function invokePlugins( const rawFrameworks = result.flavoredFrameworks ?? []; // $FlowFixMe[incompatible-use] const rawWatch = result.watchPaths ?? []; + // $FlowFixMe[incompatible-use] + const rawScriptPhases = result.scriptPhases ?? []; if (!Array.isArray(rawFrameworks)) { throw new Error( `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`, ); } + if (!Array.isArray(rawScriptPhases)) { + throw new Error( + `react-native spm: '${depName}' returned a non-array scriptPhases.`, + ); + } // Watch paths are best-effort staleness hints, so // a non-array is ignored (warn, never fatal). if (!Array.isArray(rawWatch)) { @@ -314,6 +339,60 @@ function invokePlugins( } watchPaths.push(w); } + // Fatal like flavoredFrameworks, not dropped like watchPaths: a missing + // phase produces a GREEN build whose generated content was never written. + for (const phase of rawScriptPhases) { + if ( + phase == null || + !isValidScriptPhaseId(phase.id) || + !isNonEmptyString(phase.script) || + (phase.position != null && + phase.position !== 'beforeCompile' && + phase.position !== 'end') || + !isOptionalStringList(phase.inputPaths) || + !isOptionalStringList(phase.outputPaths) || + (phase.alwaysOutOfDate != null && + typeof phase.alwaysOutOfDate !== 'boolean') + ) { + const named = typeof phase?.id === 'string' ? ` '${phase.id}'` : ''; + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase${named} ` + + '(need {id (matching /^[@A-Za-z0-9_./-]+$/, and not __proto__, ' + + 'constructor or prototype), name, script} plus optional ' + + '{position: "beforeCompile" | "end", inputPaths, outputPaths, ' + + 'alwaysOutOfDate}).', + ); + } + if (!isValidScriptPhaseName(phase.name)) { + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase name for ` + + `'${phase.id}' (a name must be a non-empty single-line string — it is ` + + "the phase's display name in Xcode).", + ); + } + if (seenScriptPhaseIds.has(phase.id)) { + throw new Error( + `react-native spm: duplicate script phase id '${phase.id}'.`, + ); + } + seenScriptPhaseIds.add(phase.id); + const normalized /*: PluginScriptPhase */ = { + id: phase.id, + name: phase.name, + script: phase.script, + position: phase.position ?? 'end', + }; + if (phase.inputPaths != null) { + normalized.inputPaths = [...phase.inputPaths]; + } + if (phase.outputPaths != null) { + normalized.outputPaths = [...phase.outputPaths]; + } + if (phase.alwaysOutOfDate != null) { + normalized.alwaysOutOfDate = phase.alwaysOutOfDate; + } + scriptPhases.push(normalized); + } } return { @@ -322,6 +401,7 @@ function invokePlugins( generatedSources, flavoredFrameworks, watchPaths, + scriptPhases, }; } diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js index 4b428f354090..c3ebc32d5935 100644 --- a/packages/react-native/scripts/spm/download-spm-artifacts.js +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -393,15 +393,63 @@ async function resolveRNDepsArtifact( return {url: snapshotUrl, version}; } +/** + * Resolves the `hermes-compiler` npm package's version from THIS project's own + * node_modules — the exact same lookup generate-spm-xcodeproj.js's + * resolveHermesCliPathSetting() uses to find the hermesc binary that will + * compile the JS bundle, and the same one react-native-xcode.sh falls back to + * for SwiftPM builds. Returns null when the package isn't resolvable (e.g. + * USE_HERMES=false apps that never installed it) so the caller can fall back + * to the npm dist-tag lookup. + */ +function resolveLocalHermesCompilerVersion( + rnRoot /*: string */, +) /*: string | null */ { + try { + const pkgPath = require.resolve('hermes-compiler/package.json', { + paths: [rnRoot], + }); + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkg /*: {version: string} */ = JSON.parse( + fs.readFileSync(pkgPath, 'utf8'), + ); + assertSafeVersion(pkg.version, 'local hermes-compiler/package.json'); + return pkg.version; + } catch (error) { + // A MODULE_NOT_FOUND resolution failure is the expected case (e.g. + // USE_HERMES=false apps that never installed hermes-compiler) — fall back + // silently. Any other failure means hermes-compiler IS installed but its + // package.json is unreadable/malformed or carries an unsafe version; warn + // loudly rather than silently regressing to the live latest-v1 dist-tag, + // which would re-introduce the version-skew crash this resolves (#57917). + if (error.code !== 'MODULE_NOT_FOUND') { + log( + ` WARNING: hermes-compiler is installed but its version could not be resolved (${error.message}); falling back to the latest-v1 dist-tag, which may not match the pinned hermesc and can crash at launch with "Wrong bytecode version".`, + ); + } + return null; + } +} + /** * Returns {url, version} for Hermes. Hermes uses its own version space * decoupled from React Native's nightly cadence — RN's `hermes-compiler` * npm package publishes a `latest-v1` dist-tag that always resolves to a - * binary that's been built and uploaded to Maven. Our default mirrors RN's - * CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js): + * binary that's been built and uploaded to Maven. * - * HERMES_VERSION unset → 'latest-v1' dist-tag - * HERMES_VERSION=latest-v1 → same (explicit) + * HERMES_VERSION unset → version pinned by the locally installed + * hermes-compiler package (node_modules). + * This is the SAME source + * resolveHermesCliPathSetting() reads for + * HERMES_CLI_PATH, so the downloaded VM and + * the hermesc that compiles the JS bundle + * always agree — a mismatched pair crashes at + * launch with "Wrong bytecode version" (#57917). + * Falls back to the 'latest-v1' npm dist-tag + * (RN's CocoaPods prebuild default; see + * scripts/ios-prebuild/hermes.js) only when + * hermes-compiler isn't locally resolvable. + * HERMES_VERSION=latest-v1 → 'latest-v1' dist-tag (explicit) * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag * HERMES_VERSION= → use that version verbatim * @@ -413,8 +461,17 @@ async function resolveHermesArtifact( rnVersion /*: string */, flavor /*: string */, rawVersion /*: string | null */, + rnRoot /*: string */, ) /*: Promise */ { - let version = process.env.HERMES_VERSION ?? 'latest-v1'; + let version = process.env.HERMES_VERSION; + + if (version == null) { + const localVersion = resolveLocalHermesCompilerVersion(rnRoot); + if (localVersion != null) { + log(` Using locally pinned hermes-compiler: ${localVersion}`); + } + version = localVersion ?? 'latest-v1'; + } if (version === 'nightly') { version = await resolveNightlyVersion('hermes-compiler'); @@ -943,7 +1000,7 @@ async function processArtifact( return localPath; }; - let tarPath: string; + let tarPath /*: string */ = ''; let fromShared = false; if (isLocalTarball) { tarPath = url; @@ -970,7 +1027,7 @@ async function processArtifact( onProgress(xcframeworkName, 0, 0, 0, false, 0); } const tmpExtractDir = path.join(outputDir, '.extract-tmp', label); - let xcfwPath: string; + let xcfwPath /*: string */ = ''; try { xcfwPath = extractXCFramework(tarPath, tmpExtractDir); } catch (e) { @@ -1119,7 +1176,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { label: 'hermes', name: 'hermes-engine', resolve: () => - resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion), + resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot), sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`, }, ]; @@ -1349,7 +1406,7 @@ function validateArtifactsCache( if (!fs.existsSync(artifactsJsonPath)) { return `artifacts.json missing in ${artifactsDir}`; } - let json: {[string]: {xcframeworkPath: string, url: string}}; + let json /*: {[string]: {xcframeworkPath: string, url: string}} */ = {}; try { // $FlowFixMe[unclear-type] JSON.parse returns any const parsed /*: any */ = JSON.parse( @@ -1390,6 +1447,7 @@ module.exports = { main, resolveCacheSlotVersion, resolveHermesArtifact, + resolveLocalHermesCompilerVersion, REQUIRED_ARTIFACTS, validateArtifactsCache, // Exposed for unit tests (pure / fetch-stubbable helpers). diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js index 0b23b983f0f0..4aea9de5ee59 100644 --- a/packages/react-native/scripts/spm/expand-spm-dependencies.js +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -10,10 +10,12 @@ 'use strict'; -const {toSwiftName} = require('./spm-utils'); +const {RESERVED_SWIFT_NAMES, makeLogger, toSwiftName} = require('./spm-utils'); const fs = require('fs'); const path = require('path'); +const {warn} = makeLogger('expand-spm-dependencies'); + /** * expand-spm-dependencies.js — Resolves transitive native deps declared via * `spm.dependencies` in a library's react-native.config.js. @@ -32,7 +34,7 @@ const path = require('path'); * list with autolinking-shaped entries so the downstream pipeline can convert * each to an SPM target without further branching. * - * I/O is injected (readConfig, resolveDep) so the logic stays pure and + * I/O is injected (readConfig, resolveDep, log) so the logic stays pure and * testable. */ @@ -44,52 +46,232 @@ import type {AutolinkedDep} from './spm-types'; type RnConfig = {...}; type ReadConfig = (root: string) => ?RnConfig; type ResolveDep = (name: string, fromRoot: string) => ?string; +type Log = (message: string) => void; +// Keyed by lower case, valued with the canonical spelling: two names differing +// only in case are not distinct enough for the build to keep the two apart. +type ReservedNames = ReadonlyMap; type Options = { readConfig: ReadConfig, resolveDep: ResolveDep, + // Names to reserve alongside RESERVED_SWIFT_NAMES, supplied by the caller + // (remote mode relabels the RN package) since this module reads no config. + extraReservedNames?: ?ReadonlyArray, + log?: ?Log, }; */ -// Validates and returns the Swift target name for a dep. Falls back to -// toSwiftName(npmName) when no override is set. The override is the dep's -// `react-native.config.js` `spm.name`, intended for libraries whose import -// prefix differs from the auto-derived name (e.g. `react-native-worklets` -// publishes headers under `` via the podspec `s.header_dir`, -// so the SPM target name should be `worklets`, not `ReactNativeWorklets`). +/** + * A misconfiguration rather than a resolution failure: scaffoldAll degrades past + * a transitive dep it cannot find, but must still surface this. + */ +class SpmNameCollisionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'SpmNameCollisionError'; + } +} + +// The charset `spm.name` must satisfy — permissive on purpose, since it has to +// admit header-dir style (lowercase with hyphens) as well as Swift identifiers. +// Shared with the app's own `spm.modules` names. +function isValidSwiftName(name /*: unknown */) /*: boolean */ { + return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name); +} + +function reservedSwiftNames( + extraReservedNames /*: ?ReadonlyArray */, +) /*: ReservedNames */ { + return new Map( + [...RESERVED_SWIFT_NAMES, ...(extraReservedNames ?? [])].map(name => [ + name.toLowerCase(), + name, + ]), + ); +} + +// The scope-borrowed form of a name: `@powersync/react-native`'s `ReactNative` +// becomes `PowersyncReactNative`. +function scopeBorrowedName( + npmName /*: string */, + swiftName /*: string */, +) /*: ?string */ { + const scope = /^@([^/]+)\//.exec(npmName)?.[1]; + return scope == null ? null : `${toSwiftName(scope)}${swiftName}`; +} + +// The Swift target name for one dep, judged in isolation. `spm.name` is for +// libraries whose import prefix differs from the derived name: +// `react-native-worklets` ships headers as `` (podspec +// `s.header_dir`), so its target is `worklets`, not `ReactNativeWorklets`. A +// derived name that lands on a reserved one borrows the npm scope instead. function resolveSwiftName( npmName /*: string */, config /*: ?RnConfig */, + reserved /*: ReservedNames */, + log /*:: ?: ?Log */, ) /*: string */ { // $FlowFixMe[prop-missing] config has dynamic shape const override = config?.spm?.name; - if (override == null) { - return toSwiftName(npmName); + if (override != null) { + if (typeof override !== 'string' || override.length === 0) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, + ); + } + if (!isValidSwiftName(override)) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + return override; } - if (typeof override !== 'string' || override.length === 0) { - throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, - ); + + const derived = toSwiftName(npmName); + if (!reserved.has(derived.toLowerCase())) { + return derived; } - // Accept Swift-identifier style (TitleCase / snake_case) and header-dir - // style (lowercase, optional hyphens). Reject whitespace, slashes, and - // other characters that would break SPM target / module identifiers. - if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(override)) { - throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, - ); + const disambiguated = scopeBorrowedName(npmName, derived); + if (disambiguated == null || reserved.has(disambiguated.toLowerCase())) { + return derived; + } + log?.( + `'${npmName}' would take React Native's reserved name '${derived}', so its npm scope is prepended: '${disambiguated}'. ` + + `Set 'spm.name' in ${npmName}'s react-native.config.js to choose the name yourself.`, + ); + return disambiguated; +} + +function assertNameNotReserved( + swiftName /*: string */, + reserved /*: ReservedNames */, + labels /*: {label: string, remedy: string} */, +) /*: void */ { + const reservedName = reserved.get(swiftName.toLowerCase()); + if (reservedName == null) { + return; + } + // Vaguer about the case clash than the dep-vs-dep message on purpose: this + // set spans package identities and product names, which collide differently. + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: ${labels.label} resolves to '${swiftName}', ` + + (reservedName === swiftName + ? `which React Native reserves for its own SPM package and products.` + : `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`) + + ` ${labels.remedy}`, + ); +} + +/** + * Throws when `swiftName` is one React Native's own manifests use. `remedy` is + * the fix: a library sets `spm.name`, an app renames its `spm.modules` entry. + */ +function assertSwiftNameNotReserved( + swiftName /*: string */, + options /*: { + label: string, + remedy: string, + extraReservedNames?: ?ReadonlyArray, + } */, +) /*: void */ { + const {label, remedy, extraReservedNames} = options; + assertNameNotReserved(swiftName, reservedSwiftNames(extraReservedNames), { + label, + remedy, + }); +} + +// Reserved-name backstop over the resolved set. Unconditional: a plugin-shipping +// library is checked like any other, so `spm scaffold` — which knows nothing +// about plugins — cannot disagree with the autolinker about the same dep. +function assertNoReservedSwiftNames( + deps /*: ReadonlyArray */, + reserved /*: ReservedNames */, +) /*: void */ { + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + assertNameNotReserved(swiftName, reserved, { + label: `'${dep.name}'`, + remedy: `Set a different 'spm.name' in ${dep.name}'s react-native.config.js.`, + }); + } +} + +// Pulls apart deps that resolved to the same name by borrowing their npm scopes. +// Every scoped member of a colliding group moves: there is no non-arbitrary +// winner to keep. Exactly one pass — retrying would trade a diagnosable error +// for a name nobody can predict. +function disambiguateSharedSwiftNames( + deps /*: ReadonlyArray */, + autoNamed /*: ReadonlySet */, + log /*: ?Log */, +) /*: void */ { + const groups /*: Map> */ = + new Map(); + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + const key = swiftName.toLowerCase(); + const group = groups.get(key); + if (group == null) { + groups.set(key, [{dep, swiftName}]); + } else { + group.push({dep, swiftName}); + } + } + + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } + for (const {dep, swiftName} of group) { + // A name we derived can borrow a second time (`AAReactNative`); the + // member whose name we did not derive is the incumbent and keeps it. + if (!autoNamed.has(dep.name)) { + continue; + } + const borrowed = scopeBorrowedName(dep.name, swiftName); + if (borrowed == null) { + continue; + } + const others = group + .filter(other => other.dep !== dep) + .map(other => `'${other.dep.name}'`) + .join(', '); + log?.( + `'${dep.name}' would share the name '${swiftName}' with ${others}, so its npm scope is prepended: '${borrowed}'. ` + + `Set 'spm.name' in ${dep.name}'s react-native.config.js to choose the name yourself.`, + ); + dep.swiftName = borrowed; + } } - return override; } function expandSpmDependencies( directDeps /*: Array */, options /*: Options */, ) /*: Array */ { - const {readConfig, resolveDep} = options; + const {readConfig, resolveDep, extraReservedNames, log} = options; + const reserved = reservedSwiftNames(extraReservedNames); const byName /*: Map */ = new Map(); for (const dep of directDeps) { byName.set(dep.name, {...dep, spmDependencies: []}); } + const autoNamed /*: Set */ = new Set(); + const resolveName = ( + npmName /*: string */, + config /*: ?RnConfig */, + ) /*: string */ => { + // $FlowFixMe[prop-missing] config has dynamic shape + if (config?.spm?.name == null) { + autoNamed.add(npmName); + } + return resolveSwiftName(npmName, config, reserved, log); + }; const queue /*: Array */ = directDeps.map(d => d.name); while (queue.length > 0) { @@ -105,7 +287,7 @@ function expandSpmDependencies( // Resolve swiftName lazily from the same config read we already need for // spm.dependencies — saves a duplicate readConfig call per direct dep. if (current.swiftName == null) { - current.swiftName = resolveSwiftName(currentName, config); + current.swiftName = resolveName(currentName, config); } // $FlowFixMe[prop-missing] config has dynamic shape const transitives /*: Array */ = config?.spm?.dependencies ?? []; @@ -134,7 +316,7 @@ function expandSpmDependencies( name: transitiveName, root: transitiveRoot, platforms: {ios: iosPlatform}, - swiftName: resolveSwiftName(transitiveName, transitiveConfig), + swiftName: resolveName(transitiveName, transitiveConfig), spmDependencies: [], }); queue.push(transitiveName); @@ -144,6 +326,14 @@ function expandSpmDependencies( current.spmDependencies = currentSpmDeps; } + const allDeps /*: Array */ = Array.from(byName.values()); + + disambiguateSharedSwiftNames(allDeps, autoNamed, log); + + // Both checks below validate the FINAL set, after that pass: a borrowed scope + // can land on a reserved name, or on one another dep already holds. + assertNoReservedSwiftNames(allDeps, reserved); + // Collision check: two deps mapping to the same Swift name (whether via // override or auto-derivation) would clobber each other in the synth // package layout and the centralized headers tree. Surface it now with a @@ -154,7 +344,7 @@ function expandSpmDependencies( // passes but the two still collide as directories on the default // case-insensitive macOS filesystem (synth package layout + headers tree). const seen /*: Map */ = new Map(); - for (const dep of byName.values()) { + for (const dep of allDeps) { const swiftName = dep.swiftName; if (swiftName == null) { continue; @@ -163,7 +353,7 @@ function expandSpmDependencies( const existing = seen.get(key); if (existing != null) { const same = existing.swiftName === swiftName; - throw new Error( + throw new SpmNameCollisionError( `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` + (same ? `both resolve to '${swiftName}'.` @@ -174,7 +364,7 @@ function expandSpmDependencies( seen.set(key, {name: dep.name, swiftName}); } - return Array.from(byName.values()); + return allDeps; } // --------------------------------------------------------------------------- @@ -188,8 +378,34 @@ function defaultReadConfig(root /*: string */) /*: ?RnConfig */ { } try { // $FlowFixMe[unsupported-syntax] - return require(configPath); - } catch { + const mod = require(configPath); + // Read both export styles, because the community CLI's two loaders + // disagree with each other: its sync path (`loadConfig`) requires the + // module and sees named exports at top level, its async path + // (`loadConfigAsync`) takes the default export only. Merging covers both, + // with named exports winning — the shape the sync path already resolves. + // Every sibling key of the default export is preserved + // (`dependency.platforms.ios` is read from this result too). + // A function-style config (`module.exports = () => ({...})`) and other + // non-objects pass through untouched — there is no default export to + // unwrap, and nulling them would hide a config that used to be read. + if (mod == null || typeof mod !== 'object') { + return mod; + } + const dflt = mod.default; + if (dflt == null || typeof dflt !== 'object') { + return mod; + } + const {default: _unused, ...named} = mod; + return {...dflt, ...named}; + } catch (e) { + // A config can fail to load for reasons unrelated to SPM (it may import a + // devDependency absent in a consumer install), so this stays a warning — + // but a silent null turns a dropped `spm` block into a link error much + // later. + warn( + `Failed to load ${configPath}: ${e.message}. Any 'spm' settings in it are ignored.`, + ); return null; } } @@ -209,7 +425,10 @@ function defaultResolveDep( } module.exports = { + SpmNameCollisionError, + assertSwiftNameNotReserved, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, defaultReadConfig, defaultResolveDep, diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js index 58ebb96b2f92..22053ba5c8ef 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -16,6 +16,8 @@ * * Invokes the React Native community CLI to produce its config and writes the * raw JSON to /build/generated/autolinking/autolinking.json. + * The config command can be overridden by `--config-command` or + * `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`, in that order, before the default. * * No filtering or reshaping happens here — the downstream consumer * (generate-spm-autolinking.js) does its own iOS-only filtering when reading @@ -60,6 +62,32 @@ const FALLBACK_CONFIG_COMMAND = [ 'config', ]; +function parseConfigCommandJson( + raw /*: string */, + source /*: string */, +) /*: Array */ { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + `${source}: config command must be a JSON array of strings. Example: '["npx","@react-native-community/cli","config"]'`, + ); + } + + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + !parsed.every(value => typeof value === 'string' && value.length > 0) + ) { + throw new Error( + `${source}: config command must be a non-empty JSON array of non-empty strings`, + ); + } + + return parsed; +} + function resolveDefaultConfigCommand( projectRoot /*: string */, ) /*: Array */ { @@ -94,6 +122,31 @@ function resolveDefaultConfigCommand( return FALLBACK_CONFIG_COMMAND; } +const ENV_CONFIG_COMMAND = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + +// The raw env override, or null when unset or blank. Exported so callers that +// only need to know whether the override is in play (setup-apple-spm.js) share +// this blankness rule instead of re-deriving it. +function readEnvConfigCommand() /*: ?string */ { + const raw = process.env[ENV_CONFIG_COMMAND]; + return typeof raw === 'string' && raw.trim().length > 0 ? raw : null; +} + +// The env override, parsed and validated, or null when unset or blank. Throws +// on a set-but-invalid value — never silently degrades to the default. +function resolveEnvConfigCommand() /*: ?Array */ { + const raw = readEnvConfigCommand(); + return raw == null ? null : parseConfigCommandJson(raw, ENV_CONFIG_COMMAND); +} + +// Env-var / default resolution for the autolinking config command. An explicit +// `configCommand` (e.g. from `--config-command`) is handled upstream by +// generateAutolinkingConfig's destructuring default, so it never reaches here — +// this only decides between the env-var override and the built-in default. +function resolveConfigCommand(projectRoot /*: string */) /*: Array */ { + return resolveEnvConfigCommand() ?? resolveDefaultConfigCommand(projectRoot); +} + function defaultCliRunner( command /*: Array */, opts /*: {cwd: string} */, @@ -116,7 +169,7 @@ function generateAutolinkingConfig( ) /*: GenerateAutolinkingConfigResult */ { const { projectRoot, - configCommand = resolveDefaultConfigCommand(projectRoot), + configCommand = resolveConfigCommand(projectRoot), cliRunner = defaultCliRunner, } = opts; @@ -158,4 +211,11 @@ function generateAutolinkingConfig( return {config, outputPath: outPath, rawJson}; } -module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand}; +module.exports = { + generateAutolinkingConfig, + parseConfigCommandJson, + readEnvConfigCommand, + resolveConfigCommand, + resolveDefaultConfigCommand, + resolveEnvConfigCommand, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index ce47509cb3c8..1f1b1b87d213 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -19,6 +19,7 @@ PluginFlavoredFramework, PluginPackageDep, PluginProductDep, + PluginScriptPhase, ReactDescriptor, RawAutolinkingJson, SpmModuleConfig, @@ -58,17 +59,24 @@ const {discoverPlugins, invokePlugins} = require('./autolinking-plugins'); const { + SpmNameCollisionError, + assertSwiftNameNotReserved, defaultReadConfig, defaultResolveDep, expandSpmDependencies, + isValidSwiftName, } = require('./expand-spm-dependencies'); const {readPodspec} = require('./read-podspec'); const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, RemoteVersionError, findProjectRoot, makeLogger, remotePackageConfig, - toSwiftName, } = require('./spm-utils'); const fs = require('fs'); const path = require('path'); @@ -89,7 +97,12 @@ const {log, warn} = makeLogger('generate-spm-autolinking'); let remoteCfg /*: ?{url: string, version: string, identity: string} */ = null; function reactNativePackageLabel() /*: string */ { - return remoteCfg != null ? remoteCfg.identity : 'ReactNative'; + return remoteCfg != null ? remoteCfg.identity : REACT_NATIVE_PACKAGE_NAME; +} +// In remote mode the RN package is labelled with the remote identity, so that +// name is reserved for this run too. +function reservedNamesForRun() /*: ?Array */ { + return remoteCfg != null ? [remoteCfg.identity] : undefined; } function reactNativePackageDecl(localDecl /*: string */) /*: string */ { return remoteCfg != null @@ -104,10 +117,11 @@ function reactNativePackageDecl(localDecl /*: string */) /*: string */ { function reactProducts() /*: Array<{name: string, package: string}> */ { const rn = reactNativePackageLabel(); return [ - {name: 'ReactHeaders', package: rn}, - {name: 'ReactNativeHeaders', package: rn}, - {name: 'ReactNativeDependenciesHeaders', package: rn}, - {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, + ...REACT_NATIVE_PRODUCTS.map(name => ({name, package: rn})), + ...REACT_CODEGEN_PRODUCTS.map(name => ({ + name, + package: REACT_CODEGEN_PACKAGE_NAME, + })), ]; } function reactProductDeps() /*: string */ { @@ -146,7 +160,7 @@ function reactDescriptor( }; } else if (absXcframeworks != null) { packageRef = { - name: 'ReactNative', + name: REACT_NATIVE_PACKAGE_NAME, path: toPosix(absXcframeworks), relPath: xcframeworksRelPath != null ? toPosix(xcframeworksRelPath) : undefined, @@ -155,7 +169,7 @@ function reactDescriptor( return null; } const products = reactProducts().filter( - p => p.package !== 'React-GeneratedCode' || codegenPackageExists, + p => p.package !== REACT_CODEGEN_PACKAGE_NAME || codegenPackageExists, ); return {packageRef, products}; } @@ -231,7 +245,6 @@ function readAutolinkingJson( * name: "MyNativeModule", * path: "ios/MyNativeModule", // relative to appRoot * exclude: ["*.js", "*.podspec"], // optional - * publicHeadersPath: ".", // optional * } * ] * } @@ -254,6 +267,41 @@ function readSpmModulesFromConfig( } } +/** + * Validates one app-local `spm.modules` name against the same rules a library's + * `spm.name` gets: a usable Swift identifier, not a name React Native reserves, + * and not one already taken by another module or an autolinked dep. + * `taken` maps lower-cased name → the name as written. + */ +function assertSpmModuleName( + name /*: unknown */, + taken /*: Map */, +) /*: void */ { + const remedy = + "Rename it in this app's react-native.config.js 'spm.modules'."; + if (typeof name !== 'string' || !isValidSwiftName(name)) { + throw new Error( + `react-native autolinking: invalid 'spm.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + const moduleName = name; + assertSwiftNameNotReserved(moduleName, { + label: `the 'spm.modules' entry '${moduleName}'`, + remedy, + extraReservedNames: reservedNamesForRun(), + }); + const clash = taken.get(moduleName.toLowerCase()); + if (clash != null) { + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` + + (clash === moduleName + ? `is already the name of another autolinked target.` + : `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) + + ` ${remedy}`, + ); + } +} + /** * Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to * skip. The escape hatch for the transitive plugin discovery (an app opts a @@ -426,7 +474,7 @@ function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { let hasClang = false; const walk = (dir /*: string */, depth /*: number */) => { if (depth > 6 || (hasSwift && hasClang)) return; - let entries: Array<{name: string, isDirectory(): boolean}>; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { // $FlowFixMe[incompatible-type] Dirent typing entries = fs.readdirSync(dir, {withFileTypes: true}); @@ -730,9 +778,9 @@ function expandSpmSourceGlobs( * Returns null if the dependency doesn't have iOS support. * * `swiftNameByNpm` maps each autolinked dep's npm name to its resolved Swift - * name (populated by expandSpmDependencies, possibly overridden via the dep's - * `spm.name` config). Optional for backwards compatibility with callers that - * don't have the map; falls back to `toSwiftName(name)` per entry. + * name (populated by expandSpmDependencies, honoring the dep's `spm.name` + * config and scope disambiguation). Every name this function emits comes from + * there — see requireSwiftName. */ /** * Read the dep's podspec (if any) and extract its declared @@ -789,11 +837,27 @@ function extractPodspecHeaderSearchPaths( return out; } +/** + * The Swift name expandSpmDependencies resolved for `npmName`, or a hard error: + * re-deriving one here would emit a reference nothing in the graph matches. + */ +function requireSwiftName( + npmName /*: string */, + resolved /*: ?string */, +) /*: string */ { + if (resolved == null) { + throw new Error( + `react-native autolinking: no resolved Swift name for '${npmName}'. expandSpmDependencies must resolve every autolinked dep's name before SPM targets are generated.`, + ); + } + return resolved; +} + function autolinkingDepToSpmTarget( depName /*: string */, dep /*: AutolinkedDep */, outputDir /*: string */, - swiftNameByNpm /*: ?Map */, + swiftNameByNpm /*: Map */, ) /*: SpmTarget | null */ { const iosPlatform = dep.platforms.ios; const sourceDir = iosPlatform.sourceDir ?? dep.root; @@ -806,10 +870,7 @@ function autolinkingDepToSpmTarget( // same convention the spmModule branch in main() follows. const relSourcePath = path.relative(outputDir, sourceDir); - // Prefer the resolved Swift name (which honors `spm.name` overrides set in - // the dep's react-native.config.js). Fall back to toSwiftName(depName) when - // the caller didn't run expandSpmDependencies. - const targetName = dep.swiftName ?? toSwiftName(depName); + const targetName = requireSwiftName(depName, dep.swiftName); // No exclude inference — main()'s emission loop emits `sources:` (an // explicit allowlist). User-supplied excludes still work. @@ -819,13 +880,11 @@ function autolinkingDepToSpmTarget( const resources = privacyManifest != null ? [privacyManifest] : undefined; // Map declared spm.dependencies (npm names) to Swift target names so the - // synth's .product(...) deps list reaches the consuming target. Each - // transitive npm name's Swift name comes from the map (honoring overrides); - // toSwiftName fallback handles entries the map doesn't know about. + // synth's .product(...) deps list reaches the consuming target. const spmDeps /*: Array */ = dep.spmDependencies ?? []; const spmTargetDependencies = spmDeps.length > 0 - ? spmDeps.map(n => swiftNameByNpm?.get(n) ?? toSwiftName(n)) + ? spmDeps.map(n => requireSwiftName(n, swiftNameByNpm.get(n))) : undefined; const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir); @@ -899,12 +958,14 @@ function generateAutolinkedPackageSwift( ) { packageDeps.push( reactNativePackageDecl( - `.package(name: "ReactNative", path: "${xcframeworksRelPath}")`, + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${xcframeworksRelPath}")`, ), ); // Per-app generated headers come from the ReactAppHeaders product in // the codegen package (sibling of the autolinking dir). - packageDeps.push(`.package(name: "React-GeneratedCode", path: "../ios")`); + packageDeps.push( + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "../ios")`, + ); } // AutolinkedAggregate's target dependencies: .product(...) for npm sub-package @@ -1007,10 +1068,10 @@ import PackageDescription import Foundation ${guardBlock}let package = Package( - name: "Autolinked", + name: "${AUTOLINKED_PACKAGE_NAME}", platforms: [.iOS(.v15)], products: [ - .library(name: "Autolinked", targets: ["AutolinkedAggregate"]), + .library(name: "${AUTOLINKED_PACKAGE_NAME}", targets: ["AutolinkedAggregate"]), ], ${packageDepsBlock} targets: [ .target( @@ -1074,13 +1135,13 @@ function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ { spec.codegenPackagePath ?? '../../../ios'; packageDeps.push( reactNativePackageDecl( - `.package(name: "ReactNative", path: "${reactNativePackagePath}")`, + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${reactNativePackagePath}")`, ), ); // Per-app generated headers come from the ReactAppHeaders product in // the codegen package. packageDeps.push( - `.package(name: "React-GeneratedCode", path: "${codegenPackagePath}")`, + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "${codegenPackagePath}")`, ); } for (const dep of spmDependencies) { @@ -1251,11 +1312,13 @@ function main(argv /*:: ?: Array */) /*: void */ { const allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + extraReservedNames: reservedNamesForRun(), + log, }); - // Map every autolinked npm name to its resolved Swift name (post-override) - // so transitive references inside autolinkingDepToSpmTarget find the right - // target identifier — not just the auto-derived toSwiftName. + // Map every autolinked npm name to its resolved Swift name so transitive + // references inside autolinkingDepToSpmTarget find the right target + // identifier. const swiftNameByNpm /*: Map */ = new Map(); for (const dep of allDeps) { if (dep.swiftName != null) { @@ -1287,8 +1350,39 @@ function main(argv /*:: ?: Array */) /*: void */ { discoveredPlugins.map(p => p.depName), ); + // Skipped means no sibling package is created for the host either, so a + // dep declaring it in `spm.dependencies` gets a package reference to a + // path this run never writes — SPM then reports only the missing path. + // Only manifests React Native emits can carry that reference: a dep + // shipping its own Package.swift declares its package references itself, + // and the classification loop below would treat it as self-managed. + const pluginHostDependents /*: Map> */ = new Map(); + for (const dep of allDeps) { + const declaredHosts = (dep.spmDependencies ?? []).filter(name => + pluginHostDeps.has(name), + ); + if (declaredHosts.length === 0) { + continue; + } + const sourceDir = dep.platforms.ios.sourceDir ?? dep.root; + if (sourceDir == null || findSelfManagedPackageDir(sourceDir) != null) { + continue; + } + for (const host of declaredHosts) { + const dependents = pluginHostDependents.get(host) ?? []; + dependents.push(dep.name); + pluginHostDependents.set(host, dependents); + } + } + for (const dep of allDeps) { if (pluginHostDeps.has(dep.name)) { + const dependents = pluginHostDependents.get(dep.name); + if (dependents != null) { + throw new Error( + `react-native autolinking: '${dep.name}' ships an SPM autolinking plugin, which owns its native contribution — so React Native does not build it as a sibling target for anything to depend on. It is declared in 'spm.dependencies' by ${dependents.map(name => `'${name}'`).join(', ')}. Remove it there; nothing is lost. Its plugin links its products into the app and resolves its own ecosystem's dependencies, so a library that builds against it does not declare it here.`, + ); + } log( `Skipping ${dep.name} target generation — provided by its SPM autolinking plugin`, ); @@ -1321,7 +1415,15 @@ function main(argv /*:: ?: Array */) /*: void */ { // the globs now relative to its dir and attach the file list to the target // so the emission loop below renders `sources: [...]` literally. const configModules = readSpmModulesFromConfig(appRoot); + // Module names land in the manifest exactly as written, so they get the same + // checks a dep's Swift name gets. Seeded with the dep target names already + // emitted so a module can't shadow an autolinked library either. + const takenSwiftNames /*: Map */ = new Map( + entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]), + ); for (const mod of configModules) { + assertSpmModuleName(mod.name, takenSwiftNames); + takenSwiftNames.set(mod.name.toLowerCase(), mod.name); const absPath = path.resolve(appRoot, mod.path); const relPath = path.relative(outputDir, absPath); const userSources = @@ -1333,7 +1435,9 @@ function main(argv /*:: ?: Array */) /*: void */ { name: mod.name, path: relPath, exclude: mod.exclude ?? [], - publicHeadersPath: mod.publicHeadersPath ?? null, + // The synth wrapper owns the module's public interface: it declares + // publicHeadersPath: "include", a symlink to the module's header tree. + publicHeadersPath: null, sources: userSources, }, origin: 'spmModule', @@ -1402,8 +1506,9 @@ function main(argv /*:: ?: Array */) /*: void */ { // longer silently synthesize one for them (that duplicated the scaffolder and // hid the gap from the developer and the library author) — collect them and // fail with an actionable message after the classification pass. spmModules - // (app-local, podspec-less, explicitly declared in react-native.config.js) - // keep their synth wrappers: there is nothing to scaffold for them. + // (app-local, explicitly declared in react-native.config.js) keep their synth + // wrappers: an app-local dir has no npm identity, so there is no package for + // the aggregator to reference until one is written for it. const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ = []; @@ -1455,11 +1560,14 @@ function main(argv /*:: ?: Array */) /*: void */ { } continue; } - // spmModule: synth wrapper is the legitimate mechanism (no podspec exists - // to scaffold from, and the app developer declared it explicitly). But a - // mixed-language module can't be wrapped either — SPM can't compile Swift + - // C-family sources in one target, and a synth wrapper would fail with a - // cryptic SPM resolve error. Surface the same friendly diagnostic the + // spmModule: the synth wrapper is the mechanism, not a fallback — an + // app-local dir has no npm identity, so the wrapper is the only package the + // aggregator can reference. No podspec is read on this route by design: + // app-local native code isn't required to carry one. (A hand-written + // Package.swift still wins — the self-managed check above claims it first.) + // But a mixed-language module can't be wrapped either — SPM can't compile + // Swift + C-family sources in one target, and a synth wrapper would fail + // with a cryptic SPM resolve error. Surface the same friendly diagnostic the // community-dep path uses instead of letting SPM emit the cryptic one. if (hasMixedLanguageSources(absSource)) { throw new Error( @@ -1686,6 +1794,7 @@ function main(argv /*:: ?: Array */) /*: void */ { let pluginGeneratedSources /*: Array<{path: string}> */ = []; let pluginFlavoredFrameworks /*: Array */ = []; let pluginWatchPaths /*: Array */ = []; + let pluginScriptPhases /*: Array */ = []; if (discoveredPlugins.length > 0) { // React-GeneratedCode is the per-app codegen package (referenced as // `../ios` from outputDir). It may be absent (no codegen this run), so the @@ -1714,15 +1823,17 @@ function main(argv /*:: ?: Array */) /*: void */ { pluginGeneratedSources = result.generatedSources; pluginFlavoredFrameworks = result.flavoredFrameworks; pluginWatchPaths = result.watchPaths; + pluginScriptPhases = result.scriptPhases; log( `SPM plugins contributed ${pluginPackageDeps.length} package(s), ` + `${pluginProductDeps.length} product(s), ` + `${pluginGeneratedSources.length} generated source(s), ` + - `${pluginFlavoredFrameworks.length} flavored framework(s)`, + `${pluginFlavoredFrameworks.length} flavored framework(s), ` + + `${pluginScriptPhases.length} script phase(s)`, ); } - // Plugin sidecars. Both are ALWAYS written — even `[]` — so removing a + // Plugin sidecars. All are ALWAYS written — even `[]` — so removing a // plugin (or dropping its declaration) clears stale entries. Machine-local // absolute paths; gitignored + regenerated every sync. fs.mkdirSync(outputDir, {recursive: true}); @@ -1741,6 +1852,11 @@ function main(argv /*:: ?: Array */) /*: void */ { JSON.stringify(pluginFlavoredFrameworks, null, 2) + '\n', 'utf8', ); + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-script-phases.json'), + JSON.stringify(pluginScriptPhases, null, 2) + '\n', + 'utf8', + ); // Top-level aggregator: references every entry as .package(path:) and // depends on each via .product(...). No more inline targets — every @@ -1873,6 +1989,7 @@ if (require.main === module) { module.exports = { main, + autolinkingDepToSpmTarget, generateAutolinkedPackageSwift, generateSynthPackageSwift, reactDescriptor, diff --git a/packages/react-native/scripts/spm/generate-spm-package.js b/packages/react-native/scripts/spm/generate-spm-package.js index 813359cf0d46..83594d80b749 100644 --- a/packages/react-native/scripts/spm/generate-spm-package.js +++ b/packages/react-native/scripts/spm/generate-spm-package.js @@ -37,6 +37,12 @@ const {prepareFlavoredFrameworks} = require('./flavored-frameworks'); const { + REACT_HEADERS_TARGET_DIR, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, deriveAppName, displayPath, findProjectRoot, @@ -166,32 +172,38 @@ function findSourcePath( * Package.swift also imports it as a named package dependency. */ function generateXCFrameworksPackageSwift() /*: string */ { + // Each product's target follows from its KIND, not from its position in the + // list: the umbrella is a Clang target over the staged headers, and every + // xcframework-backed product gets a binaryTarget of the same name. + const products = REACT_NATIVE_PRODUCTS.map( + product => ` .library(name: "${product}", targets: ["${product}"]),`, + ); + const targets = [ + ` .target( + name: "${REACT_NATIVE_UMBRELLA_PRODUCT}", + dependencies: ["${REACT_NATIVE_HEADERS_PRODUCT}"], + path: "${REACT_HEADERS_TARGET_DIR}", + publicHeadersPath: "include" + ),`, + ...REACT_NATIVE_XCFRAMEWORK_PRODUCTS.map( + product => ` .binaryTarget( + name: "${product}", + path: "${product}.xcframework" + ),`, + ), + ]; + return `// swift-tools-version: 6.0 // AUTO-GENERATED by scripts/generate-spm-package.js – do not edit manually. import PackageDescription let package = Package( - name: "ReactNative", + name: "${REACT_NATIVE_PACKAGE_NAME}", products: [ - .library(name: "ReactHeaders", targets: ["ReactHeaders"]), - .library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"]), - .library(name: "ReactNativeDependenciesHeaders", targets: ["ReactNativeDependenciesHeaders"]), +${products.join('\n')} ], targets: [ - .target( - name: "ReactHeaders", - dependencies: ["ReactNativeHeaders"], - path: "ReactHeadersTarget", - publicHeadersPath: "include" - ), - .binaryTarget( - name: "ReactNativeHeaders", - path: "ReactNativeHeaders.xcframework" - ), - .binaryTarget( - name: "ReactNativeDependenciesHeaders", - path: "ReactNativeDependenciesHeaders.xcframework" - ), +${targets.join('\n')} ] ) `; diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index f22fe26455e7..fe4687a22101 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -22,9 +22,11 @@ */ const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); +const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, addArrayStringValues, + commentSafe, ensureScalarField, findApplicationTargets, findField, @@ -41,13 +43,25 @@ const { removeObjectByUuid, serializeEntry, setScalarField, + uuidComment, } = require('./spm-pbxproj'); -const {makeLogger, remotePackageConfig} = require('./spm-utils'); +const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_CODEGEN_PACKAGE_NAME, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + isValidScriptPhaseId, + isValidScriptPhaseName, + makeLogger, + remotePackageConfig, +} = require('./spm-utils'); const fs = require('fs'); const path = require('path'); /*:: import type { FlavoredFrameworkManifestEntry, + PluginScriptPhase, XcframeworkSlice, } from './spm-types'; */ @@ -72,6 +86,17 @@ const SPM_GENERATED_SOURCES_MANIFEST = path.join( '.spm-plugin-generated-sources.json', ); +// Manifest of plugin-contributed build-time shell phases for the app target — +// SwiftPM has no `script_phase`, so a framework that must generate content into +// the app bundle (expo-constants' `EXConstants.bundle/app.config`) declares one +// through the plugin contract. +const SPM_SCRIPT_PHASES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', +); + // The single navigator group all injected generated sources are parented under // (created on first use). Its namespacedUUID id + display name. const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources'; @@ -91,36 +116,21 @@ const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = { // resolve the product dependencies — SPM doesn't expose transitive products. const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ = [ - { - product: 'ReactHeaders', - packagePath: 'build/xcframeworks', - packageName: 'ReactNative', - }, - { - product: 'ReactNativeHeaders', - packagePath: 'build/xcframeworks', - packageName: 'ReactNative', - }, - { - product: 'ReactNativeDependenciesHeaders', + ...REACT_NATIVE_PRODUCTS.map(product => ({ + product, packagePath: 'build/xcframeworks', - packageName: 'ReactNative', - }, + packageName: REACT_NATIVE_PACKAGE_NAME, + })), { - product: 'Autolinked', + product: AUTOLINKED_PACKAGE_NAME, packagePath: 'build/generated/autolinking', - packageName: 'Autolinked', - }, - { - product: 'ReactCodegen', - packagePath: 'build/generated/ios', - packageName: 'React-GeneratedCode', + packageName: AUTOLINKED_PACKAGE_NAME, }, - { - product: 'ReactAppDependencyProvider', + ...REACT_CODEGEN_APP_PRODUCTS.map(product => ({ + product, packagePath: 'build/generated/ios', - packageName: 'React-GeneratedCode', - }, + packageName: REACT_CODEGEN_PACKAGE_NAME, + })), ]; /*:: @@ -136,7 +146,15 @@ type BuildSettingChange = { // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once // CocoaPods is deintegrated. Deinit restores the original. replacedScalars?: {[string]: string}, + // Array settings that existed as a SCALAR and were promoted to a `( … )` + // array (key → the pre-injection raw value text, quotes included). Deinit + // restores that value verbatim; removing the injected members would leave + // the promoted array and its `"$(inherited)"` seed behind. + promotedArrayScalars?: {[string]: string}, }; +// An array field injection CREATED (rather than appended to a pre-existing +// one), so deinit removes the whole field and lands byte-identical. +type CreatedArrayField = {container: 'project' | 'target', key: string}; // A plugin-contributed source, normalized for pbxproj emission. `path` is // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the // matching pbxproj token ('SOURCE_ROOT' or '""'). @@ -264,30 +282,42 @@ function spmGraphToEntries( return {localRefs, remoteRef, productDeps, buildFiles}; } -// Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by -// the build phase (safety net) and the scheme pre-action (the one that -// actually fires before SPM resolution, so a single build picks up -// dep-graph changes from `npm install`). -// Build a PBXShellScriptBuildPhase entry (the "Sync SPM Autolinking" phase). +/** + * Build a PBXShellScriptBuildPhase entry. `inputPaths`/`outputPaths` accept + * either a plain path array or an already-serialized pbxproj list. + * `alwaysOutOfDate` emits Xcode's own `alwaysOutOfDate = 1;` (unquoted, right + * after `isa`, so a project Xcode rewrites stays diff-free) and is omitted + * entirely when false. `comment` overrides the cosmetic `/* … *​/` label, which + * otherwise derives from `name`. + */ function shellScriptPhase( phaseUUID /*: string */, name /*: string */, script /*: string */, - options /*: {inputPaths?: string, outputPaths?: string} */ = {}, + options /*: {inputPaths?: ?(string | ReadonlyArray), outputPaths?: ?(string | ReadonlyArray), alwaysOutOfDate?: ?boolean, comment?: string} */ = {}, ) /*: {uuid: string, comment: string, fields: {[string]: string}} */ { const empty = '(\n\t\t\t)'; + const pathList = ( + value /*: ?(string | ReadonlyArray) */, + ) /*: string */ => { + if (value == null) { + return empty; + } + return typeof value === 'string' ? value : pbxPathList(value); + }; return { uuid: phaseUUID, - comment: name, + comment: options.comment ?? name, fields: { isa: 'PBXShellScriptBuildPhase', + ...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}), buildActionMask: '2147483647', files: empty, inputFileListPaths: empty, - inputPaths: options.inputPaths ?? empty, + inputPaths: pathList(options.inputPaths), name: quoteIfNeeded(name), outputFileListPaths: empty, - outputPaths: options.outputPaths ?? empty, + outputPaths: pathList(options.outputPaths), runOnlyForDeploymentPostprocessing: '0', shellPath: '/bin/sh', shellScript: quoteIfNeeded(script), @@ -467,11 +497,126 @@ ${copies} `; } +/** + * The app target's `buildPhases` members, in the order the file lists them. + * Line-leading UUIDs only: a member's trailing comment carries a plugin-supplied + * phase name, and one that happens to look like a UUID would otherwise read as an + * extra member — leaving the actual order permanently at odds with the declared + * one, and offering a non-member as a re-seating anchor. + */ +function buildPhaseOrder( + text /*: string */, + target /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: Array */ { + const field = findField(text, target, 'buildPhases'); + if (field == null) { + return []; + } + return [...field.value.matchAll(/^[\t ]*([0-9A-Fa-f]{24})\b/gm)].map( + m => m[1], + ); +} + +/** + * Rewrite the trailing comment Xcode keeps beside a UUID — on the object's own + * definition line and on every array-member line referencing it. Xcode + * normalizes those comments on its next write, so leaving a stale one behind + * (after a plugin renames a phase) plants a spurious diff in the user's repo. + * No-op when the comment already reads `comment`. + */ +function setUuidComment( + text /*: string */, + uuid /*: string */, + comment /*: string */, +) /*: string */ { + return text.replace( + new RegExp(`(\\n[\\t ]*${uuid})(?: /\\* [^\\n]*? \\*/)?( = \\{|,)`, 'g'), + (_match, head, tail) => `${head}${uuidComment(comment)}${tail}`, + ); +} + +/*:: type SeatedPhase = {uuid: string, comment: string, position: 'beforeCompile' | 'end'}; */ + +/** + * Seat the plugin phases in the order the manifest declares: `beforeCompile` + * ones directly after the Sync SPM Autolinking phase (which stays first — it + * regenerates the content everything else reads) and always before Sources, + * `end` ones at the true end of `buildPhases`, after the app's own JS-bundle + * phase. The other members keep their relative order. + * + * `beforeCompile` is anchored on Sources, not merely on the sync phase: RN never + * re-seats its own sync phase, so a user who drags it below Sources would + * otherwise have every `beforeCompile` phase seated after compilation — the one + * thing the position promises not to do. Without a Sources phase the sync phase + * is the anchor. + * + * Rewrites the membership lines ONLY when the actual order differs from that, + * which is what keeps an unchanged sync byte-identical — `addBuildPhaseAfter` + * and `addArrayMembers` both short-circuit on a UUID that is already a member, + * so re-placing has to be driven from here. A phase a user dragged elsewhere in + * Xcode is therefore moved back: the declared position wins. + */ +function seatScriptPhases( + input /*: string */, + targetUuid /*: string */, + syncPhaseUuid /*: string */, + phases /*: ReadonlyArray */, + sourcesPhaseUuid /*: ?string */, +) /*: string */ { + const pluginUuids = new Set(phases.map(p => p.uuid)); + const actual = buildPhaseOrder( + input, + findApplicationTargetByUuid(input, targetUuid), + ); + const others = actual.filter(uuid => !pluginUuids.has(uuid)); + const beforeCompile = phases.filter(p => p.position === 'beforeCompile'); + const atEnd = phases.filter(p => p.position !== 'beforeCompile'); + const sourcesAt = + sourcesPhaseUuid != null ? others.indexOf(sourcesPhaseUuid) : -1; + const afterSyncAt = others.indexOf(syncPhaseUuid) + 1; + // 0 — no sync phase member, or Sources ahead of it — makes the beforeCompile + // phases lead the array, matching addArrayMembers' prepend fallback below. + const insertAt = + sourcesAt >= 0 ? Math.min(afterSyncAt, sourcesAt) : afterSyncAt; + const desired = [ + ...others.slice(0, insertAt), + ...beforeCompile.map(p => p.uuid), + ...others.slice(insertAt), + ...atEnd.map(p => p.uuid), + ]; + if ( + desired.length === actual.length && + desired.every((uuid, i) => uuid === actual[i]) + ) { + return input; + } + // File-wide removal is safe for a shell-phase UUID: it appears on its own + // definition line (which ends `= {`, never matched) and on `buildPhases` + // member lines. The uncovered case is a user who copied the same phase object + // into a SECOND target's buildPhases — it is stripped there too. + let text = removeArrayMembersByUuid(input, [...pluginUuids]); + const target = () => findApplicationTargetByUuid(text, targetUuid); + let anchor = insertAt > 0 ? others[insertAt - 1] : null; + for (const member of beforeCompile) { + text = + anchor == null + ? addArrayMembers(text, target(), 'buildPhases', [member], { + prepend: true, + }) + : addBuildPhaseAfter(text, target(), anchor, member); + anchor = member.uuid; + } + for (const member of atEnd) { + text = addArrayMembers(text, target(), 'buildPhases', [member]); + } + return text; +} + function addBuildPhaseAfter( text /*: string */, target /*: {bodyOpen: number, bodyClose: number, ...} */, afterUuid /*: string */, - member /*: {uuid: string, comment: string} */, + member /*: {uuid: string, comment: string, ...} */, ) /*: string */ { const field = findField(text, target, 'buildPhases'); if (field == null || field.value.includes(member.uuid)) { @@ -486,7 +631,7 @@ function addBuildPhaseAfter( const absoluteStart = field.valueStart + after.index; const lineEnd = text.indexOf('\n', absoluteStart + after[0].length); const indent = after[2]; - const line = `\n${indent}${member.uuid} /* ${member.comment} */,`; + const line = `\n${indent}${member.uuid}${uuidComment(member.comment)},`; return text.slice(0, lineEnd) + line + text.slice(lineEnd); } @@ -723,6 +868,18 @@ function escapeXmlAttribute(s /*: string */) /*: string */ { .replace(/'/g, '''); } +// The inverse of escapeXmlAttribute. `&` is expanded LAST so an entity that +// was itself escaped (`<` → `&lt;`) round-trips back to its own text +// rather than to `<`. +function unescapeXmlAttribute(s /*: string */) /*: string */ { + return s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + function generateXcscheme( appName /*: string */, targetUUID /*: string */, @@ -931,6 +1088,25 @@ const INJECTED_ARRAY_SETTINGS = [ }, ]; +// Array build settings injected only into debug-flavored configurations. +// +// Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by +// GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app +// template does not commit the setting: CocoaPods injects it at `pod install` +// time (react_native_post_install → set_build_setting +// SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug). +// An SPM app never runs CocoaPods, so without this `#if DEBUG` is false even +// in a Debug build — AppDelegate.swift's `bundleURL()` skips the Metro URL, +// falls back to a main.jsbundle that a Debug build never produced, and the app +// dies at launch with "No script url provided … unsanitizedScriptURLString = +// (null)" while Metro is running right there. +// +// Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that +// links the debug xcframeworks also compiles its Swift with DEBUG. +const DEBUG_ARRAY_SETTINGS = [ + {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, +]; + /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ function targetBuildConfigUuids( text /*: string */, @@ -1077,7 +1253,8 @@ function injectSpmIntoPbxproj( hermesCliPath /*: ?string */ = null, generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], -) /*: {text: string, injectedUuids: Array, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}} */ { + scriptPhases /*: ReadonlyArray */ = [], +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { let text = input; const mkUuid = (section /*: string */, id /*: string */) => namespacedUUID(plan.rootUuid, section, id); @@ -1110,10 +1287,7 @@ function injectSpmIntoPbxproj( insertObjects('XCSwiftPackageProductDependency', entries.productDeps); insertObjects('PBXBuildFile', entries.buildFiles); - // Track array fields we CREATE (vs. append to a pre-existing one) so deinit - // can remove the whole field and land byte-identical to the original. - const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ = - []; + const createdArrayFields /*: Array */ = []; // 2. packageReferences on the PBXProject. const pkgRefMembers = [ @@ -1304,9 +1478,14 @@ function injectSpmIntoPbxproj( const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`); const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`); generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid]; + // The name/path VALUES stay verbatim (escaped) — only the cosmetic + // comments are normalized, falling back to the normalized path (the + // ledger key) and then to no comment at all, which is well-formed. + const label = commentSafe(src.name) || commentSafe(src.path); + const inSources = label === '' ? '' : `${label} in Sources`; fileRefs.push({ uuid: fileRefUuid, - comment: src.name, + comment: label, fields: { isa: 'PBXFileReference', lastKnownFileType: src.fileType, @@ -1317,17 +1496,14 @@ function injectSpmIntoPbxproj( }); buildFiles.push({ uuid: buildFileUuid, - comment: `${src.name} in Sources`, + comment: inSources, fields: { isa: 'PBXBuildFile', - fileRef: `${fileRefUuid} /* ${src.name} */`, + fileRef: `${fileRefUuid}${uuidComment(label)}`, }, }); - sourcesMembers.push({ - uuid: buildFileUuid, - comment: `${src.name} in Sources`, - }); - groupChildren.push({uuid: fileRefUuid, comment: src.name}); + sourcesMembers.push({uuid: buildFileUuid, comment: inSources}); + groupChildren.push({uuid: fileRefUuid, comment: label}); } insertObjects('PBXFileReference', fileRefs); insertObjects('PBXBuildFile', buildFiles); @@ -1387,12 +1563,87 @@ function injectSpmIntoPbxproj( } } + // 9. Plugin-declared build phases (SwiftPM has no `script_phase`). Each + // phase's UUID is keyed on its plugin id, so re-runs refresh in place and + // `deinit` reverses it. + const scriptPhaseUuids /*: {[string]: string} */ = {}; + if (scriptPhases.length > 0) { + const seated /*: Array */ = []; + for (const scriptPhase of scriptPhases) { + const uuid = mkUuid( + 'PBXShellScriptBuildPhase', + `plugin:${scriptPhase.id}`, + ); + scriptPhaseUuids[scriptPhase.id] = uuid; + // The full name goes into the `name` field (escaped) — that is what Xcode + // displays. The cosmetic comments get it normalized, falling back to the + // normalized id and then to no comment at all, which is well-formed. The + // fallback is sanitized rather than trusted to the id charset, so widening + // that charset can never reach a comment. + const comment = + commentSafe(scriptPhase.name) || commentSafe(scriptPhase.id); + const entry = shellScriptPhase( + uuid, + scriptPhase.name, + scriptPhase.script, + { + inputPaths: scriptPhase.inputPaths, + outputPaths: scriptPhase.outputPaths, + alwaysOutOfDate: scriptPhase.alwaysOutOfDate, + comment, + }, + ); + if (!text.includes(uuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry(entry), + ); + } else { + // Rewrite the fields we own unconditionally — byte-identical when the + // plugin's declaration hasn't changed, so no content hashing is needed. + // Each write shifts offsets, hence the re-lookup per field. + for (const key of [ + 'name', + 'shellScript', + 'inputPaths', + 'outputPaths', + ]) { + const current = findObjectByUuid(text, uuid); + if (current != null) { + text = setScalarField(text, current, key, entry.fields[key]); + } + } + // Flipping alwaysOutOfDate back off means REMOVING the field — + // setScalarField would only ever write a value. + const current = findObjectByUuid(text, uuid); + if (current != null) { + text = + scriptPhase.alwaysOutOfDate === true + ? setScalarField(text, current, 'alwaysOutOfDate', '1') + : removeField(text, current, 'alwaysOutOfDate'); + } + } + injectedUuids.push(uuid); + text = setUuidComment(text, uuid, comment); + seated.push({uuid, comment, position: scriptPhase.position}); + } + text = seatScriptPhases( + text, + plan.targetUuid, + syncPhaseUuid, + seated, + sourcesPhaseUuid, + ); + } + return { text, injectedUuids, createdArrayFields, buildSettingChanges, generatedSourceUuids, + scriptPhaseUuids, }; } @@ -1450,6 +1701,28 @@ function resolveHermesCliPathSetting( } } +/** Strip the surrounding plist quotes from a build-setting token, if any. */ +function unquotePlist(s /*: string */) /*: string */ { + return s.replace(/^"/, '').replace(/"$/, ''); +} + +/** + * The individual values a build setting already carries, unquoted — for both + * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting + * (`("$(inherited)", DEBUG)`) and the scalar form the app template and + * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the + * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently + * skip the injection. + */ +function buildSettingValueTokens(value /*: string */) /*: Set */ { + return new Set( + value + .split(/[\s,()]+/) + .filter(Boolean) + .map(unquotePlist), + ); +} + function mergeReactBuildSettings( input /*: string */, configUuid /*: string */, @@ -1492,9 +1765,13 @@ function mergeReactBuildSettings( }; const createdArrayKeys /*: Array */ = []; const appendedArrayValues /*: {[string]: Array} */ = {}; + const promotedArrayScalars /*: {[string]: string} */ = {}; const createdScalars /*: Array */ = []; const arraySettings = [ ...INJECTED_ARRAY_SETTINGS, + ...(flavorForBuildConfiguration(configurationName) === 'debug' + ? DEBUG_ARRAY_SETTINGS + : []), ...frameworkArrayBuildSettings(flavoredFrameworks), ]; for (const {key, values} of arraySettings) { @@ -1503,15 +1780,41 @@ function mergeReactBuildSettings( continue; } const existing = findField(text, d, key); + // Non-null only for a scalar addArrayStringValues would promote to an array + // (same array-vs-scalar test it uses). Kept RAW: findField's token for a + // bare scalar ends at the `;`, so it carries any whitespace before it, and + // deinit has to write those bytes back verbatim. + const priorScalar = + existing != null && !existing.value.trimStart().startsWith('(') + ? existing.value + : null; if (existing == null) { createdArrayKeys.push(key); } else { - const fresh = values.filter(v => !existing.value.includes(v)); - if (fresh.length > 0) { + const present = buildSettingValueTokens(existing.value); + const fresh = values.filter(v => !present.has(unquotePlist(v))); + if (fresh.length === 0) { + // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by + // EXACT array member, so a value the user carries in the scalar form + // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would + // otherwise be promoted to an array and re-appended — an edit `deinit` + // has no record of and so could never reverse. + continue; + } + if (priorScalar == null) { appendedArrayValues[key] = fresh; } } + const beforeAdd = text; text = addArrayStringValues(text, d, key, values); + // Record only a promotion that actually happened: addArrayStringValues + // no-ops when `values` is empty or every value is already a member, and a + // recorded-but-untouched field would have deinit clobber whatever the user + // has there by then. Restoring the scalar subsumes removing the injected + // members, so the two records stay mutually exclusive per key. + if (priorScalar != null && text !== beforeAdd) { + promotedArrayScalars[key] = priorScalar; + } } const replacedScalars /*: {[string]: string} */ = {}; for (const {key, value} of scalars) { @@ -1570,6 +1873,9 @@ function mergeReactBuildSettings( appendedArrayValues, createdScalars, replacedScalars, + ...(Object.keys(promotedArrayScalars).length > 0 + ? {promotedArrayScalars} + : {}), }, }; } @@ -1799,13 +2105,13 @@ function readGeneratedSourcesManifest( appRoot /*: string */, ) /*: Array */ { const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST); - let raw: string; + let raw /*: string */ = ''; try { raw = fs.readFileSync(manifestPath, 'utf8'); } catch { return []; } - let entries: unknown; + let entries /*: unknown */ = null; try { entries = JSON.parse(raw); } catch { @@ -1837,6 +2143,86 @@ function readGeneratedSourcesManifest( return out; } +const nonEmptyStrings = (value /*: unknown */) /*: ?Array */ => + Array.isArray(value) + ? value.filter(p => typeof p === 'string' && p.length > 0) + : null; + +/** + * Read the plugin script-phases manifest at + * `/build/generated/autolinking/.spm-plugin-script-phases.json`. + * Absent, unparseable, or malformed → `[]`. Lenient by design even though the + * plugin contract validates these entries fatally at invoke time: the sidecar + * legitimately does not exist yet on a first `spm add`, and a stale or + * hand-edited file must not break injection. This reader is the ONLY gate on + * such a file, so it applies the same id/name rules (from spm-utils) that + * invokePlugins enforces fatally. + */ +function readScriptPhasesManifest( + appRoot /*: string */, +) /*: Array */ { + const manifestPath = path.join(appRoot, SPM_SCRIPT_PHASES_MANIFEST); + let raw /*: string */ = ''; + try { + raw = fs.readFileSync(manifestPath, 'utf8'); + } catch { + return []; + } + let entries /*: unknown */ = null; + try { + entries = JSON.parse(raw); + } catch { + log( + `warning: could not parse ${SPM_SCRIPT_PHASES_MANIFEST}; ` + + 'skipping script phases.', + ); + return []; + } + if (!Array.isArray(entries)) { + return []; + } + const out /*: Array */ = []; + for (const entry of entries) { + if ( + entry == null || + typeof entry !== 'object' || + !isValidScriptPhaseId(entry.id) || + !isValidScriptPhaseName(entry.name) || + typeof entry.script !== 'string' || + entry.script.length === 0 || + // An unknown position is a malformed entry, not something to coerce: it + // would silently run somewhere the plugin didn't ask for. + (entry.position != null && + entry.position !== 'beforeCompile' && + entry.position !== 'end') || + // Dedupe by id — the id seeds the phase's UUID, so a duplicate would + // insert two objects with identical UUIDs. + out.some(phase => phase.id === entry.id) + ) { + continue; + } + const phase /*: PluginScriptPhase */ = { + id: entry.id, + name: entry.name, + script: entry.script, + position: entry.position ?? 'end', + }; + const inputPaths = nonEmptyStrings(entry.inputPaths); + const outputPaths = nonEmptyStrings(entry.outputPaths); + if (inputPaths != null) { + phase.inputPaths = inputPaths; + } + if (outputPaths != null) { + phase.outputPaths = outputPaths; + } + if (typeof entry.alwaysOutOfDate === 'boolean') { + phase.alwaysOutOfDate = entry.alwaysOutOfDate; + } + out.push(phase); + } + return out; +} + /** * Read the `.spm-injected.json` marker of a previously-injected project, or * null when absent/unreadable. Used to reconcile generated sources on `update` @@ -1844,7 +2230,7 @@ function readGeneratedSourcesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array, ...} */ { +) /*: ?{generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -1857,10 +2243,11 @@ function readMarker( // Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json` // marker (the user-owned project SPM packages were injected into in place), // or null when none has been injected yet. Pure fs reads — safe for the -// build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride -// below) to call without pulling in any pbxproj-editing machinery at runtime. +// marker readers below, and for callers that only locate the project (setup- +// apple-spm.js's action defaulting and `deinit`), to call without exercising +// any pbxproj-editing machinery. function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { - let entries: Array<{name: string, isDirectory(): boolean}>; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { // $FlowFixMe[incompatible-type] Dirent typing entries = fs.readdirSync(appRoot, {withFileTypes: true}); @@ -1884,11 +2271,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { * update --version` pinned into the injected xcodeproj's `.spm-injected.json` * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj * below), or null when no project is injected yet, no override is pinned, or - * the marker can't be read (never throws). Pure fs reads — the build-time - * sync (sync-spm-autolinking.js) calls this to prefer the pinned version over - * the one derived from node_modules/react-native/package.json, so a - * version-mismatched setup keeps healing against the SAME artifact slot the - * explicit `--version` selected. + * the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's + * determineVersion prefers the pinned version over the one derived from + * node_modules/react-native/package.json, so a later flagless `add`/`update` + * (and `download`) stays on the SAME artifact slot the explicit `--version` + * selected. */ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { const xcodeprojPath = findInjectedXcodeproj(appRoot); @@ -1899,13 +2286,72 @@ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { return typeof override === 'string' && override.length > 0 ? override : null; } +/** + * Read the autolinking config command a previous `spm add`/`update` pinned into + * the injected xcodeproj's `.spm-injected.json` marker, or null when nothing + * usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but + * this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it + * on add/update/scaffold and on the build-time `sync`. Re-validated through the + * same parseConfigCommandJson the flag goes through, and never throws, so a + * hand-edited marker degrades to the env-var/default command instead of + * injecting a bogus argv into the build. + */ +function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array */ { + const xcodeprojPath = findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return null; + } + const pinned = readMarker(xcodeprojPath)?.configCommand; + if (pinned == null) { + return null; + } + try { + return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER); + } catch { + return null; + } +} + +/** + * Union the array fields RN has created across syncs, deduped by container+key. + * + * THE CANONICAL STATEMENT of why anything RN created is recorded stickily (the + * marker's `scheme.created` carries it forward for the same reason): a re-sync + * re-injects from a baseline with only the BUILD SETTINGS reversed, so it finds + * whatever the first run created already there and reports creating nothing. Once + * RN has created something, that fact has to be carried forward, or the new + * marker forgets it and `deinit` leaves an empty `packageReferences` / + * `packageProductDependencies` behind and the generated scheme on disk. + * + * The record licenses removal; it does not order it. `deinit` still checks that + * what it is about to remove is RN's (see its step 1 and isGeneratedScheme), so + * carrying forward a field the user has since taken over — or deleted by hand — + * is safe. + */ +function mergeCreatedArrayFields( + previous /*: ReadonlyArray */, + current /*: ReadonlyArray */, +) /*: Array */ { + const merged = [...previous]; + for (const field of current) { + if ( + !merged.some( + seen => seen.container === field.container && seen.key === field.key, + ) + ) { + merged.push(field); + } + } + return merged; +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -1924,6 +2370,7 @@ function injectSpmIntoExistingXcodeproj( const remote = remotePackageConfig(appRoot); const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot); const generatedSources = readGeneratedSourcesManifest(appRoot); + const scriptPhases = readScriptPhasesManifest(appRoot); const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; const prevMarker = readMarker(xcodeprojPath); @@ -1953,6 +2400,15 @@ function injectSpmIntoExistingXcodeproj( namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID), ); } + // Same reconciliation for script phases, keyed on the plugin-supplied id. + const prevScriptPhases /*: {[string]: string} */ = + prevMarker?.scriptPhases ?? {}; + const currentPhaseIds = new Set(scriptPhases.map(p => p.id)); + for (const id of Object.keys(prevScriptPhases)) { + if (!currentPhaseIds.has(id)) { + staleUuids.push(prevScriptPhases[id]); + } + } // Re-apply generated settings from a clean recorded baseline. This removes // linker entries for plugin frameworks that disappeared and keeps the new // marker a complete inverse after an idempotent update. @@ -1973,6 +2429,7 @@ function injectSpmIntoExistingXcodeproj( createdArrayFields, buildSettingChanges, generatedSourceUuids, + scriptPhaseUuids, } = injectSpmIntoPbxproj( base, { @@ -1987,6 +2444,7 @@ function injectSpmIntoExistingXcodeproj( hermesCliPath, generatedSources, flavoredFrameworks, + scriptPhases, ); const changed = writeIfChanged(pbxprojPath, text); @@ -2014,14 +2472,24 @@ function injectSpmIntoExistingXcodeproj( // intentional pin, not something to silently re-derive from // node_modules/react-native/package.json. There is no "clear" verb yet; // `deinit` (removeSpmInjection) drops the whole marker, including this - // field. Read back by readArtifactsVersionOverride (above) so the - // build-time sync (sync-spm-autolinking.js) heals against the SAME slot - // `add`/`update` selected, even on a version-mismatched setup. + // field. Read back by readArtifactsVersionOverride (above) so a later + // flagless `add`/`update`/`download` resolves to the SAME slot, even on a + // version-mismatched setup. const artifactsVersionOverride = opts.artifactsVersionOverride ?? prevMarker?.artifactsVersionOverride ?? null; + // Same set-or-preserve contract as the version pin above, for the autolinking + // config command `add`/`update` resolved (`--config-command` or + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the + // flag nor the developer's shell environment, so without the pin it + // re-derives autolinking.json with the default @react-native-community/cli + // command and breaks apps that replace it. Read back by + // readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops + // the whole marker, this field with it. + const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null; + // Marker: idempotency signal + the exact, reversible record of every edit so // `deinit` (removeSpmInjection) can undo precisely what was added. writeIfChanged( @@ -2032,15 +2500,26 @@ function injectSpmIntoExistingXcodeproj( target: plan.target.name, targetUuid: plan.target.uuid, injectedUuids: Array.from(new Set(injectedUuids)).sort(), - createdArrayFields, + createdArrayFields: mergeCreatedArrayFields( + prevMarker?.createdArrayFields ?? [], + createdArrayFields, + ), buildSettingChanges, // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next // `update` to reconcile away entries that left the manifest. generatedSources: generatedSourceUuids, + // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the + // same way. + scriptPhases: scriptPhaseUuids, artifactsVersionOverride, + configCommand, scheme: { file: schemeResult.file, - created: schemeResult.status === 'created', + // Sticky — see mergeCreatedArrayFields for why a later sync cannot + // observe this for itself. + created: + schemeResult.status === 'created' || + prevMarker?.scheme?.created === true, }, }, null, @@ -2052,6 +2531,52 @@ function injectSpmIntoExistingXcodeproj( return {status: 'injected', target: plan.target.name}; } +/** The sync pre-action's script, unescaped, or null when the scheme has none. */ +function schemePreActionScript(xml /*: string */) /*: ?string */ { + const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); + if (titleIdx < 0) { + return null; + } + const marker = 'scriptText = "'; + const start = xml.indexOf(marker, titleIdx); + if (start < 0) { + return null; + } + const valueStart = start + marker.length; + // escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always + // the closing delimiter. + const valueEnd = xml.indexOf('"', valueStart); + return valueEnd < 0 + ? null + : unescapeXmlAttribute(xml.slice(valueStart, valueEnd)); +} + +/** + * Whether `xml` is still, byte for byte, the scheme RN generates for this target + * — everything but the pre-action's script, which RN rewrites in place on every + * sync and which varies with the app's react-native path, so it cannot be part of + * an ownership test. + * + * `deinit` deletes a scheme the marker says RN created only while this holds. A + * user may replace a generated scheme with one of their own under the same name + * (same target, so `injectOrCreateScheme` finds and updates it, and the created + * record stays), and destroying that is unrecoverable where leaving a scheme + * behind is not — so the harmless way of being wrong wins: a generated scheme the + * user has since edited leaks, minus its pre-action. + */ +function isGeneratedScheme( + xml /*: string */, + appName /*: string */, + targetUuid /*: string */, + projName /*: string */, +) /*: boolean */ { + const script = schemePreActionScript(xml); + return ( + script != null && + xml === generateXcscheme(appName, targetUuid, projName, script) + ); +} + /** * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added * to a scheme, and drop the `` wrapper if it is left empty (the @@ -2097,6 +2622,25 @@ function removeRecordedBuildSettings( ); } } + const promotedArrayScalars /*: {[string]: string} */ = + change.promotedArrayScalars ?? {}; + for (const key of Object.keys(promotedArrayScalars)) { + const current = dict(); + const originalValue = promotedArrayScalars[key]; + // A field that is gone was deleted by the user after injection; restoring + // it would resurrect it, at the top of the dict, matching neither state. + if ( + current != null && + typeof originalValue === 'string' && + findField(text, current, key) != null + ) { + // Rewriting the whole value is what makes the promotion reversible at + // all — its members and its `"$(inherited)"` seed are indistinguishable + // from the user's own once folded together. The tradeoff: members the + // user hand-added to the promoted array afterwards are discarded. + text = setScalarField(text, current, key, originalValue); + } + } for (const key of change.createdArrayKeys ?? []) { const current = dict(); if (current != null) { @@ -2158,7 +2702,15 @@ function removeSpmInjection( f.container === 'project' ? findProjectObject(text) : findObjectByUuid(text, marker.targetUuid); - if (obj != null) { + if (obj == null) { + continue; + } + // The record says the field did not exist before RN created it, which is + // necessary but not sufficient: anything still in it after our own members + // are gone is the user's (their own SPM package, added to the same field), + // and dropping the field would orphan it. + const field = findField(text, obj, f.key); + if (field != null && /^\(\s*\)$/.test(field.value)) { text = removeField(text, obj, f.key); } } @@ -2178,7 +2730,8 @@ function removeSpmInjection( writeIfChanged(pbxprojPath, text); log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); - // 3. Scheme: delete it if injection created it, else strip the pre-action. + // 3. Scheme: delete it if injection created it AND still owns its contents, + // else strip the pre-action and leave the file (see isGeneratedScheme). const scheme = marker.scheme; if (scheme != null && scheme.file != null) { const schemePath = path.join( @@ -2187,11 +2740,21 @@ function removeSpmInjection( 'xcschemes', scheme.file, ); - if (scheme.created === true) { - fs.rmSync(schemePath, {force: true}); - } else if (fs.existsSync(schemePath)) { + if (fs.existsSync(schemePath)) { const xml = fs.readFileSync(schemePath, 'utf8'); - writeIfChanged(schemePath, removePreActionFromScheme(xml)); + const ours = + scheme.created === true && + isGeneratedScheme( + xml, + marker.target, + marker.targetUuid, + path.basename(xcodeprojPath, '.xcodeproj'), + ); + if (ours) { + fs.rmSync(schemePath, {force: true}); + } else { + writeIfChanged(schemePath, removePreActionFromScheme(xml)); + } } } @@ -2210,6 +2773,7 @@ module.exports = { ensureStubPackages, buildSpmDependencyGraph, spmGraphToEntries, + buildPhaseOrder, planInjection, injectSpmIntoPbxproj, injectSpmIntoExistingXcodeproj, @@ -2220,5 +2784,7 @@ module.exports = { removePreActionFromScheme, findInjectedXcodeproj, readArtifactsVersionOverride, + readPinnedConfigCommand, + readScriptPhasesManifest, SPM_INJECTED_MARKER, }; diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index 09e246b5cb17..ef26d6e63614 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -36,6 +36,7 @@ import type { */ const { + SpmNameCollisionError, defaultReadConfig, defaultResolveDep, expandSpmDependencies, @@ -43,6 +44,10 @@ const { const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); const {readPodspec} = require('./read-podspec'); const { + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, SCAFFOLDER_MARKER, makeLogger, remotePackageConfig, @@ -197,7 +202,7 @@ function collectSubdirs( ]); const out /*: Array */ = []; const walk = (absDir /*: string */, relDir /*: string */) => { - let entries: Array<{name: string, isDirectory(): boolean}>; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { // $FlowFixMe[incompatible-type] Dirent typing entries = fs.readdirSync(absDir, {withFileTypes: true}); @@ -235,14 +240,18 @@ function translatePodspecToSpmTarget( // `s.dependency "RNWorklets"` — a pod-style name the `react-native-*` // heuristic can't recognize — to the right sibling package. Empty by default. podToNpm /*: Map */ = new Map(), + // npm name → resolved Swift name for every autolinked dep, so a sibling + // reference honors that sibling's `spm.name` instead of re-deriving it. + swiftNameByNpm /*: Map */ = new Map(), ) /*: SpmScaffoldSpec */ { const warnings = [...model.warnings]; - // Swift target name: ALWAYS toSwiftName(npm-name). The autolinker - // registers each autolinked dep under that name in its aggregator (and in - // any sibling spm.dependencies refs), so the scaffolded Package.swift's - // product/library name must match — otherwise SPM resolution fails with - // a name mismatch on `.product(name: "X", package: "X")`. + // Swift target name: whatever the autolinker resolved for this dep — its + // `spm.name` override when set, else toSwiftName(npm-name). The autolinker + // registers the dep under that name in its aggregator (and in any sibling + // spm.dependencies refs), so the scaffolded Package.swift's product/library + // name must match it exactly — otherwise SPM resolution fails with a name + // mismatch on `.product(name: "X", package: "X")`. // // The podspec's `header_dir` is captured separately: when it changes the // include surface (e.g. `` instead of ``), @@ -251,10 +260,8 @@ function translatePodspecToSpmTarget( // `` resolve through // `-I common/cpp/`). Module-style includes that NEED the target name to // match (e.g. reanimated's `` via SwiftPM's auto-generated - // module map) require an explicit `spm.name` override in - // react-native.config.js — handled by the existing autolinker flow, not - // here. - const swiftName = toSwiftName(dep.name); + // module map) are what `spm.name` is for. + const swiftName = dep.swiftName ?? toSwiftName(dep.name); // Header search paths — substitute Xcode build-setting tokens against the // dep root. Anything we can't substitute is dropped + warned (avoids @@ -512,6 +519,14 @@ function translatePodspecToSpmTarget( ); } + const siblingSwiftNames /*: {[npmName: string]: string} */ = {}; + for (const npmName of siblingNames) { + const resolved = swiftNameByNpm.get(npmName); + if (resolved != null) { + siblingSwiftNames[npmName] = resolved; + } + } + return { swiftName, sources: expandedSources, @@ -520,6 +535,7 @@ function translatePodspecToSpmTarget( needsObjCPrefix, coreReactNative, siblingNames, + siblingSwiftNames, extraFrameworks: model.frameworks, weakFrameworks: model.weakFrameworks, compilerFlags: model.compilerFlags, @@ -661,7 +677,8 @@ function emitScaffoldedPackageSwift( // the app by definition, so it stays a path reference — relative, // computed at scaffold time. const remote = ctx.remote; - const rnLabel = remote != null ? remote.identity : 'ReactNative'; + const rnLabel = + remote != null ? remote.identity : REACT_NATIVE_PACKAGE_NAME; const codegenDir = ctx.codegenPackageDir; if (codegenDir == null) { throw new Error( @@ -679,24 +696,25 @@ function emitScaffoldedPackageSwift( 'emitScaffoldedPackageSwift: localXcfwPackageDir is required when no remote package is configured.', ); } - packageDeps.push(`.package(name: "ReactNative", path: "${xcfwDir}")`); + packageDeps.push( + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${xcfwDir}")`, + ); } packageDeps.push( - `.package(name: "React-GeneratedCode", path: "${codegenDir}")`, - ); - targetDeps.push(`.product(name: "ReactHeaders", package: "${rnLabel}")`); - targetDeps.push( - `.product(name: "ReactNativeHeaders", package: "${rnLabel}")`, - ); - targetDeps.push( - `.product(name: "ReactNativeDependenciesHeaders", package: "${rnLabel}")`, - ); - targetDeps.push( - '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "${codegenDir}")`, ); + for (const product of REACT_NATIVE_PRODUCTS) { + targetDeps.push(`.product(name: "${product}", package: "${rnLabel}")`); + } + for (const product of REACT_CODEGEN_PRODUCTS) { + targetDeps.push( + `.product(name: "${product}", package: "${REACT_CODEGEN_PACKAGE_NAME}")`, + ); + } } for (const siblingName of spec.siblingNames) { - const swiftSibling = toSwiftName(siblingName); + const swiftSibling = + spec.siblingSwiftNames?.[siblingName] ?? toSwiftName(siblingName); // The autolinker references each self-managed (scaffolded) dep through a // `libs/` symlink, and SPM resolves a manifest's relative // package paths against that symlink location — so a sibling lives at @@ -796,6 +814,10 @@ type ScaffoldContext = { // podspec-name → npm-name index over all autolinked deps, so pod-style // `s.dependency` names (e.g. "RNWorklets") wire to the right sibling. podToNpm?: Map, + // npm-name → resolved Swift name over all autolinked deps, so sibling + // references honor each sibling's `spm.name`. + swiftNameByNpm?: Map, + remote: ?{url: string, version: string, identity: string}, }; */ @@ -939,7 +961,7 @@ function scaffoldPackageSwiftForDep( }; } - let model: PodspecModel; + let model; try { model = readPodspec(podspecPath); } catch (e) { @@ -954,6 +976,7 @@ function scaffoldPackageSwiftForDep( model, dep, ctx.podToNpm ?? new Map(), + ctx.swiftNameByNpm ?? new Map(), ); // Mixed-language fail-closed: SPM can't compile Swift + C-family in one @@ -1008,7 +1031,7 @@ function scaffoldPackageSwiftForDep( .join('/'); const content = emitScaffoldedPackageSwift(spec, { cacheSlotLabel: ctx.cacheSlotLabel, - remote: remotePackageConfig(ctx.appRoot), + remote: ctx.remote, codegenPackageDir: relFromManifest('build', 'generated', 'ios'), localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), }); @@ -1135,13 +1158,22 @@ function scaffoldAll( directDeps.push({name, root, platforms: {ios: iosPlatform}}); } - let allDeps: Array; + // Outside the try: a malformed remote config (RemoteVersionError) is a + // misconfiguration to surface, not an expansion failure to degrade past. + const remote = remotePackageConfig(appRoot); + + let allDeps /*: Array */ = []; try { allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + extraReservedNames: remote != null ? [remote.identity] : undefined, + log, }); } catch (e) { + if (e instanceof SpmNameCollisionError) { + throw e; + } // A transitive-resolution failure shouldn't abort the whole scaffold pass; // fall back to the direct deps so at least those get manifests. log(`Transitive spm.dependencies expansion failed: ${e.message}`); @@ -1176,6 +1208,13 @@ function scaffoldAll( } } + const swiftNameByNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + if (dep.swiftName != null) { + swiftNameByNpm.set(dep.name, dep.swiftName); + } + } + const ctx /*: ScaffoldContext */ = { appRoot, projectRoot, @@ -1184,6 +1223,8 @@ function scaffoldAll( dryRun: opts.dryRun === true, cacheSlotLabel: opts.cacheSlotLabel ?? null, podToNpm, + swiftNameByNpm, + remote, }; const skipSet /*: Set */ = new Set(opts.skipDeps ?? []); diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js index 37db184129f4..6501894440cb 100644 --- a/packages/react-native/scripts/spm/spm-pbxproj.js +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -28,13 +28,55 @@ function generateUUID(seed /*: string */) /*: string */ { } /** - * Escapes a string for OpenStep plist format if needed. + * Escapes a string for OpenStep plist format if needed. A literal CR or tab in + * a quoted value is legal but Xcode rewrites it to its escape on the next save, + * planting a spurious diff in the user's repo — so escape those too (a plugin + * `script` with CRLF line endings is how one gets in). */ function quoteIfNeeded(s /*: string */) /*: string */ { if (/^[a-zA-Z0-9._/]+$/.test(s)) { return s; } - return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; + return `"${s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t')}"`; +} + +/** + * Normalize an UNTRUSTED label (a plugin's script-phase name, say) for use + * inside a pbxproj `/* … *​/` comment. Such a comment is purely cosmetic — Xcode + * regenerates it from the object's own fields — while the text AROUND it is + * scanned by delimiter, so every character that could be read as structure (or + * split the line) becomes a space, and runs of whitespace collapse: + * `findObjectByUuid` takes the first `{` after a UUID as that object's body, + * `removeArrayMembersByUuid` identifies a member line by its trailing comma, and + * `scanToClose` treats a `"` as opening a string. Stable (same input ⇒ same + * output), so re-syncing stays byte-identical. Returns '' when nothing survives; + * callers supply the fallback label. + * + * Comments RN writes to Xcode's OWN convention (`XCLocalSwiftPackageReference + * "build/xcframeworks"`, ` in Sources`) are NOT run through this: they are + * fixed strings, and normalizing them would rewrite bytes Xcode itself produces — + * a spurious diff in the user's repo on their next save. + */ +function commentSafe(s /*: string */) /*: string */ { + return s + .replace(/[{}(),;="*/\s]/g, ' ') + .replace(/ +/g, ' ') + .trim(); +} + +/** + * Format the ` /* … *​/` suffix pbxproj writes after a UUID, omitted entirely for + * an empty label (an uncommented UUID is well-formed — Xcode writes those itself + * for unnamed groups). Labels that did not originate in this repo must already + * have been through `commentSafe`. + */ +function uuidComment(comment /*: ?string */) /*: string */ { + return comment != null && comment !== '' ? ` /* ${comment} */` : ''; } /** @@ -47,11 +89,7 @@ function quoteIfNeeded(s /*: string */) /*: string */ { function serializeEntry( entry /*: {readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...} */, ) /*: string */ { - const comment = - entry.comment != null && entry.comment !== '' - ? ` /* ${entry.comment} */` - : ''; - let out = `\t\t${entry.uuid}${comment} = {`; + let out = `\t\t${entry.uuid}${uuidComment(entry.comment)} = {`; const fieldKeys = Object.keys(entry.fields); if ( fieldKeys.length <= 3 && @@ -343,8 +381,7 @@ function addArrayMembers( const memberIndent = fieldIndent + '\t'; const line = ( m /*: {readonly uuid: string, readonly comment?: ?string, ...} */, - ) => - `${memberIndent}${m.uuid}${m.comment != null && m.comment !== '' ? ` /* ${m.comment} */` : ''},\n`; + ) => `${memberIndent}${m.uuid}${uuidComment(m.comment)},\n`; const field = findField(text, obj, key); if (field != null) { @@ -367,6 +404,38 @@ function addArrayMembers( return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); } +/** + * Indices of the `,` separators at an array's top level — a comma inside a + * quoted member (`"$(FOO),weird"`) separates nothing. + */ +function topLevelCommas(inner /*: string */) /*: Array */ { + const out = []; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === '"') { + i = scanString(inner, i); + } else if (inner[i] === ',') { + out.push(i); + } + } + return out; +} + +/** + * The members of an array's inner text (what sits between its parens), trimmed + * and with empty slots — e.g. the one a trailing comma leaves — dropped. A bare + * scalar value parses as its own single member. + */ +function arrayMembers(inner /*: string */) /*: Array */ { + const members = []; + let start = 0; + for (const comma of topLevelCommas(inner)) { + members.push(inner.slice(start, comma)); + start = comma + 1; + } + members.push(inner.slice(start)); + return members.map(m => m.trim()).filter(m => m !== ''); +} + /** * Append raw string values to a `( … )` array build-setting (e.g. * OTHER_LDFLAGS), deduping by exact token. Creates the setting seeded with @@ -385,31 +454,57 @@ function addArrayStringValues( const field = findField(text, obj, key); if (field != null) { + const isArray = field.value.trimStart().startsWith('('); + const openParen = isArray + ? field.valueStart + field.value.indexOf('(') + : -1; + const closeParen = isArray ? scanToClose(text, openParen) : -1; + const inner = isArray ? text.slice(openParen + 1, closeParen) : field.value; // Dedup by EXACT existing member, not substring — a substring check would // treat `"-ObjC"` as already present when only `"-ObjCFoo"` is there (and - // vice-versa). Parse the current members (array `( … )` or bare scalar). - const existingMembers = new Set( - field.value - .replace(/^\s*\(/, '') - .replace(/\)\s*$/, '') - .split(',') - .map(s => s.trim()) - .filter(s => s.length > 0), - ); + // vice-versa). + const existingMembers = new Set(arrayMembers(inner)); const fresh = values.filter(v => !existingMembers.has(v)); if (fresh.length === 0) { return text; } - if (field.value.trimStart().startsWith('(')) { - // Existing array — splice fresh members before the closing `)`. - const lineStart = text.lastIndexOf('\n', field.tokenEnd - 1) + 1; - const lines = fresh.map(v => `${memberIndent}${v},\n`).join(''); - return text.slice(0, lineStart) + lines + text.slice(lineStart); + if (isArray) { + if (inner.includes('\n')) { + // Multi-line array — one member per line, before the closing `)`. + const lineStart = text.lastIndexOf('\n', closeParen) + 1; + const lines = fresh.map(v => `${memberIndent}${v},\n`).join(''); + return text.slice(0, lineStart) + lines + text.slice(lineStart); + } + // One-line array (hand-edited projects, XcodeGen, Tuist) — splice the + // members in ahead of the `)`, in the separator style already there. + // Reformatting it multi-line instead would have to record the old shape + // to stay reversible on deinit. + const commas = topLevelCommas(inner); + const gapMatch = + commas.length > 0 ? /^[\t ]*/.exec(inner.slice(commas[0] + 1)) : null; + const gap = gapMatch != null ? gapMatch[0] : ' '; + const core = inner.replace(/[\t ]+$/, ''); + const joined = fresh.join(`,${gap}`); + const insertion = + core === '' + ? joined + : core.endsWith(',') + ? `${gap}${joined},` + : `,${gap}${joined}`; + const at = openParen + 1 + core.length; + return text.slice(0, at) + insertion + text.slice(at); } - // Existing scalar — promote to an array preserving the prior value. + // Existing scalar — promote to an array preserving the prior value. Skip it + // when it IS the `"$(inherited)"` the array is seeded with (emitted twice), + // or when it is empty (a bare `,` is not a valid plist element). The seed + // test unquotes first: pbxproj accepts `$(inherited)` bare, and Xcode's own + // quoted form is the same value to the build system. + const prior = field.value.trim(); + const carriesPrior = + prior !== '' && prior.replace(/^"(.*)"$/s, '$1') !== '$(inherited)'; const replacement = arrayBlock([ '"$(inherited)"', - field.value.trim(), + ...(carriesPrior ? [prior] : []), ...fresh, ]); return ( @@ -464,7 +559,12 @@ function setScalarField( // --------------------------------------------------------------------------- // Surgical removal — the inverse of the additive helpers above. `deinit` uses // these to undo exactly what injection added, leaving every other byte (incl. -// user edits made after injection) untouched. All are pure string transforms. +// user edits made after injection) untouched. The exception is a scalar that +// injection promoted to an array: reversing that rewrites the whole field (see +// removeRecordedBuildSettings), so members added to it afterwards are lost. +// That is not deinit-only — every re-sync reverts from the recorded baseline +// before re-injecting, so an `spm update` discards them just the same. +// All are pure string transforms. // --------------------------------------------------------------------------- /** @@ -495,7 +595,8 @@ function removeObjectByUuid( * `uuids` from every `( … )` list in the file (packageReferences, * packageProductDependencies, a Frameworks phase's `files`, buildPhases, …). * Only matches member lines (trailing comma), never the object-definition line - * (which ends in `= {`), so it composes safely with removeObjectByUuid. + * (which ends in `= {`), so it composes safely with removeObjectByUuid — which + * holds because no comment can contain a comma (see commentSafe). */ function removeArrayMembersByUuid( text /*: string */, @@ -525,6 +626,33 @@ function removeField( return text.slice(0, f.matchStart) + text.slice(f.tokenEnd + 1); } +/** + * Drop one member from an array field's value text. The patterns mirror the + * forms addArrayStringValues inserts, tried in the order that makes the span + * removed exactly the span it added: a line of its own (multi-line array), then + * after a comma, before a comma, or alone ahead of the `)` (the one-line + * shapes). Each is anchored on a delimiter, so a value that is merely a prefix + * of a longer member is never mistaken for it. + */ +function removeArrayMember( + region /*: string */, + value /*: string */, +) /*: string */ { + const v = escapeRegExp(value); + for (const pattern of [ + `\\n[\\t ]*${v},`, + `,[\\t ]*${v}(?=[\\t ]*[,)])`, + `[\\t ]*${v},[\\t ]*`, + `[\\t ]*${v}(?=[\\t ]*\\))`, + ]) { + const shorter = region.replace(new RegExp(pattern), ''); + if (shorter !== region) { + return shorter; + } + } + return region; +} + /** * Remove specific raw string members from an existing `( … )` array field * (inverse of addArrayStringValues' append branch). Leaves the field and any @@ -542,7 +670,7 @@ function removeArrayStringValues( } let region = text.slice(f.valueStart, f.tokenEnd); for (const val of values) { - region = region.replace(new RegExp(`\\n[\\t ]*${escapeRegExp(val)},`), ''); + region = removeArrayMember(region, val); } return text.slice(0, f.valueStart) + region + text.slice(f.tokenEnd); } @@ -628,6 +756,8 @@ module.exports = { namespacedUUID, serializeEntry, quoteIfNeeded, + commentSafe, + uuidComment, // Surgical-edit toolkit (in-place injection): scanString, scanToClose, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index cc30c8823b65..849fecff5637 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -16,6 +16,9 @@ export type SetupArgs = { // `debug/` and `release/` cache slots, each with artifacts.json. artifacts: string | null, skipCodegen: boolean, + // Overrides the autolinking config command; also settable via + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND. + configCommand: Array | null, // Artifact download policy: 'auto' fetches when missing, 'skip' never // fetches, 'force' clears the cache slot and re-downloads. downloadPolicy: 'auto' | 'skip' | 'force', @@ -165,7 +168,6 @@ export type SpmModuleConfig = { name: string, path: string, exclude?: Array, - publicHeadersPath?: ?string, // Optional CocoaPods-style glob allowlist (analog of s.source_files). // When set, replaces auto source discovery for the module — only files // matching one of these patterns are passed to SPM via `sources:`. @@ -216,6 +218,21 @@ export type PluginProductDep = {name: string, package: string}; // in the codegen package so it compiles. export type PluginGeneratedSource = {path: string}; +// A build-time shell phase for the app target — SwiftPM's missing analog of +// CocoaPods' `script_phase`. `id` is the stable ledger key and deterministic +// UUID seed (charset /^[@A-Za-z0-9_./-]+$/, so a scoped npm name works; `:` is +// excluded because the seed is `plugin:`); `position` is normalized to 'end' +// by invokePlugins so consumers never re-derive the default. +export type PluginScriptPhase = { + id: string, + name: string, + script: string, + position: 'beforeCompile' | 'end', + inputPaths?: Array, + outputPaths?: Array, + alwaysOutOfDate?: boolean, +}; + // A plugin-declared dynamic XCFramework pair. RN validates both paths, stages // immutable app-local slots, and links/embeds the selected framework outside // SwiftPM. @@ -299,6 +316,10 @@ export type PluginResult = { // for staleness, e.g. the plugin dep's own `Package.swift` and per-module // manifests. Folded into `.spm-sync-watch-paths` by main(). watchPaths: Array, + // Build-time shell phases for the app target, recorded to + // `.spm-plugin-script-phases.json` by main() and injected by `spm add`/ + // `update`. + scriptPhases: Array, }; export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ @@ -307,6 +328,11 @@ export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ generatedSources?: Array, flavoredFrameworks?: Array, watchPaths?: Array, + // `position` may be omitted here; invokePlugins normalizes it to 'end'. + scriptPhases?: Array<{ + ...PluginScriptPhase, + position?: 'beforeCompile' | 'end', + }>, }; export type DiscoveredPlugin = { @@ -455,10 +481,13 @@ export type SpmScaffoldSpec = { // Bucketed dependency references — pre-computed by the translation layer. // `coreReactNative` is true when ANY React-* / RCT* / RCT-Folly / glog // dep is present (so we add React's invariant header products). - // `siblingNames` are npm names that match other autolinked deps — resolved - // to Swift names by the scaffold orchestrator before emit. + // `siblingNames` are npm names that match other autolinked deps. + // `siblingSwiftNames` carries each one's resolved Swift name (honoring the + // sibling's `spm.name`); a sibling absent from it falls back to + // toSwiftName(npmName) at emit time. coreReactNative: boolean, siblingNames: Array, + siblingSwiftNames?: {[npmName: string]: string}, // Extra frameworks beyond the autolinker's default UIKit/Foundation/CoreGraphics // set. Merged with the defaults at emit time. extraFrameworks: Array, diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index e01afe05c368..938cfe5fae6e 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -14,6 +14,67 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +// The package and product names React Native's own generated manifests use. A +// dependency whose Swift name lands on one of them surfaces as a duplicate-name +// error from inside SPM's resolution, far from the react-native.config.js that +// caused it, so the autolinker rejects the collision up front instead. +// +// These cover the emitters in this directory and the collision guard. Adding a +// React Native SPM product also touches, depending on what the product is: +// - scripts/codegen/templates/Package.swift.spm-template — if the per-app +// codegen package must depend on it (a static Swift file, patched by +// string replacement, not generated from these constants) +// - flavored-frameworks.js INVARIANT_BINARY_TARGETS — if it is +// xcframework-backed +// - download-spm-artifacts.js REQUIRED_ARTIFACTS — if it ships as its own +// downloadable artifact (that list names ARTIFACTS, which overlap with +// product names without being the same set) +const REACT_NATIVE_PACKAGE_NAME /*: string */ = 'ReactNative'; +const REACT_CODEGEN_PACKAGE_NAME /*: string */ = 'React-GeneratedCode'; +// The autolinking aggregator package, whose single product shares its name. +const AUTOLINKED_PACKAGE_NAME /*: string */ = 'Autolinked'; +// React Native's products by KIND, so no consumer has to infer the kind from a +// position in a flat list. The umbrella is a Clang target over the staged +// headers that re-exports the pure-RN namespaces; the rest are each backed by +// an xcframework of the same name. +const REACT_NATIVE_UMBRELLA_PRODUCT /*: string */ = 'ReactHeaders'; +const REACT_NATIVE_HEADERS_PRODUCT /*: string */ = 'ReactNativeHeaders'; +const REACT_NATIVE_DEPENDENCIES_HEADERS_PRODUCT /*: string */ = + 'ReactNativeDependenciesHeaders'; +const REACT_NATIVE_XCFRAMEWORK_PRODUCTS /*: ReadonlyArray */ = + Object.freeze([ + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_DEPENDENCIES_HEADERS_PRODUCT, + ]); +// Derived, so a new product cannot be added without choosing a kind above. +const REACT_NATIVE_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_UMBRELLA_PRODUCT, + ...REACT_NATIVE_XCFRAMEWORK_PRODUCTS, +]); +// The codegen package product every autolinked target depends on for the app's +// generated headers. +const REACT_CODEGEN_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + 'ReactAppHeaders', +]); +// The codegen package products the APP target links, declared by the static +// template rather than by any emitter here. +const REACT_CODEGEN_APP_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + 'ReactCodegen', + 'ReactAppDependencyProvider', +]); +const REACT_HEADERS_TARGET_DIR /*: string */ = 'ReactHeadersTarget'; +// Target names only have to be unique within their own package, so +// REACT_HEADERS_TARGET_DIR is deliberately absent: a dependency named after it +// collides with nothing. +const RESERVED_SWIFT_NAMES /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + ...REACT_NATIVE_PRODUCTS, + ...REACT_CODEGEN_PRODUCTS, + ...REACT_CODEGEN_APP_PRODUCTS, +]); + /** * Creates a logger trio {log, warn, die} that prefixes messages with [name]. * log – green prefix, writes to stdout @@ -585,10 +646,10 @@ function installSpmCodegenTemplate( if (remote != null) { content = content .replace( - '.package(name: "ReactNative", path: "../../xcframeworks"),', + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "../../xcframeworks"),`, `.package(url: "${remote.url}", exact: "${remote.version}"),`, ) - .split('package: "ReactNative")') + .split(`package: "${REACT_NATIVE_PACKAGE_NAME}")`) .join(`package: "${remote.identity}")`); } fs.writeFileSync(codegenPkgSwift, content, 'utf8'); @@ -627,7 +688,59 @@ function runCodegenAndInstallTemplate( } } +// --------------------------------------------------------------------------- +// Autolinking-plugin script phases — the shared validation rules. Two gates +// apply them with different POLICIES (invokePlugins throws, the injector's +// sidecar reader skips the entry), so only the rules live here. See +// __docs__/spm-autolinking-plugins.md for the reasoning. +// --------------------------------------------------------------------------- + +// `@` and `/` are admitted so a package can use its own scoped npm name +// (`@expo/log-box`). `:` is not: the id is hashed into the UUID seed as +// `plugin:`, and keeping the separator out of the id keeps that seed +// unambiguous. +const SCRIPT_PHASE_ID_PATTERN = /^[@A-Za-z0-9_./-]+$/; + +// `ledger.__proto__ = uuid` sets the prototype instead of an own property, so a +// phase with one of these ids would look recorded, vanish through +// JSON.stringify, and never be removable by `deinit`. +const RESERVED_SCRIPT_PHASE_IDS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + +function isValidScriptPhaseId(value /*: unknown */) /*: boolean */ { + return ( + typeof value === 'string' && + SCRIPT_PHASE_ID_PATTERN.test(value) && + !RESERVED_SCRIPT_PHASE_IDS.has(value) + ); +} + +/** + * The name reaches the project file twice: verbatim in the escaped `name` field + * Xcode displays, and normalized (spm-pbxproj's commentSafe) in the cosmetic + * `/* … *​/` comments. Both are safe for any single-line string, so the only + * thing left to refuse is a line break — which no Xcode phase display name can + * carry anyway. + */ +function isValidScriptPhaseName(value /*: unknown */) /*: boolean */ { + return typeof value === 'string' && value.length > 0 && !/[\r\n]/.test(value); +} + module.exports = { + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + REACT_NATIVE_PRODUCTS, + REACT_CODEGEN_PRODUCTS, + REACT_CODEGEN_APP_PRODUCTS, + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, makeLogger, displayPath, sharedCacheDir, @@ -644,5 +757,7 @@ module.exports = { RemoteVersionError, installSpmCodegenTemplate, runCodegenAndInstallTemplate, + isValidScriptPhaseId, + isValidScriptPhaseName, SCAFFOLDER_MARKER, }; diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index 7ba17f98b836..d5f5eeed620c 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -369,7 +369,7 @@ describe('Native Animated', () => { const value = new Animated.Value(0); value.__makeNative(); const listener = jest.fn(); - const event = Animated.event([{nativeEvent: {foo: value}}], { + const event = new Animated.Event([{nativeEvent: {foo: value}}], { useNativeDriver: true, listener, }); diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js index d0757fcdba75..63770292a848 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js @@ -20,13 +20,14 @@ import TextInputState from '../../../../../../Libraries/Components/TextInput/Tex import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {createRef} from 'react'; -import {ScrollView, Text, TextInput, View} from 'react-native'; +import {Modal, ScrollView, Text, TextInput, View} from 'react-native'; import { NativeText, NativeVirtualText, } from 'react-native/Libraries/Text/TextNativeComponent'; import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; import Event from 'react-native/src/private/webapis/dom/events/Event'; +import ReactNativeDocument from 'react-native/src/private/webapis/dom/nodes/ReactNativeDocument'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement'; import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; @@ -398,6 +399,47 @@ describe('ReactNativeElement', () => { expect(childNodeC.parentNode).toBe(null); expect(childNodeC.parentElement).toBe(null); }); + + it('returns the containing element as the parent of a modal host view, not the document', () => { + const parentRef = createRef(); + const modalRef = createRef(); + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const parentNode = ensureReactNativeElement(parentRef.current); + const modalNode = ensureReactNativeElement(modalRef.current); + const document = ensureInstance( + parentNode.ownerDocument, + ReactNativeDocument, + ); + + // Capture the relations before tearing down, so cleanup runs even if + // the assertions below fail. + const modalParentNode = modalNode.parentNode; + const modalParentElement = modalNode.parentElement; + + // Unmount and drain the queue so the modal's AppContainer passive + // effects (in __DEV__) don't trip the global "MessageQueue is not + // empty" validation hook. + root.destroy(); + Fantom.runWorkLoop(); + + // The host view is a root-kind shadow node, but its parent + // must still be its actual containing element, NOT the document. + // Two-phase event propagation (e.g. focus/blur bubbling to ancestors + // rendered above the modal) walks this parent chain, so returning the + // document here silently severs bubbling at the modal boundary. + expect(modalParentNode).toBe(parentNode); + expect(modalParentElement).toBe(parentNode); + expect(modalParentNode).not.toBe(document); + }); }); describe('compareDocumentPosition / contains', () => { diff --git a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm index 8f023f21f075..1d366e828d93 100644 --- a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm +++ b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm @@ -13,7 +13,11 @@ #import #import +#if __has_include() +#import +#else #import "RCTFabricComponentsPlugins.h" +#endif using namespace facebook::react; diff --git a/packages/rn-tester/react-native.config.js b/packages/rn-tester/react-native.config.js index c63fd9e7127c..7d9058c4686d 100644 --- a/packages/rn-tester/react-native.config.js +++ b/packages/rn-tester/react-native.config.js @@ -36,7 +36,6 @@ module.exports = { { name: 'ReactCommonSamples', path: '../react-native/ReactCommon/react/nativemodule/samples/platform/ios', - publicHeadersPath: '.', }, { name: 'ReactRCTPushNotification', diff --git a/private/core-cli-utils/src/private/app.js b/private/core-cli-utils/src/private/app.js index 21e9ea297dcc..50733a64a08b 100644 --- a/private/core-cli-utils/src/private/app.js +++ b/private/core-cli-utils/src/private/app.js @@ -187,7 +187,7 @@ const bundleApp = ( 'Check if SourceMap script available', () => { composeSourceMaps = getNodePackagePath( - 'react-native/scripts/compose-source-maps.js', + 'react-native/scripts/compose-source-maps', ); }, ); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 78719f779281..51245baacdf0 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -11326,11 +11326,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -11423,12 +11429,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index d395957f60b8..70ff9edf923a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -10946,11 +10946,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -11043,12 +11049,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 3fd714c64445..409733580c4e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -11176,11 +11176,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -11273,12 +11279,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 4b13dfdae6f7..e08e639a382e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -13193,11 +13193,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -13290,12 +13296,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 19d442c50c79..34d05dd76833 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -12875,11 +12875,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -12972,12 +12978,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 556c74b08116..da4483867b58 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -13053,11 +13053,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -13150,12 +13156,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index ddd9a6db9130..d49eb20e98c6 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -8318,11 +8318,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -8415,12 +8421,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index e1b04d6e3dd0..f0182c4a07a4 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -8143,11 +8143,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -8240,12 +8246,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 254a75c2d6bc..7154c5ebdba5 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -8309,11 +8309,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -8406,12 +8412,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index c2dbc9b31869..5ae6c19cf733 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -263,7 +263,7 @@ async function rewriteLocalImports( async function getProcessedSnapshotResult( tempDirectory: string, options: BuildApiSnapshotOptions, - packages: $ReadOnlyArray<{directory: string, name: string}>, + packages: ReadonlyArray<{directory: string, name: string}>, ): Promise { const rollupPath = path.join( tempDirectory, diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js index c77c99bb3fea..f673e9273f6e 100644 --- a/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js @@ -22,11 +22,19 @@ async function exportsAfterPipeline(code: string) { return ast.program.body .filter(node => node.type === 'ExportNamedDeclaration') .flatMap(node => node.specifiers) - .map(specifier => ({ - local: specifier.local.name, - public: specifier.exported.name, - comment: specifier.trailingComments?.[0]?.value, - })); + .map(specifier => { + if ( + specifier?.type !== 'ExportSpecifier' || + specifier.exported.type !== 'Identifier' + ) { + throw new Error('Expected a named export with an identifier alias'); + } + return { + local: specifier.local.name, + public: specifier.exported.name, + comment: specifier.trailingComments?.[0]?.value, + }; + }); } describe('organizeDeclarations and versionExportedApis pipeline', () => { diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/ensureUndefinedOnOptionalMembers-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/ensureUndefinedOnOptionalMembers-test.js new file mode 100644 index 000000000000..2214361da192 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/ensureUndefinedOnOptionalMembers-test.js @@ -0,0 +1,65 @@ +/** + * 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 + */ + +const ensureUndefinedOnOptionalMembersVisitor = require('../ensureUndefinedOnOptionalMembers.js'); +const babel = require('@babel/core'); + +async function translate(code: string): Promise { + const result = await babel.transformAsync(code, { + plugins: [ + '@babel/plugin-syntax-typescript', + ensureUndefinedOnOptionalMembersVisitor, + ], + }); + + return result.code; +} + +describe('ensureUndefinedOnOptionalMembers', () => { + test('should add undefined to optional type members', async () => { + const code = ` + type Foo = { + a?: number, + 'b-key'?: number | string, + c?: () => void, + d: boolean, + e: string | undefined, + f?: (() => void) | number, + }; + `; + const result = await translate(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + a?: number | undefined; + 'b-key'?: number | string | undefined; + c?: (() => void) | undefined; + d: boolean; + e: string | undefined; + f?: (() => void) | number | undefined; + };" + `); + }); + + test('should not add undefined when already present', async () => { + const code = ` + type Foo = { + a?: number | undefined, + b?: undefined, + }; + `; + const result = await translate(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + a?: number | undefined; + b?: undefined; + };" + `); + }); +}); diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js index ab449e877b5a..e678880442b9 100644 --- a/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js @@ -42,4 +42,20 @@ describe('removeUndefinedFromOptionalMembers', () => { };" `); }); + + test('should unwrap the lone remaining constituent, dropping redundant parens', async () => { + const code = ` + type Foo = { + a?: (() => void) | undefined, + b?: number | string | undefined, + }; + `; + const result = await translate(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + a?: () => void; + b?: number | string; + };" + `); + }); }); diff --git a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js index 9c97ce7f8118..2a71455d03f7 100644 --- a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js +++ b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js @@ -18,7 +18,7 @@ import type { function canonicalizeSource( source: string, - packageNames: $ReadOnlyArray, + packageNames: ReadonlyArray, ): string { if (!source.startsWith('./') && !source.startsWith('../')) { return source; @@ -37,8 +37,8 @@ function canonicalizeSource( } function canonicalizeLocalPackageImports( - packageNames: $ReadOnlyArray, -): PluginObj { + packageNames: ReadonlyArray, +): PluginObj { function canonicalizeNodeSource( nodePath: NodePath< ExportAllDeclaration | ExportNamedDeclaration | ImportDeclaration, diff --git a/scripts/js-api/build-types/transforms/typescript/ensureUndefinedOnOptionalMembers.js b/scripts/js-api/build-types/transforms/typescript/ensureUndefinedOnOptionalMembers.js new file mode 100644 index 000000000000..c1cf97ae2901 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/ensureUndefinedOnOptionalMembers.js @@ -0,0 +1,71 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {PluginObj} from '@babel/core'; + +import * as t from '@babel/types'; + +/** + * Ensures optional object members carry an explicit `| undefined`, e.g. + * `foo?: number` becomes `foo?: number | undefined` (members that already + * include `undefined` are left unchanged). + * + * This is the format required by TypeScript consumers building with + * `exactOptionalPropertyTypes`, under which `foo?: T` and `foo?: T | undefined` + * are distinct. It applies to the per-module generated types; the API snapshot + * pipeline still strips it back via `removeUndefinedFromOptionalMembers`. + * + * This lives in React Native as a stopgap: flow-api-translator does not yet emit + * this format, and the generated types cannot wait for its next release. + * + * The API snapshot resets this via `removeUndefinedFromOptionalMembers`, which + * also unwraps the resulting single-constituent unions so function-valued + * members remain bare function types for `sortProperties` to group. + */ +const visitor: PluginObj = { + visitor: { + TSPropertySignature(path) { + if (path.node.optional !== true) { + return; + } + + const typeAnnotation = path.node.typeAnnotation; + if (!typeAnnotation || !t.isTSTypeAnnotation(typeAnnotation)) { + return; + } + + const actualTypeAnnotation = typeAnnotation.typeAnnotation; + + if (t.isTSUndefinedKeyword(actualTypeAnnotation)) { + return; + } + + if (t.isTSUnionType(actualTypeAnnotation)) { + if ( + actualTypeAnnotation.types.some(type => t.isTSUndefinedKeyword(type)) + ) { + return; + } + typeAnnotation.typeAnnotation = t.tsUnionType([ + ...actualTypeAnnotation.types, + t.tsUndefinedKeyword(), + ]); + return; + } + + typeAnnotation.typeAnnotation = t.tsUnionType([ + actualTypeAnnotation, + t.tsUndefinedKeyword(), + ]); + }, + }, +}; + +module.exports = visitor; diff --git a/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js b/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js index 195ec9262d85..d4318882cc18 100644 --- a/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js +++ b/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js @@ -29,11 +29,27 @@ const visitor: PluginObj = { return; } - const newTypeAnnotation = t.cloneDeep(actualTypeAnnotation); - newTypeAnnotation.types = newTypeAnnotation.types.filter( + const remainingTypes = actualTypeAnnotation.types.filter( type => t.isTSUndefinedKeyword(type) === false, ); - typeAnnotation.typeAnnotation = newTypeAnnotation; + if (remainingTypes.length === actualTypeAnnotation.types.length) { + return; + } + + // Unwrap to the bare type when a single constituent remains, otherwise a + // wrapper node survives that prints identically but breaks downstream + // transforms (e.g. `sortProperties`) that key on the node type. The lone + // constituent may be parenthesized (a function type inside a union needs + // parens, e.g. `(() => void) | undefined`); those parens are redundant + // once it is no longer part of a union. + if (remainingTypes.length === 1) { + const [remaining] = remainingTypes; + typeAnnotation.typeAnnotation = t.isTSParenthesizedType(remaining) + ? remaining.typeAnnotation + : remaining; + } else { + typeAnnotation.typeAnnotation = t.tsUnionType(remainingTypes); + } }, }, }; diff --git a/scripts/js-api/build-types/translateSourceFile.js b/scripts/js-api/build-types/translateSourceFile.js index 94ec10e915b7..089ed11d6f2e 100644 --- a/scripts/js-api/build-types/translateSourceFile.js +++ b/scripts/js-api/build-types/translateSourceFile.js @@ -32,6 +32,7 @@ const preTransforms: Array = [ ]; const postTransforms = (filePath: string): Array> => [ require('./transforms/typescript/convertTypeAliasesToInterfaces'), + require('./transforms/typescript/ensureUndefinedOnOptionalMembers'), require('./transforms/typescript/replaceProtectedConstructors'), require('./transforms/typescript/replaceDefaultExportName')(filePath), ];