11#if __has_include(<React/RCTAssert.h>)
22#import < React/RCTAssert.h>
33#import < React/RCTBridgeModule.h>
4+ #import < React/RCTConstants.h>
45#import < React/RCTConvert.h>
56#import < React/RCTEventDispatcher.h>
67#import < React/RCTRootView.h>
@@ -36,6 +37,11 @@ @implementation CodePush {
3637 BOOL _allowed;
3738 BOOL _restartInProgress;
3839 NSMutableArray *_restartQueue;
40+
41+ // Reload readiness tracking. See -registerSettleObservers for details.
42+ BOOL _instanceSettled;
43+ // YES exactly while a reload is parked waiting for the instance to settle.
44+ BOOL _reloadPending;
3945}
4046
4147RCT_EXPORT_MODULE ()
@@ -45,6 +51,11 @@ @implementation CodePush {
4551// These constants represent emitted events
4652static NSString *const DownloadProgressEvent = @" CodePushDownloadProgress" ;
4753
54+ // How long a parked reload waits for a settle signal before reloading anyway.
55+ // See -registerSettleObservers for why this exists and why the exact value is
56+ // not critical.
57+ static const NSTimeInterval SettleTimeout = 2.0 ;
58+
4859// These constants represent valid deployment statuses
4960static NSString *const DeploymentFailed = @" DeploymentFailed" ;
5061static NSString *const DeploymentSucceeded = @" DeploymentSucceeded" ;
@@ -395,15 +406,104 @@ - (instancetype)init
395406 _allowed = YES ;
396407 _restartInProgress = NO ;
397408 _restartQueue = [NSMutableArray arrayWithCapacity: 1 ];
398-
409+
399410 self = [super init ];
400411 if (self) {
412+ [self registerSettleObservers ];
401413 [self initializeUpdateAfterRestart ];
402414 }
403415
404416 return self;
405417}
406418
419+ #pragma mark - Reload readiness tracking
420+
421+ /*
422+ * Why a reload can be deferred, and why it can also time out.
423+ *
424+ * 1. The problem. A CodePush reload tears down the current RCTInstance. Right
425+ * after bundle evaluation, RN enqueues -[RCTFabricSurface start] on a
426+ * background queue for that same instance. Tearing the instance down while
427+ * that block is in flight crashes inside RN's mounting layer. The window
428+ * opens when the block is enqueued and closes a couple of queue hops later,
429+ * so it lasts under a millisecond when idle and tens of milliseconds under
430+ * load. CI simulators are exactly where it stretches.
431+ *
432+ * 2. So we wait it out. This module is initialized *during* bundle evaluation,
433+ * which is before the window opens, so every instance starts out unsettled
434+ * and -loadBundle parks the reload until we see the surface get past its
435+ * startup.
436+ *
437+ * 3. What we wait for. The two signals below are the terminal outcomes of an
438+ * instance's startup, the same pair expo-updates watches in its error
439+ * recovery flow:
440+ *
441+ * - RCTContentDidAppearNotification: Fabric posts this from
442+ * RCTRootComponentView on the first child mount. That needs a JS render
443+ * and commit, so it lands well after the block we're racing.
444+ * - RCTJavaScriptDidFailToLoadNotification: the bundle never evaluated, so
445+ * no surface startup will follow.
446+ *
447+ * 4. Neither signal is guaranteed. A first render that returns nil mounts no
448+ * child, so RCTContentDidAppear never arrives — think of a splash gate or a
449+ * fonts/auth loader. That app shape also runs sync() with an IMMEDIATE
450+ * install before it shows any UI, which makes it the most likely caller to
451+ * hit the parked path in the first place.
452+ *
453+ * 5. Hence the timeout in -loadBundle. Without an escape, a parked reload never
454+ * fires, and since _restartInProgress stays YES it also blocks every later
455+ * restartApp() for the lifetime of the process. Letting the timeout expire
456+ * is safe: the window from step 1 shut long before it. SettleTimeout is
457+ * therefore generous on purpose, so the real signal wins whenever it exists
458+ * and the timeout stays a last resort.
459+ *
460+ * There is deliberately nothing here to mark an instance unsettled again. A
461+ * reload from any source destroys the RCTInstance and rebuilds its TurboModules,
462+ * so the module observing these notifications never survives one.
463+ */
464+ - (void )registerSettleObservers
465+ {
466+ NSNotificationCenter *center = [NSNotificationCenter defaultCenter ];
467+ [center addObserver: self
468+ selector: @selector (instanceDidSettle )
469+ name: RCTContentDidAppearNotification
470+ object: nil ];
471+ [center addObserver: self
472+ selector: @selector (instanceDidSettle )
473+ name: RCTJavaScriptDidFailToLoadNotification
474+ object: nil ];
475+ }
476+
477+ - (void )instanceDidSettle
478+ {
479+ // A reload tears the instance down synchronously, so releasing the reload
480+ // inline would destroy the surface partway through the mount that just
481+ // notified us.
482+ dispatch_async (dispatch_get_main_queue (), ^{
483+ self->_instanceSettled = YES ;
484+
485+ if (self->_reloadPending ) {
486+ CPLog (@" Instance settled. Restarting app." );
487+ [self releasePendingReload ];
488+ }
489+ });
490+ }
491+
492+ /*
493+ * Fires a parked reload, if one is still parked. Called both by the settle
494+ * signal and by the timeout in -loadBundle, so whichever arrives first wins and
495+ * the other becomes a no-op. Main queue only.
496+ */
497+ - (void )releasePendingReload
498+ {
499+ if (!_reloadPending) {
500+ return ;
501+ }
502+
503+ _reloadPending = NO ;
504+ [self performBundleReload ];
505+ }
506+
407507/*
408508 * This method is used when the app is started to either
409509 * initialize a pending update or rollback a faulty update
@@ -547,18 +647,49 @@ - (void)loadBundle
547647 // This needs to be async dispatched because the bridge is not set on init
548648 // when the app first starts, therefore rollbacks will not take effect.
549649 dispatch_async (dispatch_get_main_queue (), ^{
550- // If the current bundle URL is using http(s), then assume the dev
551- // is debugging and therefore, shouldn't be redirected to a local
552- // file (since Chrome wouldn't support it). Otherwise, update
553- // the current bundle URL to point at the latest update
554- if ([CodePush isUsingTestConfiguration ] || ![super .bridge.bundleURL.scheme hasPrefix: @" http" ]) {
555- [super .bridge setValue: [CodePush bundleURL ] forKey: @" bundleURL" ];
650+ if (!self->_instanceSettled ) {
651+ // Reloading now could tear this instance down while its Fabric surface
652+ // is still starting up, so park the reload until the instance settles.
653+ // The timeout below is the escape hatch for a settle signal that never
654+ // comes. Both are explained in -registerSettleObservers.
655+ CPLog (@" Restart deferred until the current instance has settled." );
656+ self->_reloadPending = YES ;
657+
658+ // Weak, so that a module outliving the teardown of its own instance
659+ // cannot reload against a bridge that no longer belongs to it.
660+ __weak __typeof (self) weakSelf = self;
661+ dispatch_after (dispatch_time (DISPATCH_TIME_NOW , (int64_t )(SettleTimeout * NSEC_PER_SEC )),
662+ dispatch_get_main_queue (), ^{
663+ __typeof (self) strongSelf = weakSelf;
664+ if (strongSelf && strongSelf->_reloadPending ) {
665+ CPLog (@" Timed out waiting for the current instance to settle. Restarting app anyway." );
666+ [strongSelf releasePendingReload ];
667+ }
668+ });
669+ return ;
556670 }
557671
558- RCTTriggerReloadCommandListeners ( @" react-native-code-push: Restart " ) ;
672+ [ self performBundleReload ] ;
559673 });
560674}
561675
676+ /*
677+ * Performs the actual reload. Must be called on the main queue, and only once the
678+ * current instance has settled or the wait for it timed out. See -loadBundle.
679+ */
680+ - (void )performBundleReload
681+ {
682+ // If the current bundle URL is using http(s), then assume the dev
683+ // is debugging and therefore, shouldn't be redirected to a local
684+ // file (since Chrome wouldn't support it). Otherwise, update
685+ // the current bundle URL to point at the latest update
686+ if ([CodePush isUsingTestConfiguration ] || ![super .bridge.bundleURL.scheme hasPrefix: @" http" ]) {
687+ [super .bridge setValue: [CodePush bundleURL ] forKey: @" bundleURL" ];
688+ }
689+
690+ RCTTriggerReloadCommandListeners (@" react-native-code-push: Restart" );
691+ }
692+
562693/*
563694 * This method is used when a pending update never finished loading (i.e. it
564695 * crashed before calling notifyApplicationReady) and needs to be rolled back
@@ -804,7 +935,7 @@ - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending
804935
805936 _restartInProgress = NO ;
806937 if ([_restartQueue count ] > 0 ) {
807- BOOL buf = [_restartQueue valueForKey: @" @ firstObject" ];
938+ BOOL buf = [[ _restartQueue firstObject ] boolValue ];
808939 [_restartQueue removeObjectAtIndex: 0 ];
809940 [self restartAppInternal: buf];
810941 }
@@ -1010,7 +1141,7 @@ - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending
10101141
10111142 if ([_restartQueue count ] > 0 ) {
10121143 CPLog (@" Executing pending restart." );
1013- BOOL buf = [_restartQueue valueForKey: @" @ firstObject" ];
1144+ BOOL buf = [[ _restartQueue firstObject ] boolValue ];
10141145 [_restartQueue removeObjectAtIndex: 0 ];
10151146 [self restartAppInternal: buf];
10161147 }
0 commit comments