Skip to content

Commit fafd7b5

Browse files
chrfalchclaude
andcommitted
feat(spm): never auto-scaffold; surface missing manifests at sync AND resolution
A missing community-library Package.swift is a real gap that must be fixed deliberately (scaffold + patch-package, or upstream), not papered over. Two changes make it loud and actionable, and remove the auto-scaffold that hid it. 1. init/update no longer auto-scaffold. `runScaffold` now runs ONLY for the explicit `spm scaffold` action; init/update/sync fall straight through to the autolinker, which throws MissingManifestError (exit 2 → hard Xcode build error) for any npm dep lacking a shipped/scaffolded manifest. Removed the interactive prompt, the --yes/non-TTY auto-accept, and the dry-run/skip machinery (confirmScaffold deleted). The post-scaffold guidance now points to patch-package only — the `"postinstall": "spm scaffold"` suggestion is gone, because auto-restoring on every install would defeat the pressure to fix the gap (a wiped scaffold SHOULD re-surface the error). reportMissingManifests now prints a 4-point message (scaffold → patch-package → tell the maintainer → returns on a fresh node_modules). 2. Eval-time missing-manifest guard in the generated aggregator Package.swift. SwiftPM resolves the package graph BEFORE the Xcode "Sync SPM Autolinking" build phase runs, so a scaffolded library manifest wiped by a node_modules reset (no committed patch) failed resolution with an opaque "package manifest cannot be accessed" — the sync-phase message never printed. Manifest eval may READ the filesystem (only writes are sandboxed), so the aggregator now checks each referenced library's Package.swift at resolution time and fatalErrors with the same actionable guidance. The sync generator re-emits the guard every build, so it is always present for the next resolution. npmName threaded onto NpmDepRef so the guard names the installed package, not its Swift target. Verified E2E (/tmp/SpmE2E, remote mode): a wiped library manifest now surfaces the 4-point message at resolution instead of the opaque error; the explicit `spm scaffold` still scaffolds; init hard-errors (exit 2) without scaffolding. 323 spm/scripts tests pass; flow/prettier/eslint clean on touched files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11869ee commit fafd7b5

4 files changed

Lines changed: 123 additions & 100 deletions

File tree

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

Lines changed: 25 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -802,25 +802,6 @@ async function maybePatchPodfile(
802802
);
803803
}
804804

805-
function confirmScaffold(
806-
depNames /*: Array<string> */,
807-
) /*: Promise<boolean> */ {
808-
log('');
809-
log(`Found ${depNames.length} community RN package(s) without SPM support:`);
810-
for (const n of depNames) {
811-
log(` • ${n}`);
812-
}
813-
log('');
814-
log(
815-
'Scaffolding writes a Package.swift into node_modules/<dep>/ derived from\n' +
816-
"the dep's podspec. node_modules gets wiped by `npm install` — to persist:\n" +
817-
' • add a `"postinstall": "npx react-native spm scaffold"` to package.json, OR\n' +
818-
' • `npx patch-package <dep>` after scaffolding.',
819-
);
820-
log('');
821-
return promptYesNo('Generate Package.swift for the deps above?', true);
822-
}
823-
824805
function confirmDestructive(
825806
targets /*: Array<CleanTarget> */,
826807
) /*: Promise<boolean> */ {
@@ -978,15 +959,18 @@ function runCodegenStep(
978959
* Runs as part of `init` / `update` / `scaffold` actions. Each invocation
979960
* is a no-op for deps already in a clean state.
980961
*/
962+
// Scaffolding is an EXPLICIT, manual step (`npx react-native spm scaffold`) and
963+
// is NEVER run automatically by init/update/sync. A missing Package.swift is a
964+
// real gap that must surface as a hard build error (see reportMissingManifests)
965+
// so the user fixes it deliberately: scaffold, then persist with patch-package
966+
// (node_modules is not committed), and ideally get it fixed upstream. There is
967+
// intentionally no prompt and no auto-restore — auto-scaffolding would hide the
968+
// error, and a wiped scaffold SHOULD re-surface it.
981969
async function runScaffold(
982970
args /*: SetupArgs */,
983971
appRoot /*: string */,
984972
projectRoot /*: string */,
985973
reactNativeRoot /*: string */,
986-
// Caller's resolved action — same union as resolveAction's return type.
987-
// Typed precisely so Flow accepts the `action === 'scaffold'` checks
988-
// below (strict mode rejects `string === <singleton>` as invalid-compare).
989-
action /*: 'init' | 'update' | 'sync' | 'clean' | 'codegen' | 'download' | 'scaffold' */,
990974
) /*: Promise<void> */ {
991975
// Resolve the cache slot identifier so the scaffolded files carry it as
992976
// a comment — that's how SPM's manifest hash bumps on slot transitions.
@@ -1000,74 +984,20 @@ async function runScaffold(
1000984
// doesn't get the slot-bump comment.
1001985
}
1002986

1003-
// Pass 1: dry-run to discover which deps would be scaffolded for the
1004-
// FIRST time (no existing Package.swift). Those are the only ones we
1005-
// prompt the user about — regens of files we already own happen silently.
1006-
let dryResults;
1007-
try {
1008-
dryResults = scaffoldAll({
1009-
appRoot,
1010-
projectRoot,
1011-
reactNativeRoot,
1012-
cacheSlotLabel,
1013-
force: action === 'scaffold',
1014-
dryRun: true,
1015-
});
1016-
} catch (e) {
1017-
logError(
1018-
`scaffold dry-run failed: ${e.message}. Continuing — community deps may not autolink.`,
1019-
);
1020-
return;
1021-
}
1022-
1023-
const newScaffoldDeps /*: Array<string> */ = [];
1024-
for (const r of dryResults) {
1025-
if (r.status === 'written' && r.previouslyExisted === false) {
1026-
newScaffoldDeps.push(r.depName);
1027-
}
1028-
}
1029-
1030-
// Decide which (if any) first-time deps the user wants scaffolded.
1031-
// - `scaffold` action: user explicitly asked → no prompt
1032-
// - `--yes`: bypass prompt
1033-
// - non-TTY (CI): auto-accept
1034-
// - otherwise: prompt with a list; default Yes
1035-
let skipDeps /*: Array<string> */ = [];
1036-
if (
1037-
newScaffoldDeps.length > 0 &&
1038-
action !== 'scaffold' &&
1039-
!args.cleanYes &&
1040-
process.stdin.isTTY === true
1041-
) {
1042-
const proceed = await confirmScaffold(newScaffoldDeps);
1043-
if (!proceed) {
1044-
// Decline ALL first-time scaffolds; existing scaffolder-marker files
1045-
// still get regenerated (slot changes etc.).
1046-
skipDeps = newScaffoldDeps;
1047-
log(
1048-
'Skipping first-time scaffolds for this run. ' +
1049-
'Re-run `npx react-native spm scaffold` (or pass --yes) to accept.',
1050-
);
1051-
}
1052-
}
1053-
1054987
let results;
1055988
try {
1056989
results = scaffoldAll({
1057990
appRoot,
1058991
projectRoot,
1059992
reactNativeRoot,
1060993
cacheSlotLabel,
1061-
// `scaffold` action forces a re-render even when slot is unchanged,
1062-
// so a user re-running it after editing a podspec gets the new
1063-
// content. `update`/`init` are non-forcing (idempotent).
1064-
force: action === 'scaffold',
1065-
skipDeps,
994+
// Always force a re-render so re-running after editing a podspec picks
995+
// up the new content.
996+
force: true,
1066997
});
1067998
} catch (e) {
1068-
logError(
1069-
`scaffold failed: ${e.message}. Continuing — community deps may not autolink.`,
1070-
);
999+
logError(`scaffold failed: ${e.message}.`);
1000+
process.exitCode = 1;
10711001
return;
10721002
}
10731003

@@ -1084,9 +1014,11 @@ async function runScaffold(
10841014
}
10851015
log('');
10861016
log(
1087-
'TIP: node_modules is wiped by `npm install`. To persist:\n' +
1088-
' • add `"postinstall": "npx react-native spm scaffold"` to package.json (preferred), OR\n' +
1089-
' • `npx patch-package <dep>` after scaffolding (cross-machine portability with caveats).',
1017+
'node_modules is NOT committed and is wiped by `npm install`. To keep\n' +
1018+
'these manifests, create and commit a patch with a tool like patch-package:\n' +
1019+
' • `npx patch-package <dep>` for each scaffolded dep, then commit the patch.\n' +
1020+
'Also consider asking the maintainer to ship a Package.swift upstream.\n' +
1021+
'Without a committed patch the build will hard-error again after a fresh install.',
10901022
);
10911023
log('');
10921024
}
@@ -1681,12 +1613,14 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
16811613
}
16821614

16831615
// Scaffold Package.swift for community RN packages that don't ship SPM
1684-
// support. Runs BEFORE the autolinker so the autolinker sees the
1685-
// scaffolded files as self-managed (via isSelfManagedPackage's
1686-
// AUTOGEN_MARKER check) and references them directly from the aggregator.
1687-
// No-op for deps that already have an upstream Package.swift, opted out,
1688-
// or had no .podspec.
1689-
await runScaffold(args, appRoot, projectRoot, reactNativeRoot, action);
1616+
// support — ONLY for the explicit `scaffold` action. init/update never
1617+
// auto-scaffold: a missing manifest must surface as a hard error (the
1618+
// autolinker below throws MissingManifestError → exit 2) so the gap is
1619+
// visible and fixed deliberately (scaffold + patch-package, or upstream).
1620+
// Auto-scaffolding would silently hide that real error.
1621+
if (action === 'scaffold') {
1622+
await runScaffold(args, appRoot, projectRoot, reactNativeRoot);
1623+
}
16901624

16911625
runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen);
16921626
log('Generating build/generated/autolinking/Package.swift...');

packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,36 @@ describe('generateAutolinkedPackageSwift (aggregator)', () => {
5757
expect(result).toContain('.product(name: "B", package: "B")');
5858
});
5959

60+
it('emits an eval-time missing-manifest guard naming each lib by npm name', () => {
61+
const result = generateAutolinkedPackageSwift({
62+
npmDeps: [
63+
{
64+
swiftName: 'ReactNativeSafeAreaContext',
65+
packagePath: 'libs/ReactNativeSafeAreaContext',
66+
npmName: 'react-native-safe-area-context',
67+
},
68+
],
69+
});
70+
// The guard runs at resolution (manifest eval) — before the Xcode sync
71+
// build phase — so a wiped library manifest surfaces an actionable message
72+
// instead of SwiftPM's opaque "manifest cannot be accessed".
73+
expect(result).toContain('let __rnAutolinkedLibs');
74+
expect(result).toContain(
75+
'(path: "libs/ReactNativeSafeAreaContext", npm: "react-native-safe-area-context")',
76+
);
77+
expect(result).toContain('FileManager.default.fileExists');
78+
expect(result).toContain('npx react-native spm scaffold');
79+
expect(result).toContain('npx patch-package');
80+
expect(result).toContain('fatalError(');
81+
// The guard reads its own location to resolve lib paths.
82+
expect(result).toContain('#filePath');
83+
});
84+
85+
it('omits the guard entirely when there are no npm deps', () => {
86+
const result = generateAutolinkedPackageSwift({});
87+
expect(result).not.toContain('__rnAutolinkedLibs');
88+
});
89+
6090
it('emits inline .target() blocks for each inlineTarget alongside AutolinkedAggregate', () => {
6191
const result = generateAutolinkedPackageSwift({
6292
inlineTargets: [
@@ -823,10 +853,14 @@ describe('MissingManifestError + reportMissingManifests', () => {
823853
expect(err).toBeInstanceOf(MissingManifestError);
824854
expect(errSpy).toHaveBeenCalledTimes(2);
825855
const lines = errSpy.mock.calls.map(c => c[0]);
826-
// Xcode only renders lines beginning with `error: `.
856+
// Xcode only renders the `error: ` headline; each dep is one such message.
827857
expect(lines.every(l => l.startsWith('error: '))).toBe(true);
828858
expect(lines[0]).toContain('react-native-foo');
829859
expect(lines[0]).toContain('npx react-native spm scaffold');
860+
// The pressure mechanics: persist via patch-package, and the error returns
861+
// on a fresh node_modules if you don't (no auto-scaffold/auto-restore).
862+
expect(lines[0]).toContain('patch-package');
863+
expect(lines[0]).toContain('node_modules is reset');
830864
});
831865

832866
it('tells the user a podspec-less dep cannot be auto-scaffolded', () => {

packages/react-native/scripts/spm/generate-spm-autolinking.js

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,20 @@ function reportMissingManifests(
339339
deps /*: Array<{name: string, npmName: string, hasPodspec: boolean}> */,
340340
) /*: MissingManifestError */ {
341341
for (const d of deps) {
342-
const fix = d.hasPodspec
343-
? `Run \`npx react-native spm scaffold\` from your terminal to generate one (persist it with patch-package, or contribute a Package.swift upstream to ${d.npmName}).`
344-
: `${d.npmName} ships no podspec, so it cannot be auto-scaffolded — it needs Swift Package Manager support added manually.`;
345-
// eslint-disable-next-line no-console
346-
console.error(
347-
`error: Package.swift is missing for library "${d.npmName}". ${fix}`,
348-
);
342+
if (d.hasPodspec) {
343+
console.error(
344+
`error: Package.swift is missing for library "${d.npmName}" — it ships no Swift Package Manager support.\n` +
345+
` 1. Run \`npx react-native spm scaffold\` to generate a Package.swift for ${d.npmName}.\n` +
346+
` 2. Persist it with a patch: \`npx patch-package ${d.npmName}\`, and commit the patch (node_modules is not committed).\n` +
347+
` 3. Ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).\n` +
348+
' 4. Without a committed patch, this same error returns whenever node_modules is reset (fresh install / CI).',
349+
);
350+
} else {
351+
console.error(
352+
`error: Package.swift is missing for library "${d.npmName}", and it ships no podspec so it cannot be scaffolded automatically.\n` +
353+
` • It needs Swift Package Manager support added manually — ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).`,
354+
);
355+
}
349356
}
350357
return new MissingManifestError(deps);
351358
}
@@ -771,6 +778,49 @@ function generateAutolinkedPackageSwift(
771778
const inlineDeclsBlock =
772779
inlineDecls.length > 0 ? `,\n${inlineDecls.join(',\n')}` : '';
773780

781+
// Eval-time missing-manifest guard. SwiftPM resolves the package graph BEFORE
782+
// the Xcode "Sync SPM Autolinking" build phase runs, so a community library
783+
// whose Package.swift is absent at resolution time (e.g. a scaffolded manifest
784+
// wiped by a node_modules reset without a committed patch) fails resolution
785+
// with an opaque "package manifest cannot be accessed" error — and the
786+
// actionable sync-phase message never prints. Manifest evaluation may READ the
787+
// filesystem (only writes are sandboxed), so the aggregator checks each
788+
// referenced library here and explains the cause + fix at resolution time.
789+
const guardEntries = npmDeps.map(d => {
790+
const pkgPath = d.packagePath ?? `packages/${d.swiftName}`;
791+
return ` (path: "${pkgPath}", npm: "${d.npmName ?? d.swiftName}")`;
792+
});
793+
const guardBlock =
794+
guardEntries.length > 0
795+
? `// Eval-time guard: surface a wiped/absent library Package.swift here (at
796+
// resolution) instead of the opaque SwiftPM "manifest cannot be accessed".
797+
let __rnAutolinkedLibs: [(path: String, npm: String)] = [
798+
${guardEntries.join(',\n')},
799+
]
800+
do {
801+
let __here = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
802+
let __missing = __rnAutolinkedLibs.filter {
803+
!FileManager.default.fileExists(
804+
atPath: __here.appendingPathComponent($0.path)
805+
.appendingPathComponent("Package.swift").path)
806+
}
807+
if !__missing.isEmpty {
808+
var __msg = ""
809+
for lib in __missing {
810+
__msg += "error: Package.swift is missing for library \\"\\(lib.npm)\\" — its Swift Package Manager manifest is not present (a scaffolded manifest wiped by a node_modules reset without a committed patch, or the library ships none).\\n"
811+
__msg += " 1. Run \`npx react-native spm scaffold\` to (re)generate it.\\n"
812+
__msg += " 2. Persist it with \`npx patch-package \\(lib.npm)\` and commit the patch (node_modules is not committed).\\n"
813+
__msg += " 3. Ask \\(lib.npm)'s maintainer to ship a Package.swift upstream.\\n"
814+
__msg += " 4. Without a committed patch, this error returns on every fresh install / CI.\\n"
815+
}
816+
FileHandle.standardError.write(Data(__msg.utf8))
817+
fatalError("Missing Package.swift for: \\(__missing.map { $0.npm }.joined(separator: ", ")). See the message above.")
818+
}
819+
}
820+
821+
`
822+
: '';
823+
774824
return `// swift-tools-version: 6.0
775825
// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually.
776826
// Top-level Autolinked package. Every autolinked dep (npm or spmModule) is
@@ -781,7 +831,7 @@ function generateAutolinkedPackageSwift(
781831
import PackageDescription
782832
import Foundation
783833
784-
let package = Package(
834+
${guardBlock}let package = Package(
785835
name: "Autolinked",
786836
platforms: [.iOS(.v15)],
787837
products: [
@@ -1243,6 +1293,7 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
12431293
aggregatorPackageDeps.push({
12441294
swiftName: target.name,
12451295
packagePath: `libs/${target.name}`,
1296+
npmName: entry.npmName ?? target.name,
12461297
});
12471298
continue;
12481299
}

packages/react-native/scripts/spm/spm-types.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@ export type NpmDepRef = {
241241
// Path passed to .package(path:). Relative to autolinked/ (the aggregator's
242242
// dir). For in-place synth this is the dep's real source dir.
243243
packagePath?: string,
244+
// The npm package name (e.g. react-native-safe-area-context). Used by the
245+
// aggregator's eval-time missing-manifest guard to name the library a
246+
// developer installed (not its Swift target name).
247+
npmName?: string,
244248
};
245249
246250
export type AggregatorInput = {

0 commit comments

Comments
 (0)