Skip to content

Commit d59ebbd

Browse files
chrfalchclaude
andcommitted
SPM: let autolinking plugins declare build-time script phases
SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that needs to generate content at build time had no way to get one under SwiftPM. The first casualty is expo-constants: nothing writes `EXConstants.bundle/app.config`, which surfaces at runtime as "Unable to find the embedded app config". Add a 6th field to the SwiftPM autolinking plugin contract: scriptPhases: [{id, name, script, position?: 'beforeCompile' | 'end', // default 'end' inputPaths?, outputPaths?, alwaysOutOfDate?}] A plugin returns data; RN validates it, records it to a `.spm-plugin-script-phases.json` sidecar (written even when empty, so removing a plugin clears stale entries), and `spm add`/`update` emits one PBXShellScriptBuildPhase per entry, tracked in the `.spm-injected.json` marker by `id` so update reconciles and deinit reverts. Design notes: - `end` appends at the true end of `buildPhases`, which is after the JS bundle phase — where expo-constants needs to write, since it targets $TARGET_BUILD_DIR. Anchoring relative to the Frameworks phase would land it before the bundle phase. - `beforeCompile` anchors after RN's own "Sync SPM Autolinking" phase, which must stay first because it regenerates autolinking; the anchor chains forward so declared order survives. Position and relative order are re-derived on every sync, so a phase moved by hand in Xcode returns to its declared slot. - Validation is fatal, matching flavoredFrameworks rather than watchPaths: a silently dropped phase means the generated content is never written and the app fails at runtime with no build-time signal — the very bug being fixed. - `id` is the ledger key and the deterministic UUID seed. Its charset allows a scoped npm name (@expo/log-box) but excludes `:`, which separates the `plugin:<id>` seed. `__proto__`/`constructor`/`prototype` are rejected because a JSON ledger key of `__proto__` vanishes through JSON.stringify. - A plugin-supplied `name` reaches pbxproj comments, which are scanned by single-line regexes, so what lands in a comment is normalized (a name containing `*/`, `{` or `,` otherwise produced an unopenable project and a phase deinit could not remove). The full name still goes verbatim into the `name` field Xcode displays. The same normalization now covers generated-source filenames, which had the identical hole. Also fixes, in passing, that add -> update -> deinit did not restore project.pbxproj byte-for-byte even with no script phases: the second run's marker forgot what the first had created, so an empty packageReferences / packageProductDependencies field and the generated scheme were left behind. The created-record now carries forward and scheme.created is sticky, and a created array field is removed only when it is empty after RN's own members come out — so a package a user added to it survives deinit. Verified end to end by the Expo team on a real Expo app: the phase lands last, after "Bundle React Native code and images", the build succeeds, and app.config is written with the sdkVersion whose absence caused the original bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c5bd054 commit d59ebbd

14 files changed

Lines changed: 2595 additions & 68 deletions

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ module.exports = function plugin(context) {
9797
'/…/node_modules/expo/Package.swift',
9898
'/…/node_modules/expo/expo-module.config.json',
9999
],
100+
scriptPhases: [
101+
// Build-time shell phases on the app target — SwiftPM's missing
102+
// `script_phase`:
103+
{
104+
id: 'expo-constants.generate-app-config',
105+
name: 'Generate Expo App Config',
106+
script: '"$NODE_BINARY" .../createExpoConfig.js',
107+
position: 'beforeCompile', // default: 'end'
108+
inputPaths: ['$(SRCROOT)/../app.config.js'],
109+
outputPaths: ['$(DERIVED_FILE_DIR)/EXConstants.bundle/app.config'],
110+
alwaysOutOfDate: true,
111+
},
112+
],
100113
};
101114
};
102115
```
@@ -134,6 +147,89 @@ warning. Absolute-only, because the generated phase tests these paths with no cw
134147
context. The kept paths are folded into `<outputDir>/.spm-sync-watch-paths`
135148
alongside RN's own, then deduped and sorted.
136149

150+
#### `scriptPhases` — build-time shell phases on the app target
151+
152+
SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must
153+
run a script during the app's build — `expo-constants` writing
154+
`EXConstants.bundle/app.config` is the first consumer — declares it here. Each
155+
entry is recorded to `<outputDir>/.spm-plugin-script-phases.json`, which
156+
`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry
157+
on the injected app target:
158+
159+
| Key | Meaning |
160+
|---|---|
161+
| `id` | **Stable key** — the ledger entry and the deterministic UUID seed. Charset `/^[@A-Za-z0-9_./-]+$/`, so a scoped npm name like `@expo/log-box` is a valid id; a `:` is not, because the id is hashed into the UUID seed as `plugin:<id>` and the separator must stay unambiguous. Renaming it is a *remove + add*, not a rename. |
162+
| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. |
163+
| `script` | The shell body. |
164+
| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. |
165+
| `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. |
166+
| `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. |
167+
168+
**Placement.** `'end'` appends at the true end of the target's `buildPhases`
169+
after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the
170+
"Sync SPM Autolinking" phase, which stays first because it regenerates the
171+
content everything else reads, and always **before Sources**: React Native never
172+
re-seats its own sync phase, so if you have dragged that below Sources your
173+
`beforeCompile` phases are seated ahead of Sources instead of following it.
174+
Phases sharing a position keep their declared order.
175+
176+
**Position is enforced on every sync.** `add`/`update` compares where the plugin
177+
phases actually sit in `buildPhases` against the declared placement and, **only
178+
when the two differ**, lifts their membership lines and re-seats them in
179+
declared order. So changing `position` — or swapping two phases that share one —
180+
takes effect on the next `spm add`/`update`, with no remove + re-add. When they
181+
agree nothing is rewritten, which is what keeps an unchanged declaration
182+
re-syncing to a byte-identical project. The consequence worth knowing: a phase
183+
you **drag somewhere else in Xcode is moved back** to its declared position on
184+
the next sync, because the plugin's declaration is the source of truth. Only the
185+
`id` behaves differently — it is a key, not a label, so renaming it is a remove
186+
+ add.
187+
188+
Phases are injected by `spm add` / `spm update` **only**. The build-time `sync`
189+
rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared
190+
phase appears on the next `add`/`update`, not on the next build. Each phase's
191+
UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's
192+
`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path
193+
lists, `alwaysOutOfDate` and placement in place, `update` removes phases that
194+
left the sidecar, and `deinit` reverts all of them.
195+
196+
Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a
197+
non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one
198+
plugin or across plugins) aborts the run. A silently dropped phase would produce
199+
a green build whose generated content was never written — a runtime failure with
200+
no build-time signal — and two phases sharing an `id` would collapse onto one
201+
ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even
202+
though the charset admits them: as keys of that ledger they never become own
203+
properties, so the phase would look recorded, disappear when the marker is
204+
serialized, and be unremovable by `deinit`.
205+
206+
**Hostile names.** A `name` reaches the project file twice. In the `name` field —
207+
what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any
208+
single-line string is expressible. Beside the phase's UUID, on the object's
209+
definition line and on its `buildPhases` member line, it also becomes a
210+
`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the
211+
`name` field) but the text around them is scanned by delimiter, so the name is
212+
**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single
213+
spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id`
214+
normalized the same way — and then to no comment at all if nothing survives
215+
either. Without that, a `{` in a comment would make the injector read
216+
the next object's body as this one's, and a `,` would make `deinit` delete the
217+
wrong line — corruption with no error. Only a line break is therefore rejected
218+
outright; a name is a display name, and no Xcode phase name spans lines.
219+
220+
The injector's read of the sidecar is deliberately lenient — the file does not
221+
exist yet on a first `spm add`, and a stale or hand-edited copy must not break
222+
injection. An absent file yields no phases silently, an unparseable one warns,
223+
and a single entry failing the same checks (bad or reserved `id`, empty or
224+
multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is
225+
**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it
226+
enforces exactly the rules the plugin contract does.
227+
228+
**Gating is the script's job.** The phase runs for every configuration and
229+
platform the target builds; if it should be a no-op for some of them (Release
230+
only, simulator only, …), the script must check `$CONFIGURATION` / `$PLATFORM_NAME`
231+
and exit early.
232+
137233
### Context (input)
138234

139235
| Field | Meaning |
@@ -188,6 +284,7 @@ wiring, it stays correct across repackaging.
188284
| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). |
189285
| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). |
190286
| `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. |
287+
| `scriptPhases` | Recorded for `spm add` / `update` to emit one `PBXShellScriptBuildPhase` per entry on the app target. Malformed entries and duplicate `id`s are fatal. |
191288

192289
The plugin returns **data** — it never writes into React Native's generated
193290
tree. RN owns the merge, so a re-sync reproduces the same `Package.swift`
@@ -237,6 +334,20 @@ message identifying the framework. A framework silently dropping its modules
237334
A target without a Sources phase logs loudly and skips the wiring (injection
238335
otherwise succeeds). v1 targets only the injected app target and assumes
239336
`.swift` in practice (`.m`/`.mm` are mapped as future-proofing).
337+
- **Implemented & tested:** `scriptPhases`, contract through injection.
338+
`invokePlugins` validates every entry fatally (`id` charset plus the reserved
339+
`__proto__`/`constructor`/`prototype` names, a single-line `name`, a required
340+
`script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus
341+
duplicate `id`s), and the merge always
342+
rewrites `.spm-plugin-script-phases.json``[]` when no plugin declares any,
343+
so removing a plugin clears stale entries. The `spm add`/`update` xcodeproj
344+
injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry
345+
on the app target at the requested position, recording the id→UUID map in the
346+
`.spm-injected.json` marker so a re-run refreshes each phase's content in
347+
place, re-seats it when its declared position or order changed, `update`
348+
removes phases that left the sidecar, and `deinit` reverts
349+
them. Like `flavoredFrameworks`, the
350+
build-time `sync` only rewrites the sidecar; it never mutates the project.
240351
- **Co-design with Expo (not final):** codegen **provider ordering** — codegen
241352
must consume the same discovered module set the plugin contributes — is
242353
intentionally left for the first real plugin to drive to a stable shape.

packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,4 +340,204 @@ describe('invokePlugins', () => {
340340
expect(res.watchPaths).toEqual([]);
341341
expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true);
342342
});
343+
344+
it('merges scriptPhases across plugins, preserving optional fields', () => {
345+
const res = invokePlugins(
346+
[
347+
mk('expo-constants', () => ({
348+
scriptPhases: [
349+
{
350+
id: 'expo-constants.generate-app-config',
351+
name: 'Generate Expo App Config',
352+
script: 'node ./write-app-config.js',
353+
position: 'beforeCompile',
354+
inputPaths: ['$(SRCROOT)/../app.config.js'],
355+
outputPaths: ['$(DERIVED_FILE_DIR)/app.config'],
356+
alwaysOutOfDate: true,
357+
},
358+
],
359+
})),
360+
mk('b', () => ({
361+
scriptPhases: [{id: 'b.stamp', name: 'Stamp', script: 'echo hi'}],
362+
})),
363+
],
364+
ctx,
365+
);
366+
expect(res.scriptPhases).toEqual([
367+
{
368+
id: 'expo-constants.generate-app-config',
369+
name: 'Generate Expo App Config',
370+
script: 'node ./write-app-config.js',
371+
position: 'beforeCompile',
372+
inputPaths: ['$(SRCROOT)/../app.config.js'],
373+
outputPaths: ['$(DERIVED_FILE_DIR)/app.config'],
374+
alwaysOutOfDate: true,
375+
},
376+
{id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'},
377+
]);
378+
});
379+
380+
// A package's own npm name is the obvious stable id, and the scoped form is
381+
// the common one (`@expo/log-box` is a named consumer).
382+
it.each([['@expo/log-box'], ['@expo/ui']])(
383+
'accepts the scoped npm name %s as a scriptPhase id',
384+
id => {
385+
const res = invokePlugins(
386+
[mk('expo', () => ({scriptPhases: [{id, name: 'X', script: 'echo'}]}))],
387+
ctx,
388+
);
389+
expect(res.scriptPhases).toEqual([
390+
{id, name: 'X', script: 'echo', position: 'end'},
391+
]);
392+
},
393+
);
394+
395+
it('defaults scriptPhases to [] when no plugin declares any', () => {
396+
const res = invokePlugins([mk('a', () => ({}))], ctx);
397+
expect(res.scriptPhases).toEqual([]);
398+
});
399+
400+
it('rejects a non-array scriptPhases declaration', () => {
401+
expect(() =>
402+
invokePlugins(
403+
[mk('expo', () => ({scriptPhases: {id: 'x', name: 'X', script: 'y'}}))],
404+
ctx,
405+
),
406+
).toThrow(/non-array scriptPhases/);
407+
});
408+
409+
it.each([
410+
['missing id', {name: 'X', script: 'echo'}],
411+
['empty id', {id: '', name: 'X', script: 'echo'}],
412+
['non-string id', {id: 7, name: 'X', script: 'echo'}],
413+
['an id containing a space', {id: 'a b', name: 'X', script: 'echo'}],
414+
// `:` is excluded so the `plugin:<id>` UUID seed stays unambiguous.
415+
['an id containing a colon', {id: 'expo:phase', name: 'X', script: 'echo'}],
416+
['missing name', {id: 'a', script: 'echo'}],
417+
['empty name', {id: 'a', name: '', script: 'echo'}],
418+
['missing script', {id: 'a', name: 'X'}],
419+
['empty script', {id: 'a', name: 'X', script: ''}],
420+
[
421+
'unknown position',
422+
{id: 'a', name: 'X', script: 'echo', position: 'afterLink'},
423+
],
424+
[
425+
'non-array inputPaths',
426+
{id: 'a', name: 'X', script: 'echo', inputPaths: '/in'},
427+
],
428+
[
429+
'non-string inputPaths entry',
430+
{id: 'a', name: 'X', script: 'echo', inputPaths: [7]},
431+
],
432+
[
433+
'empty inputPaths entry',
434+
{id: 'a', name: 'X', script: 'echo', inputPaths: ['']},
435+
],
436+
[
437+
'non-array outputPaths',
438+
{id: 'a', name: 'X', script: 'echo', outputPaths: '/out'},
439+
],
440+
[
441+
'non-string outputPaths entry',
442+
{id: 'a', name: 'X', script: 'echo', outputPaths: [null]},
443+
],
444+
[
445+
'non-boolean alwaysOutOfDate',
446+
{id: 'a', name: 'X', script: 'echo', alwaysOutOfDate: 'yes'},
447+
],
448+
['null entry', null],
449+
['a number instead of an entry', 42],
450+
['a string instead of an entry', 'echo'],
451+
['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}],
452+
[
453+
'the reserved id constructor',
454+
{id: 'constructor', name: 'X', script: 'echo'},
455+
],
456+
['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}],
457+
])('rejects a scriptPhase with %s', (_label, entry) => {
458+
expect(() =>
459+
invokePlugins([mk('expo', () => ({scriptPhases: [entry]}))], ctx),
460+
).toThrow(/invalid scriptPhase/);
461+
});
462+
463+
it('names the offending id in the invalid-scriptPhase error', () => {
464+
expect(() =>
465+
invokePlugins(
466+
[
467+
mk('expo', () => ({
468+
scriptPhases: [
469+
{id: 'ok.phase', name: 'Fine', script: 'echo'},
470+
{id: 'bad:phase', name: 'Bad', script: 'echo'},
471+
],
472+
})),
473+
],
474+
ctx,
475+
),
476+
).toThrow(/invalid scriptPhase 'bad:phase'/);
477+
});
478+
479+
it('rejects a duplicate scriptPhase id within a single plugin', () => {
480+
expect(() =>
481+
invokePlugins(
482+
[
483+
mk('expo', () => ({
484+
scriptPhases: [
485+
{id: 'dup.phase', name: 'One', script: 'echo one'},
486+
{id: 'dup.phase', name: 'Two', script: 'echo two'},
487+
],
488+
})),
489+
],
490+
ctx,
491+
),
492+
).toThrow(/duplicate script phase id 'dup\.phase'/);
493+
});
494+
495+
it('rejects a duplicate scriptPhase id across plugins, naming it', () => {
496+
const phase = () => ({id: 'dup.phase', name: 'Dup', script: 'echo'});
497+
expect(() =>
498+
invokePlugins(
499+
[
500+
mk('a', () => ({scriptPhases: [phase()]})),
501+
mk('b', () => ({scriptPhases: [phase()]})),
502+
],
503+
ctx,
504+
),
505+
).toThrow(/duplicate script phase id 'dup\.phase'/);
506+
});
507+
508+
// A line break is the one thing an Xcode phase display name can never carry.
509+
// Every other hostile character is safe by construction: the injector
510+
// normalizes the name for the `/* … */` comments and escapes it in the `name`
511+
// field, so nothing structural reaches the project text.
512+
it.each([
513+
['a newline', 'Line one\nLine two'],
514+
['a carriage return', 'Line one\rLine two'],
515+
])('rejects a scriptPhase name containing %s', (_label, name) => {
516+
expect(() =>
517+
invokePlugins(
518+
[
519+
mk('expo', () => ({
520+
scriptPhases: [{id: 'expo.phase', name, script: 'echo'}],
521+
})),
522+
],
523+
ctx,
524+
),
525+
).toThrow(/invalid scriptPhase name for 'expo\.phase'/);
526+
});
527+
528+
it.each([
529+
['pbxproj quoting', 'Bundle "app.config"'],
530+
['a comment terminator', 'Bad */ = { x'],
531+
['a comment opener', 'Bad /* opener'],
532+
])('keeps a scriptPhase name needing %s verbatim', (_label, name) => {
533+
const res = invokePlugins(
534+
[
535+
mk('expo', () => ({
536+
scriptPhases: [{id: 'expo.phase', name, script: 'echo'}],
537+
})),
538+
],
539+
ctx,
540+
);
541+
expect(res.scriptPhases[0].name).toBe(name);
542+
});
343543
});

0 commit comments

Comments
 (0)