From 0b528ab7ac0d899fd1af0291464c2e77c8ea4837 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 5 Aug 2026 07:48:11 -0700 Subject: [PATCH] fix: make an interrupted React-Core-prebuilt swap recoverable replace-rncore-version.js wrote .last_build_configuration only after it had already replaced React.xcframework. A build cancelled between the two steps left the marker naming a flavor that was no longer on disk, and when no marker was present at all the script assumed the on-disk flavor was Debug. Either way the next build for that configuration took the "no need to replace" path and linked against the other configuration's core, which fails with undefined C++ symbols. Nothing corrected the state afterwards, including a clean, because the pod directory survives it. The marker is now written before the framework is touched, holding a sentinel that is not a valid configuration, so a run that finds it knows the previous swap did not finish and replaces again. The fresh install path also records the configuration it assumed instead of leaving that state implicit, which is sound because a swap can no longer leave the marker missing. --- .../__tests__/replace-rncore-version-test.js | 79 +++++++++++++++++++ .../scripts/replace-rncore-version.js | 30 ++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/react-native/scripts/__tests__/replace-rncore-version-test.js b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js index f435e2fa1a9..ad077aa93dd 100644 --- a/packages/react-native/scripts/__tests__/replace-rncore-version-test.js +++ b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js @@ -19,6 +19,18 @@ const path = require('node:path'); const VERSION = '0.87.0-test'; const SLICE = 'ios-arm64_x86_64-simulator'; const BINARY = path.join(SLICE, 'React.framework', 'React'); +const SCRIPT = require.resolve('../replace-rncore-version.js'); +const MARKER = path.join('React-Core-prebuilt', '.last_build_configuration'); + +// Runs the script the way the "[RNCore] Replace React Native Core for the right +// configuration" build phase does, from Pods/ and through the CLI entry point. +function runScriptPhase(podsRoot, configuration) { + return execFileSync( + process.execPath, + [SCRIPT, '-c', configuration, '-r', VERSION, '-p', podsRoot], + {cwd: podsRoot, encoding: 'utf8'}, + ); +} function writeFile(filePath, contents) { fs.mkdirSync(path.dirname(filePath), {recursive: true}); @@ -119,6 +131,73 @@ describe('replaceRNCoreConfiguration', () => { expect(fs.readFileSync(expoModuleMap, 'utf8')).toBe('module React {}\n'); }); + // Regression tests for #57598. The marker used to be written only after the + // framework had already been replaced, so a build cancelled in between left it + // naming a flavor that was no longer on disk. Every later build for that + // flavor then took the "nothing to do" path and linked against the other + // configuration's core, which fails with undefined C++ symbols and which a + // clean does not undo because the pod directory survives it. + describe('marker bookkeeping', () => { + const marker = () => path.join(podsRoot, MARKER); + const binary = () => + fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'); + + it('records the configuration when it skips a fresh install', () => { + // `pod install` leaves the debug flavor and no marker, so a Debug build + // has nothing to swap. It still has to write down what is on disk, + // otherwise the state stays implicit and stays unverifiable. + expect(fs.existsSync(marker())).toBe(false); + + runScriptPhase(podsRoot, 'Debug'); + + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + expect(binary()).toBe('binary-Debug'); + }); + + it('invalidates the marker before it touches the framework', () => { + fs.writeFileSync(marker(), 'Debug'); + // Drop the tarball so the Release run fails once it is already under way, + // standing in for a build cancelled part way through the swap. + fs.rmSync( + path.join( + podsRoot, + 'ReactNativeCore-artifacts', + `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`, + ), + ); + + expect(() => runScriptPhase(podsRoot, 'Release')).toThrow(); + + // The marker must no longer claim Debug: the framework may already have + // been swapped, and a Debug build that trusts it would silently skip. + expect(fs.readFileSync(marker(), 'utf8')).not.toBe('Debug'); + }); + + it('replaces the framework when the marker shows an unfinished swap', () => { + buildTarball(podsRoot, 'Debug'); + // A swap that was interrupted: the Release flavor is on disk and the + // marker never got its final value. + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + fs.writeFileSync(marker(), 'in-progress'); + expect(binary()).toBe('binary-Release'); + + runScriptPhase(podsRoot, 'Debug'); + + expect(binary()).toBe('binary-Debug'); + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + }); + + it('still skips when the marker already matches the configuration', () => { + fs.writeFileSync(marker(), 'Release'); + const before = binary(); + + const output = runScriptPhase(podsRoot, 'Release'); + + expect(output).toContain('No need to replace React-Core-prebuilt'); + expect(binary()).toBe(before); + }); + }); + it('fails when the tarball has no React.xcframework', () => { const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-')); writeFile(path.join(stage, 'unrelated.txt'), 'nope'); diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index dbfc11ecaa4..07e4edba740 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -18,6 +18,11 @@ const yargs = require('yargs'); const LAST_BUILD_FILENAME = 'React-Core-prebuilt/.last_build_configuration'; +// Stored in LAST_BUILD_FILENAME while the framework on disk is being swapped. +// It is not a valid configuration, so a run that finds it knows the previous +// swap did not finish and the flavor on disk cannot be trusted. +const REPLACEMENT_IN_PROGRESS = 'in-progress'; + function validateBuildConfiguration(configuration /*: string */) { if (!['Debug', 'Release'].includes(configuration)) { throw new Error(`Invalid configuration ${configuration}`); @@ -42,13 +47,24 @@ function shouldReplaceRnCoreConfiguration(configuration /*: string */) { ); return false; } + // Anything else, including REPLACEMENT_IN_PROGRESS left by a swap that was + // cancelled or killed, means the flavor on disk is not the requested one or + // is unknown. Replace it. + return true; } - // Assumption: if there is no stored last build, we assume that it was build for debug. - if (!fileExists && configuration === 'Debug') { + // No marker means `pod install` has just laid the pod down and nothing has + // swapped it since, because a swap records REPLACEMENT_IN_PROGRESS before it + // touches the framework. The podspec source is always the debug tarball (see + // resolve_podspec_source in scripts/cocoapods/rncore.rb), so the flavor on + // disk is Debug and a Debug build has nothing to do. + if (configuration === 'Debug') { console.log( 'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt', ); + // Record the assumption rather than leaving it implicit, so the state is + // readable and a later run never has to make it again. + updateLastBuildConfiguration(configuration); return false; } @@ -130,6 +146,10 @@ function updateLastBuildConfiguration(configuration /*: string */) { fs.writeFileSync(LAST_BUILD_FILENAME, configuration); } +function markReplacementInProgress() /*: void */ { + fs.writeFileSync(LAST_BUILD_FILENAME, REPLACEMENT_IN_PROGRESS); +} + function main( configuration /*: string */, version /*: string */, @@ -142,6 +162,12 @@ function main( return; } + // Invalidate the marker before the framework is touched. A build cancelled + // between the swap and the update used to leave the marker naming a flavor + // that was no longer on disk, and every later build for that flavor then took + // the skip path and linked against the wrong core, which a clean does not + // undo. Recording the swap first makes an interrupted run recoverable. + markReplacementInProgress(); replaceRNCoreConfiguration(configuration, version, podsRoot); updateLastBuildConfiguration(configuration); console.log('Done replacing React Native prebuilt');