Skip to content

Commit 6e42b09

Browse files
chrfalchclaude
andcommitted
feat(cocoapods): dependency-only facades for third-party pods in prebuilt-deps mode
In prebuilt-deps mode the real source pods (RCT-Folly, glog, boost, DoubleConversion, fmt, fast_float, SocketRocket) are not declared in the Podfile, so a community podspec's hardcoded 's.dependency "RCT-Folly"' would resolve from the CocoaPods trunk and compile from source next to the prebuilt binary — the dual-copy bug class behind the 2026-07-03 SocketRocket regression. RNDepsFacades generates dependency-only local facade podspecs (build/rndeps-facades/<Name>/): no sources, no headers, a single dependency on ReactNativeDependencies. Versions and subspecs are derived from the real podspecs in third-party-podspecs/ (RCT-Folly keeps /Default + /Fabric with default_subspecs = ["Default"]; SocketRocket is synthesized from socket_rocket_config, fail-closed if absent). Declared as :path pods so Podfile-local resolution beats trunk and nothing is fetched. ReactNativeDependencies remains the single header authority. Docs: scripts/cocoapods/__docs__/prebuilt-deps.md describes the full prebuilt-deps header/facade contract and the mode-by-mode supplier table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ba03925 commit 6e42b09

3 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Prebuilt ReactNativeDependencies — self-serving headers + deps facades
2+
3+
How the third-party C/C++ deps (`RCT-Folly`, `glog`, `boost`, `DoubleConversion`,
4+
`fmt`, `fast_float`, `SocketRocket`) are served when
5+
`ReactNativeDependenciesUtils.build_react_native_deps_from_source()` is false
6+
(prebuilt-deps mode). Source-deps mode is unaffected by everything below.
7+
8+
## Pod-served headers, CocoaPods only (`rndependencies.rb`)
9+
10+
In prebuilt-deps mode the `ReactNativeDependencies` POD (CocoaPods) is the single authority
11+
for the third-party deps: compiled code lives in its xcframework binary, and the
12+
artifact's own `Headers/{folly,glog,boost,fmt,double-conversion,fast_float,
13+
SocketRocket}` are flattened into the pod's `Headers/` by the podspec's
14+
`prepare_command`. Consumers resolve bare `<folly/...>` / `<SocketRocket/...>`
15+
via CocoaPods public-header linkage from `s.dependency "ReactNativeDependencies"`,
16+
plus an explicit `HEADER_SEARCH_PATHS` entry
17+
(`$(PODS_ROOT)/ReactNativeDependencies/Headers`). The real source pods are neither depended on nor searched.
18+
19+
NOTE: this is a CocoaPods-level contract. The deps XCFRAMEWORK itself is NOT
20+
self-serving: it is framework-type without `HeadersPath`, so its root `Headers/`
21+
is invisible to SPM binaryTargets (verified 2026-07-04 — `HeadersPath` is
22+
rejected on framework entries). In SPM the six C++ namespaces are served by
23+
ReactNativeHeaders.xcframework; serving them from the deps side requires the
24+
phase-2 headers-only library sidecar (see rn-deps-self-serving plan).
25+
26+
## Why SocketRocket is vended here
27+
28+
React-Core compiled from source (source-core + prebuilt-deps mix) imports
29+
`<SocketRocket/SRWebSocket.h>` (`RCTReconnectingWebSocket.m`), and in
30+
prebuilt-deps mode there is NO real SocketRocket pod in the graph — the artifact
31+
is the sole supplier. This does not reintroduce the 2026-07-03 dual-copy
32+
regression: that bug relocated SocketRocket copies onto every pod's search path
33+
(via ReactNativeHeaders → React-Core-prebuilt) while a REAL SocketRocket pod
34+
coexisted. Here there is exactly one physical copy and no coexisting pod.
35+
36+
## Deps facades (`rndeps_facades.rb`, declared in `react_native_pods.rb`)
37+
38+
The real source pods are only declared in the deps-from-source branch, so in
39+
prebuilt-deps mode a community podspec's hardcoded `s.dependency "RCT-Folly"` /
40+
`"RCT-Folly/Fabric"` / `"glog"` would resolve from the CocoaPods trunk and
41+
compile from source next to the prebuilt binary. `RNDepsFacades` generates
42+
dependency-only facade podspecs (`build/rndeps-facades/<Name>/`), installed as
43+
LOCAL pods (`:path`, so Podfile-local resolution beats trunk, nothing fetched):
44+
no sources, no headers, single dependency on `ReactNativeDependencies`.
45+
Versions + subspecs are DERIVED from the real podspecs in `third-party-podspecs/`
46+
(RCT-Folly keeps `/Default` + `/Fabric`, `default_subspecs = ["Default"]`).
47+
SocketRocket has no local podspec — its facade version is SYNTHESIZED from
48+
`Helpers::Constants::socket_rocket_config[:version]`, fail-closed if absent.
49+
`:modular_headers` is intentionally dropped on facade declarations: a
50+
dependency-only placeholder builds no module; consumers get modules from
51+
`ReactNativeDependencies`.
52+
53+
## Mode × supplier table
54+
55+
| core × deps | real 3P pods in graph | SocketRocket headers supplier |
56+
|---|---|---|
57+
| source + source | yes (`react_native_pods.rb` deps-source branch) | real pod |
58+
| source + prebuilt | no | RNDeps artifact (sole supplier) |
59+
| prebuilt + source | yes | real pod |
60+
| prebuilt + prebuilt | no | RNDeps artifact |
61+
62+
## SocketRocket privacy manifest
63+
64+
Upstream SocketRocket ships NO privacy manifest, and the deps artifact
65+
historically carried bundles only for boost/folly/glog. Fixed alongside this
66+
work: the deps prebuild (`scripts/releases/ios-prebuild/configuration.js`) now
67+
embeds `ReactNativeDependencies_SocketRocket.bundle/PrivacyInfo.xcprivacy`,
68+
sourced from an RN-authored manifest at
69+
`scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy`
70+
(accurate-empty:
71+
SocketRocket uses no Required Reason APIs). Facades remain resource-free by
72+
design.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
require 'json'
7+
require 'fileutils'
8+
9+
# Dependency-only facade podspecs for the third-party deps in prebuilt-deps
10+
# mode (deps-side analogue of RNCoreFacades). Design + rationale:
11+
# scripts/cocoapods/__docs__/prebuilt-deps.md
12+
module RNDepsFacades
13+
# The name of the umbrella prebuilt-deps pod every facade depends on. Its pod
14+
# self-serves the third-party headers + carries the binary (see
15+
# rndependencies.rb, ReactNativeDependencies.podspec).
16+
DEPS_POD = "ReactNativeDependencies"
17+
18+
# pod name => podspec path (relative to the react-native package root), or
19+
# :synthesized for a pod with no local podspec (SocketRocket). Version +
20+
# subspecs + default_subspecs are DERIVED from the real podspec where one
21+
# exists; synthesized entries derive their version from a constant.
22+
FACADE_PODS = {
23+
"RCT-Folly" => "third-party-podspecs/RCT-Folly.podspec",
24+
"glog" => "third-party-podspecs/glog.podspec",
25+
"boost" => "third-party-podspecs/boost.podspec",
26+
"DoubleConversion" => "third-party-podspecs/DoubleConversion.podspec",
27+
"fmt" => "third-party-podspecs/fmt.podspec",
28+
"fast_float" => "third-party-podspecs/fast_float.podspec",
29+
"SocketRocket" => :synthesized,
30+
}
31+
32+
# Sub-directory (relative to the install root) that holds the generated
33+
# deps facades. Kept separate from RNCoreFacades' `build/rncore-facades` so
34+
# the two families never collide.
35+
FACADE_RELDIR = File.join("build", "rndeps-facades")
36+
37+
@@install_root = nil
38+
39+
# Generates the facade podspecs and returns the base directory holding them.
40+
# Each facade gets its OWN sub-directory containing a single
41+
# `<Name>.podspec.json`, so it can be installed as a LOCAL pod via
42+
# `:path => <dir>` (PathSource uses the spec in place and never downloads
43+
# `spec.source`). Idempotent; safe to call once per `pod install`.
44+
#
45+
# `react_native_path` locates the real third-party podspecs we mirror.
46+
# version + subspecs + default_subspecs are DERIVED from the real spec (or,
47+
# for SocketRocket, synthesized from the socket_rocket_config version) so the
48+
# facade stays graph-equivalent to the source pod. NO source_files and NO
49+
# headers are emitted — the ReactNativeDependencies pod supplies both. A
50+
# facaded pod whose real podspec can't be read is a hard error (see
51+
# load_real_spec) — silently shipping an empty facade would hide drift.
52+
def self.generate(react_native_path, install_root, ios_version)
53+
@@install_root = install_root.to_s
54+
abs_base = File.join(@@install_root, FACADE_RELDIR)
55+
FileUtils.mkdir_p(abs_base)
56+
FACADE_PODS.each do |name, podspec_rel_path|
57+
dir = File.join(abs_base, name)
58+
FileUtils.mkdir_p(dir)
59+
60+
if podspec_rel_path == :synthesized
61+
spec = synthesized_spec(name, ios_version)
62+
else
63+
podspec_path = File.join(react_native_path.to_s, podspec_rel_path)
64+
real = load_real_spec(podspec_path, name)
65+
spec = derived_spec(name, real, ios_version)
66+
end
67+
68+
File.write(File.join(dir, "#{name}.podspec.json"), JSON.pretty_generate(spec))
69+
end
70+
abs_base
71+
end
72+
73+
# Facade dir for `<name>`, RELATIVE to the install root — pass to `pod :path =>`.
74+
# Relative (not absolute) so the path CocoaPods records in Podfile.lock is
75+
# portable rather than machine-specific.
76+
def self.facade_path(name)
77+
File.join(FACADE_RELDIR, name)
78+
end
79+
80+
# Base spec skeleton shared by derived + synthesized facades: dependency-only,
81+
# no source_files, no headers. Depends solely on ReactNativeDependencies.
82+
def self.base_spec(name, version, ios_version)
83+
{
84+
"name" => name,
85+
"version" => version,
86+
"summary" => "Prebuilt facade for #{name} (code + headers live in #{DEPS_POD}).",
87+
"homepage" => "https://reactnative.dev/",
88+
"license" => "MIT",
89+
"authors" => "Meta Platforms, Inc. and its affiliates",
90+
"platforms" => { "ios" => ios_version },
91+
# Required podspec attribute, but never fetched: installed as a LOCAL
92+
# pod (`:path => <dir>`), which uses this spec in place and ships no
93+
# source_files. Placeholder only.
94+
"source" => { "git" => "https://github.com/facebook/react-native.git" },
95+
"dependencies" => { DEPS_POD => [] },
96+
}
97+
end
98+
private_class_method :base_spec
99+
100+
# Facade derived from a real third-party podspec: version + subspecs +
101+
# default_subspecs mirror the real spec so a bare `pod '<Name>'` and any
102+
# `pod '<Name>/<Subspec>'` resolve to the SAME graph (e.g. RCT-Folly's
103+
# bare + /Default + /Fabric). Each subspec is also dependency-only and
104+
# depends on ReactNativeDependencies.
105+
#
106+
# NOTE: resources (e.g. RCT-Folly's PrivacyInfo.xcprivacy) are intentionally
107+
# NOT carried. In prebuilt-deps mode the third-party code — and its privacy
108+
# manifest — is embedded in the ReactNativeDependencies artifact; the facade
109+
# only needs to declare the dependency (see the design note in the PR).
110+
def self.derived_spec(name, real, ios_version)
111+
spec = base_spec(name, real.version.to_s, ios_version)
112+
113+
defaults = Array(real.default_subspecs)
114+
spec["default_subspecs"] = defaults unless defaults.empty?
115+
116+
subspecs = derive_subspecs(real)
117+
unless subspecs.empty?
118+
spec["subspecs"] = subspecs.map do |ss|
119+
{ "name" => ss, "dependencies" => { DEPS_POD => [] } }
120+
end
121+
end
122+
123+
spec
124+
end
125+
private_class_method :derived_spec
126+
127+
# Facade synthesized for a pod with NO local podspec (SocketRocket, a trunk
128+
# pod). Version comes from Helpers::Constants::socket_rocket_config; no
129+
# subspecs. A missing/blank constant is a hard error rather than a silent
130+
# versionless facade — a bare `pod 'SocketRocket'` in the source path is
131+
# `"~> #{socket_rocket_config[:version]}"`, so the facade MUST carry a version
132+
# that satisfies that constraint.
133+
def self.synthesized_spec(name, ios_version)
134+
version = synthesized_version(name)
135+
base_spec(name, version, ios_version)
136+
end
137+
private_class_method :synthesized_spec
138+
139+
# Resolves the synthesized version for a no-podspec facade. Fail-closed on a
140+
# missing constant/version. Only SocketRocket is synthesized today.
141+
def self.synthesized_version(name)
142+
case name
143+
when "SocketRocket"
144+
unless defined?(Helpers::Constants) && Helpers::Constants.respond_to?(:socket_rocket_config)
145+
raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \
146+
"Helpers::Constants.socket_rocket_config is unavailable."
147+
end
148+
version = Helpers::Constants.socket_rocket_config[:version]
149+
if version.nil? || version.to_s.strip.empty?
150+
raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \
151+
"socket_rocket_config[:version] is missing or empty."
152+
end
153+
version.to_s
154+
else
155+
raise "[RNDepsFacades] No synthesized version rule for facaded pod '#{name}'. " \
156+
"Add one to synthesized_version or give it a real podspec in FACADE_PODS."
157+
end
158+
end
159+
private_class_method :synthesized_version
160+
161+
# Loads the real podspec so we can mirror its structure. A facaded pod with a
162+
# declared podspec path MUST have a readable real podspec — if it's missing or
163+
# unparseable we raise rather than ship an empty facade (which would silently
164+
# drop subspecs / the version, the very drift this mechanism prevents).
165+
def self.load_real_spec(path, name)
166+
unless File.exist?(path)
167+
raise "[RNDepsFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \
168+
"Update FACADE_PODS in rndeps_facades.rb if the podspec moved."
169+
end
170+
Pod::Specification.from_file(path)
171+
rescue => e
172+
raise "[RNDepsFacades] Failed to read real podspec for facaded pod '#{name}' at #{path}: #{e.message}"
173+
end
174+
private_class_method :load_real_spec
175+
176+
# Library (non-test, non-app) subspec names of the real spec, so third-party
177+
# libs depending on `<pod>/<subspec>` (e.g. `RCT-Folly/Fabric`) keep
178+
# resolving. Derived, never hand-listed.
179+
def self.derive_subspecs(real)
180+
real.subspecs
181+
.reject { |ss| ss.test_specification? || (ss.respond_to?(:app_specification?) && ss.app_specification?) }
182+
.map(&:base_name)
183+
end
184+
private_class_method :derive_subspecs
185+
end

packages/react-native/scripts/react_native_pods.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
require_relative './cocoapods/spm.rb'
2323
require_relative './cocoapods/rncore.rb'
2424
require_relative './cocoapods/rncore_facades.rb'
25+
require_relative './cocoapods/rndeps_facades.rb'
2526
# Importing to expose use_native_modules!
2627
require_relative './cocoapods/autolinking.rb'
2728

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

245+
# Facades: community pods' hardcoded s.dependency "RCT-Folly"/"glog"/... must
246+
# resolve locally instead of from trunk. See __docs__/prebuilt-deps.md.
247+
RNDepsFacades.generate(react_native_path, Pod::Config.instance.installation_root, min_ios_version_supported)
248+
pod 'DoubleConversion', :path => RNDepsFacades.facade_path('DoubleConversion')
249+
pod 'glog', :path => RNDepsFacades.facade_path('glog')
250+
pod 'boost', :path => RNDepsFacades.facade_path('boost')
251+
pod 'fast_float', :path => RNDepsFacades.facade_path('fast_float')
252+
pod 'fmt', :path => RNDepsFacades.facade_path('fmt')
253+
pod 'RCT-Folly', :path => RNDepsFacades.facade_path('RCT-Folly')
254+
pod 'SocketRocket', :path => RNDepsFacades.facade_path('SocketRocket')
255+
244256
if !ReactNativeCoreUtils.build_rncore_from_source()
245257
pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true
246258
end

0 commit comments

Comments
 (0)