Skip to content

Commit 07530b2

Browse files
chrfalchclaude
andcommitted
fix(spm): invalidate Swift explicit-module + build-description caches on flavor hard-fail
The deliberate flavor-flip red build has already planned against the pre-flip graph, precompiling Swift explicit modules from the old flavor's interfaces (a debug graph's -Onone interfaces depend on SwiftOnoneSupport, unavailable in a Release rebuild — Expo hit this as `missing required module 'SwiftOnoneSupport'` in Swift source packages importing precompiled Swift modules). The converge rebuild reused that cache, which is not keyed by anything the flip changes. The hard-fail path now clears $OBJROOT/{ExplicitPrecompiledModules, SwiftExplicitPrecompiledModules,XCBuildData} before exiting. All three go together: the pcm dirs hold the stale modules, and XCBuildData holds the dependency-scan state referencing them by absolute path — deleting either without the other strands the rebuild (both directions empirically proven). Best-effort, gated on $OBJROOT, and confined to the already- failing red build, so green paths never pay it. E2E on a real app: matched Debug green; flip Release red with the invalidation logged; converge rebuild green with a release-flavored, signature-valid embedded framework. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d189c94 commit 07530b2

2 files changed

Lines changed: 96 additions & 9 deletions

File tree

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

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,34 @@ describe('buildSyncAutolinkingScript — swap sandwich + hard-fail', () => {
499499
expect(script.indexOf('exit 1', gate)).toBeGreaterThan(gate);
500500
});
501501

502+
it('invalidates the Swift explicit-module + build-description caches ONLY inside the hard-fail block', () => {
503+
const gate = script.indexOf('if [ "$MISMATCH_PENDING" -eq 1 ]');
504+
const exitIdx = script.indexOf('exit 1', gate);
505+
const rmIdx = script.indexOf('ExplicitPrecompiledModules');
506+
// All three dirs are cleared together (the pcm dirs AND XCBuildData, whose
507+
// scan state references the pcms by absolute path).
508+
expect(script).toContain('ExplicitPrecompiledModules');
509+
expect(script).toContain('SwiftExplicitPrecompiledModules');
510+
expect(script).toContain('XCBuildData');
511+
// The invalidation is INSIDE the hard-fail block: after the trailing swap,
512+
// after the gate, and BEFORE the exit 1.
513+
expect(rmIdx).toBeGreaterThan(script.lastIndexOf('run_swap_flavor'));
514+
expect(rmIdx).toBeGreaterThan(gate);
515+
expect(rmIdx).toBeLessThan(exitIdx);
516+
// Gated on OBJROOT and unconditionally non-fatal (can't mask the exit 1).
517+
expect(script).toContain('if [ -n "${OBJROOT:-}" ]');
518+
// A single rm -rf clears all three (pcm dirs + XCBuildData), non-fatal.
519+
expect(script).toMatch(
520+
/rm -rf[^\n]*ExplicitPrecompiledModules[^\n]*XCBuildData[^\n]*true/,
521+
);
522+
// NEVER present in the sync-only pre-action variant.
523+
for (const needle of ['ExplicitPrecompiledModules', 'XCBuildData']) {
524+
expect(
525+
buildSchemePreActionScript('../node_modules/react-native'),
526+
).not.toContain(needle);
527+
}
528+
});
529+
502530
it('parses under `sh -n`', () => {
503531
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-'));
504532
const file = path.join(dir, 'phase.sh');
@@ -559,17 +587,24 @@ describe('sync script — shell behavior of HARD-FAIL + CONVERGE', () => {
559587

560588
afterEach(() => fs.rmSync(dir, {recursive: true, force: true}));
561589

562-
function run(scriptFile, configuration) {
590+
function run(scriptFile, configuration, objroot) {
591+
const env = {
592+
...process.env,
593+
SRCROOT: srcroot,
594+
BUILT_PRODUCTS_DIR: path.join(dir, 'products'),
595+
NODE_BINARY: process.execPath,
596+
CONFIGURATION: configuration,
597+
FAKE_PIN_FILE: pinFile,
598+
};
599+
// Control OBJROOT explicitly (don't leak the host's).
600+
if (objroot != null) {
601+
env.OBJROOT = objroot;
602+
} else {
603+
delete env.OBJROOT;
604+
}
563605
try {
564606
execFileSync('/bin/sh', [scriptFile], {
565-
env: {
566-
...process.env,
567-
SRCROOT: srcroot,
568-
BUILT_PRODUCTS_DIR: path.join(dir, 'products'),
569-
NODE_BINARY: process.execPath,
570-
CONFIGURATION: configuration,
571-
FAKE_PIN_FILE: pinFile,
572-
},
607+
env,
573608
stdio: ['ignore', 'pipe', 'pipe'],
574609
encoding: 'utf8',
575610
});
@@ -600,4 +635,39 @@ describe('sync script — shell behavior of HARD-FAIL + CONVERGE', () => {
600635
const {code} = run(setup('debug'), 'Debug');
601636
expect(code).toBe(0);
602637
});
638+
639+
it('on a true-mismatch hard-fail, invalidates the pcm + XCBuildData caches (dirs gone, still exit 1)', () => {
640+
const scriptFile = setup('debug'); // start debug, build Release → mismatch
641+
const objroot = path.join(dir, 'obj');
642+
const cache = path.join(objroot, 'ExplicitPrecompiledModules');
643+
const xcbuild = path.join(objroot, 'XCBuildData');
644+
fs.mkdirSync(cache, {recursive: true});
645+
fs.mkdirSync(xcbuild, {recursive: true});
646+
fs.writeFileSync(path.join(cache, 'stale.pcm'), 'x');
647+
const {code} = run(scriptFile, 'Release', objroot);
648+
expect(code).toBe(1);
649+
// Both the pcm dir AND the build-description/scan cache are gone — deleting
650+
// one without the other strands the rebuild.
651+
expect(fs.existsSync(cache)).toBe(false);
652+
expect(fs.existsSync(xcbuild)).toBe(false);
653+
});
654+
655+
it('leaves the pcm + XCBuildData caches intact on a matched (green) build', () => {
656+
const scriptFile = setup('debug'); // matched Debug build → no hard-fail
657+
const objroot = path.join(dir, 'obj');
658+
const cache = path.join(objroot, 'ExplicitPrecompiledModules');
659+
const xcbuild = path.join(objroot, 'XCBuildData');
660+
fs.mkdirSync(cache, {recursive: true});
661+
fs.mkdirSync(xcbuild, {recursive: true});
662+
const {code} = run(scriptFile, 'Debug', objroot);
663+
expect(code).toBe(0);
664+
expect(fs.existsSync(cache)).toBe(true);
665+
expect(fs.existsSync(xcbuild)).toBe(true);
666+
});
667+
668+
it('mismatch still exits 1 cleanly when OBJROOT is unset (no rm error noise)', () => {
669+
const {code, out} = run(setup('debug'), 'Release', undefined);
670+
expect(code).toBe(1);
671+
expect(out).not.toMatch(/rm:/);
672+
});
603673
});

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,23 @@ fi
629629
# rebuild is green and nothing stale ever ships. Fires only when the swap ran and
630630
# actually corrected a mismatch (the CLI already printed the actionable error:).
631631
if [ "$MISMATCH_PENDING" -eq 1 ]; then
632+
# Best-effort: this deliberate red build already PLANNED against the pre-flip
633+
# graph, precompiling Swift explicit modules from the debug -Onone interfaces
634+
# (which depend on SwiftOnoneSupport). Those caches are NOT keyed by anything
635+
# the flavor flip changed, so the Release rebuild would reuse them and fail
636+
# ('missing required module SwiftOnoneSupport'). Drop them so the rebuild
637+
# re-precompiles against the corrected graph. XCBuildData MUST go WITH the pcm
638+
# dirs: it holds the build-description / dependency-scan state that references
639+
# the precompiled modules by ABSOLUTE path, so deleting the pcm dirs alone
640+
# strands the rebuild ('module file .../….pcm not found'), and deleting
641+
# XCBuildData alone leaves the stale debug pcms in place — both directions are
642+
# empirically proven broken, so all three are cleared together. Both pcm dir
643+
# namings exist across Xcode versions; guarded + '|| true' so it can NEVER mask
644+
# the deliberate build failure below.
645+
if [ -n "\${OBJROOT:-}" ]; then
646+
echo "Invalidating Swift explicit-module + build-description caches so the rebuild re-precompiles against the switched flavor..."
647+
rm -rf "$OBJROOT/ExplicitPrecompiledModules" "$OBJROOT/SwiftExplicitPrecompiledModules" "$OBJROOT/XCBuildData" 2>/dev/null || true
648+
fi
632649
echo "error: React Native SwiftPM frameworks were on the wrong flavor for this \${CONFIGURATION:-} build and have been switched — build again (one-time after a configuration change)."
633650
exit 1
634651
fi

0 commit comments

Comments
 (0)