Skip to content

fix(ios): release the Expo Updates startup gate when the launch already finished - #529

Open
dustinsoftware wants to merge 1 commit into
callstack:mainfrom
dustinsoftware:fix/ios-updates-startup-finished-launch
Open

dustinsoftware wants to merge 1 commit into
callstack:mainfrom
dustinsoftware:fix/ios-updates-startup-finished-launch

Conversation

@dustinsoftware

@dustinsoftware dustinsoftware commented Nov 12, 2026 •

Copy link
Copy Markdown

This is an upstream port of a patch in our shipping app. The patch works around a race that impacts Expo 57 and 58 using react-native-brownfield, which can sometimes result in a blank screen on launch. As far as I can tell, brownfield 3.6.1 did not have this issue (which is what we were using before). First seen on 5.1.1.

What

ExpoUpdatesStartup now releases its waiters when a started expo-updates controller has already finished launching, even when there is no launch asset, and re-checks after it assigns its delegate.

Why

Once the controller is started, the gate released only on launchAssetUrl() != nil or on the AppControllerDelegate callback. expo-updates sends that callback once per start, and only when a delegate is already set (EnabledAppController.startupProcedureDidLaunch, expo-updates 58.0.21 EnabledAppController.swift:121-131). Two cases therefore waited forever:

  1. The host started the controller, and the launch had already failed with no embedded main.jsbundle, so launchAssetUrl() is nil and the callback had already been skipped.
  2. The launch finished, successfully or not, between the gate's first check and controller.delegate = self.

How

  • hasFinishedLaunching(_:): launchAssetUrl() != nil, or getConstantsForModule().toModuleConstantsMap()["launchDuration"] is non-nil. expo-updates sets startupEndTime, and with it launchDuration, at the start of startupProcedureDidLaunch, which runs for both normal and emergency launches. isEmergencyLaunch covers failures only, and isStartupProcedureRunning stays true until the background update check ends, so neither works as the signal.
  • The fast path accepts controller.isStarted && hasFinishedLaunching(controller).
  • After controller.delegate = self, an already-started controller is checked again and the waiters are released at once if it has finished. If the delegate callback also arrives, it finds no waiters left and does nothing.
  • The delegate callback and the re-check share one releaseCallbacks().
  • launchAssetUrl() is still never read before start(), so the expo-updates 57 trap fixed in fix(ios): don't read the expo-updates launch asset before the controller starts #508 stays fixed.

Test plan

  • packages/react-native-brownfield/tests/ios/run-expo-startup.sh: the stub gains getConstantsForModule() and a hook that runs just before the delegate is assigned. Added cases: launch already failed before the first request; launch finishes (success and failure) before the delegate is assigned. Passes at -Onone and -O; the same script with the main version of ExpoUpdatesStartup.swift fails on the first added case.
  • A separate 11-scenario macOS harness with a stub controller mirroring EnabledAppController (launch time, delegate-only-if-set, emergency launch with and without an embedded bundle): main 8/11 (the three cases above hang), this branch 11/11, at -Onone and -O.
  • swiftc -typecheck of the changed file against a built expo-updates 58.0.21 EXUpdates module (iOS simulator, arm64): no errors or warnings.
  • Not run: on-device test in an example app with expo-updates enabled; Detox e2e.

Changeset

.changeset/ios-updates-startup-finished-launch.md: patch for @callstack/react-native-brownfield.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gf7Xx7K4DTaYw2pLT2TJF7

…dy finished

The gate treated a started controller as ready only when launchAssetUrl()
was non-nil, and otherwise waited for the AppControllerDelegate callback.
expo-updates sends that callback once per start, and only when a delegate
is already set at that moment. A launch that failed before the gate ran
(nil launch asset), or one that finished between the gate's first check and
`controller.delegate = self`, therefore never released the waiters.

A started controller now counts as ready when it has a launch asset or
reports a launchDuration, which expo-updates sets in
startupProcedureDidLaunch for both normal and emergency launches. The gate
checks again after assigning its delegate, and both paths release through
one releaseCallbacks().
@dustinsoftware
dustinsoftware marked this pull request as ready for review November 12, 2026 17:13
Copilot AI balanced review requested due to automatic review settings November 12, 2026 17:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues block approval of the iOS Expo Updates startup-race fix.

No open issues 🎉

What changed in this PR

Fixes an iOS Expo Updates startup race that can leave brownfield launches waiting indefinitely when the host-started controller finishes before the startup gate receives its callback. Preserves the Expo 57 safeguard against reading the launch asset before the controller starts.

Changes:

  • Detects completed launches even without a launch asset and rechecks after delegate assignment.
  • Centralizes waiter release and adds regression cases for failed launches and delegate-registration races.
  • Adds a patch changeset for @callstack/react-native-brownfield.
File Description
packages/​react-native-brownfield/​tests/​ios/​run-expo-startup.sh Extends the controller stub and tests missed-callback scenarios.
packages/​react-native-brownfield/​ios/​Expo/​ExpoUpdatesStartup.swift Detects finished launches and releases pending startup callbacks.
.changeset/​ios-updates-startup-finished-launch.md Documents the startup-gate fix for a patch release.

Review effort: Balanced

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants