diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index 8d02b3ec1496..46a6c59c13c1 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 c41bcba02903..9c5a9b0a1846 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -71,7 +71,9 @@ let yoga = RNTarget( name: .yoga, path: "ReactCommon/yoga", excludedPaths: ["test"], - 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 c1d6f97234a4..6473278a2e7e 100644 --- a/packages/react-native/scripts/cocoapods/utils.rb +++ b/packages/react-native/scripts/cocoapods/utils.rb @@ -881,6 +881,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 f9af7ba5a041..7f87a051433e 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 @@ -65,6 +70,7 @@ let package = Package( // frameworks" warning here. Libraries are held to the API in // their own packages. .define("RN_ALLOW_FRAMEWORKS"), + .unsafeFlags(["-O2"], .when(configuration: .release)), ], linkerSettings: [ .linkedFramework("Foundation") @@ -76,7 +82,11 @@ 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"), @@ -85,6 +95,7 @@ let package = Package( .define("NDEBUG", .when(configuration: .release)), // Generated code (see ReactCodegen above). .define("RN_ALLOW_FRAMEWORKS"), + .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 317652ca69ed..f8e3f7d729d7 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -641,6 +641,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'},