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');