refactor(iOS): include resources like bundles into the precompiled XCFrameworks#57305
refactor(iOS): include resources like bundles into the precompiled XCFrameworks#57305chrfalch wants to merge 25 commits into
Conversation
9b0b73d to
2c1bf1d
Compare
c2c6397 to
2540bdc
Compare
2540bdc to
72768b5
Compare
6255670 to
8158f41
Compare
…S overlay ## Summary In RN 0.87 we'll hopefully release a new version of our precompiled binaries that works without the VFS overlay: - react/react-native#57285 - removal of VFS overlay - react/react-native#57305 - moved resource bundles to xcframework Meaning that we'll get completely self-contained XCFrameworks from RN which is a prerequisite for SwiftPM to not become too complex. ## How This PR addds support for detecting wether we're running with an RN version that uses these new frameworks or not - making sure we fallback to the old version when running with pre 0.87. In addition it updates the pod install pipeline with the same tests. NOTE: When merging 0.87 into Expo (as our current React Native), we can remove a lot of code in a follow-up PR that removes the VFS support all-over. There are a couple of 3rd party packages not compatible with RN 0.87 yet, so I tested with some patches for these packages. In addition a small binary fix was added to fix a missing method in 0.87 in EXpo's code. ## Test-plan - ✅ Built Bare-Expo, minimal-tester and precompiled with the new binaries from React native - ✅ Verified that the same versions are using VFS and precompiling correctly when using RN 0.86
The minimal machinery to build the packaged header structures: - headers-spec.js: the executable layout contract (rules R1-R8) — which namespaces are hoisted into the React framework, which carry module maps, and how collisions are rejected. - headers-inventory.js: scans the source tree and classifies every shipped header (language surface + modularizability bucket) — the input to the spec. computeInventory() feeds the build in-memory; the CLI writes a JSON manifest. - headers-compose.js: emits the layout — writes the <React/...> headers + umbrella + module map into each React.framework slice (detected by the framework's presence), and assembles the headers-only ReactNativeHeaders.xcframework (every other namespace + deps + Hermes). Called by xcframework.js during compose. This is the alternative header source that lets consumers resolve React Native headers without a clang VFS overlay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit the headers-spec layout unconditionally and delete the VFS overlay across
JS, CI publish, and Ruby. Consumers resolve headers the way the SwiftPM branch
does: <React/...> from the vendored React.framework, every other namespace from
ReactNativeHeaders. No root Headers/ on the xcframework, no VFS.
JS:
- xcframework.js: always emit the React.framework spec layout and build
ReactNativeHeaders.xcframework (was gated behind RN_ZERO_I_LAYOUT=1). Remove
the legacy header path entirely — the podspec->root-Headers enumeration,
createModuleMapFile, and copyHeaderFilesToSlices — so the published
React.xcframework is a standard framework (Info.plist + per-slice
React.framework/{Headers,Modules}), no root Headers/ or Modules/. Ship
ReactNativeHeaders.xcframework inside the reactnative-core tarball (sibling of
React.xcframework) so the prebuilt pod can vend both; keep the standalone
ReactNativeHeaders.xcframework.tar.gz for the SPM path. Drop the
React-VFS-template.yaml emit and the ./vfs import.
- vfs.js: deleted (its only consumer was xcframework.js).
- types.js: drop the now-unused VFSEntry/VFSOverlay/HeaderMapping types.
- replace-rncore-version.js: drop the React-VFS.yaml preservation rationale.
Ruby/CocoaPods:
- React-Core-prebuilt.podspec: vend React.xcframework (its per-slice
React.framework + module map serves <React/...> and @import React via
FRAMEWORK_SEARCH_PATHS); flatten ReactNativeHeaders' headers into a top-level
Headers/ in prepare_command and expose them via the pod header search path.
Drop the VFS-era root header_mappings_dir/module_map.
- rncore.rb: remove the -ivfsoverlay injection and process_vfs_overlay;
add_rncore_dependency and configure_aggregate_xcconfig now add a
HEADER_SEARCH_PATHS to React-Core-prebuilt/Headers for podspec, aggregate, and
third-party targets.
- react_native_pods.rb: drop the process_vfs_overlay post-install call.
Docs: replace the "VFS Overlay System" section with the headers-spec layout;
drop the obsolete "Known Issues" (pre-headers-spec) section.
Verified end-to-end: prebuild compose produces a VFS-free, root-Headers-free
React.xcframework; rn-tester pod install + xcodebuild (prebuilt path) BUILD
SUCCEEDED with zero -ivfsoverlay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
React.framework is a clang module; when an SPM consumer precompiles it, a
modular React/ header that #imports <react/...> hit
-Wnon-modular-include-in-framework-module because the lowercase react/
namespace (served from ReactNativeHeaders, per R1's Linux/Windows-safe layout)
was deliberately kept out of any module.
Give react/ a module where it already lives instead of relocating it (relocation
would require case-folding react.framework -> React.framework, which only works
on case-insensitive filesystems):
- headers-spec.js: drop the react/ namespace-module exemption so its
objc-modular-candidates get a module; emit that module as
ReactNativeHeaders_react (a module literally named 'react' would alias the
React framework module on a case-insensitive filesystem). Module names are
internal; <react/...> still resolves by header path and is now modular.
- headers-inventory.js: classify C++ default member initializers in aggregates
(e.g. struct { NSString *family = nil; } in RCTFontProperties.h) as ObjC++ so
these are not misclassified objc-modular-candidate and pulled into a plain
ObjC module they cannot compile in.
The unguarded ObjC/C react/ headers (e.g. JSRuntimeFactoryCAPI.h) now resolve
modularly; the C++ react/renderer/* includes are #ifdef __cplusplus-guarded and
skipped during the ObjC module emit, so they need no module.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In prebuilt mode the React core pods' code + headers live entirely in React.xcframework / React-Core-prebuilt. Re-installing their SOURCE podspecs made them ship duplicate headers that shadow the prebuilt artifact and break the React framework's clang explicit-module precompile (-Wnon-modular-include-in-framework-module) under Xcode 26. Install those core pods as dependency-only FACADES instead: generated podspecs with no sources/headers, installed via :path (so nothing is fetched), each depending on React-Core-prebuilt. Version, subspecs, default_subspec and resources (e.g. the privacy manifest) are DERIVED from the real podspec so the facade stays graph- and resource-equivalent to the source pod. With the shadowing gone the React module precompiles cleanly with SWIFT_ENABLE_EXPLICIT_MODULES on, so the Xcode-26 workaround (#53457) is removed. The prebuilt header search path + ReactNativeHeaders module-map activation are consolidated into a single post-install injection site (configure_aggregate_xcconfig); add_rncore_dependency now only declares the React-Core-prebuilt dependency. rn-tester's NativeComponentExample uses the canonical <React/...> include for RCTFabricComponentsPlugins.h (resolved from the framework) so it builds against the facaded React-RCTFabric in prebuilt mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`yarn format-check` (prettier) was failing CI on PR #57285. Run prettier on the ios-prebuild headers scripts (headers-compose.js, headers-inventory.js), replace-rncore-version.js, and __docs__/README.md so format-check passes. No logic changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1, verify, ratchet) Hardens the header layout against new/changed headers so consumer-facing regressions fail the PREBUILD instead of a downstream (rn-tester/Expo/ community) build: - headers-verify.js (new; runs in the prebuild compose CI job): include-health ratchet against a committed baseline (notShipped/unresolved/quoted- unresolvable includes in shipped headers — 27 baselined today); structural byte-compare of the composed module maps/umbrellas against the spec render; compile smokes — an ObjC TU precompiling the React module (every umbrella header) + all 14 R5 namespace modules + the R10 umbrella + __has_include asserts, an Expo-shaped ObjC++ TU (the R9 textual Fabric surface), and a Swift TU (import React + RCTBridge.moduleRegistry). - R11: one source, one content location. 116 sources ship under multiple spellings (React/X.h + CoreModules/RCTImage/RCTAnimation/... forms, bare React_RCTAppDelegate aliases). The flattened layout duplicated their declarations, so any -fmodules consumer touching two spellings (even transitively via a legacy spelling) hit redefinition errors — found by the gate's first run. The module-owned spelling keeps the content; every other spelling is emitted as a one-line redirect shim. - Single source of truth for third-party namespaces: the inventory's include classifier now derives from DEPS_NAMESPACES, and compose enforces set-equality with the deps artifact in BOTH directions (missing OR undeclared namespace fails). The undeclared direction immediately surfaced SocketRocket, which the deps artifact ships but was never relocated. - R5 exemption assert: an invalid-module-identifier namespace gaining a modular-candidate header now fails the plan instead of silently shipping a non-modular header. - Inventory records quoted includes that don't resolve to a shipped header (quotedNotShipped) instead of dropping them. Docs: __docs__/headers-rules.md documents rules R1-R11, the emission pipeline, and the resilience model.
…iveHeaders Declaring SocketRocket in DEPS_NAMESPACES (added to satisfy the deps set-equality guard) relocated textual copies of its headers into ReactNativeHeaders and thus into the flattened React-Core-prebuilt/Headers, which sits on every pod's HEADER_SEARCH_PATHS. The real SocketRocket pod still exists in consumer graphs and vends the same headers, so the copies collide: duplicate @interface definitions when the SocketRocket pod compiles (rn-tester, prebuilt + USE_FRAMEWORKS=dynamic), and a poisoned module graph in use_frameworks/explicit-modules apps that don't compile SocketRocket sources (Expo: ReactCodegen/RCTModulesConformingToProtocolsProvider.h "file not found"). Bisect confirmed removing the SocketRocket dir flips red->green and the R11 shims are not implicated. Add DEPS_NAMESPACES_NOT_RELOCATED: such namespaces count as declared for the set-equality guard (a genuinely new dep still fails closed), the include classifier still recognizes them, and the headers gate now asserts they stay ABSENT from the artifact. Note: no CI lane covers prebuilt + use_frameworks (the dynamic-frameworks lane builds from source), which is why CI stayed green.
8158f41 to
562985d
Compare
| # (not merge) the first. RCTI18nStrings holds React-Core's localized strings | ||
| # (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest. | ||
| # (Prebuilt/SwiftPM get both from inside React.xcframework instead — see | ||
| # scripts/ios-prebuild/{i18n,privacy}.js — but source builds ship them here.) |
There was a problem hiding this comment.
Major: This comment points to ios-prebuild/i18n.js and ios-prebuild/privacy.js, but no such files exist — all the logic lives in the single new framework-resources.js. (Same stale pointer in rncore_facades.rb.) These are load-bearing 'see X' references; please update them to ios-prebuild/framework-resources.js.
| ); | ||
| if (privacyManifest != null) { | ||
| fs.writeFileSync( | ||
| path.join(fwk, 'PrivacyInfo.xcprivacy'), |
There was a problem hiding this comment.
Major: Embedding PrivacyInfo.xcprivacy and RCTI18nStrings.bundle inside each slice's React.framework only reaches the app if the prebuilt is consumed as a dynamic framework. Under static linkage Xcode won't copy the wrapper's nested resources, so RCTLocalizedStringFromKey silently falls back to the untranslated defaultValue and the privacy manifest isn't aggregated. dSYM handling suggests dynamic linkage, but the failure mode is silent — worth confirming/documenting that the prebuilt is always consumed dynamically.
| /** | ||
| * Headers compose — emits the headers-spec layout (rules R1–R8 in | ||
| * headers-spec.js) into a React.xcframework and builds the headers-only | ||
| * ReactNativeHeaders.xcframework beside it. The prebuild path (xcframework.js) |
There was a problem hiding this comment.
Minor: emitReactFrameworkHeaders is shared by the pre-sign compose path and by ensureHeadersLayout (which runs on already-cached artifacts). Writing PrivacyInfo.xcprivacy / RCTI18nStrings.bundle into a framework after it was signed would invalidate the signature — please confirm the cached path is not post-signature.
| const merged /*: PrivacyManifest */ = { | ||
| NSPrivacyAccessedAPITypes: typeOrder.map(category => ({ | ||
| NSPrivacyAccessedAPIType: category, | ||
| NSPrivacyAccessedAPITypeReasons: reasonsByType.get(category) ?? [], |
There was a problem hiding this comment.
Minor: Every merged category is emitted with NSPrivacyAccessedAPITypeReasons: reasonsByType.get(cat) ?? [], so a source manifest that omitted the reasons key comes out with an added empty-array key. The 'passes a single manifest through unchanged' test only holds because the fixtures already carry all three keys — the 'unchanged' guarantee is narrower than it looks.
| trackingDomains.add(domain); | ||
| } | ||
| for (const dataType of manifest.NSPrivacyCollectedDataTypes ?? []) { | ||
| const key = JSON.stringify(dataType) ?? ''; |
There was a problem hiding this comment.
Minor: Collected-data-type dedup via JSON.stringify is key-order sensitive. Two pods declaring the same data-type dict with different key order (order comes from each plist) won't dedup and produce duplicates. A canonicalizing (sorted-key) key would be more robust.
| if (!fs.existsSync(dir)) { | ||
| continue; | ||
| } | ||
| for (const rel of fs.readdirSync(dir, {recursive: true})) { |
There was a problem hiding this comment.
Minor: fs.readdirSync(dir, {recursive:true}) is stable only on Node 20+, and fs.cpSync needs Node 16.7+. If these iOS scripts can run on older Node than the packaging pipeline, recursive readdir will throw or silently ignore the option — worth confirming against the repo's supported Node engines.
| } | ||
| // Embed React-Core's localized strings as RCTI18nStrings.bundle so the | ||
| // framework-aware RCTLocalizedString loader resolves them in prebuilt/SPM. | ||
| i18nLocales = buildI18nStringsBundle( |
There was a problem hiding this comment.
Minor: i18nLocales is reassigned each slice iteration and the bundle is rebuilt (rmSync+cpSync) per slice, so the final log reports only the last slice's count and the rebuild is redundant. It could be built once to a temp dir and copied into each slice, mirroring how the privacy manifest is handled.
|
|
||
| 'use strict'; | ||
|
|
||
| const { |
There was a problem hiding this comment.
Test gap: serializePrivacyManifest/readPrivacyManifest have no round-trip test (so a plist.build shape regression in the actual bytes written would pass CI), there's no integration test that resources land in each slice, and the RCTLocalizedString.mm loader (the highest-risk code given the static/dynamic concern) is untested.
|
|
||
| /** The Info.plist contents that make the copied .lproj dirs load as an NSBundle. */ | ||
| function i18nBundleInfoPlist() /*: {[string]: string} */ { | ||
| return { |
There was a problem hiding this comment.
Nit: The i18n bundle Info.plist omits CFBundleVersion / CFBundleShortVersionString. Optional for a resource bundle, but some Apple validation tooling warns on version-less bundles.
| # (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest. | ||
| # (Prebuilt/SwiftPM get both from inside React.xcframework instead — see | ||
| # scripts/ios-prebuild/{i18n,privacy}.js — but source builds ship them here.) | ||
| s.resource_bundles = { |
There was a problem hiding this comment.
Nice — consolidating these also fixes a latent bug: the old podspec's separate resource_bundle and resource_bundles assignments both singularize to the same attribute, so the second replaced the first and source builds were effectively dropping RCTI18nStrings.bundle.
| # Both bundles in one declaration: a second `resource_bundle(s) =` would replace | ||
| # (not merge) the first. RCTI18nStrings holds React-Core's localized strings | ||
| # (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest. | ||
| # (Prebuilt/SwiftPM get both from inside React.xcframework instead — see |
There was a problem hiding this comment.
Blocking — needs verification before merge.
This design ships the privacy manifest and RCTI18nStrings.bundle only from inside React.xcframework's React.framework; the prebuilt facade carries no pod-level resource_bundles/resources (see rncore_facades.rb: "the facade carries NO resources"). We still install the prebuilt via CocoaPods, and most apps link their pod graph statically (the New Architecture default: static libraries, no use_frameworks!).
CocoaPods copies resources nested inside a .framework only when it embeds that framework. It does embed vendored dynamic frameworks (via the [CP] Embed Pods Frameworks phase), and the prebuilt is currently built type: .dynamic (scripts/releases/ios-prebuild/swift-package.js), so in principle the nested bundle should be copied even in a static-pods app. But this is silent if it breaks — you'd get untranslated strings (RCTLocalizedStringFromKey falling back to the English default) and a missing PrivacyInfo.xcprivacy in the app's privacy report, with no build error.
Could we verify on a real app with the default CocoaPods setup (static libraries, no use_frameworks!) that:
React.framework/RCTI18nStrings.bundleis actually copied intoApp.app/Frameworks/React.framework/andRCTLocalizedStringFromKeyreturns translated strings, andPrivacyInfo.xcprivacyis aggregated into the generated privacy report?
Also worth checking use_frameworks! :linkage => :static and app-extension / Mac Catalyst targets, which don't always get an embed-frameworks phase.
…den compose freshness + shell usage
Review feedback on the header modularization (VFS-overlay removal):
- computeInventory() now returns natural-path collisions; computeSpecPlan()
fails closed on them (R8) so two sources projecting to the same Headers/
path can no longer be silently merged (only identities[0].source was kept).
- ensureHeadersLayout()'s freshness marker folds a sha256 of the compose
tooling (headers-{inventory,spec,compose}.js) so a local script edit forces
a recompose — the source xcframework's Info.plist mtime alone couldn't.
- scanHeader() strips /* ... */ block comments (multi-line aware) so a
documentation line mentioning namespace/template/constexpr can't trip the
C++ detector and shrink the umbrella.
- Anonymous aggregates with C++ member initializers (typedef struct { x = ..; })
are now detected as ObjC++ (tag name made optional).
- Multi-hyphen pod names namespaced correctly (.replace(/-/g,'_')).
- cp/tar shell-outs switched to execFileSync with arg arrays (CodeQL
"command from environment values"; parity with headers-verify.js).
- Clearer hermes-missing warning; headers-spec R9/R10 added to the contract
docblock (was R1–R8 then R11).
- New headers-inventory-test.js covering scanHeader (cxx guards, #else/#elif
flips, anonymous aggregate, block/line comments).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
562985d to
3a57ac3
Compare
|
Thanks! Addressed in Blocking ( Other fixes:
Test gap: added |
… sort-imports) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3a57ac3 to
8b50e2c
Compare
/bin/cp -c (APFS clonefile) is macOS-only; Linux CI exercises these paths through the jest integration tests, where GNU cp rejects it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…prebuilt/SwiftPM
Source builds get React-Core's non-header resources from the podspec
resource_bundles, but in the prebuilt path the source pods aren't installed
(CocoaPods facades) / not present (SwiftPM), so they were lost. Reproduce them in
the artifact at compose time via scripts/ios-prebuild/framework-resources.js:
- Privacy manifest: merge the PrivacyInfo.xcprivacy of the pods baked into
React.framework into one manifest at the framework root, where Xcode's
privacy-report aggregation picks it up (React.framework is dynamic; no runtime).
- RCTI18nStrings: rebuild RCTI18nStrings.bundle from React/I18n/strings/*.lproj
inside React.framework, resolved at runtime by the framework-aware loader.
RCTLocalizedString.mm now resolves the strings bundle from the code's own bundle
first (React.framework when prebuilt/SwiftPM) with a main-bundle fallback (static
source builds), keeping the graceful nil -> default behaviour.
React-Core.podspec declares RCTI18nStrings and React-Core_privacy in one
resource_bundles map (a later resource_bundles= had silently overwritten the
first), so source builds ship both again. The RNCore facade no longer carries
React-Core_privacy: the prebuilt artifact owns these resources now.
Red/green unit + integration tests in __tests__/framework-resources-test.js.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The FACADE_REEXPOSED_HEADERS loop passed an undefined `podspec_dir` to
copy_reexposed_headers, crashing `pod install` at `use_react_native!`
("undefined local variable or method 'podspec_dir'"). Derive it from the
real podspec path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review feedback on embedding React.framework's non-header resources:
- Privacy-manifest merge no longer fabricates an empty NSPrivacyAccessedAPITypeReasons
key when a source omitted it (tracks per-category key presence), so a single
manifest truly passes through unchanged.
- NSPrivacyCollectedDataTypes dedup canonicalizes (recursively sorts) keys before
hashing, so two pods declaring the same dict in different key order dedup.
- RCTI18nStrings.bundle is now built ONCE into a temp stage and cloned into each
slice (mirrors the privacy manifest) instead of rebuilt inside the slice loop;
i18nLocales is computed once.
- i18n bundle Info.plist gains CFBundleShortVersionString / CFBundleVersion so
Apple validation tooling doesn't warn on a version-less bundle.
- Fixed stale "see" pointers ios-prebuild/{i18n,privacy}.js -> framework-resources.js
(React-Core.podspec + rncore_facades.rb).
- Tests: serialize->readPrivacyManifest round-trip, order-insensitive collected-type
dedup, omitted-reasons-key regression, CFBundleVersion presence, and an
emitReactFrameworkHeaders integration test asserting PrivacyInfo.xcprivacy +
RCTI18nStrings.bundle land in EVERY slice.
Note: resources are written after _CodeSignature is stripped (R7 — signed after
compose), so no signature invalidation; readdirSync({recursive})/cpSync are safe on
the repo's Node >=22 engine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mixed is now a deprecated utility type (use unknown), and the NSPrivacyAccessedAPITypeReasons refinement was invalidated by the intervening Map.get() call — hoist it into a local first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dle clone Same macOS-only clonefile issue as the other copy sites; this one is on the resource-landing path the jest integration test exercises on Linux. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8b50e2c to
ca08afa
Compare
Summary
Depends on #57285
In source builds,
React-Coreships non-header resources — its privacy manifest (PrivacyInfo.xcprivacy) and its localized strings (RCTI18nStrings) — via the podspec's resource_bundles. In the prebuilt path those source pods aren't installed (CocoaPods facades) or aren't present at all (SwiftPM), so these resources were silently dropped: prebuilt/SwiftPM apps shipped no React Native privacy manifest, and localized strings were unavailable.This embeds them in React.framework at prebuild time so they ship uniformly across CocoaPods-prebuilt and SwiftPM, with source builds unchanged:
It also fixes a latent bug in
React-Core.podspec: a later resource_bundles = was overwriting the earlier resource_bundle =, so source builds had stopped shipping RCTI18nStrings. Both bundles are now declared together. With the artifact owning these resources, the prebuilt RNCore facade no longer carries them.Changelog:
[IOS][FIXED] - Ship React-Core's privacy manifest and localized strings (RCTI18nStrings) inside the prebuilt React.xcframework, so CocoaPods-prebuilt and SwiftPM apps include them
Test Plan