Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Prebuilt ReactNativeDependencies — self-serving headers + deps facades

How the third-party C/C++ deps (`RCT-Folly`, `glog`, `boost`,
`DoubleConversion`, `fmt`, `fast_float`, `SocketRocket`) are served when
`ReactNativeDependenciesUtils.build_react_native_deps_from_source()` is false
(prebuilt-deps mode). Source-deps mode is unaffected by everything below.

## Pod-served headers, CocoaPods only (`rndependencies.rb`)

In prebuilt-deps mode the `ReactNativeDependencies` POD (CocoaPods) is the
single authority for the third-party deps: compiled code lives in its
xcframework binary, and the artifact's own
`Headers/{folly,glog,boost,fmt,double-conversion,fast_float,SocketRocket}` are
flattened into the pod's `Headers/` by the podspec's `prepare_command`.
Consumers resolve bare `<folly/...>` / `<SocketRocket/...>` via CocoaPods
public-header linkage from `s.dependency "ReactNativeDependencies"`, plus an
explicit `HEADER_SEARCH_PATHS` entry
(`$(PODS_ROOT)/ReactNativeDependencies/Headers`). The real source pods are
neither depended on nor searched.

NOTE: this is a CocoaPods-level contract. The deps XCFRAMEWORK itself is NOT
self-serving: it is framework-type without `HeadersPath`, so its root `Headers/`
is invisible to SPM binaryTargets (verified 2026-07-04 — `HeadersPath` is
rejected on framework entries). In SPM the six C++ namespaces are served by
ReactNativeHeaders.xcframework; serving them from the deps side requires the
phase-2 headers-only library sidecar (see rn-deps-self-serving plan).

## Why SocketRocket is vended here

React-Core compiled from source (source-core + prebuilt-deps mix) imports
`<SocketRocket/SRWebSocket.h>` (`RCTReconnectingWebSocket.m`), and in
prebuilt-deps mode there is NO real SocketRocket pod in the graph — the artifact
is the sole supplier. This does not reintroduce the 2026-07-03 dual-copy
regression: that bug relocated SocketRocket copies onto every pod's search path
(via ReactNativeHeaders → React-Core-prebuilt) while a REAL SocketRocket pod
coexisted. Here there is exactly one physical copy and no coexisting pod.

## Deps facades (`rndeps_facades.rb`, declared in `react_native_pods.rb`)

The real source pods are only declared in the deps-from-source branch, so in
prebuilt-deps mode a community podspec's hardcoded `s.dependency "RCT-Folly"` /
`"RCT-Folly/Fabric"` / `"glog"` would resolve from the CocoaPods trunk and
compile from source next to the prebuilt binary. `RNDepsFacades` generates
dependency-only facade podspecs (`build/rndeps-facades/<Name>/`), installed as
LOCAL pods (`:path`, so Podfile-local resolution beats trunk, nothing fetched):
no sources, no headers, single dependency on `ReactNativeDependencies`.
Versions + subspecs are DERIVED from the real podspecs in
`third-party-podspecs/` (RCT-Folly keeps `/Default` + `/Fabric`,
`default_subspecs = ["Default"]`). SocketRocket has no local podspec — its
facade version is SYNTHESIZED from
`Helpers::Constants::socket_rocket_config[:version]`, fail-closed if absent.
`:modular_headers` is intentionally dropped on facade declarations: a
dependency-only placeholder builds no module; consumers get modules from
`ReactNativeDependencies`.

## Mode × supplier table

| core × deps | real 3P pods in graph | SocketRocket headers supplier |
| ------------------- | ----------------------------------------------- | ------------------------------- |
| source + source | yes (`react_native_pods.rb` deps-source branch) | real pod |
| source + prebuilt | no | RNDeps artifact (sole supplier) |
| prebuilt + source | yes | real pod |
| prebuilt + prebuilt | no | RNDeps artifact |

## SocketRocket privacy manifest

Upstream SocketRocket ships NO privacy manifest, and the deps artifact
historically carried bundles only for boost/folly/glog. Fixed alongside this
work: the deps prebuild (`scripts/releases/ios-prebuild/configuration.js`) now
embeds `ReactNativeDependencies_SocketRocket.bundle/PrivacyInfo.xcprivacy`,
sourced from an RN-authored manifest at
`scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy`
(accurate-empty: SocketRocket uses no Required Reason APIs). Facades remain
resource-free by design.
16 changes: 14 additions & 2 deletions packages/react-native/scripts/cocoapods/rndependencies.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,22 @@ def add_rn_third_party_dependencies(s)
header_search_paths << "$(PODS_ROOT)/SocketRocket"
header_search_paths << "$(PODS_ROOT)/RCT-Folly"

current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths
# uniq so a second call on the same spec can't duplicate entries.
current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq
else
# Prebuilt-deps mode: this pod SELF-SERVES the third-party headers from its
# own xcframework (incl. SocketRocket - sole supplier in this mode). See
# scripts/cocoapods/__docs__/prebuilt-deps.md for the full contract.
s.dependency "ReactNativeDependencies"
current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] ||= [] << "$(PODS_ROOT)/ReactNativeDependencies"

header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice — this mirrors the from-source branch's String-vs-Array handling and fixes the prior ||= [] << ... idiom that never appended when the key already existed.

if header_search_paths.is_a?(String)
header_search_paths = header_search_paths.split(" ")
end
# Artifact headers are flattened into the pod-local Headers/ by the podspec
# prepare_command (see __docs__/prebuilt-deps.md).
header_search_paths << "$(PODS_ROOT)/ReactNativeDependencies/Headers"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This appends the search path unconditionally; if add_rn_third_party_dependencies ran twice on the same spec the entry would duplicate. Low risk (one call per spec) and now symmetric with the from-source branch — just noting.

current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths
end

s.pod_target_xcconfig = current_pod_target_xcconfig
Expand Down
194 changes: 194 additions & 0 deletions packages/react-native/scripts/cocoapods/rndeps_facades.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# 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.

require 'json'
require 'fileutils'
# Self-contained against require ordering: this module reads
# Helpers::Constants.socket_rocket_config. react_native_pods.rb normally loads
# helpers.rb first, but requiring it here (idempotent) removes that implicit
# dependency. The defined? guard at the use site stays as a backstop.
require_relative './helpers'

# Dependency-only facade podspecs for the third-party deps in prebuilt-deps
# mode (deps-side analogue of RNCoreFacades). Design + rationale:
# scripts/cocoapods/__docs__/prebuilt-deps.md
module RNDepsFacades

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major (test coverage): This entire new module ships without tests, and several branchy behaviors are unit-testable without a full pod install: derive_subspecs filtering, the two synthesized_version fail-closed raise paths, derived_spec version/subspec derivation, and load_real_spec's missing-vs-unparseable errors. These error contracts are load-bearing (a silently-empty facade would hide drift) and deserve a few RSpec/minitest cases.

# The name of the umbrella prebuilt-deps pod every facade depends on. Its pod
# self-serves the third-party headers + carries the binary (see
# rndependencies.rb, ReactNativeDependencies.podspec).
DEPS_POD = "ReactNativeDependencies"

# pod name => podspec path (relative to the react-native package root), or
# :synthesized for a pod with no local podspec (SocketRocket). Version +
# subspecs + default_subspecs are DERIVED from the real podspec where one
# exists; synthesized entries derive their version from a constant.
FACADE_PODS = {
"RCT-Folly" => "third-party-podspecs/RCT-Folly.podspec",
"glog" => "third-party-podspecs/glog.podspec",
"boost" => "third-party-podspecs/boost.podspec",
"DoubleConversion" => "third-party-podspecs/DoubleConversion.podspec",
"fmt" => "third-party-podspecs/fmt.podspec",
"fast_float" => "third-party-podspecs/fast_float.podspec",
"SocketRocket" => :synthesized,
}

# Sub-directory (relative to the install root) that holds the generated
# deps facades. Kept separate from RNCoreFacades' `build/rncore-facades` so
# the two families never collide.
FACADE_RELDIR = File.join("build", "rndeps-facades")

@@install_root = nil

# Generates the facade podspecs and returns the base directory holding them.
# Each facade gets its OWN sub-directory containing a single
# `<Name>.podspec.json`, so it can be installed as a LOCAL pod via
# `:path => <dir>` (PathSource uses the spec in place and never downloads
# `spec.source`). Idempotent; safe to call once per `pod install`.
#
# `react_native_path` locates the real third-party podspecs we mirror.
# version + subspecs + default_subspecs are DERIVED from the real spec (or,
# for SocketRocket, synthesized from the socket_rocket_config version) so the
# facade matches the source pod's spec/subspec SHAPE. It is not fully
# graph-equivalent: every derived subspec depends only on
# ReactNativeDependencies, so intra-pod subspec deps (e.g. RCT-Folly/Fabric
# -> RCT-Folly/Default) are not reproduced — harmless here because the deps
# are all declared explicitly in react_native_pods.rb. NO source_files and
# NO headers are emitted — the ReactNativeDependencies pod supplies both. A
# facaded pod whose real podspec can't be read is a hard error (see
# load_real_spec) — silently shipping an empty facade would hide drift.
def self.generate(react_native_path, install_root, ios_version)
@@install_root = install_root.to_s
abs_base = File.join(@@install_root, FACADE_RELDIR)
FileUtils.mkdir_p(abs_base)
FACADE_PODS.each do |name, podspec_rel_path|
dir = File.join(abs_base, name)
FileUtils.mkdir_p(dir)

if podspec_rel_path == :synthesized
spec = synthesized_spec(name, ios_version)
else
podspec_path = File.join(react_native_path.to_s, podspec_rel_path)
real = load_real_spec(podspec_path, name)
spec = derived_spec(name, real, ios_version)
end

File.write(File.join(dir, "#{name}.podspec.json"), JSON.pretty_generate(spec))
end
abs_base
end

# Facade dir for `<name>`, RELATIVE to the install root — pass to `pod :path =>`.
# Relative (not absolute) so the path CocoaPods records in Podfile.lock is
# portable rather than machine-specific.
def self.facade_path(name)
File.join(FACADE_RELDIR, name)
end

# Base spec skeleton shared by derived + synthesized facades: dependency-only,
# no source_files, no headers. Depends solely on ReactNativeDependencies.
def self.base_spec(name, version, ios_version)
{
"name" => name,
"version" => version,
"summary" => "Prebuilt facade for #{name} (code + headers live in #{DEPS_POD}).",
"homepage" => "https://reactnative.dev/",
"license" => "MIT",
"authors" => "Meta Platforms, Inc. and its affiliates",
"platforms" => { "ios" => ios_version },
# Required podspec attribute, but never fetched: installed as a LOCAL
# pod (`:path => <dir>`), which uses this spec in place and ships no
# source_files. Placeholder only.
"source" => { "git" => "https://github.com/facebook/react-native.git" },
"dependencies" => { DEPS_POD => [] },
}
end
private_class_method :base_spec

# Facade derived from a real third-party podspec: version + subspecs +
# default_subspecs mirror the real spec so a bare `pod '<Name>'` and any
# `pod '<Name>/<Subspec>'` resolve to the SAME graph (e.g. RCT-Folly's
# bare + /Default + /Fabric). Each subspec is also dependency-only and
# depends on ReactNativeDependencies.
#
# NOTE: resources (e.g. RCT-Folly's PrivacyInfo.xcprivacy) are intentionally
# NOT carried. In prebuilt-deps mode the third-party code — and its privacy
# manifest — is embedded in the ReactNativeDependencies artifact; the facade
# only needs to declare the dependency (see the design note in the PR).
def self.derived_spec(name, real, ios_version)
spec = base_spec(name, real.version.to_s, ios_version)

defaults = Array(real.default_subspecs)
spec["default_subspecs"] = defaults unless defaults.empty?

subspecs = derive_subspecs(real)
unless subspecs.empty?
spec["subspecs"] = subspecs.map do |ss|
{ "name" => ss, "dependencies" => { DEPS_POD => [] } }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Every derived subspec is emitted depending only on ReactNativeDependencies, so the facade RCT-Folly/Fabric loses the real subspec's dependency on RCT-Folly/Default. Low impact in practice (all six deps are declared explicitly in react_native_pods.rb), but the facade graph isn't truly 'graph-equivalent to the source pod' as the header comment claims — either preserve intra-pod subspec deps or soften the comment.

end
end

spec
end
private_class_method :derived_spec

# Facade synthesized for a pod with NO local podspec (SocketRocket, a trunk
# pod). Version comes from Helpers::Constants::socket_rocket_config; no
# subspecs. A missing/blank constant is a hard error rather than a silent
# versionless facade — a bare `pod 'SocketRocket'` in the source path is
# `"~> #{socket_rocket_config[:version]}"`, so the facade MUST carry a version
# that satisfies that constraint.
def self.synthesized_spec(name, ios_version)
version = synthesized_version(name)
base_spec(name, version, ios_version)
end
private_class_method :synthesized_spec

# Resolves the synthesized version for a no-podspec facade. Fail-closed on a
# missing constant/version. Only SocketRocket is synthesized today.
def self.synthesized_version(name)
case name
when "SocketRocket"
unless defined?(Helpers::Constants) && Helpers::Constants.respond_to?(:socket_rocket_config)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: This references Helpers::Constants but the file only requires json and fileutils; it works solely because react_native_pods.rb loads helpers.rb first. The defined? guard keeps it fail-closed, but a require_relative './helpers.rb' would make the module self-contained against require reordering.

raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \
"Helpers::Constants.socket_rocket_config is unavailable."
end
version = Helpers::Constants.socket_rocket_config[:version]
if version.nil? || version.to_s.strip.empty?
raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \
"socket_rocket_config[:version] is missing or empty."
end
version.to_s
else
raise "[RNDepsFacades] No synthesized version rule for facaded pod '#{name}'. " \
"Add one to synthesized_version or give it a real podspec in FACADE_PODS."
end
end
private_class_method :synthesized_version

# Loads the real podspec so we can mirror its structure. A facaded pod with a
# declared podspec path MUST have a readable real podspec — if it's missing or
# unparseable we raise rather than ship an empty facade (which would silently
# drop subspecs / the version, the very drift this mechanism prevents).
def self.load_real_spec(path, name)
unless File.exist?(path)
raise "[RNDepsFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \
"Update FACADE_PODS in rndeps_facades.rb if the podspec moved."
end
Pod::Specification.from_file(path)
rescue => e
raise "[RNDepsFacades] Failed to read real podspec for facaded pod '#{name}' at #{path}: #{e.message}"
end
private_class_method :load_real_spec

# Library (non-test, non-app) subspec names of the real spec, so third-party
# libs depending on `<pod>/<subspec>` (e.g. `RCT-Folly/Fabric`) keep
# resolving. Derived, never hand-listed.
def self.derive_subspecs(real)
real.subspecs
.reject { |ss| ss.test_specification? || (ss.respond_to?(:app_specification?) && ss.app_specification?) }
.map(&:base_name)
end
private_class_method :derive_subspecs
end
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ function checkExistingVersion(
dependencyLog(
`React Native Dependencies found on disk at: ${artifactsPath}.\nNo version file has been found. We are going to use it anyway, but there might be some unexpected behaviors.`,
);
// Honor the message above: an artifact without a version marker is a
// locally-staged one (e.g. a freshly composed deps build). Use it as-is.
// NOTE: this returns BEFORE the version.txt write below, so a
// locally-staged artifact never gains a marker — every later run re-hits
// this branch. That is intentional (don't clobber a hand-staged build),
// but downstream code must not assume version.txt exists here.
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: This early return true runs before the version.txt write below, so a locally-staged artifact never gets a version marker. Every later run re-hits this branch and returns true, meaning a subsequent version change won't invalidate the staged artifact until it's manually removed. Matches the stated intent, but worth a one-line comment since downstream code assuming version.txt exists would be surprised.

}
} else {
dependencyLog('React Native Dependencies not found on disk');
Expand Down
12 changes: 12 additions & 0 deletions packages/react-native/scripts/react_native_pods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
require_relative './cocoapods/spm.rb'
require_relative './cocoapods/rncore.rb'
require_relative './cocoapods/rncore_facades.rb'
require_relative './cocoapods/rndeps_facades.rb'
# Importing to expose use_native_modules!
require_relative './cocoapods/autolinking.rb'

Expand Down Expand Up @@ -243,6 +244,17 @@ def use_react_native! (
ReactNativeCoreUtils.rncore_log("Using React Native Core and React Native Dependencies prebuilt versions.")
pod 'ReactNativeDependencies', :podspec => "#{prefix}/third-party-podspecs/ReactNativeDependencies.podspec", :modular_headers => true

# Facades: community pods' hardcoded s.dependency "RCT-Folly"/"glog"/... must
# resolve locally instead of from trunk. See __docs__/prebuilt-deps.md.
RNDepsFacades.generate(react_native_path, Pod::Config.instance.installation_root, min_ios_version_supported)
pod 'DoubleConversion', :path => RNDepsFacades.facade_path('DoubleConversion')
pod 'glog', :path => RNDepsFacades.facade_path('glog')
pod 'boost', :path => RNDepsFacades.facade_path('boost')
pod 'fast_float', :path => RNDepsFacades.facade_path('fast_float')
pod 'fmt', :path => RNDepsFacades.facade_path('fmt')
pod 'RCT-Folly', :path => RNDepsFacades.facade_path('RCT-Folly')
pod 'SocketRocket', :path => RNDepsFacades.facade_path('SocketRocket')

if !ReactNativeCoreUtils.build_rncore_from_source()
pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ Pod::Spec.new do |spec|
# Check if XCFRAMEWORK_PATH is empty
if [ -z "$XCFRAMEWORK_PATH" ]; then
echo "ERROR: XCFRAMEWORK_PATH is empty."
exit 0
exit 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is a real bug fix. The old prepare_command printed 'ERROR' then exited 0, silently producing a broken install with no flattened headers; failing loudly is correct.

fi

# Check if HEADERS_PATH is empty
if [ -z "$HEADERS_PATH" ]; then
echo "ERROR: HEADERS_PATH is empty."
exit 0
exit 1
fi

cp -R "$HEADERS_PATH/." Headers
Expand Down
18 changes: 14 additions & 4 deletions scripts/releases/ios-prebuild/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,27 @@ const dependencies /*: ReadonlyArray<Dependency> */ = [
},
},
{
name: 'socket-rocket',
name: 'SocketRocket',
version: '0.7.1',
// Xcode 26's SwiftPM rejects a public-headers dir where the umbrella
// header (SocketRocket.h) has sibling directories (SocketRocket/Internal):
// "target 'SocketRocket' has invalid header layout". Stage the flat public
// headers into include/ and point publicHeaderFiles there. The artifact's
// Headers/SocketRocket namespace (files.headers) is unaffected, and
// Xcode 16 accepts both layouts.
prepareScript: 'mkdir -p include && cp SocketRocket/*.h include/',
url: new URL(
'https://github.com/facebookincubator/SocketRocket/archive/refs/tags/0.7.1.tar.gz',
),
files: {
sources: ['SocketRocket/**/*.{h,m}'],
sources: ['SocketRocket/**/*.{h,m}', 'include/*.h'],
headers: ['SocketRocket/*.h'],
resources: [
'../../../scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy',
],
},
settings: {
publicHeaderFiles: './SocketRocket',
publicHeaderFiles: './include',
headerSearchPaths: [
'./',
'SocketRocket',
Expand Down Expand Up @@ -254,7 +264,7 @@ const dependencies /*: ReadonlyArray<Dependency> */ = [
'fmt',
'boost',
'fast_float',
'socket-rocket',
'SocketRocket',
],
settings: {
publicHeaderFiles: './',
Expand Down
Loading
Loading