Skip to content

Commit f62a520

Browse files
committed
iOS: gate CodePush-triggered reloads on a Fabric readiness signal
1 parent b9d0351 commit f62a520

1 file changed

Lines changed: 141 additions & 10 deletions

File tree

ios/CodePush/CodePush.m

Lines changed: 141 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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

4147
RCT_EXPORT_MODULE()
@@ -45,6 +51,11 @@ @implementation CodePush {
4551
// These constants represent emitted events
4652
static 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
4960
static NSString *const DeploymentFailed = @"DeploymentFailed";
5061
static 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

Comments
 (0)