Skip to content

[Draft] Bazel support: build the full RNTester.app on Xcode 26 (rules_js Berry fork + native XCFramework link)#3014

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

[Draft] Bazel support: build the full RNTester.app on Xcode 26 (rules_js Berry fork + native XCFramework link)#3014
Saadnajmi wants to merge 16 commits into
microsoft:mainfrom
Saadnajmi:saadnajmi/bazel-support-research

Conversation

@Saadnajmi

Copy link
Copy Markdown
Collaborator

Summary

Supersedes #2959 (the original PR's source fork is disabled, so that branch can no longer be updated). This carries the same three macOS Fabric focus/first-responder fixes forward, with @tvinhas's original commit cherry-picked to preserve authorship credit, plus one small refinement.

The three regressions were surfaced while validating the "Verify focus changes" item on #2901.

1. RCTViewComponentView.blur() was a no-op

resignFirstResponder on NSWindow doesn't clear the focused subview, so ref.blur() from JS did nothing. Now clears the first responder via makeFirstResponder:nil, guarded on self so it only clears focus when this view actually holds it — matching iOS [self resignFirstResponder] semantics and avoiding stealing focus from an unrelated view.

2. Fabric setAccessibilityFocus was a silent no-op on macOS

synchronouslyDispatchAccessbilityEventOnUIThread: had a #if !TARGET_OS_OSX guard with no #else, so AccessibilityInfo.setAccessibilityFocus(reactTag) did nothing on the Fabric path. Added the macOS branch (makeFirstResponder: + NSAccessibilityFocusedUIElementChangedNotification), mirroring the old-arch path in RCTAccessibilityManager.mm.

3. RCTVirtualViewComponentView keyboard-nav hooks

Added acceptsFirstResponder and accessibilityHitTest: to the macOS branch so keyboard/Tab and VoiceOver navigation unhide flagged-offscreen views under the hideOffscreenVirtualViews optimization. Partial by design (a fully hidden=YES view is dropped from the responder chain — full fix needs a hide-strategy redesign, tracked as follow-up).

Difference from #2959

  • Added the if ([[self window] firstResponder] == self) guard on blur() for iOS-parity / to avoid focus-stealing.
  • Trimmed the verbose inline comments.

Test plan

  • Builds clean: RNTester-macOS (xcodebuild -scheme RNTester-macOS -destination 'generic/platform=macOS') — BUILD SUCCEEDED.
  • All three changes are macOS-gated (#if TARGET_OS_OSX / #else) and preserve existing iOS/visionOS behavior.

Credit

Original work by @tvinhas in #2959 (commit cherry-picked to retain authorship).

Co-authored-by: Thiago Vinhas thiago@vinhas.net

Saadnajmi and others added 16 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>
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