Skip to content

iOS prebuilt core: replace-rncore-version.js assumes Debug when .last_build_configuration is missing, wedging Debug builds against a Release-flavored core #57598

Description

@Coniferish

Description

scripts/replace-rncore-version.js (the "[RNDeps] Replace React Native Core for the right configuration" build phase for the prebuilt iOS core) decides whether to swap the extracted React-Core-prebuilt flavor by comparing the requested configuration against the .last_build_configuration marker file. When the marker file is missing, it assumes the on-disk flavor is Debug:

// Assumption: if there is no stored last build, we assume that it was build for debug.
if (!fileExists && configuration === 'Debug') {
  console.log(
    'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt',
  );
  return false;
}

Two problems compound here:

  1. The skip branch never writes the marker. If the assumption is wrong — the framework on disk is actually the Release flavor — every subsequent Debug build takes this same branch forever. The state is self-perpetuating: nothing ever corrects it, including xcodebuild clean.
  2. The swap is not atomic. main() runs replaceRNCoreConfiguration() (tar extraction) first and updateLastBuildConfiguration() (marker write) after. A build cancelled/killed between the two leaves a Release-flavored framework with no marker — exactly the state that trips problem 1 on the next Debug build.

Both are still present on main as of today (the extraction itself was recently hardened with temp-dir extraction and verification, but the missing-marker assumption and the extract-then-mark ordering are unchanged).

Why the resulting failure is hard to diagnose

A Debug link against the Release-flavored core fails with undefined C++ symbols:

Undefined symbols for architecture arm64:
  "VTT for facebook::react::Props" / "VTT for facebook::react::BaseViewProps" /
  "VTT for facebook::react::YogaStylableProps" /
  "vtable for facebook::react::DebugStringConvertible"
  referenced from: construction vtables in libRNSVG.a, libRNGestureHandler.a,
  libExpoModulesCore.a, ... (any Fabric component archive)
  "_OBJC_CLASS_$_RCTPackagerConnection", "_OBJC_CLASS_$_RCTPerfMonitor",
  facebook::hermes::inspector_modern::chrome::enableDebugging(...), ...

Mechanism: without NDEBUG, react/debug/flags.h defines REACT_NATIVE_DEBUGRN_DEBUG_STRING_CONVERTIBLE=1, which gives Props/DebugStringConvertible out-of-line virtual methods. Debug-compiled third-party Fabric pods therefore emit external references to those vtables/VTTs — and only the debug flavor of the prebuilt core exports them (verified with nm: the release flavor exports neither the three VTTs nor DebugStringConvertible's vtable, nor the debug-only ObjC/inspector classes). Release-configuration compiles never reference them (the vtables are emitted with vague linkage into every archive), which is why Release builds keep working — making it look like the last added native dependency broke Debug builds. In our case it was misattributed to react-native-svg for a full day.

Steps to reproduce

In any RN 0.81+ iOS app using the prebuilt core (RCT_USE_PREBUILT_RNCORE=1, the default with prebuilt artifacts present):

  1. Build Release for simulator once — the phase swaps Pods/React-Core-prebuilt to the release flavor and writes .last_build_configuration = Release.
  2. Simulate the interrupted-swap window (or any cleanup tool touching Pods): rm ios/Pods/React-Core-prebuilt/.last_build_configuration
  3. Build Debug. The phase logs No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt and the link fails with the undefined facebook::react vtables/VTTs above.
  4. Every subsequent Debug build fails identically; clean does not help. (Recovery is also confounded by the stale copy under Build/Products/…/XCFrameworkIntermediates/React-Core-prebuilt, which the CocoaPods copy-xcframeworks phase can skip refreshing and which clean never removes — but the root defect is the marker logic above.)

Conversely, an interrupted Debug→Release swap silently ships the debug-flavored core in a Release build (links fine, assertions on) — the same defect with a quieter failure mode.

Suggested fix

Any of these would close the hole:

  • Write the marker at initial extraction time (podspec prepare_command extracts the debug tarball — writing Debug there makes "marker missing" impossible in a healthy tree), and/or
  • Treat a missing marker as unknown and perform the replace instead of assuming Debug (one redundant extraction on first build vs. an unrecoverable wedge), and
  • Write the marker's previous value → remove-before-extract or write marker only after successful extraction within the same guarded operation, so an interrupted swap re-runs rather than wedges.

React Native Version

0.81.5 (Expo SDK 54; repro'd on macOS 26.5.1 / Xcode 26.6, new architecture enabled). The relevant code is unchanged on main.

Affected Platforms

Build - iOS (simulator and device Debug builds)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions