feat(iOS) swiftpm support in iOS#57332
Open
chrfalch wants to merge 67 commits into
Open
Conversation
64fcaab to
68bbbba
Compare
68bbbba to
a0a1630
Compare
85a1d6f to
40320bc
Compare
40320bc to
9623012
Compare
8177e87 to
c47b761
Compare
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>
…tarball The prebuilt React core now ships two xcframeworks — React.xcframework and the headers-only ReactNativeHeaders.xcframework. React-Core-prebuilt's prepare_command flattens the latter's Headers (including module.modulemap) into the pod. The compose step only tar'd React.xcframework, so consumers got no React-Core-prebuilt/Headers/module.modulemap and failed the build with "module map file ... not found". Tar both xcframeworks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…le (R9) The public-umbrella model (which replaced the VFS overlay) excludes `+Private` and objc-blocked headers from React.framework's module, so privileged framework consumers (e.g. Expo) that `#import <React/RCTBridge+Private.h>`, `<React/RCTMountingManager.h>`, etc. fail to compile under explicit modules even though the headers still ship in React.framework/Headers. Add R9: a curated allowlist appended to the React module map — `RCTBridge+Private.h` as a real `header` (objc-modular-candidate, reaches no C++) and the six Fabric headers as `textual header` (objc-blocked; a real member would re-trip -Wnon-modular-include, and their <react/...> C++ includes resolve at the consumer's use site). Backwards-compatible: existing `#import <React/...>` (and Swift `import React`) sites are unchanged. Fails closed if an allowlisted header is removed/renamed or drifts bucket. Note: RCTUIKit.h / RCTRootContentView.h are absent from source entirely and need restoration, not exposure — out of scope here.
The flattened ReactNativeHeaders layout ships the individual React_RCTAppDelegate/*.h headers but no per-namespace umbrella. Consumers like Expo probe `<React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h>` via __has_include (RCTAppDelegateUmbrella.h); with the umbrella gone the probe fails and RCTReactNativeFactory / RCTRootViewFactory are never declared, breaking the Expo pod's clang module. Add R10: emit a per-namespace umbrella (content DERIVED from namespaceModules so it can't drift — e.g. RCTArchConfiguratorProtocol.h, gone from this branch, is correctly omitted) and add it to that namespace's module so the import stays modular under explicit modules. Targeted via UMBRELLA_NAMESPACES (currently just React_RCTAppDelegate, the only umbrella Expo imports); fails closed if a listed namespace loses its modular headers.
…ic facade Community Fabric modules quote-import "RCTFabricComponentsPlugins.h" (~47x: slider, maps, pager-view, keyboard-controller, ...). In source, React-RCTFabric vended it at header_dir "React", so it landed in dependents' CocoaPods header maps and the bare quoted name resolved. In prebuilt mode React-RCTFabric is a dependency-only facade that ships no headers — the only copy is baked angle-only into React.framework (it's objc-blocked, excluded from the framework module map), so quoted imports fail to resolve. Re-vend JUST that one header from the facade (FACADE_REEXPOSED_HEADERS), copied as a self-contained snapshot at header_dir "React", restoring dependents' header maps exactly as the source pod did. Header-only (no compiled sources, no duplicate symbols — the impl stays in React.framework). Re-exposing a single header does not put <react/...>/<yoga/...> on -I, so it does not reintroduce the non-modular-include shadowing the modular layout eliminates. Fails closed if the glob matches nothing.
…the headers compose buildReactNativeHeadersXcframework copied each declared deps namespace (folly/glog/boost/fmt/double-conversion/fast_float) from the staged ReactNativeDependencies headers, but only console.warn'd on a missing one and kept going — silently shipping a ReactNativeHeaders.xcframework without third-party header resolution (consumers then fail on <folly/...> etc.). The summary log also printed the INTENDED namespace list, masking the gap. Throw instead: a missing declared deps namespace means deps weren't staged (third-party/ReactNativeDependencies.xcframework/Headers — from a full prebuild or the cache slot), so refuse to emit an incomplete artifact.
…est imports Two CI fixes for the prebuild-ios-core workflow: - prebuild-ios-core.yml: the compose-xcframework job downloaded the build slices and React headers but never staged third-party/ReactNativeDependencies.xcframework. buildReactNativeHeadersXcframework folds the third-party deps namespaces (folly/glog/boost/...) into ReactNativeHeaders.xcframework, so it fail-closed with "deps namespace 'folly' missing ... refusing to ship an incomplete ReactNativeHeaders.xcframework". Add the Download + Extract ReactNativeDependencies steps (mirroring build-slices) so the deps headers are present before composing. - headers-spec-test.js: reorder requires so the `../headers-spec` import sorts before `fs`, fixing the @react-native/monorepo/sort-imports warning that failed `eslint --max-warnings 0` in test_js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scripts/spm toolchain that generates a SwiftPM Package.swift + xcodeproj for React Native and community libraries, on top of the headers-spec header layout (React.framework + ReactNativeHeaders, no VFS). Consumes headers-compose.ensureHeadersLayout to compose the header artifacts; ships the compose runtime + scripts/spm via the npm files allowlist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SPM-mode consumption for the example apps: rn-tester and helloworld each get a Package.swift + *-SPM xcodeproj that consume React via SwiftPM product deps (zero header search paths). Adds react-native-test-library (apple + common) and its rn-tester example, plus the react-native.config.js spmModules wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…scripts generate-spm-package previously composed the headers-spec layout on the consumer side when the prebuilt artifacts lacked ReactNativeHeaders.xcframework. That pulled the ios-prebuild build scripts (headers-compose and its deps) into the published npm package, which in turn required the `plist` dependency at consumer runtime. The prebuilt core artifact ships React.xcframework AND ReactNativeHeaders.xcframework together, so a consumer never needs to compose the layout. Remove the consumer-side compose: fail with a clear error if ReactNativeHeaders is absent, and stop listing scripts/ios-prebuild/* in the package files allowlist — those are full-repo prebuild scripts, not consumer runtime code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m-scratch generator
Replace the ambiguous `init`/`--from-scratch` (with its silent
in-place→from-scratch fall-through that could rename a user's project to
.legacy) with four clear verbs:
add inject SPM into the existing .xcodeproj, in place (idempotent;
fail-loud on a CocoaPods-integrated pbxproj). --deintegrate runs
`pod deintegrate` + strips RN from the Podfile + removes the
leftover empty Pods group.
update re-run the pipeline + refresh the injection.
deinit surgical inverse of add (reverse exactly what .spm-injected.json
records → byte-identical revert; no prompt).
scaffold unchanged.
sync/codegen/download stay but are hidden from primary help.
Zero-arg `npx react-native spm` auto-resolves: injected→update; a fresh
CocoaPods project (safe-gate: stock Podfile + git-clean pbxproj/Podfile)→
add --deintegrate; else strict add.
- Delete the from-scratch xcodeproj generator + legacy-migration/Podfile-patch
machinery and the now-dead whole-file-generation helpers in spm-pbxproj.js.
- Add surgical pbxproj removal primitives (removeObjectByUuid,
removeArrayMembersByUuid, removeField, removeArrayStringValues,
removeEmptyPodsGroup); injection now records its exact edits in
.spm-injected.json so deinit can reverse them.
- Merge flags: --skip/force-download → --download <auto|skip|force>;
--local-xcframework + --artifacts-dir → --artifacts <path>. Remove
--skip-xcodeproj/--force-xcodeproj/--bundle-identifier/--entry-file and the
--platform-name CLI shim.
- generate-spm-package now throws on artifact-slot failures (incl. missing
ReactNativeHeaders) instead of a silent exitCode+return.
- Update CLI registration, __doc__/spm-scripts.md, and the spm test suite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The newer toolchain on the rebased base flags deprecated Flow utility types and a few lint nits in the SPM scripts + test library: - Flow: `$ReadOnly`/`$ReadOnlyArray`/`$ReadOnlySet` -> `Readonly*`, `$Shape` -> `Partial`, `mixed` -> `unknown`, `+` variance sigil -> `readonly`; narrow the nullable remote-package config before reading `.url`/`.version` (the `plan.isRemote` branch didn't refine it). - ESLint: sort requires in expand-spm-dependencies.js and the two react-native-test-library entrypoints; drop unnecessary `\"` escapes in generate-spm-autolinking-test.js. - Prettier formatting on spm-pbxproj.js. No behavior change; 349 scripts/spm tests still green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…orld
The `*-SPM.xcodeproj` projects (+ their app-local Package.swift) added to
the example apps were untested demo scaffolding — no CI job builds them.
In helloworld they also broke `test_ios_helloworld`: two `.xcodeproj` in
`ios/` made CocoaPods unable to auto-select the project ("Could not
automatically select an Xcode project") during `pod install`.
Remove Helloworld-SPM.xcodeproj and RNTester-SPM.xcodeproj plus their
Package.swift, and revert the SPM-artifact lines from the .gitignore
files. The rn-tester TestLibrary example and SPM tooling are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReactNativeHeaders is pure-RN after the deps-headers split: the third-party namespaces (folly/glog/boost/fmt/double-conversion/ fast_float/SocketRocket) ship in the ReactNativeDependenciesHeaders sidecar the deps prebuild emits (the binary deps xcframework is framework-type — invisible to SwiftPM binaryTargets). - download-spm-artifacts: headers-only companions are staged out of their parent tarball (ReactNativeHeaders from the ReactCore tarball, the sidecar from the deps tarball) on fresh extracts, with fast-path backfill — generalizing the hermes-headers pattern. This retires the --headers-tarball priming workaround for ReactNativeHeaders; the override remains for testing, joined by --deps-headers-tarball / RN_DEPS_HEADERS_TARBALL_PATH. Both companions register in artifacts.json fail-closed (pre-companion tarballs die with actionable advice). REQUIRED_ARTIFACTS now lists all five artifacts, fixing the inconsistency where generate-spm-package hard-required ReactNativeHeaders but cache validation never checked it. - generate-spm-package needs no code change: it renders one product + binaryTarget per artifacts.json entry. - Product consumers add the ReactNativeDependenciesHeaders product wherever deps namespaces must resolve: reactProductDeps() (autolinking), the codegen Package.swift template, and scaffolded community-library manifests (SCAFFOLDER_VERSION 17 -> 18 so existing scaffolds regenerate). Verified: fresh-app E2E (verdaccio -> cli init -> spm add -> build), rn-tester and helloworld in-place migrations, 350 spm jest tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrating rn-tester (RNTesterPods.xcodeproj, in-place) and helloworld to
SwiftPM surfaced five latent bugs:
- scaffold: podspecs with root-level sources (s.source_files =
"*.{h,m,mm}") got no publicHeadersPath, so SPM rejected the target
over the nonexistent default include/. The scaffolder now mirrors
root headers into a generated include/<SwiftName>/ shim dir
(publicHeadersPath: "include"), giving dependents the CocoaPods
header-map spelling `#import <SwiftName/Header.h>`.
- scaffold: root podspec globs matched the scaffolder's own output on
re-runs — Package.swift's `.swift` extension tripped the
mixed-language gate into DELETING the manifest it had just written
(runs alternated between scaffolding and self-destructing). Scaffold
artifacts (Package.swift, the prefix header, include/ shims) are now
excluded from source expansion.
- autolinking: the one-time legacy-layout cleanup unconditionally
removed `<dep>/include/`, nuking the scaffolder's shim dirs on every
sync. It now only removes include/ together with a marker-bearing
legacy in-place Package.swift.
- scaffold: transitive spm.dependencies entries carry no podspecPath,
so podspec-name sibling deps (s.dependency "TestLibraryCommon")
could not be wired ("Unknown dependency"). The sibling map now
discovers podspecs at those deps' roots.
- add --deintegrate: a ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the
CocoaPods template default) dangles once CocoaPods is deintegrated,
breaking the "Bundle React Native code and images" phase. The
injection now replaces it with the SPM-computed path, recording the
original in the marker so deinit restores it exactly.
Also declares TestLibraryApple's real dependency on TestLibraryCommon
in its podspec (CocoaPods resolved the undeclared edge leniently
through the shared Public headers dir; SPM needs it for sibling
package wiring).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- download-spm-artifacts.js: the tar/cp invocations interpolated URL/env-derived paths into shell strings (js/shell-command-injection, incl. one new critical on the companion backfill path). All four now use execFileSync argv form — no shell involved. - spm-pbxproj.js: generateUUID used md5 (js/weak-cryptographic-algorithm). Not a security use — the hash only derives deterministic pbxproj UUIDs — but sha256 (truncated to the same 24 hex chars) is just as deterministic and keeps the scanner green. Affects only not-yet- released injections; the marker records actual UUIDs so deinit of md5-era injections is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overhaul the SwiftPM tooling docs to match current behavior: - Mark the feature Preview (header + intro) — commands/layout/distribution may change; CocoaPods stays the default. - Fresh clones & CI: document that Xcode resolves the package graph before any build phase, so a clean checkout needs one `npx react-native spm` run (or a `postinstall` hook) before xcodebuild — the auto-sync phase can't bootstrap it. Correct the Quick Start lines that implied otherwise, and add a "Resolve Package Graph" row + the matching Troubleshooting entry. - Drop stale VFS overlay references (VFS was removed; headers now come from the React / ReactNativeHeaders / ReactNativeDependenciesHeaders products) — rewrite Header Resolution, and fix the sync-spm-autolinking.js comment. - Directory Layout: list all five artifacts. - Migration: spell out exactly what `--deintegrate` removes vs preserves and how to keep non-RN pods (re-`pod install`, open the .xcworkspace, never re-add `use_react_native!`). - New Brownfield section (project must live inside the JS tree; use `--xcodeproj`/`--productName`; native-root-with-RN-subfolder unsupported). - New "Community packages without a Package.swift" section — scaffold + patch-package, and recommend filing an issue/PR to ship the manifest upstream. - Use "SwiftPM" in prose throughout (identifiers, the `spm` command, and the literal "Sync SPM Autolinking" phase name kept verbatim); move the Pipeline/Directory/Header/Auto-Sync deep-dives into a Reference section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re dep
rn-tester's test libraries (TestLibraryApple / TestLibraryCommon) failed to
build under SwiftPM with `RCTDeprecation/RCTDeprecation.h file not found`: the
scaffolded Package.swift carried no React products because the scaffolder's
`coreReactNative` detection missed them.
Both are plain ObjC modules that wire React core ONLY through the
`install_modules_dependencies(s)` podspec helper and ship no `codegenConfig`.
The scaffolder's two detection paths both fall through for that shape:
1. Explicit `s.dependency "React-Core"` — but the helper is stripped before
`pod ipc spec` (and its `min_supported_versions` call makes pod-ipc fail
outright, falling back to the regex parser, which can't expand the helper
either), so React-Core never surfaces in the parsed dependencies.
2. package.json `codegenConfig` — only marks Fabric / New-Arch libraries,
not plain ObjC modules.
Record whether the podspec source calls `install_modules_dependencies` (visible
even though the helper is stripped for pod-ipc) and treat its presence as an
authoritative React-core signal alongside codegenConfig — injecting that
family is the helper's entire job.
- read-podspec.js: set `usesInstallModulesDependencies` from the podspec source
- scaffold-package-swift.js: OR it into the coreReactNative fallback
- spm-types.js: add the field to PodspecModel
Verified: TestLibraryApple/Common now scaffold with the full React product set
(ReactNative, ReactNativeHeaders, ReactNativeDependenciesHeaders,
ReactAppHeaders); rn-tester BUILD SUCCEEDED. New focused unit test covers the
plain-ObjC-module-with-install_modules_dependencies shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…iew) Add an extension seam so a framework with its own module system (Expo is the first consumer) can contribute to the SwiftPM autolinking graph that `npx react-native spm` generates — the analog of the Podfile / `use_expo_modules!` / `react_native_post_install` hooks CocoaPods gave Expo. A one-shot post-process of the generated Package.swift is clobbered on the next sync (the Xcode auto-sync build phase re-runs autolinking on every dependency change), so plugins are invoked from generate-spm-autolinking's `main()` — the single function both `add`/`update` and the build-time `sync` call — and run on every regeneration. Discovery is transitive and zero-config: any autolinked dep opts in from its own react-native.config.js (`spm.autolinkingPlugin`), mirroring how CocoaPods pulls in `use_expo_modules!`. An app can exclude a plugin via `spm.denyPlugins` (escape hatch, not a required allowlist). Fail-closed and named: a plugin that can't load, doesn't export a function, throws, or returns a malformed contribution aborts the run identifying the framework — a silent drop (green build, missing native code) is worse than a loud stop. A plugin returns DATA (packageDependencies / productDependencies / generatedSources); RN owns the merge, so regeneration stays deterministic and idempotent, and package/product deps are deduped by name. The plugin context exposes `react` — RN's own React package ref + product set (local xor remote, incl. ReactAppHeaders from the per-app React-GeneratedCode package), derived from one source of truth so a plugin depends on exactly RN's React surface without re-deriving paths/identity that move as RN repackages. Also threads `--flavor` (debug/release) through setup + sync into the context so plugins can pick the matching prebuilt slice. Contract is Preview / unstable while Expo validates it; to be ratified via RFC. `generatedSources` is captured but its codegen registration + provider ordering is intentionally left for the first real plugin to drive to a stable shape. - autolinking-plugins.js: discoverPlugins + invokePlugins (new) - generate-spm-autolinking.js: discovery/invoke in main(), reactDescriptor, plugin-host-dep target exemption, merge into the aggregator manifest - spm-types.js: PluginContext / ReactDescriptor / PluginResult / …; flavor arg - setup-apple-spm.js, sync-spm-autolinking.js: thread --flavor - __doc__/spm-autolinking-plugins.md (new) + spm-scripts.md pointer - tests: autolinking-plugins-test.js (new) + reactDescriptor / contribution cases in generate-spm-autolinking-test.js Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive generate-spm-autolinking's main() over a minimal app fixture whose only autolinked iOS dep is a plugin host (expo: native sources, no Package.swift) to pin down the exemption added with the plugin system: a dep that declares an `spm.autolinkingPlugin` owns its native contribution and must NOT also be source-built through the community-lib path. A regression pair, so the guard is meaningful rather than incidental: - With the plugin declared: main() does not throw, the plugin's ExpoModulesCore package + product contribution is merged into the aggregator, and expo is not source-built (no `packages/<Name>` wrapper ref, no `__rnAutolinkedLibs` guard). - Negative control: the SAME fixture with the plugin declaration removed throws MissingManifestError — proving the exemption is load-bearing. This exercises the full main() path (dep walk, plugin discovery/invoke, manifest write) rather than a pure helper, which is why it was missing; the surrounding pieces (discoverPlugins / invokePlugins / the manifest merge) were already covered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omics
Group A of the SwiftPM review (code + unit-testable; SPM Release build fixes
land separately). Majors:
- generate-spm-xcodeproj.js: the generated Sync build phase ran `spm sync` then
`RC=$?` under `set -euo pipefail`, so a non-zero exit aborted the phase and the
RC branch was unreachable (every transient sync failure broke the build). Now
`spm sync || RC=$?` captures the code so the warn-and-exit-0 branch works.
- spm-utils.js: buildPerAppHeaderTree built into `outDir + '.tmp'`, which sits
INSIDE build/generated/ios — a folded root — so foldDir folded the half-built
farm into a spurious `ReactAppHeaders.tmp/` namespace duplicating every codegen
header. Build into build/.react-app-headers.tmp (outside every folded root),
then rename into place. Test asserts `.tmp/` is absent.
- read-podspec.js: pod-ipc JSON extraction anchored on the first `{`/last `}`
anywhere, so a pre-JSON diagnostic containing braces (`Building {module}`) shifted
the slice and dropped SILENTLY to the weaker regex parser. Anchor on the
line-boundary `{`…`}` pod ipc actually emits, and log when the authoritative
parse is abandoned.
Minors / CodeQL / ergonomics:
- spm-pbxproj.js: array build-setting dedup was substring-based (`-ObjC` vs
`-ObjCFoo`); now exact-token as the docstring promised.
- expand-spm-dependencies.js: swiftName collision detection is case-insensitive
(`worklets` vs `Worklets` collide on case-insensitive macOS filesystems).
- generate-spm-autolinking.js: the mixed-language friendly diagnostic now also
guards user spm.modules / synth wrappers, not just the community-dep path; and
the `.podspec` scanners skip a crashed run's `.spm-scaffold-*` leftover.
- download-spm-artifacts.js: registry `version` is validated against a safe charset
before it flows into Maven URLs / tar filenames (clear error instead of a 404;
satisfies the CodeQL command-line data-flow). Added `--deps-tarball` /
RN_DEPS_TARBALL_PATH (symmetric with --core-tarball) — a local deps tarball
auto-stages its headers sidecar via the existing companion path.
- generate-spm-package.js: guard against a stale/wrong React.xcframework layout
(missing Headers/React-umbrella.h or a nested Headers/React_Core/) — fail loudly
instead of dying deep in the consumer's Clang module build.
- scaffold-package-swift.js: correct the `exclude:` comment (none is emitted).
CodeQL weak-crypto (spm-pbxproj generateUUID) is already sha256 — those alerts are
stale pre-fix and clear on rescan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l-artifacts
Makes SwiftPM Release builds link + bundle (Debug already worked). Root-caused
via ios-build-matrix.sh; unblocks RN-Tester + community-Fabric SPM Release.
- (A) NDEBUG propagation. The Release React.framework strips
DebugStringConvertible's vtable (and shifts the ShadowNode layout that
inherits it) under NDEBUG, but the autolinked Fabric synth targets compiled
with zero RN defines → undefined-symbol / layout-mismatch at link. Emit
`.define("DEBUG", .when(.debug))` + `.define("NDEBUG", .when(.release))` on
every autolinked C++ target: scaffolded community libs
(scaffold-package-swift.js, SCAFFOLDER_VERSION 18→19 so existing scaffolds
regenerate), synth wrappers, and inline spm.module targets
(generate-spm-autolinking.js). NDEBUG-only is deliberate: Debug is green with
no defines, so the always-on RN defines are provably unneeded — NDEBUG is the
sole Debug↔Release divergence. Mirrors packages/react-native/Package.swift.
- (B) hermesc resolution. Under SPM there is no hermes-engine pod, so
react-native-xcode.sh's `$PODS_ROOT/hermes-engine/.../hermesc` fallback
resolves to a non-existent path and Release Hermes bundling fails for every
SPM app. Inject HERMES_CLI_PATH (=$(REACT_NATIVE_PATH)/../hermes-compiler/
hermesc/osx-bin/hermesc) via mergeReactBuildSettings; react-native-xcode.sh
honors a pre-set value before its pod fallback, and an existing user value is
left untouched. Reversed byte-for-byte on deinit (covered by the round-trip test).
- (Major #3) `--artifacts <local.xcframework>`: artifacts.json now also records
ReactNativeHeaders + ReactNativeDependenciesHeaders (they ship beside their
binaries in the tarballs). They are required by validateArtifactsCache /
generate-spm-package; omitting them made the local slot report incomplete and
forced a full Maven download, defeating "use it directly".
Tests: NDEBUG defines asserted in the scaffolded + synth manifests;
HERMES_CLI_PATH asserted in both build configs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the prior Release-support commit — two gaps the ios-build-matrix Release run exposed that the prior commit missed. - (A, completion) The codegen ReactCodegen / ReactAppDependencyProvider targets were NOT getting NDEBUG. That is where the app's codegen'd Fabric C++ lives (RNTMyNativeViewProps), so rn-tester still failed to link with an undefined "vtable for DebugStringConvertible". Add the DEBUG/NDEBUG config defines to scripts/codegen/templates/Package.swift.spm-template (the SPM codegen manifest installed by spm sync). Verified: rn-tester SPM Release now BUILD SUCCEEDED. - (B, fix) HERMES_CLI_PATH must be ABSOLUTE, not `$(REACT_NATIVE_PATH)/../…`. node_modules/react-native is commonly a SYMLINK (monorepo default + many real apps); a `..` after it resolves — kernel-side — to the symlink TARGET's parent (packages/), so the relative path pointed at a non-existent packages/hermes-compiler and Release JS bundling failed with "No such file". resolveHermesCliPathSetting now returns the require.resolve'd absolute hermesc path (regenerated per `spm add`; matches how the hermes-engine pod sets it). Verified: HelloWorld SPM Release now BUILD SUCCEEDED. Matrix: helloworld + rn-tester SPM Release both PASS (newapp pending a Verdaccio re-publish of these scripts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RN ships flavored prebuilt binaries — debug React/hermesvm/ReactNativeDependencies carry the dev experience (dev menu, assertions, RN_DEBUG_STRING_CONVERTIBLE), release strips them — so a Debug build must embed debug binaries and a Release/archive the release ones. SwiftPM binaryTargets can't branch on the build configuration, so mirror the CocoaPods React-Core-prebuilt swap (replace-rncore-version.js): - `spm add` now downloads BOTH flavors up front (best-effort) so the swap never needs the network mid-build. - New `swap-flavor` action + swap-flavor.js: reads the Xcode build env ($CONFIGURATION / $BUILT_PRODUCTS_DIR / $PLATFORM_NAME), slice-matches the target flavor's React / ReactNativeDependencies / hermesvm frameworks from the cache slot, and rsyncs them over the SwiftPM-copied frameworks in BUILT_PRODUCTS_DIR before Link. Idempotent, best-effort (never fatal). Resolves the cache slot via a single-level readlink (not realpath) so a locally-built React.xcframework symlinked into the slot doesn't send resolution out of the slot and under-swap. - The generated build phase invokes it on every build (no-op in the scheme pre-action, where BUILT_PRODUCTS_DIR isn't populated yet). Verified on the sim BOTH directions (hash-compared against the slot binaries): add-debug→Release build ships release; add-release→Debug build ships debug; all three frameworks swapped. Unit tests cover platform→slice mapping (incl. Mac Catalyst) and the swap at both the top-level and PackageFrameworks layouts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eHeaders The core prebuild left <hermes/...> to be folded consumer-side (ensureHeadersLayout), so a SwiftPM consumer of a published ReactNativeHeaders hit "'hermes/hermes.h' file not found". Pass the hermes-ios tarball's staged destroot/include (.build/artifacts/hermes/destroot/include) as the 5th arg to buildReactNativeHeadersXcframework so the PUBLISHED ReactNativeHeaders carries the hermes/ namespace. Null when unstaged (then <hermes/...> stays consumer-composed, as before) — no behavior change for source builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the missing depsTarball field to DownloadArgs, replace the deprecated mixed utility type with unknown, and make debug-log interpolation explicit (String(boolean), nullish fallback for ?string). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lint no-useless-escape) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
plutil is macOS-only; the destructured execFileSync requires a hoisted module mock. Portable plist-parse stand-in keeps the suite hermetic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
92fc1bb to
ea1d681
Compare
…d sync The generated Sync SPM Autolinking phase dispatched through 'npx react-native', which requires @react-native-community/cli — absent in Expo apps — so the Debug/Release flavor swap silently no-oped and the build linked the add-time flavor (Debug + release React => RCT_DEV=0 => 'No script URL provided' at launch). - Resolve NODE_BINARY from the app-local .xcode.env(.local) (also covers Xcode GUI builds with no node on PATH), fallback to PATH. - Resolve the react-native dir at build time via require.resolve — the generation-time baked path goes stale in pnpm stores — with the baked path as fallback. - Dispatch scripts/setup-apple-spm.js directly for both swap-flavor and sync (npx fallback retained for sync). - When the swap cannot run AND the pinned add-time flavor provably mismatches $CONFIGURATION, fail the build with an actionable error: instead of a swallowed warning. Reported by Expo SwiftPM integration testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erence on add The community template has carried a navigator-only JavaScriptCore.framework PBXFileReference since RN 0.60 (added in 0.58 when JSI required explicitly linking system JSC; the CocoaPods migration removed the build-phase link but left the file reference dangling). It links nothing and is meaningless under Hermes, but confuses users of converted projects. `spm add` now removes it as one-time conversion hygiene, alongside the existing empty-Pods-group cleanup. Safety-gated: a reference that any PBXBuildFile still links (an app deliberately using JSC) is left untouched, as is the old tvOS DEVELOPER_DIR variant. `update` stays a minimal re-sync and never touches it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tifacts contract
Four fixes from Expo's SwiftPM integration retest, plus the new plugin
artifact-swap contract:
1. Sync failure-atomicity: install the SPM codegen template immediately
after codegen, before autolinking-plugin execution — a plugin throw can
no longer leave codegen's mis-rooted default Package.swift breaking
every subsequent Resolve Package Graph.
2. `spm update` now refreshes the injected script text: new setScalarField
rewrites the sync phase's shellScript and the scheme pre-action's
scriptText in place (byte-identical when unchanged; index-splicing on
the scheme to survive $-sequences in the script).
3. Embed-flavor fix, three layers (mechanism proven by instrumented
builds: Xcode captures binaryTarget embed sources at build-graph-
construction time, and scheme pre-actions race graph construction):
- swap sandwich in the shared phase/pre-action script; the symlink
repoint half of swap-flavor no longer requires BUILT_PRODUCTS_DIR,
so the pre-action can act before the graph is built;
- pin-preserving sync (flavor derived from the current slot pin
instead of hardcoded debug), so sync can't clobber a repoint;
- deterministic appended `Fix SPM Embedded Flavor` phase: runs after
Xcode's implicit SPM embed, rsyncs correct-flavor slices over
.app/Frameworks and re-codesigns (CocoaPods-style, ad-hoc fallback).
Verified E2E on a scratch app: forced-stale, debug-pinned, clean
Release builds embed release deterministically (nm getDebugProps = 0,
signatures verify), and the reverse Debug flip embeds debug.
4. flavoredArtifacts plugin contract: plugins may declare precompiled
flavor pairs ({name, link, flavors:{debug,release}}); merged with
warn-and-drop validation, recorded to
.spm-plugin-flavored-artifacts.json ([] clears removals), and consumed
by swap-flavor's unified loop — RN's own frameworks are normalized
into the same shape, so plugin artifacts (e.g. Expo's precompiled
modules) inherit the identical swap mechanics, including the embed
fix. RN only repoints plugin-owned links, never creates/deletes them.
Docs: flavoredArtifacts contract + note that context.appRoot is the
Xcode project dir, not the package root.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Autolinking plugins can return generatedSources (e.g. Expo's ExpoModulesProvider.swift), previously recorded to a sidecar but consumed by nothing. An @objc class compiled into the static AutolinkedAggregate never reaches the ObjC classlist, so NSClassFromString discovery fails — name-discovered classes must compile into the app target itself. The injector now reads .spm-plugin-generated-sources.json at add/update and wires each source into the injected app target: PBXFileReference + PBXBuildFile + Sources-phase entry, parented under a single namespaced "SPM Generated Sources" group. Paths are stored SRCROOT-relative when under the app root, absolute otherwise. All UUIDs are deterministic (namespaced by normalized path) so re-injection is byte-identical. The .spm-injected.json marker gains a generatedSources section (path -> [uuids]); `spm update` reconciles it against the current manifest, retiring entries whose sources disappeared (and the group when the last one goes). `spm deinit` reverts everything via the existing recorded-UUID machinery. A target with no Sources phase logs loudly and skips the wiring without failing the injection. The generated-sources sidecar is now always written ([] when empty, like the flavored-artifacts sidecar) so removing a plugin entirely reconciles instead of leaving a stale manifest. Verified E2E on a scratch app with a real autolinking plugin: the generated @objc class reaches the app binary's ObjC classlist, and unregistering the plugin retires all pbxproj entries on the next update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…plate warning Three staleness/robustness hardening items: 1. Watch-path staleness (closes three gaps): the sync phase's .spm-sync-watch-paths file now carries mixed dirs AND files. Each autolinked npm dep's checked-in root Package.swift and .react-native/ dir are watched (dep root threaded from the autolinking model), so editing a library manifest finally trips the in-build re-sync (file edits never bump parent-dir mtimes, so the old dirs-only watch missed them). Plugins gain a watchPaths contract (absolute dirs/files, validated warn-and-drop like flavoredArtifacts) so plugin packages contribute staleness inputs. A watched path that VANISHES now forces a re-sync so the autolinker surfaces the real config error instead of dangling-symlink noise. The generated phase loop distinguishes -d (find -newer probe) / -f ([ -nt ]) / vanished (stale). Behavioral sh tests drive the real generated loop under /bin/sh. 2. Artifact version pinning: an explicit `spm add/update --version <v>` is recorded as artifactsVersionOverride in .spm-injected.json (preserved on later runs without --version; removed by deinit), and the in-build sync prefers it over the package.json-derived version — previously a version-mismatched setup had every in-build sync heal against the wrong artifact slot. findInjectedXcodeproj moved to generate-spm-xcodeproj.js as the single marker-lookup authority. 3. installSpmCodegenTemplate warns loudly when the SPM codegen template file is missing (previously a silent no-op that left codegen's mis-rooted default Package.swift breaking every subsequent Resolve Package Graph). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lavor flips The appended `Fix SPM Embedded Flavor` phase raced Xcode's standalone CodeSign of the same frameworks (no dependency edge exists: declaring the .app paths as phase outputs collides with the implicit embed's outputs — "Multiple commands produce" — and declaring the SOURCE paths cycles through the package targets). Instrumented builds further proved the implicit embed is unordered with respect to script phases entirely, and that scheme pre-actions race graph capture non-deterministically: no in-build mechanism can affect what the current build embeds. Only the state at build start counts. New semantics — hard-fail + converge: - The appended phase is gone (and is actively removed from already- injected projects on `spm add`/`update`). React Native writes nothing into the .app; Xcode's own embed copies and signs the pinned flavor. - A build that STARTS with the wrong pinned flavor corrects the symlinks and BUILT_PRODUCTS copies, completes the sync, then deliberately fails: "frameworks were pinned to 'debug' but this is a Release build — they have been switched; build again." The rebuild is green with the correct flavor linked AND embedded. Exactly one explanatory red build per configuration flip; a stale-flavor binary can never ship silently. - Only the LEADING swap may flag the failure (it alone observes the start state); a trailing correction converges silently, so mid-build re-syncs and fresh-clone auto-heals never produce a false red. - The scheme pre-action is now sync-only: a pre-action swap that won its race would mask a mismatched start into a false green. - `npx react-native spm update --flavor <x>` remains the zero-red-build way to switch ahead of time. E2E-verified on a real app with code signing enabled, both directions: matched builds green; flips red with the message and a converged pin; rebuilds green with the correct embedded flavor (nm-verified) and valid signatures; stale re-syncs on a matched pin stay green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… on flavor hard-fail
The deliberate flavor-flip red build has already planned against the
pre-flip graph, precompiling Swift explicit modules from the old flavor's
interfaces (a debug graph's -Onone interfaces depend on SwiftOnoneSupport,
unavailable in a Release rebuild — Expo hit this as `missing required
module 'SwiftOnoneSupport'` in Swift source packages importing precompiled
Swift modules). The converge rebuild reused that cache, which is not keyed
by anything the flip changes.
The hard-fail path now clears $OBJROOT/{ExplicitPrecompiledModules,
SwiftExplicitPrecompiledModules,XCBuildData} before exiting. All three go
together: the pcm dirs hold the stale modules, and XCBuildData holds the
dependency-scan state referencing them by absolute path — deleting either
without the other strands the rebuild (both directions empirically
proven). Best-effort, gated on $OBJROOT, and confined to the already-
failing red build, so green paths never pay it.
E2E on a real app: matched Debug green; flip Release red with the
invalidation logged; converge rebuild green with a release-flavored,
signature-valid embedded framework.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…om CONFIGURATION Two footguns in the flavor-pin behavior that made naive CI red and could silently re-pin an app: F1: `spm update` without --flavor used a yargs default of 'debug', silently re-pinning a release-pinned app. The flag now has no default; a single resolveFlavor() point in main() resolves: explicit flag (validated, hard error otherwise) -> existing pin (new null-returning readFlavorPin shared helper) -> 'debug'. F2: the in-build sync's fresh-state (no pin yet) flavor was hardcoded 'debug' even though $CONFIGURATION is available. Fresh state now derives the initial flavor via a shared flavorFromConfiguration() implementing Xcode's own config-name mapping (name contains debug/development -> debug, else release) with an RN_SPM_FLAVOR env override as escape hatch. The same rule replaces the exact-match 'Release' comparisons in swap-flavor.js and the generated shell fallback, so custom configuration names (e.g. Staging) now match what SwiftPM actually compiles package settings as. The hard-fail/converge contract is unchanged apart from those two mapping lines. 527 spm tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on, and should land after (bottom-up):
s.dependency "RCT-Folly"resolves to local facades instead of trunk source pods.The whole stack is rebased onto current main (2026-07-06).
This PR consumes the final five-artifact set: React, ReactNativeHeaders (pure-RN), ReactNativeDependencies, ReactNativeDependenciesHeaders, hermes-engine.
Summary
Adds npx react-native spm and the package-generation tooling that turns an app into a SwiftPM-integrated RN app using the base stack's prebuilt XCFrameworks. CocoaPods stays supported — additive, opt-in, no Ruby toolchain. Integration is injected into the existing .xcodeproj in place (nothing generated/renamed/replaced), recorded in .spm-injected.json so it reverses exactly.
What this PR adds
Documentation
scripts/spm/__doc__/spm-scripts.mdscripts/spm/__doc__/spm-header-paths-contract.mdChangelog:
[IOS] [ADDED] -
npx react-native spmcommand + SwiftPM package-generation tooling (opt-in; CocoaPods stays supported)Test Plan
350 scripts/spm unit tests (incl. byte-identical add→deinit round-trip); manual E2E against the five-artifact set: fresh app via
cli init→spm add --deintegrate→build (artifact resources verified in the app), rn-tester in-place migration (RNTesterPods.xcodeproj, test libraries + spmModules), helloworld in-place migration — all BUILD SUCCEEDED. Verifiednpx react-native spm <cmd>from the command line in each journey.Scope & limitations
iOS, prebuilt-only (no build-from-source yet); full spm.xcframework/spm.source library metadata not yet (app-local spm.modules + scaffolding are); Expo not yet.
Follow-ups
Remote-mode ReactNative Package.swift must vend the ReactNativeDependenciesHeaders product (spm-distribution repo); library self-containment for repo-portable manifests; build-from-source.