Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ module.exports = function plugin(context) {
'/…/node_modules/expo/Package.swift',
'/…/node_modules/expo/expo-module.config.json',
],
scriptPhases: [
// Build-time shell phases on the app target — SwiftPM's missing
// `script_phase`:
{
id: 'expo-constants.generate-app-config',
name: 'Generate Expo App Config',
script: '"$NODE_BINARY" .../createExpoConfig.js',
position: 'beforeCompile', // default: 'end'
inputPaths: ['$(SRCROOT)/../app.config.js'],
outputPaths: ['$(DERIVED_FILE_DIR)/EXConstants.bundle/app.config'],
alwaysOutOfDate: true,
},
],
};
};
```
Expand Down Expand Up @@ -134,6 +147,89 @@ warning. Absolute-only, because the generated phase tests these paths with no cw
context. The kept paths are folded into `<outputDir>/.spm-sync-watch-paths`
alongside RN's own, then deduped and sorted.

#### `scriptPhases` — build-time shell phases on the app target

SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must
run a script during the app's build — `expo-constants` writing
`EXConstants.bundle/app.config` is the first consumer — declares it here. Each
entry is recorded to `<outputDir>/.spm-plugin-script-phases.json`, which
`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry
on the injected app target:

| Key | Meaning |
|---|---|
| `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. |
| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. |
| `script` | The shell body. |
| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. |
| `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. |
| `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. |

**Placement.** `'end'` appends at the true end of the target's `buildPhases` —
after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the
"Sync SPM Autolinking" phase, which stays first because it regenerates the
content everything else reads, and always **before Sources**: React Native never
re-seats its own sync phase, so if you have dragged that below Sources your
`beforeCompile` phases are seated ahead of Sources instead of following it.
Phases sharing a position keep their declared order.

**Position is enforced on every sync.** `add`/`update` compares where the plugin
phases actually sit in `buildPhases` against the declared placement and, **only
when the two differ**, lifts their membership lines and re-seats them in
declared order. So changing `position` — or swapping two phases that share one —
takes effect on the next `spm add`/`update`, with no remove + re-add. When they
agree nothing is rewritten, which is what keeps an unchanged declaration
re-syncing to a byte-identical project. The consequence worth knowing: a phase
you **drag somewhere else in Xcode is moved back** to its declared position on
the next sync, because the plugin's declaration is the source of truth. Only the
`id` behaves differently — it is a key, not a label, so renaming it is a remove
+ add.

Phases are injected by `spm add` / `spm update` **only**. The build-time `sync`
rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared
phase appears on the next `add`/`update`, not on the next build. Each phase's
UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's
`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path
lists, `alwaysOutOfDate` and placement in place, `update` removes phases that
left the sidecar, and `deinit` reverts all of them.

Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a
non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one
plugin or across plugins) aborts the run. A silently dropped phase would produce
a green build whose generated content was never written — a runtime failure with
no build-time signal — and two phases sharing an `id` would collapse onto one
ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even
though the charset admits them: as keys of that ledger they never become own
properties, so the phase would look recorded, disappear when the marker is
serialized, and be unremovable by `deinit`.

**Hostile names.** A `name` reaches the project file twice. In the `name` field —
what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any
single-line string is expressible. Beside the phase's UUID, on the object's
definition line and on its `buildPhases` member line, it also becomes a
`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the
`name` field) but the text around them is scanned by delimiter, so the name is
**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single
spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` —
normalized the same way — and then to no comment at all if nothing survives
either. Without that, a `{` in a comment would make the injector read
the next object's body as this one's, and a `,` would make `deinit` delete the
wrong line — corruption with no error. Only a line break is therefore rejected
outright; a name is a display name, and no Xcode phase name spans lines.

The injector's read of the sidecar is deliberately lenient — the file does not
exist yet on a first `spm add`, and a stale or hand-edited copy must not break
injection. An absent file yields no phases silently, an unparseable one warns,
and a single entry failing the same checks (bad or reserved `id`, empty or
multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is
**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it
enforces exactly the rules the plugin contract does.

**Gating is the script's job.** The phase runs for every configuration and
platform the target builds; if it should be a no-op for some of them (Release
only, simulator only, …), the script must check `$CONFIGURATION` / `$PLATFORM_NAME`
and exit early.

### Context (input)

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

The plugin returns **data** — it never writes into React Native's generated
tree. RN owns the merge, so a re-sync reproduces the same `Package.swift`
Expand Down Expand Up @@ -237,6 +334,20 @@ message identifying the framework. A framework silently dropping its modules
A target without a Sources phase logs loudly and skips the wiring (injection
otherwise succeeds). v1 targets only the injected app target and assumes
`.swift` in practice (`.m`/`.mm` are mapped as future-proofing).
- **Implemented & tested:** `scriptPhases`, contract through injection.
`invokePlugins` validates every entry fatally (`id` charset plus the reserved
`__proto__`/`constructor`/`prototype` names, a single-line `name`, a required
`script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus
duplicate `id`s), and the merge always
rewrites `.spm-plugin-script-phases.json` — `[]` when no plugin declares any,
so removing a plugin clears stale entries. The `spm add`/`update` xcodeproj
injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry
on the app target at the requested position, recording the id→UUID map in the
`.spm-injected.json` marker so a re-run refreshes each phase's content in
place, re-seats it when its declared position or order changed, `update`
removes phases that left the sidecar, and `deinit` reverts
them. Like `flavoredFrameworks`, the
build-time `sync` only rewrites the sidecar; it never mutates the project.
- **Co-design with Expo (not final):** codegen **provider ordering** — codegen
must consume the same discovered module set the plugin contributes — is
intentionally left for the first real plugin to drive to a stable shape.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,4 +340,204 @@ describe('invokePlugins', () => {
expect(res.watchPaths).toEqual([]);
expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true);
});

it('merges scriptPhases across plugins, preserving optional fields', () => {
const res = invokePlugins(
[
mk('expo-constants', () => ({
scriptPhases: [
{
id: 'expo-constants.generate-app-config',
name: 'Generate Expo App Config',
script: 'node ./write-app-config.js',
position: 'beforeCompile',
inputPaths: ['$(SRCROOT)/../app.config.js'],
outputPaths: ['$(DERIVED_FILE_DIR)/app.config'],
alwaysOutOfDate: true,
},
],
})),
mk('b', () => ({
scriptPhases: [{id: 'b.stamp', name: 'Stamp', script: 'echo hi'}],
})),
],
ctx,
);
expect(res.scriptPhases).toEqual([
{
id: 'expo-constants.generate-app-config',
name: 'Generate Expo App Config',
script: 'node ./write-app-config.js',
position: 'beforeCompile',
inputPaths: ['$(SRCROOT)/../app.config.js'],
outputPaths: ['$(DERIVED_FILE_DIR)/app.config'],
alwaysOutOfDate: true,
},
{id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'},
]);
});

// A package's own npm name is the obvious stable id, and the scoped form is
// the common one (`@expo/log-box` is a named consumer).
it.each([['@expo/log-box'], ['@expo/ui']])(
'accepts the scoped npm name %s as a scriptPhase id',
id => {
const res = invokePlugins(
[mk('expo', () => ({scriptPhases: [{id, name: 'X', script: 'echo'}]}))],
ctx,
);
expect(res.scriptPhases).toEqual([
{id, name: 'X', script: 'echo', position: 'end'},
]);
},
);

it('defaults scriptPhases to [] when no plugin declares any', () => {
const res = invokePlugins([mk('a', () => ({}))], ctx);
expect(res.scriptPhases).toEqual([]);
});

it('rejects a non-array scriptPhases declaration', () => {
expect(() =>
invokePlugins(
[mk('expo', () => ({scriptPhases: {id: 'x', name: 'X', script: 'y'}}))],
ctx,
),
).toThrow(/non-array scriptPhases/);
});

it.each([
['missing id', {name: 'X', script: 'echo'}],
['empty id', {id: '', name: 'X', script: 'echo'}],
['non-string id', {id: 7, name: 'X', script: 'echo'}],
['an id containing a space', {id: 'a b', name: 'X', script: 'echo'}],
// `:` is excluded so the `plugin:<id>` UUID seed stays unambiguous.
['an id containing a colon', {id: 'expo:phase', name: 'X', script: 'echo'}],
['missing name', {id: 'a', script: 'echo'}],
['empty name', {id: 'a', name: '', script: 'echo'}],
['missing script', {id: 'a', name: 'X'}],
['empty script', {id: 'a', name: 'X', script: ''}],
[
'unknown position',
{id: 'a', name: 'X', script: 'echo', position: 'afterLink'},
],
[
'non-array inputPaths',
{id: 'a', name: 'X', script: 'echo', inputPaths: '/in'},
],
[
'non-string inputPaths entry',
{id: 'a', name: 'X', script: 'echo', inputPaths: [7]},
],
[
'empty inputPaths entry',
{id: 'a', name: 'X', script: 'echo', inputPaths: ['']},
],
[
'non-array outputPaths',
{id: 'a', name: 'X', script: 'echo', outputPaths: '/out'},
],
[
'non-string outputPaths entry',
{id: 'a', name: 'X', script: 'echo', outputPaths: [null]},
],
[
'non-boolean alwaysOutOfDate',
{id: 'a', name: 'X', script: 'echo', alwaysOutOfDate: 'yes'},
],
['null entry', null],
['a number instead of an entry', 42],
['a string instead of an entry', 'echo'],
['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}],
[
'the reserved id constructor',
{id: 'constructor', name: 'X', script: 'echo'},
],
['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}],
])('rejects a scriptPhase with %s', (_label, entry) => {
expect(() =>
invokePlugins([mk('expo', () => ({scriptPhases: [entry]}))], ctx),
).toThrow(/invalid scriptPhase/);
});

it('names the offending id in the invalid-scriptPhase error', () => {
expect(() =>
invokePlugins(
[
mk('expo', () => ({
scriptPhases: [
{id: 'ok.phase', name: 'Fine', script: 'echo'},
{id: 'bad:phase', name: 'Bad', script: 'echo'},
],
})),
],
ctx,
),
).toThrow(/invalid scriptPhase 'bad:phase'/);
});

it('rejects a duplicate scriptPhase id within a single plugin', () => {
expect(() =>
invokePlugins(
[
mk('expo', () => ({
scriptPhases: [
{id: 'dup.phase', name: 'One', script: 'echo one'},
{id: 'dup.phase', name: 'Two', script: 'echo two'},
],
})),
],
ctx,
),
).toThrow(/duplicate script phase id 'dup\.phase'/);
});

it('rejects a duplicate scriptPhase id across plugins, naming it', () => {
const phase = () => ({id: 'dup.phase', name: 'Dup', script: 'echo'});
expect(() =>
invokePlugins(
[
mk('a', () => ({scriptPhases: [phase()]})),
mk('b', () => ({scriptPhases: [phase()]})),
],
ctx,
),
).toThrow(/duplicate script phase id 'dup\.phase'/);
});

// A line break is the one thing an Xcode phase display name can never carry.
// Every other hostile character is safe by construction: the injector
// normalizes the name for the `/* … */` comments and escapes it in the `name`
// field, so nothing structural reaches the project text.
it.each([
['a newline', 'Line one\nLine two'],
['a carriage return', 'Line one\rLine two'],
])('rejects a scriptPhase name containing %s', (_label, name) => {
expect(() =>
invokePlugins(
[
mk('expo', () => ({
scriptPhases: [{id: 'expo.phase', name, script: 'echo'}],
})),
],
ctx,
),
).toThrow(/invalid scriptPhase name for 'expo\.phase'/);
});

it.each([
['pbxproj quoting', 'Bundle "app.config"'],
['a comment terminator', 'Bad */ = { x'],
['a comment opener', 'Bad /* opener'],
])('keeps a scriptPhase name needing %s verbatim', (_label, name) => {
const res = invokePlugins(
[
mk('expo', () => ({
scriptPhases: [{id: 'expo.phase', name, script: 'echo'}],
})),
],
ctx,
);
expect(res.scriptPhases[0].name).toBe(name);
});
});
Loading
Loading