fix(swiftpm): fix two ways array build settings were mishandled - #57744
Open
chrfalch wants to merge 4 commits into
Open
fix(swiftpm): fix two ways array build settings were mishandled#57744chrfalch wants to merge 4 commits into
chrfalch wants to merge 4 commits into
Conversation
This was referenced Jul 29, 2026
cipolleschi
approved these changes
Jul 31, 2026
|
@cipolleschi has imported this pull request. If you are a Meta employee, you can view this in D114317839. |
Contributor
|
A comment left by @fabriziocucci: Two tiny things, both non-blocking and maybe I'm missing context:
|
`spm add` merges its array build settings (`HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, `FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS`) via `addArrayStringValues`, which has three add paths: create the field, append to an existing array, or promote an existing SCALAR into a `( … )` array. Only the first two were reversible. A promotion was recorded as `appendedArrayValues`, whose reversal only strips the injected members — so `spm deinit` left the promoted array and its `"$(inherited)"` seed behind, and the original scalar was never recorded anywhere to restore from. A scalar is the ordinary shape in a real project (`HEADER_SEARCH_PATHS = "$(inherited)";` in the Debug config), so this broke the byte-identical restore `deinit` promises on a common input. Record the pre-injection value in a new `promotedArrayScalars` field on the marker's `BuildSettingChange` and restore it in place. Notes on the details: - The recorded value is kept RAW. `findField`'s token for a bare scalar runs to the `;`, so it carries any whitespace before it, and deinit has to write those bytes back. The value emitted as an array MEMBER is still trimmed — a member with trailing whitespace would be malformed. The two differ deliberately. - The record is gated on the merge having actually changed the text. `addArrayStringValues` no-ops when its value list is empty (as `FRAMEWORK_SEARCH_PATHS` is with no flavored frameworks) or when every value is already present, and a recorded-but-untouched field would have deinit clobber whatever the user has there by then. - Restoration is skipped when the field is absent at deinit time: it was deleted after injection, and re-adding it would resurrect it at the top of the dictionary, matching neither the original nor the user's intent. - Promotion no longer re-emits a prior value that is itself `"$(inherited)"` (the seed) or empty (a bare `,` is not a valid plist element). Reversing a promotion rewrites the whole field, because the injected members and the seed are indistinguishable from the user's own once folded together. Members hand-added to a promoted array afterwards are therefore lost; the removal-helper banner in spm-pbxproj.js now names that exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tradeoff Review feedback on the promoted-scalar restore: - The seed guard compared the prior scalar against the quoted `"$(inherited)"` only, so an unquoted `$(inherited)` — equally valid, and present in the suite's own untrimmed-scalar fixture — was re-emitted alongside the seed. Deinit still restored it byte-identically (the record is raw), so this was duplication rather than breakage, but it defeated the guard. Compare unquoted, and parametrize the guard's unit test over both spellings so the injected shape is asserted, not just the post-deinit bytes. - The reversal tradeoff is not deinit-only: every re-sync reverts from the recorded baseline before re-injecting, so an `spm update` discards hand-added members just the same. Say so in the banner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
addArrayStringValues' "already an array" branch assumed the array was
multi-line: it anchored on `lastIndexOf('\n', tokenEnd - 1)`. For a single-line
array there is no newline inside the value, so that lands at the end of the
PREVIOUS line and the new members were spliced above the field — outside the
array, as a bare entry in the dict body:
{
"/new", <- invalid pbxproj
HEADER_SEARCH_PATHS = ("/vendor", ); <- member never added
OTHER = 1;
}
One `spm add` against such a project produced a file Xcode cannot open, and
because removeArrayStringValues only searches inside the field's value region,
`deinit` could never remove the stray line. Xcode writes multi-line arrays, but
hand-edited projects and other generators (XcodeGen, Tuist) emit compact ones.
Splice the members inline ahead of the `)` instead, matching the separator style
already present and honouring an existing trailing comma. Reformatting to
multi-line would change the user's formatting and would need the old shape
recorded to stay reversible, for no benefit. removeArrayStringValues gains
delimiter-anchored patterns for the shapes `add` can now produce, so the span
removed is exactly the span inserted and every shape round-trips byte-for-byte.
The dedupe parse was also quote-blind: it split members on every `,`, so a
member holding a quoted comma (`"$(FOO(x)),weird"`) parsed as two tokens and
defeated the exact-token short-circuit. It now uses a quote-aware splitter.
The multi-line path is unchanged byte-for-byte, verified by a differential
harness against the previous implementation over 48 add/remove cases, plus a
test pinning its exact output bytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`spm add` promotes an array build setting that is already present as a scalar (`HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, `FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS`), and reversing that promotion rewrites the whole field, because the injected members and the user's own are indistinguishable once folded together. Members hand-added to such an array afterwards are lost on `deinit`, and on `update`, which reverts to the recorded baseline before re-injecting. The docs claimed the pre-`add` restore was byte-identical with no qualification. - Follow a stock Xcode app target being an instance of this (the template writes `LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` as a target-level scalar), so the caveat reads as concrete rather than theoretical, and note that an existing array keeps the shape it was written in — including the one-line form hand edits and other generators emit. - The `.spm-injected.json` row of "What to commit" described the marker as a record of injected edits only; it also pins the pre-injection value of a build setting `add` rewrote. Docs only; no behavior change. [Internal] - SwiftPM: document that `deinit` cannot preserve members added by hand to a promoted array build setting Prose review of `packages/react-native/scripts/spm/__doc__/spm-scripts.md`. Every claim traced to this PR's code: the recorded marker key (`promotedArrayScalars` on `BuildSettingChange`), the settings merged as arrays (`INJECTED_ARRAY_SETTINGS` + `frameworkArrayBuildSettings`), the whole-field rewrite in `removeRecordedBuildSettings`, and `update` re-applying from `removeRecordedBuildSettings(original, prevMarker…)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chrfalch
force-pushed
the
chrfalch/spm-promoted-array-scalar-restore
branch
from
August 1, 2026 07:21
9379eb3 to
c105182
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Aug 10, 2026
Summary:
**`spm add --version <ver>` pinned a value that nothing ever read back.**
The marker write has been there all along, and so has the reader — `readArtifactsVersionOverride()` in `spm/generate-spm-xcodeproj.js`, exported and unit-tested. It just had **zero production callers**. Every reference to it was a test.
`determineVersion` — the only resolver — went straight from the flag to `package.json`:
```js
let version = args.version; // --version
if (version == null) {
version = pkgJson.version; // react-native/package.json; marker never consulted
}
```
That value picks which artifact slots the project gets wired to. So:
1. `spm add --version 0.88.0-nightly-…` → wires the nightly's slots, pins the label ✅
2. `spm update` (no flag) → silently resolves `package.json`'s version instead, re-pointing the project at different slots, while the marker still claims the nightly ❌
`--version` was effectively single-use. In this monorepo `package.json` is `1000.0.0`, which has no published artifacts, so a flagless run after a pinned `add` fails outright — which is why the standing advice has been to pass `--version` on *every* invocation. That advice was working around this bug.
**Fix:** insert the pin between the two existing sources.
```
--version → pinned override → react-native/package.json
```
15 lines of logic. `spm download` and the scaffold path pick it up for free, since both consume the same resolved value. Because this is persistent state, one line is logged when the pin is the source, so a stale pin is diagnosable instead of silent; there is still no way to clear it short of `deinit`.
**Three comments were actively wrong** and are corrected here: the reader's doc block, the marker-field comment, and `findInjectedXcodeproj`'s comment all asserted that the build-time sync calls this via `readArtifactsVersionOverride`. It does not and never did — the `sync` action returns before artifacts are resolved at all. Those comments are what made the gap invisible; they misled me while investigating.
## Changelog:
[Internal] [Fixed] - SwiftPM: `spm --version` now sticks, so a later run without the flag keeps using the pinned artifact version
Pull Request resolved: #57762
Test Plan:
`yarn jest packages/react-native/scripts` → **31 suites, 684 tests**, all green.
New tests, written red first — the load-bearing one failed with `Expected: "0.80.0" / Received: "1000.0.0"`, i.e. exactly the reported bug:
- `--version` given → wins, even with a different value pinned
- no flag, pin present → the pinned version is used
- no flag, no pin → `package.json`'s version (unchanged behaviour)
- no flag, corrupt or absent marker → `package.json`'s version, no throw
- the log line fires only when the pin is the source
The three fallback cases passed *before* the fix too, which is the point — they pin today's behaviour so this change can't regress it.
## Note on landing order
Touches `setup-apple-spm.js` and `spm/generate-spm-xcodeproj.js`, which #57744, #57756 and #57757 also touch. All are cut independently from `main`; whichever lands first, I'll rebase the rest. #57756 is the closest relative — it does the same wiring for `--config-command`, which had the identical write-only-pin shape.
Reviewed By: fabriziocucci
Differential Revision: D114318107
Pulled By: cipolleschi
fbshipit-source-id: 157da5b2eaaef9adee924eaa1832b7a38b008f4c
meta-codesync Bot
pushed a commit
that referenced
this pull request
Aug 10, 2026
…es (#57757) 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 writes `EXConstants.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: ```js scriptPhases: [{ id: 'expo-constants.app-config', // stable: ledger key + deterministic UUID seed name: 'Generate Expo app.config', // Xcode's display name script: '…', position: 'end', // 'end' (default) | 'beforeCompile' inputPaths: ['$(SRCROOT)/../app.json'], outputPaths: ['$(TARGET_BUILD_DIR)/…/EXConstants.bundle/app.config'], alwaysOutOfDate: true, }] ``` The 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 `.spm-injected.json` by `id`, so `update` reconciles and `deinit` reverts. ### 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: ``` 5. Resources 6. Bundle React Native code and images 7. [Expo Dev Launcher] Strip Local Network Keys for Release 8. Generate Expo app.config ← last ``` `BUILD SUCCEEDED`, and `EXConstants.bundle/app.config` is written with `sdkVersion: 56.0.0` — precisely the value whose absence caused the original bug. Red baseline confirmed first: before the declaration, the same app built with `0 script phase(s)`, an empty sidecar, and no `EXConstants.bundle` at all. They also independently confirmed `deinit` leaves zero residue, `add` is idempotent (same sha1 twice), and no phase duplicates. Their side is expo/expo#47647. ### Design decisions worth a reviewer's attention - **`end` appends at the true end of `buildPhases`**, 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 is `Sources, Frameworks, Resources, Bundle React Native code and images`. - **`beforeCompile` anchors 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. - **Validation is fatal**, matching `flavoredFrameworks` rather than the lenient `watchPaths`. 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. - **`id` is the ledger key and the UUID seed.** The charset allows a scoped npm name (`expo/log-box`) but excludes `:`, which separates the `plugin:<id>` seed. `__proto__`/`constructor`/`prototype` are rejected because `plainObject['__proto__'] = v` vanishes through `JSON.stringify`, which would record a phase that `deinit` could never remove. - **A plugin-supplied `name` reaches 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 phase `deinit` reported removing but didn't. 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 fixed in passing `add → update → deinit` did **not** restore `project.pbxproj` byte-for-byte, even with zero script phases: the second run's marker forgot what the first had created, leaving an empty `packageReferences` / `packageProductDependencies` and the generated `.xcscheme` behind. The created-record now carries forward and `scheme.created` is 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 survives `deinit`), 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 `scriptPhases` Pull Request resolved: #57757 Test 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: - one declared phase → exactly one `PBXShellScriptBuildPhase`, correct `name`/`shellScript`/serialized paths; `alwaysOutOfDate` emitted as unquoted `1` only when set - `end` lands last; `beforeCompile` lands after the sync phase and before Sources; declared order preserved for multiple phases of each position and for a mix; a changed `position` is re-seated on the next sync - add / update-in-place / remove keyed on `id`; unchanged re-sync byte-identical; `deinit` byte-identical with no orphan object or section - a 17-row hostile-`name` matrix (`{ } ( ) , ; = */ /* * /`, unbalanced quote, tab, unicode, 300 chars, a name that normalizes to empty) × {balanced after add, byte-identical re-inject, clean deinit} - scripts containing quotes, backslashes, newlines and `$(VAR)` round-trip through emission, refresh and deinit - contract validation: 16 malformed-entry cases, duplicate `id` across plugins, reserved ids, scoped ids accepted, `:` rejected - `add → update → deinit` byte-identity with zero phases and with two **Not covered by unit tests, deliberately:** that `end` runs after the JS bundle phase. The `plain-app.pbxproj` fixture 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 → add` is 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 committed `project.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.js` and `spm-pbxproj.js`, which #57744 and #57756 also touch — including the same marker-write block. All three are cut independently from `main`; whichever lands first, I'll rebase the others. Happy to restack in whatever order is easiest to review. Reviewed By: fabriziocucci Differential Revision: D114318236 Pulled By: cipolleschi fbshipit-source-id: 4aa7958c302323299eda9db17adcec0d86edeebe
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:
Two bugs in
addArrayStringValues, the helper that adds members to an array build setting (HEADER_SEARCH_PATHS,OTHER_LDFLAGS,FRAMEWORK_SEARCH_PATHS,LD_RUNPATH_SEARCH_PATHS). Both hit real projects; neither was visible from the existing fixture.1. A promoted scalar was never restored. When the setting already exists as a scalar, it gets promoted to a
( … )array — but that was recorded as a plain member-append, sodeinitstripped the members and left the array shell plus its injected"$(inherited)"behind. The original scalar was never recorded, so it couldn't be put back. Stock Xcode projects hit this: the app template setsLD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";as a target-level scalar.Fixed by pinning the pre-injection value in
.spm-injected.jsonand restoring it in place. Three details: the value is stored raw (a bare scalar's token runs to the;, so it carries whitespace that must come back); it's recorded only if the merge actually changed the field (otherwisedeinitwould overwrite a setting we never touched); and restoration is skipped if the field is gone, so a setting deleted afteraddisn't resurrected.2. A one-line array was corrupted. The append anchored on
lastIndexOf('\n', tokenEnd - 1), which assumes multi-line. With no newline in the value that lands on the previous line, so members were spliced above the field, outside the array:One
spm addproduced a project Xcode can't open, anddeinitcouldn't remove the stray line. Xcode writes multi-line arrays, but hand-edited projects and other generators (XcodeGen, Tuist) emit compact ones.Fixed by splicing inline ahead of the
), matching the separator style already there. Reformatting to multi-line would change the user's formatting and need the old shape recorded to stay reversible. Removal gained delimiter-anchored patterns for the shapesaddnow produces, so the span removed is the span inserted.Also: the dedupe parse split on every
,, so a member holding a quoted comma ("$(FOO(x)),weird") parsed as two tokens and defeated the exact-token check. Now quote-aware.Known tradeoff: reversing a promotion rewrites the whole field, since the injected members and the seed are indistinguishable from the user's own once folded together. Members hand-added to a promoted array afterwards are lost; the removal-helper banner says so.
Changelog:
[Internal] [Fixed] - SwiftPM:
spm deinitrestores a promoted scalar build setting, andspm addno longer corrupts a one-line arrayTest Plan:
yarn jest packages/react-native/scripts→ 722 tests, all green.Written red first. Byte-identical
add→deinitround-trip for every pre-existing shape: absent, multi-line array, bare and quoted scalars (including trailing whitespace and an empty value), and the one-line forms(),("/a"),("/a", ),("/a","/b"),("/a", "/b"). Plusadd→update→deinit, a user-added member survivingdeinit, and an injector-level test that a project with a one-line setting stays delimiter-balanced.The multi-line path is unchanged byte-for-byte — verified with a differential harness loading the previous implementation alongside the new one over 48 add/remove cases, plus a test pinning its exact output bytes.