Skip to content

build(bazel): add experimental RNTester macOS build#3014

Draft
Saadnajmi wants to merge 25 commits into
microsoft:mainfrom
Saadnajmi:saadnajmi/bazel-support-research
Draft

build(bazel): add experimental RNTester macOS build#3014
Saadnajmi wants to merge 25 commits into
microsoft:mainfrom
Saadnajmi:saadnajmi/bazel-support-research

Conversation

@Saadnajmi

@Saadnajmi Saadnajmi commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Draft / full-stack research PR. Everything is additive. The existing Yarn/Metro/Xcode/SPM/CocoaPods workflows and published npm package remain unchanged.

One-command result

From a clean checkout on Xcode 26:

bazel build //packages/rn-tester/RNTester-macOS:app

Bazel downloads SHA-pinned Hermes/RNDependencies artifacts, translates the Yarn Berry lock for rules_js, bundles the real RNTester JavaScript + assets, runs app/core codegen, compiles React from source, links/signs the macOS app, and emits RNTesterMacBazelFull.zip.

Verified locally with all local React/RNDependencies/Hermes artifacts hidden: the cold build completed, the app contained no React.framework (source-linked), and launched offline with the real Fabric/C++ TurboModule examples and all resources.

Architecture

  • Yarn 4 pnpm linker baseline from chore: support Yarn's pnpm nodeLinker #3010; the Berry yarn.lock remains the package source of truth.
  • Generated JS BUILD metadata for workspace :pkg targets (gen_package_builds.mjs).
  • Generated native graph: swift package dump-package resolves Package.swift; generate_spm_targets.js writes deterministic Starlark metadata for 58 targets; one rn_spm_native_graph() call creates the Bazel libraries without BUILD files throughout React source.
  • React source build: canonical source-header projection + Bazel-generated FBReactNativeSpec; the aggregate spm_React mirrors SwiftPM's all-target React product.
  • Pinned bootstrap artifacts:
    • ReactNativeDependencies 0.86.0 (f6533c…b78f2)
    • Hermes from RN 0.81.0 (45ae8f…22fbe), whose .hermesversion names the exact fork merge-base Hermes commit e0fc671…; the standalone macOS hermes.framework is imported.
  • Prebuilt React canary remains available with --//:rn_from_source=false.

RNTester fidelity

  • Uses RNTester's unmodified real AppDelegate.mm.
  • Bundles the real NativeComponent/CxxModule/ScreenshotManager JS (no stubs).
  • Embeds standard main.jsbundle for offline fallback.
  • Saves Metro assets (including all six navbar icons).
  • Compiles the macOS asset catalog/storyboard and embeds Assets.car, AppIcon.icns, entitlements, and PrivacyInfo.xcprivacy.
  • Embeds dynamic Hermes and ReactNativeDependencies frameworks.

Xcode 26 work

  • Prevent mobile deployment-target variables from mis-targeting host hermesc.
  • Add HERMES_APPLE_PLATFORMS for macOS-only source builds.
  • Scope C++20 to C++/ObjC++ files so mixed ObjC targets compile.

CI

.github/workflows/bazel.yml now has:

  • Linux Berry/rules_js proof.
  • macos-26 clean-checkout RNTester build running the exact command above.
  • Artifact checks for source linkage, signing, frameworks, bundle, asset catalog, privacy manifest, and tab icons.

See docs/bazel.md for design details, limitations, cache strategy, and the remaining from-source Hermes/RNDependencies roadmap.

Saadnajmi and others added 18 commits July 6, 2026 13:02
Establishes an additive, experimental Bazel build for react-native-macos and
proves the hardest, most novel piece end to end: driving aspect-build rules_js
from the repo's Yarn 4 (Berry) yarn.lock as the single source of truth, with no
committed pnpm-lock.

What's green (verified):
- `bazel test //tools/bazel/berry/example:verify` — a real Berry yarn.lock is
  translated to a pnpm-lock v9 by a dependency-free converter
  (tools/bazel/berry/berry_to_pnpm_lock.mjs) via a one-seam patch to rules_js
  (tools/bazel/patches/aspect_rules_js_berry.patch, applied with
  single_version_override), then rules_js fetches + links + runs it.
- On the real monorepo lock the converter yields 33 importers / 1333 packages
  with zero dangling references; rules_js fetches all 1333.

Scaffolded (tagged manual, WIP — see docs/bazel.md):
- metro_bundle macro (tools/bazel/js/metro.bzl) + rn-tester bundle target.
- Prebuilt XCFramework import seam (tools/bazel/apple/xcframeworks.bzl) and a
  macos_application slice reusing rn-tester's AppDelegate/main.

Also: MODULE.bazel/.bazelrc/.bazelversion/.bazelignore, a non-blocking CI
workflow that runs the green proof, and a design doc (docs/bazel.md). The
generated pnpm-lock.yaml and MODULE.bazel.lock are gitignored; the only
published-adjacent change is an inert empty pnpm.onlyBuiltDependencies in the
private monorepo root package.json.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…olding

Extends the Bazel slice toward an end-to-end RNTester macOS build.

First-party workspace linking (rules_js):
- Every workspace package now exposes a `:pkg` npm_package target via a generated
  BUILD.bazel, so rules_js can link first-party packages (react-native-macos etc.)
  from the Berry yarn.lock. `bazel build //packages/react-native:pkg` builds; rn-tester's
  node_modules links react-native-macos (consumed as `react-native`).

rn-tester slice:
- packages/rn-tester/BUILD.bazel: npm_link_all_packages + a Metro bundle target
  (js_run_binary around bazel/bundle.js, which drives Metro's API with the
  react-native -> react-native-macos alias) + the macos_application wiring.
- packages/rn-tester/bazel/bundle.js: sandbox-safe Metro bundling entry.

Verified / documented (docs/bazel.md):
- rn-tester's macOS JS bundle builds outside Bazel (~7MB) after building
  @react-native/codegen lib — proving the JS is bundleable.
- Two remaining blockers for a running app, documented:
  1) the macOS app can't be built here because the Hermes nightly tarball 404s
     (no XCFrameworks); the app target is fully scaffolded for when they exist.
  2) the hermetic Bazel Metro bundle needs the strict-deps tooling closure declared
     (rules_js does not hoist like Yarn).

Targets that require these follow-ups remain tagged `manual`; the green Berry-fork
proof (//tools/bazel/berry/example:verify) is unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…a 404)

The earlier note blamed a "Hermes nightly 404". That was caused by forcing
HERMES_VERSION=nightly, which bypasses react-native-macos's Hermes version-resolution
patches (scripts/ios-prebuild/microsoft-hermes.js). Invoked correctly, ios-prebuild
resolves the right Hermes (merge-base commit on main / published artifact on release
branches). Building it from source in this environment fails for toolchain reasons:
default CMake is 4.x (use cmake@3.26.4), and Xcode 26 / AppleClang 21 trips LLVM's
CheckAtomic. The prebuild CI pins Xcode 16.2/16.1, which is what builds these
XCFrameworks. Not a fork gap.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On Xcode 26 / AppleClang 21, clang honors the *_DEPLOYMENT_TARGET environment
variables and mis-targets the *native* host hermesc build to visionOS when
XROS_DEPLOYMENT_TARGET is set in the environment (XROS takes precedence even if
MACOSX_DEPLOYMENT_TARGET is also set). ios-prebuild sets XROS_DEPLOYMENT_TARGET for
the cross-platform target builds and it leaks into build_host_hermesc, producing:

  cc: warning: using sysroot for 'MacOSX' but targeting 'XR' [-Wincompatible-sysroot]
  error: 'pthread_setname_np' is unavailable: not available on visionOS

which fails llvh's CheckAtomic ("Host compiler appears to require libatomic, but
cannot find it") and aborts the from-source Hermes build.

Build the native host tools in a subshell that unsets the mobile
IOS/XROS/TVOS_DEPLOYMENT_TARGET vars and sets MACOSX_DEPLOYMENT_TARGET, so clang
targets macOS. Verified: HAVE_CXX_ATOMICS_WITHOUT_LIB now succeeds and hermesc
compiles under Xcode 26.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ORMS)

Adds an opt-in HERMES_APPLE_PLATFORMS env var to build only selected Apple slices
into hermes.xcframework (default: all, unchanged). Enables macOS-only from-source
Hermes builds, which is useful on Xcode 26+ (the upstream Hermes Mac Catalyst target
triple is rejected by the newer clang) and greatly speeds up macOS-only iteration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
With the Hermes host-build fix + HERMES_APPLE_PLATFORMS subset + CMake 3.x, verified on
Xcode 26.4 that ios-prebuild produces hermes.xcframework (from source),
ReactNativeDependencies.xcframework (downloaded), and React.xcframework (BUILD SUCCEEDED).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… 26)

Adds tools/bazel/apple/prebuilt_xcframeworks.bzl: a repository rule + module extension
that symlinks the SPM-prebuilt React/ReactNativeDependencies/hermes XCFrameworks (from
packages/react-native/.build and third-party/) into a Bazel repo and exposes
apple_*_xcframework_import targets. Verified on Xcode 26.4:
  bazel build @rn_prebuilt_xcframeworks//:React //:hermes //:ReactNativeDependencies
builds. Wires the rn-tester macOS app host deps to these targets.

Note: rn-tester's real AppDelegate needs rn-tester-specific native modules + codegen not
present in the XCFrameworks, so a green app needs those built or a minimal RN host.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…undle progress

Converter correctness fix (tools/bazel/berry/berry_to_pnpm_lock.mjs):
- Apply Yarn `resolutions` when resolving dependency descriptors. Berry rewrites matching
  descriptors (e.g. resolutions:{debug:">=3.1.0"} makes all `debug` resolve to
  debug@npm:>=3.1.0) while dependency entries keep their original ranges (debug: "npm:4").
  Previously these edges were silently dropped (e.g. https-proxy-agent lost its `debug`
  dep), producing an incomplete node_modules graph.
- Trim dependency ranges so a descriptor like "npm:^3.1.0 " (Yarn keeps insignificant
  trailing whitespace) matches the trimmed descriptor key.
- Add a single-version fallback and report any dropped edges. Result: 0 dropped edges on
  the monorepo lock (previously silently dropping several).

rn-tester JS bundle (Phase A, still WIP/manual):
- Declare the bundler tooling closure on rn-tester (metro, @react-native/metro-config,
  @react-native/metro-babel-transformer, react) so rules_js's strict node_modules resolves
  it; regenerates yarn.lock.
- bazel/bundle.js: resolve project root/entry/out via env, disable watchman + enable
  symlinks for the sandbox. Metro now loads and resolves the full first-party + third-party
  graph; remaining blockers are documented in docs/bazel.md (first-party packages must be
  consumed in built/dist form, and a Metro file-map vs Bazel file-layout issue).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…dist, Metro file-map)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ers)

Phase A (JS bundle): rules_js drives Metro to emit a full 1.9MB
RNTesterApp.macos.jsbundle. First-party workspace packages are built in
dist form (first_party.bzl) so their src entry points don't escape the
copied node_modules; copy_tree.js stages a symlink-free project dir to
satisfy Metro's Node file-map crawler. Custom Metro resolver handles the
react-native -> react-native-macos alias (incl. subpaths) and directory
index resolution. Fixed the final output-path mismatch: pass bundleOut
(verbatim) instead of out, which Metro rewrites to force a .js suffix.

Phase B (codegen): tools/bazel/react_native builds @react-native/codegen
hermetically (codegen_lib) and generates AppSpecs + RCTAppDependencyProvider
for rn-tester's Native{Component,Module,CxxModule}Example.

All targets are Bazel-only and opt-in; the default yarn/xcodebuild/SPM
flows are untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bazel build //packages/rn-tester:RNTesterMacBazel now produces a launchable
macOS RNTester .app entirely with Bazel: the Metro JS bundle, a native
RCTReactNativeFactory host, and the prebuilt React/hermes/ReactNativeDependencies
XCFrameworks linked + the bundle embedded as an app resource.

The SPM prebuild flattens React.xcframework's headers into per-module dirs
(Headers/<Module>/x.h) and ships only a partial set, so the framework isn't
directly consumable by clang. Two pieces bridge it:

* tools/bazel/apple/reconstruct_react_headers.py — a repo-rule script that scans
  the framework headers' #include directives and rebuilds a canonical -I symlink
  tree for the Obj-C <React/...> / <RCTReactNativeFactory.h> surface (basename
  collisions disambiguated by path-segment match). Exposed as :React_headers with
  the RN RCT_* defines.
* //packages/react-native:rn_cxx_headers — the complete, canonically-nested C++
  headers (<react/renderer/...>, <ReactCommon/...>, <jsi/...>, <yoga/...>) sourced
  from the RN tree (same main version as the binary) via the podspec-equivalent
  include roots, including the react-native-macos view platform/macos override.
  Third-party <folly/...> etc. come from ReactNativeDependencies' canonical Headers.

RN's new-arch C++ needs C++20 (-std=c++20 in .bazelrc). The minimal host omits
rn-tester's own native example modules so it links against only the prebuilt
binaries; wiring those + the Phase B codegen is the next increment toward the full
AppDelegate. App/native targets stay tagged manual so bazel build //... stays green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tive modules)

Adds :RNTesterMacBazelFull, linking rn-tester's real RNTester/AppDelegate.mm
(via bazel/real_main.m) instead of the minimal host:

* Phase B codegen is now compiled: rn_codegen emits explicit file targets and
  wraps them in cc_library header targets with virtual prefixes so the app can
  #import <ReactCodegen/...>, <ReactAppDependencyProvider/...> and
  <react/renderer/components/AppSpecs/...>; rn_tester_appspecs_lib compiles the
  AppSpecs + providers (RCTAppDependencyProvider is linked into the app).
* NativeCxxModuleExample (C++ TurboModule) compiled + exposed as
  <NativeCxxModuleExample/...>.
* Sample TurboModules (SampleTurboCxxModule / RCTSampleTurboModule) built from the
  RN source samples; +samples include roots on rn_cxx_headers.
* RCTLinking + RCTPushNotification built from source (not in the prebuilt
  React.xcframework) and exposed as <React/...>; UserNotifications.framework linked.
* reconstruct_react_headers.py: map the <FBReactNativeSpec/FBReactNativeSpec.h>
  core-codegen umbrella (referenced by app sources, not framework headers).

RN_DISABLE_OSS_PLUGIN_HEADER still skips the Fabric NativeComponentExample (its
sources use quoted plugin includes not yet wired). All targets stay tagged manual.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mple

Builds NativeComponentExample (RNTMyNativeView Fabric component + legacy/interop
views) and links it into :RNTesterMacBazelFull, dropping RN_DISABLE_OSS_PLUGIN_HEADER.
The real, unmodified rn-tester AppDelegate.mm now compiles + links with ALL its
example modules (codegen providers, C++ TurboModule, Fabric component, sample
TurboModules) — no rn-tester source is patched or #ifdef'd out.

A small header shim exposes the core <RCTFabricComponentsPlugins.h> (quoted by the
component sources; impl lives in React.xcframework).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Applies the JS-ecosystem ergonomic lesson (derive build metadata from
package.json rather than hand-authoring per package) to our Bazel setup:

* tools/bazel/js/workspace_package.bzl - rn_workspace_package() macro that
  collapses the repeated npm_link_all_packages()+npm_package boilerplate.
* tools/bazel/js/gen_package_builds.mjs - reads the root package.json
  'workspaces' globs and (re)writes the boilerplate BUILD files. It only owns
  generator-owned files (its @generated marker, or pure legacy :pkg boilerplate)
  and skips anything declaring other targets (react-native, rn-tester, the
  first_party dist packages, etc.), mirroring Gazelle's '# gazelle:' override model.
  Supports --all (create missing) and --check (CI staleness guard).

Regenerated 23 boilerplate files from 24 -> 6 lines each (net -414 lines).
Verified: the generated :pkg targets build and the rn-tester JS bundle still
bundles. This is the incremental step toward full gazelle-based generation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…xes app launch)

The SPM prebuild ships React.framework and ReactNativeDependencies.framework as
dynamic frameworks (dylibs), but they were imported via apple_static_xcframework_import,
so rules_apple linked them with @rpath load commands without embedding them. The app
crashed at launch in dyld: 'Library not loaded: @rpath/React.framework/Versions/A/React'.

Importing all three (React, ReactNativeDependencies, hermes) as dynamic embeds them in
Contents/Frameworks. Verified: RNTesterMacBazel.app now launches and stays running
(loads the embedded Metro bundle + Hermes offline).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…JS inner loop

Captures the lessons from the pow.rs 'Bazel is incompatible with JavaScript'
critique: keep the JS dev loop (yarn/Metro/Jest/debugging) off Bazel, consume
artifacts at seams rather than re-Bazelifying node_modules, stay a two-way door
(additive/manual), and mind the single-server-per-output_base serialization.
Grounds the node_modules friction in our own copy_tree.js/first_party.bzl workarounds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Saadnajmi Saadnajmi changed the title [Draft] Bazel support: build the full RNTester.app on Xcode 26 (rules_js Berry fork + native XCFramework link) build(bazel): add experimental RNTester macOS build Jul 9, 2026
Saadnajmi and others added 7 commits July 9, 2026 14:22
- satisfy Flow annotation and import-order lint rules for Bazel JS tools
- ignore Bazel output symlinks so eslint remains usable after a Bazel build
- verify generated workspace BUILD files in CI and broaden path triggers
- preserve hand-owned targets in generated BUILD files
- regenerate the translated pnpm lock whenever yarn.lock changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add //packages/rn-tester/RNTester-macOS:app as the stable entry point.

Bundle the real native-example JavaScript through package-local js_library targets instead of replacing it with stubs. Resolve RNTester source-relative react-native imports against the Bazel package alias, emit the conventional main.jsbundle expected by RCTBundleURLProvider, and keep the minimal host on the same bundle name.

Verified the exact target builds on Xcode 26.4, embeds one 1.9 MB main.jsbundle containing the Fabric/Screenshot/C++ TurboModule implementations, and launches without Metro.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use swift package dump-package as the canonical graph resolver and emit deterministic Starlark metadata for all 56 SwiftPM targets. A single rn_spm_native_graph() call creates prefixed objc_library targets in packages/react-native, preserving source/exclude sets, dependencies, C++ flags, defines, header search paths, and macOS frameworks without BUILD files throughout the source tree.

Bridge the SwiftPM binary dependency to both ReactNativeDependencies.framework and its canonical header tree. Verified generated Yoga, React-debug, React-jsi, and React-utils targets compile from source on Xcode 26.4.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a source-derived canonical header repository, scope C++20 to C++/ObjC++ files, preserve Hermes/RNDependencies header seams, and generate/compile FBReactNativeSpec inside Bazel. The Package.swift-derived graph now compiles through spm_React_RCTAppDelegate on Xcode 26.4.

Add a default-off --//:rn_from_source flag and consistent source/prebuilt header selections for RNTester, codegen, and native examples. App-level source mode remains experimental and is intentionally not made the default in this commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Model Package.swift's React product as the aggregate of all generated regular targets, add the missing DevTools runtime settings targets, and preserve required macOS frameworks. The default-off rn_from_source mode now builds and launches RNTester without embedding React.framework while retaining prebuilt Hermes and ReactNativeDependencies as bootstrap inputs.

Complete application resources as part of the same end-to-end milestone: save Metro assets returned by runBuild, compile the macOS asset catalog and storyboard, and embed app icons, privacy manifest, and entitlements. Verified both source and prebuilt modes build; source mode launches offline with the real native-example bundle and tab icons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Default rn_from_source to true and make native bootstrap artifacts available from a clean checkout. ReactNativeDependencies 0.86.0 and the ABI-exact RN 0.81.0 Hermes artifact are downloaded from Maven with pinned SHA-256 values; downloaded Hermes uses the archive's standalone macOS framework. Keep missing prebuilt React isolated behind the false canary branch instead of invalidating every native import.

Also fix the case-sensitive Package.swift TypeSafety path surfaced by the fresh-artifact test. Verified the exact command with local React, RNDependencies, and Hermes artifacts hidden:

  bazel build //packages/rn-tester/RNTester-macOS:app

The cold build completed and the resulting source-linked app launched with all resources.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Run the exact canonical build on macos-26 with no local native artifacts. Verify the resulting app is source-linked (no React.framework), signed, and contains pinned Hermes/RNDependencies, the JS bundle, asset catalog, privacy manifest, and all six navbar icons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant