From b98808218cf6bad6af6871d71c8fd4944e6a5028 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 7 Sep 2026 12:17:37 +0200 Subject: [PATCH 1/5] [iOS][SPM] Derive the generated manifests' iOS platform floor from the app The Autolinked aggregate, the per-dependency synth packages and the scaffolded community packages hardcoded `platforms: [.iOS(.v15)]`. SwiftPM refuses to link a product whose minimum platform is above the depending target's, so any self-managed package with a higher floor (every Expo package: iOS 16.4) failed to resolve: The package product 'Expo' requires minimum platform version 16.4 for the iOS platform, but this target supports 15.0 (in target 'AutolinkedAggregate') The floor is now the app's own IPHONEOS_DEPLOYMENT_TARGET, read from the injected .xcodeproj (the marker's target, else the `--product-name` target, else every application target; target configuration first, then the project-level configuration of the same name; the lowest wins), clamped to React Native's minimum (15.1) and emitted in string form (`.iOS("16.4")`). setup-apple-spm.js resolves it once per run and passes it to the generator, the sync script and the scaffolder via `--ios-deployment-target`. SCAFFOLDER_VERSION is bumped so existing scaffolds regenerate. Co-Authored-By: Claude Fable 5.1 --- .../react-native/scripts/setup-apple-spm.js | 46 +++- .../scripts/spm/__docs__/spm-scripts.md | 12 ++ .../generate-spm-autolinking-test.js | 95 +++++++++ .../__tests__/ios-deployment-target-test.js | 170 +++++++++++++++ .../__tests__/scaffold-package-swift-test.js | 19 ++ .../spm/__tests__/setup-apple-spm-test.js | 81 +++++++ .../__tests__/sync-spm-autolinking-test.js | 20 +- .../scripts/spm/generate-spm-autolinking.js | 21 +- .../scripts/spm/generate-spm-xcodeproj.js | 4 +- .../scripts/spm/ios-deployment-target.js | 197 ++++++++++++++++++ .../scripts/spm/scaffold-package-swift.js | 14 +- .../react-native/scripts/spm/spm-types.js | 4 + .../scripts/spm/sync-spm-autolinking.js | 13 +- 13 files changed, 687 insertions(+), 9 deletions(-) create mode 100644 packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js create mode 100644 packages/react-native/scripts/spm/ios-deployment-target.js diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index a89f9f138dca..5e81255ebde5 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -99,9 +99,14 @@ const { findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, readArtifactsVersionOverride, + readMarker, readPinnedConfigCommand, removeSpmInjection, } = require('./spm/generate-spm-xcodeproj'); +const { + MIN_IOS_VERSION_SUPPORTED, + resolveIosDeploymentTarget, +} = require('./spm/ios-deployment-target'); const {scaffoldAll} = require('./spm/scaffold-package-swift'); const { RemoteVersionError, @@ -452,6 +457,7 @@ async function runScaffold( appRoot /*: string */, projectRoot /*: string */, reactNativeRoot /*: string */, + iosDeploymentTarget /*: string */, ) /*: Promise */ { // Resolve the cache slot identifier so the scaffolded files carry it as // a comment — that's how SPM's manifest hash bumps on slot transitions. @@ -472,6 +478,7 @@ async function runScaffold( projectRoot, reactNativeRoot, cacheSlotLabel, + iosDeploymentTarget, // Always force a re-render so re-running after editing a podspec picks // up the new content. force: true, @@ -788,6 +795,29 @@ function resolveInjectionTarget( return {path: path.join(appRoot, names[0])}; } +/** The app's own floor for every manifest this run generates, logged once. */ +function resolveAppIosDeploymentTarget( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: string */ { + const target = resolveInjectionTarget(args, appRoot); + const xcodeprojPath = target.path ?? null; + const read = resolveIosDeploymentTarget({ + xcodeprojPath, + targetUuid: + xcodeprojPath != null ? readMarker(xcodeprojPath)?.targetUuid : null, + targetName: args.productName, + }); + const value = read ?? MIN_IOS_VERSION_SUPPORTED; + const origin = + read != null && xcodeprojPath != null + ? `from ${path.basename(xcodeprojPath)}` + : // `sync` and `scaffold` don't otherwise surface why no project was picked. + `react-native default${target.error != null ? `; ${target.error}` : ''}`; + log(`iOS deployment target: ${value} (${origin})`); + return value; +} + /** * Inject SPM packages into the user's existing .xcodeproj, in place — the only * xcodeproj strategy (`add` and `update` both run this; there is no @@ -1117,6 +1147,9 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { } log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`); } + const iosDeploymentTarget /*: string */ = needsCliConfig + ? resolveAppIosDeploymentTarget(args, appRoot) + : MIN_IOS_VERSION_SUPPORTED; const reactNativeRoot = resolveReactNativeRoot( autolinkingConfigResult, projectRoot, @@ -1160,6 +1193,8 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { appRoot, '--react-native-root', reactNativeRoot, + '--ios-deployment-target', + iosDeploymentTarget, ]); } catch (e) { if (e instanceof MissingManifestError) { @@ -1193,7 +1228,13 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // visible and fixed deliberately (scaffold + patch-package, or upstream). // Auto-scaffolding would silently hide that real error. if (action === 'scaffold') { - await runScaffold(args, appRoot, projectRoot, reactNativeRoot); + await runScaffold( + args, + appRoot, + projectRoot, + reactNativeRoot, + iosDeploymentTarget, + ); } runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen); @@ -1204,6 +1245,8 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { appRoot, '--react-native-root', reactNativeRoot, + '--ios-deployment-target', + iosDeploymentTarget, ]); } catch (e) { if (e instanceof MissingManifestError) { @@ -1288,6 +1331,7 @@ module.exports = { generateAutolinkingConfigOrFailClosed, parseArgs, resolveAction, + resolveAppIosDeploymentTarget, resolveConfigCommandToPin, resolveExplicitConfigCommand, shouldAutoDeintegrate, diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 8a38007b81c5..e06dcd4426e2 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -216,6 +216,18 @@ load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at `pod install` time, so this keeps SwiftPM apps at parity. An existing value is left alone. +### iOS deployment target + +Every manifest React Native generates — the `Autolinked` aggregate, the synth +package per dependency, and each scaffolded community package — declares the +same platform floor: your app's `IPHONEOS_DEPLOYMENT_TARGET`, never below React +Native's own minimum (15.1). SwiftPM refuses to link a product whose minimum is +higher than the target depending on it, so a dependency that needs more (Expo's +packages need iOS 16.4) only resolves once the app asks for at least as much: +raise the deployment target in Xcode and re-run `react-native spm update`. +Scaffolded packages keep the floor they were scaffolded with until you re-run +`react-native spm scaffold`. + ## Files the tool touches Paths are relative to the Xcode project directory (`ios/`) unless noted. diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index baa0a8fa20ba..6c77c271a713 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -466,6 +466,101 @@ describe('generateSynthPackageSwift', () => { }); }); +// --------------------------------------------------------------------------- +// Platform floor — a manifest must not sit below the app's own iOS deployment +// target: SwiftPM refuses a product whose minimum is higher than the depending +// target's, so a self-managed dep at iOS 16.4 (Expo) would fail to link. +// --------------------------------------------------------------------------- + +describe('iOS platform floor', () => { + it('defaults the aggregator to the React Native minimum, and raises it on request', () => { + expect(generateAutolinkedPackageSwift({})).toContain( + 'platforms: [.iOS("15.1")]', + ); + expect(generateAutolinkedPackageSwift({})).not.toContain('.v15'); + expect( + generateAutolinkedPackageSwift({iosDeploymentTarget: '16.4'}), + ).toContain('platforms: [.iOS("16.4")]'); + }); + + it('defaults each synth package to the React Native minimum, and raises it on request', () => { + const spec = {swiftName: 'MyDep'}; + expect(generateSynthPackageSwift(spec)).toContain( + 'platforms: [.iOS("15.1")]', + ); + expect(generateSynthPackageSwift(spec)).not.toContain('.v15'); + expect( + generateSynthPackageSwift({...spec, iosDeploymentTarget: '16.4'}), + ).toContain('platforms: [.iOS("16.4")]'); + }); +}); + +describe('main() — --ios-deployment-target', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // An app-local spm.module is the only route that produces BOTH the + // aggregator and a synth manifest without a community dep on disk. + function buildApp() { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-platform-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const modDir = path.join(appRoot, 'ios', 'MyNativeModule'); + fs.mkdirSync(modDir, {recursive: true}); + fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n'); + fs.writeFileSync( + path.join(appRoot, 'react-native.config.js'), + `module.exports = ${JSON.stringify({ + spm: {modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}]}, + })};\n`, + ); + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies: {}}), + ); + return {appRoot, rnRoot, autolinkDir}; + } + + it('sanitizes the flag into both the aggregator and the synth manifest', () => { + const {appRoot, rnRoot, autolinkDir} = buildApp(); + main([ + '--app-root', + appRoot, + '--react-native-root', + rnRoot, + '--ios-deployment-target', + '16', + ]); + for (const manifest of [ + path.join(autolinkDir, 'Package.swift'), + path.join(autolinkDir, 'packages', 'MyNativeModule', 'Package.swift'), + ]) { + expect(fs.readFileSync(manifest, 'utf8')).toContain( + 'platforms: [.iOS("16.0")]', + ); + } + }); +}); + // --------------------------------------------------------------------------- // linkHeaderTree // diff --git a/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js b/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js new file mode 100644 index 000000000000..254b164608e7 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js @@ -0,0 +1,170 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + MIN_IOS_VERSION_SUPPORTED, + readIosDeploymentTargetFromPbxproj, + resolveIosDeploymentTarget, + sanitizeIosDeploymentTarget, +} = require('../ios-deployment-target'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const FIXTURE = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// Fixture anchors (see __fixtures__/plain-app.pbxproj): one app target whose +// own Debug/Release configs carry no IPHONEOS_DEPLOYMENT_TARGET, and two +// project-level configs that do (15.1). +const TARGET_DEBUG = 'AA0000000000000000000901'; +const TARGET_RELEASE = 'AA00000000000000000000A2'; + +// Insert IPHONEOS_DEPLOYMENT_TARGET into one XCBuildConfiguration. +function withSetting(text, configUuid, value) { + const at = text.indexOf(configUuid + ' /*'); + const lineEnd = text.indexOf('\n', text.indexOf('buildSettings = {', at)) + 1; + return ( + text.slice(0, lineEnd) + + `\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = ${value};\n` + + text.slice(lineEnd) + ); +} + +function withProjectSetting(text, value) { + return text.replaceAll( + 'IPHONEOS_DEPLOYMENT_TARGET = 15.1;', + `IPHONEOS_DEPLOYMENT_TARGET = ${value};`, + ); +} + +function withoutProjectSetting(text) { + return text.replaceAll('\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.1;\n', ''); +} + +const RAISED_TARGET = withSetting( + withSetting(FIXTURE, TARGET_DEBUG, '16.4'), + TARGET_RELEASE, + '16.4', +); +// A second application target with no configurations of its own, so it inherits +// the project's 15.1 while MyApp declares 16.4 — target selection (and the +// minimum-over-targets rule) is observable. +const SECOND = 'BB0000000000000000000101'; +const TWO = RAISED_TARGET.replace( + '/* End PBXNativeTarget section */', + `\t\t${SECOND} /* Second */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000601 /* project configs */; + name = Second; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */`, +); + +describe('sanitizeIosDeploymentTarget', () => { + it.each([ + ['16', '16.0'], + ['16.4', '16.4'], + ['14.0', MIN_IOS_VERSION_SUPPORTED], + ['$(FOO)', MIN_IOS_VERSION_SUPPORTED], + ['16.4; rm -rf /', MIN_IOS_VERSION_SUPPORTED], + ['', MIN_IOS_VERSION_SUPPORTED], + [null, MIN_IOS_VERSION_SUPPORTED], + [undefined, MIN_IOS_VERSION_SUPPORTED], + ])('normalizes and clamps %p to %p', (raw, expected) => { + expect(sanitizeIosDeploymentTarget(raw)).toBe(expected); + }); +}); + +const MIXED_CONFIGS = withSetting( + withSetting(FIXTURE, TARGET_DEBUG, '17.0'), + TARGET_RELEASE, + '16.4', +); +const PROJECT_16_0 = withProjectSetting(FIXTURE, '16.0'); +const PROJECT_BARE_16 = withProjectSetting(FIXTURE, '16'); +const PROJECT_14_0 = withProjectSetting(FIXTURE, '14.0'); +const PROJECT_VARIABLE = withProjectSetting(FIXTURE, '"$(SOME_VAR)"'); +const NOTHING_DECLARED = withoutProjectSetting(FIXTURE); +const GONE_UUID = 'CC0000000000000000000000'; + +describe('readIosDeploymentTargetFromPbxproj', () => { + it.each([ + ['project-level fallback', FIXTURE, {}, '15.1'], + ['target-level wins', RAISED_TARGET, {}, '16.4'], + ['minimum across configurations', MIXED_CONFIGS, {}, '16.4'], + ['raised project-level', PROJECT_16_0, {}, '16.0'], + ['bare major normalized', PROJECT_BARE_16, {}, '16.0'], + ['below the minimum is not clamped here', PROJECT_14_0, {}, '14.0'], + ['a variable reference is ignored', PROJECT_VARIABLE, {}, null], + ['nothing declared', NOTHING_DECLARED, {}, null], + ['minimum over every app target', TWO, {}, '15.1'], + ['named target', TWO, {targetName: 'MyApp'}, '16.4'], + ['unmatched targetName', TWO, {targetName: 'Nope'}, '15.1'], + ['uuid beats name', TWO, {targetUuid: SECOND, targetName: 'MyApp'}, '15.1'], + ['uuid that is not a target', TWO, {targetUuid: TARGET_DEBUG}, '15.1'], + ['uuid that is gone', TWO, {targetUuid: GONE_UUID}, '15.1'], + ])('%s', (_name, text, opts, expected) => { + expect(readIosDeploymentTargetFromPbxproj(text, opts)).toBe(expected); + }); +}); + +describe('resolveIosDeploymentTarget', () => { + const dirs = []; + + afterEach(() => { + for (const dir of dirs) { + fs.rmSync(dir, {recursive: true, force: true}); + } + dirs.length = 0; + }); + + // A throwaway .xcodeproj, so the fs path runs for real. + function write(text) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-iosdt-')); + dirs.push(dir); + const xcodeprojPath = path.join(dir, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), text, 'utf8'); + return xcodeprojPath; + } + + it('reads the app value, clamped to the React Native minimum', () => { + expect( + resolveIosDeploymentTarget({xcodeprojPath: write(RAISED_TARGET)}), + ).toBe('16.4'); + expect( + resolveIosDeploymentTarget({ + xcodeprojPath: write(withProjectSetting(FIXTURE, '14.0')), + }), + ).toBe(MIN_IOS_VERSION_SUPPORTED); + }); + + it('returns null when there is nothing usable to read', () => { + expect( + resolveIosDeploymentTarget({ + xcodeprojPath: write(withoutProjectSetting(FIXTURE)), + }), + ).toBeNull(); + expect(resolveIosDeploymentTarget({xcodeprojPath: null})).toBeNull(); + expect( + resolveIosDeploymentTarget({xcodeprojPath: '/no/such/App.xcodeproj'}), + ).toBeNull(); + }); + + it('matches min_ios_version_supported in helpers.rb', () => { + expect(MIN_IOS_VERSION_SUPPORTED).toBe('15.1'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index 83b34597c7fc..0b93ace096c9 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -562,6 +562,21 @@ describe('emitScaffoldedPackageSwift', () => { expect(out).toContain('// Cache slot: 0.87.0-nightly-20260513-abc/debug'); }); + it('floors the platform at the React Native minimum by default, in string form', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + expect(out).toContain('platforms: [.iOS("15.1")]'); + // The enum form cannot express a patch-level floor like 16.4. + expect(out).not.toContain('.v15'); + }); + + it('raises the platform floor to the app deployment target', () => { + const out = emitScaffoldedPackageSwift(baseSpec(), { + cacheSlotLabel: null, + iosDeploymentTarget: '16.4', + }); + expect(out).toContain('platforms: [.iOS("16.4")]'); + }); + it('emits DEBUG/NDEBUG config-gated cxxSettings so Fabric C++ matches the prebuilt React.framework ABI', () => { const out = emitScaffoldedPackageSwift(baseSpec()); expect(out).toContain('.define("DEBUG", .when(configuration: .debug))'); @@ -1527,6 +1542,10 @@ describe('SCAFFOLDER_VERSION', () => { expect(SCAFFOLDER_VERSION).toBeGreaterThanOrEqual(1); }); + it('is at the version the current emitter output requires', () => { + expect(SCAFFOLDER_VERSION).toBe(20); + }); + it('emitter writes the current version to the file', () => { const out = emitScaffoldedPackageSwift({ swiftName: 'foo', diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 362538af284c..19748f5c8ddd 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -18,6 +18,7 @@ const { generateAutolinkingConfigOrFailClosed, parseArgs, resolveAction, + resolveAppIosDeploymentTarget, resolveConfigCommandToPin, resolveExplicitConfigCommand, shouldAutoDeintegrate, @@ -650,3 +651,83 @@ describe('determineVersion', () => { ); }); }); + +describe('resolveAppIosDeploymentTarget', () => { + let appRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-setup-iosdt-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + function logged() { + return logSpy.mock.calls.map(call => call.join(' ')).join('\n'); + } + + // MyApp declares 16.4; Second has no configurations of its own and inherits + // the project's 15.1. No marker — the first `spm add`, where only + // --product-name identifies the target. + function mkTwoTargetProject() { + const dir = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(dir, {recursive: true}); + let pbxproj = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', + ); + for (const config of [ + 'AA0000000000000000000901 /*', + 'AA00000000000000000000A2 /*', + ]) { + const settingsAt = pbxproj.indexOf( + 'buildSettings = {', + pbxproj.indexOf(config), + ); + const lineEnd = pbxproj.indexOf('\n', settingsAt) + 1; + pbxproj = + pbxproj.slice(0, lineEnd) + + '\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 16.4;\n' + + pbxproj.slice(lineEnd); + } + fs.writeFileSync( + path.join(dir, 'project.pbxproj'), + pbxproj.replace( + '/* End PBXNativeTarget section */', + `\t\tBB0000000000000000000101 /* Second */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000601 /* project configs */; + name = Second; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */`, + ), + 'utf8', + ); + } + + it('reads the app target --product-name selects, not the lowest one', () => { + mkTwoTargetProject(); + expect(resolveAppIosDeploymentTarget({productName: 'MyApp'}, appRoot)).toBe( + '16.4', + ); + expect(logged()).toContain( + 'iOS deployment target: 16.4 (from MyApp.xcodeproj)', + ); + // Without a name no target is singled out, so the floor must hold for all. + expect(resolveAppIosDeploymentTarget({productName: null}, appRoot)).toBe( + '15.1', + ); + }); + + it('names the reason alongside the default when no project could be picked', () => { + expect(resolveAppIosDeploymentTarget({}, appRoot)).toBe('15.1'); + expect(logged()).toContain( + 'iOS deployment target: 15.1 (react-native default; no .xcodeproj found', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js index c917f1acf02c..b2e81d23e5d0 100644 --- a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js @@ -47,8 +47,12 @@ describe('sync-spm-autolinking main', () => { }; } + function baseArgv() { + return ['--app-root', appRoot, '--react-native-root', rnRoot]; + } + function run(deps) { - return main(['--app-root', appRoot, '--react-native-root', rnRoot], deps); + return main(baseArgv(), deps); } function stampPath() { @@ -92,6 +96,20 @@ describe('sync-spm-autolinking main', () => { expect(fs.existsSync(stampPath())).toBe(true); }); + it('forwards --ios-deployment-target to the autolinker only when given', async () => { + const withFlag = makeDeps(); + await main([...baseArgv(), '--ios-deployment-target', '16.4'], withFlag); + expect(withFlag.generateAutolinking).toHaveBeenCalledWith([ + ...baseArgv(), + '--ios-deployment-target', + '16.4', + ]); + + const withoutFlag = makeDeps(); + await run(withoutFlag); + expect(withoutFlag.generateAutolinking).toHaveBeenCalledWith(baseArgv()); + }); + it('continues with existing output when codegen fails', async () => { const deps = makeDeps({ runCodegenAndInstallTemplate: jest.fn(() => { diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index ecb91f8d64aa..3b41a6b56ad9 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -67,6 +67,10 @@ const { defaultResolveDep, expandSpmDependencies, } = require('./expand-spm-dependencies'); +const { + MIN_IOS_VERSION_SUPPORTED, + sanitizeIosDeploymentTarget, +} = require('./ios-deployment-target'); const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { AUTOLINKED_PACKAGE_NAME, @@ -210,6 +214,10 @@ function parseArgs(argv /*: Array */) /*: AutolinkingArgs */ { describe: 'Path to the xcframeworks sub-package (absolute or relative to appRoot)', }) + .option('ios-deployment-target', { + type: 'string', + describe: `Platform floor of the generated manifests (default: ${MIN_IOS_VERSION_SUPPORTED})`, + }) .usage( 'Usage: $0 [options]\n\nGenerates autolinked/Package.swift for SPM autolinking.', ) @@ -222,6 +230,9 @@ function parseArgs(argv /*: Array */) /*: AutolinkingArgs */ { autolinkingJson: parsed['autolinking-json'] ?? null, output: parsed.output ?? null, xcframeworksPath: parsed['xcframeworks-path'] ?? null, + iosDeploymentTarget: sanitizeIosDeploymentTarget( + parsed['ios-deployment-target'], + ), }; } @@ -941,6 +952,8 @@ function generateAutolinkedPackageSwift( input.pluginPackageDeps ?? []; const pluginProductDeps /*: ReadonlyArray */ = input.pluginProductDeps ?? []; + const iosDeploymentTarget /*: string */ = + input.iosDeploymentTarget ?? MIN_IOS_VERSION_SUPPORTED; // Package-level dependencies: one .package(path:) per autolinked dep, // plus ReactNative if any inline target needs to import React headers. @@ -1074,7 +1087,7 @@ import Foundation ${guardBlock}let package = Package( name: "${AUTOLINKED_PACKAGE_NAME}", - platforms: [.iOS(.v15)], + platforms: [.iOS("${iosDeploymentTarget}")], products: [ .library(name: "${AUTOLINKED_PACKAGE_NAME}", targets: ["AutolinkedAggregate"]), ], @@ -1124,6 +1137,8 @@ function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ { const targetPath /*: string */ = spec.targetPath ?? `Sources/${swiftName}`; const siblingSynthAbsolutePaths /*: {[string]: string} */ = spec.siblingSynthAbsolutePaths ?? {}; + const iosDeploymentTarget /*: string */ = + spec.iosDeploymentTarget ?? MIN_IOS_VERSION_SUPPORTED; // Package dependencies — ReactNative + each spm sibling synth package. // The React + codegen package paths are plain relative strings computed by @@ -1222,7 +1237,7 @@ import PackageDescription let package = Package( name: "${swiftName}", - platforms: [.iOS(.v15)], + platforms: [.iOS("${iosDeploymentTarget}")], products: [ .library(name: "${swiftName}"${isDynamic ? ', type: .dynamic' : ''}, targets: ["${swiftName}"]), ], @@ -1697,6 +1712,7 @@ function main(argv /*:: ?: Array */) /*: void */ { const synthContent = generateSynthPackageSwift({ swiftName: target.name, + iosDeploymentTarget: args.iosDeploymentTarget, exclude: prefixedExclude, sources: prefixedSources, // Stub include/ subdir lives in the wrapper dir; satisfies SPM's @@ -1871,6 +1887,7 @@ function main(argv /*:: ?: Array */) /*: void */ { // autolinked dep is a real SPM package in its own source dir. const aggregatorContent = generateAutolinkedPackageSwift({ npmDeps: aggregatorPackageDeps, + iosDeploymentTarget: args.iosDeploymentTarget, hasReactDep, xcframeworksRelPath, pluginPackageDeps, diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 0e86cff7fa53..287975c3a058 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -2181,7 +2181,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2718,6 +2718,7 @@ module.exports = { buildSchemePreActionScript, buildEmbedFrameworksScript, flavorForBuildConfiguration, + targetBuildConfigUuids, frameworkConditionalSettings, ensureStubPackages, buildSpmDependencyGraph, @@ -2732,6 +2733,7 @@ module.exports = { addPreActionToScheme, removePreActionFromScheme, findInjectedXcodeproj, + readMarker, readArtifactsVersionOverride, readPinnedConfigCommand, readScriptPhasesManifest, diff --git a/packages/react-native/scripts/spm/ios-deployment-target.js b/packages/react-native/scripts/spm/ios-deployment-target.js new file mode 100644 index 000000000000..f38aa18184f2 --- /dev/null +++ b/packages/react-native/scripts/spm/ios-deployment-target.js @@ -0,0 +1,197 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * The iOS platform floor of the SwiftPM manifests React Native generates. + * SwiftPM refuses to link a product whose minimum is above the depending + * target's, so the floor tracks the app's own deployment target rather than a + * hardcoded value a dependency (every Expo package: iOS 16.4) can exceed. + */ + +const {targetBuildConfigUuids} = require('./generate-spm-xcodeproj'); +const { + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, +} = require('./spm-pbxproj'); +const fs = require('node:fs'); +const path = require('node:path'); + +// Keep in sync with `min_ios_version_supported` in scripts/cocoapods/helpers.rb. +const MIN_IOS_VERSION_SUPPORTED /*: string */ = '15.1'; + +const IOS_VERSION_RE = /^\d+(\.\d+){0,2}$/; + +function normalizeIosVersion(version /*: string */) /*: string */ { + return version.includes('.') ? version : `${version}.0`; +} + +function segment( + parts /*: Array */, + index /*: number */, +) /*: number */ { + return index < parts.length ? Number(parts[index]) : 0; +} + +/** Numeric, not lexicographic: `9.0` < `10.0` and `16.4` < `16.10`. */ +function compareIosVersions(a /*: string */, b /*: string */) /*: number */ { + const left = a.split('.'); + const right = b.split('.'); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const delta = segment(left, i) - segment(right, i); + if (delta !== 0) { + return delta < 0 ? -1 : 1; + } + } + return 0; +} + +function unquote(value /*: string */) /*: string */ { + return value.replace(/^"|"$/g, ''); +} + +/** + * A version reduced to something safe to interpolate into a manifest: + * normalized, and never below React Native's own minimum. + */ +function sanitizeIosDeploymentTarget(raw /*: ?string */) /*: string */ { + if (raw == null || !IOS_VERSION_RE.test(raw)) { + return MIN_IOS_VERSION_SUPPORTED; + } + const version = normalizeIosVersion(raw); + return compareIosVersions(version, MIN_IOS_VERSION_SUPPORTED) > 0 + ? version + : MIN_IOS_VERSION_SUPPORTED; +} + +function configName( + text /*: string */, + configObj /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: ?string */ { + const field = findField(text, configObj, 'name'); + return field != null ? unquote(field.value) : null; +} + +/** `IPHONEOS_DEPLOYMENT_TARGET`, when the configuration sets a plain version. */ +function configDeploymentTarget( + text /*: string */, + configObj /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: ?string */ { + const settings = findField(text, configObj, 'buildSettings'); + if (settings == null) { + return null; + } + const field = findField( + text, + {bodyOpen: settings.valueStart, bodyClose: settings.tokenEnd - 1}, + 'IPHONEOS_DEPLOYMENT_TARGET', + ); + if (field == null) { + return null; + } + const raw = unquote(field.value); + return IOS_VERSION_RE.test(raw) ? normalizeIosVersion(raw) : null; +} + +/** + * The app's declared iOS deployment target, or null when nothing declares one. + * Target selection: the marker's `targetUuid`, else the app target named + * `targetName` (`--product-name`), else every app target. Each configuration + * falls back to the project-level one of the same name, and the lowest wins — + * a single manifest floor has to hold for every configuration. + */ +function readIosDeploymentTargetFromPbxproj( + text /*: string */, + opts /*:: ?: {targetUuid?: ?string, targetName?: ?string} */, +) /*: ?string */ { + const targetUuid = opts?.targetUuid; + const markedObj = + targetUuid != null ? findObjectByUuid(text, targetUuid) : null; + // A hand-edited marker can name a uuid that is not (or no longer) a target. + const marked = + markedObj != null && + findField(text, markedObj, 'isa')?.value === 'PBXNativeTarget' + ? markedObj + : null; + const apps = findApplicationTargets(text); + const named = + opts?.targetName != null + ? apps.find(app => app.name === opts.targetName) + : null; + const targets = marked != null ? [marked] : named != null ? [named] : apps; + + const projectDefaults /*: Map */ = new Map(); + const project = findProjectObject(text); + if (project != null) { + for (const uuid of targetBuildConfigUuids(text, project)) { + const config = findObjectByUuid(text, uuid); + const name = config != null ? configName(text, config) : null; + const value = + config != null ? configDeploymentTarget(text, config) : null; + if (name != null && value != null) { + projectDefaults.set(name, value); + } + } + } + + let floor /*: ?string */ = null; + for (const target of targets) { + for (const uuid of targetBuildConfigUuids(text, target)) { + const config = findObjectByUuid(text, uuid); + if (config == null) { + continue; + } + const name = configName(text, config); + const value = + configDeploymentTarget(text, config) ?? + (name != null ? projectDefaults.get(name) : null) ?? + null; + if ( + value != null && + (floor == null || compareIosVersions(value, floor) < 0) + ) { + floor = value; + } + } + } + return floor; +} + +/** + * The sanitized floor read from `/project.pbxproj`, or null when + * there is no project, it cannot be read, or it declares nothing usable. + */ +function resolveIosDeploymentTarget( + opts /*: {xcodeprojPath: ?string, targetUuid?: ?string, targetName?: ?string} */, +) /*: ?string */ { + const {xcodeprojPath, targetUuid, targetName} = opts; + if (xcodeprojPath == null) { + return null; + } + try { + const found = readIosDeploymentTargetFromPbxproj( + fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8'), + {targetUuid, targetName}, + ); + return found != null ? sanitizeIosDeploymentTarget(found) : null; + } catch { + return null; + } +} + +module.exports = { + MIN_IOS_VERSION_SUPPORTED, + readIosDeploymentTargetFromPbxproj, + resolveIosDeploymentTarget, + sanitizeIosDeploymentTarget, +}; diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index 5c0e3f8325af..47785d916844 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -44,6 +44,7 @@ const { resolveSwiftName, } = require('./expand-spm-dependencies'); const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); +const {MIN_IOS_VERSION_SUPPORTED} = require('./ios-deployment-target'); const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { REACT_CODEGEN_PACKAGE_NAME, @@ -107,7 +108,9 @@ const {log, warn} = makeLogger('scaffold-package-swift'); // v19: scaffolded C++ targets carry DEBUG/NDEBUG config defines so their Fabric // ABI matches the prebuilt React.framework (Release strips DebugStringConvertible // under NDEBUG). Bumped so existing scaffolds regenerate with the defines. -const SCAFFOLDER_VERSION = 19; +// v20: the platform floor is the app's iOS deployment target in string form +// (the `.v15` enum cannot express a dependency minimum like 16.4). +const SCAFFOLDER_VERSION = 20; const SCAFFOLDER_VERSION_LINE_RE = /^\/\/ AUTO-SCAFFOLDED-VERSION: (\d+)$/m; const AUTOGEN_MARKER = @@ -562,6 +565,7 @@ type EmitContext = { // Relative path to the app's local xcframeworks package // (/build/xcframeworks). Only referenced when remote == null. localXcfwPackageDir?: ?string, + iosDeploymentTarget?: ?string, }; */ @@ -578,6 +582,8 @@ function emitScaffoldedPackageSwift( ) /*: string */ { const slotComment = ctx.cacheSlotLabel != null ? `\n// Cache slot: ${ctx.cacheSlotLabel}` : ''; + const iosDeploymentTarget /*: string */ = + ctx.iosDeploymentTarget ?? MIN_IOS_VERSION_SUPPORTED; // React headers need NO search paths — they come from the React / // ReactNativeHeaders binaryTargets and the ReactAppHeaders product (see @@ -765,7 +771,7 @@ import PackageDescription let package = Package( name: "${spec.swiftName}", - platforms: [.iOS(.v15)], + platforms: [.iOS("${iosDeploymentTarget}")], products: [ .library(name: "${spec.swiftName}", targets: ["${spec.swiftName}"]), ], @@ -863,6 +869,7 @@ type ScaffoldContext = { // references honor each sibling's declared name. swiftNameByNpm?: Map, remote: ?{url: string, version: string, identity: string}, + iosDeploymentTarget?: ?string, }; */ @@ -1062,6 +1069,7 @@ function scaffoldPackageSwiftForDep( const content = emitScaffoldedPackageSwift(spec, { cacheSlotLabel: ctx.cacheSlotLabel, remote: ctx.remote, + iosDeploymentTarget: ctx.iosDeploymentTarget, codegenPackageDir: relFromManifest('build', 'generated', 'ios'), localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), }); @@ -1136,6 +1144,7 @@ type ScaffoldAllOptions = { dryRun?: boolean, cacheSlotLabel?: ?string, autolinkingJsonPath?: string, + iosDeploymentTarget?: ?string, // npm dep names to skip entirely — used when the user declined the // confirmation prompt for first-time scaffolds. Skipped deps still // appear in the returned results array with status='skipped-opt-out'. @@ -1271,6 +1280,7 @@ function scaffoldAll( force: opts.force === true, dryRun: opts.dryRun === true, cacheSlotLabel: opts.cacheSlotLabel ?? null, + iosDeploymentTarget: opts.iosDeploymentTarget ?? null, podToNpm, swiftNameByNpm, remote, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index dcda8d3addd7..a89142f6ada0 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -67,6 +67,8 @@ export type AutolinkingArgs = { autolinkingJson: string | null, output: string | null, xcframeworksPath: string | null, + // Platform floor of every manifest this run writes; sanitized by parseArgs. + iosDeploymentTarget: string, }; export type SpmTarget = { @@ -206,6 +208,7 @@ export type AggregatorInput = { // the aggregator's package deps + the AutolinkedAggregate target deps. pluginPackageDeps?: ReadonlyArray, pluginProductDeps?: ReadonlyArray, + iosDeploymentTarget?: ?string, }; // --- Autolinking plugins (PREVIEW / unstable contract) --- @@ -377,6 +380,7 @@ export type SynthPackageSpec = { // `` resolve through the // dep's own `common/cpp/` subtree. headerSearchPaths?: ?Array, + iosDeploymentTarget?: ?string, }; diff --git a/packages/react-native/scripts/spm/sync-spm-autolinking.js b/packages/react-native/scripts/spm/sync-spm-autolinking.js index b37a7e89da75..78e1fcbd43a0 100644 --- a/packages/react-native/scripts/spm/sync-spm-autolinking.js +++ b/packages/react-native/scripts/spm/sync-spm-autolinking.js @@ -82,6 +82,10 @@ async function main( demandOption: true, describe: 'Path to react-native package root', }) + .option('ios-deployment-target', { + type: 'string', + describe: 'Platform floor forwarded to the autolinker', + }) .help() .parseSync(); @@ -115,12 +119,17 @@ async function main( deps.installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); log('Re-generating build/generated/autolinking/Package.swift...'); - deps.generateAutolinking([ + const autolinkingArgv = [ '--app-root', appRoot, '--react-native-root', reactNativeRoot, - ]); + ]; + const iosDeploymentTarget = parsed['ios-deployment-target']; + if (iosDeploymentTarget != null) { + autolinkingArgv.push('--ios-deployment-target', iosDeploymentTarget); + } + deps.generateAutolinking(autolinkingArgv); // Rebuild the per-app generated-headers farm (vended as the ReactAppHeaders // SPM target inside the codegen package). React core headers need no trees From cb7b00aee3f1ed06ae93c98bd6fe9737050432f0 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 9 Sep 2026 09:57:58 +0200 Subject: [PATCH 2/5] [iOS][SPM] Trim redundant platform-floor tests Drop table rows and assertions that exercised the same branch twice or pinned internal layering, share the pbxproj fixture builders between the two suites that need a two-target project, and turn the helpers.rb parity test into a real read of min_ios_version_supported. Two matching code simplifications: the reader no longer normalizes (the comparison is numeric and the resolver sanitizes), and the marker uuid is used without an `isa` guard (a stale uuid still degrades to the default). Co-Authored-By: Claude Fable 5.1 --- .../generate-spm-autolinking-test.js | 2 - .../__tests__/ios-deployment-target-test.js | 125 +++++++----------- .../scripts/spm/__tests__/pbxproj-variants.js | 89 +++++++++++++ .../__tests__/scaffold-package-swift-test.js | 2 - .../spm/__tests__/setup-apple-spm-test.js | 39 +----- .../scripts/spm/ios-deployment-target.js | 11 +- 6 files changed, 140 insertions(+), 128 deletions(-) create mode 100644 packages/react-native/scripts/spm/__tests__/pbxproj-variants.js diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index 6c77c271a713..27fb060fee06 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -477,7 +477,6 @@ describe('iOS platform floor', () => { expect(generateAutolinkedPackageSwift({})).toContain( 'platforms: [.iOS("15.1")]', ); - expect(generateAutolinkedPackageSwift({})).not.toContain('.v15'); expect( generateAutolinkedPackageSwift({iosDeploymentTarget: '16.4'}), ).toContain('platforms: [.iOS("16.4")]'); @@ -488,7 +487,6 @@ describe('iOS platform floor', () => { expect(generateSynthPackageSwift(spec)).toContain( 'platforms: [.iOS("15.1")]', ); - expect(generateSynthPackageSwift(spec)).not.toContain('.v15'); expect( generateSynthPackageSwift({...spec, iosDeploymentTarget: '16.4'}), ).toContain('platforms: [.iOS("16.4")]'); diff --git a/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js b/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js index 254b164608e7..09ef588f3cd6 100644 --- a/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js +++ b/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js @@ -16,62 +16,31 @@ const { resolveIosDeploymentTarget, sanitizeIosDeploymentTarget, } = require('../ios-deployment-target'); +const { + PLAIN_APP, + SECOND_TARGET, + TARGET_DEBUG, + TARGET_RELEASE, + raisedTarget, + twoAppTargets, + withoutProjectSetting, + withProjectSetting, + withSetting, +} = require('./pbxproj-variants'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const FIXTURE = fs.readFileSync( - path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), - 'utf8', -); - -// Fixture anchors (see __fixtures__/plain-app.pbxproj): one app target whose -// own Debug/Release configs carry no IPHONEOS_DEPLOYMENT_TARGET, and two -// project-level configs that do (15.1). -const TARGET_DEBUG = 'AA0000000000000000000901'; -const TARGET_RELEASE = 'AA00000000000000000000A2'; - -// Insert IPHONEOS_DEPLOYMENT_TARGET into one XCBuildConfiguration. -function withSetting(text, configUuid, value) { - const at = text.indexOf(configUuid + ' /*'); - const lineEnd = text.indexOf('\n', text.indexOf('buildSettings = {', at)) + 1; - return ( - text.slice(0, lineEnd) + - `\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = ${value};\n` + - text.slice(lineEnd) - ); -} - -function withProjectSetting(text, value) { - return text.replaceAll( - 'IPHONEOS_DEPLOYMENT_TARGET = 15.1;', - `IPHONEOS_DEPLOYMENT_TARGET = ${value};`, - ); -} - -function withoutProjectSetting(text) { - return text.replaceAll('\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.1;\n', ''); -} - -const RAISED_TARGET = withSetting( - withSetting(FIXTURE, TARGET_DEBUG, '16.4'), +const RAISED = raisedTarget('16.4'); +const TWO = twoAppTargets('16.4'); +const MIXED_CONFIGS = withSetting( + withSetting(PLAIN_APP, TARGET_DEBUG, '17.0'), TARGET_RELEASE, '16.4', ); -// A second application target with no configurations of its own, so it inherits -// the project's 15.1 while MyApp declares 16.4 — target selection (and the -// minimum-over-targets rule) is observable. -const SECOND = 'BB0000000000000000000101'; -const TWO = RAISED_TARGET.replace( - '/* End PBXNativeTarget section */', - `\t\t${SECOND} /* Second */ = { - isa = PBXNativeTarget; - buildConfigurationList = AA0000000000000000000601 /* project configs */; - name = Second; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */`, -); +const VARIABLE_VALUE = withProjectSetting(PLAIN_APP, '"$(SOME_VAR)"'); +const NOTHING_DECLARED = withoutProjectSetting(PLAIN_APP); +const GONE_UUID = 'CC0000000000000000000000'; describe('sanitizeIosDeploymentTarget', () => { it.each([ @@ -79,42 +48,28 @@ describe('sanitizeIosDeploymentTarget', () => { ['16.4', '16.4'], ['14.0', MIN_IOS_VERSION_SUPPORTED], ['$(FOO)', MIN_IOS_VERSION_SUPPORTED], - ['16.4; rm -rf /', MIN_IOS_VERSION_SUPPORTED], - ['', MIN_IOS_VERSION_SUPPORTED], [null, MIN_IOS_VERSION_SUPPORTED], - [undefined, MIN_IOS_VERSION_SUPPORTED], ])('normalizes and clamps %p to %p', (raw, expected) => { expect(sanitizeIosDeploymentTarget(raw)).toBe(expected); }); }); -const MIXED_CONFIGS = withSetting( - withSetting(FIXTURE, TARGET_DEBUG, '17.0'), - TARGET_RELEASE, - '16.4', -); -const PROJECT_16_0 = withProjectSetting(FIXTURE, '16.0'); -const PROJECT_BARE_16 = withProjectSetting(FIXTURE, '16'); -const PROJECT_14_0 = withProjectSetting(FIXTURE, '14.0'); -const PROJECT_VARIABLE = withProjectSetting(FIXTURE, '"$(SOME_VAR)"'); -const NOTHING_DECLARED = withoutProjectSetting(FIXTURE); -const GONE_UUID = 'CC0000000000000000000000'; - describe('readIosDeploymentTargetFromPbxproj', () => { it.each([ - ['project-level fallback', FIXTURE, {}, '15.1'], - ['target-level wins', RAISED_TARGET, {}, '16.4'], + ['project-level fallback', PLAIN_APP, {}, '15.1'], + ['target-level wins', RAISED, {}, '16.4'], ['minimum across configurations', MIXED_CONFIGS, {}, '16.4'], - ['raised project-level', PROJECT_16_0, {}, '16.0'], - ['bare major normalized', PROJECT_BARE_16, {}, '16.0'], - ['below the minimum is not clamped here', PROJECT_14_0, {}, '14.0'], - ['a variable reference is ignored', PROJECT_VARIABLE, {}, null], + ['a variable reference is ignored', VARIABLE_VALUE, {}, null], ['nothing declared', NOTHING_DECLARED, {}, null], ['minimum over every app target', TWO, {}, '15.1'], ['named target', TWO, {targetName: 'MyApp'}, '16.4'], ['unmatched targetName', TWO, {targetName: 'Nope'}, '15.1'], - ['uuid beats name', TWO, {targetUuid: SECOND, targetName: 'MyApp'}, '15.1'], - ['uuid that is not a target', TWO, {targetUuid: TARGET_DEBUG}, '15.1'], + [ + 'uuid beats name', + TWO, + {targetUuid: SECOND_TARGET, targetName: 'MyApp'}, + '15.1', + ], ['uuid that is gone', TWO, {targetUuid: GONE_UUID}, '15.1'], ])('%s', (_name, text, opts, expected) => { expect(readIosDeploymentTargetFromPbxproj(text, opts)).toBe(expected); @@ -142,21 +97,19 @@ describe('resolveIosDeploymentTarget', () => { } it('reads the app value, clamped to the React Native minimum', () => { - expect( - resolveIosDeploymentTarget({xcodeprojPath: write(RAISED_TARGET)}), - ).toBe('16.4'); + expect(resolveIosDeploymentTarget({xcodeprojPath: write(RAISED)})).toBe( + '16.4', + ); expect( resolveIosDeploymentTarget({ - xcodeprojPath: write(withProjectSetting(FIXTURE, '14.0')), + xcodeprojPath: write(withProjectSetting(PLAIN_APP, '14.0')), }), ).toBe(MIN_IOS_VERSION_SUPPORTED); }); it('returns null when there is nothing usable to read', () => { expect( - resolveIosDeploymentTarget({ - xcodeprojPath: write(withoutProjectSetting(FIXTURE)), - }), + resolveIosDeploymentTarget({xcodeprojPath: write(NOTHING_DECLARED)}), ).toBeNull(); expect(resolveIosDeploymentTarget({xcodeprojPath: null})).toBeNull(); expect( @@ -165,6 +118,18 @@ describe('resolveIosDeploymentTarget', () => { }); it('matches min_ios_version_supported in helpers.rb', () => { - expect(MIN_IOS_VERSION_SUPPORTED).toBe('15.1'); + const helpers = fs.readFileSync( + path.join(__dirname, '..', '..', 'cocoapods', 'helpers.rb'), + 'utf8', + ); + const match = helpers.match( + /def self\.min_ios_version_supported\s+return\s+'([\d.]+)'/, + ); + if (match == null) { + throw new Error( + 'could not read min_ios_version_supported from scripts/cocoapods/helpers.rb', + ); + } + expect(MIN_IOS_VERSION_SUPPORTED).toBe(match[1]); }); }); diff --git a/packages/react-native/scripts/spm/__tests__/pbxproj-variants.js b/packages/react-native/scripts/spm/__tests__/pbxproj-variants.js new file mode 100644 index 000000000000..30a833fd60ff --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/pbxproj-variants.js @@ -0,0 +1,89 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// Variants of the plain-app pbxproj fixture, shared by the tests that exercise +// iOS-deployment-target reading. The base fixture has one app target whose own +// Debug/Release configs carry no IPHONEOS_DEPLOYMENT_TARGET, and two +// project-level configs that set 15.1. + +const fs = require('node:fs'); +const path = require('node:path'); + +const PLAIN_APP = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +const TARGET_DEBUG = 'AA0000000000000000000901'; +const TARGET_RELEASE = 'AA00000000000000000000A2'; +const SECOND_TARGET = 'BB0000000000000000000101'; + +/** Insert IPHONEOS_DEPLOYMENT_TARGET into one XCBuildConfiguration. */ +function withSetting(text, configUuid, value) { + const at = text.indexOf(configUuid + ' /*'); + const lineEnd = text.indexOf('\n', text.indexOf('buildSettings = {', at)) + 1; + return ( + text.slice(0, lineEnd) + + `\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = ${value};\n` + + text.slice(lineEnd) + ); +} + +function withProjectSetting(text, value) { + return text.replaceAll( + 'IPHONEOS_DEPLOYMENT_TARGET = 15.1;', + `IPHONEOS_DEPLOYMENT_TARGET = ${value};`, + ); +} + +function withoutProjectSetting(text) { + return text.replaceAll('\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.1;\n', ''); +} + +/** The single app target declaring `version` in both its configurations. */ +function raisedTarget(version) { + return withSetting( + withSetting(PLAIN_APP, TARGET_DEBUG, version), + TARGET_RELEASE, + version, + ); +} + +/** + * Two app targets: MyApp declares `version`, while Second has no configurations + * of its own and inherits the project's 15.1 — so target selection and the + * minimum-over-targets rule are both observable. + */ +function twoAppTargets(version) { + return raisedTarget(version).replace( + '/* End PBXNativeTarget section */', + `\t\t${SECOND_TARGET} /* Second */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000601 /* project configs */; + name = Second; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */`, + ); +} + +module.exports = { + PLAIN_APP, + SECOND_TARGET, + TARGET_DEBUG, + TARGET_RELEASE, + raisedTarget, + twoAppTargets, + withProjectSetting, + withSetting, + withoutProjectSetting, +}; diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index 0b93ace096c9..33f724210b39 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -565,8 +565,6 @@ describe('emitScaffoldedPackageSwift', () => { it('floors the platform at the React Native minimum by default, in string form', () => { const out = emitScaffoldedPackageSwift(baseSpec()); expect(out).toContain('platforms: [.iOS("15.1")]'); - // The enum form cannot express a patch-level floor like 16.4. - expect(out).not.toContain('.v15'); }); it('raises the platform floor to the app deployment target', () => { diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 19748f5c8ddd..6e6278e4c1a0 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -25,6 +25,7 @@ const { } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); +const {twoAppTargets} = require('./pbxproj-variants'); const {execFileSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); @@ -670,42 +671,14 @@ describe('resolveAppIosDeploymentTarget', () => { return logSpy.mock.calls.map(call => call.join(' ')).join('\n'); } - // MyApp declares 16.4; Second has no configurations of its own and inherits - // the project's 15.1. No marker — the first `spm add`, where only - // --product-name identifies the target. + // No marker — the first `spm add`, where only --product-name identifies the + // target among the fixture's two app targets. function mkTwoTargetProject() { const dir = path.join(appRoot, 'MyApp.xcodeproj'); fs.mkdirSync(dir, {recursive: true}); - let pbxproj = fs.readFileSync( - path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), - 'utf8', - ); - for (const config of [ - 'AA0000000000000000000901 /*', - 'AA00000000000000000000A2 /*', - ]) { - const settingsAt = pbxproj.indexOf( - 'buildSettings = {', - pbxproj.indexOf(config), - ); - const lineEnd = pbxproj.indexOf('\n', settingsAt) + 1; - pbxproj = - pbxproj.slice(0, lineEnd) + - '\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 16.4;\n' + - pbxproj.slice(lineEnd); - } fs.writeFileSync( path.join(dir, 'project.pbxproj'), - pbxproj.replace( - '/* End PBXNativeTarget section */', - `\t\tBB0000000000000000000101 /* Second */ = { - isa = PBXNativeTarget; - buildConfigurationList = AA0000000000000000000601 /* project configs */; - name = Second; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */`, - ), + twoAppTargets('16.4'), 'utf8', ); } @@ -718,10 +691,6 @@ describe('resolveAppIosDeploymentTarget', () => { expect(logged()).toContain( 'iOS deployment target: 16.4 (from MyApp.xcodeproj)', ); - // Without a name no target is singled out, so the floor must hold for all. - expect(resolveAppIosDeploymentTarget({productName: null}, appRoot)).toBe( - '15.1', - ); }); it('names the reason alongside the default when no project could be picked', () => { diff --git a/packages/react-native/scripts/spm/ios-deployment-target.js b/packages/react-native/scripts/spm/ios-deployment-target.js index f38aa18184f2..84758ee1772d 100644 --- a/packages/react-native/scripts/spm/ios-deployment-target.js +++ b/packages/react-native/scripts/spm/ios-deployment-target.js @@ -100,7 +100,7 @@ function configDeploymentTarget( return null; } const raw = unquote(field.value); - return IOS_VERSION_RE.test(raw) ? normalizeIosVersion(raw) : null; + return IOS_VERSION_RE.test(raw) ? raw : null; } /** @@ -115,14 +115,7 @@ function readIosDeploymentTargetFromPbxproj( opts /*:: ?: {targetUuid?: ?string, targetName?: ?string} */, ) /*: ?string */ { const targetUuid = opts?.targetUuid; - const markedObj = - targetUuid != null ? findObjectByUuid(text, targetUuid) : null; - // A hand-edited marker can name a uuid that is not (or no longer) a target. - const marked = - markedObj != null && - findField(text, markedObj, 'isa')?.value === 'PBXNativeTarget' - ? markedObj - : null; + const marked = targetUuid != null ? findObjectByUuid(text, targetUuid) : null; const apps = findApplicationTargets(text); const named = opts?.targetName != null From 6e097126d2128fc190af1b25f5ace1687d0cc485 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 9 Sep 2026 11:50:58 +0200 Subject: [PATCH 3/5] [iOS][SPM] Honor an xcconfig-provided IPHONEOS_DEPLOYMENT_TARGET A configuration without a literal deployment target now falls back to the xcconfig its baseConfigurationReference points at, per Xcode's precedence (target literal, target xcconfig, project literal, project xcconfig). `` references are anchored through the PBXGroup chain, `#include` lines are followed in place so a later assignment in the includer wins, and only the unconditional key with a plain version counts. A floor set through a build-setting variable is still not resolved and falls back to React Native's minimum. Co-Authored-By: Claude Fable 5.1 --- .../scripts/spm/__docs__/spm-scripts.md | 6 +- .../__tests__/ios-deployment-target-test.js | 133 +++++++++++ .../scripts/spm/__tests__/pbxproj-variants.js | 57 +++++ .../scripts/spm/ios-deployment-target.js | 206 +++++++++++++++++- .../react-native/scripts/spm/spm-pbxproj.js | 57 +++-- 5 files changed, 428 insertions(+), 31 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index e06dcd4426e2..2879e59ce408 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -225,7 +225,11 @@ Native's own minimum (15.1). SwiftPM refuses to link a product whose minimum is higher than the target depending on it, so a dependency that needs more (Expo's packages need iOS 16.4) only resolves once the app asks for at least as much: raise the deployment target in Xcode and re-run `react-native spm update`. -Scaffolded packages keep the floor they were scaffolded with until you re-run + +A floor set in an `.xcconfig` your configuration is based on is honored, +`#include` chains included; one set through a build-setting variable +(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. Scaffolded +packages keep the floor they were scaffolded with until you re-run `react-native spm scaffold`. ## Files the tool touches diff --git a/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js b/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js index 09ef588f3cd6..dab233c05107 100644 --- a/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js +++ b/packages/react-native/scripts/spm/__tests__/ios-deployment-target-test.js @@ -26,6 +26,7 @@ const { withoutProjectSetting, withProjectSetting, withSetting, + withXcconfigRef, } = require('./pbxproj-variants'); const fs = require('node:fs'); const os = require('node:os'); @@ -76,6 +77,121 @@ describe('readIosDeploymentTargetFromPbxproj', () => { }); }); +describe('readIosDeploymentTargetFromPbxproj — xcconfig chain', () => { + const SRC_ROOT = '/app'; + const APP_XCCONFIG = `${SRC_ROOT}/Config/App.xcconfig`; + const BASE_XCCONFIG = `${SRC_ROOT}/Config/Base.xcconfig`; + // Both target configurations point at the xcconfig, and the project level + // declares nothing — so the xcconfig alone decides the floor. + const XCCONFIG_ONLY = withXcconfigRef(withoutProjectSetting(PLAIN_APP), [ + TARGET_DEBUG, + TARGET_RELEASE, + ]); + + function read(text, files, opts) { + const readFile = jest.fn(absPath => files[absPath] ?? null); + const value = readIosDeploymentTargetFromPbxproj(text, { + srcRoot: SRC_ROOT, + readFile, + ...opts, + }); + return {value, readFile}; + } + + it('falls back to the xcconfig the configuration references', () => { + expect( + read(XCCONFIG_ONLY, { + [APP_XCCONFIG]: 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n', + }).value, + ).toBe('16.4'); + }); + + it('prefers a literal in the configuration over its xcconfig', () => { + const text = withSetting( + withSetting(XCCONFIG_ONLY, TARGET_DEBUG, '17.0'), + TARGET_RELEASE, + '17.0', + ); + expect( + read(text, {[APP_XCCONFIG]: 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n'}).value, + ).toBe('17.0'); + }); + + it('follows an #include chain', () => { + expect( + read(XCCONFIG_ONLY, { + [APP_XCCONFIG]: '#include "Base.xcconfig"\n', + [BASE_XCCONFIG]: 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n', + }).value, + ).toBe('16.4'); + }); + + it('lets an assignment after the #include win', () => { + expect( + read(XCCONFIG_ONLY, { + [APP_XCCONFIG]: + '#include? "Base.xcconfig"\nIPHONEOS_DEPLOYMENT_TARGET = 17.0;\n', + [BASE_XCCONFIG]: 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n', + }).value, + ).toBe('17.0'); + }); + + it('ignores comments and conditional assignments', () => { + expect( + read(XCCONFIG_ONLY, { + [APP_XCCONFIG]: + '// IPHONEOS_DEPLOYMENT_TARGET = 18.0\n' + + 'IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*] = 16.4\n', + }).value, + ).toBeNull(); + }); + + it('ignores a value that is not a plain version', () => { + expect( + read(XCCONFIG_ONLY, { + [APP_XCCONFIG]: 'IPHONEOS_DEPLOYMENT_TARGET = $(inherited)\n', + }).value, + ).toBeNull(); + }); + + it('resolves a "" reference through the group path', () => { + const text = withXcconfigRef( + withoutProjectSetting(PLAIN_APP), + [TARGET_DEBUG, TARGET_RELEASE], + {filePath: 'App.xcconfig', groupPath: 'Config'}, + ); + const {value, readFile} = read(text, { + [APP_XCCONFIG]: 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n', + }); + expect(value).toBe('16.4'); + expect(readFile).toHaveBeenCalledWith(APP_XCCONFIG); + }); + + it('reports nothing when the xcconfig is missing', () => { + expect(read(XCCONFIG_ONLY, {}).value).toBeNull(); + }); + + it('terminates on an #include cycle', () => { + expect( + read(XCCONFIG_ONLY, { + [APP_XCCONFIG]: + 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n#include "Base.xcconfig"\n', + [BASE_XCCONFIG]: '#include "App.xcconfig"\n', + }).value, + ).toBe('16.4'); + }); + + it('skips the xcconfig step without a srcRoot', () => { + const {value, readFile} = read( + XCCONFIG_ONLY, + {[APP_XCCONFIG]: 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n'}, + {srcRoot: null}, + ); + expect(value).toBeNull(); + expect(readFile).not.toHaveBeenCalled(); + }); +}); + describe('resolveIosDeploymentTarget', () => { const dirs = []; @@ -117,6 +233,23 @@ describe('resolveIosDeploymentTarget', () => { ).toBeNull(); }); + it('reads a floor that only an xcconfig on disk declares', () => { + const xcodeprojPath = write( + withXcconfigRef(withoutProjectSetting(PLAIN_APP), [ + TARGET_DEBUG, + TARGET_RELEASE, + ]), + ); + const configDir = path.join(path.dirname(xcodeprojPath), 'Config'); + fs.mkdirSync(configDir); + fs.writeFileSync( + path.join(configDir, 'App.xcconfig'), + 'IPHONEOS_DEPLOYMENT_TARGET = 16.4\n', + 'utf8', + ); + expect(resolveIosDeploymentTarget({xcodeprojPath})).toBe('16.4'); + }); + it('matches min_ios_version_supported in helpers.rb', () => { const helpers = fs.readFileSync( path.join(__dirname, '..', '..', 'cocoapods', 'helpers.rb'), diff --git a/packages/react-native/scripts/spm/__tests__/pbxproj-variants.js b/packages/react-native/scripts/spm/__tests__/pbxproj-variants.js index 30a833fd60ff..25b226285b56 100644 --- a/packages/react-native/scripts/spm/__tests__/pbxproj-variants.js +++ b/packages/react-native/scripts/spm/__tests__/pbxproj-variants.js @@ -76,6 +76,62 @@ function twoAppTargets(version) { ); } +const XCCONFIG_REF = 'DD0000000000000000000101'; +const XCCONFIG_GROUP = 'DD0000000000000000000201'; + +function insertIntoObject(text, uuid, line) { + const lineEnd = + text.indexOf('\n', text.indexOf('= {', text.indexOf(uuid + ' /*'))) + 1; + return text.slice(0, lineEnd) + `\t\t\t${line}\n` + text.slice(lineEnd); +} + +/** + * Point the given configurations at one `.xcconfig` file reference. + * `sourceTree` defaults to SOURCE_ROOT; pass `groupPath` to place the + * reference in a `""` PBXGroup carrying that path instead. + */ +function withXcconfigRef(text, configUuids, opts = {}) { + const filePath = opts.filePath ?? 'Config/App.xcconfig'; + const groupPath = opts.groupPath ?? null; + const sourceTree = + groupPath != null ? '""' : (opts.sourceTree ?? 'SOURCE_ROOT'); + const name = path.basename(filePath); + + let out = text; + for (const configUuid of configUuids) { + out = insertIntoObject( + out, + configUuid, + `baseConfigurationReference = ${XCCONFIG_REF} /* ${name} */;`, + ); + } + out = out.replace( + '/* End PBXFileReference section */', + `\t\t${XCCONFIG_REF} /* ${name} */ = { + isa = PBXFileReference; + lastKnownFileType = text.xcconfig; + path = ${filePath}; + sourceTree = ${sourceTree}; + }; +/* End PBXFileReference section */`, + ); + if (groupPath != null) { + out = out.replace( + '/* End PBXGroup section */', + `\t\t${XCCONFIG_GROUP} /* ${groupPath} */ = { + isa = PBXGroup; + children = ( + ${XCCONFIG_REF} /* ${name} */, + ); + path = ${groupPath}; + sourceTree = ""; + }; +/* End PBXGroup section */`, + ); + } + return out; +} + module.exports = { PLAIN_APP, SECOND_TARGET, @@ -85,5 +141,6 @@ module.exports = { twoAppTargets, withProjectSetting, withSetting, + withXcconfigRef, withoutProjectSetting, }; diff --git a/packages/react-native/scripts/spm/ios-deployment-target.js b/packages/react-native/scripts/spm/ios-deployment-target.js index 84758ee1772d..b6239b01925e 100644 --- a/packages/react-native/scripts/spm/ios-deployment-target.js +++ b/packages/react-native/scripts/spm/ios-deployment-target.js @@ -23,6 +23,8 @@ const { findField, findObjectByUuid, findProjectObject, + forEachObjectInSection, + uuidsInArray, } = require('./spm-pbxproj'); const fs = require('node:fs'); const path = require('node:path'); @@ -32,6 +34,17 @@ const MIN_IOS_VERSION_SUPPORTED /*: string */ = '15.1'; const IOS_VERSION_RE = /^\d+(\.\d+){0,2}$/; +const DEPLOYMENT_TARGET_KEY = 'IPHONEOS_DEPLOYMENT_TARGET'; + +// An xcconfig may `#include` another; cap the chain instead of trusting it. +const MAX_XCCONFIG_DEPTH = 16; + +/*:: +// Everything the reader needs to follow a configuration's xcconfig. Without a +// srcRoot the xcconfig step is skipped (a caller with only pbxproj text). +type XcconfigContext = {srcRoot: ?string, readFile: (absPath: string) => ?string}; +*/ + function normalizeIosVersion(version /*: string */) /*: string */ { return version.includes('.') ? version : `${version}.0`; } @@ -74,6 +87,149 @@ function sanitizeIosDeploymentTarget(raw /*: ?string */) /*: string */ { : MIN_IOS_VERSION_SUPPORTED; } +function defaultReadFile(absPath /*: string */) /*: ?string */ { + try { + return fs.readFileSync(absPath, 'utf8'); + } catch { + return null; + } +} + +/** + * `IPHONEOS_DEPLOYMENT_TARGET` as an xcconfig defines it, following `#include` + * lines in place so a later assignment in the includer wins. Null when the file + * is unreadable, sets no unconditional value, or sets one that is not a plain + * version (`$(inherited)`). + */ +function parseXcconfigSetting( + absPath /*: string */, + content /*: string */, + readFile /*: (absPath: string) => ?string */, + visited /*: Set */ = new Set(), + depth /*: number */ = 0, +) /*: ?string */ { + if (visited.has(absPath)) { + return null; + } + visited.add(absPath); + const assignment = new RegExp(`^${DEPLOYMENT_TARGET_KEY}\\s*=\\s*([^;]*);?$`); + let value /*: ?string */ = null; + for (const rawLine of content.split('\n')) { + const line = rawLine.replace(/\/\/.*$/, '').trim(); + const include = line.match(/^#include\??\s+"([^"]+)"/); + if (include != null) { + const included = readXcconfigSetting( + path.resolve(path.dirname(absPath), include[1]), + readFile, + visited, + depth + 1, + ); + if (included != null) { + value = included; + } + continue; + } + const match = line.match(assignment); + if (match != null) { + const raw = unquote(match[1].trim()); + value = IOS_VERSION_RE.test(raw) ? raw : null; + } + } + return value; +} + +/** parseXcconfigSetting for a path not read yet — the `#include` entry point. */ +function readXcconfigSetting( + absPath /*: string */, + readFile /*: (absPath: string) => ?string */, + visited /*: Set */, + depth /*: number */, +) /*: ?string */ { + if (depth > MAX_XCCONFIG_DEPTH) { + return null; + } + const content = readFile(absPath); + return content != null + ? parseXcconfigSetting(absPath, content, readFile, visited, depth) + : null; +} + +/** + * Directory components of the PBXGroup chain holding `uuid`, outermost first — + * how a `""` file reference's path is anchored to the project dir. + */ +function groupPathPrefix( + text /*: string */, + uuid /*: string */, +) /*: Array */ { + const groups /*: Array<{uuid: string, path: ?string, children: Set}> */ = + []; + forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { + const children = findField(text, body, 'children'); + const groupPath = findField(text, body, 'path'); + groups.push({ + uuid: groupUuid, + path: groupPath != null ? unquote(groupPath.value) : null, + children: children != null ? uuidsInArray(children.value) : new Set(), + }); + }); + + const parts /*: Array */ = []; + let current = uuid; + for (let i = 0; i < groups.length; i++) { + const parent = groups.find(group => group.children.has(current)); + if (parent == null) { + break; + } + if (parent.path != null) { + parts.unshift(parent.path); + } + current = parent.uuid; + } + return parts; +} + +/** + * Absolute paths to try for the xcconfig a configuration is based on, in order: + * the reference's own anchoring first, then plain `/`. + */ +function xcconfigCandidates( + text /*: string */, + configObj /*: {bodyOpen: number, bodyClose: number, ...} */, + srcRoot /*: string */, +) /*: Array */ { + const base = findField(text, configObj, 'baseConfigurationReference'); + const refUuid = base?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; + if (refUuid == null) { + return []; + } + const ref = findObjectByUuid(text, refUuid); + if (ref == null) { + return []; + } + const pathField = findField(text, ref, 'path'); + if (pathField == null) { + return []; + } + const refPath = unquote(pathField.value); + if (path.isAbsolute(refPath)) { + return [refPath]; + } + const sourceTreeField = findField(text, ref, 'sourceTree'); + const sourceTree = + sourceTreeField != null ? unquote(sourceTreeField.value) : ''; + const fallback = path.join(srcRoot, refPath); + if (sourceTree !== '') { + return [fallback]; + } + const anchored = path.join( + srcRoot, + ...groupPathPrefix(text, refUuid), + refPath, + ); + return anchored === fallback ? [fallback] : [anchored, fallback]; +} + function configName( text /*: string */, configObj /*: {bodyOpen: number, bodyClose: number, ...} */, @@ -82,8 +238,8 @@ function configName( return field != null ? unquote(field.value) : null; } -/** `IPHONEOS_DEPLOYMENT_TARGET`, when the configuration sets a plain version. */ -function configDeploymentTarget( +/** A plain-version `IPHONEOS_DEPLOYMENT_TARGET` literal in a configuration. */ +function configLiteral( text /*: string */, configObj /*: {bodyOpen: number, bodyClose: number, ...} */, ) /*: ?string */ { @@ -94,7 +250,7 @@ function configDeploymentTarget( const field = findField( text, {bodyOpen: settings.valueStart, bodyClose: settings.tokenEnd - 1}, - 'IPHONEOS_DEPLOYMENT_TARGET', + DEPLOYMENT_TARGET_KEY, ); if (field == null) { return null; @@ -103,17 +259,49 @@ function configDeploymentTarget( return IOS_VERSION_RE.test(raw) ? raw : null; } +/** + * What one configuration declares: its own literal, else the xcconfig it is + * based on (Xcode's own precedence). + */ +function configDeploymentTarget( + text /*: string */, + configObj /*: {bodyOpen: number, bodyClose: number, ...} */, + ctx /*: XcconfigContext */, +) /*: ?string */ { + const literal = configLiteral(text, configObj); + if (literal != null || ctx.srcRoot == null) { + return literal; + } + for (const candidate of xcconfigCandidates(text, configObj, ctx.srcRoot)) { + const content = ctx.readFile(candidate); + if (content != null) { + return parseXcconfigSetting(candidate, content, ctx.readFile); + } + } + return null; +} + /** * The app's declared iOS deployment target, or null when nothing declares one. * Target selection: the marker's `targetUuid`, else the app target named * `targetName` (`--product-name`), else every app target. Each configuration - * falls back to the project-level one of the same name, and the lowest wins — - * a single manifest floor has to hold for every configuration. + * falls back to the project-level one of the same name (each level accepting a + * literal or the xcconfig it is based on), and the lowest wins — a single + * manifest floor has to hold for every configuration. */ function readIosDeploymentTargetFromPbxproj( text /*: string */, - opts /*:: ?: {targetUuid?: ?string, targetName?: ?string} */, + opts /*:: ?: { + targetUuid?: ?string, + targetName?: ?string, + srcRoot?: ?string, + readFile?: (absPath: string) => ?string, + } */, ) /*: ?string */ { + const ctx /*: XcconfigContext */ = { + srcRoot: opts?.srcRoot, + readFile: opts?.readFile ?? defaultReadFile, + }; const targetUuid = opts?.targetUuid; const marked = targetUuid != null ? findObjectByUuid(text, targetUuid) : null; const apps = findApplicationTargets(text); @@ -130,7 +318,7 @@ function readIosDeploymentTargetFromPbxproj( const config = findObjectByUuid(text, uuid); const name = config != null ? configName(text, config) : null; const value = - config != null ? configDeploymentTarget(text, config) : null; + config != null ? configDeploymentTarget(text, config, ctx) : null; if (name != null && value != null) { projectDefaults.set(name, value); } @@ -146,7 +334,7 @@ function readIosDeploymentTargetFromPbxproj( } const name = configName(text, config); const value = - configDeploymentTarget(text, config) ?? + configDeploymentTarget(text, config, ctx) ?? (name != null ? projectDefaults.get(name) : null) ?? null; if ( @@ -174,7 +362,7 @@ function resolveIosDeploymentTarget( try { const found = readIosDeploymentTargetFromPbxproj( fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8'), - {targetUuid, targetName}, + {targetUuid, targetName, srcRoot: path.dirname(xcodeprojPath)}, ); return found != null ? sanitizeIosDeploymentTarget(found) : null; } catch { diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js index c116ff38e13f..9cd40df867a1 100644 --- a/packages/react-native/scripts/spm/spm-pbxproj.js +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -267,19 +267,16 @@ function findProjectObject(text /*: string */) /*: ObjectRange | null */ { return findObjectByUuid(text, m[1]); } -/** - * Every PBXNativeTarget whose productType is an application. Returns uuid + - * name + body range for each. Used to pick the app target to inject into - * (and to refuse on ambiguity). - */ -function findApplicationTargets( +/** Visit every top-level object of a section, in file order. */ +function forEachObjectInSection( text /*: string */, -) /*: Array<{uuid: string, name: string, bodyOpen: number, bodyClose: number}> */ { - const section = findSection(text, 'PBXNativeTarget'); + name /*: string */, + fn /*: (obj: {uuid: string, comment: ?string, bodyOpen: number, bodyClose: number}) => void */, +) /*: void */ { + const section = findSection(text, name); if (section == null) { - return []; + return; } - const out = []; const re = /\n\t\t([0-9A-Fa-f]{24})(?: \/\* (.*?) \*\/)? = \{/g; re.lastIndex = section.contentStart; for (;;) { @@ -287,25 +284,42 @@ function findApplicationTargets( if (m == null || m.index >= section.end) { break; } - const uuid = m[1]; - const comment = m[2]; const bodyOpen = text.indexOf('{', m.index); const bodyClose = scanToClose(text, bodyOpen); - const obj = {uuid, bodyOpen, bodyClose}; - const productType = findField(text, obj, 'productType'); - if ( - productType != null && - /com\.apple\.product-type\.application/.test(productType.value) - ) { + fn({uuid: m[1], comment: m[2], bodyOpen, bodyClose}); + re.lastIndex = bodyClose; + } +} + +/** + * Every PBXNativeTarget whose productType is an application. Returns uuid + + * name + body range for each. Used to pick the app target to inject into + * (and to refuse on ambiguity). + */ +function findApplicationTargets( + text /*: string */, +) /*: Array<{uuid: string, name: string, bodyOpen: number, bodyClose: number}> */ { + const out = []; + forEachObjectInSection( + text, + 'PBXNativeTarget', + ({uuid, comment, bodyOpen, bodyClose}) => { + const obj = {uuid, bodyOpen, bodyClose}; + const productType = findField(text, obj, 'productType'); + if ( + productType == null || + !/com\.apple\.product-type\.application/.test(productType.value) + ) { + return; + } const nameField = findField(text, obj, 'name'); const name = nameField != null ? nameField.value.replace(/^"|"$/g, '') : (comment ?? uuid); out.push({uuid, name, bodyOpen, bodyClose}); - } - re.lastIndex = bodyClose; - } + }, + ); return out; } @@ -766,6 +780,7 @@ module.exports = { findSection, findProjectObject, findApplicationTargets, + forEachObjectInSection, uuidsInArray, detectFieldIndent, insertObjectsIntoSection, From eace13d4ca8cd1f35dd04ac55fd819953134cfa3 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 9 Sep 2026 11:50:58 +0200 Subject: [PATCH 4/5] [iOS][SPM] Refresh the platform floor of scaffolded manifests on add/update `spm add` and `spm update` now rewrite the `platforms: [.iOS(...)]` element of every scaffolded manifest carrying the scaffolder marker to the app's deployment target, covering the `.iOS(.v15)` form older scaffolds carry. Nothing else in the file is touched and no scaffold is created; the full re-render stays with `spm scaffold`. Co-Authored-By: Claude Fable 5.1 --- .../react-native/scripts/setup-apple-spm.js | 24 ++- .../scripts/spm/__docs__/spm-scripts.md | 7 +- .../__tests__/scaffold-package-swift-test.js | 150 ++++++++++++++++++ .../scripts/spm/scaffold-package-swift.js | 76 +++++++++ 4 files changed, 253 insertions(+), 4 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 5e81255ebde5..0ace9683a03a 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -107,7 +107,10 @@ const { MIN_IOS_VERSION_SUPPORTED, resolveIosDeploymentTarget, } = require('./spm/ios-deployment-target'); -const {scaffoldAll} = require('./spm/scaffold-package-swift'); +const { + refreshScaffoldedPlatformFloors, + scaffoldAll, +} = require('./spm/scaffold-package-swift'); const { RemoteVersionError, buildPerAppHeaderTree, @@ -1238,6 +1241,25 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { } runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen); + + // A manifest scaffolded before the app's deployment target changed (or by a + // pre-v20 scaffolder) would pin a floor SwiftPM then refuses to link against. + if ( + (action === 'add' || action === 'update') && + autolinkingConfigResult != null + ) { + for (const refreshed of refreshScaffoldedPlatformFloors({ + appRoot, + autolinkingJsonPath: autolinkingConfigResult.outputPath, + iosDeploymentTarget, + })) { + log( + `Refreshed platform floor in ${path.relative(appRoot, refreshed.path)}: ` + + `${refreshed.from} → ${refreshed.to}`, + ); + } + } + log('Generating build/generated/autolinking/Package.swift...'); try { generateAutolinking([ diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 2879e59ce408..47059cb79da2 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -228,9 +228,10 @@ raise the deployment target in Xcode and re-run `react-native spm update`. A floor set in an `.xcconfig` your configuration is based on is honored, `#include` chains included; one set through a build-setting variable -(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. Scaffolded -packages keep the floor they were scaffolded with until you re-run -`react-native spm scaffold`. +(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. `spm add` and +`spm update` also refresh the platform-floor line of existing scaffolded +manifests (they never create new ones) — if you persisted a scaffold with +`patch-package`, re-run `npx patch-package ` afterwards. ## Files the tool touches diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index 33f724210b39..bfd83f47bba9 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -14,6 +14,7 @@ const { SCAFFOLDER_MARKER, SCAFFOLDER_VERSION, emitScaffoldedPackageSwift, + refreshScaffoldedPlatformFloors, scaffoldAll, scaffoldPackageSwiftForDep, translatePodspecToSpmTarget, @@ -1646,3 +1647,152 @@ describe('scaffoldPackageSwiftForDep — version-based regen', () => { expect(result.status).toBe('skipped-scaffolder-marker'); }); }); + +// --------------------------------------------------------------------------- +// refreshScaffoldedPlatformFloors — `spm add`/`update` bring the floor of +// manifests scaffolded earlier (possibly by an older generator) up to the +// app's current deployment target, without regenerating them. +// --------------------------------------------------------------------------- + +describe('refreshScaffoldedPlatformFloors', () => { + let appRoot; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-refresh-floor-')); + }); + + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + function manifest(floorLine) { + return ( + `// swift-tools-version: 6.0\n${SCAFFOLDER_MARKER}\n` + + '// AUTO-SCAFFOLDED-VERSION: 19\n\nlet package = Package(\n' + + ' name: "foo",\n' + + ` ${floorLine}\n` + + ' products: [],\n)\n' + ); + } + + // One dep per entry: `content` is written to /Package.swift unless null. + function writeApp(deps) { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + const dependencies = {}; + for (const [name, content] of Object.entries(deps)) { + const root = path.join(appRoot, 'node_modules', name); + fs.mkdirSync(root, {recursive: true}); + if (content != null) { + fs.writeFileSync(path.join(root, 'Package.swift'), content, 'utf8'); + } + dependencies[name] = {root, platforms: {ios: {}}}; + } + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + } + + function refresh() { + return refreshScaffoldedPlatformFloors({ + appRoot, + iosDeploymentTarget: '16.4', + }); + } + + function read(depName) { + return fs.readFileSync( + path.join(appRoot, 'node_modules', depName, 'Package.swift'), + 'utf8', + ); + } + + it('rewrites only the platform line of a scaffolded manifest', () => { + const before = manifest('platforms: [.iOS("15.1")],'); + writeApp({'react-native-foo': before}); + + expect(refresh()).toEqual([ + { + depName: 'react-native-foo', + path: path.join(appRoot, 'node_modules/react-native-foo/Package.swift'), + from: '15.1', + to: '16.4', + }, + ]); + expect(read('react-native-foo')).toBe( + before.replace('.iOS("15.1")', '.iOS("16.4")'), + ); + }); + + it('rewrites the enum form an older scaffolder emitted', () => { + writeApp({'react-native-foo': manifest('platforms: [.iOS(.v15)],')}); + + expect(refresh()).toEqual([ + expect.objectContaining({from: '.v15', to: '16.4'}), + ]); + expect(read('react-native-foo')).toContain('platforms: [.iOS("16.4")]'); + }); + + it('rewrites only the .iOS element of an extended platforms array', () => { + writeApp({ + 'react-native-foo': manifest( + 'platforms: [.iOS("15.1"), .macOS(.v13), .tvOS("16.0")],', + ), + }); + + expect(refresh()).toEqual([expect.objectContaining({from: '15.1'})]); + expect(read('react-native-foo')).toContain( + 'platforms: [.iOS("16.4"), .macOS(.v13), .tvOS("16.0")],', + ); + }); + + it('leaves a manifest already on the floor untouched', () => { + writeApp({'react-native-foo': manifest('platforms: [.iOS("16.4")],')}); + const manifestPath = path.join( + appRoot, + 'node_modules/react-native-foo/Package.swift', + ); + const before = fs.statSync(manifestPath).mtimeMs; + + expect(refresh()).toEqual([]); + expect(fs.statSync(manifestPath).mtimeMs).toBe(before); + }); + + it('ignores manifests it does not own, and deps with none', () => { + const upstream = '// hand-authored\nplatforms: [.iOS("15.1")],\n'; + const autogen = `// AUTO-GENERATED by scripts/generate-spm-autolinking.js\n${SCAFFOLDER_MARKER}\nplatforms: [.iOS("15.1")],\n`; + writeApp({ + 'react-native-upstream': upstream, + 'react-native-autogen': autogen, + 'react-native-none': null, + }); + + expect(refresh()).toEqual([]); + expect(read('react-native-upstream')).toBe(upstream); + expect(read('react-native-autogen')).toBe(autogen); + }); + + it('returns nothing when there is no autolinking.json', () => { + expect(refresh()).toEqual([]); + }); + + it('honors an autolinking.json written outside the default location', () => { + writeApp({'react-native-foo': manifest('platforms: [.iOS("15.1")],')}); + const moved = path.join(appRoot, 'elsewhere', 'autolinking.json'); + fs.mkdirSync(path.dirname(moved)); + fs.renameSync( + path.join(appRoot, 'build/generated/autolinking/autolinking.json'), + moved, + ); + + expect(refresh()).toEqual([]); + expect( + refreshScaffoldedPlatformFloors({ + appRoot, + autolinkingJsonPath: moved, + iosDeploymentTarget: '16.4', + }), + ).toEqual([expect.objectContaining({depName: 'react-native-foo'})]); + }); +}); diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index 47785d916844..5dd4f7173baf 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -1131,6 +1131,81 @@ function scaffoldPackageSwiftForDep( }; } +// The `.iOS(...)` element of the emitted platforms array — the `.v15` enum form +// pre-v20 scaffolds carry included, and without the array's closing bracket so +// a user-extended array (`[.iOS(…), .macOS(…)]`) is still matched. +const PLATFORM_FLOOR_RE = /platforms: \[\.iOS\((\.v\d+|"[^"]*")\)/; + +/** + * Bring the platform floor of already-scaffolded manifests up to the app's + * deployment target — `spm add`/`update` must not leave a dep pinned below the + * app (SwiftPM would refuse to link it), and re-scaffolding is the user's call. + * Only the platform line of a file carrying our own marker is rewritten; no + * file is created. `from` is the previous token, verbatim (`15.1` or `.v15`). + */ +function refreshScaffoldedPlatformFloors( + opts /*: {appRoot: string, autolinkingJsonPath?: string, iosDeploymentTarget: string} */, +) /*: Array<{depName: string, path: string, from: string, to: string}> */ { + const {appRoot, iosDeploymentTarget} = opts; + const autolinkingJsonPath = + opts.autolinkingJsonPath ?? + path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); + const refreshed = []; + + let deps; + try { + // $FlowFixMe[incompatible-type] JSON.parse returns any + deps = JSON.parse( + fs.readFileSync(autolinkingJsonPath, 'utf8'), + ).dependencies; + } catch { + return refreshed; + } + if (deps == null) { + return refreshed; + } + + for (const depName of Object.keys(deps)) { + const root = deps[depName]?.root; + if (typeof root !== 'string') { + continue; + } + const manifestPath = path.join(root, 'Package.swift'); + let content; + try { + content = fs.readFileSync(manifestPath, 'utf8'); + } catch { + continue; + } + if ( + !content.includes(SCAFFOLDER_MARKER) || + content.includes(AUTOGEN_MARKER) + ) { + continue; + } + const match = content.match(PLATFORM_FLOOR_RE); + if (match == null) { + continue; + } + const from = match[1].replace(/"/g, ''); + if (from === iosDeploymentTarget) { + continue; + } + fs.writeFileSync( + manifestPath, + content.replace(match[0], `platforms: [.iOS("${iosDeploymentTarget}")`), + 'utf8', + ); + refreshed.push({ + depName, + path: manifestPath, + from, + to: iosDeploymentTarget, + }); + } + return refreshed; +} + // --------------------------------------------------------------------------- // Multi-dep orchestrator // --------------------------------------------------------------------------- @@ -1306,6 +1381,7 @@ function scaffoldAll( } module.exports = { + refreshScaffoldedPlatformFloors, scaffoldAll, scaffoldPackageSwiftForDep, translatePodspecToSpmTarget, From 167dc22c9e90c90f1557be4baec30046c8fb25fb Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 10 Sep 2026 12:37:03 +0200 Subject: [PATCH 5/5] [iOS][SPM] Refresh transitive scaffolded manifests too The floor refresh visited only the direct autolinking.json entries, while the scaffolder covers the expanded set including transitive SwiftPM dependencies, so a previously scaffolded transitive dep kept its old floor after `spm update`. Both now share one dependency-collection helper. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/scaffold-package-swift-test.js | 39 +++++ .../scripts/spm/scaffold-package-swift.js | 165 ++++++++++-------- 2 files changed, 133 insertions(+), 71 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index bfd83f47bba9..e05e07e011d3 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -1773,6 +1773,45 @@ describe('refreshScaffoldedPlatformFloors', () => { expect(read('react-native-autogen')).toBe(autogen); }); + it('refreshes a transitive spm.dependency that autolinking.json never lists', () => { + writeApp({'react-native-a': manifest('platforms: [.iOS("15.1")],')}); + fs.writeFileSync( + path.join(appRoot, 'node_modules/react-native-a/package.json'), + JSON.stringify({ + name: 'react-native-a', + swiftpmConfig: {dependencies: ['react-native-transitive']}, + }), + ); + const transitiveRoot = path.join( + appRoot, + 'node_modules', + 'react-native-transitive', + ); + fs.mkdirSync(transitiveRoot, {recursive: true}); + fs.writeFileSync( + path.join(transitiveRoot, 'package.json'), + JSON.stringify({name: 'react-native-transitive', version: '1.0.0'}), + ); + fs.writeFileSync( + path.join(transitiveRoot, 'react-native.config.js'), + 'module.exports = {dependency: {platforms: {ios: {}}}};\n', + ); + fs.writeFileSync( + path.join(transitiveRoot, 'Package.swift'), + manifest('platforms: [.iOS("15.1")],'), + 'utf8', + ); + + expect( + refresh() + .map(entry => entry.depName) + .sort(), + ).toEqual(['react-native-a', 'react-native-transitive']); + expect(read('react-native-transitive')).toContain( + 'platforms: [.iOS("16.4")]', + ); + }); + it('returns nothing when there is no autolinking.json', () => { expect(refresh()).toEqual([]); }); diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index 5dd4f7173baf..cf7e5879ce2d 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -1131,6 +1131,77 @@ function scaffoldPackageSwiftForDep( }; } +/** + * The dependency set the autolinker considers: the iOS entries of + * autolinking.json plus their transitive SwiftPM dependencies. Null when the + * file declares none; throws when it cannot be read or parsed. `onSkipped` + * reports the entries dropped for having no iOS platform. + */ +function collectAutolinkedDeps( + opts /*: { + autolinkingJsonPath: string, + remote: ?{url: string, version: string, identity: string}, + onSkipped?: (name: string) => void, + } */, +) /*: ?Array */ { + const {autolinkingJsonPath, remote, onSkipped} = opts; + /*:: type AutolinkingJson = {dependencies?: ?{[string]: {root?: string, platforms?: {ios?: ?{...}, ...}, ...}}, ...}; */ + // $FlowFixMe[incompatible-type] JSON.parse returns any + const data /*: AutolinkingJson */ = JSON.parse( + fs.readFileSync(autolinkingJsonPath, 'utf8'), + ); + const deps = data.dependencies; + if (deps == null) { + return null; + } + + const directDeps /*: Array */ = []; + for (const name of Object.keys(deps)) { + const raw = deps[name]; + if (raw == null) continue; + const root = raw.root; + const ios = raw.platforms?.ios; + if (typeof root !== 'string' || ios == null) { + onSkipped?.(name); + continue; + } + // $FlowFixMe[incompatible-type] `ios` shape is runtime-validated above + const iosPlatform /*: AutolinkingIosPlatform */ = ios; + directDeps.push({name, root, platforms: {ios: iosPlatform}}); + } + + try { + return expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + readPodspec: defaultReadPodspec, + extraReservedNames: remote != null ? [remote.identity] : undefined, + }); + } catch (e) { + if (e instanceof SpmNameCollisionError) { + throw e; + } + // A transitive-resolution failure shouldn't abort the whole pass; fall back + // to the direct deps so at least those are covered. They are named the way + // the autolinker names them — a manifest written under any other name + // outlives this error on disk and then fails to resolve. + log(`Transitive dependency expansion failed: ${e.message}`); + return directDeps.map(dep => { + const resolved = resolveSwiftName( + dep.name, + readSwiftpmConfig(dep.root, defaultReadConfig(dep.root)), + defaultReadPodspec(dep.root, dep.platforms.ios.podspecPath), + ); + return { + ...dep, + swiftName: resolved.name, + swiftNameSource: resolved.source, + swiftNamePodspecKey: resolved.podspecKey, + }; + }); + } +} + // The `.iOS(...)` element of the emitted platforms array — the `.v15` enum form // pre-v20 scaffolds carry included, and without the array's closing bracket so // a user-extended array (`[.iOS(…), .macOS(…)]`) is still matched. @@ -1152,12 +1223,15 @@ function refreshScaffoldedPlatformFloors( path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); const refreshed = []; - let deps; + // Best-effort: an unreadable autolinking.json, a name collision or a + // malformed remote config is the autolinker's error to report a moment + // later, not this pass's. + let deps /*: ?Array */ = null; try { - // $FlowFixMe[incompatible-type] JSON.parse returns any - deps = JSON.parse( - fs.readFileSync(autolinkingJsonPath, 'utf8'), - ).dependencies; + deps = collectAutolinkedDeps({ + autolinkingJsonPath, + remote: remotePackageConfig(appRoot), + }); } catch { return refreshed; } @@ -1165,11 +1239,7 @@ function refreshScaffoldedPlatformFloors( return refreshed; } - for (const depName of Object.keys(deps)) { - const root = deps[depName]?.root; - if (typeof root !== 'string') { - continue; - } + for (const {name: depName, root} of deps) { const manifestPath = path.join(root, 'Package.swift'); let content; try { @@ -1247,76 +1317,29 @@ function scaffoldAll( return []; } - /*:: type AutolinkingJson = {dependencies?: ?{[string]: {root?: string, platforms?: {ios?: ?{...}, ...}, ...}}, ...}; */ - // $FlowFixMe[incompatible-type] JSON.parse returns any - const data /*: AutolinkingJson */ = JSON.parse( - fs.readFileSync(autolinkingJsonPath, 'utf8'), - ); - const deps = data.dependencies; - if (deps == null) { - return []; - } - - // Narrow the direct autolinking.json entries with an iOS platform, then - // expand transitive `spm.dependencies` so the scaffolder covers EXACTLY the - // set the autolinker considers. Without this, a transitive native dep that + // The scaffolder covers EXACTLY the set the autolinker considers, transitive + // `spm.dependencies` included. Without them, a transitive native dep that // ships no Package.swift would be flagged by the autolinker but never // scaffolded here — leaving `react-native spm scaffold` unable to clear the // autolinker's missing-manifest error. const results /*: Array */ = []; - const directDeps /*: Array */ = []; - for (const name of Object.keys(deps)) { - const raw = deps[name]; - if (raw == null) continue; - const root = raw.root; - const ios = raw.platforms?.ios; - if (typeof root !== 'string' || ios == null) { + // Outside collectAutolinkedDeps' own try: a malformed remote config + // (RemoteVersionError) is a misconfiguration to surface, not an expansion + // failure to degrade past. + const remote = remotePackageConfig(appRoot); + const allDeps = collectAutolinkedDeps({ + autolinkingJsonPath, + remote, + onSkipped: name => { results.push({ depName: name, status: 'skipped-no-ios', reason: 'no iOS platform in autolinking.json', }); - continue; - } - // $FlowFixMe[incompatible-type] `ios` shape is runtime-validated above - const iosPlatform /*: AutolinkingIosPlatform */ = ios; - directDeps.push({name, root, platforms: {ios: iosPlatform}}); - } - - // Outside the try: a malformed remote config (RemoteVersionError) is a - // misconfiguration to surface, not an expansion failure to degrade past. - const remote = remotePackageConfig(appRoot); - - let allDeps /*: Array */ = []; - try { - allDeps = expandSpmDependencies(directDeps, { - readConfig: defaultReadConfig, - resolveDep: defaultResolveDep, - readPodspec: defaultReadPodspec, - extraReservedNames: remote != null ? [remote.identity] : undefined, - }); - } catch (e) { - if (e instanceof SpmNameCollisionError) { - throw e; - } - // A transitive-resolution failure shouldn't abort the whole scaffold pass; - // fall back to the direct deps so at least those get manifests. They are - // named the same way the autolinker names them — a manifest written under - // any other name outlives this error on disk and then fails to resolve. - log(`Transitive dependency expansion failed: ${e.message}`); - allDeps = directDeps.map(dep => { - const resolved = resolveSwiftName( - dep.name, - readSwiftpmConfig(dep.root, defaultReadConfig(dep.root)), - defaultReadPodspec(dep.root, dep.platforms.ios.podspecPath), - ); - return { - ...dep, - swiftName: resolved.name, - swiftNameSource: resolved.source, - swiftNamePodspecKey: resolved.podspecKey, - }; - }); + }, + }); + if (allDeps == null) { + return results; } // Index every autolinked dep's podspec name → its npm name, so a dep that