Skip to content

Commit 54d0f61

Browse files
chrfalchclaude
andcommitted
[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 <noreply@anthropic.com>
1 parent a8f9b24 commit 54d0f61

4 files changed

Lines changed: 253 additions & 4 deletions

File tree

‎packages/react-native/scripts/setup-apple-spm.js‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ const {
107107
MIN_IOS_VERSION_SUPPORTED,
108108
resolveIosDeploymentTarget,
109109
} = require('./spm/ios-deployment-target');
110-
const {scaffoldAll} = require('./spm/scaffold-package-swift');
110+
const {
111+
refreshScaffoldedPlatformFloors,
112+
scaffoldAll,
113+
} = require('./spm/scaffold-package-swift');
111114
const {
112115
RemoteVersionError,
113116
buildPerAppHeaderTree,
@@ -1226,6 +1229,25 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
12261229
}
12271230

12281231
runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen);
1232+
1233+
// A manifest scaffolded before the app's deployment target changed (or by a
1234+
// pre-v20 scaffolder) would pin a floor SwiftPM then refuses to link against.
1235+
if (
1236+
(action === 'add' || action === 'update') &&
1237+
autolinkingConfigResult != null
1238+
) {
1239+
for (const refreshed of refreshScaffoldedPlatformFloors({
1240+
appRoot,
1241+
autolinkingJsonPath: autolinkingConfigResult.outputPath,
1242+
iosDeploymentTarget,
1243+
})) {
1244+
log(
1245+
`Refreshed platform floor in ${path.relative(appRoot, refreshed.path)}: ` +
1246+
`${refreshed.from} → ${refreshed.to}`,
1247+
);
1248+
}
1249+
}
1250+
12291251
log('Generating build/generated/autolinking/Package.swift...');
12301252
try {
12311253
generateAutolinking([

‎packages/react-native/scripts/spm/__docs__/spm-scripts.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,10 @@ raise the deployment target in Xcode and re-run `react-native spm update`.
228228

229229
A floor set in an `.xcconfig` your configuration is based on is honored,
230230
`#include` chains included; one set through a build-setting variable
231-
(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. Scaffolded
232-
packages keep the floor they were scaffolded with until you re-run
233-
`react-native spm scaffold`.
231+
(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. `spm add` and
232+
`spm update` also refresh the platform-floor line of existing scaffolded
233+
manifests (they never create new ones) — if you persisted a scaffold with
234+
`patch-package`, re-run `npx patch-package <dep>` afterwards.
234235

235236
## Files the tool touches
236237

‎packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
SCAFFOLDER_MARKER,
1515
SCAFFOLDER_VERSION,
1616
emitScaffoldedPackageSwift,
17+
refreshScaffoldedPlatformFloors,
1718
scaffoldAll,
1819
scaffoldPackageSwiftForDep,
1920
translatePodspecToSpmTarget,
@@ -1336,3 +1337,152 @@ describe('scaffoldPackageSwiftForDep — version-based regen', () => {
13361337
expect(result.status).toBe('skipped-scaffolder-marker');
13371338
});
13381339
});
1340+
1341+
// ---------------------------------------------------------------------------
1342+
// refreshScaffoldedPlatformFloors — `spm add`/`update` bring the floor of
1343+
// manifests scaffolded earlier (possibly by an older generator) up to the
1344+
// app's current deployment target, without regenerating them.
1345+
// ---------------------------------------------------------------------------
1346+
1347+
describe('refreshScaffoldedPlatformFloors', () => {
1348+
let appRoot;
1349+
1350+
beforeEach(() => {
1351+
appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-refresh-floor-'));
1352+
});
1353+
1354+
afterEach(() => {
1355+
fs.rmSync(appRoot, {recursive: true, force: true});
1356+
});
1357+
1358+
function manifest(floorLine) {
1359+
return (
1360+
`// swift-tools-version: 6.0\n${SCAFFOLDER_MARKER}\n` +
1361+
'// AUTO-SCAFFOLDED-VERSION: 19\n\nlet package = Package(\n' +
1362+
' name: "foo",\n' +
1363+
` ${floorLine}\n` +
1364+
' products: [],\n)\n'
1365+
);
1366+
}
1367+
1368+
// One dep per entry: `content` is written to <root>/Package.swift unless null.
1369+
function writeApp(deps) {
1370+
const autolinkingDir = path.join(appRoot, 'build/generated/autolinking');
1371+
fs.mkdirSync(autolinkingDir, {recursive: true});
1372+
const dependencies = {};
1373+
for (const [name, content] of Object.entries(deps)) {
1374+
const root = path.join(appRoot, 'node_modules', name);
1375+
fs.mkdirSync(root, {recursive: true});
1376+
if (content != null) {
1377+
fs.writeFileSync(path.join(root, 'Package.swift'), content, 'utf8');
1378+
}
1379+
dependencies[name] = {root, platforms: {ios: {}}};
1380+
}
1381+
fs.writeFileSync(
1382+
path.join(autolinkingDir, 'autolinking.json'),
1383+
JSON.stringify({dependencies}),
1384+
);
1385+
}
1386+
1387+
function refresh() {
1388+
return refreshScaffoldedPlatformFloors({
1389+
appRoot,
1390+
iosDeploymentTarget: '16.4',
1391+
});
1392+
}
1393+
1394+
function read(depName) {
1395+
return fs.readFileSync(
1396+
path.join(appRoot, 'node_modules', depName, 'Package.swift'),
1397+
'utf8',
1398+
);
1399+
}
1400+
1401+
it('rewrites only the platform line of a scaffolded manifest', () => {
1402+
const before = manifest('platforms: [.iOS("15.1")],');
1403+
writeApp({'react-native-foo': before});
1404+
1405+
expect(refresh()).toEqual([
1406+
{
1407+
depName: 'react-native-foo',
1408+
path: path.join(appRoot, 'node_modules/react-native-foo/Package.swift'),
1409+
from: '15.1',
1410+
to: '16.4',
1411+
},
1412+
]);
1413+
expect(read('react-native-foo')).toBe(
1414+
before.replace('.iOS("15.1")', '.iOS("16.4")'),
1415+
);
1416+
});
1417+
1418+
it('rewrites the enum form an older scaffolder emitted', () => {
1419+
writeApp({'react-native-foo': manifest('platforms: [.iOS(.v15)],')});
1420+
1421+
expect(refresh()).toEqual([
1422+
expect.objectContaining({from: '.v15', to: '16.4'}),
1423+
]);
1424+
expect(read('react-native-foo')).toContain('platforms: [.iOS("16.4")]');
1425+
});
1426+
1427+
it('rewrites only the .iOS element of an extended platforms array', () => {
1428+
writeApp({
1429+
'react-native-foo': manifest(
1430+
'platforms: [.iOS("15.1"), .macOS(.v13), .tvOS("16.0")],',
1431+
),
1432+
});
1433+
1434+
expect(refresh()).toEqual([expect.objectContaining({from: '15.1'})]);
1435+
expect(read('react-native-foo')).toContain(
1436+
'platforms: [.iOS("16.4"), .macOS(.v13), .tvOS("16.0")],',
1437+
);
1438+
});
1439+
1440+
it('leaves a manifest already on the floor untouched', () => {
1441+
writeApp({'react-native-foo': manifest('platforms: [.iOS("16.4")],')});
1442+
const manifestPath = path.join(
1443+
appRoot,
1444+
'node_modules/react-native-foo/Package.swift',
1445+
);
1446+
const before = fs.statSync(manifestPath).mtimeMs;
1447+
1448+
expect(refresh()).toEqual([]);
1449+
expect(fs.statSync(manifestPath).mtimeMs).toBe(before);
1450+
});
1451+
1452+
it('ignores manifests it does not own, and deps with none', () => {
1453+
const upstream = '// hand-authored\nplatforms: [.iOS("15.1")],\n';
1454+
const autogen = `// AUTO-GENERATED by scripts/generate-spm-autolinking.js\n${SCAFFOLDER_MARKER}\nplatforms: [.iOS("15.1")],\n`;
1455+
writeApp({
1456+
'react-native-upstream': upstream,
1457+
'react-native-autogen': autogen,
1458+
'react-native-none': null,
1459+
});
1460+
1461+
expect(refresh()).toEqual([]);
1462+
expect(read('react-native-upstream')).toBe(upstream);
1463+
expect(read('react-native-autogen')).toBe(autogen);
1464+
});
1465+
1466+
it('returns nothing when there is no autolinking.json', () => {
1467+
expect(refresh()).toEqual([]);
1468+
});
1469+
1470+
it('honors an autolinking.json written outside the default location', () => {
1471+
writeApp({'react-native-foo': manifest('platforms: [.iOS("15.1")],')});
1472+
const moved = path.join(appRoot, 'elsewhere', 'autolinking.json');
1473+
fs.mkdirSync(path.dirname(moved));
1474+
fs.renameSync(
1475+
path.join(appRoot, 'build/generated/autolinking/autolinking.json'),
1476+
moved,
1477+
);
1478+
1479+
expect(refresh()).toEqual([]);
1480+
expect(
1481+
refreshScaffoldedPlatformFloors({
1482+
appRoot,
1483+
autolinkingJsonPath: moved,
1484+
iosDeploymentTarget: '16.4',
1485+
}),
1486+
).toEqual([expect.objectContaining({depName: 'react-native-foo'})]);
1487+
});
1488+
});

‎packages/react-native/scripts/spm/scaffold-package-swift.js‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,81 @@ function scaffoldPackageSwiftForDep(
10811081
};
10821082
}
10831083

1084+
// The `.iOS(...)` element of the emitted platforms array — the `.v15` enum form
1085+
// pre-v20 scaffolds carry included, and without the array's closing bracket so
1086+
// a user-extended array (`[.iOS(…), .macOS(…)]`) is still matched.
1087+
const PLATFORM_FLOOR_RE = /platforms: \[\.iOS\((\.v\d+|"[^"]*")\)/;
1088+
1089+
/**
1090+
* Bring the platform floor of already-scaffolded manifests up to the app's
1091+
* deployment target — `spm add`/`update` must not leave a dep pinned below the
1092+
* app (SwiftPM would refuse to link it), and re-scaffolding is the user's call.
1093+
* Only the platform line of a file carrying our own marker is rewritten; no
1094+
* file is created. `from` is the previous token, verbatim (`15.1` or `.v15`).
1095+
*/
1096+
function refreshScaffoldedPlatformFloors(
1097+
opts /*: {appRoot: string, autolinkingJsonPath?: string, iosDeploymentTarget: string} */,
1098+
) /*: Array<{depName: string, path: string, from: string, to: string}> */ {
1099+
const {appRoot, iosDeploymentTarget} = opts;
1100+
const autolinkingJsonPath =
1101+
opts.autolinkingJsonPath ??
1102+
path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json');
1103+
const refreshed = [];
1104+
1105+
let deps;
1106+
try {
1107+
// $FlowFixMe[incompatible-type] JSON.parse returns any
1108+
deps = JSON.parse(
1109+
fs.readFileSync(autolinkingJsonPath, 'utf8'),
1110+
).dependencies;
1111+
} catch {
1112+
return refreshed;
1113+
}
1114+
if (deps == null) {
1115+
return refreshed;
1116+
}
1117+
1118+
for (const depName of Object.keys(deps)) {
1119+
const root = deps[depName]?.root;
1120+
if (typeof root !== 'string') {
1121+
continue;
1122+
}
1123+
const manifestPath = path.join(root, 'Package.swift');
1124+
let content;
1125+
try {
1126+
content = fs.readFileSync(manifestPath, 'utf8');
1127+
} catch {
1128+
continue;
1129+
}
1130+
if (
1131+
!content.includes(SCAFFOLDER_MARKER) ||
1132+
content.includes(AUTOGEN_MARKER)
1133+
) {
1134+
continue;
1135+
}
1136+
const match = content.match(PLATFORM_FLOOR_RE);
1137+
if (match == null) {
1138+
continue;
1139+
}
1140+
const from = match[1].replace(/"/g, '');
1141+
if (from === iosDeploymentTarget) {
1142+
continue;
1143+
}
1144+
fs.writeFileSync(
1145+
manifestPath,
1146+
content.replace(match[0], `platforms: [.iOS("${iosDeploymentTarget}")`),
1147+
'utf8',
1148+
);
1149+
refreshed.push({
1150+
depName,
1151+
path: manifestPath,
1152+
from,
1153+
to: iosDeploymentTarget,
1154+
});
1155+
}
1156+
return refreshed;
1157+
}
1158+
10841159
// ---------------------------------------------------------------------------
10851160
// Multi-dep orchestrator
10861161
// ---------------------------------------------------------------------------
@@ -1248,6 +1323,7 @@ function scaffoldAll(
12481323
}
12491324

12501325
module.exports = {
1326+
refreshScaffoldedPlatformFloors,
12511327
scaffoldAll,
12521328
scaffoldPackageSwiftForDep,
12531329
translatePodspecToSpmTarget,

0 commit comments

Comments
 (0)