SPM: let autolinking plugins declare build-time script phases - #57757
Open
chrfalch wants to merge 1 commit into
Open
SPM: let autolinking plugins declare build-time script phases#57757chrfalch wants to merge 1 commit into
chrfalch wants to merge 1 commit into
Conversation
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>
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.
Summary:
SwiftPM has no equivalent of CocoaPods'
script_phase, so a framework that generates content at build time can't get one. The first casualty is expo-constants: nothing writesEXConstants.bundle/app.config, which shows up at runtime as "Unable to find the embedded app config".This adds a 6th field to the SwiftPM autolinking plugin contract:
The plugin returns data; RN validates it, records it to a
.spm-plugin-script-phases.jsonsidecar (written even when empty, so removing a plugin clears stale entries), andspm add/updateemits onePBXShellScriptBuildPhaseper entry — tracked in.spm-injected.jsonbyid, soupdatereconciles anddeinitreverts.Verified end to end by the Expo team
On a real Expo app, against a local cut of this branch. A
position: 'end'phase lands last, after the JS bundle phase:BUILD SUCCEEDED, andEXConstants.bundle/app.configis written withsdkVersion: 56.0.0— precisely the value whose absence caused the original bug. Red baseline confirmed first: before the declaration, the same app built with0 script phase(s), an empty sidecar, and noEXConstants.bundleat all. They also independently confirmeddeinitleaves zero residue,addis idempotent (same sha1 twice), and no phase duplicates.Their side is expo/expo#47647.
Design decisions worth a reviewer's attention
endappends at the true end ofbuildPhases, which is after the JS bundle phase — where expo-constants must write, since it targets$TARGET_BUILD_DIR. Anchoring relative to the Frameworks phase (the obvious-looking choice) lands it before the bundle phase, because real template order isSources, Frameworks, Resources, Bundle React Native code and images.beforeCompileanchors after RN's own "Sync SPM Autolinking" phase, which must stay first since it regenerates autolinking — a plugin phase ahead of it would run against stale generated content. The anchor chains forward so declared order survives. Position and relative order are re-derived every sync, so a phase dragged by hand in Xcode returns to its declared slot.flavoredFrameworksrather than the lenientwatchPaths. A silently dropped phase means the content is never written and the app fails at runtime with no build-time signal — which is the bug being fixed.idis the ledger key and the UUID seed. The charset allows a scoped npm name (@expo/log-box) but excludes:, which separates theplugin:<id>seed.__proto__/constructor/prototypeare rejected becauseplainObject['__proto__'] = vvanishes throughJSON.stringify, which would record a phase thatdeinitcould never remove.namereaches pbxproj comments, and those are scanned by single-line regexes. What lands in a comment is therefore normalized: a name containing*/,{or,otherwise produced a brace-unbalanced project Xcode couldn't open, or an orphan phasedeinitreported removing but didn't. The full name still goes verbatim into thenamefield Xcode displays. The same normalization now covers generated-source filenames, which had the identical hole.Also fixed in passing
add → update → deinitdid not restoreproject.pbxprojbyte-for-byte, even with zero script phases: the second run's marker forgot what the first had created, leaving an emptypackageReferences/packageProductDependenciesand the generated.xcschemebehind. The created-record now carries forward andscheme.createdis sticky. Two guards came with that, both tested: 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 survivesdeinit), and the scheme is deleted only if it is still RN's own (so a scheme the user has taken over is left alone).Changelog:
[Internal] [Added] - SwiftPM: autolinking plugins can declare build-time script phases via
scriptPhasesTest Plan:
yarn jest packages/react-native/scripts→ 853 tests, all green. The SwiftPM suite specifically went from 462 → 637 tests.Written red first throughout. Coverage includes:
PBXShellScriptBuildPhase, correctname/shellScript/serialized paths;alwaysOutOfDateemitted as unquoted1only when setendlands last;beforeCompilelands after the sync phase and before Sources; declared order preserved for multiple phases of each position and for a mix; a changedpositionis re-seated on the next syncid; unchanged re-sync byte-identical;deinitbyte-identical with no orphan object or sectionnamematrix ({ } ( ) , ; = */ /* * /, unbalanced quote, tab, unicode, 300 chars, a name that normalizes to empty) × {balanced after add, byte-identical re-inject, clean deinit}$(VAR)round-trip through emission, refresh and deinitidacross plugins, reserved ids, scoped ids accepted,:rejectedadd → update → deinitbyte-identity with zero phases and with twoNot covered by unit tests, deliberately: that
endruns after the JS bundle phase. Theplain-app.pbxprojfixture has no bundle phase, so it is unassertable here — this is disclosed in a comment at the test rather than papered over, and is exactly what the Expo verification above establishes.Known limitation, not addressed here: against a project Xcode has previously saved,
add → deinit → addis structurally identical (same UUIDs, same reference counts) but not byte-identical — Xcode writes multi-line dicts in sorted order, the injector writes single-line dicts in insertion order, which shows up as formatting churn in a committedproject.pbxproj. Pre-existing for every object the injector emits, not specific to script phases, and filed separately.Note on landing order
This touches
generate-spm-xcodeproj.jsandspm-pbxproj.js, which #57744 and #57756 also touch — including the same marker-write block. All three are cut independently frommain; whichever lands first, I'll rebase the others. Happy to restack in whatever order is easiest to review.