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
41 changes: 29 additions & 12 deletions packages/react-native/scripts/setup-apple-spm.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@
* directing you to `--deintegrate`).
*
* Options:
* --version <ver> React Native version (default: the resolved
* node_modules/react-native version).
* --version <ver> React Native version. Pinned into
* .spm-injected.json and reused by later runs
* until a new one is passed (default: the
* resolved node_modules/react-native version).
* --yes Skip the dirty-pbxproj confirmation prompt.
* [add] --xcodeproj <path> Which .xcodeproj to inject into (when several).
* [add] --product-name <name> Which app target to inject into (when several).
Expand Down Expand Up @@ -89,10 +91,12 @@ const {
const {main: generatePackage} = require('./spm/generate-spm-package');
const {findSourcePath} = require('./spm/generate-spm-package');
const {
SPM_INJECTED_MARKER,
cleanupDanglingJavaScriptCoreRef,
cleanupLeftoverPodsGroup,
findInjectedXcodeproj,
injectSpmIntoExistingXcodeproj,
readArtifactsVersionOverride,
removeSpmInjection,
} = require('./spm/generate-spm-xcodeproj');
const {scaffoldAll} = require('./spm/scaffold-package-swift');
Expand Down Expand Up @@ -149,7 +153,7 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
.option('version', {
type: 'string',
describe:
'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json',
'React Native version (e.g. 0.80.0). Sticks: later runs reuse it until you pass a new one. Defaults to the version in node_modules/react-native/package.json',
})
.option('yes', {
type: 'boolean',
Expand Down Expand Up @@ -373,19 +377,31 @@ function resolveReactNativeRoot(
return reactNativeRoot;
}

// Explicit `--version` → the version an earlier `--version` pinned into the
// injection marker → node_modules/react-native/package.json. The pin makes
// `--version` stick for later flagless runs, which would otherwise re-point the
// project at a different artifact slot than the one it was wired to.
function determineVersion(
args /*: SetupArgs */,
reactNativeRoot /*: string */,
appRoot /*: string */,
) /*: string */ {
let version = args.version;
if (version == null) {
// $FlowFixMe[incompatible-type] JSON.parse returns any
const pkgJson /*: {version: string} */ = JSON.parse(
fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'),
if (args.version != null) {
return args.version;
}
const pinned = readArtifactsVersionOverride(appRoot);
if (pinned != null) {
log(
`Using version ${pinned} pinned in ${SPM_INJECTED_MARKER} by an earlier ` +
'--version. Pass --version to change it.',
);
version = pkgJson.version;
return pinned;
}
return version;
// $FlowFixMe[incompatible-type] JSON.parse returns any
const pkgJson /*: {version: string} */ = JSON.parse(
fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'),
);
return pkgJson.version;
}

function runCodegenStep(
Expand Down Expand Up @@ -438,7 +454,7 @@ async function runScaffold(
// a comment — that's how SPM's manifest hash bumps on slot transitions.
let cacheSlotLabel /*: ?string */ = null;
try {
const rawVersion = args.version ?? determineVersion(args, reactNativeRoot);
const rawVersion = determineVersion(args, reactNativeRoot, appRoot);
const slotVersion = await resolveCacheSlotVersion(rawVersion);
cacheSlotLabel = `${slotVersion}/dual-flavor`;
} catch {
Expand Down Expand Up @@ -1048,7 +1064,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
autolinkingConfigResult,
projectRoot,
);
const version = determineVersion(args, reactNativeRoot);
const version = determineVersion(args, reactNativeRoot, appRoot);
log(`React Native version: ${version}`);

// Resolve remote SPM mode ONCE up front. remotePackageConfig throws
Expand Down Expand Up @@ -1210,6 +1226,7 @@ if (require.main === module) {
module.exports = {
main,
detectStandardRnLayoutRedirect,
determineVersion,
findInjectedXcodeproj,
generateAutolinkingConfigOrFailClosed,
parseArgs,
Expand Down
23 changes: 21 additions & 2 deletions packages/react-native/scripts/spm/__doc__/spm-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`).

| Option | Description |
|---|---|
| `--version <ver>` | RN version (default: from package.json) |
| `--version <ver>` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) |
| `--yes` | Skip the dirty-pbxproj confirmation prompt |
| `--xcodeproj <path>` | [add] Which `.xcodeproj` to inject into (when several exist) |
| `--productName <name>` | [add] Which app target to inject into (when several exist) |
Expand All @@ -138,6 +138,25 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`).
| `--download <auto\|skip\|force>` | [advanced] Artifact download policy (default: auto) |
| `--skipCodegen` | [advanced] Skip the codegen step |

### Pinning the React Native version

The resolved version selects **which artifact slots the project is wired to**, so
it has to stay the same from one run to the next. `--version` is therefore
recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and
read back by later runs, which resolve the version in this order:

1. an explicit `--version <ver>`,
2. the version a previous `--version` pinned into the marker,
3. `node_modules/react-native/package.json`.

So you pass the flag once, and a later flagless `add`/`update` stays on the slots
it selected. Without the pin, that flagless run falls back to `package.json` and
re-points the project at different artifact slots while the marker still
advertises the pinned version.

`deinit` deletes the marker, and with it the pin — a later `add` resolves
`node_modules/react-native/package.json` again unless you pass `--version`.

### Debug/Release flavor is automatic

React Native ships **flavored** prebuilt binaries: the *debug* `React.framework`
Expand All @@ -161,7 +180,7 @@ package graph, or require a second build.
| Path | Commit? | Why |
|------|---------|-----|
| `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. |
| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. |
| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also holds the `--version` pin (`artifactsVersionOverride`) that keeps later runs on the same artifact slots. |
| `build/generated/` | No | Codegen/autolinking output; regenerated |
| `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache |
| `Package.resolved` | No | SwiftPM resolution file; machine-specific |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ describe('generated-sources reconciliation on update', () => {

// ---------------------------------------------------------------------------
// artifactsVersionOverride — the marker field persisting an explicit
// `spm add/update --version <ver>` pin (see setup-apple-spm.js /
// sync-spm-autolinking.js). SETS on an explicit override; PRESERVES a
// `spm add/update --version <ver>` pin (see setup-apple-spm.js's
// determineVersion). SETS on an explicit override; PRESERVES a
// previously-recorded value when the caller omits one; deinit drops it along
// with the rest of the marker.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -366,9 +366,9 @@ describe('artifactsVersionOverride marker field', () => {
});

// ---------------------------------------------------------------------------
// readArtifactsVersionOverride — pure fs read, used by the build-time sync
// (sync-spm-autolinking.js) to prefer a pinned version over the one derived
// from node_modules/react-native/package.json.
// readArtifactsVersionOverride — pure fs read, used by setup-apple-spm.js's
// determineVersion to prefer a pinned version over the one derived from
// node_modules/react-native/package.json.
// ---------------------------------------------------------------------------
describe('readArtifactsVersionOverride', () => {
it('returns null when no xcodeproj has been injected yet', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

const {
detectStandardRnLayoutRedirect,
determineVersion,
ensureBothArtifactFlavors,
findInjectedXcodeproj,
generateAutolinkingConfigOrFailClosed,
Expand All @@ -28,12 +29,17 @@ const path = require('node:path');

// Create an in-place-injected xcodeproj fixture: a directory carrying the
// `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes).
function mkInjectedXcodeproj(appRoot, name) {
function mkInjectedXcodeproj(appRoot, name, extraMarkerFields = {}) {
const dir = path.join(appRoot, name);
fs.mkdirSync(dir, {recursive: true});
fs.writeFileSync(
path.join(dir, SPM_INJECTED_MARKER),
JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}),
JSON.stringify({
rootUuid: 'X',
target: 'MyApp',
injectedUuids: [],
...extraMarkerFields,
}),
);
return dir;
}
Expand Down Expand Up @@ -388,3 +394,91 @@ describe('shouldAutoDeintegrate', () => {
expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true);
});
});

// ---------------------------------------------------------------------------
// determineVersion — which RN version the artifact slots are wired to:
// explicit --version → the `artifactsVersionOverride` pinned in the injection
// marker by a previous `--version` → node_modules/react-native/package.json.
// ---------------------------------------------------------------------------

describe('determineVersion', () => {
let appRoot;
let reactNativeRoot;
let logSpy;

beforeEach(() => {
appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-app-'));
reactNativeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-rn-'));
fs.writeFileSync(
path.join(reactNativeRoot, 'package.json'),
JSON.stringify({name: 'react-native', version: '1000.0.0'}),
);
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
fs.rmSync(appRoot, {recursive: true, force: true});
fs.rmSync(reactNativeRoot, {recursive: true, force: true});
});

const logged = () => logSpy.mock.calls.map(c => c.join(' ')).join('\n');

it('prefers an explicit --version over a pinned override', () => {
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
artifactsVersionOverride: '0.80.0',
});

expect(
determineVersion({version: '0.81.0'}, reactNativeRoot, appRoot),
).toBe('0.81.0');
expect(logged()).not.toMatch(/spm-injected\.json/);
});

it('uses the pinned override when --version is omitted', () => {
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
artifactsVersionOverride: '0.80.0',
});

expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
'0.80.0',
);
});

it('names the marker in the log when the pin is the source', () => {
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
artifactsVersionOverride: '0.80.0',
});
determineVersion({version: null}, reactNativeRoot, appRoot);

expect(logged()).toMatch(/0\.80\.0/);
expect(logged()).toMatch(/spm-injected\.json/);
});

it("falls back to react-native's package.json with no pin recorded", () => {
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj');

expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
'1000.0.0',
);
expect(logged()).not.toMatch(/spm-injected\.json/);
});

it("falls back to react-native's package.json when no project is injected", () => {
mkXcodeproj(appRoot, 'MyApp.xcodeproj');

expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
'1000.0.0',
);
});

it('falls back without throwing when the marker is corrupt', () => {
const xcodeproj = path.join(appRoot, 'MyApp.xcodeproj');
fs.mkdirSync(xcodeproj, {recursive: true});
fs.writeFileSync(path.join(xcodeproj, SPM_INJECTED_MARKER), '{not json');

expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
'1000.0.0',
);
});
});
22 changes: 11 additions & 11 deletions packages/react-native/scripts/spm/generate-spm-xcodeproj.js
Original file line number Diff line number Diff line change
Expand Up @@ -1856,9 +1856,9 @@ function readMarker(

// Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json`
// marker (the user-owned project SPM packages were injected into in place),
// or null when none has been injected yet. Pure fs reads — safe for the
// build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride
// below) to call without pulling in any pbxproj-editing machinery at runtime.
// or null when none has been injected yet. Pure fs reads — no pbxproj-editing
// machinery, so cheap callers (action resolution, readArtifactsVersionOverride
// below) can use it freely.
function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
try {
Expand All @@ -1884,11 +1884,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
* update --version` pinned into the injected xcodeproj's `.spm-injected.json`
* marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
* below), or null when no project is injected yet, no override is pinned, or
* the marker can't be read (never throws). Pure fs reads — the build-time
* sync (sync-spm-autolinking.js) calls this to prefer the pinned version over
* the one derived from node_modules/react-native/package.json, so a
* version-mismatched setup keeps healing against the SAME artifact slot the
* explicit `--version` selected.
* the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's
* determineVersion prefers the pinned version over the one derived from
* node_modules/react-native/package.json, so a later flagless `add`/`update`
* (and `download`) stays on the SAME artifact slot the explicit `--version`
* selected.
*/
function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
const xcodeprojPath = findInjectedXcodeproj(appRoot);
Expand Down Expand Up @@ -2014,9 +2014,9 @@ function injectSpmIntoExistingXcodeproj(
// intentional pin, not something to silently re-derive from
// node_modules/react-native/package.json. There is no "clear" verb yet;
// `deinit` (removeSpmInjection) drops the whole marker, including this
// field. Read back by readArtifactsVersionOverride (above) so the
// build-time sync (sync-spm-autolinking.js) heals against the SAME slot
// `add`/`update` selected, even on a version-mismatched setup.
// field. Read back by readArtifactsVersionOverride (above) so a later
// flagless `add`/`update`/`download` resolves to the SAME slot, even on a
// version-mismatched setup.
const artifactsVersionOverride =
opts.artifactsVersionOverride ??
prevMarker?.artifactsVersionOverride ??
Expand Down
Loading