Skip to content

Commit 3798aa1

Browse files
chrfalchclaude
andcommitted
feat(spm): framework autolinking plugins for react-native spm (Preview)
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>
1 parent 02aa02e commit 3798aa1

9 files changed

Lines changed: 953 additions & 6 deletions

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,8 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
11321132
appRoot,
11331133
'--react-native-root',
11341134
reactNativeRoot,
1135+
'--flavor',
1136+
args.flavor,
11351137
]);
11361138
} catch (e) {
11371139
if (e instanceof MissingManifestError) {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# SwiftPM Autolinking Plugins (Preview)
2+
3+
> **Preview / unstable contract.** The discovery mechanism and the plugin
4+
> function's context/return shape may change while the first consumers (Expo)
5+
> validate it. Pin to a React Native version if you depend on it.
6+
7+
How a framework with its own module system — Expo is the first consumer —
8+
contributes to the SwiftPM autolinking graph that `npx react-native spm`
9+
generates. See [spm-scripts.md](./spm-scripts.md) for the base tool.
10+
11+
## Why a plugin (not a static list or a post-process)
12+
13+
The documented extension points don't cover a framework:
14+
15+
- `spm.modules` in `react-native.config.js` is a **static** list of simple
16+
source modules. A framework discovers its modules **dynamically** (scanning
17+
`node_modules`), generates a **module registry**, and ships mixed
18+
Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't
19+
handle.
20+
- A one-shot **post-process** of the generated `Package.swift` is **clobbered
21+
on the next sync**: the Xcode [auto-sync build phase](./spm-scripts.md#auto-sync-build-phase)
22+
re-runs autolinking on every dependency change. A framework's contribution
23+
must run *whenever autolinking runs*.
24+
25+
A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s
26+
`main()` — the single function that both `add` / `update` **and** the
27+
build-time `sync` call — so the contribution is regenerated on every build and
28+
never goes stale.
29+
30+
(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile,
31+
`use_expo_modules!`, and `react_native_post_install` hooks.)
32+
33+
## Discovery — transitive, zero app config
34+
35+
A dependency opts in from its **own** `react-native.config.js`, so installing
36+
the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!`
37+
transitively):
38+
39+
```js
40+
// node_modules/expo/react-native.config.js
41+
module.exports = {
42+
spm: {autolinkingPlugin: './spm/autolinking-plugin.js'},
43+
};
44+
```
45+
46+
The autolinker already walks every dependency's `react-native.config.js`; any
47+
that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level
48+
registration or allowlist is required.
49+
50+
**Opt-out escape hatch.** An app can exclude a plugin from its own
51+
`react-native.config.js`:
52+
53+
```js
54+
module.exports = {
55+
spm: {denyPlugins: ['some-framework']}, // npm names to skip
56+
};
57+
```
58+
59+
## The contract
60+
61+
A plugin is a function exported from the module named above
62+
(`module.exports = fn`, or `default` / `plugin` named exports also work):
63+
64+
```js
65+
module.exports = function plugin(context) {
66+
return {
67+
packageDependencies: [
68+
// Local package (e.g. a scanned module dir) …
69+
{name: 'ExpoModulesCore', path: '../../../node_modules/expo-modules-core/ios'},
70+
// … or a remote/published package:
71+
// {name: 'SomePkg', url: 'https://…/SomePkg.git', version: '1.2.3'},
72+
],
73+
productDependencies: [
74+
// Linked by the app's AutolinkedAggregate target:
75+
{name: 'ExpoModulesCore', package: 'ExpoModulesCore'},
76+
],
77+
generatedSources: [
78+
// e.g. the generated module registry, registered with codegen:
79+
{path: 'build/generated/expo/ExpoModulesProvider.swift'},
80+
],
81+
};
82+
};
83+
```
84+
85+
### Context (input)
86+
87+
| Field | Meaning |
88+
|---|---|
89+
| `appRoot` | The `ios/` directory being injected. |
90+
| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. |
91+
| `reactNativeRoot` | Resolved `react-native` package root. |
92+
| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. |
93+
| `outputDir` | `build/generated/autolinking` — where generated artifacts land. |
94+
| `flavor` | `'debug'` \| `'release'` — pick the matching slice of per-configuration prebuilt xcframeworks. |
95+
| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. |
96+
97+
#### `context.react` — depending on React
98+
99+
A plugin that emits its own `Package.swift` must declare React as a dependency.
100+
Rather than re-deriving React Native's package path, identity, and product
101+
names — which differ between local and remote mode and **move as RN
102+
repackages** — take them from `context.react`:
103+
104+
```js
105+
react: {
106+
packageRef:
107+
{name: 'ReactNative', path: '<absolute>', relPath: '<relative-to-outputDir>'} // local
108+
| {name: '<identity>', url: '<url>', version: '<version>'}, // remote (SPM-resolved)
109+
products: [
110+
{name: 'ReactNative', package: 'ReactNative'},
111+
{name: 'ReactNativeHeaders', package: 'ReactNative'},
112+
{name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'},
113+
{name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, // ← separate, per-app package
114+
],
115+
}
116+
```
117+
118+
Local vs remote is signalled by which `packageRef` keys are present (`path` xor
119+
`url`+`version`). `packageRef.path` is **absolute** — always correct no matter
120+
which subdirectory of `outputDir` the plugin writes its own manifest into (the
121+
generated manifests are gitignored and regenerated every sync, so there's no
122+
portability cost); `relPath` (relative to `outputDir`) is provided as a
123+
convenience. `products` is the set React Native wires into **its own** autolinked
124+
targets (so a plugin's target compiles against exactly RN's React surface),
125+
filtered to those resolvable this run — every listed product is safe to
126+
reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in
127+
the separate `React-GeneratedCode` package (per-app codegen), which a
128+
hand-rolled plugin would miss, and which is omitted when that package is absent.
129+
Because RN derives this list from one source of truth alongside its own product
130+
wiring, it stays correct across repackaging.
131+
132+
### Return (contributions, all optional)
133+
134+
| Field | Merged into |
135+
|---|---|
136+
| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). |
137+
| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). |
138+
| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). |
139+
140+
The plugin returns **data** — it never writes into React Native's generated
141+
tree. RN owns the merge, so a re-sync reproduces the same `Package.swift`
142+
byte-for-byte (idempotent). Package and product contributions are **deduped by
143+
name** across plugins.
144+
145+
## Lifecycle
146+
147+
```
148+
react-native spm add / update ─┐
149+
├─► generate-spm-autolinking main()
150+
Xcode "Sync SPM Autolinking" ──┘ │
151+
(build phase, every build) ├─ 1. discover plugins (dep configs)
152+
├─ 2. RN builds its own dep graph
153+
├─ 3. invoke plugins (context in)
154+
└─ 4. merge results → aggregator Package.swift
155+
```
156+
157+
Because steps 1–4 run in the one `main()`, everything above shares the same
158+
seam — there is no separate hook to wire for the build-time path.
159+
160+
## Failure behavior
161+
162+
Fail-closed and **named**: a plugin that fails to load, doesn't export a
163+
function, throws, or returns a malformed contribution aborts the run with a
164+
message identifying the framework. A framework silently dropping its modules
165+
(a green build missing native code) is worse than a loud stop.
166+
167+
## Status & open items (Preview)
168+
169+
- **Implemented & tested:** discovery (transitive + deny-list), invocation,
170+
package + product merge, `flavor` in context, fail-closed, dedupe.
171+
- **Co-design with Expo (not final):** `generatedSources` is captured
172+
(written to `.spm-plugin-generated-sources.json`) but its codegen
173+
registration + **provider ordering** — codegen must consume the same
174+
discovered module set the plugin contributes — is intentionally left for the
175+
first real plugin to drive to a stable shape.
176+
- Contract to be ratified via RFC once Expo's plugin proves it (framed as a
177+
generic hook, not Expo-specific code in RN).

packages/react-native/scripts/spm/__doc__/spm-scripts.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,19 @@ workaround keeps your app building.
272272
> automatically — the error says so. Opt it out via `react-native.config.js`
273273
> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework.
274274
275+
## Framework plugins (Preview)
276+
277+
Frameworks with their own module system (e.g. Expo) contribute to the
278+
autolinking graph through a **plugin** — a function invoked on every
279+
regeneration (including the build-time sync) that adds SwiftPM package refs,
280+
product dependencies, and generated sources. Discovery is transitive
281+
(installing the framework is enough), and the plugin returns data that RN
282+
merges idempotently.
283+
284+
See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the
285+
discovery mechanism, the full context/return contract, lifecycle, and failure
286+
behavior.
287+
275288
## Removing / resetting
276289

277290
To remove SwiftPM entirely, use `deinit` (the inverse of `add`):

0 commit comments

Comments
 (0)