From 411b660f3a2ce1e2eac7a8ac67f7cafcc6b85f4d Mon Sep 17 00:00:00 2001 From: Tomasz Zawadzki Date: Mon, 18 May 2026 15:47:11 +0200 Subject: [PATCH 1/2] Start using precompiled headers on Android (#9434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds precompiled headers (PCH) to the Android CMake builds for `libreanimated.so` and `libworklets.so`. PCH lets clang parse the heavy stable-third-party headers (`folly/dynamic.h`, `jsi/jsi.h`, `fbjni.h`, RN renderer headers, C++ standard library) once per build instead of once per translation unit. This is a build-time-only change. No runtime behavior, ABI, or `.so` size is affected. Inspired by [expo/expo#39641](https://github.com/expo/expo/pull/39641) by @lukmccall from Expo SDK team – huge thanks for your help and insights! Eight files. The PCH itself is four files; both libs follow the same pattern: - `packages/react-native-reanimated/android/CMakeLists.txt` — bump `cmake_minimum_required` to `3.16` (needed for `target_precompile_headers`); add `target_precompile_headers(reanimated PRIVATE ${ANDROID_CPP_DIR}/ReanimatedPCH.h)`. - `packages/react-native-reanimated/android/src/main/cpp/ReanimatedPCH.h` — new file. stdlib subset + `folly/dynamic`, `folly/json/dynamic`, `folly/Format`, `folly/Conv`, `folly/Expected` + `jsi/jsi`, `jsi/JSIDynamic` + `fbjni/fbjni` + 8 RN renderer headers (`ShadowNode`, `ShadowTree`, `UIManager`, `ViewProps`, `BaseViewProps`, `RootProps`, `RootShadowNode`, `EventDispatcher`). - `packages/react-native-worklets/android/CMakeLists.txt` — same shape (`cmake_minimum_required` → `3.16`, `target_precompile_headers(worklets PRIVATE ...)`). - `packages/react-native-worklets/android/src/main/cpp/WorkletsPCH.h` — new file. stdlib subset + `jsi/jsi` + `fbjni/fbjni`. Smaller than reanimated's set because worklets has fewer TUs (36 vs 96) and adding more was net-negative. Four more files implement the [Android Studio sync workaround](#android-studio-sync-workaround): - `packages/react-native-reanimated/android/build.gradle.kts`, `packages/react-native-worklets/android/build.gradle.kts` — apply `./generate-stub-pch.gradle.kts`. - `packages/react-native-reanimated/android/generate-stub-pch.gradle.kts`, `packages/react-native-worklets/android/generate-stub-pch.gradle.kts` — new gradle scripts. No internal Reanimated/Worklets headers are PCH'd, so local edits to project sources never invalidate the PCH and trigger a full rebuild. No `-Wpedantic`/`-Werror` suppression pragmas were needed — both PCHs compile clean. clangd's `misc-include-cleaner` warnings on the stdlib includes are silenced via `// IWYU pragma: begin_keep / end_keep`. `react/renderer/animationbackend/AnimationBackend.h` was deliberately excluded from the PCH because it does not exist on some older RN versions which are still supported per `peerDependencies`. Each variant measured 5 cold runs, median, on M3 Pro (12 cores), arm64-v8a only. - **C++ compile CPU**: pure `ninja` invocation (CMake configure done once up-front; `.o` / `.pch` / `.so` wiped between runs); `time -p ninja` reports user+sys CPU summed across cores. Machine-independent. - **Native-only wall**: same `ninja` invocation, real time. - **End-to-end wall**: `./gradlew :app:assembleDebug -PreactNativeArchitectures=arm64-v8a --no-build-cache --no-configuration-cache --no-daemon`; full wipe of every `build/` and `.cxx/` between runs. Header set was picked by ranking external/stable headers from `clang -ftime-trace` by `sum(parse_time) × TU_count` and keeping only those reaching ~60% TU coverage on reanimated (≥85% on worklets, since its smaller TU pool tolerates less PCH overhead). Several wider/narrower variants were measured. | Frame | Baseline | PCH | Δ | |---|---|---|---| | C++ compile work (CPU, both libs, per ABI) — what PCH actually does | 242.4s | 117.1s | **−51.7%** | | Native-only wall (`ninja`, both libs combined) | 25.2s | 15.9s | **−37%** | | End-to-end `:app:assembleDebug` wall (arm64-v8a, no caches) | 74.2s | 64.9s | **−12.5%** (−9.25s) | The end-to-end −12.5% is the conservative number — that's the wall-clock gain a developer running a single-ABI debug build on this machine sees. The native-only and CPU numbers are the headline "this is what the change is doing" metrics. On CI runners with fewer cores or when building 4 ABIs, the wall-clock improvement scales much closer to the CPU improvement. Per-lib breakdown of the pure-`ninja` measurement: | Lib | TUs | Baseline CPU | PCH CPU | Δ CPU | Baseline wall | PCH wall | Δ wall | |---|---|---|---|---|---|---|---| | react-native-worklets | 36 | 35.40s | 23.18s | **−34.5%** | 4.06s | 3.57s | −12.1% | | react-native-reanimated | 96 | 207.00s | 93.87s | **−54.7%** | 21.13s | 12.30s | −41.8% | `target_precompile_headers` causes CMake to generate `cmake_pch.hxx` (a wrapper that `#include`s every header listed in `*PCH.h`), build it once into a `cmake_pch.hxx.pch` binary, and force-include the wrapper via `-Xclang -include -Xclang …cmake_pch.hxx` in every TU compile command (clang transparently loads the paired `.pch`). clang emits `.d` dependency files for both the PCH compile and each TU; ninja stores these and stats every recorded path on each build. If anything transitively pulled in by the PCH changed mtime, the `.pch` is rebuilt — and because every TU depends on the `.pch`, they all rebuild too. | Scenario | What rebuilds | Cost vs no-PCH | |---|---|---| | Edit a Reanimated/Worklets `.cpp` | Just that TU | Same | | Edit a Reanimated/Worklets `.h` not in PCH | Every TU that includes it | Same | | Edit `*PCH.h` itself | PCH binary + every TU in that lib | Same as a clean lib build | | RN patch bump (same prefab transform hash) | Usually nothing | Same | | RN minor bump | PCH binary + every TU | Same as today (RN bumps already invalidate every TU because prefab include paths are part of compile commands) | | NDK / libc++ update | PCH binary + every TU | Same as today | | Compile-flag change (feature flag macro, build type) | PCH binary + every TU | Same as today | PCH never adds invalidation — it only adds an extra build artifact that recompiles in lock-step with the things that already trigger a full rebuild. The invariant is: any change that previously forced a clean lib rebuild still does, plus a ~1–2 s PCH compile, then every TU is faster. clang refuses to load a PCH built with mismatched flags (different language standard, target, defines, etc.) — it errors `module file built with different flags` rather than silently producing wrong code. So a stale `.pch` is a build failure, not a runtime bug. The PCH binary lives inside `.cxx////CMakeFiles/.dir/` and is wiped by `gradlew clean` and the existing `cleanCMakeCache` task — no new maintenance. One incremental-dev gotcha worth flagging for future contributors: adding a `#include` to a `*PCH.h` to "speed up my TU" forces every contributor's next build to rebuild the entire lib. Enabling PCH causes Android Studio's Gradle Sync to surface a C++ indexer error on a clean checkout — the IDE can't index sources without the precompiled header binary (`cmake_pch.hxx.pch`), which doesn't exist until the first build. The project compiles fine; the failure is sync-only. To unblock sync **without making sync slower**, both packages apply `generate-stub-pch.gradle.kts`. After `configureCMakeDebug`, it walks `compile_commands.json` and compiles a **stub `.pch` from an empty header** for every ABI. Generating the real PCH here would defeat the purpose — it'd push several seconds of full PCH compile into every sync. The stub satisfies the indexer, and its mtime is backdated below `cmake_pch.hxx.cxx` so the next real build invokes ninja to regenerate the proper PCH binary in the normal build pipeline. Adapted from [expo/expo#45921](https://github.com/expo/expo/pull/45921). - [ ] `yarn android` from `apps/fabric-example` — app builds and launches; existing screens behave normally. - [ ] CI: existing Android build + lint jobs pass. - [ ] Spot-check on RN 0.81 / 0.82 / 0.83 / 0.84 / 0.85 to confirm none of the PCH'd RN renderer headers regress on older versions. - [ ] Open `apps/fabric-example/android` in Android Studio on a clean checkout (no prior `.cxx/` build artifacts), run Gradle Sync, confirm no C++ indexer error like the one in the workaround section appears and that `./gradlew :app:generateStubPCH` produced stub `.pch` files under `.cxx/`. - [ ] After the sync, run a full debug build and verify ninja regenerates the real PCH binaries (i.e. stub mtimes get overwritten and the proper PCH files are produced). --- .../android/CMakeLists.txt | 5 +- .../android/build.gradle | 2 + .../android/generate-stub-pch.gradle.kts | 84 +++++++++++++++++++ .../android/src/main/cpp/ReanimatedPCH.h | 46 ++++++++++ .../android/CMakeLists.txt | 5 +- .../android/build.gradle | 1 + .../android/generate-stub-pch.gradle.kts | 84 +++++++++++++++++++ .../android/src/main/cpp/WorkletsPCH.h | 26 ++++++ 8 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-reanimated/android/generate-stub-pch.gradle.kts create mode 100644 packages/react-native-reanimated/android/src/main/cpp/ReanimatedPCH.h create mode 100644 packages/react-native-worklets/android/generate-stub-pch.gradle.kts create mode 100644 packages/react-native-worklets/android/src/main/cpp/WorkletsPCH.h diff --git a/packages/react-native-reanimated/android/CMakeLists.txt b/packages/react-native-reanimated/android/CMakeLists.txt index 3f87cab8570b..10e782a33ea8 100644 --- a/packages/react-native-reanimated/android/CMakeLists.txt +++ b/packages/react-native-reanimated/android/CMakeLists.txt @@ -1,5 +1,5 @@ project(Reanimated) -cmake_minimum_required(VERSION 3.8) +cmake_minimum_required(VERSION 3.16) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) @@ -50,6 +50,9 @@ find_package(react-native-worklets REQUIRED CONFIG) add_library(reanimated SHARED ${REANIMATED_COMMON_CPP_SOURCES} ${REANIMATED_ANDROID_CPP_SOURCES}) +target_precompile_headers(reanimated PRIVATE + "${ANDROID_CPP_DIR}/ReanimatedPCH.h") + if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 80) include( "${REACT_NATIVE_DIR}/ReactCommon/cmake-utils/react-native-flags.cmake") diff --git a/packages/react-native-reanimated/android/build.gradle b/packages/react-native-reanimated/android/build.gradle index 8167d16df2b2..5485d4aaf9b8 100644 --- a/packages/react-native-reanimated/android/build.gradle +++ b/packages/react-native-reanimated/android/build.gradle @@ -130,6 +130,8 @@ if (project == rootProject) { apply plugin: "com.android.library" apply plugin: "maven-publish" +apply from: "./generate-stub-pch.gradle.kts" + android { compileSdkVersion safeExtGet("compileSdkVersion", 36) diff --git a/packages/react-native-reanimated/android/generate-stub-pch.gradle.kts b/packages/react-native-reanimated/android/generate-stub-pch.gradle.kts new file mode 100644 index 000000000000..a900f9015baa --- /dev/null +++ b/packages/react-native-reanimated/android/generate-stub-pch.gradle.kts @@ -0,0 +1,84 @@ +import groovy.json.JsonSlurper + +// Workaround for Android Studio's C++ analyzer surfacing errors during Gradle +// Sync when CMake's precompiled header binary (`cmake_pch.hxx.pch`, generated +// from `ReanimatedPCH.h`) hasn't been built yet. The project compiles fine, +// but on a clean sync the IDE can't index sources without the PCH binary. See +// the underlying issue: https://issuetracker.google.com/issues/187448826 + +// Generates minimal stub `.pch` files from an empty header so the IDE's C++ +// engine doesn't fail during sync. The actual build regenerates the real PCH +// files via ninja because we set each stub PCH's mtime older than its source +// (`cmake_pch.hxx.cxx`). + +// Adapted from Expo's PR: https://github.com/expo/expo/pull/45921 + +val cxxDir = project.file(".cxx") + +val generateStubPCHTask = tasks.register("generateStubPCH") { + dependsOn("configureCMakeDebug") + doLast { + if (!cxxDir.exists()) { + return@doLast + } + cxxDir.walkTopDown().forEach { file -> + if (file.name != "compile_commands.json") { + return@forEach + } + @Suppress("UNCHECKED_CAST") + val entries = JsonSlurper().parseText(file.readText()) as List> + entries.forEach entry@{ entry -> + val entryFile = entry["file"] as String + if (!entryFile.endsWith("cmake_pch.hxx.cxx")) { + return@entry + } + val pchFile = File(entryFile.substring(0, entryFile.length - ".cxx".length) + ".pch") + if (!pchFile.exists() || pchFile.length() == 0L) { + pchFile.parentFile.mkdirs() + + val command = entry["command"] as String + val compiler = command.split(" ").first() + val target = Regex("""--target=\S+""").find(command)!!.value + val sysroot = Regex("""--sysroot=\S+""").find(command)!!.value + + val stubHeader = File(pchFile.parentFile, "stub_pch.hxx") + stubHeader.writeText("") + + val process = ProcessBuilder( + compiler, + target, + sysroot, + "-x", "c++-header", + "-o", pchFile.absolutePath, + stubHeader.absolutePath, + ) + .directory(File(entry["directory"] as String)) + .redirectErrorStream(true) + .start() + process.outputStream.close() + if (process.waitFor() != 0) { + throw GradleException( + "Stub PCH generation failed: ${process.inputStream.bufferedReader().readText()}", + ) + } + } + // Ensure PCH is older than source so ninja rebuilds the real one during build + pchFile.setLastModified(File(entryFile).lastModified() - 1) + } + } + } +} + +// Register `prepareKotlinBuildScriptModel` if absent (Android Studio sync needs it +// to exist so the stub PCH generation runs) or configure it if some other plugin +// already registered it (e.g. on CI, where re-registering throws `Cannot add task +// 'prepareKotlinBuildScriptModel' as a task with that name already exists.`). +if (tasks.findByName("prepareKotlinBuildScriptModel") == null) { + tasks.register("prepareKotlinBuildScriptModel") { + dependsOn(generateStubPCHTask) + } +} else { + tasks.named("prepareKotlinBuildScriptModel") { + dependsOn(generateStubPCHTask) + } +} diff --git a/packages/react-native-reanimated/android/src/main/cpp/ReanimatedPCH.h b/packages/react-native-reanimated/android/src/main/cpp/ReanimatedPCH.h new file mode 100644 index 000000000000..6f8e23b160cd --- /dev/null +++ b/packages/react-native-reanimated/android/src/main/cpp/ReanimatedPCH.h @@ -0,0 +1,46 @@ +#pragma once + +// IWYU pragma: begin_keep + +// C++ standard library +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// fbjni +#include + +// folly +#include +#include +#include +#include +#include + +// jsi +#include +#include + +// react-native renderer (Fabric) +#include +#include +#include +#include +#include +#include +#include +#include + +// IWYU pragma: end_keep diff --git a/packages/react-native-worklets/android/CMakeLists.txt b/packages/react-native-worklets/android/CMakeLists.txt index 120f2cebb56f..608f9bb2413e 100644 --- a/packages/react-native-worklets/android/CMakeLists.txt +++ b/packages/react-native-worklets/android/CMakeLists.txt @@ -1,5 +1,5 @@ project(Worklets) -cmake_minimum_required(VERSION 3.8) +cmake_minimum_required(VERSION 3.16) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) @@ -68,6 +68,9 @@ endif() add_library(worklets SHARED ${WORKLETS_COMMON_CPP_SOURCES} ${WORKLETS_ANDROID_CPP_SOURCES}) +target_precompile_headers(worklets PRIVATE + "${ANDROID_CPP_DIR}/WorkletsPCH.h") + if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 80) include( "${REACT_NATIVE_DIR}/ReactCommon/cmake-utils/react-native-flags.cmake") diff --git a/packages/react-native-worklets/android/build.gradle b/packages/react-native-worklets/android/build.gradle index 159d9fd841a9..1594cd245c5e 100644 --- a/packages/react-native-worklets/android/build.gradle +++ b/packages/react-native-worklets/android/build.gradle @@ -174,6 +174,7 @@ if (project == rootProject) { } apply from: "./fix-prefab.gradle" +apply from: "./generate-stub-pch.gradle.kts" android { diff --git a/packages/react-native-worklets/android/generate-stub-pch.gradle.kts b/packages/react-native-worklets/android/generate-stub-pch.gradle.kts new file mode 100644 index 000000000000..12585edd630c --- /dev/null +++ b/packages/react-native-worklets/android/generate-stub-pch.gradle.kts @@ -0,0 +1,84 @@ +import groovy.json.JsonSlurper + +// Workaround for Android Studio's C++ analyzer surfacing errors during Gradle +// Sync when CMake's precompiled header binary (`cmake_pch.hxx.pch`, generated +// from `WorkletsPCH.h`) hasn't been built yet. The project compiles fine, +// but on a clean sync the IDE can't index sources without the PCH binary. See +// the underlying issue: https://issuetracker.google.com/issues/187448826 + +// Generates minimal stub `.pch` files from an empty header so the IDE's C++ +// engine doesn't fail during sync. The actual build regenerates the real PCH +// files via ninja because we set each stub PCH's mtime older than its source +// (`cmake_pch.hxx.cxx`). + +// Adapted from Expo's PR: https://github.com/expo/expo/pull/45921 + +val cxxDir = project.file(".cxx") + +val generateStubPCHTask = tasks.register("generateStubPCH") { + dependsOn("configureCMakeDebug") + doLast { + if (!cxxDir.exists()) { + return@doLast + } + cxxDir.walkTopDown().forEach { file -> + if (file.name != "compile_commands.json") { + return@forEach + } + @Suppress("UNCHECKED_CAST") + val entries = JsonSlurper().parseText(file.readText()) as List> + entries.forEach entry@{ entry -> + val entryFile = entry["file"] as String + if (!entryFile.endsWith("cmake_pch.hxx.cxx")) { + return@entry + } + val pchFile = File(entryFile.substring(0, entryFile.length - ".cxx".length) + ".pch") + if (!pchFile.exists() || pchFile.length() == 0L) { + pchFile.parentFile.mkdirs() + + val command = entry["command"] as String + val compiler = command.split(" ").first() + val target = Regex("""--target=\S+""").find(command)!!.value + val sysroot = Regex("""--sysroot=\S+""").find(command)!!.value + + val stubHeader = File(pchFile.parentFile, "stub_pch.hxx") + stubHeader.writeText("") + + val process = ProcessBuilder( + compiler, + target, + sysroot, + "-x", "c++-header", + "-o", pchFile.absolutePath, + stubHeader.absolutePath, + ) + .directory(File(entry["directory"] as String)) + .redirectErrorStream(true) + .start() + process.outputStream.close() + if (process.waitFor() != 0) { + throw GradleException( + "Stub PCH generation failed: ${process.inputStream.bufferedReader().readText()}", + ) + } + } + // Ensure PCH is older than source so ninja rebuilds the real one during build + pchFile.setLastModified(File(entryFile).lastModified() - 1) + } + } + } +} + +// Register `prepareKotlinBuildScriptModel` if absent (Android Studio sync needs it +// to exist so the stub PCH generation runs) or configure it if some other plugin +// already registered it (e.g. on CI, where re-registering throws `Cannot add task +// 'prepareKotlinBuildScriptModel' as a task with that name already exists.`). +if (tasks.findByName("prepareKotlinBuildScriptModel") == null) { + tasks.register("prepareKotlinBuildScriptModel") { + dependsOn(generateStubPCHTask) + } +} else { + tasks.named("prepareKotlinBuildScriptModel") { + dependsOn(generateStubPCHTask) + } +} diff --git a/packages/react-native-worklets/android/src/main/cpp/WorkletsPCH.h b/packages/react-native-worklets/android/src/main/cpp/WorkletsPCH.h new file mode 100644 index 000000000000..0347fc99b98c --- /dev/null +++ b/packages/react-native-worklets/android/src/main/cpp/WorkletsPCH.h @@ -0,0 +1,26 @@ +#pragma once + +// IWYU pragma: begin_keep + +// C++ standard library +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// fbjni +#include + +// jsi +#include + +// IWYU pragma: end_keep From 7630b9f96d3ebf6ff52113cebd7c6ab708c41b6b Mon Sep 17 00:00:00 2001 From: Wojciech Rok <58606210+tshmieldev@users.noreply.github.com> Date: Tue, 19 May 2026 11:15:53 +0200 Subject: [PATCH 2/2] fix: clang-tidy version mismatch w/ pch (#9455) ## Summary We use precompiled headers in android build This breaks clang-tidy CI when clang version is mismatched between NDK and ubuntu/system clang This PR makes the clang-tidy script use the NDK clang ## Test plan CI works --- scripts/clang-tidy-lint.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/clang-tidy-lint.sh b/scripts/clang-tidy-lint.sh index 5449aaf4f3f6..79358b8472a7 100755 --- a/scripts/clang-tidy-lint.sh +++ b/scripts/clang-tidy-lint.sh @@ -15,4 +15,7 @@ if [ ! -f "compile_commands.json" ]; then ) fi -run-clang-tidy -quiet -p . -header-filter="^.*/$1/.*\.h$" "$1" +ndk_bin="$(grep -oE '/[^ "]+/clang\+\+' compile_commands.json | head -1 | xargs dirname)" + +run-clang-tidy -quiet -p . -clang-tidy-binary "$ndk_bin/clang-tidy" \ + -header-filter="^.*/$1/.*\.h$" "$1"