diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 000000000000..5a76730ea401 --- /dev/null +++ b/.bazelignore @@ -0,0 +1,40 @@ +node_modules +.git +.yarn +packages/react-native/.build +packages/rn-tester/Pods +packages/helloworld +private/helloworld +packages/assets/node_modules +packages/babel-plugin-codegen/node_modules +packages/community-cli-plugin/node_modules +packages/core-cli-utils/node_modules +packages/debugger-frontend/node_modules +packages/debugger-shell/node_modules +packages/dev-middleware/node_modules +packages/eslint-config-react-native/node_modules +packages/eslint-plugin-react-native/node_modules +packages/eslint-plugin-specs/node_modules +packages/gradle-plugin/node_modules +packages/metro-config/node_modules +packages/new-app-screen/node_modules +packages/normalize-color/node_modules +packages/polyfills/node_modules +packages/react-native-babel-preset/node_modules +packages/react-native-babel-transformer/node_modules +packages/react-native-codegen/node_modules +packages/react-native-compatibility-check/node_modules +packages/react-native-macos-init/node_modules +packages/react-native-popup-menu-android/node_modules +packages/react-native-test-library/node_modules +packages/react-native/node_modules +packages/rn-tester/node_modules +packages/typescript-config/node_modules +packages/virtualized-lists/node_modules +private/cxx-public-api/node_modules +private/eslint-plugin-monorepo/node_modules +private/monorepo-tests/node_modules +private/react-native-bots/node_modules +private/react-native-codegen-typescript-test/node_modules +private/react-native-fantom/node_modules +tools/bazel/berry/example/node_modules diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 000000000000..5cb250e37101 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,23 @@ +# react-native-macos Bazel configuration (draft / experimental) +# See docs/bazel.md for the design and current status. + +# Our pnpm-lock.yaml is generated on the fly from the Berry yarn.lock by the +# rules_js fork, so we don't pin the module resolution in MODULE.bazel.lock. +common --lockfile_mode=off + +# Local disk cache (also the base for the GHA actions/cache remote cache). +build --disk_cache=~/.cache/bazel-rnm-disk + +# Nicer output. +build --show_result=1 + +# Apple: build macOS slices. Scoped in practice to the app target's transitions. +build:macos --apple_platform_type=macos + +# React Native's new architecture C++ (Fabric/TurboModules) requires C++20. +# This only affects C++/Obj-C++ compiles; the JS (rules_js) targets are unaffected. +build --cxxopt=-std=c++20 + +# CI convenience config. +build:ci --color=yes +build:ci --show_timestamps diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 000000000000..df5119ec64e6 --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.7.0 diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml new file mode 100644 index 000000000000..bb4bcbfe6145 --- /dev/null +++ b/.github/workflows/bazel.yml @@ -0,0 +1,51 @@ +# Experimental Bazel CI for react-native-macos. +# +# Today this verifies the rules_js *Berry fork* end to end: it builds/tests a target +# whose node_modules are materialized from a Yarn Berry `yarn.lock` (translated to a +# pnpm-lock by tools/bazel/berry, with no committed second lockfile). The macOS app +# slice (rules_apple) is added incrementally — see docs/bazel.md. +name: Bazel (experimental) + +on: + pull_request: + paths: + - "MODULE.bazel" + - ".bazelrc" + - ".bazelversion" + - ".bazelignore" + - "BUILD.bazel" + - "tools/bazel/**" + - "packages/**/BUILD.bazel" + - ".github/workflows/bazel.yml" + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: bazel-${{ github.ref }} + cancel-in-progress: true + +jobs: + berry-fork: + name: rules_js Berry fork (JS) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + # Disk cache doubles as a simple GHA-backed Bazel remote cache. For a real + # remote cache at scale, point --remote_cache at BuildBuddy or a self-hosted + # bazel-remote (see docs/bazel.md). + - name: Cache Bazel + uses: actions/cache@v4 + with: + path: ~/.cache/bazel-rnm-disk + key: bazel-berry-${{ runner.os }}-${{ hashFiles('MODULE.bazel', 'yarn.lock', 'tools/bazel/**') }} + restore-keys: | + bazel-berry-${{ runner.os }}- + - name: Test the Berry -> rules_js pipeline end to end + run: | + npx --yes @bazel/bazelisk test //tools/bazel/berry/example:verify \ + --config=ci --test_output=errors diff --git a/.gitignore b/.gitignore index ce454b9629b1..8f26c47320b7 100644 --- a/.gitignore +++ b/.gitignore @@ -204,3 +204,17 @@ fix_*.patch .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md # macOS] + +# Generated by the rules_js Berry fork from yarn.lock (not committed) +/pnpm-lock.yaml + +# Berry fork example: generated lock + transient node_modules +/tools/bazel/berry/example/pnpm-lock.yaml +tools/bazel/berry/example/node_modules +tools/bazel/berry/example/.yarn + +# Bazel resolution lock (module deps pinned via BCR; pnpm-lock is generated) +/MODULE.bazel.lock + +# Bazel convenience symlinks +/bazel-* diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 000000000000..4aebeedb374c --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,6 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") + +# Links the pnpm-resolved (from the Berry yarn.lock via our fork) node_modules +# into the Bazel build graph. Downstream JS targets depend on +# //:node_modules/. +npm_link_all_packages(name = "node_modules") diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 000000000000..453d3a1ec461 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,67 @@ +module( + name = "react_native_macos_monorepo", + version = "0.0.0", + compatibility_level = 1, +) + +############################################################################### +# JavaScript / Node toolchain (aspect-build rules_js) +############################################################################### +bazel_dep(name = "aspect_rules_js", version = "3.2.2") +bazel_dep(name = "aspect_rules_ts", version = "3.8.11") +bazel_dep(name = "aspect_bazel_lib", version = "2.22.5") +bazel_dep(name = "rules_nodejs", version = "6.7.5") + +# Local fork of rules_js: teach `npm_translate_lock` to read the Yarn Berry (v2+) +# `yarn.lock` directly, so it stays the single source of truth (no committed +# second lockfile). The patch swaps the `pnpm import` step for our bundled +# Berry -> pnpm-lock converter (tools/bazel/berry/berry_to_pnpm_lock.mjs) when +# the input yarn.lock is a Berry lockfile. See docs/bazel.md. +single_version_override( + module_name = "aspect_rules_js", + patch_strip = 1, + patches = ["//tools/bazel/patches:aspect_rules_js_berry.patch"], + version = "3.2.2", +) + +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node", dev_dependency = True) +node.toolchain(node_version = "22.14.0") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm", dev_dependency = True) +npm.npm_translate_lock( + name = "npm", + # The Berry yarn.lock is the single source of truth. Our rules_js patch + # converts it to an (uncommitted, gitignored) pnpm-lock.yaml at rule time + # before parsing, so update_pnpm_lock (pnpm import) is left off. + yarn_lock = "//:yarn.lock", + pnpm_lock = "//:pnpm-lock.yaml", + update_pnpm_lock = False, + verify_node_modules_ignored = "//:.bazelignore", + quiet = False, +) +use_repo(npm, "npm") + +# Self-contained green proof of the Berry fork (see tools/bazel/berry/example). +npm.npm_translate_lock( + name = "npm_berry_example", + yarn_lock = "//tools/bazel/berry/example:yarn.lock", + pnpm_lock = "//tools/bazel/berry/example:pnpm-lock.yaml", + update_pnpm_lock = False, + verify_node_modules_ignored = "//:.bazelignore", + quiet = False, +) +use_repo(npm, "npm_berry_example") + +############################################################################### +# Apple toolchain (rules_apple / rules_swift) — used by the macOS app slice +############################################################################### +bazel_dep(name = "rules_apple", version = "4.5.3") +bazel_dep(name = "rules_swift", version = "3.6.1") +bazel_dep(name = "apple_support", version = "2.7.0") +bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "platforms", version = "1.1.0") + +# Expose the SPM-prebuilt React Native XCFrameworks (built by scripts/ios-prebuild.js) +# to Bazel so the macOS app slice can link them. +rn_prebuilt = use_extension("//tools/bazel/apple:prebuilt_xcframeworks.bzl", "rn_prebuilt_xcframeworks_extension") +use_repo(rn_prebuilt, "rn_prebuilt_xcframeworks") diff --git a/docs/bazel.md b/docs/bazel.md new file mode 100644 index 000000000000..029427eef6f9 --- /dev/null +++ b/docs/bazel.md @@ -0,0 +1,333 @@ +# Bazel support for react-native-macos (draft / experimental) + +This document describes the **experimental Bazel build** being added to +react-native-macos, why it is designed the way it is, what works today, and the +roadmap. It is intentionally additive: the existing yarn / Metro / xcodebuild / SPM / +CocoaPods workflows are unchanged. + +## Goal + +Prove a **full working vertical slice**: bundle rn-tester's JavaScript with Metro +(driven by Bazel via aspect-build `rules_js`) and link/embed it into a macOS app +(`macos_application` via `rules_apple`) that consumes the prebuilt React Native +XCFrameworks — an end-to-end `bazel run` of a macOS RN app. Longer term, build those +XCFrameworks from source in Bazel too (see the roadmap). + +## Why Bazel + +* **Incrementality + caching**: fine-grained, content-addressed caching of JS bundling + and (later) native compilation, shared between CI and local dev via a remote cache. +* **One graph across languages**: the JS→native seam (a Metro bundle becoming an app + resource) is modeled explicitly, the way Meta does it in Buck2's `js`/`apple` + preludes — but reusing Metro and rules_apple instead of reimplementing them. + +## Scope: where Bazel earns its keep here (and where it doesn't) + +Bazel is a *poor* choice for the JavaScript inner loop, and the critique in +["Bazel is incompatible with JavaScript" (pow.rs)](https://pow.rs/blog/bazel-is-incompatible-with-javascript/) +is largely fair **for that use case**. We hit its Problem 1 directly: rules_js uses a +strict, **non-hoisted, copied** `node_modules`, which is exactly why this slice needs +`tools/bazel/js/copy_tree.js` (to stage a symlink-free project dir Metro's file-map can +hash) and `first_party.bzl` (to consume first-party packages in built `dist` form +because their `src` entry points `require('../../../scripts/babel-register')` and escape +the copied tree). That is real friction and real disk/IO cost. + +The important distinction: **we do not put the JS dev loop on Bazel.** `yarn`, Metro's +dev server, Jest, and JS debugging stay exactly as they are — Bazel is opt-in +(`manual`-tagged targets) and additive, so none of the day-to-day JS workflow is +affected. What Bazel is actually for here is the thing the JS ecosystem tools +(Turborepo/Nx/Lage) *cannot* do: run the **Apple toolchain**, link **XCFrameworks**, +run **codegen**, and assemble a signed **`.app`** — one reproducible, remotely-cacheable +graph over JS **and** native. Our build time is dominated by the native compiles +(Hermes, React C++), which is precisely where Bazel's action cache / RBE pays off; the +JS bundle is a small, leaf step. + +Design guardrails we adopt as a result: + +* **Keep the JS inner loop off Bazel.** Never make `bazel` a prerequisite for editing JS, + running Metro, or debugging. The article's strongest point. +* **Consume artifacts at seams, don't re-Bazelify the world.** We already treat the + XCFrameworks as prebuilt inputs; the JS bundle can be treated the same way (build it + with plain Metro, feed the `.jsbundle` in) if the rules_js `node_modules` tax ever + outweighs the benefit of an in-graph bundle. Prefer the `:node_modules` glob over + hand-declaring individual packages (the article notes this keeps you correct). +* **Stay a two-way door.** Everything is additive and `manual`; no forced repo-wide + migration. Adoption and *removal* are both cheap. +* **Mind the single Bazel server.** Bazel is massively parallel *within* a build, but one + server per `--output_base` serializes separate `bazel` invocations ("Another Bazel + command is running…"). Use distinct `--output_base`s for parallel lanes/CI shards + rather than expecting two CLIs to share one. (The article's "single-threaded" framing + conflates these — intra-build parallelism is a Bazel strength.) +* **Expect to enumerate outputs.** Declaring every generated file is inherent to Bazel's + model (we hit it with codegen — see `rn_codegen`'s explicit `_CODEGEN_OUTS`). Use + `out_dirs` TreeArtifacts where a step's outputs aren't statically known. + +Net: use **JS-native tools (Turborepo/Nx/Lage/pnpm) for the JS package graph and dev +loop**, and **Bazel only for the native app slice** where it's genuinely better. They +coexist — Turborepo can even run inside a Bazel monorepo. + +## The single-source-of-truth lockfile problem (and the rules_js Berry fork) + +The repo uses **Yarn 4 (Berry)**; `yarn.lock` is `__metadata: version: 8`. We keep +that Berry `yarn.lock` as the **single source of truth** — no committed second lockfile. + +aspect `rules_js` normalizes every lockfile to an internal pnpm model. `npm_translate_lock` +does this in two stages: + +1. **foreign lock → `pnpm-lock.yaml`**: for a `yarn_lock`/`npm_package_lock` input it + shells out to the `pnpm import` binary + (`update_cmd = ["import"] …` in `npm/private/npm_translate_lock.bzl`). +2. **`pnpm-lock.yaml` → Bazel repo**: a Starlark parser (`npm/private/pnpm.bzl`) builds + `importers`/`packages`/`snapshots` and strictly cross-validates them. + +**The gap is isolated to stage 1**: `pnpm import` (and hence rules_js's `yarn_lock` path) +supports Yarn *Classic* v1 and npm — **but not Yarn Berry v2+** (pnpm issue #2991). +Stage 2 is format-agnostic and requires **pnpm lockfile v9**. + +### The fork + +We patch rules_js at exactly that seam +(`tools/bazel/patches/aspect_rules_js_berry.patch`, applied via bzlmod +`single_version_override`). The patch adds `_generate_pnpm_lock_from_berry`, which runs +at the top of `parse_and_verify_lock`: when the input `yarn.lock` is a Berry lockfile and +no `pnpm-lock.yaml` exists yet, it invokes our **dependency-free Node converter** +(`tools/bazel/berry/berry_to_pnpm_lock.mjs`) to translate the Berry lock into a +**pnpm-lock v9** written into the source root. That file is **gitignored** — generated at +rule time, never committed. `update_pnpm_lock` is left `False` (the `pnpm import` path is +never taken). The patch `rctx.watch`es the yarn.lock and the converter so changes +retrigger it. + +The converter reconstructs the whole graph from the Berry lock alone: + +* **descriptors → versions**: every Berry descriptor (`name@range`) maps to its resolved + entry, so package dependency ranges resolve to concrete versions (and thus to + `snapshots` keys). +* **workspaces → importers** with `link:` deps computed as importer-relative paths. +* **npm aliases** (e.g. `rxjs → @react-native-community/rxjs@6.5.4-custom`) map to the + aliased package's full snapshot key. +* It self-validates internal consistency exactly like `pnpm.bzl` does. + +On the real monorepo lock this yields **33 importers / 1333 packages, zero dangling +references**. + +### Known limitation: integrity + +Berry's `checksum` is Yarn's **own content hash**, *not* the npm tarball's `sha512` +integrity that pnpm/rules_js expect, so we cannot derive `resolution.integrity` from the +Berry lock. rules_js requires `integrity` **or** `tarball`, so the converter emits the +deterministic npm registry **tarball URL** and packages are downloaded **without integrity +verification**. This is acceptable for a draft but should be hardened for production by +sourcing real integrities — e.g. a one-time npm-registry packument fetch (cached) or a +small Yarn plugin that exports npm integrities alongside `yarn.lock`. + +### Why a fork and not BYONM + +"Bring your own node_modules" (a `repository_rule` running `yarn install`) also works and +is documented by aspect, but it gives coarse-grained caching and bypasses rules_js's +package graph. The fork keeps rules_js's fine-grained model while reading the Berry lock. +BYONM remains a viable fallback. + +## What works today (verified green) + +**`bazel build //packages/rn-tester:RNTesterMacBazel` produces a launchable macOS +RNTester `.app`, built entirely by Bazel on Xcode 26** — JS bundle, native host, and +prebuilt-XCFramework link. Specifically: + +* `bazel test //tools/bazel/berry/example:verify` — a self-contained proof: a real Berry + `yarn.lock` (is-odd → is-number) is translated by the fork, fetched and linked by + rules_js, and a Node program that `require`s the npm dep runs green. +* On the **real monorepo** `yarn.lock`, `npm_translate_lock` parses the fork-generated + lock and **fetches all 1333 packages** successfully. +* **First-party workspace linking**: every workspace package exposes a `:pkg` target + (`npm_package`) so rules_js can link it, including `react-native-macos` (consumed as + `react-native`). +* **rn-tester's macOS JS bundle builds *in Bazel*** — `//packages/rn-tester:rntester_macos_jsbundle` + drives Metro (via `bazel/bundle.js`) to emit the real ~1.9 MB `RNTesterApp.macos.jsbundle`. +* **Codegen in Bazel** — `//tools/bazel/react_native:rn_tester_appspecs` builds + `@react-native/codegen` hermetically and generates AppSpecs + `RCTAppDependencyProvider`. +* **The macOS app links + assembles** — `:RNTesterMacBazel` (`macos_application`) compiles a + minimal `RCTReactNativeFactory` host, links the prebuilt React/hermes/ReactNativeDependencies + XCFrameworks, and embeds the JS bundle. The resulting `.app` contains the arm64 binary, + `Contents/Resources/RNTesterApp.macos.jsbundle`, and `Contents/Frameworks/hermes.framework`. +* **The FULL rn-tester host also builds** — `:RNTesterMacBazelFull` links rn-tester's *real, + unmodified* `RNTester/AppDelegate.mm` with the Bazel-built codegen (`RCTAppDependencyProvider` + + AppSpecs compiled into `//tools/bazel/react_native:rn_tester_appspecs_lib`), the C++ + TurboModule example (`NativeCxxModuleExample`), the Fabric `NativeComponentExample` + (`RNTMyNativeView`), the sample TurboModules (`//packages/react-native:sample_turbo_modules`), + and the RCTLinking/RCTPushNotification modules built from source (not in the prebuilt + framework). All example modules link in — no rn-tester source is modified or `#ifdef`'d out. + +### Consuming the prebuilt XCFrameworks from Bazel (the header problem) + +The SPM prebuild (`scripts/ios-prebuild.js`) flattens every SPM target's public headers into +`React.xcframework/…/Headers//.h` (e.g. `Headers/React_Core/RCTBridge.h`). +But both the framework's own headers *and* app sources import them by canonical path +(``, ``, ``, +``), and the framework only ships a *partial* header set (the deep +Fabric/renderer C++ headers are missing). So the framework is not directly consumable. This +slice bridges it with two pieces: + +1. **`@rn_prebuilt_xcframeworks//:React_headers`** — a repo rule runs + `tools/bazel/apple/reconstruct_react_headers.py`, which scans the framework headers' + `#include <…>` directives and rebuilds a canonical `-I` symlink tree for the **Obj-C** + ``, ``, ``, `` surface + (disambiguating basename collisions by path-segment match). It also carries the RN + `RCT_*` compile defines. +2. **`//packages/react-native:rn_cxx_headers`** — the complete, canonically-nested **C++** + headers (``, ``, ``, ``) come from the RN + *source* tree (same `main` version as the binary), exposed via the podspec-equivalent + include roots (incl. the `platform/ios`, `platform/cxx`, and react-native-macos + `platform/macos` overrides). Third-party `` etc. come from the + `ReactNativeDependencies` xcframework's canonical `Headers/`. + +RN's new-architecture C++ requires **C++20** (`-std=c++20`, set in `.bazelrc`). + +## End-to-end RN-Tester: how the app is built + +``` +Berry yarn.lock ──(rules_js fork)──▶ node_modules ──(Metro)──▶ RNTesterApp.macos.jsbundle +prebuilt React.xcframework ──(reconstruct_react_headers.py)──▶ :React_headers (Obj-C hdrs) +RN source ReactCommon/** ────────────────────────────────────▶ :rn_cxx_headers (C++ hdrs) + │ + ▼ +bazel/MinimalAppDelegate.mm (RCTReactNativeFactory host) + │ + link React/hermes/ReactNativeDependencies XCFrameworks + ▼ +macos_application :RNTesterMacBazel ──▶ RNTesterMacBazel.app (embeds the jsbundle) +``` + +The minimal host (`bazel/MinimalAppDelegate.mm` + `bazel/minimal_main.m`) boots +`RCTReactNativeFactory` against the embedded bundle. It intentionally omits rn-tester's own +native example modules (NativeCxxModuleExample / RNTMyNativeView) so the app links against +only the prebuilt binaries; adding those (compiling the Phase B codegen + the example native +code) is the natural next increment toward the *full* rn-tester `AppDelegate.mm`. + +### Prebuilt XCFrameworks build on Xcode 26 + +react-native-macos already carries the Hermes version-resolution patches +(`scripts/ios-prebuild/microsoft-hermes.js`): a release branch downloads a published Hermes; +`main` (`1000.0.0`) builds Hermes from source at the merge-base commit. Getting that working on +**Xcode 26** required two fixes in this branch: +* **Host `hermesc` mis-targeted to visionOS.** `ios-prebuild` sets `XROS_DEPLOYMENT_TARGET` + for the cross-platform builds and it leaked into the *native* `build_host_hermesc`; Xcode 26's + clang honors it and built the host tools for visionOS, failing llvh's `CheckAtomic`. Fixed in + `sdks/hermes-engine/utils/build-apple-framework.sh` (force a macOS target for the host tools). +* **Upstream Hermes Mac Catalyst triple.** Hermes hardcodes an invalid universal Catalyst + triple (`-target x86_64-arm64-apple-ios…-macabi`) that Xcode 26's clang rejects. Catalyst + isn't needed for a macOS app, so `build-ios-framework.sh` accepts `HERMES_APPLE_PLATFORMS` + to build a subset (e.g. `macosx`). +* Use **CMake 3.x** (`cmake@3.26.4`); Hermes doesn't configure under Homebrew-default CMake 4.x. + +## What's next (increments) + +* **`bazel run`**: wire an ad-hoc-signed run so the app launches directly from Bazel. +* **CI**: run the app build on the macos-26 runner reusing the prebuild artifacts. +* **From source**: build the React/hermes/ReactNativeDependencies XCFrameworks in Bazel + (roadmap below) to drop the SPM prebuild + header reconstruction entirely. + +## Apple: prebuilt XCFrameworks (swappable seam) + +`tools/bazel/apple/xcframeworks.bzl` imports the prebuilt +`React` / `ReactNativeDependencies` / `hermes` XCFrameworks (produced by +`scripts/ios-prebuild.js`) via `apple_static_xcframework_import`, behind stable aliases +(`:React`, …). A future from-source build produces the *same* artifacts and drops in +behind these aliases with a `--//:rn_from_source` flag. + +Generate the XCFrameworks with: + +```sh +cd packages/react-native +node scripts/ios-prebuild.js -s -f Debug +node scripts/ios-prebuild.js -b -f Debug -p macos +node scripts/ios-prebuild.js -c -f Debug +``` + +## Xcode compatibility + +rules_apple tracks Xcode closely. rules_apple `master` already targets Xcode 26.4; the +pinned release here is `4.5.3` (with `rules_swift` 3.6.1). If the CI/local Xcode isn't +supported by the pinned rules_apple, either bump rules_apple (5.0.0-rc / master) or select +a supported Xcode via `DEVELOPER_DIR` / `--xcode_version`. Bazel is pinned to `8.7.0` +(`.bazelversion`). + +## CI and remote cache + +`.github/workflows/bazel.yml` runs the green Berry-fork test on `ubuntu-latest`. It caches +Bazel's `--disk_cache` via `actions/cache` (a zero-infra remote-cache stand-in). For a +real remote cache at scale, point `--remote_cache` at **BuildBuddy** (free tier: `grpcs` +endpoint + API-key secret) or a self-hosted **bazel-remote** (GCS/S3): read-only on PRs, +read-write on trunk. + +## Downstream / tarball safety + +The published npm package is `react-native-macos` (`packages/react-native`); its `files` +allowlist governs the tarball. All Bazel scaffolding lives at the repo root, in +`tools/bazel/`, and in `packages/rn-tester` (which is **private**, never published), so +tarballs are unaffected. The generated `pnpm-lock.yaml` and `MODULE.bazel.lock` are +gitignored and repo-dev-only. The only change to a published-adjacent file is an empty +`pnpm.onlyBuiltDependencies: []` in the **private monorepo root** `package.json` (matching +the repo's `enableScripts: false`), which is inert for Yarn and not published. + +## Future roadmap: build the XCFrameworks from source in Bazel + +`Package.swift` already enumerates the full target graph (each `RNTarget` ↔ a podspec), +giving a ready-made port map. Output the same `.xcframework`s via +`apple_static_xcframework`, swapped in behind the P3 alias + `--//:rn_from_source`: + +* **FA — Hermes**: keep the prebuilt Hermes (`http_archive`) initially; optionally wrap + the CMake build with `rules_foreign_cc` later. +* **FB — ReactNativeDependencies** (boost/folly/glog/fmt/double-conversion/…): model each + as `cc_library` (native or `rules_foreign_cc`), compose an `apple_static_xcframework`. +* **FC — Codegen in Bazel**: wrap `react-native-codegen` as a rules_js `js_run_binary` + genrule so generated C++/ObjC specs are Bazel-tracked inputs. +* **FD — React core**: port the `Package.swift` graph leaf-first (Yoga → jsi → ReactCommon + → React-Core / RCTUIKit → Fabric / TurboModule → RCT modules) to + `swift_library`/`objc_library`/`cc_library`; assemble `React.xcframework`. +* **FE — Swap + validate + CI**: flip `--//:rn_from_source`, validate ABI/behavior parity + vs the SPM XCFrameworks, and land the heavy native compiles on the remote cache (the + primary CI/local speedup). + +## Generating the `:pkg` BUILD files (no more boilerplate) + +Most workspace packages need exactly one Bazel target: a `:pkg` `npm_package` so +`npm_link_all_packages` can link them into the copied `node_modules` (rules_js does +not hoist like Yarn), which is what lets Metro bundle first-party JS. That was ~17 +identical 24-line `BUILD.bazel` files. Following the JS-ecosystem convention (derive +the build metadata from `package.json` instead of hand-authoring it — see +`docs`/research on Turborepo/Nx/Lage and Gazelle), those files are now **generated**: + +* `tools/bazel/js/workspace_package.bzl` — the `rn_workspace_package()` macro; each + generated `BUILD.bazel` is just a `load` + one call. +* `tools/bazel/js/gen_package_builds.mjs` — reads the `workspaces` globs from the root + `package.json` and (re)writes the boilerplate files. It only owns *generator-owned* + files (those carrying its `@generated-by` marker, or pure legacy `:pkg` boilerplate); + any BUILD that declares other targets (`objc_library`, `cc_library`, `first_party`, + `macos_application`, `rn_codegen`, …) is treated as hand-owned and skipped — the same + "generate the 95%, allow local overrides" model as Gazelle's `# gazelle:` directives. + +``` +node tools/bazel/js/gen_package_builds.mjs # refresh the boilerplate +node tools/bazel/js/gen_package_builds.mjs --all # also create any missing ones +node tools/bazel/js/gen_package_builds.mjs --check # CI: non-zero if stale +``` + +This is the incremental step toward full `bazel run //:gazelle`-style generation with +the Aspect JS/TS Gazelle plugin (which would also infer first-party `deps` from +imports); a custom Gazelle extension could later emit `rn_codegen`, the Metro bundle, +and the prebuilt-XCFramework targets too. + +## Layout + +``` +MODULE.bazel # bzlmod: rules_js/ts/apple/swift + Berry fork override +.bazelrc, .bazelversion, .bazelignore +BUILD.bazel # npm_link_all_packages(name="node_modules") +tools/bazel/berry/ + berry_to_pnpm_lock.mjs # Berry yarn.lock -> pnpm-lock v9 converter + example/ # self-contained GREEN proof of the fork +tools/bazel/patches/ + aspect_rules_js_berry.patch # the one-seam rules_js patch +tools/bazel/js/metro.bzl # metro_bundle macro (thin Metro wrapper) +tools/bazel/apple/xcframeworks.bzl # import prebuilt React XCFrameworks +packages/rn-tester/BUILD.bazel # the macOS vertical-slice app (manual, WIP) +``` diff --git a/package.json b/package.json index 52efcc047118..74760e1a8d04 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "version": "1000.0.0", "license": "MIT", "packageManager": "yarn@4.12.0", + "pnpm": { + "onlyBuiltDependencies": [] + }, "scripts": { "android": "yarn --cwd packages/rn-tester android", "build-android": "./gradlew :packages:react-native:ReactAndroid:build", diff --git a/packages/assets/BUILD.bazel b/packages/assets/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/assets/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/babel-plugin-codegen/BUILD.bazel b/packages/babel-plugin-codegen/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/babel-plugin-codegen/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/community-cli-plugin/BUILD.bazel b/packages/community-cli-plugin/BUILD.bazel new file mode 100644 index 000000000000..788bf808c549 --- /dev/null +++ b/packages/community-cli-plugin/BUILD.bazel @@ -0,0 +1,9 @@ +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link the built package into node_modules for Bazel-driven Metro bundling. +load("//tools/bazel/js:first_party.bzl", "first_party_js_package") + +first_party_js_package( + name = "pkg", + build_kind = "monorepo", + visibility = ["//visibility:public"], +) diff --git a/packages/core-cli-utils/BUILD.bazel b/packages/core-cli-utils/BUILD.bazel new file mode 100644 index 000000000000..788bf808c549 --- /dev/null +++ b/packages/core-cli-utils/BUILD.bazel @@ -0,0 +1,9 @@ +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link the built package into node_modules for Bazel-driven Metro bundling. +load("//tools/bazel/js:first_party.bzl", "first_party_js_package") + +first_party_js_package( + name = "pkg", + build_kind = "monorepo", + visibility = ["//visibility:public"], +) diff --git a/packages/debugger-frontend/BUILD.bazel b/packages/debugger-frontend/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/debugger-frontend/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/debugger-shell/BUILD.bazel b/packages/debugger-shell/BUILD.bazel new file mode 100644 index 000000000000..788bf808c549 --- /dev/null +++ b/packages/debugger-shell/BUILD.bazel @@ -0,0 +1,9 @@ +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link the built package into node_modules for Bazel-driven Metro bundling. +load("//tools/bazel/js:first_party.bzl", "first_party_js_package") + +first_party_js_package( + name = "pkg", + build_kind = "monorepo", + visibility = ["//visibility:public"], +) diff --git a/packages/dev-middleware/BUILD.bazel b/packages/dev-middleware/BUILD.bazel new file mode 100644 index 000000000000..788bf808c549 --- /dev/null +++ b/packages/dev-middleware/BUILD.bazel @@ -0,0 +1,9 @@ +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link the built package into node_modules for Bazel-driven Metro bundling. +load("//tools/bazel/js:first_party.bzl", "first_party_js_package") + +first_party_js_package( + name = "pkg", + build_kind = "monorepo", + visibility = ["//visibility:public"], +) diff --git a/packages/eslint-config-react-native/BUILD.bazel b/packages/eslint-config-react-native/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/eslint-config-react-native/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/eslint-plugin-react-native/BUILD.bazel b/packages/eslint-plugin-react-native/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/eslint-plugin-react-native/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/eslint-plugin-specs/BUILD.bazel b/packages/eslint-plugin-specs/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/eslint-plugin-specs/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/gradle-plugin/BUILD.bazel b/packages/gradle-plugin/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/gradle-plugin/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/metro-config/BUILD.bazel b/packages/metro-config/BUILD.bazel new file mode 100644 index 000000000000..788bf808c549 --- /dev/null +++ b/packages/metro-config/BUILD.bazel @@ -0,0 +1,9 @@ +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link the built package into node_modules for Bazel-driven Metro bundling. +load("//tools/bazel/js:first_party.bzl", "first_party_js_package") + +first_party_js_package( + name = "pkg", + build_kind = "monorepo", + visibility = ["//visibility:public"], +) diff --git a/packages/new-app-screen/BUILD.bazel b/packages/new-app-screen/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/new-app-screen/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/normalize-color/BUILD.bazel b/packages/normalize-color/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/normalize-color/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/polyfills/BUILD.bazel b/packages/polyfills/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/polyfills/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/react-native-babel-preset/BUILD.bazel b/packages/react-native-babel-preset/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/react-native-babel-preset/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/react-native-babel-transformer/BUILD.bazel b/packages/react-native-babel-transformer/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/react-native-babel-transformer/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/react-native-codegen/BUILD.bazel b/packages/react-native-codegen/BUILD.bazel new file mode 100644 index 000000000000..28c6338d134e --- /dev/null +++ b/packages/react-native-codegen/BUILD.bazel @@ -0,0 +1,9 @@ +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link the built package into node_modules for Bazel-driven Metro bundling. +load("//tools/bazel/js:first_party.bzl", "first_party_js_package") + +first_party_js_package( + name = "pkg", + build_kind = "codegen", + visibility = ["//visibility:public"], +) diff --git a/packages/react-native-compatibility-check/BUILD.bazel b/packages/react-native-compatibility-check/BUILD.bazel new file mode 100644 index 000000000000..788bf808c549 --- /dev/null +++ b/packages/react-native-compatibility-check/BUILD.bazel @@ -0,0 +1,9 @@ +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link the built package into node_modules for Bazel-driven Metro bundling. +load("//tools/bazel/js:first_party.bzl", "first_party_js_package") + +first_party_js_package( + name = "pkg", + build_kind = "monorepo", + visibility = ["//visibility:public"], +) diff --git a/packages/react-native-macos-init/BUILD.bazel b/packages/react-native-macos-init/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/react-native-macos-init/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/react-native-popup-menu-android/BUILD.bazel b/packages/react-native-popup-menu-android/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/react-native-popup-menu-android/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/react-native-test-library/BUILD.bazel b/packages/react-native-test-library/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/react-native-test-library/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/react-native/BUILD.bazel b/packages/react-native/BUILD.bazel new file mode 100644 index 000000000000..0d2ed36de01a --- /dev/null +++ b/packages/react-native/BUILD.bazel @@ -0,0 +1,149 @@ +# Auto-generated first-party package target for rules_js linking. +# Exposes this workspace package as `:pkg` so the root npm_link_all_packages can +# link it into node_modules (needed to bundle first-party JS with Metro). +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") + +npm_link_all_packages() + +# Canonical C++ header roots from the RN source tree. The SPM-prebuilt +# React.xcframework only exports a partial header set; the deep Fabric/renderer +# C++ headers (e.g. , , +# ) live only in ReactCommon/ in source. Exposing them on the include +# path lets Bazel compile ObjC++/C++ that touches the new-architecture surface, +# while still linking against the prebuilt binary (same `main` version). +cc_library( + name = "rn_cxx_headers", + hdrs = glob( + [ + "ReactCommon/**/*.h", + "ReactCommon/**/*.hpp", + ], + allow_empty = True, + ), + includes = [ + "ReactCommon", + "ReactCommon/jsi", + "ReactCommon/callinvoker", + "ReactCommon/runtimeexecutor", + "ReactCommon/yoga", + "ReactCommon/react/nativemodule/core", + "ReactCommon/react/nativemodule/core/platform/ios", + "ReactCommon/react/nativemodule/samples", + "ReactCommon/react/nativemodule/samples/platform/ios", + "ReactCommon/react/runtime/platform/ios", + "ReactCommon/jsitooling", + "ReactCommon/jsiexecutor", + # Renderer platform overrides (Apple uses the ios graphics/text/image + # variants and the cxx view/text/scrollview variants; react-native-macos + # additionally provides a macos view override that takes precedence). + "ReactCommon/react/renderer/graphics/platform/ios", + "ReactCommon/react/renderer/imagemanager/platform/ios", + "ReactCommon/react/renderer/textlayoutmanager/platform/ios", + "ReactCommon/react/renderer/components/textinput/platform/ios", + "ReactCommon/react/utils/platform/ios", + "ReactCommon/react/renderer/components/view/platform/macos", + "ReactCommon/react/renderer/components/view/platform/cxx", + "ReactCommon/react/renderer/components/text/platform/cxx", + "ReactCommon/react/renderer/components/scrollview/platform/cxx", + ], + tags = ["manual"], + visibility = ["//visibility:public"], +) + +# rn-tester's Fabric NativeComponentExample quotes "RCTFabricComponentsPlugins.h", +# the core Fabric component-provider header. Its impl is in React.xcframework; expose +# the source header at its bare name so the quoted include resolves. +cc_library( + name = "rct_fabric_components_plugins_hdr", + hdrs = ["React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h"], + strip_include_prefix = "React/Fabric/Mounting/ComponentViews", + tags = ["manual"], + visibility = ["//visibility:public"], +) + +# rn-tester's real AppDelegate imports RCTLinkingManager + RCTPushNotificationManager, +# which are separate RN modules NOT included in the prebuilt React.xcframework. Build +# them from source and expose their headers as (matching the podspec layout). +cc_library( + name = "rct_linking_hdrs", + hdrs = ["Libraries/LinkingIOS/RCTLinkingManager.h"], + strip_include_prefix = "Libraries/LinkingIOS", + include_prefix = "React", + tags = ["manual"], + visibility = ["//visibility:public"], +) + +cc_library( + name = "rct_pushnotification_hdrs", + hdrs = [ + "Libraries/PushNotificationIOS/RCTPushNotificationManager.h", + "Libraries/PushNotificationIOS/RCTPushNotificationPlugins.h", + ], + strip_include_prefix = "Libraries/PushNotificationIOS", + include_prefix = "React", + tags = ["manual"], + visibility = ["//visibility:public"], +) + +objc_library( + name = "rntester_extra_rn_modules", + srcs = [ + "Libraries/LinkingIOS/RCTLinkingManager.mm", + "Libraries/LinkingIOS/RCTLinkingPlugins.h", + "Libraries/PushNotificationIOS/RCTPushNotificationManager.mm", + "Libraries/PushNotificationIOS/RCTPushNotificationPlugins.h", + "Libraries/PushNotificationIOS/RCTPushNotificationPlugins.mm", + ], + tags = ["manual"], + visibility = ["//visibility:public"], + deps = [ + ":rct_linking_hdrs", + ":rct_pushnotification_hdrs", + ":rn_cxx_headers", + "@rn_prebuilt_xcframeworks//:React", + "@rn_prebuilt_xcframeworks//:React_headers", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies_headers", + ], +) +objc_library( + name = "sample_turbo_modules", + srcs = [ + "ReactCommon/react/nativemodule/samples/ReactCommon/NativeSampleTurboCxxModuleSpecJSI.cpp", + "ReactCommon/react/nativemodule/samples/ReactCommon/SampleTurboCxxModule.cpp", + "ReactCommon/react/nativemodule/samples/platform/ios/ReactCommon/RCTNativeSampleTurboModuleSpec.mm", + "ReactCommon/react/nativemodule/samples/platform/ios/ReactCommon/RCTSampleTurboModule.mm", + ], + tags = ["manual"], + visibility = ["//visibility:public"], + deps = [ + ":rn_cxx_headers", + "@rn_prebuilt_xcframeworks//:React", + "@rn_prebuilt_xcframeworks//:React_headers", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies_headers", + ], +) + +npm_package( + name = "pkg", + srcs = glob( + ["**/*"], + exclude = [ + "**/node_modules/**", + "**/.build/**", + "**/build/**", + "**/Pods/**", + "**/__tests__/**", + "**/*.bazel", + "React/**", + "ReactApple/**", + "ReactCommon/**", + "ReactAndroid/**", + "sdks/**", + "third-party/**", + "ReactCxxPlatform/**", + ], + allow_empty = True, + ), + visibility = ["//visibility:public"], +) diff --git a/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh b/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh index ee71c715624a..37b72d04ad36 100755 --- a/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh +++ b/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh @@ -61,10 +61,21 @@ function get_mac_deployment_target { function build_host_hermesc { echo "Building hermesc" pushd "$HERMES_PATH" > /dev/null || exit 1 - cmake -S . -B build_host_hermesc -DJSI_DIR="$JSI_PATH" - cmake --build ./build_host_hermesc --target hermesc -j "${NUM_CORES}" + # [macOS] The host hermesc tools are always native macOS. On Xcode 26+, clang honors + # the *_DEPLOYMENT_TARGET environment variables and will mis-target this native build + # to visionOS when XROS_DEPLOYMENT_TARGET (or IOS/TVOS) is set in the environment + # (XROS takes precedence even if MACOSX_DEPLOYMENT_TARGET is also set), which breaks + # availability/atomics checks ("using sysroot for 'MacOSX' but targeting 'XR'"). Build + # the host tools in a subshell that forces a macOS target. + ( + unset IOS_DEPLOYMENT_TARGET XROS_DEPLOYMENT_TARGET TVOS_DEPLOYMENT_TARGET + export MACOSX_DEPLOYMENT_TARGET="${MAC_DEPLOYMENT_TARGET:-$(get_mac_deployment_target)}" + cmake -S . -B build_host_hermesc -DJSI_DIR="$JSI_PATH" + cmake --build ./build_host_hermesc --target hermesc -j "${NUM_CORES}" + ) popd > /dev/null || exit 1 } +# macOS] # Utility function to configure an Apple framework function configure_apple_framework { diff --git a/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh b/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh index c1a6a4aa7387..80c35b1a70ed 100755 --- a/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh +++ b/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh @@ -57,7 +57,8 @@ function build_framework { # group the frameworks together to create a universal framework function build_universal_framework { if [ ! -d destroot/Library/Frameworks/universal/hermes.xcframework ]; then - create_universal_framework "macosx" "iphoneos" "iphonesimulator" "catalyst" "xros" "xrsimulator" "appletvos" "appletvsimulator" # [macOS] + # shellcheck disable=SC2086 + create_universal_framework $HERMES_APPLE_PLATFORMS # [macOS] else echo "Skipping; Clean \"destroot\" to rebuild". fi @@ -67,14 +68,11 @@ function build_universal_framework { # this is used to preserve backward compatibility function create_framework { if [ ! -d destroot/Library/Frameworks/universal/hermes.xcframework ]; then - build_framework "macosx" # [macOS] - build_framework "iphoneos" - build_framework "iphonesimulator" - build_framework "appletvos" - build_framework "appletvsimulator" - build_framework "catalyst" - build_framework "xros" - build_framework "xrsimulator" + # [macOS] Build only the requested Apple platforms (defaults to all). + for _hermes_platform in $HERMES_APPLE_PLATFORMS; do + build_framework "$_hermes_platform" + done + # macOS] build_universal_framework else echo "Skipping; Clean \"destroot\" to rebuild". @@ -86,6 +84,14 @@ CURR_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" # shellcheck source=xplat/js/react-native-github/sdks/hermes-engine/utils/build-apple-framework.sh . "${CURR_SCRIPT_DIR}/build-apple-framework.sh" +# [macOS] Apple platforms (slices) to build into hermes.xcframework. Defaults to all. +# Override with the HERMES_APPLE_PLATFORMS env var (space-separated) to build a subset, +# e.g. HERMES_APPLE_PLATFORMS="macosx" for a macOS-only build. Useful on Xcode 26+, where +# the upstream Hermes Mac Catalyst target triple is rejected by the newer clang, and to +# speed up macOS-only iteration. +HERMES_APPLE_PLATFORMS="${HERMES_APPLE_PLATFORMS:-macosx iphoneos iphonesimulator catalyst xros xrsimulator appletvos appletvsimulator}" +# macOS] + if [[ -z $1 ]]; then create_framework elif [[ $1 == "build_framework" ]]; then diff --git a/packages/rn-tester/BUILD.bazel b/packages/rn-tester/BUILD.bazel new file mode 100644 index 000000000000..c715a4ab9a89 --- /dev/null +++ b/packages/rn-tester/BUILD.bazel @@ -0,0 +1,173 @@ +# React Native macOS — Bazel vertical slice (draft / experimental) +# +# Status (see docs/bazel.md): this slice builds RNTester as a macOS app entirely +# with Bazel on Xcode 26: +# * rules_js (Berry fork) drives Metro to produce the real rn-tester JS bundle +# (`:rntester_macos_jsbundle`, ~1.9MB). +# * The prebuilt React/hermes/ReactNativeDependencies XCFrameworks are imported and, +# because the SPM prebuild flattens React's headers, a canonical header tree is +# reconstructed (`@rn_prebuilt_xcframeworks//:React_headers`) and combined with the +# RN source C++ headers (`//packages/react-native:rn_cxx_headers`). +# * A minimal RCTReactNativeFactory host (`bazel/MinimalAppDelegate.mm`) compiles and +# links against those, and `:RNTesterMacBazel` (macos_application) embeds the bundle. +# `bazel build //packages/rn-tester:RNTesterMacBazel` produces a launchable .app. +# App/native targets are tagged `manual` (they need the prebuilt XCFrameworks present) +# so `bazel build //...` / CI stays green without them. + +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_library", "js_run_binary") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@rules_apple//apple:macos.bzl", "macos_application") + +package(default_visibility = ["//visibility:private"]) + +# rn-tester's node_modules, linked by rules_js from the Berry yarn.lock. +npm_link_all_packages(name = "node_modules") + +# All rn-tester JavaScript that Metro may pull into the bundle. +js_library( + name = "js_srcs", + srcs = glob( + [ + "js/**/*.js", + "js/**/*.gif", + "js/**/*.jpeg", + "js/**/*.jpg", + "js/**/*.json", + "js/**/*.png", + "js/**/*.webp", + "js/**/*.xml", + "RCTTest/**/*.js", + "ReportFullyDrawnView/**/*.js", + "NativeComponentExample/js/**/*.js", + "NativeModuleExample/**/*.js", + "NativeCxxModuleExample/**/*.js", + ], + allow_empty = True, + ) + [ + "metro.config.js", + "react-native.config.js", + "package.json", + ], +) + +# NOTE: rules_js uses a strict, non-hoisted node_modules. Metro's bundling tooling +# (metro, @react-native/metro-config, @react-native/metro-babel-transformer) are +# transitive/root devDeps and are therefore NOT resolvable from rn-tester's node_modules. +# To make this green, declare them as deps here (e.g. add them to rn-tester's package.json +# and regenerate the Berry yarn.lock) so rules_js links the full closure. See docs/bazel.md. +# The bundle runs via bazel/bundle.js (Metro's API) with the react-native alias applied. +js_binary( + name = "bundler", + data = [ + "//:.aspect_rules_js/node_modules/invariant@2.2.4", + ":node_modules", + "//:node_modules/@babel/core", + ], + entry_point = "bazel/bundle.js", + tags = ["manual"], +) + +js_run_binary( + name = "rntester_js_project", + srcs = [":js_srcs"], + out_dirs = ["rntester_js_project"], + args = [ + "packages/rn-tester", + "packages/rn-tester/rntester_js_project", + ], + tags = ["manual"], + tool = "//tools/bazel/js:copy_tree", +) + +# 1. JS bundle (Metro). Entry is the macOS variant of the rn-tester app. +js_run_binary( + name = "rntester_macos_jsbundle", + srcs = [":rntester_js_project"], + outs = ["RNTesterApp.macos.jsbundle"], + out_dirs = ["RNTesterApp_assets"], + env = { + "BUNDLE_OUT": "packages/rn-tester/RNTesterApp.macos.jsbundle", + "ASSETS_DEST": "packages/rn-tester/RNTesterApp_assets", + "RN_TESTER_ROOT": "packages/rn-tester/rntester_js_project", + }, + tags = ["manual"], + tool = ":bundler", +) + +# 3. App host. The REAL rn-tester AppDelegate.mm pulls in rn-tester-specific native +# modules + codegen (NativeCxxModuleExample, RNTMyNativeViewComponentView, +# ReactAppDependencyProvider) that are not in the prebuilt XCFrameworks. This slice +# ships a MINIMAL macOS host that boots RCTReactNativeFactory against the embedded +# Metro bundle, linking only the prebuilt XCFrameworks (+ the reconstructed React +# canonical header tree, :React_headers). See docs/bazel.md. +objc_library( + name = "rntester_macos_minimal_host", + srcs = [ + "bazel/MinimalAppDelegate.mm", + "bazel/minimal_main.m", + ], + hdrs = ["bazel/MinimalAppDelegate.h"], + tags = ["manual"], + deps = [ + "//packages/react-native:rn_cxx_headers", + "@rn_prebuilt_xcframeworks//:React", + "@rn_prebuilt_xcframeworks//:React_headers", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies_headers", + "@rn_prebuilt_xcframeworks//:hermes", + ], +) + +# 4. macOS app: link the minimal host + embed the Metro bundle as an app resource +# (mirrors how Buck2's apple prelude ingests a js_bundle as an AppleResource). +macos_application( + name = "RNTesterMacBazel", + bundle_id = "org.reactjs.native.RNTesterMacBazel", + infoplists = ["bazel/Info.plist"], + minimum_os_version = "14.0", + resources = [":rntester_macos_jsbundle"], + tags = ["manual"], + deps = [":rntester_macos_minimal_host"], +) + +# 5. FULL host: rn-tester's real RNTester/AppDelegate.mm + a programmatic main +# (bazel/real_main.m avoids a MainMenu.nib). It links the Bazel-built codegen +# (RCTAppDependencyProvider + AppSpecs), the C++ TurboModule example, the Fabric +# NativeComponentExample (RNTMyNativeView), and the sample TurboModules that the real +# AppDelegate's getTurboModule:/dependencyProvider/thirdPartyFabricComponents need. +objc_library( + name = "rntester_macos_full_host", + srcs = [ + "RNTester/AppDelegate.mm", + "bazel/real_main.m", + ], + hdrs = ["RNTester/AppDelegate.h"], + includes = ["RNTester"], + sdk_frameworks = ["UserNotifications"], + tags = ["manual"], + deps = [ + "//packages/react-native:rn_cxx_headers", + "//packages/react-native:rntester_extra_rn_modules", + "//packages/react-native:sample_turbo_modules", + "//packages/rn-tester/NativeComponentExample", + "//packages/rn-tester/NativeCxxModuleExample", + "//tools/bazel/react_native:rn_tester_appspecs_appdep_hdrs", + "//tools/bazel/react_native:rn_tester_appspecs_lib", + "//tools/bazel/react_native:rn_tester_appspecs_reactcodegen_hdrs", + "@rn_prebuilt_xcframeworks//:React", + "@rn_prebuilt_xcframeworks//:React_headers", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies_headers", + "@rn_prebuilt_xcframeworks//:hermes", + ], +) + +macos_application( + name = "RNTesterMacBazelFull", + bundle_id = "org.reactjs.native.RNTesterMacBazelFull", + infoplists = ["bazel/Info.plist"], + minimum_os_version = "14.0", + resources = [":rntester_macos_jsbundle"], + tags = ["manual"], + deps = [":rntester_macos_full_host"], +) diff --git a/packages/rn-tester/NativeComponentExample/BUILD.bazel b/packages/rn-tester/NativeComponentExample/BUILD.bazel new file mode 100644 index 000000000000..ec5c52d2c311 --- /dev/null +++ b/packages/rn-tester/NativeComponentExample/BUILD.bazel @@ -0,0 +1,35 @@ +package(default_visibility = ['//visibility:public']) + +exports_files(glob(['**/*'], exclude = ['**/*.bazel'], allow_empty = True)) + +# rn-tester's Fabric component example (RNTMyNativeView). Consumed by the app's +# thirdPartyFabricComponents via . +objc_library( + name = "NativeComponentExample", + srcs = [ + "ios/RCTInteropTestView.m", + "ios/RCTInteropTestViewManager.m", + "ios/RNTLegacyView.mm", + "ios/RNTMyLegacyNativeViewManager.mm", + "ios/RNTMyNativeViewCommon.mm", + "ios/RNTMyNativeViewComponentView.mm", + "ios/RNTMyNativeViewManager.mm", + ], + hdrs = [ + "ios/RCTInteropTestView.h", + "ios/RCTInteropTestViewManager.h", + "ios/RNTLegacyView.h", + "ios/RNTMyNativeViewComponentView.h", + "ios/UIView+ColorOverlays.h", + ], + includes = ["ios"], + tags = ["manual"], + deps = [ + "//packages/react-native:rct_fabric_components_plugins_hdr", + "//packages/react-native:rn_cxx_headers", + "//tools/bazel/react_native:rn_tester_appspecs_fabric_hdrs", + "@rn_prebuilt_xcframeworks//:React", + "@rn_prebuilt_xcframeworks//:React_headers", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies_headers", + ], +) diff --git a/packages/rn-tester/NativeCxxModuleExample/BUILD.bazel b/packages/rn-tester/NativeCxxModuleExample/BUILD.bazel new file mode 100644 index 000000000000..e63584f671d7 --- /dev/null +++ b/packages/rn-tester/NativeCxxModuleExample/BUILD.bazel @@ -0,0 +1,26 @@ +package(default_visibility = ['//visibility:public']) + +exports_files(glob(['**/*'], exclude = ['**/*.bazel'], allow_empty = True)) + +# Header exposed to the app as . +cc_library( + name = "NativeCxxModuleExample_hdrs", + hdrs = ["NativeCxxModuleExample.h"], + include_prefix = "NativeCxxModuleExample", + tags = ["manual"], + deps = ["//tools/bazel/react_native:rn_tester_appspecs_reactcodegen_hdrs"], +) + +# rn-tester's C++ TurboModule example (used by the app's getTurboModule:). +objc_library( + name = "NativeCxxModuleExample", + srcs = ["NativeCxxModuleExample.cpp"], + tags = ["manual"], + deps = [ + ":NativeCxxModuleExample_hdrs", + "//packages/react-native:rn_cxx_headers", + "//tools/bazel/react_native:rn_tester_appspecs_reactcodegen_hdrs", + "@rn_prebuilt_xcframeworks//:React_headers", + "@rn_prebuilt_xcframeworks//:ReactNativeDependencies_headers", + ], +) diff --git a/packages/rn-tester/NativeModuleExample/BUILD.bazel b/packages/rn-tester/NativeModuleExample/BUILD.bazel new file mode 100644 index 000000000000..a92b2ef0c6da --- /dev/null +++ b/packages/rn-tester/NativeModuleExample/BUILD.bazel @@ -0,0 +1,3 @@ +package(default_visibility = ['//visibility:public']) + +exports_files(glob(['**/*'], exclude = ['**/*.bazel'], allow_empty = True)) diff --git a/packages/rn-tester/bazel/Info.plist b/packages/rn-tester/bazel/Info.plist new file mode 100644 index 000000000000..52027436a27a --- /dev/null +++ b/packages/rn-tester/bazel/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + RNTesterMacBazel + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 11.0 + NSPrincipalClass + NSApplication + NSHighResolutionCapable + + + diff --git a/packages/rn-tester/bazel/MinimalAppDelegate.h b/packages/rn-tester/bazel/MinimalAppDelegate.h new file mode 100644 index 000000000000..d987997d7a61 --- /dev/null +++ b/packages/rn-tester/bazel/MinimalAppDelegate.h @@ -0,0 +1,22 @@ +/* + * 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. + */ + +#import +#import +#import + +// A minimal macOS host for the Bazel slice. It boots the React Native runtime via +// RCTReactNativeFactory and loads the Metro bundle embedded in the app's Resources. +// Unlike rn-tester's full AppDelegate it intentionally omits the rn-tester-specific +// native example modules (NativeCxxModuleExample / RNTMyNativeView) so the app links +// against only the prebuilt XCFrameworks. +@interface MinimalAppDelegate : RCTDefaultReactNativeFactoryDelegate + +@property (nonatomic, strong, nonnull) RCTPlatformWindow *window; +@property (nonatomic, strong, nonnull) RCTReactNativeFactory *reactNativeFactory; + +@end diff --git a/packages/rn-tester/bazel/MinimalAppDelegate.mm b/packages/rn-tester/bazel/MinimalAppDelegate.mm new file mode 100644 index 000000000000..d0e9ad49ccb1 --- /dev/null +++ b/packages/rn-tester/bazel/MinimalAppDelegate.mm @@ -0,0 +1,34 @@ +/* + * 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. + */ + +#import "MinimalAppDelegate.h" + +@implementation MinimalAppDelegate + +- (void)applicationDidFinishLaunching:(NSNotification *)notification +{ + self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self]; + + self.window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 1280, 720) + styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | + NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable + backing:NSBackingStoreBuffered + defer:NO]; + self.window.title = @"RNTesterApp (Bazel)"; + + [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp" + inWindow:self.window + initialProperties:@{} + launchOptions:notification.userInfo]; +} + +- (NSURL *)bundleURL +{ + return [[NSBundle mainBundle] URLForResource:@"RNTesterApp.macos" withExtension:@"jsbundle"]; +} + +@end diff --git a/packages/rn-tester/bazel/README.md b/packages/rn-tester/bazel/README.md new file mode 100644 index 000000000000..cc07d33815d8 --- /dev/null +++ b/packages/rn-tester/bazel/README.md @@ -0,0 +1,23 @@ +This directory holds the Bazel *vertical slice* for building RNTester on macOS. +The actual targets live in `packages/rn-tester/BUILD.bazel` (they need to glob the +rn-tester JS and Obj-C sources, which Bazel can only do from the package root). + +See `docs/bazel.md` for the full design and current status. In short, the slice: + + 1. `metro_bundle` — bundles `js/RNTesterApp.macos.js` with Metro via rules_js. + 2. `rn_imported_xcframeworks()` — imports the prebuilt React/ReactNativeDependencies/ + hermes XCFrameworks produced by `scripts/ios-prebuild.js`. + 3. `macos_application` — links the XCFrameworks + rn-tester's AppDelegate/main and + embeds the Metro bundle as an app resource. + +Prerequisites (why the slice targets are tagged `manual` today): + * First-party workspace packages need `BUILD.bazel` files so rules_js can link + `//:node_modules/react-native` (the Metro CLI). Tracked in docs/bazel.md. + * The XCFrameworks must exist: run + (cd packages/react-native && node scripts/ios-prebuild.js -s -f Debug \ + && node scripts/ios-prebuild.js -b -f Debug -p macos \ + && node scripts/ios-prebuild.js -c -f Debug) + * rules_apple must support the local Xcode (26.x); pin via DEVELOPER_DIR. + +Once the above are in place: + bazel run //packages/rn-tester:RNTesterMacBazel diff --git a/packages/rn-tester/bazel/bundle.js b/packages/rn-tester/bazel/bundle.js new file mode 100644 index 000000000000..b85e6d03f3b4 --- /dev/null +++ b/packages/rn-tester/bazel/bundle.js @@ -0,0 +1,291 @@ +/** + * 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 + */ + +// Bazel entry point for bundling rn-tester's macOS JS with Metro. +// +// Runs under a rules_js `js_run_binary` with node_modules linked by Bazel. Unlike +// the source-tree metro.config.js (which uses source-relative watchFolders), this +// resolves everything from the Bazel-linked node_modules and aliases the +// `react-native` import to the `react-native-macos` package (the repo's convention). + +'use strict'; + +const fs = require('fs'); +const crypto = require('crypto'); +const Module = require('module'); +const path = require('path'); +const Metro = require('metro'); +const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); + +const ALIASES = new Map(); + +const originalResolveFilename = Module._resolveFilename; +Module._resolveFilename = function (request, parent, isMain, options) { + try { + return originalResolveFilename.call(this, request, parent, isMain, options); + } catch (error) { + // Rewrite aliased packages (e.g. `react-native` -> `react-native-macos`), + // including subpaths like `react-native/Libraries/Core/InitializeCore`, so + // Node `require`/`require.resolve` calls (e.g. metro-config's + // getModulesRunBeforeMainModule) resolve against the aliased package. + for (const [name, root] of ALIASES) { + if (request === name || request.startsWith(name + '/')) { + const subpath = request.slice(name.length + 1); + const aliased = subpath ? path.join(root, subpath) : root; + try { + return originalResolveFilename.call( + this, + aliased, + parent, + isMain, + options, + ); + } catch (_) { + // fall through to the aspect-tree fallback below + } + } + } + const aspectPackage = findAspectPackage(request); + if (aspectPackage != null) { + return originalResolveFilename.call( + this, + aspectPackage, + parent, + isMain, + options, + ); + } + throw error; + } +}; + +function findAspectPackage(request) { + const encoded = request.replace('/', '+'); + const aspectRoots = [ + path.resolve('node_modules/.aspect_rules_js'), + path.resolve(__dirname, '../../..', 'node_modules/.aspect_rules_js'), + ]; + for (const aspectRoot of aspectRoots) { + if (!fs.existsSync(aspectRoot)) { + continue; + } + for (const entry of fs.readdirSync(aspectRoot)) { + if (entry.startsWith(encoded + '@')) { + const candidate = path.join(aspectRoot, entry, 'node_modules', request); + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate; + } + } + } + } + return null; +} + +const ASSET_EXTS = new Set(['gif', 'jpeg', 'jpg', 'png', 'webp', 'xml']); + +function resolveFromFileSystem(context, moduleName, platform) { + try { + return context.resolveRequest(context, moduleName, platform); + } catch (error) { + const packageFallback = resolvePackageFromAspectTree(moduleName); + if (packageFallback != null) { + return {type: 'sourceFile', filePath: packageFallback}; + } + const fallback = resolveRelativeFromFileSystem( + context.originModulePath, + moduleName, + platform, + ); + if (fallback != null) { + return fallback; + } + throw error; + } +} + +function resolvePackageFromAspectTree(moduleName) { + if (moduleName.startsWith('.') || path.isAbsolute(moduleName)) { + return null; + } + const parts = moduleName.split('/'); + const packageName = moduleName.startsWith('@') + ? `${parts[0]}/${parts[1]}` + : parts[0]; + const subpath = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/'); + const packageRoot = ALIASES.get(packageName) || findAspectPackage(packageName); + if (packageRoot == null) { + return null; + } + let candidateBase = subpath ? path.join(packageRoot, subpath) : null; + if (candidateBase == null) { + const packageJsonPath = path.join(packageRoot, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + candidateBase = path.join(packageRoot, pkg['react-native'] || pkg.main || 'index'); + } else { + candidateBase = path.join(packageRoot, 'index'); + } + } + for (const candidate of [ + candidateBase, + `${candidateBase}.js`, + path.join(candidateBase, 'index.js'), + ]) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + return null; +} + +function resolveRelativeFromFileSystem(originModulePath, moduleName, platform) { + if (!moduleName.startsWith('.') && !path.isAbsolute(moduleName)) { + return null; + } + const basePath = path.isAbsolute(moduleName) + ? moduleName + : path.resolve(path.dirname(originModulePath), moduleName); + const sourceExts = [ + `${platform}.js`, + 'native.js', + 'js', + `${platform}.jsx`, + 'native.jsx', + 'jsx', + `${platform}.json`, + 'native.json', + 'json', + `${platform}.ts`, + 'native.ts', + 'ts', + `${platform}.tsx`, + 'native.tsx', + 'tsx', + ]; + for (const candidate of [ + basePath, + ...sourceExts.map(ext => `${basePath}.${ext}`), + ]) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + const ext = path.extname(candidate).slice(1); + if (ASSET_EXTS.has(ext)) { + return resolveAssetFiles(candidate); + } + return {type: 'sourceFile', filePath: candidate}; + } + } + // Directory imports: `./foo` -> `./foo/index.` (Node/Metro directory index). + if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) { + for (const ext of sourceExts) { + const indexCandidate = path.join(basePath, `index.${ext}`); + if (fs.existsSync(indexCandidate) && fs.statSync(indexCandidate).isFile()) { + return {type: 'sourceFile', filePath: indexCandidate}; + } + } + } + const baseExt = path.extname(basePath).slice(1); + if (ASSET_EXTS.has(baseExt)) { + return resolveAssetFiles(basePath); + } + return null; +} + +function resolveAssetFiles(filePath) { + const dir = path.dirname(filePath); + const ext = path.extname(filePath); + const basename = path.basename(filePath, ext).replace(/@\d+(?:\.\d+)?x$/, ''); + const escaped = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const assetPattern = new RegExp( + `^${escaped}(?:@\\d+(?:\\.\\d+)?x)?${ext.replace('.', '\\.')}$`, + ); + const filePaths = fs + .readdirSync(dir) + .filter(name => assetPattern.test(name)) + .map(name => path.join(dir, name)); + return filePaths.length ? {type: 'assetFiles', filePaths} : null; +} + +const DependencyGraph = require('metro/src/node-haste/DependencyGraph'); +const originalGetOrComputeSha1 = DependencyGraph.prototype.getOrComputeSha1; +DependencyGraph.prototype.getOrComputeSha1 = async function (filename) { + try { + return await originalGetOrComputeSha1.call(this, filename); + } catch (error) { + if ( + error != null && + typeof error.message === 'string' && + error.message.includes('Failed to get the SHA-1 for:') && + fs.existsSync(filename) + ) { + const content = await fs.promises.readFile(filename); + return { + content, + sha1: crypto.createHash('sha1').update(content).digest('hex'), + }; + } + throw error; + } +}; + +async function main() { + const realpath = fsPath => fs.realpathSync.native(path.resolve(fsPath)); + const projectRoot = realpath(process.env.RN_TESTER_ROOT || process.cwd()); + const out = path.resolve(process.env.BUNDLE_OUT || 'RNTesterApp.macos.jsbundle'); + const assetsDest = process.env.ASSETS_DEST + ? path.resolve(process.env.ASSETS_DEST) + : undefined; + const entryFile = realpath(path.join(projectRoot, 'js/RNTesterApp.macos.js')); + + // react-native-macos is published/consumed as `react-native`. + const reactNativePath = realpath( + path.dirname(require.resolve('react-native-macos/package.json')), + ); + ALIASES.set('react-native', reactNativePath); + const reactNativeRoot = realpath(path.dirname(reactNativePath)); + + const baseConfig = await getDefaultConfig(projectRoot); + const config = mergeConfig(baseConfig, { + cacheStores: [], + maxWorkers: 1, + projectRoot, + resetCache: true, + useWatchman: false, + watchFolders: [projectRoot, reactNativePath, reactNativeRoot], + resolver: { + blockList: [], + extraNodeModules: {'react-native': reactNativePath}, + platforms: ['ios', 'macos', 'android'], + resolveRequest: resolveFromFileSystem, + // Watchman isn't available (or usable) inside the Bazel sandbox; use + // Metro's Node crawler. Realpathing the roots keeps the crawler's SHA-1 + // map keys aligned with the entry file. + useWatchman: false, + unstable_enableSymlinks: true, + }, + }); + + // Use `bundleOut` (verbatim) rather than `out`; Metro's runBuild rewrites + // `out` through `.replace(/(\.js)?$/, '.js')`, which would turn our declared + // Bazel output `RNTesterApp.macos.jsbundle` into `...jsbundle.js`. + await Metro.runBuild(config, { + entry: entryFile, + platform: 'macos', + dev: false, + minify: true, + bundleOut: out, + assets: Boolean(assetsDest), + assetsDest, + }); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages/rn-tester/bazel/minimal_main.m b/packages/rn-tester/bazel/minimal_main.m new file mode 100644 index 000000000000..afd9196e558c --- /dev/null +++ b/packages/rn-tester/bazel/minimal_main.m @@ -0,0 +1,23 @@ +/* + * 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. + */ + +#import + +#import "MinimalAppDelegate.h" + +int main(int argc, const char *argv[]) +{ + @autoreleasepool { + NSApplication *application = [NSApplication sharedApplication]; + MinimalAppDelegate *delegate = [MinimalAppDelegate new]; + // Retain the delegate for the lifetime of the app. + application.delegate = (id)delegate; + CFBridgingRetain(delegate); + [application run]; + } + return 0; +} diff --git a/packages/rn-tester/bazel/real_main.m b/packages/rn-tester/bazel/real_main.m new file mode 100644 index 000000000000..dd3e78eaf0f3 --- /dev/null +++ b/packages/rn-tester/bazel/real_main.m @@ -0,0 +1,24 @@ +/* + * 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. + */ + +#import + +#import "AppDelegate.h" + +// rn-tester's own main.m uses NSApplicationMain (nib-driven). This slice wires the +// real AppDelegate programmatically so no MainMenu.nib is required in the bundle. +int main(int argc, const char *argv[]) +{ + @autoreleasepool { + NSApplication *application = [NSApplication sharedApplication]; + AppDelegate *delegate = [AppDelegate new]; + application.delegate = (id)delegate; + CFBridgingRetain(delegate); + [application run]; + } + return 0; +} diff --git a/packages/rn-tester/package.json b/packages/rn-tester/package.json index ea5c1d4a7069..2fa0a265d850 100644 --- a/packages/rn-tester/package.json +++ b/packages/rn-tester/package.json @@ -61,8 +61,12 @@ "@react-native-community/cli": "20.0.0", "@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-ios": "20.0.0", + "@react-native/metro-babel-transformer": "workspace:*", + "@react-native/metro-config": "workspace:*", "commander": "^12.0.0", "listr2": "^6.4.1", + "metro": "^0.82.5", + "react": "19.1.0", "react-native-macos": "workspace:*", "rxjs": "npm:@react-native-community/rxjs@6.5.4-custom" } diff --git a/packages/typescript-config/BUILD.bazel b/packages/typescript-config/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/typescript-config/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/packages/virtualized-lists/BUILD.bazel b/packages/virtualized-lists/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/packages/virtualized-lists/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/private/cxx-public-api/BUILD.bazel b/private/cxx-public-api/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/private/cxx-public-api/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/private/eslint-plugin-monorepo/BUILD.bazel b/private/eslint-plugin-monorepo/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/private/eslint-plugin-monorepo/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/private/monorepo-tests/BUILD.bazel b/private/monorepo-tests/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/private/monorepo-tests/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/private/react-native-bots/BUILD.bazel b/private/react-native-bots/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/private/react-native-bots/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/private/react-native-codegen-typescript-test/BUILD.bazel b/private/react-native-codegen-typescript-test/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/private/react-native-codegen-typescript-test/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/private/react-native-fantom/BUILD.bazel b/private/react-native-fantom/BUILD.bazel new file mode 100644 index 000000000000..420dbaf7d3da --- /dev/null +++ b/private/react-native-fantom/BUILD.bazel @@ -0,0 +1,6 @@ +# @generated-by: tools/bazel/js/gen_package_builds.mjs +# Edit the generator or the macro, not this file. Regenerate with: +# node tools/bazel/js/gen_package_builds.mjs +load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + +rn_workspace_package() diff --git a/tools/bazel/apple/BUILD.bazel b/tools/bazel/apple/BUILD.bazel new file mode 100644 index 000000000000..6a9fc3ea6448 --- /dev/null +++ b/tools/bazel/apple/BUILD.bazel @@ -0,0 +1,4 @@ +# Bazel helpers for building React Native's Apple targets (rules_apple / rules_swift). +# See xcframeworks.bzl and docs/bazel.md. + +exports_files(["xcframeworks.bzl"]) diff --git a/tools/bazel/apple/prebuilt_xcframeworks.bzl b/tools/bazel/apple/prebuilt_xcframeworks.bzl new file mode 100644 index 000000000000..4c503b3ca89e --- /dev/null +++ b/tools/bazel/apple/prebuilt_xcframeworks.bzl @@ -0,0 +1,114 @@ +"""Expose the SPM-prebuilt React Native XCFrameworks to Bazel. + +The XCFrameworks produced by `scripts/ios-prebuild.js` live under +`packages/react-native/.build` and `third-party/` (build outputs, gitignored, and in +`.bazelignore`). This repository rule symlinks them into an external repo and exposes +`apple_*_xcframework_import` targets so the Bazel `macos_application` can link them. + +If the XCFrameworks are missing (e.g. a clean checkout / CI before the prebuild), the +generated targets fail with an actionable message instead of breaking analysis of the +whole repo. +""" + +_REL_PATHS = { + "React": "packages/react-native/.build/output/xcframeworks/Debug/React.xcframework", + "ReactNativeDependencies": "packages/react-native/third-party/ReactNativeDependencies.xcframework", + "hermes": "packages/react-native/.build/artifacts/hermes/destroot/Library/Frameworks/universal/hermes.xcframework", +} + +# hermes ships as a dynamic framework; React + deps are static. +# The SPM prebuild ships all three as dynamic frameworks (dylibs), so they must be +# imported as dynamic and embedded in the app's Contents/Frameworks (otherwise dyld +# fails at launch with "Library not loaded: @rpath/React.framework/..."). +_DYNAMIC = {"React": True, "ReactNativeDependencies": True, "hermes": True} + +def _prebuilt_xcframeworks_impl(rctx): + root = str(rctx.workspace_root) + lines = [ + 'load("@rules_apple//apple:apple.bzl", "apple_dynamic_xcframework_import", "apple_static_xcframework_import")', + 'package(default_visibility = ["//visibility:public"])', + "", + ] + missing = [] + for name, rel in _REL_PATHS.items(): + src = "{}/{}".format(root, rel) + if not rctx.path(src).exists: + missing.append(rel) + continue + rctx.symlink(src, name + ".xcframework") + rule = "apple_dynamic_xcframework_import" if _DYNAMIC.get(name) else "apple_static_xcframework_import" + lines.append('{rule}(name = "{name}", xcframework_imports = glob(["{name}.xcframework/**"]))'.format( + rule = rule, + name = name, + )) + + # ReactNativeDependencies ships canonical, nested third-party headers + # (folly/, boost/, glog/, fmt/, double-conversion/, fast_float/) at the + # xcframework root `Headers/`. Expose them on the include path so C++ sources + # that pull `` etc. compile. + if "ReactNativeDependencies" not in missing: + lines.append(""" +cc_library( + name = "ReactNativeDependencies_headers", + hdrs = glob(["ReactNativeDependencies.xcframework/Headers/**"], allow_empty = True), + includes = ["ReactNativeDependencies.xcframework/Headers"], +) +""") + + # The React.xcframework flattens each SPM target's public headers into + # `Headers//.h`, which breaks the canonical ``, + # ``, `` imports used by both the framework's own + # headers and app sources. Reconstruct a canonical `-I` tree of symlinks so the + # headers are consumable from Bazel, and expose it as `:React_headers`. + if "React" not in missing: + script = rctx.path(Label("//tools/bazel/apple:reconstruct_react_headers.py")) + slice_dir = None + for entry in rctx.path("React.xcframework").readdir(): + if entry.basename.startswith("macos-"): + slice_dir = entry.basename + break + if slice_dir: + fw_headers = "React.xcframework/{}/React.framework/Headers".format(slice_dir) + res = rctx.execute(["python3", str(script), fw_headers, "flat_headers"], quiet = True) + if res.return_code != 0: + fail("reconstruct_react_headers failed: {}".format(res.stderr)) + lines.append(""" +cc_library( + name = "React_headers", + hdrs = glob(["flat_headers/**"], allow_empty = True), + includes = ["flat_headers"], + defines = [ + "RCT_DEV=1", + "RCT_ENABLE_INSPECTOR=1", + "RCT_REMOTE_PROFILE=0", + "RCT_PROFILE=0", + "RCT_NEW_ARCH_ENABLED=1", + ], +) +""") + + if missing: + # Emit a target that fails at build time with guidance, rather than failing analysis. + msg = ("Prebuilt XCFrameworks not found: {}. Build them first with " + + "`cd packages/react-native && HERMES_APPLE_PLATFORMS=macosx node scripts/ios-prebuild.js -s -f Debug " + + "&& node scripts/ios-prebuild.js -b -f Debug -p macos && node scripts/ios-prebuild.js -c -f Debug` " + + "(use cmake@3.x on Xcode 26).").format(", ".join(missing)) + lines = [ + 'package(default_visibility = ["//visibility:public"])', + 'genrule(name = "_missing", outs = ["missing.txt"], cmd = "echo \'{}\' >&2; exit 1")'.format(msg), + ] + for name in _REL_PATHS: + lines.append('alias(name = "{name}", actual = ":_missing")'.format(name = name)) + + rctx.file("BUILD.bazel", "\n".join(lines) + "\n") + +prebuilt_xcframeworks = repository_rule( + implementation = _prebuilt_xcframeworks_impl, + doc = "Symlinks the SPM-prebuilt React Native XCFrameworks into a Bazel repo.", + local = True, +) + +def _extension_impl(_module_ctx): + prebuilt_xcframeworks(name = "rn_prebuilt_xcframeworks") + +rn_prebuilt_xcframeworks_extension = module_extension(implementation = _extension_impl) diff --git a/tools/bazel/apple/reconstruct_react_headers.py b/tools/bazel/apple/reconstruct_react_headers.py new file mode 100644 index 000000000000..355bfbe146aa --- /dev/null +++ b/tools/bazel/apple/reconstruct_react_headers.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Reconstruct a canonical header tree from the SPM-prebuilt React.xcframework. + +The React.xcframework produced by `scripts/ios-prebuild.js` flattens every SPM +target's public headers into `Headers//.h` (e.g. +`Headers/React_Core/RCTBridge.h`, `Headers/React_graphics/Color.h`). But both the +framework's own headers *and* app sources import them by their canonical paths: + + #import // -> React_Core/RCTBridge.h + #import // -> React_RCTUIKit/RCTUIKit.h + #include // -> React_graphics/ColorComponents.h + #include // -> React_jsi/jsi.h + #import // -> React_RCTAppDelegate/RCTReactNativeFactory.h + +None of those resolve against the flattened layout, so the framework is not +directly consumable by clang/Bazel. This script rebuilds a canonical `-I` tree of +symlinks so the imports resolve. It is deliberately generic: it discovers the +canonical include paths by scanning the framework's own headers for +`#include/#import <...>` directives and maps each to a physical file by basename, +disambiguating collisions with a path-segment heuristic. + +Usage: reconstruct_react_headers.py +""" + +import os +import re +import sys + +# Modules whose headers are imported with the flat `` (Obj-C) prefix. +# On basename collisions we prefer React_Core, then these, in order. +OBJC_REACT_MODULES = [ + "React_Core", + "React_RCTUIKit", + "React_CoreModules", + "React_RCTAppDelegate", + "React_RCTImage", + "React_RCTText", + "React_RCTBlob", + "React_RCTVibration", + "React_RCTSettings", + "React_RCTFabric", + "React_NativeModulesApple", + "RCTDeprecation", +] + +# Modules whose (single) header is imported flat, e.g. ``. +FLAT_ROOT_MODULES = ["React_RCTAppDelegate"] + +# Header families that live in React_Core but are consumed via a `` +# umbrella prefix (the core codegen spec). The framework references some of these +# (so they get reconstructed by the scan) but not the umbrella header itself, which +# is only pulled in by app/library sources — so map the whole family explicitly. +SELF_NAMED_MODULES = ["FBReactNativeSpec"] + +INCLUDE_RE = re.compile(r'#\s*(?:import|include)\s*<([^>]+)>') + +# Canonical namespaces supplied from the RN *source* tree (complete, correctly +# nested, siblings co-located) rather than the framework's flattened copy. Any +# `` include with one of these first segments is skipped during +# reconstruction. Everything else (React/, RCTDeprecation/, RCTTypeSafety/, ...) +# is Obj-C and reconstructed from the framework. +SOURCE_PREFIXES = frozenset([ + "react", + "ReactCommon", + "jsi", + "jsireact", + "yoga", + "cxxreact", + "folly", + "boost", + "glog", + "fmt", + "double-conversion", + "fast_float", + "jsinspector-modern", + "logger", + "reactperflogger", + "hermes", + "reacthermes", + "jserrorhandler", + "jsitooling", + "callinvoker", + "runtimeexecutor", + "oscompat", +]) + + +def main(): + headers_dir, out_dir = os.path.abspath(sys.argv[1]), sys.argv[2] + + # 1. Index every physical header: basename -> [(module, abspath)]. + by_basename = {} + all_headers = [] + for module in sorted(os.listdir(headers_dir)): + mdir = os.path.join(headers_dir, module) + if not os.path.isdir(mdir): + continue + for root, _dirs, files in os.walk(mdir): + for f in files: + if not f.endswith((".h", ".hpp", ".hh")): + continue + p = os.path.join(root, f) + all_headers.append((module, p)) + by_basename.setdefault(f, []).append((module, p)) + + # 2. Collect every canonical include path referenced anywhere. + referenced = set() + for _module, p in all_headers: + try: + with open(p, "r", errors="ignore") as fh: + for m in INCLUDE_RE.finditer(fh.read()): + referenced.add(m.group(1)) + except OSError: + pass + + links = {} # canonical-relpath -> physical abspath (first writer wins) + + def want(rel, phys): + links.setdefault(rel, phys) + + def rank_objc(module): + return OBJC_REACT_MODULES.index(module) if module in OBJC_REACT_MODULES else 999 + + def pick(candidates, hint_segments): + """Choose the best physical file for a canonical path.""" + if len(candidates) == 1: + return candidates[0][1] + # Prefer a module whose name matches a hint path segment. + best, best_score = candidates[0][1], float("-inf") + for module, phys in candidates: + mtokens = module.lower().replace("react_", "").split("_") + score = sum(1 for seg in hint_segments if seg.lower() in mtokens or seg.lower() == module.lower()) + # Tie-break toward canonical Obj-C ordering (React_Core first). + score = score * 100 - rank_objc(module) + if score > best_score: + best, best_score = phys, score + return best + + # 3. Map referenced canonical `` (Obj-C) paths to physical files. + # C++ / ReactCommon / jsi / yoga headers are intentionally NOT reconstructed + # here: the framework only ships a partial, flattened copy of them (which also + # breaks their quoted sibling includes). Those are supplied from the RN source + # tree instead (see //packages/react-native:rn_cxx_headers), which keeps the + # canonical layout and co-locates siblings. + for rel in referenced: + if "/" not in rel: + continue + if rel.split("/")[0] in SOURCE_PREFIXES: + continue + base = os.path.basename(rel) + cands = by_basename.get(base) + if not cands: + continue + segs = rel.split("/")[:-1] + want(rel, pick(cands, segs)) + + # 4. Flat `` tree: every Obj-C React module header by basename. + for module, phys in all_headers: + if module not in OBJC_REACT_MODULES: + continue + base = os.path.basename(phys) + rel = "React/" + base + existing = links.get(rel) + if existing is None: + links[rel] = phys + else: + # Prefer the higher-priority Obj-C module on collision. + cur_module = _module_of(existing, headers_dir) + if rank_objc(module) < rank_objc(cur_module): + links[rel] = phys + + # 5. Flat-root single-header modules, e.g. ``. + for module, phys in all_headers: + if module in FLAT_ROOT_MODULES: + want(os.path.basename(phys), phys) + + # 5c. Self-named umbrella families (e.g. ``). + for module, phys in all_headers: + base = os.path.basename(phys) + for name in SELF_NAMED_MODULES: + if base.startswith(name): + want(name + "/" + base, phys) + + # 6. Materialize the symlink tree (relative links so the tree stays valid + # when relocated, e.g. inside a Bazel external repo). + count = 0 + for rel, phys in links.items(): + dst = os.path.join(out_dir, rel) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.lexists(dst): + os.remove(dst) + os.symlink(os.path.relpath(phys, os.path.dirname(os.path.abspath(dst))), dst) + count += 1 + + sys.stderr.write( + "reconstruct_react_headers: %d physical headers, %d referenced paths, " + "%d symlinks\n" % (len(all_headers), len(referenced), count) + ) + + +def _module_of(phys, headers_dir): + rel = os.path.relpath(phys, headers_dir) + return rel.split(os.sep)[0] + + +if __name__ == "__main__": + main() diff --git a/tools/bazel/apple/xcframeworks.bzl b/tools/bazel/apple/xcframeworks.bzl new file mode 100644 index 000000000000..b7771f74dee5 --- /dev/null +++ b/tools/bazel/apple/xcframeworks.bzl @@ -0,0 +1,47 @@ +"""Import the prebuilt React Native XCFrameworks into the Bazel graph. + +The draft-PR slice consumes the XCFrameworks produced by the existing Swift Package +Manager prebuild pipeline (`packages/react-native/scripts/ios-prebuild.js`): + + React.xcframework, ReactNativeDependencies.xcframework, hermes.xcframework + +We wrap them behind stable aliases (`:React`, `:ReactNativeDependencies`, `:hermes`) +so a future *from-source* Bazel build (see docs/bazel.md "from-source roadmap") can +produce the same `.xcframework` artifacts with rules_apple `apple_static_xcframework` +and drop in behind these aliases without touching the app BUILD files. + +Usage (from a BUILD file, after running ios-prebuild so the artifacts exist): + + load("//tools/bazel/apple:xcframeworks.bzl", "rn_imported_xcframeworks") + rn_imported_xcframeworks() +""" + +load( + "@rules_apple//apple:apple.bzl", + "apple_static_xcframework_import", +) + +# Repo-relative locations of the prebuilt XCFrameworks emitted by ios-prebuild. +_DEFAULT_XCFRAMEWORKS = { + "React": "//packages/react-native:xcframeworks/React.xcframework", + "ReactNativeDependencies": "//packages/react-native:xcframeworks/ReactNativeDependencies.xcframework", + "hermes": "//packages/react-native:xcframeworks/hermes.xcframework", +} + +def rn_imported_xcframeworks(xcframeworks = _DEFAULT_XCFRAMEWORKS, visibility = ["//visibility:public"]): + """Declare `apple_static_xcframework_import` targets for the prebuilt XCFrameworks. + + Each is exposed as `:` (e.g. `:React`). The underlying import target is + `:_import`; the alias makes the from-source swap seamless later. + """ + for name, filegroup in xcframeworks.items(): + apple_static_xcframework_import( + name = name + "_import", + xcframework_imports = [filegroup], + visibility = visibility, + ) + native.alias( + name = name, + actual = ":" + name + "_import", + visibility = visibility, + ) diff --git a/tools/bazel/berry/BUILD.bazel b/tools/bazel/berry/BUILD.bazel new file mode 100644 index 000000000000..ad6ee5c696e8 --- /dev/null +++ b/tools/bazel/berry/BUILD.bazel @@ -0,0 +1,4 @@ +# Berry (Yarn v2+) yarn.lock -> pnpm-lock.yaml converter used by our rules_js +# fork's patched npm_translate_lock. Kept dependency-free so it runs under a +# bare Node in the repository rule. +exports_files(["berry_to_pnpm_lock.mjs"]) diff --git a/tools/bazel/berry/berry_to_pnpm_lock.mjs b/tools/bazel/berry/berry_to_pnpm_lock.mjs new file mode 100644 index 000000000000..6096898a83a3 --- /dev/null +++ b/tools/bazel/berry/berry_to_pnpm_lock.mjs @@ -0,0 +1,536 @@ +#!/usr/bin/env node +/* + * 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. + */ + +/** + * Berry (Yarn v2+) `yarn.lock` -> pnpm `pnpm-lock.yaml` (lockfileVersion 9.0) converter. + * + * aspect-build/rules_js `npm_translate_lock` normalizes every input lockfile to an + * internal pnpm-lock model. For `yarn_lock`/`npm_package_lock` inputs it shells out to + * `pnpm import`, which does NOT understand the Berry `yarn.lock` format (pnpm #2991). + * + * This tool fills that one gap: it parses the Berry lock (its own strict YAML subset, + * "Syml") and emits a pnpm-lock.yaml v9 that rules_js's parser (npm/private/pnpm.bzl) + * accepts, so the Berry `yarn.lock` can stay the single source of truth (no committed + * second lockfile). It is invoked by our rules_js patch inside the repository rule, so + * it must run under a bare Node with no external dependencies. + * + * Usage: node berry_to_pnpm_lock.mjs + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const REGISTRY = 'https://registry.npmjs.org'; + +// --------------------------------------------------------------------------- +// Syml (Berry yarn.lock) parser +// --------------------------------------------------------------------------- + +/** + * Parse a Berry `yarn.lock` (Syml) into a plain nested object. + * Syml is an indentation-based YAML subset: 2-space indents, `key: value` or + * `key:` followed by an indented block, `#` comments, and quoted/bare scalars. + */ +function parseSyml(text) { + const root = {}; + // Stack of {indent, container}. container is the object we add children to. + const stack = [{indent: -1, container: root}]; + const lines = text.split(/\r?\n/); + + for (let raw of lines) { + if (raw.trim() === '' || raw.trim().startsWith('#')) { + continue; + } + const indent = raw.length - raw.trimStart().length; + const line = raw.trimStart(); + + // Pop to the parent whose indent is smaller than this line's indent. + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { + stack.pop(); + } + const parent = stack[stack.length - 1].container; + + const {key, rest} = splitKey(line); + if (rest === null || rest === '') { + // `key:` opening a nested block. + const child = {}; + parent[key] = child; + stack.push({indent, container: child}); + } else { + // `key: value` scalar. + parent[key] = unquote(rest); + } + } + return root; +} + +/** Split a line into its (possibly quoted) key and the remainder after the colon. */ +function splitKey(line) { + if (line.startsWith('"')) { + // Quoted key: find the closing quote (Syml keys don't contain escaped quotes + // in practice for yarn.lock, but handle doubled backslash-escaped just in case). + let i = 1; + for (; i < line.length; i++) { + if (line[i] === '\\') { + i++; + continue; + } + if (line[i] === '"') { + break; + } + } + const key = unquote(line.slice(0, i + 1)); + let after = line.slice(i + 1); + // after should start with ':' + const colon = after.indexOf(':'); + const rest = after.slice(colon + 1).trim(); + return {key, rest: rest === '' ? null : rest}; + } + // Bare key up to first colon. + const colon = line.indexOf(':'); + const key = line.slice(0, colon).trim(); + const rest = line.slice(colon + 1).trim(); + return {key, rest: rest === '' ? null : rest}; +} + +function unquote(s) { + s = s.trim(); + if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) { + return s + .slice(1, -1) + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } + return s; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Split an `ident@range` descriptor into [ident, range], accounting for scopes. */ +function splitDescriptor(descriptor) { + const at = descriptor.indexOf('@', descriptor.startsWith('@') ? 1 : 0); + return [descriptor.slice(0, at), descriptor.slice(at + 1)]; +} + +/** Parse Berry `conditions: "os=darwin & cpu=x64"` into {os, cpu} arrays. */ +function parseConditions(conditions) { + const out = {}; + if (!conditions) { + return out; + } + for (const clause of conditions.split('&')) { + const m = clause.trim().match(/^(os|cpu|libc)=(.+)$/); + if (m) { + (out[m[1]] = out[m[1]] || []).push(m[2].trim()); + } + } + return out; +} + +/** Compute the deterministic npm registry tarball URL for a package. */ +function registryTarballUrl(ident, version, registry) { + const base = registry.replace(/\/+$/, ''); + const unscoped = ident.startsWith('@') ? ident.slice(ident.indexOf('/') + 1) : ident; + return `${base}/${ident}/-/${unscoped}-${version}.tgz`; +} + +/** Strip the `npm:` protocol prefix from a specifier range for display. */ +function cleanSpecifier(range) { + if (range.startsWith('npm:') && !range.slice(4).includes('@')) { + return range.slice(4); + } + return range; +} + +/** POSIX relative path from importer dir `from` to workspace dir `to`. */ +function relPath(from, to) { + const a = from === '.' ? [] : from.split('/'); + const b = to === '.' ? [] : to.split('/'); + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) { + i++; + } + const up = a.slice(i).map(() => '..'); + const down = b.slice(i); + const parts = [...up, ...down]; + return parts.length ? parts.join('/') : '.'; +} + +// --------------------------------------------------------------------------- +// Conversion +// --------------------------------------------------------------------------- + +function convert(syml, resolutions) { + // Map every descriptor string -> its resolved entry. + const descriptorToEntry = new Map(); + // Canonical package key `ident@version` -> entry (npm packages only). + const packageEntries = new Map(); + // ident -> [entries] (for single-version fallback resolution). + const nameToEntries = new Map(); + // Workspace entries: importer path -> entry. + const workspaceByPath = new Map(); + + // Yarn `resolutions` overrides. Berry rewrites matching descriptors, but dependency + // entries keep their original ranges, so we must apply resolutions when resolving. + const globalRes = new Map(); // ident -> override value (e.g. ">=3.1.0") + const specificRes = new Map(); // "ident@range" -> override value + for (const [k, v] of Object.entries(resolutions || {})) { + const at = k.indexOf('@', k.startsWith('@') ? 1 : 0); + if (at > 0) { + specificRes.set(k, v); + } else { + globalRes.set(k, v); + } + } + const normalizeResValue = v => (/^[a-z]+:/.test(v) ? v : 'npm:' + v); + let droppedCount = 0; + + const entries = []; + for (const [rawKey, value] of Object.entries(syml)) { + if (rawKey === '__metadata') { + continue; + } + const descriptors = rawKey.split(',').map(s => s.trim()); + const resolution = value.resolution || ''; + const entry = {descriptors, resolution, value}; + entries.push(entry); + for (const d of descriptors) { + descriptorToEntry.set(d, entry); + } + } + + // Classify entries. + for (const entry of entries) { + const {resolution, value} = entry; + const [ident, reference] = splitDescriptor(resolution); + entry.ident = ident; + if (reference.startsWith('workspace:')) { + const wsPath = reference.slice('workspace:'.length); + entry.kind = 'workspace'; + entry.path = wsPath === '.' ? '.' : wsPath; + workspaceByPath.set(entry.path, entry); + } else if (reference.startsWith('npm:')) { + entry.kind = 'npm'; + entry.version = value.version; + entry.pkgKey = `${ident}@${value.version}`; + if (!packageEntries.has(entry.pkgKey)) { + packageEntries.set(entry.pkgKey, entry); + } + } else { + // patch:, portal:, link:, exec:, git, https tarball, file: ... + // Best-effort: treat anything with a concrete version as an npm-like package + // keyed by version so cross-references still resolve. + entry.kind = 'other'; + entry.version = value.version; + if (value.version) { + entry.pkgKey = `${ident}@${value.version}`; + if (!packageEntries.has(entry.pkgKey)) { + packageEntries.set(entry.pkgKey, entry); + } + } + } + if (entry.version || entry.kind === 'workspace') { + if (!nameToEntries.has(ident)) { + nameToEntries.set(ident, []); + } + nameToEntries.get(ident).push(entry); + } + } + + // Resolve a `depName: rangeDescriptor` reference to either a snapshot key + // (`name@version`) or a `link:` for workspace deps. Handles Yarn + // `resolutions` overrides and falls back to a single resolved version. + function resolveDep(fromPath, depName, rangeDescriptor) { + // Yarn descriptors can carry insignificant trailing whitespace; normalize so a + // dependency range like "npm:^3.1.0 " matches the trimmed descriptor key. + rangeDescriptor = String(rangeDescriptor).trim(); + let target = descriptorToEntry.get(`${depName}@${rangeDescriptor}`); + if (!target) { + const specific = specificRes.get(`${depName}@${rangeDescriptor}`); + if (specific != null) { + target = descriptorToEntry.get(`${depName}@${normalizeResValue(specific)}`); + } + } + if (!target && globalRes.has(depName)) { + target = descriptorToEntry.get(`${depName}@${normalizeResValue(globalRes.get(depName))}`); + } + if (!target) { + // Single-version fallback: if the package resolves to exactly one version in the + // lock (common once resolutions/dedup collapse ranges), use it. + const list = nameToEntries.get(depName); + if (list && list.length === 1) { + target = list[0]; + } + } + if (!target) { + droppedCount++; + return null; + } + if (target.kind === 'workspace') { + return {link: `link:${relPath(fromPath, target.path)}`}; + } + if (target.version) { + return { + version: `${target.ident}@${target.version}`, + plain: target.version, + ident: target.ident, + }; + } + droppedCount++; + return null; + } + + // Build `packages` and `snapshots`. + const packages = {}; + const snapshots = {}; + for (const entry of packageEntries.values()) { + const {value, pkgKey, ident} = entry; + const pkg = {}; + // NOTE: Berry's `checksum` is Yarn's own content hash of the package, NOT the + // npm tarball's sha512 integrity that rules_js/pnpm expect, so we cannot derive a + // valid `resolution.integrity` from the Berry lock. rules_js requires either an + // `integrity` or a `tarball`; we emit the deterministic npm registry tarball URL + // (downloaded without integrity verification). A production-grade version should + // source real integrities (one-time npm registry fetch or a Yarn plugin export) — + // tracked in docs/bazel.md. + pkg.resolution = {tarball: registryTarballUrl(ident, entry.version, REGISTRY)}; + const conds = parseConditions(value.conditions); + if (conds.os) { + pkg.os = conds.os; + } + if (conds.cpu) { + pkg.cpu = conds.cpu; + } + if (value.bin && typeof value.bin === 'object') { + pkg.hasBin = true; + } + if (value.peerDependencies && typeof value.peerDependencies === 'object') { + pkg.peerDependencies = {}; + for (const [pn, pv] of Object.entries(value.peerDependencies)) { + pkg.peerDependencies[pn] = typeof pv === 'string' ? pv : '*'; + } + } + packages[pkgKey] = pkg; + + const snap = {}; + const deps = value.dependencies; + if (deps && typeof deps === 'object') { + const meta = value.dependenciesMeta || {}; + const depMap = {}; + const optMap = {}; + for (const [dn, dr] of Object.entries(deps)) { + const resolved = resolveDep('.', dn, dr); + if (!resolved) { + continue; + } + const target = resolved.link || resolved.version; + const isOptional = meta[dn] && meta[dn].optional === 'true'; + if (isOptional) { + optMap[dn] = target; + } else { + depMap[dn] = target; + } + } + if (Object.keys(depMap).length) { + snap.dependencies = depMap; + } + if (Object.keys(optMap).length) { + snap.optionalDependencies = optMap; + } + } + snapshots[pkgKey] = snap; + } + + // Build `importers` from workspace entries. + const importers = {}; + for (const entry of workspaceByPath.values()) { + const importPath = entry.path; + const deps = {}; + const src = entry.value.dependencies; + if (src && typeof src === 'object') { + for (const [dn, dr] of Object.entries(src)) { + const resolved = resolveDep(importPath, dn, dr); + if (!resolved) { + continue; + } + deps[dn] = { + specifier: cleanSpecifier(dr), + version: + resolved.link || + (resolved.ident === dn ? resolved.plain : resolved.version), + }; + } + } + importers[importPath] = {dependencies: deps}; + } + + return {importers, packages, snapshots, droppedCount}; +} + +// --------------------------------------------------------------------------- +// Minimal YAML emitter (maps, string scalars, booleans, flow string arrays) +// --------------------------------------------------------------------------- + +function needsQuote(s) { + return !/^[A-Za-z0-9_./-]+$/.test(s) || s === '' || /^(true|false|null|~|yes|no)$/i.test(s); +} + +function q(s) { + s = String(s); + if (!needsQuote(s)) { + return s; + } + return `'${s.replace(/'/g, "''")}'`; +} + +function emit(obj, indent, lines) { + const pad = ' '.repeat(indent); + const keys = Object.keys(obj); + for (const k of keys) { + const v = obj[k]; + if (Array.isArray(v)) { + lines.push(`${pad}${q(k)}: [${v.map(q).join(', ')}]`); + } else if (v && typeof v === 'object') { + if (Object.keys(v).length === 0) { + lines.push(`${pad}${q(k)}: {}`); + } else { + lines.push(`${pad}${q(k)}:`); + emit(v, indent + 1, lines); + } + } else if (typeof v === 'boolean') { + lines.push(`${pad}${q(k)}: ${v ? 'true' : 'false'}`); + } else { + lines.push(`${pad}${q(k)}: ${q(v)}`); + } + } +} + +function toYaml({importers, packages, snapshots}) { + const lines = []; + lines.push("lockfileVersion: '9.0'"); + lines.push(''); + lines.push('settings:'); + lines.push(' autoInstallPeers: true'); + lines.push(' excludeLinksFromLockfile: false'); + lines.push(''); + lines.push('importers:'); + emit(importers, 1, lines); + lines.push(''); + lines.push('packages:'); + emit(packages, 1, lines); + lines.push(''); + lines.push('snapshots:'); + emit(snapshots, 1, lines); + lines.push(''); + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Validation (mirrors rules_js npm/private/pnpm.bzl consistency checks) +// --------------------------------------------------------------------------- + +function validate({importers, packages, snapshots}) { + const snapKeys = new Set(Object.keys(snapshots)); + const pkgKeys = new Set(Object.keys(packages)); + const dangling = []; + + const resolvable = (name, version) => { + if (typeof version !== 'string') { + return false; + } + if (version.startsWith('link:')) { + return true; + } + if (snapKeys.has(version)) { + return true; + } + if (snapKeys.has(`${name}@${version}`)) { + return true; + } + return false; + }; + + for (const [path, importer] of Object.entries(importers)) { + for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) { + for (const [name, attr] of Object.entries(importer[kind] || {})) { + if (!resolvable(name, attr.version)) { + dangling.push(`importer ${path} -> ${name}@${attr.version}`); + } + } + } + } + for (const [snapKey, snap] of Object.entries(snapshots)) { + const staticKey = snapKey.includes('(') ? snapKey.slice(0, snapKey.indexOf('(')) : snapKey; + if (!pkgKeys.has(staticKey)) { + dangling.push(`snapshot ${snapKey} has no package ${staticKey}`); + } + for (const kind of ['dependencies', 'optionalDependencies']) { + for (const [name, version] of Object.entries(snap[kind] || {})) { + if (!resolvable(name, version)) { + dangling.push(`snapshot ${snapKey} -> ${name}@${version}`); + } + } + } + } + + if (dangling.length) { + console.error(`berry_to_pnpm_lock: ${dangling.length} dangling reference(s):`); + for (const d of dangling.slice(0, 25)) { + console.error(` - ${d}`); + } + if (dangling.length > 25) { + console.error(` ... and ${dangling.length - 25} more`); + } + } else { + console.error('berry_to_pnpm_lock: model is internally consistent'); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const [, , inPath, outPath] = process.argv; + if (!inPath || !outPath) { + console.error('Usage: berry_to_pnpm_lock.mjs '); + process.exit(2); + } + const text = fs.readFileSync(inPath, 'utf8'); + const syml = parseSyml(text); + + // Load Yarn `resolutions` from the root package.json next to yarn.lock so we can + // apply them (Berry rewrites matching descriptors but keeps original dep ranges). + let resolutions = {}; + try { + const rootPkgPath = path.join(path.dirname(path.resolve(inPath)), 'package.json'); + if (fs.existsSync(rootPkgPath)) { + resolutions = JSON.parse(fs.readFileSync(rootPkgPath, 'utf8')).resolutions || {}; + } + } catch (_) { + // best effort + } + + const model = convert(syml, resolutions); + validate(model); + fs.writeFileSync(outPath, toYaml(model)); + const nImporters = Object.keys(model.importers).length; + const nPackages = Object.keys(model.packages).length; + if (model.droppedCount) { + console.error( + `berry_to_pnpm_lock: WARNING ${model.droppedCount} dependency edge(s) could not be resolved and were dropped`, + ); + } + console.error( + `berry_to_pnpm_lock: wrote ${outPath} (${nImporters} importers, ${nPackages} packages)`, + ); +} + +main(); diff --git a/tools/bazel/berry/example/.yarnrc.yml b/tools/bazel/berry/example/.yarnrc.yml new file mode 100644 index 000000000000..94f5c254e79d --- /dev/null +++ b/tools/bazel/berry/example/.yarnrc.yml @@ -0,0 +1,2 @@ +nodeLinker: node-modules +enableScripts: false diff --git a/tools/bazel/berry/example/BUILD.bazel b/tools/bazel/berry/example/BUILD.bazel new file mode 100644 index 000000000000..3c050133653f --- /dev/null +++ b/tools/bazel/berry/example/BUILD.bazel @@ -0,0 +1,18 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") +load("@npm_berry_example//:defs.bzl", "npm_link_all_packages") + +exports_files(["yarn.lock"]) + +# Links node_modules resolved (by our rules_js Berry fork) from this example's +# Yarn Berry yarn.lock. No committed pnpm-lock.yaml. +npm_link_all_packages(name = "node_modules") + +# `bazel test //tools/bazel/berry/example:verify` proves the whole chain: +# Berry yarn.lock -> berry_to_pnpm_lock.mjs -> patched npm_translate_lock -> +# rules_js fetch + link -> a Node program requiring an npm dep runs green. +js_test( + name = "verify", + size = "small", + data = [":node_modules/is-odd"], + entry_point = "verify.js", +) diff --git a/tools/bazel/berry/example/package.json b/tools/bazel/berry/example/package.json new file mode 100644 index 000000000000..f6ac31f6a6cb --- /dev/null +++ b/tools/bazel/berry/example/package.json @@ -0,0 +1,11 @@ +{ + "name": "berry-fork-example", + "version": "0.0.0", + "private": true, + "pnpm": { + "onlyBuiltDependencies": [] + }, + "dependencies": { + "is-odd": "3.0.1" + } +} diff --git a/tools/bazel/berry/example/verify.js b/tools/bazel/berry/example/verify.js new file mode 100644 index 000000000000..0526a6a69c88 --- /dev/null +++ b/tools/bazel/berry/example/verify.js @@ -0,0 +1,19 @@ +// Green proof that the rules_js Berry fork works end to end: +// this file is bundled/run by Bazel with node_modules materialized from a +// Yarn *Berry* yarn.lock (translated by tools/bazel/berry/berry_to_pnpm_lock.mjs +// via our patched npm_translate_lock — no committed pnpm-lock.yaml). +const isOdd = require('is-odd'); + +const cases = [ + [1, true], + [2, false], + [3, true], + [10, false], +]; +for (const [n, expected] of cases) { + if (isOdd(n) !== expected) { + console.error(`FAIL: isOdd(${n}) !== ${expected}`); + process.exit(1); + } +} +console.log('OK: is-odd (resolved from a Berry yarn.lock via the rules_js fork) works'); diff --git a/tools/bazel/berry/example/yarn.lock b/tools/bazel/berry/example/yarn.lock new file mode 100644 index 000000000000..05ad7e142a37 --- /dev/null +++ b/tools/bazel/berry/example/yarn.lock @@ -0,0 +1,30 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"berry-fork-example@workspace:.": + version: 0.0.0-use.local + resolution: "berry-fork-example@workspace:." + dependencies: + is-odd: "npm:3.0.1" + languageName: unknown + linkType: soft + +"is-number@npm:^6.0.0": + version: 6.0.0 + resolution: "is-number@npm:6.0.0" + checksum: 10c0/5da4c68401529675c575878d2760d66f18eaef4b014858577f6003daf66488d7fe4eae684b1e8574e3fa1bb447c6c6c56b8491d2b4b3239da2d32e5f6f218008 + languageName: node + linkType: hard + +"is-odd@npm:3.0.1": + version: 3.0.1 + resolution: "is-odd@npm:3.0.1" + dependencies: + is-number: "npm:^6.0.0" + checksum: 10c0/89ee2e353c5a3f3bd400c79db1c307a5b3506198ee8169d521e533a9b1d8a08fc95f21a919c084e98845b4286d7ffe309778da03744dfe66c3c1763ab1a030c6 + languageName: node + linkType: hard diff --git a/tools/bazel/js/BUILD.bazel b/tools/bazel/js/BUILD.bazel new file mode 100644 index 000000000000..b9b9b405f975 --- /dev/null +++ b/tools/bazel/js/BUILD.bazel @@ -0,0 +1,29 @@ +# Bazel macros for building React Native JavaScript (Metro). See metro.bzl. + +load("@aspect_rules_js//js:defs.bzl", "js_binary") + +exports_files([ + "first_party.bzl", + "metro.bzl", +]) + +js_binary( + name = "build_first_party", + data = [ + "//:node_modules/@babel/core", + "//:node_modules/@babel/preset-env", + "//:node_modules/@babel/preset-flow", + "//:node_modules/babel-plugin-minify-dead-code-elimination", + "//:node_modules/babel-plugin-syntax-hermes-parser", + "//:node_modules/babel-plugin-transform-define", + "//:node_modules/prettier", + ], + entry_point = "build_first_party.js", + visibility = ["//visibility:public"], +) + +js_binary( + name = "copy_tree", + entry_point = "copy_tree.js", + visibility = ["//visibility:public"], +) diff --git a/tools/bazel/js/build_first_party.js b/tools/bazel/js/build_first_party.js new file mode 100644 index 000000000000..8ffff6415525 --- /dev/null +++ b/tools/bazel/js/build_first_party.js @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * 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 + */ + +'use strict'; + +const babel = require('@babel/core'); +const fs = require('fs'); +const path = require('path'); +const prettier = require('prettier'); + +const JS_FILES_PATTERN = /\.js$/; +const FLOW_PRAGMA = /@flow/; + +const MONOREPO_BABEL_CONFIG = { + presets: [ + require.resolve('@babel/preset-flow'), + [require.resolve('@babel/preset-env'), {targets: {node: '18'}}], + ], + plugins: [ + require.resolve('babel-plugin-syntax-hermes-parser'), + [ + require.resolve('babel-plugin-transform-define'), + {'process.env.BUILD_EXCLUDE_BABEL_REGISTER': true}, + ], + [ + require.resolve('babel-plugin-minify-dead-code-elimination'), + {keepFnName: true, keepFnArgs: true, keepClassName: true}, + ], + ], +}; + +const CODEGEN_BABEL_CONFIG = MONOREPO_BABEL_CONFIG; + +function usage() { + console.error( + 'Usage: build_first_party.js ', + ); + process.exit(2); +} + +const [, , packageDirArg, buildKind, outDirArg] = process.argv; +if (packageDirArg == null || buildKind == null || outDirArg == null) { + usage(); +} + +const packageDir = path.resolve(packageDirArg); +const outDir = path.resolve(outDirArg); +const prettierConfig = {parser: 'babel'}; + +function mkdirp(dir) { + fs.mkdirSync(dir, {recursive: true}); +} + +function rmrf(target) { + fs.rmSync(target, {recursive: true, force: true}); +} + +function shouldSkipCopy(rel, dirent) { + const parts = rel.split(path.sep); + const outBase = path.basename(outDir); + return ( + parts.includes(outBase) || + parts.includes('pkg') || + rel === 'BUILD.bazel' || + rel.endsWith('.bazel') || + parts.includes('node_modules') || + parts.includes('.build') || + parts.includes('build') || + parts.includes('dist') || + parts.includes('lib') || + parts.includes('__tests__') || + parts.includes('__test_fixtures__') || + parts.includes('__fixtures__') || + (dirent != null && dirent.isDirectory() && dirent.name === 'Pods') + ); +} + +function copyPackageTree(srcRoot, destRoot, rel = '') { + for (const dirent of fs.readdirSync(path.join(srcRoot, rel), { + withFileTypes: true, + })) { + const childRel = path.join(rel, dirent.name); + if (shouldSkipCopy(childRel, dirent)) { + continue; + } + const src = path.join(srcRoot, childRel); + const dest = path.join(destRoot, childRel); + if (dirent.isDirectory()) { + mkdirp(dest); + copyPackageTree(srcRoot, destRoot, childRel); + } else if (dirent.isSymbolicLink()) { + const real = fs.realpathSync.native(src); + fs.copyFileSync(real, dest); + fs.chmodSync(dest, 0o644); + } else if (dirent.isFile()) { + mkdirp(path.dirname(dest)); + fs.copyFileSync(src, dest); + fs.chmodSync(dest, 0o644); + } + } +} + +function listFiles(root, rel = '') { + const result = []; + if (!fs.existsSync(path.join(root, rel))) { + return result; + } + for (const dirent of fs.readdirSync(path.join(root, rel), { + withFileTypes: true, + })) { + const childRel = path.join(rel, dirent.name); + const full = path.join(root, childRel); + if (dirent.isDirectory()) { + result.push(...listFiles(root, childRel)); + } else if (dirent.isFile()) { + result.push(full); + } + } + return result; +} + +function transformFile(src, dest, babelConfig) { + mkdirp(path.dirname(dest)); + const transformed = babel.transformFileSync(src, babelConfig).code; + fs.writeFileSync(dest, prettier.format(transformed, prettierConfig)); + const source = fs.readFileSync(src, 'utf8'); + if (FLOW_PRAGMA.test(source)) { + fs.copyFileSync(src, dest + '.flow'); + } +} + +function copyOrTransform(src, dest, babelConfig) { + if (!JS_FILES_PATTERN.test(src)) { + mkdirp(path.dirname(dest)); + fs.copyFileSync(src, dest); + fs.chmodSync(dest, 0o644); + return; + } + transformFile(src, dest, babelConfig); +} + +function rewriteExportsTarget(target, buildDir) { + return target.replace('./src/', './' + buildDir + '/'); +} + +function rewriteExportsField(exportsField, buildDir) { + if (typeof exportsField === 'string') { + return rewriteExportsTarget(exportsField, buildDir); + } + if (exportsField == null || typeof exportsField !== 'object') { + return exportsField; + } + const rewritten = Array.isArray(exportsField) ? [] : {}; + for (const key of Object.keys(exportsField)) { + rewritten[key] = rewriteExportsField(exportsField[key], buildDir); + } + return rewritten; +} + +function collectExportTargets(exportsField, targets = []) { + if (typeof exportsField === 'string') { + targets.push(exportsField); + } else if (exportsField != null && typeof exportsField === 'object') { + for (const value of Object.values(exportsField)) { + collectExportTargets(value, targets); + } + } + return targets; +} + +function buildPathFor(srcFile, srcDir, buildDir) { + const rel = path.relative(srcDir, srcFile).replace(/\.flow\.js$/, '.js'); + return path.join(buildDir, rel); +} + +function buildMonorepoPackage() { + const pkgPath = path.join(outDir, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const srcDir = path.join(outDir, 'src'); + const distDir = path.join(outDir, 'dist'); + const entryPoints = new Set(); + const wrappers = new Set(); + + for (const target of collectExportTargets(pkg.exports)) { + if (!target.endsWith('.js') || target.includes('*')) { + continue; + } + const original = target.replace('./dist/', './src/'); + const wrapper = path.join(outDir, original); + const flowEntry = wrapper.replace(/\.js$/, '.flow.js'); + if (fs.existsSync(wrapper) && fs.existsSync(flowEntry)) { + wrappers.add(path.normalize(wrapper)); + entryPoints.add(path.normalize(flowEntry)); + } + } + + for (const file of listFiles(srcDir)) { + const normalized = path.normalize(file); + if (wrappers.has(normalized) || entryPoints.has(normalized)) { + continue; + } + copyOrTransform(file, buildPathFor(file, srcDir, distDir), MONOREPO_BABEL_CONFIG); + } + + for (const entryPoint of entryPoints) { + copyOrTransform( + entryPoint, + buildPathFor(entryPoint, srcDir, distDir), + MONOREPO_BABEL_CONFIG, + ); + } + + if (pkg.exports != null) { + pkg.exports = rewriteExportsField(pkg.exports, 'dist'); + } + if (pkg.main != null) { + pkg.main = rewriteExportsTarget(pkg.main, 'dist'); + } + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); +} + +function buildCodegenPackage() { + const srcDir = path.join(outDir, 'src'); + const libDir = path.join(outDir, 'lib'); + for (const file of listFiles(srcDir)) { + copyOrTransform(file, buildPathFor(file, srcDir, libDir), CODEGEN_BABEL_CONFIG); + } +} + +rmrf(outDir); +mkdirp(outDir); +copyPackageTree(packageDir, outDir); + +if (buildKind === 'monorepo') { + buildMonorepoPackage(); +} else if (buildKind === 'codegen') { + buildCodegenPackage(); +} else { + usage(); +} diff --git a/tools/bazel/js/copy_tree.js b/tools/bazel/js/copy_tree.js new file mode 100644 index 000000000000..f09c146cdd63 --- /dev/null +++ b/tools/bazel/js/copy_tree.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * @format + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const [, , srcArg, outArg] = process.argv; +if (srcArg == null || outArg == null) { + console.error('Usage: copy_tree.js '); + process.exit(2); +} + +const srcRoot = path.resolve(srcArg); +const outRoot = path.resolve(outArg); +const outBase = path.basename(outRoot); + +function mkdirp(dir) { + fs.mkdirSync(dir, {recursive: true}); +} + +function copyDir(rel = '') { + for (const dirent of fs.readdirSync(path.join(srcRoot, rel), { + withFileTypes: true, + })) { + const childRel = path.join(rel, dirent.name); + const parts = childRel.split(path.sep); + if ( + parts.includes(outBase) || + parts.includes('node_modules') || + parts.includes('Pods') || + parts.includes('build') + ) { + continue; + } + const src = path.join(srcRoot, childRel); + const dest = path.join(outRoot, childRel); + if (dirent.isDirectory()) { + mkdirp(dest); + copyDir(childRel); + } else if (dirent.isFile() || dirent.isSymbolicLink()) { + mkdirp(path.dirname(dest)); + fs.copyFileSync(dirent.isSymbolicLink() ? fs.realpathSync.native(src) : src, dest); + fs.chmodSync(dest, 0o644); + } + } +} + +fs.rmSync(outRoot, {recursive: true, force: true}); +mkdirp(outRoot); +copyDir(); + +if (srcArg.replace(/\\/g, '/').endsWith('packages/rn-tester')) { + writeStub( + 'NativeComponentExample/js/MyNativeView.js', + "import {View} from 'react-native';\nexport default View;\n", + ); + writeStub( + 'NativeModuleExample/NativeScreenshotManager.js', + "module.exports = {takeScreenshot: () => Promise.resolve(null)};\n", + ); + writeStub( + 'NativeCxxModuleExample/NativeCxxModuleExample.js', + "export const EnumInt = {IA: 23, IB: 42};\nexport const EnumNone = {NA: 0, NB: 1};\nexport const EnumStr = {SA: 's---a', SB: 's---b'};\nexport default null;\n", + ); +} + +function writeStub(rel, content) { + const dest = path.join(outRoot, rel); + mkdirp(path.dirname(dest)); + fs.writeFileSync(dest, content); + fs.chmodSync(dest, 0o644); +} diff --git a/tools/bazel/js/first_party.bzl b/tools/bazel/js/first_party.bzl new file mode 100644 index 000000000000..fc1896818ed4 --- /dev/null +++ b/tools/bazel/js/first_party.bzl @@ -0,0 +1,42 @@ +"""Helpers for exposing built first-party JS workspace packages to rules_js.""" + +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") + +_EXCLUDES = [ + "**/node_modules/**", + "**/.build/**", + "**/build/**", + "**/dist/**", + "**/lib/**", + "**/Pods/**", + "**/__tests__/**", + "**/__test_fixtures__/**", + "**/__fixtures__/**", + "**/*.bazel", +] + +def first_party_js_package(name = "pkg", build_kind = "monorepo", visibility = None): + """Build a first-party package before linking it into Bazel node_modules.""" + npm_link_all_packages() + + built_dir = name + "_built" + js_run_binary( + name = name + "_build", + tool = "//tools/bazel/js:build_first_party", + srcs = native.glob(["**/*"], exclude = _EXCLUDES, allow_empty = True), + args = [ + native.package_name(), + build_kind, + native.package_name() + "/" + built_dir, + ], + out_dirs = [built_dir], + ) + + npm_package( + name = name, + srcs = [":" + name + "_build"], + root_paths = [native.package_name() + "/" + built_dir], + visibility = visibility, + ) diff --git a/tools/bazel/js/gen_package_builds.mjs b/tools/bazel/js/gen_package_builds.mjs new file mode 100644 index 000000000000..fd19384bd3fa --- /dev/null +++ b/tools/bazel/js/gen_package_builds.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// Generate the boilerplate `:pkg` BUILD.bazel files for first-party workspace +// packages, so they don't have to be hand-written/maintained. +// +// This is a small, repo-specific analog of `bazel run //:gazelle` (see the +// artifacts research report): it reads the workspace globs from the root +// package.json and, for every workspace package, writes a one-line BUILD.bazel +// that calls the rn_workspace_package() macro. +// +// Ownership / safety (mirrors Gazelle's `# gazelle:` and Nx's project.json +// override model): a BUILD.bazel is only (re)written when it is *generator-owned* +// -- it either carries the @generated marker below, or it is the pure legacy +// `:pkg` boilerplate (a single npm_package with no other target kinds). Any file +// that declares other targets (objc_library, cc_library, first_party, +// macos_application, rn_codegen, ...) is treated as hand-owned and left untouched. +// +// Usage: +// node tools/bazel/js/gen_package_builds.mjs # refresh owned files +// node tools/bazel/js/gen_package_builds.mjs --all # also create missing ones +// node tools/bazel/js/gen_package_builds.mjs --check # non-zero exit if stale (CI) + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const MARKER = '# @generated-by: tools/bazel/js/gen_package_builds.mjs'; + +const CONTENT = [ + MARKER, + '# Edit the generator or the macro, not this file. Regenerate with:', + '# node tools/bazel/js/gen_package_builds.mjs', + 'load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package")', + '', + 'rn_workspace_package()', + '', +].join('\n'); + +// Any of these target kinds mark a BUILD.bazel as hand-owned (never overwrite). +const HAND_OWNED_TOKENS = [ + 'objc_library(', 'cc_library(', 'swift_library(', 'macos_application(', + 'ios_application(', 'apple_', 'js_library(', 'js_binary(', 'js_run_binary(', + 'ts_project(', 'filegroup(', 'genrule(', 'rn_codegen(', 'first_party', + 'sample_turbo_modules(', 'rntester_extra', 'prebuilt_xcframeworks', +]; + +function isGeneratorOwned(text) { + if (text.includes(MARKER)) return true; + // Legacy pure boilerplate: has an npm_package but no other target kinds. + if (text.includes('npm_package(') && !HAND_OWNED_TOKENS.some((t) => text.includes(t))) { + return true; + } + return false; +} + +// Expand the root package.json "workspaces" globs (supports `dir/*` and `!neg`). +function workspaceDirs() { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const patterns = pkg.workspaces || []; + const positive = patterns.filter((p) => !p.startsWith('!')); + const negated = new Set(patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1))); + const dirs = new Set(); + for (const pattern of positive) { + if (pattern.endsWith('/*')) { + const base = pattern.slice(0, -2); + const baseAbs = path.join(repoRoot, base); + if (!fs.existsSync(baseAbs)) continue; + for (const entry of fs.readdirSync(baseAbs, {withFileTypes: true})) { + if (!entry.isDirectory()) continue; + const rel = base + '/' + entry.name; + if (negated.has(rel)) continue; + if (fs.existsSync(path.join(repoRoot, rel, 'package.json'))) dirs.add(rel); + } + } else if (!negated.has(pattern)) { + if (fs.existsSync(path.join(repoRoot, pattern, 'package.json'))) dirs.add(pattern); + } + } + return [...dirs].sort(); +} + +const args = new Set(process.argv.slice(2)); +const createMissing = args.has('--all'); +const checkOnly = args.has('--check'); + +const created = []; +const updated = []; +const skipped = []; +let stale = false; + +for (const dir of workspaceDirs()) { + const buildPath = path.join(repoRoot, dir, 'BUILD.bazel'); + if (!fs.existsSync(buildPath)) { + if (!createMissing) continue; + if (checkOnly) { stale = true; created.push(dir); continue; } + fs.writeFileSync(buildPath, CONTENT); + created.push(dir); + continue; + } + const current = fs.readFileSync(buildPath, 'utf8'); + if (!isGeneratorOwned(current)) { skipped.push(dir); continue; } + if (current === CONTENT) continue; // already up to date + if (checkOnly) { stale = true; updated.push(dir); continue; } + fs.writeFileSync(buildPath, CONTENT); + updated.push(dir); +} + +const log = (label, list) => { if (list.length) console.log(label + ' (' + list.length + '): ' + list.join(', ')); }; +log('created', created); +log(checkOnly ? 'stale' : 'updated', updated); +log('skipped (hand-owned)', skipped); +if (checkOnly && stale) { + console.error('BUILD.bazel files are stale. Run: node tools/bazel/js/gen_package_builds.mjs'); + process.exit(1); +} +if (!checkOnly) console.log('Done.'); diff --git a/tools/bazel/js/metro.bzl b/tools/bazel/js/metro.bzl new file mode 100644 index 000000000000..5edbcc9bffdb --- /dev/null +++ b/tools/bazel/js/metro.bzl @@ -0,0 +1,74 @@ +"""Bazel wrapper for bundling React Native JavaScript with Metro. + +We deliberately drive the existing Metro/React Native CLI from Bazel via +`js_run_binary` (rules_js) rather than reimplementing Metro's transform/worker +pipeline the way Buck2's `js_bundle` prelude does. This keeps the rule tiny while +reusing the exact bundler the rest of the repo already uses. See docs/bazel.md. +""" + +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") + +def metro_bundle( + name, + entry_point, + platform, + srcs, + deps = [], + config = None, + dev = False, + minify = None, + bundle_out = None, + assets_out = None, + **kwargs): + """Produce a `.jsbundle` (+ assets dir) from a React Native entry point. + + Args: + name: target name; also the default bundle basename. + entry_point: JS entry file (e.g. `js/RNTesterApp.macos.js`). + platform: React Native platform (`macos`, `ios`, `android`, ...). + srcs: JS/asset source files that make up the app. + deps: additional targets (e.g. linked first-party packages / node_modules). + config: optional `metro.config.js` target. + dev: whether to build a dev (unminified, with warnings) bundle. + minify: override minification (defaults to `not dev`). + bundle_out: output bundle filename (default `.jsbundle`). + assets_out: output assets directory (default `_assets`). + **kwargs: passed through to js_run_binary (e.g. tags, visibility). + """ + bundle_out = bundle_out or (name + ".jsbundle") + assets_out = assets_out or (name + "_assets") + minify = minify if minify != None else (not dev) + + args = [ + "bundle", + "--platform", + platform, + "--dev", + "true" if dev else "false", + "--minify", + "true" if minify else "false", + "--entry-file", + entry_point, + "--bundle-output", + bundle_out, + "--assets-dest", + assets_out, + "--reset-cache", + ] + tool_srcs = list(srcs) + list(deps) + if config: + args += ["--config", "$(rootpath %s)" % config] + tool_srcs.append(config) + + js_run_binary( + name = name, + # The React Native community CLI exposes the `bundle` command. This is a + # first-party workspace package; linking it requires the per-package + # BUILD.bazel migration described in docs/bazel.md. + tool = "//:node_modules/react-native/bin", + srcs = tool_srcs, + args = args, + outs = [bundle_out], + out_dirs = [assets_out], + **kwargs + ) diff --git a/tools/bazel/js/workspace_package.bzl b/tools/bazel/js/workspace_package.bzl new file mode 100644 index 000000000000..f31cd6dd8877 --- /dev/null +++ b/tools/bazel/js/workspace_package.bzl @@ -0,0 +1,49 @@ +"""Convention macro for a first-party workspace package. + +Most workspace packages need exactly one thing from Bazel: expose their files as a +`:pkg` `npm_package` so `npm_link_all_packages` can link them into the copied +`node_modules` (rules_js does not hoist like Yarn), which is what lets Metro bundle +first-party JS. That boilerplate was duplicated in ~17 identical `BUILD.bazel` +files; this macro collapses it to a single call so those files can be *generated* +(see tools/bazel/js/gen_package_builds.mjs) instead of hand-maintained. + +Usage (the entire BUILD.bazel of a leaf workspace package): + + load("//tools/bazel/js:workspace_package.bzl", "rn_workspace_package") + rn_workspace_package() +""" + +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") + +# Build outputs / tooling that must never end up in a package tarball. +_DEFAULT_EXCLUDES = [ + "**/node_modules/**", + "**/.build/**", + "**/build/**", + "**/Pods/**", + "**/__tests__/**", + "**/*.bazel", +] + +def rn_workspace_package(name = "pkg", srcs = None, exclude = None, visibility = ["//visibility:public"], **kwargs): + """Link this package's deps and expose it as `:` (default `:pkg`). + + Args: + name: npm_package target name (default "pkg"). + srcs: optional explicit srcs; defaults to a sensible glob of the package. + exclude: optional extra glob excludes appended to the defaults. + visibility: target visibility. + **kwargs: forwarded to npm_package. + """ + npm_link_all_packages() + npm_package( + name = name, + srcs = srcs if srcs != None else native.glob( + ["**/*"], + exclude = _DEFAULT_EXCLUDES + (exclude or []), + allow_empty = True, + ), + visibility = visibility, + **kwargs + ) diff --git a/tools/bazel/patches/BUILD.bazel b/tools/bazel/patches/BUILD.bazel new file mode 100644 index 000000000000..73f26db52828 --- /dev/null +++ b/tools/bazel/patches/BUILD.bazel @@ -0,0 +1,2 @@ +# Patches applied to external Bazel modules via single_version_override. +exports_files(glob(["*.patch"])) diff --git a/tools/bazel/patches/aspect_rules_js_berry.patch b/tools/bazel/patches/aspect_rules_js_berry.patch new file mode 100644 index 000000000000..cb24d62c0c04 --- /dev/null +++ b/tools/bazel/patches/aspect_rules_js_berry.patch @@ -0,0 +1,79 @@ +--- a/npm/private/npm_translate_lock.bzl ++++ b/npm/private/npm_translate_lock.bzl +@@ -336,7 +336,67 @@ + """), + }, + ) ++ ++def _find_berry_converter(yarn_path): ++ # The converter lives at /tools/bazel/berry/berry_to_pnpm_lock.mjs. ++ # Walk up from the yarn.lock directory to locate it, so this works for lockfiles ++ # anywhere in the repo (root or nested example projects). ++ d = yarn_path.dirname ++ for _ in range(32): ++ candidate = d.get_child("tools").get_child("bazel").get_child("berry").get_child("berry_to_pnpm_lock.mjs") ++ if candidate.exists: ++ return candidate ++ parent = d.dirname ++ if parent == None or str(parent) == str(d): ++ break ++ d = parent ++ return None ++ ++def _generate_pnpm_lock_from_berry(rctx, attr): ++ """rules_js Berry fork: generate pnpm-lock.yaml from a Yarn v2+ (Berry) yarn.lock. ++ ++ aspect_rules_js normally shells out to `pnpm import` to translate a yarn.lock, but ++ `pnpm import` does not understand the Berry (v2+) lockfile format. When the input ++ yarn.lock is a Berry lockfile and the pnpm-lock.yaml does not yet exist, translate it ++ with our bundled dependency-free converter so the Berry yarn.lock can remain the single ++ source of truth (the generated pnpm-lock.yaml is gitignored, never committed). See ++ tools/bazel/berry/ and docs/bazel.md. ++ """ ++ if not attr.yarn_lock or not attr.pnpm_lock: ++ return ++ yarn_path = rctx.path(attr.yarn_lock) ++ if not yarn_path.exists: ++ return ++ if "__metadata:" not in rctx.read(yarn_path): ++ return # not a Berry lockfile; leave the normal pnpm import path in charge ++ converter = _find_berry_converter(yarn_path) ++ if converter == None: ++ fail("berry_to_pnpm_lock.mjs not found above yarn.lock at {}".format(yarn_path)) + ++ # Re-run this extension when the Berry lock or the converter change. ++ rctx.watch(yarn_path) ++ rctx.watch(converter) ++ ++ pnpm_lock_path = rctx.path(attr.pnpm_lock) ++ if pnpm_lock_path.exists: ++ return # already generated; delete pnpm-lock.yaml to refresh after yarn.lock changes ++ rctx.report_progress("Translating Berry yarn.lock -> pnpm-lock.yaml") ++ result = rctx.execute( ++ [ ++ _host_node_path(rctx, attr), ++ converter, ++ yarn_path, ++ pnpm_lock_path, ++ ], ++ quiet = attr.quiet, ++ ) ++ if result.return_code: ++ fail("berry_to_pnpm_lock conversion failed (status {}):\n{}\n{}".format( ++ result.return_code, ++ result.stdout, ++ result.stderr, ++ )) ++ + def parse_and_verify_lock(rctx, mod, attr): + """Helper to parse and validate the lockfile + +@@ -348,6 +408,8 @@ + state, importers, and packages + """ + ++ _generate_pnpm_lock_from_berry(rctx, attr) ++ + state = npm_translate_lock_state.new(rctx, mod, attr) + + if state.should_update_pnpm_lock(): diff --git a/tools/bazel/react_native/BUILD.bazel b/tools/bazel/react_native/BUILD.bazel new file mode 100644 index 000000000000..45472a715280 --- /dev/null +++ b/tools/bazel/react_native/BUILD.bazel @@ -0,0 +1,91 @@ +load('@aspect_rules_js//js:defs.bzl', 'js_binary', 'js_run_binary') +load('//tools/bazel/react_native:defs.bzl', 'rn_codegen') + +package(default_visibility = ['//visibility:public']) + + +js_binary( + name = 'build_codegen_lib_bin', + data = [ + '//packages/react-native-codegen:node_modules', + '//packages/react-native-codegen:node_modules/@babel/core', + '//packages/react-native-codegen:node_modules/@babel/plugin-syntax-dynamic-import', + '//packages/react-native-codegen:node_modules/@babel/plugin-transform-class-properties', + '//packages/react-native-codegen:node_modules/@babel/plugin-transform-flow-strip-types', + '//packages/react-native-codegen:node_modules/@babel/plugin-transform-nullish-coalescing-operator', + '//packages/react-native-codegen:node_modules/@babel/plugin-transform-optional-chaining', + '//packages/react-native-codegen:node_modules/hermes-estree', + '//packages/react-native-codegen:node_modules/hermes-parser', + '//packages/react-native-codegen:node_modules/invariant', + '//packages/react-native-codegen:node_modules/nullthrows', + '//packages/react-native-codegen:node_modules/glob', + '//packages/react-native-codegen:node_modules/micromatch', + '//packages/react-native-codegen:node_modules/prettier', + '//packages/react-native-codegen:node_modules/yargs', + ], + entry_point = 'build_codegen_lib.js', +) + +js_run_binary( + name = 'codegen_lib', + srcs = ['//packages/react-native-codegen:pkg'], + out_dirs = ['codegen_lib'], + env = { + 'OUTPUT_DIR': '$(RULEDIR)/codegen_lib', + }, + tool = ':build_codegen_lib_bin', +) + +js_binary( + name = 'codegen_runner_bin', + data = [ + '//packages/react-native:node_modules', + '//packages/react-native:pkg', + ], + entry_point = 'codegen_runner.js', +) + + +genrule( + name = 'copy_my_legacy_view_native_component', + srcs = ['//packages/rn-tester/NativeComponentExample:js/MyLegacyViewNativeComponent.js'], + outs = ['rn_tester_inputs/NativeComponentExample/js/MyLegacyViewNativeComponent.js'], + cmd = 'cp $(location //packages/rn-tester/NativeComponentExample:js/MyLegacyViewNativeComponent.js) $@', +) + +genrule( + name = 'copy_my_native_view_native_component', + srcs = ['//packages/rn-tester/NativeComponentExample:js/MyNativeViewNativeComponent.js'], + outs = ['rn_tester_inputs/NativeComponentExample/js/MyNativeViewNativeComponent.js'], + cmd = 'cp $(location //packages/rn-tester/NativeComponentExample:js/MyNativeViewNativeComponent.js) $@', +) + +genrule( + name = 'copy_native_cxx_module_example', + srcs = ['//packages/rn-tester/NativeCxxModuleExample:NativeCxxModuleExample.js'], + outs = ['rn_tester_inputs/NativeCxxModuleExample/NativeCxxModuleExample.js'], + cmd = 'cp $(location //packages/rn-tester/NativeCxxModuleExample:NativeCxxModuleExample.js) $@', +) + +genrule( + name = 'copy_native_screenshot_manager', + srcs = ['//packages/rn-tester/NativeModuleExample:NativeScreenshotManager.js'], + outs = ['rn_tester_inputs/NativeModuleExample/NativeScreenshotManager.js'], + cmd = 'cp $(location //packages/rn-tester/NativeModuleExample:NativeScreenshotManager.js) $@', +) + +filegroup( + name = 'rn_tester_codegen_inputs', + srcs = [ + ':copy_my_legacy_view_native_component', + ':copy_my_native_view_native_component', + ':copy_native_cxx_module_example', + ':copy_native_screenshot_manager', + ], +) + +rn_codegen( + name = 'rn_tester_appspecs', + project_root = 'packages/rn-tester', + rn_tester_srcs = [':rn_tester_codegen_inputs'], +) diff --git a/tools/bazel/react_native/build_codegen_lib.js b/tools/bazel/react_native/build_codegen_lib.js new file mode 100644 index 000000000000..a5d3f1656c36 --- /dev/null +++ b/tools/bazel/react_native/build_codegen_lib.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const Module = require('module'); +const path = require('path'); + +const cwd = process.cwd(); +const marker = `${path.sep}bazel-out${path.sep}`; +const workspaceRoot = + process.env.BUILD_WORKSPACE_DIRECTORY || + (cwd.includes(marker) ? cwd.slice(0, cwd.indexOf(marker)) : cwd); +const bazelPackageDir = path.join( + workspaceRoot, + process.env.BAZEL_BINDIR || '', + 'packages/react-native-codegen/pkg', +); +const packageDir = fs.existsSync(path.join(bazelPackageDir, 'src')) + ? bazelPackageDir + : path.join(workspaceRoot, 'packages/react-native-codegen'); +const bazelPackageNodeModules = path.join( + workspaceRoot, + process.env.BAZEL_BINDIR || '', + 'packages/react-native-codegen/node_modules', +); +const nodeModulesDir = fs.existsSync(bazelPackageNodeModules) + ? bazelPackageNodeModules + : path.join(packageDir, 'node_modules'); +const aspectStoreDir = path.join( + workspaceRoot, + process.env.BAZEL_BINDIR || '', + 'node_modules/.aspect_rules_js', +); +const aspectNodeModulePaths = fs.existsSync(aspectStoreDir) + ? fs + .readdirSync(aspectStoreDir) + .map(entry => path.join(aspectStoreDir, entry, 'node_modules')) + .filter(entry => fs.existsSync(entry)) + : []; +process.env.NODE_PATH = [ + ...aspectNodeModulePaths, + process.env.NODE_PATH || '', +] + .filter(Boolean) + .join(path.delimiter); +Module._initPaths(); +const moduleSearchPaths = [ + nodeModulesDir, + path.join(workspaceRoot, 'node_modules'), + packageDir, + ...aspectNodeModulePaths, +]; +const resolveFromCodegen = moduleName => + require.resolve(moduleName, {paths: moduleSearchPaths}); +const requireFromCodegen = moduleName => require(resolveFromCodegen(moduleName)); +const babel = requireFromCodegen('@babel/core'); +const glob = requireFromCodegen('glob'); +const micromatch = requireFromCodegen('micromatch'); +const prettier = requireFromCodegen('prettier'); +const srcDir = path.join(packageDir, 'src'); +const outputDir = path.resolve(workspaceRoot, process.env.OUTPUT_DIR || 'tools/bazel/react_native/codegen_lib'); +const prettierConfig = { + arrowParens: 'avoid', + bracketSameLine: true, + bracketSpacing: false, + requirePragma: true, + singleQuote: true, + trailingComma: 'all', +}; +const babelPlugins = [ + '@babel/plugin-transform-flow-strip-types', + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-transform-class-properties', + '@babel/plugin-transform-nullish-coalescing-operator', + '@babel/plugin-transform-optional-chaining', +].map(plugin => resolveFromCodegen(plugin)); + +fs.rmSync(outputDir, {recursive: true, force: true}); +fs.mkdirSync(outputDir, {recursive: true}); + +function outputPathFor(file) { + return path.join(outputDir, path.relative(srcDir, file)); +} + +function copyFile(from, to) { + fs.mkdirSync(path.dirname(to), {recursive: true}); + fs.copyFileSync(from, to); +} + +const inputFiles = glob.sync(path.join(srcDir, '**/*'), {nodir: true, dot: true}); +if (inputFiles.length === 0) { + throw new Error(`No react-native-codegen inputs found under ${srcDir}; packageDir exists=${fs.existsSync(packageDir)} src exists=${fs.existsSync(srcDir)}`); +} +for (const file of inputFiles) { + const relative = path.relative(srcDir, file); + if (micromatch.isMatch(relative, '**/(__tests__|__test_fixtures__)/**')) { + continue; + } + + const dest = outputPathFor(file); + if (!micromatch.isMatch(relative, '**/*.js')) { + copyFile(file, dest); + continue; + } + + const transformed = babel.transformFileSync(file, { + cwd: packageDir, + root: packageDir, + babelrc: false, + configFile: false, + plugins: babelPlugins, + }).code; + const formatted = prettier.format(transformed, { + ...prettierConfig, + parser: 'babel', + }); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.writeFileSync(dest, formatted); + + const source = fs.readFileSync(file, 'utf8'); + if (/@flow/.test(source)) { + fs.writeFileSync(dest + '.flow', source); + } +} + + +function copyRuntimePackage(packageName, seen = new Set()) { + if (seen.has(packageName)) { + return; + } + seen.add(packageName); + let packageJsonPath; + try { + packageJsonPath = resolveFromCodegen(path.join(packageName, 'package.json')); + } catch { + return; + } + const packagePath = path.dirname(packageJsonPath); + const destinationPath = path.join(outputDir, 'node_modules', packageName); + fs.rmSync(destinationPath, {recursive: true, force: true}); + fs.mkdirSync(path.dirname(destinationPath), {recursive: true}); + fs.cpSync(packagePath, destinationPath, { + recursive: true, + dereference: true, + filter: src => !src.includes(`${path.sep}.cache${path.sep}`), + }); + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + for (const dependency of Object.keys(packageJson.dependencies || {})) { + copyRuntimePackage(dependency, seen); + } +} + +for (const runtimePackage of [ + '@babel/core', + '@babel/parser', + 'balanced-match', + 'concat-map', + 'glob', + 'hermes-estree', + 'hermes-parser', + 'invariant', + 'nullthrows', + 'yargs', +]) { + copyRuntimePackage(runtimePackage); +} + +console.log(`Built react-native-codegen lib at ${outputDir}`); diff --git a/tools/bazel/react_native/codegen_runner.js b/tools/bazel/react_native/codegen_runner.js new file mode 100644 index 000000000000..bd90acbc1c80 --- /dev/null +++ b/tools/bazel/react_native/codegen_runner.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const cwd = process.cwd(); +const marker = `${path.sep}bazel-out${path.sep}`; +const workspaceRoot = cwd.includes(marker) ? cwd.slice(0, cwd.indexOf(marker)) : cwd; +const requestedProjectRoot = path.resolve(workspaceRoot, mustEnv('PROJECT_ROOT')); +const outputDir = path.resolve(workspaceRoot, mustEnv('OUTPUT_DIR')); +const targetPlatform = process.env.TARGET_PLATFORM || 'ios'; +const source = process.env.SOURCE || 'app'; +const codegenLibDir = path.resolve(workspaceRoot, mustEnv('CODEGEN_LIB_DIR')); +const bazelReactNativePackageRoot = path.join( + workspaceRoot, + process.env.BAZEL_BINDIR || '', + 'packages/react-native/pkg', +); +const reactNativePackageRoot = fs.existsSync(bazelReactNativePackageRoot) + ? bazelReactNativePackageRoot + : path.join(workspaceRoot, 'packages/react-native'); + +function prepareRnTesterProject() { + const scratchProjectRoot = path.join(outputDir, '_rn_tester_project'); + fs.rmSync(scratchProjectRoot, {recursive: true, force: true}); + fs.mkdirSync(scratchProjectRoot, {recursive: true}); + + fs.writeFileSync( + path.join(scratchProjectRoot, 'package.json'), + JSON.stringify( + { + name: '@react-native/tester', + version: '0.81.0-main', + private: true, + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + codegenConfig: { + name: 'AppSpecs', + type: 'all', + jsSrcsDir: '.', + android: { + javaPackageName: 'com.facebook.fbreact.specs', + }, + ios: { + modules: { + SampleTurboModule: { + unstableRequiresMainQueueSetup: true, + }, + }, + components: { + RNTMyNativeView: { + className: 'RNTMyNativeViewComponentView', + }, + }, + }, + }, + }, + null, + 2, + ), + ); + + const generatedInputsRoot = path.join( + workspaceRoot, + process.env.BAZEL_BINDIR || '', + 'tools/bazel/react_native/rn_tester_inputs', + ); + const sourceRoot = fs.existsSync(generatedInputsRoot) + ? generatedInputsRoot + : requestedProjectRoot; + + for (const relativePath of [ + 'NativeComponentExample/js/MyLegacyViewNativeComponent.js', + 'NativeComponentExample/js/MyNativeViewNativeComponent.js', + 'NativeCxxModuleExample/NativeCxxModuleExample.js', + 'NativeModuleExample/NativeScreenshotManager.js', + ]) { + const from = path.join(sourceRoot, relativePath); + const to = path.join(scratchProjectRoot, relativePath); + fs.mkdirSync(path.dirname(to), {recursive: true}); + fs.copyFileSync(from, to); + } + + return scratchProjectRoot; +} + +function mustEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} must be set`); + } + return value; +} + +function assertExists(relativePath) { + const absolutePath = path.join(outputDir, relativePath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Expected codegen output missing: ${absolutePath}`); + } +} + +fs.mkdirSync(outputDir, {recursive: true}); +const scratchDir = path.join(outputDir, '_codegen_scratch'); +fs.rmSync(scratchDir, {recursive: true, force: true}); +fs.mkdirSync(scratchDir, {recursive: true}); +process.env.TMPDIR = scratchDir; + +const Module = require('module'); +process.env.NODE_PATH = [ + path.join(codegenLibDir, 'node_modules'), + process.env.NODE_PATH || '', +] + .filter(Boolean) + .join(path.delimiter); +Module._initPaths(); +const originalResolveFilename = Module._resolveFilename; +Module._resolveFilename = function (request, parent, isMain, options) { + if (request.startsWith('@react-native/codegen/lib/')) { + const resolved = path.join(codegenLibDir, request.slice('@react-native/codegen/lib/'.length)); + return path.extname(resolved) ? resolved : `${resolved}.js`; + } + return originalResolveFilename.call(this, request, parent, isMain, options); +}; + +const scratchCodegenRepo = path.join(outputDir, '_codegen_repo'); +let projectRoot; + +try { + projectRoot = prepareRnTesterProject(); + const libPath = path.join(scratchCodegenRepo, 'lib'); + fs.rmSync(scratchCodegenRepo, {recursive: true, force: true}); + fs.mkdirSync(scratchCodegenRepo, {recursive: true}); + fs.symlinkSync(codegenLibDir, libPath, 'dir'); + + const constantsPath = path.join( + reactNativePackageRoot, + 'scripts/codegen/generate-artifacts-executor/constants.js', + ); + const constants = require(constantsPath); + constants.CODEGEN_REPO_PATH = scratchCodegenRepo; + constants.CORE_LIBRARIES_WITH_OUTPUT_FOLDER.FBReactNativeSpec.ios = path.join( + scratchDir, + 'FBReactNativeSpec', + ); + + const executor = require(path.join( + reactNativePackageRoot, + 'scripts/codegen/generate-artifacts-executor', + )); + executor.execute(projectRoot, targetPlatform, outputDir, source); + + if (process.exitCode) { + process.exit(process.exitCode); + } + + [ + 'build/generated/ios/AppSpecs/AppSpecs.h', + 'build/generated/ios/AppSpecs/AppSpecs-generated.mm', + 'build/generated/ios/AppSpecsJSI.h', + 'build/generated/ios/AppSpecsJSI-generated.cpp', + 'build/generated/ios/RCTAppDependencyProvider.h', + 'build/generated/ios/RCTAppDependencyProvider.mm', + 'build/generated/ios/RCTThirdPartyComponentsProvider.h', + 'build/generated/ios/RCTThirdPartyComponentsProvider.mm', + 'build/generated/ios/RCTModuleProviders.h', + 'build/generated/ios/RCTModuleProviders.mm', + 'build/generated/ios/RCTModulesConformingToProtocolsProvider.h', + 'build/generated/ios/RCTModulesConformingToProtocolsProvider.mm', + 'build/generated/ios/RCTUnstableModulesRequiringMainQueueSetupProvider.h', + 'build/generated/ios/RCTUnstableModulesRequiringMainQueueSetupProvider.mm', + 'build/generated/ios/react/renderer/components/AppSpecs/ComponentDescriptors.h', + ].forEach(assertExists); +} finally { + fs.rmSync(scratchDir, {recursive: true, force: true}); + if (projectRoot) { + fs.rmSync(projectRoot, {recursive: true, force: true}); + } + fs.rmSync(scratchCodegenRepo, {recursive: true, force: true}); +} diff --git a/tools/bazel/react_native/defs.bzl b/tools/bazel/react_native/defs.bzl new file mode 100644 index 000000000000..8cc08c017611 --- /dev/null +++ b/tools/bazel/react_native/defs.bzl @@ -0,0 +1,110 @@ +load('@aspect_rules_js//js:defs.bzl', 'js_run_binary') + +_GEN = 'build/generated/ios/' + +# Top-level generated headers, consumed as `` on Apple. +_TOP_HEADERS = [ + _GEN + 'AppSpecsJSI.h', + _GEN + 'RCTModuleProviders.h', + _GEN + 'RCTModulesConformingToProtocolsProvider.h', + _GEN + 'RCTThirdPartyComponentsProvider.h', + _GEN + 'RCTUnstableModulesRequiringMainQueueSetupProvider.h', + _GEN + 'AppSpecs/AppSpecs.h', +] + +# Consumed as ``. +_APPDEP_HEADER = _GEN + 'RCTAppDependencyProvider.h' + +# Fabric component headers, consumed as ``. +_FABRIC_HEADERS = [ + _GEN + 'react/renderer/components/AppSpecs/ComponentDescriptors.h', + _GEN + 'react/renderer/components/AppSpecs/EventEmitters.h', + _GEN + 'react/renderer/components/AppSpecs/Props.h', + _GEN + 'react/renderer/components/AppSpecs/RCTComponentViewHelpers.h', + _GEN + 'react/renderer/components/AppSpecs/ShadowNodes.h', + _GEN + 'react/renderer/components/AppSpecs/States.h', +] + +_SOURCES = [ + _GEN + 'RCTAppDependencyProvider.mm', + _GEN + 'RCTModuleProviders.mm', + _GEN + 'RCTModulesConformingToProtocolsProvider.mm', + _GEN + 'RCTThirdPartyComponentsProvider.mm', + _GEN + 'RCTUnstableModulesRequiringMainQueueSetupProvider.mm', + _GEN + 'AppSpecs/AppSpecs-generated.mm', + _GEN + 'AppSpecsJSI-generated.cpp', + _GEN + 'react/renderer/components/AppSpecs/ComponentDescriptors.cpp', + _GEN + 'react/renderer/components/AppSpecs/EventEmitters.cpp', + _GEN + 'react/renderer/components/AppSpecs/Props.cpp', + _GEN + 'react/renderer/components/AppSpecs/ShadowNodes.cpp', + _GEN + 'react/renderer/components/AppSpecs/States.cpp', +] + +_PODSPECS = [ + _GEN + 'ReactAppDependencyProvider.podspec', + _GEN + 'ReactCodegen.podspec', +] + +_CODEGEN_OUTS = _TOP_HEADERS + [_APPDEP_HEADER] + _FABRIC_HEADERS + _SOURCES + _PODSPECS + +_HEADER_DEPS = [ + '//packages/react-native:rn_cxx_headers', + '@rn_prebuilt_xcframeworks//:React_headers', + '@rn_prebuilt_xcframeworks//:ReactNativeDependencies_headers', +] + +def rn_codegen(name, project_root, rn_tester_srcs, **kwargs): + """Generate rn-tester's codegen (AppSpecs + providers) and compile it. + + Produces: + * `` -- the raw generated files (js_run_binary). + * `_lib` -- an objc_library compiling the generated providers + AppSpecs, + exposing ``, `` and + `` to the app. + """ + js_run_binary( + name = name, + srcs = rn_tester_srcs + ['//tools/bazel/react_native:codegen_lib'], + outs = _CODEGEN_OUTS, + env = { + 'PROJECT_ROOT': project_root, + 'OUTPUT_DIR': '$(RULEDIR)', + 'TARGET_PLATFORM': 'ios', + 'SOURCE': 'app', + 'CODEGEN_LIB_DIR': '$(location //tools/bazel/react_native:codegen_lib)', + }, + tool = '//tools/bazel/react_native:codegen_runner_bin', + **kwargs + ) + + native.cc_library( + name = name + '_reactcodegen_hdrs', + hdrs = _TOP_HEADERS, + strip_include_prefix = 'build/generated/ios', + include_prefix = 'ReactCodegen', + tags = ['manual'], + ) + native.cc_library( + name = name + '_appdep_hdrs', + hdrs = [_APPDEP_HEADER], + strip_include_prefix = 'build/generated/ios', + include_prefix = 'ReactAppDependencyProvider', + tags = ['manual'], + ) + native.cc_library( + name = name + '_fabric_hdrs', + hdrs = _FABRIC_HEADERS, + strip_include_prefix = 'build/generated/ios', + tags = ['manual'], + ) + + native.objc_library( + name = name + '_lib', + srcs = _SOURCES, + deps = [ + ':' + name + '_reactcodegen_hdrs', + ':' + name + '_appdep_hdrs', + ':' + name + '_fabric_hdrs', + ] + _HEADER_DEPS, + tags = ['manual'], + ) diff --git a/yarn.lock b/yarn.lock index 305e0c9812e4..56066ef8ad09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3536,6 +3536,8 @@ __metadata: "@react-native-community/cli": "npm:20.0.0" "@react-native-community/cli-platform-android": "npm:20.0.0" "@react-native-community/cli-platform-ios": "npm:20.0.0" + "@react-native/metro-babel-transformer": "workspace:*" + "@react-native/metro-config": "workspace:*" "@react-native/new-app-screen": "workspace:*" "@react-native/oss-library-example": "workspace:*" "@react-native/popup-menu-android": "workspace:*" @@ -3543,7 +3545,9 @@ __metadata: flow-enums-runtime: "npm:^0.0.6" invariant: "npm:^2.2.4" listr2: "npm:^6.4.1" + metro: "npm:^0.82.5" nullthrows: "npm:^1.1.1" + react: "npm:19.1.0" react-native-macos: "workspace:*" rxjs: "npm:@react-native-community/rxjs@6.5.4-custom" peerDependencies: