From f407034625cc5b074bf0a840d57b07be861179de Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 22 Oct 2026 04:23:06 -0700 Subject: [PATCH] Compile React Native C/C++ at -O2 on iOS to match Android (#59226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: iOS Release builds compile React Native's C, C++ and Objective-C code with `-Os` (optimize for size). React Native never sets `GCC_OPTIMIZATION_LEVEL`, so Xcode's default applies. Android compiles the same code with `-O2` (optimize for speed) in Release. Yoga even sets `-O2` explicitly ("prioritize speed", `ReactCommon/yoga/cmake/project-defaults.cmake`). This PR sets `GCC_OPTIMIZATION_LEVEL = 2` for Release builds of React Native's own code on iOS, to match Android. Android also builds Yoga and glog at `-O2` in Debug builds, so this PR does the same on iOS. All other code keeps `-O0` in Debug. Hermes keeps its size-optimized `MinSizeRel` build, as on Android. Swift code is not affected. In a layout benchmark on Apple Silicon, a Yoga layout takes 21–54% less time at `-O2` than at `-Os` in Release. In Debug it takes about 94% less time at `-O2` than at `-O0` (16–19 times faster). The stripped React binary grows by 6.2%. After this PR, every library that both platforms compile uses the same optimization level: React Native core, Yoga, folly, glog, fmt, boost and double-conversion. Two differences stay out of scope: Android also uses link-time optimization, and Android turns off Yoga's assertions in Debug builds. I found no commit that chose `-Os` for iOS on purpose. The earlier optimization decisions were all for Android: `c04ab921924` (2016, "Use -O3 to compile CSSLayout in open source … This is how we compile internally") and https://github.com/react/react-native/issues/37371 (2023, Yoga's reference CMake build at `-O2`). The question came up in the community. A user profiling Bluesky found Yoga about 20% faster at `-O2` and layout 2–5% faster in the full app: https://github.com/bluesky-social/social-app/pull/12061. Their app-level workaround is https://github.com/expo/expo/pull/51770. An app-level setting cannot reach the prebuilt React Native frameworks, so the fix belongs here. ### What changes | Build path | Change | |---|---| | Prebuilt `React.xcframework` (`packages/react-native/scripts/ios-prebuild/build.js`) | Adds `GCC_OPTIMIZATION_LEVEL=2` to the `xcodebuild` command for the Release flavor only. | | Prebuilt `ReactNativeDependencies.xcframework` (`scripts/releases/ios-prebuild/build.js`) | Same, for folly, glog, fmt, boost, double-conversion, fast_float and SocketRocket. | | CI cache (`.github/workflows/prebuild-ios-dependencies.yml`) | The slice and xcframework cache keys now include `scripts/releases/ios-prebuild/build.js`. Without this, CI reuses the cached `-Os` build. | | Yoga in `React.xcframework` (`packages/react-native/Package.swift`) | The `yoga` target gets `-O2` in every configuration, as on Android (`ReactCommon/yoga/CMakeLists.txt`). | | glog in `ReactNativeDependencies.xcframework` (`scripts/releases/ios-prebuild/configuration.js`) | glog gets `-O2` in every configuration, as on Android (`ReactAndroid/src/main/jni/third-party/glog/CMakeLists.txt`). | | CocoaPods builds from source (`react_native_post_install`) | New step `set_optimization_level_for_react_native_pods`. It sets level 2 on Release-type configurations of React Native pods, and on every configuration of `Yoga` and `glog`. | | SwiftPM codegen package (`packages/react-native/scripts/codegen/templates/Package.swift.spm-template`) | `ReactCodegen` and `ReactAppDependencyProvider` get `-O2` in Release through `unsafeFlags`, as the CocoaPods generated pods do. SwiftPM allows `unsafeFlags` here because the app always uses this package from a local path. | The command-line setting applies to every configuration, so the prebuild scripts add it only when they build Release. The per-target `-O2` for Yoga and glog comes after the configuration's `-O0` on the compiler command line, so it wins in Debug. In CocoaPods, a React Native pod is one of these: - a local pod inside the `react-native` package - one of React Native's third-party dependency pods (`RCT-Folly`, `glog`, `boost`, `DoubleConversion`, `fmt`, `fast_float`, `SocketRocket`) - one of the generated pods `ReactCodegen` and `ReactAppDependencyProvider` Community library pods, the app's own pods and `hermes-engine` do not change. Release-type configurations are found by `config.type == :release`, as `add_ndebug_flag_to_pods_in_release` already does, so custom configurations such as "Staging" are included. An app that wants the old behaviour can set `GCC_OPTIMIZATION_LEVEL` back to `s` in its own `post_install` after `react_native_post_install`. ### Benchmark: layout speed Yoga built twice from this branch with Apple clang 21.0.0 (`-std=c++20 -DNDEBUG -arch arm64`); only the optimization level differs. The benchmark harness is the same object file in both builds. Every iteration forces a full relayout (root width alternates 390/391). There were 10 runs of each build, alternating. Both builds produce identical layout results. Machine: Apple M4 Max, macOS 27.0.1. This is a Mac, not an iPhone, and the trees are synthetic. | Scenario | `-Os` (µs per layout, median) | `-O2` (µs per layout, median) | Time reduction | |---|---|---|---| | Feed: 500 rows, each with an avatar, 3 measured text nodes and 4 buttons | 2,879 | 2,261 | 21% | | Deep: 15 levels, mixed row/column, padding, margin, percent widths | 1,098 | 805 | 27% | | Grid: 1,000 wrapped items with `aspectRatio` | 1,018 | 472 | 54% | "Time reduction" = 1 − median(`-O2`) / median(`-Os`). The spread between the fastest and slowest of the 10 runs was 3–11%. Debug builds, same harness, built without `NDEBUG` so Yoga's assertions stay on, as in an iOS Debug build. Median of 3 alternating runs: | Scenario | `-O0` (µs per layout) | `-O2` (µs per layout) | Time reduction | |---|---|---|---| | Feed | 36,350 | 2,310 | 94% (15.7× faster) | | Deep | 15,184 | 816 | 95% (18.6× faster) | | Grid | 8,190 | 486 | 94% (16.9× faster) | ### Benchmark: binary size Release, `ios-arm64` device slice. Sizes are uncompressed; App Store download size will differ. | Binary | `-Os` (bytes) | `-O2` (bytes) | Difference | |---|---|---|---| | `React.framework/React`, stripped (`strip -x`) | 6,154,912 | 6,537,576 | +382,664 (+6.2%) | | `React.framework/React`, `__TEXT` segment | 4,931,584 | 5,324,800 | +393,216 (+8.0%) | | `ReactNativeDependencies.framework`, stripped | 778,240 | 873,296 | +95,056 (+12.2%) | | Yoga only, `__text` section | 77,140 | 112,188 | +35,048 (+45%) | The unstripped React binary gets *smaller* (10,785,672 → 10,335,408 bytes). More inlining at `-O2` leaves about 4,950 fewer symbols, so the link-edit data shrinks more than the code grows. The stripped size is the relevant number. If only Yoga were moved to `-O2`, the stripped React binary would grow by 32,776 bytes (+0.53%). ## Changelog: [IOS] [CHANGED] - Compile React Native's C/C++/Objective-C code with `-O2` instead of `-Os` in Release, and Yoga and glog with `-O2` in all builds, to match Android Pull Request resolved: https://github.com/react/react-native/pull/59226 Test Plan: **Unit tests.** New Jest tests check that the Release command has `GCC_OPTIMIZATION_LEVEL=2`, the Debug command does not, and the rest of each command is unchanged. They failed before the change and pass after it. ``` yarn jest packages/react-native/scripts/ios-prebuild/__tests__/build-test.js scripts/releases/ios-prebuild/__tests__/build-test.js Tests: 8 passed, 8 total ``` A new Jest test checks that the SwiftPM codegen template gives `ReactCodegen` and `ReactAppDependencyProvider` the Release-only `-O2` flag in both `cSettings` and `cxxSettings`, and that `ReactAppHeaders` does not get it. It failed before the change and passes after it. ``` yarn jest packages/react-native/scripts/spm/__tests__/codegen-template-optimization-test.js Tests: 5 passed, 5 total ``` All tests in `packages/react-native/scripts/ios-prebuild` and `scripts/releases`: 24 suites, 196 tests passed. All tests in `packages/react-native/scripts/spm`: 23 suites, 1060 tests passed. ESLint, Prettier and Flow report no errors on the changed files. **Prebuilt frameworks.** I built `React.xcframework` (`node scripts/ios-prebuild --setup --build --compose --platforms ios --flavor `) and `ReactNativeDependencies.xcframework` (`node scripts/releases/prepare-ios-prebuilds.js --setup --build --compose --platforms ios --configuration `), then read the last `-O` flag in each target's compiler argument file: | Build | React Native code | Yoga | glog | Other dependencies | |---|---|---|---|---| | Release, before this PR | `-Os` | `-Os` | `-Os` | `-Os` | | Release, with this PR | `-O2` | `-O2` | `-O2` | `-O2` | | Debug, with this PR | `-O0` | `-O2` | `-O2` | `-O0` | **CocoaPods.** `RCT_USE_PREBUILT_RNCORE=0 RCT_USE_RN_DEP=0 bundle exec pod install` in `packages/rn-tester`, then read `Pods.xcodeproj`: - `Yoga` and `glog`: `GCC_OPTIMIZATION_LEVEL = 2` in Debug and Release. - Other React Native targets (`React-*`, `RCT-Folly`, `ReactCodegen`, `SocketRocket`, …): 2 in Release, unset in Debug. - RNTester's own pods (`ScreenshotManager`, `MyNativeView`, `NativeCxxModuleExample`, …) and the app targets: unset in both configurations. **SwiftPM.** In `private/helloworld`: `node ../../packages/react-native/cli.js spm add --deintegrate --version nightly`, then `xcodebuild -configuration Release -sdk iphonesimulator build`. The build succeeded, and Xcode accepted `unsafeFlags` in the generated package. The `-O` flags in each target's compiler argument file: - `ReactCodegen` and `ReactAppDependencyProvider`, Release: `-Os`, then `-O2`. The last flag wins. - The app target and `ReactAppHeaders`, Release: `-Os` only. - `ReactCodegen` and `ReactAppDependencyProvider`, Debug: `-O0` only. **Not tested:** - `pod install` in prebuilt mode. No prebuilt frameworks exist for the development version, so it fell back to source. In prebuilt mode the React pods contain only binaries, so the setting has no effect there. - Layout speed on a physical iPhone, and in RNTester. - The Ruby CocoaPods unit tests, which are not run in CI. Reviewed By: andrewdacenko Differential Revision: D126452701 Pulled By: cipolleschi fbshipit-source-id: d1b4296200a37300f0d6134cc8f922b4c3754c61 (cherry picked from commit 60c817e9256abaea212ebd826d18043d4f1c5109) Conflict resolution for 0.88-stable: added only the -O2 flags, without the main-only Yoga excludedPaths and codegen RN_ALLOW_FRAMEWORKS define. --- .../workflows/prebuild-ios-dependencies.yml | 8 +- packages/react-native/Package.swift | 4 +- .../react-native/scripts/cocoapods/utils.rb | 51 ++++++++++++ .../templates/Package.swift.spm-template | 15 +++- .../ios-prebuild/__tests__/build-test.js | 41 ++++++++++ .../scripts/ios-prebuild/build.js | 27 ++++++- .../react-native/scripts/react_native_pods.rb | 1 + .../codegen-template-optimization-test.js | 81 +++++++++++++++++++ .../ios-prebuild/__tests__/build-test.js | 55 +++++++++++++ scripts/releases/ios-prebuild/build.js | 25 +++++- .../releases/ios-prebuild/configuration.js | 9 ++- 11 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/build-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/codegen-template-optimization-test.js create mode 100644 scripts/releases/ios-prebuild/__tests__/build-test.js diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index b34fcf203eb7..0f96ceac6ed8 100644 --- a/.github/workflows/prebuild-ios-dependencies.yml +++ b/.github/workflows/prebuild-ios-dependencies.yml @@ -72,7 +72,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/third-party/.build/Build/Products - key: v3-ios-dependencies-slice-folder-${{ matrix.slice }}-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }} + key: v3-ios-dependencies-slice-folder-${{ matrix.slice }}-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/build.js') }} - name: Setup xcode if: steps.restore-slice-folder.outputs.cache-hit != 'true' uses: ./.github/actions/setup-xcode @@ -101,7 +101,7 @@ jobs: uses: actions/cache/save@v5 if: ${{ github.ref == 'refs/heads/main' }} # To avoid that the cache explode with: - key: v3-ios-dependencies-slice-folder-${{ matrix.slice }}-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }} + key: v3-ios-dependencies-slice-folder-${{ matrix.slice }}-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/build.js') }} enableCrossOsArchive: true path: | packages/react-native/third-party/.build/Build/Products @@ -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: v5-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/build.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' @@ -204,4 +204,4 @@ jobs: 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: v5-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/build.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/packages/react-native/Package.swift b/packages/react-native/Package.swift index 25a126e0cce7..c42140033ed5 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -70,7 +70,9 @@ let rctDeprecation = RNTarget( let yoga = RNTarget( name: .yoga, path: "ReactCommon/yoga", - publicHeadersPath: "yoga" + publicHeadersPath: "yoga", + // Android builds Yoga at -O2 in every build type, Debug included, so match it. + defines: [.unsafeFlags(["-O2"])] ) // React-oscompat.podspec diff --git a/packages/react-native/scripts/cocoapods/utils.rb b/packages/react-native/scripts/cocoapods/utils.rb index a31cab7442f6..d60c24b2d2c1 100644 --- a/packages/react-native/scripts/cocoapods/utils.rb +++ b/packages/react-native/scripts/cocoapods/utils.rb @@ -784,6 +784,57 @@ def self.add_ndebug_flag_to_pods_in_release(installer) end end + REACT_NATIVE_THIRD_PARTY_PODS = [ + 'RCT-Folly', + 'glog', + 'boost', + 'DoubleConversion', + 'fmt', + 'fast_float', + 'SocketRocket', + ].freeze + + # Generated by React Native into the app's build folder, so not under react_native_path. + REACT_NATIVE_GENERATED_PODS = [ + 'ReactCodegen', + 'ReactAppDependencyProvider', + ].freeze + + ALWAYS_OPTIMIZED_PODS = [ + 'Yoga', + 'glog', + ].freeze + + # Xcode builds Release at -Os by default. Android builds React Native and its + # third-party dependencies at -O2 in Release, and Yoga and glog at -O2 in Debug + # too, so match it. Hermes keeps its own MinSizeRel build on both platforms, and + # pods outside React Native are left alone. + def self.set_optimization_level_for_react_native_pods(installer, react_native_path) + Pod::UI.puts("Setting GCC_OPTIMIZATION_LEVEL to 2 for React Native pods in Release, and for #{ALWAYS_OPTIMIZED_PODS.join(', ')} in all configurations") + rn_real_path = File.realpath(File.expand_path(react_native_path, Pod::Config.instance.installation_root.to_s)) + + installer.target_installation_results.pod_target_installation_results.each do |_, target_installation_result| + pod_name = target_installation_result.target.pod_name + next unless self.react_native_pod?(installer.sandbox, pod_name, rn_real_path) + + always_optimized = ALWAYS_OPTIMIZED_PODS.include?(pod_name) + target_installation_result.native_target.build_configurations.each do |config| + next unless always_optimized || config.type == :release + config.build_settings['GCC_OPTIMIZATION_LEVEL'] = '2' + end + end + end + + def self.react_native_pod?(sandbox, pod_name, rn_real_path) + return false if pod_name == 'hermes-engine' + return true if REACT_NATIVE_THIRD_PARTY_PODS.include?(pod_name) + return true if REACT_NATIVE_GENERATED_PODS.include?(pod_name) + return false unless sandbox.local?(pod_name) + + pod_real_path = File.realpath(sandbox.pod_dir(pod_name).to_s) + pod_real_path == rn_real_path || pod_real_path.start_with?(rn_real_path + File::SEPARATOR) + end + def self.add_flag_to_map_with_inheritance(map, field, flag) if map[field] == nil map[field] = "$(inherited)" + flag diff --git a/packages/react-native/scripts/codegen/templates/Package.swift.spm-template b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template index 97cdb78fd54f..f9c91805f099 100644 --- a/packages/react-native/scripts/codegen/templates/Package.swift.spm-template +++ b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template @@ -50,7 +50,12 @@ let package = Package( path: "ReactCodegen", exclude: ["ReactCodegen.podspec"], publicHeadersPath: ".", - cSettings: [.headerSearchPath("headers")], + // Release builds at -O2 to match CocoaPods and Android; Xcode defaults + // Release to -Os. cSettings covers .m sources, cxxSettings .mm/.cpp. + cSettings: [ + .headerSearchPath("headers"), + .unsafeFlags(["-O2"], .when(configuration: .release)), + ], // DEBUG/NDEBUG match the prebuilt React.framework's config-gated C++ // ABI: NDEBUG (Release) strips DebugStringConvertible's vtable and // shifts the ShadowNode layout that codegen'd Props inherit, so this @@ -61,6 +66,7 @@ let package = Package( .headerSearchPath("headers"), .define("DEBUG", .when(configuration: .debug)), .define("NDEBUG", .when(configuration: .release)), + .unsafeFlags(["-O2"], .when(configuration: .release)), ], linkerSettings: [ .linkedFramework("Foundation") @@ -72,13 +78,18 @@ let package = Package( path: "ReactAppDependencyProvider", exclude: ["ReactAppDependencyProvider.podspec"], publicHeadersPath: ".", - cSettings: [.headerSearchPath(".."), .headerSearchPath("headers")], + cSettings: [ + .headerSearchPath(".."), + .headerSearchPath("headers"), + .unsafeFlags(["-O2"], .when(configuration: .release)), + ], cxxSettings: [ .headerSearchPath(".."), .headerSearchPath("headers"), // Match the prebuilt React.framework NDEBUG-gated C++ ABI (see above). .define("DEBUG", .when(configuration: .debug)), .define("NDEBUG", .when(configuration: .release)), + .unsafeFlags(["-O2"], .when(configuration: .release)), ], linkerSettings: [ .linkedFramework("Foundation") diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/build-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/build-test.js new file mode 100644 index 000000000000..b27a2a9cb39a --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/build-test.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {getSwiftPackageBuildCommand} = require('../build'); + +describe('getSwiftPackageBuildCommand', () => { + it('builds Release at -O2', () => { + const command = getSwiftPackageBuildCommand('Release', 'iOS', 'output'); + + expect(command).toContain('GCC_OPTIMIZATION_LEVEL=2'); + }); + + it('leaves the Debug optimization level to the project', () => { + const command = getSwiftPackageBuildCommand('Debug', 'iOS', 'output'); + + expect(command).not.toContain('GCC_OPTIMIZATION_LEVEL'); + }); + + it.each(['Debug', 'Release'])('keeps the base %s command', buildType => { + const command = getSwiftPackageBuildCommand( + buildType, + 'iOS Simulator', + 'output', + ); + + expect(command).toContain( + 'xcodebuild -scheme React -destination "generic/platform=iOS Simulator" -derivedDataPath "output" ' + + `-configuration "${buildType}" SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES OTHER_SWIFT_FLAGS="-no-verify-emitted-module-interface" ` + + 'DEBUG_INFORMATION_FORMAT="dwarf-with-dsym"', + ); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/build.js b/packages/react-native/scripts/ios-prebuild/build.js index 9970ae7a7925..73a723689cb4 100644 --- a/packages/react-native/scripts/ios-prebuild/build.js +++ b/packages/react-native/scripts/ios-prebuild/build.js @@ -56,6 +56,23 @@ function computeFrameworkPaths( return frameworkPaths; } +function getSwiftPackageBuildCommand( + buildType /*: BuildFlavor */, + platform /*: Destination */, + outputFolder /*: string */, +) /*: string */ { + const command = + `xcodebuild -scheme React -destination "generic/platform=${platform}" -derivedDataPath "${outputFolder}" ` + + `-configuration "${buildType}" SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES OTHER_SWIFT_FLAGS="-no-verify-emitted-module-interface" ` + + `DEBUG_INFORMATION_FORMAT="dwarf-with-dsym"`; + // Xcode defaults Release to -Os. Android builds React Native at -O2 in + // Release. A command-line setting applies to every configuration, so add it + // only to Release builds. + return buildType === 'Release' + ? `${command} GCC_OPTIMIZATION_LEVEL=2` + : command; +} + function buildSwiftPackage( rootFolder /*: string */, buildFolder /*: string */, @@ -63,10 +80,11 @@ function buildSwiftPackage( platform /*: Destination */, outputFolder /*: string */, ) { - const buildCommand = - `xcodebuild -scheme React -destination "generic/platform=${platform}" -derivedDataPath "${outputFolder}" ` + - `-configuration "${buildType}" SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES OTHER_SWIFT_FLAGS="-no-verify-emitted-module-interface" ` + - `DEBUG_INFORMATION_FORMAT="dwarf-with-dsym"`; + const buildCommand = getSwiftPackageBuildCommand( + buildType, + platform, + outputFolder, + ); buildLog(`Building Swift package for ${buildType}`); buildLog(buildCommand); @@ -94,4 +112,5 @@ module.exports = { buildSwiftPackage, computeFrameworkPaths, computeProductsFolder, + getSwiftPackageBuildCommand, }; diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index c0c13480dadd..fe79fa39329a 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -632,6 +632,7 @@ def react_native_post_install( ReactNativePodsUtils.updateOSDeploymentTarget(installer) ReactNativePodsUtils.set_dynamic_frameworks_flags(installer) ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) + ReactNativePodsUtils.set_optimization_level_for_react_native_pods(installer, react_native_path) if !ReactNativeCoreUtils.build_rncore_from_source() # The Xcode-26 SWIFT_ENABLE_EXPLICIT_MODULES=NO workaround (#53457) is removed: diff --git a/packages/react-native/scripts/spm/__tests__/codegen-template-optimization-test.js b/packages/react-native/scripts/spm/__tests__/codegen-template-optimization-test.js new file mode 100644 index 000000000000..7063b4546264 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/codegen-template-optimization-test.js @@ -0,0 +1,81 @@ +/** + * 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 fs = require('node:fs'); +const path = require('node:path'); + +const TEMPLATE = fs.readFileSync( + path.resolve( + __dirname, + '..', + '..', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ), + 'utf8', +); + +const RELEASE_O2 = '.unsafeFlags(["-O2"], .when(configuration: .release))'; + +// Returns the text between the bracket at `openIndex` and its matching close. +function balancedBody(source, openIndex) { + const open = source[openIndex]; + const close = {'(': ')', '[': ']'}[open]; + let depth = 0; + for (let i = openIndex; i < source.length; i++) { + if (source[i] === open) { + depth++; + } else if (source[i] === close && --depth === 0) { + return source.slice(openIndex + 1, i); + } + } + throw new Error(`Unbalanced ${open} at index ${openIndex}`); +} + +function targetBlock(name) { + const re = /\.target\(/g; + let match; + while ((match = re.exec(TEMPLATE)) != null) { + const body = balancedBody(TEMPLATE, match.index + '.target'.length); + if (body.includes(`name: "${name}"`)) { + return body; + } + } + throw new Error(`Target ${name} not found in the codegen template`); +} + +function settings(block, key) { + const start = block.indexOf(`${key}: [`); + if (start === -1) { + return null; + } + return balancedBody(block, start + `${key}: `.length); +} + +describe('codegen Package.swift.spm-template optimization level', () => { + describe.each(['ReactCodegen', 'ReactAppDependencyProvider'])( + '%s', + targetName => { + it.each(['cSettings', 'cxxSettings'])( + 'compiles at -O2 in Release via %s', + key => { + expect(settings(targetBlock(targetName), key)).toContain(RELEASE_O2); + }, + ); + }, + ); + + it('leaves the headers-only ReactAppHeaders target without flags', () => { + expect(targetBlock('ReactAppHeaders')).not.toContain('unsafeFlags'); + }); +}); diff --git a/scripts/releases/ios-prebuild/__tests__/build-test.js b/scripts/releases/ios-prebuild/__tests__/build-test.js new file mode 100644 index 000000000000..39940535e169 --- /dev/null +++ b/scripts/releases/ios-prebuild/__tests__/build-test.js @@ -0,0 +1,55 @@ +/** + * 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 {getPlatformBuildCommand} = require('../build'); + +describe('getPlatformBuildCommand', () => { + it('builds Release at -O2', () => { + const command = getPlatformBuildCommand( + 'ReactNativeDependencies', + 'Release', + 'iOS', + 'build', + ); + + expect(command).toContain('GCC_OPTIMIZATION_LEVEL=2'); + }); + + it('leaves the Debug optimization level to the project', () => { + const command = getPlatformBuildCommand( + 'ReactNativeDependencies', + 'Debug', + 'iOS', + 'build', + ); + + expect(command).not.toContain('GCC_OPTIMIZATION_LEVEL'); + }); + + it.each(['Debug', 'Release'])('keeps the base %s command', configuration => { + const command = getPlatformBuildCommand( + 'ReactNativeDependencies', + configuration, + 'iOS Simulator', + 'build', + ); + + expect(command).toMatch( + new RegExp( + '^xcodebuild -scheme "ReactNativeDependencies" -destination "generic/platform=iOS Simulator" ' + + '-derivedDataPath "build" ' + + `-configuration "${configuration}" ` + + 'SKIP_INSTALL=NO\\s+BUILD_LIBRARY_FOR_DISTRIBUTION=YES\\s+DEBUG_INFORMATION_FORMAT=dwarf-with-dsym', + ), + ); + }); +}); diff --git a/scripts/releases/ios-prebuild/build.js b/scripts/releases/ios-prebuild/build.js index 825b4e9e577e..5296e3872424 100644 --- a/scripts/releases/ios-prebuild/build.js +++ b/scripts/releases/ios-prebuild/build.js @@ -53,6 +53,22 @@ async function buildPlatform( buildFolder /*: string */, ) { console.log(`Building ${destination}...`); + const command = getPlatformBuildCommand( + scheme, + configuration, + destination, + buildFolder, + ); + + execSync(command, {cwd: rootFolder, stdio: 'inherit'}); +} + +function getPlatformBuildCommand( + scheme /*: string */, + configuration /*: string */, + destination /*: Destination */, + buildFolder /*: string */, +) /*: string */ { const command = `xcodebuild -scheme "${scheme}" -destination "generic/platform=${destination}" ` + `-derivedDataPath "${buildFolder}" ` + @@ -60,10 +76,15 @@ async function buildPlatform( 'SKIP_INSTALL=NO \ BUILD_LIBRARY_FOR_DISTRIBUTION=YES \ DEBUG_INFORMATION_FORMAT=dwarf-with-dsym'; - - execSync(command, {cwd: rootFolder, stdio: 'inherit'}); + // Xcode defaults Release to -Os. Android builds these dependencies at -O2 in + // Release. A command-line setting applies to every configuration, so add it + // only to Release builds. + return configuration === 'Release' + ? `${command} GCC_OPTIMIZATION_LEVEL=2` + : command; } module.exports = { buildDepenencies, + getPlatformBuildCommand, }; diff --git a/scripts/releases/ios-prebuild/configuration.js b/scripts/releases/ios-prebuild/configuration.js index 3259ac632974..99aa6e6d8ac0 100644 --- a/scripts/releases/ios-prebuild/configuration.js +++ b/scripts/releases/ios-prebuild/configuration.js @@ -53,8 +53,13 @@ const dependencies /*: ReadonlyArray */ = [ settings: { publicHeaderFiles: './headers', headerSearchPaths: ['src'], - cCompilerFlags: ['-Wno-shorten-64-to-32'], - cxxCompilerFlags: ['-Wno-shorten-64-to-32', `-std=${CPP_STANDARD}`], + // Android builds glog at -O2 in every build type, Debug included, so match it. + cCompilerFlags: ['-Wno-shorten-64-to-32', '-O2'], + cxxCompilerFlags: [ + '-Wno-shorten-64-to-32', + `-std=${CPP_STANDARD}`, + '-O2', + ], defines: [ {name: 'DEFINES_MODULE', value: 'YES'}, {name: 'USE_HEADERMAP', value: 'NO'},