diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt index 890edb58bd86..6f950382906e 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt @@ -29,6 +29,7 @@ import com.facebook.react.utils.DependencyUtils.readVersionAndGroupStrings import com.facebook.react.utils.JdkConfiguratorUtils.configureJavaToolChains import com.facebook.react.utils.JsonUtils import com.facebook.react.utils.NdkConfiguratorUtils.configureReactNativeNdk +import com.facebook.react.utils.NdkConfiguratorUtils.configureStubPchGeneration import com.facebook.react.utils.ProjectUtils.needsCodegenFromPackageJson import com.facebook.react.utils.PropertyUtils import com.facebook.react.utils.findPackageJsonFile @@ -99,6 +100,7 @@ class ReactPlugin : Plugin { } configureReactNativeNdk(project, extension) + configureStubPchGeneration(project) configureBuildConfigFieldsForApp(project, extension) configureDevServerLocation(project) configureBackwardCompatibilityReactMap(project) diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GenerateStubPchTask.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GenerateStubPchTask.kt new file mode 100644 index 000000000000..0eceee1e6a2f --- /dev/null +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GenerateStubPchTask.kt @@ -0,0 +1,121 @@ +/* + * 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.tasks + +import com.google.gson.Gson +import com.google.gson.JsonArray +import java.io.File +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction + +abstract class GenerateStubPchTask : DefaultTask() { + + init { + group = "react" + description = "Generates stub precompiled headers so Android Studio can sync C++ sources." + outputs.upToDateWhen { false } + } + + /** The AGP-managed `.cxx` directory. */ + @get:Internal abstract val cxxDirectory: DirectoryProperty + + @TaskAction + fun taskAction() { + val cxxDir = cxxDirectory.get().asFile + if (!cxxDir.isDirectory) { + return + } + + cxxDir + .walkTopDown() + .filter { it.isFile && it.name == COMPILE_COMMANDS_FILENAME } + .forEach { generateStubsFor(it) } + } + + internal fun generateStubsFor(compileCommands: File) { + val entries = runCatching { + Gson().fromJson(compileCommands.readText(), JsonArray::class.java) + }.getOrNull() ?: return + + for (element in entries) { + val entry = element.asJsonObject + val source = entry.get("file")?.asString ?: continue + if (!source.endsWith(PCH_SOURCE_SUFFIX)) { + continue + } + + val pchFile = File(source.removeSuffix(SOURCE_EXTENSION) + PCH_EXTENSION) + // Anything already on disk was either built for real or stubbed by an earlier sync. + if (pchFile.length() > 0L) { + continue + } + + val command = entry.get("command")?.asString ?: continue + val directory = entry.get("directory")?.asString ?: continue + compileEmptyPch(command, File(directory), pchFile) + + // A stub is not a valid input for the real compilation, so keep it older than its source. + // That way the next build treats it as stale and replaces it before anything consumes it. + pchFile.setLastModified(File(source).lastModified() - 1) + } + } + + private fun compileEmptyPch(command: String, workingDir: File, pchFile: File) { + pchFile.parentFile.mkdirs() + val stubHeader = File(pchFile.parentFile, STUB_HEADER_FILENAME).apply { writeText("") } + + val process = ProcessBuilder(stubCompilerArguments(command, pchFile, stubHeader)) + .directory(workingDir) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + process.outputStream.close() + + if (process.waitFor() != 0) { + throw GradleException("RNGP - Stub precompiled header generation failed:\n$output") + } + } + + internal fun stubCompilerArguments( + command: String, + pchFile: File, + stubHeader: File, + ): List { + val target = + TARGET_FLAG.find(command)?.value + ?: throw GradleException("RNGP - Could not find --target in: $command") + val sysroot = + SYSROOT_FLAG.find(command)?.value + ?: throw GradleException("RNGP - Could not find --sysroot in: $command") + + return listOf( + command.substringBefore(' '), + target, + sysroot, + "-x", + "c++-header", + "-o", + pchFile.absolutePath, + stubHeader.absolutePath, + ) + } + + companion object { + private const val COMPILE_COMMANDS_FILENAME = "compile_commands.json" + private const val PCH_SOURCE_SUFFIX = "cmake_pch.hxx.cxx" + private const val SOURCE_EXTENSION = ".cxx" + private const val PCH_EXTENSION = ".pch" + private const val STUB_HEADER_FILENAME = "stub_pch.hxx" + private val TARGET_FLAG = Regex("""--target=\S+""") + private val SYSROOT_FLAG = Regex("""--sysroot=\S+""") + } +} diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt index 5c69496f3db0..62b1f920ad8e 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt @@ -10,6 +10,7 @@ package com.facebook.react.utils import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.variant.Variant import com.facebook.react.ReactExtension +import com.facebook.react.tasks.GenerateStubPchTask import com.facebook.react.utils.ProjectUtils.getReactNativeArchitectures import java.io.File import org.gradle.api.Project @@ -65,6 +66,20 @@ internal object NdkConfiguratorUtils { } } + /** + * The codegen targets share a precompiled header, which only a real build produces. Android + * Studio's C++ engine needs one at sync time, so we hook a task that writes stubs into the sync + * itself. See [GenerateStubPchTask]. + */ + fun configureStubPchGeneration(project: Project) { + val generateStubPchTask = project.tasks.register("generateStubPch", GenerateStubPchTask::class.java) { task -> + task.cxxDirectory.set(project.layout.projectDirectory.dir(".cxx")) + task.dependsOn(project.tasks.matching { it.name.startsWith("configureCMakeDebug") }) + } + + project.tasks.maybeCreate("prepareKotlinBuildScriptModel").dependsOn(generateStubPchTask) + } + /** * This method is used to configure the .so Packaging Options for the given variant. It will make * sure we specify the correct .pickFirsts for all the .so files we are producing or that we're diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GenerateStubPchTaskTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GenerateStubPchTaskTest.kt new file mode 100644 index 000000000000..2b117e41f455 --- /dev/null +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GenerateStubPchTaskTest.kt @@ -0,0 +1,85 @@ +/* + * 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.tasks + +import com.facebook.react.tests.createTestTask +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.gradle.api.GradleException +import org.junit.Assume.assumeFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GenerateStubPchTaskTest { + + @get:Rule val tempFolder = TemporaryFolder() + + @Test + fun generateStubPchTask_groupIsSetCorrectly() { + val task = createTestTask {} + assertThat(task.group).isEqualTo("react") + } + + @Test + fun stubCompilerArguments_extractsCompilerAndFlags() { + val task = createTestTask() + val pchFile = tempFolder.newFile("cmake_pch.hxx.pch") + val stubHeader = tempFolder.newFile("stub_pch.hxx") + + val arguments = + task.stubCompilerArguments( + "/ndk/clang++ --target=aarch64-none-linux-android24 --sysroot=/ndk/sysroot -Wall -c x.cxx", + pchFile, + stubHeader, + ) + + assertThat(arguments) + .containsExactly( + "/ndk/clang++", + "--target=aarch64-none-linux-android24", + "--sysroot=/ndk/sysroot", + "-x", + "c++-header", + "-o", + pchFile.absolutePath, + stubHeader.absolutePath, + ) + } + + @Test + fun stubCompilerArguments_withoutTarget_fails() { + val task = createTestTask() + + assertThatThrownBy { + task.stubCompilerArguments( + "/ndk/clang++ --sysroot=/ndk/sysroot -c x.cxx", + tempFolder.newFile("a.pch"), + tempFolder.newFile("a.hxx"), + ) + } + .isInstanceOf(GradleException::class.java) + .hasMessageContaining("--target") + } + + @Test + fun stubCompilerArguments_withoutSysroot_fails() { + val task = createTestTask() + + assertThatThrownBy { + task.stubCompilerArguments( + "/ndk/clang++ --target=aarch64-none-linux-android24 -c x.cxx", + tempFolder.newFile("a.pch"), + tempFolder.newFile("a.hxx"), + ) + } + .isInstanceOf(GradleException::class.java) + .hasMessageContaining("--sysroot") + } +} diff --git a/packages/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake b/packages/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake index baa0fefa95ec..79463f35a300 100644 --- a/packages/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +++ b/packages/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake @@ -89,12 +89,19 @@ target_link_libraries(${CMAKE_PROJECT_NAME} add_library(common_flags INTERFACE) target_compile_options(common_flags INTERFACE ${folly_FLAGS}) +# Defines the `reactnative_pch` target and `target_reuse_reactnative_pch()`, so +# the codegen targets below share a single precompiled header. Has to come after +# `common_flags`, as the precompiled header is built with the same flags as its +# consumers. +include(${CMAKE_CURRENT_LIST_DIR}/ReactNative-precompiled-header.cmake) + # If project is on RN CLI v9, then we can use the following lines to link against the autolinked 3rd party libraries. if(EXISTS ${PROJECT_BUILD_DIR}/generated/autolinking/src/main/jni/Android-autolinking.cmake) include(${PROJECT_BUILD_DIR}/generated/autolinking/src/main/jni/Android-autolinking.cmake) target_link_libraries(${CMAKE_PROJECT_NAME} ${AUTOLINKED_LIBRARIES}) foreach(autolinked_library ${AUTOLINKED_LIBRARIES}) target_link_libraries(${autolinked_library} common_flags) + target_reuse_reactnative_pch(${autolinked_library}) endforeach() endif() @@ -104,6 +111,7 @@ if(EXISTS ${PROJECT_BUILD_DIR}/generated/source/codegen/jni/CMakeLists.txt) get_property(APP_CODEGEN_TARGET DIRECTORY ${PROJECT_BUILD_DIR}/generated/source/codegen/jni/ PROPERTY BUILDSYSTEM_TARGETS) target_link_libraries(${CMAKE_PROJECT_NAME} ${APP_CODEGEN_TARGET}) target_link_libraries(${APP_CODEGEN_TARGET} common_flags) + target_reuse_reactnative_pch(${APP_CODEGEN_TARGET}) # We need to pass the generated header and module provider to the OnLoad.cpp file so # local app modules can properly be linked. diff --git a/packages/react-native/ReactAndroid/cmake-utils/ReactNative-precompiled-header.cmake b/packages/react-native/ReactAndroid/cmake-utils/ReactNative-precompiled-header.cmake new file mode 100644 index 000000000000..042c01e88203 --- /dev/null +++ b/packages/react-native/ReactAndroid/cmake-utils/ReactNative-precompiled-header.cmake @@ -0,0 +1,52 @@ +# 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. + +# Clang stamps a .pch with the time it was built, which makes it +# non-reproducible: ccache can never reuse one, and a PCH restored from the cache +# gets rejected as "modified since built" by the sources that consume it. Both +# the owner and the consumers need the flag, as it changes how the header is +# both written and read. +set(REACT_NATIVE_PCH_FLAGS "$<$:-Xclang;-fno-pch-timestamp>") + +add_library(reactnative_pch STATIC EXCLUDE_FROM_ALL + ${CMAKE_CURRENT_LIST_DIR}/precompiled-header/pch-owner.cpp) + +target_compile_reactnative_options(reactnative_pch PRIVATE) +set_target_properties(reactnative_pch PROPERTIES CXX_STANDARD 20 CXX_EXTENSIONS OFF) + +target_link_libraries(reactnative_pch PRIVATE common_flags fbjni jsi reactnative) + +target_compile_options(reactnative_pch PRIVATE ${REACT_NATIVE_PCH_FLAGS}) +target_precompile_headers(reactnative_pch PRIVATE + "$<$:${CMAKE_CURRENT_LIST_DIR}/precompiled-header/pch.h>") + +# Points `target` at the precompiled header owned by `reactnative_pch`. Targets +# that can't consume one - imported and interface libraries - are skipped, as +# are names that aren't targets at all: an autolinked library is allowed to skip +# itself when its source directory is missing. +function(target_reuse_reactnative_pch target) + if (NOT TARGET ${target}) + return() + endif () + + get_target_property(is_imported ${target} IMPORTED) + if (is_imported) + return() + endif () + + get_target_property(target_type ${target} TYPE) + if (target_type STREQUAL "INTERFACE_LIBRARY") + return() + endif () + + # See the note on REACT_NATIVE_PCH_FLAGS above: the flag has to be on both + # sides of the reuse. + target_compile_options(${target} PRIVATE ${REACT_NATIVE_PCH_FLAGS}) + + # clang rejects a precompiled header built with a different C++ dialect. + set_target_properties(${target} PROPERTIES CXX_STANDARD 20 CXX_EXTENSIONS OFF) + + target_precompile_headers(${target} REUSE_FROM reactnative_pch) +endfunction() diff --git a/packages/react-native/ReactAndroid/cmake-utils/precompiled-header/pch-owner.cpp b/packages/react-native/ReactAndroid/cmake-utils/precompiled-header/pch-owner.cpp new file mode 100644 index 000000000000..2206a7f4a8be --- /dev/null +++ b/packages/react-native/ReactAndroid/cmake-utils/precompiled-header/pch-owner.cpp @@ -0,0 +1,10 @@ +/* + * 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. + */ + +// This translation unit exists solely so the `reactnative_pch` target has +// something to compile, and therefore produces a precompiled header that the +// codegen targets can reuse. diff --git a/packages/react-native/ReactAndroid/cmake-utils/precompiled-header/pch.h b/packages/react-native/ReactAndroid/cmake-utils/precompiled-header/pch.h new file mode 100644 index 000000000000..e5ea7310a482 --- /dev/null +++ b/packages/react-native/ReactAndroid/cmake-utils/precompiled-header/pch.h @@ -0,0 +1,21 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include