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.
13 changes: 12 additions & 1 deletion packages/react-native/scripts/cocoapods/rndependencies.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,19 @@ def add_rn_third_party_dependencies(s)

current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths
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"] || []
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"
current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths
end

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

# 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
# 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 stays graph-equivalent to the source pod. 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 => [] } }
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)
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,10 @@ 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) — falling
// through here rmSync'd it and re-downloaded. Use it as-is.
return true;
}
} 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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
Loading