Skip to content

Commit bc5ee3f

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

1 file changed

Lines changed: 157 additions & 10 deletions

File tree

ios/CodePush/CodePush.m

Lines changed: 157 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ @implementation CodePush {
3636
BOOL _allowed;
3737
BOOL _restartInProgress;
3838
NSMutableArray *_restartQueue;
39+
40+
// Reload readiness tracking. See -registerSettleObserver for details.
41+
BOOL _instanceSettled;
42+
// YES exactly while a reload is parked waiting for the instance to settle.
43+
BOOL _reloadPending;
3944
}
4045

4146
RCT_EXPORT_MODULE()
@@ -45,6 +50,11 @@ @implementation CodePush {
4550
// These constants represent emitted events
4651
static NSString *const DownloadProgressEvent = @"CodePushDownloadProgress";
4752

53+
// How long a parked reload waits for a settle signal before reloading anyway.
54+
// See -registerSettleObserver for why this exists and why the exact value is
55+
// not critical.
56+
static const NSTimeInterval SettleTimeout = 5.0;
57+
4858
// These constants represent valid deployment statuses
4959
static NSString *const DeploymentFailed = @"DeploymentFailed";
5060
static NSString *const DeploymentSucceeded = @"DeploymentSucceeded";
@@ -395,15 +405,104 @@ - (instancetype)init
395405
_allowed = YES;
396406
_restartInProgress = NO;
397407
_restartQueue = [NSMutableArray arrayWithCapacity:1];
398-
408+
399409
self = [super init];
400410
if (self) {
411+
[self registerSettleObserver];
401412
[self initializeUpdateAfterRestart];
402413
}
403414

404415
return self;
405416
}
406417

418+
#pragma mark - Immediate update + reload readiness tracking
419+
420+
/*
421+
* In case of an immediate bundle update, we need to tear down the current
422+
* RCTInstance at the right time.
423+
*
424+
* Right after bundle evaluation, RN enqueues -[RCTFabricSurface start] on a
425+
* background queue for that same instance. Tearing the instance down while
426+
* that block is in flight crashes inside RN's mounting layer. This module is
427+
* initialized *during* bundle evaluation, so in case of immediate update
428+
* mode, -loadBundle needs to park the reload until we see the surface get
429+
* past its startup.
430+
*
431+
* The signal we wait for is RCTContentDidAppearNotification. Fabric posts it
432+
* from RCTRootComponentView on the first child mount. That needs a JS render
433+
* and commit, so by that time the surface startup is done.
434+
*
435+
* Note: the signal is not guaranteed, so we also need a fallback timeout. If
436+
* the first render returns nil and mounts no child, RCTContentDidAppear
437+
* never arrives. Think of a splash gate or a fonts/auth loader. That kind of
438+
* app could run codepush.sync() with an IMMEDIATE install before it shows
439+
* any UI.
440+
*
441+
* Running the teardown after the timeout fires is safe because the delay is
442+
* large enough that the surface startup already completed by that time.
443+
*/
444+
- (void)registerSettleObserver
445+
{
446+
[[NSNotificationCenter defaultCenter] addObserver:self
447+
selector:@selector(instanceDidSettle)
448+
name:RCTContentDidAppearNotification
449+
object:nil];
450+
}
451+
452+
- (void)instanceDidSettle
453+
{
454+
// Note: A reload tears the instance down synchronously.
455+
// Calling -releasePendingReload directly would destroy the surface partway through
456+
// the mount that just notified us.
457+
dispatch_async(dispatch_get_main_queue(), ^{
458+
self->_instanceSettled = YES;
459+
460+
if (self->_reloadPending) {
461+
CPLog(@"Instance settled.");
462+
[self releasePendingReload];
463+
}
464+
});
465+
}
466+
467+
/*
468+
* Fires a parked reload, if one is still parked. Called by the settle signal, by
469+
* the timeout in -loadBundle and by -allow, so whichever arrives first wins. Main queue only.
470+
*/
471+
- (void)releasePendingReload
472+
{
473+
if (!_reloadPending) {
474+
return;
475+
}
476+
477+
if (!_allowed) {
478+
CPLog(@"Restart stays parked because restarts are disallowed.");
479+
return;
480+
}
481+
482+
CPLog(@"Restarting app.");
483+
_reloadPending = NO;
484+
[self performBundleReload];
485+
}
486+
487+
/*
488+
* Cancels a parked reload, if one is parked.
489+
* Main queue only.
490+
*/
491+
- (void)cancelPendingReload
492+
{
493+
if (!_reloadPending) {
494+
return;
495+
}
496+
497+
CPLog(@"Cleared a parked restart.");
498+
_reloadPending = NO;
499+
500+
// The restart that parked it still holds _restartInProgress, so we clear that too.
501+
// Otherwise every later restart request would queue behind a restart that is
502+
// never going to happen.
503+
_restartInProgress = NO;
504+
}
505+
407506
/*
408507
* This method is used when the app is started to either
409508
* initialize a pending update or rollback a faulty update
@@ -547,18 +646,55 @@ - (void)loadBundle
547646
// This needs to be async dispatched because the bridge is not set on init
548647
// when the app first starts, therefore rollbacks will not take effect.
549648
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"];
649+
if (!self->_instanceSettled) {
650+
CPLog(@"Restart deferred until the current instance has settled.");
651+
self->_reloadPending = YES;
652+
653+
// Weak, so that a module outliving the teardown of its own instance (because of the timer)
654+
// cannot reload against a bridge that no longer belongs to it.
655+
__weak __typeof(self) weakSelf = self;
656+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SettleTimeout * NSEC_PER_SEC)),
657+
dispatch_get_main_queue(), ^{
658+
__typeof(self) strongSelf = weakSelf;
659+
if (!strongSelf) {
660+
return;
661+
}
662+
663+
// The timeout is long enough that the surface startup has completed by now,
664+
// whether or not the signal ever arrived. Treating the instance as settled
665+
// from here on means a later restart doesn't park and wait all over again,
666+
// which matters for an app that never posts the signal at all.
667+
strongSelf->_instanceSettled = YES;
668+
669+
if (strongSelf->_reloadPending) {
670+
CPLog(@"Timed out waiting for the current instance to settle.");
671+
[strongSelf releasePendingReload];
672+
}
673+
});
674+
return;
556675
}
557676

558-
RCTTriggerReloadCommandListeners(@"react-native-code-push: Restart");
677+
[self performBundleReload];
559678
});
560679
}
561680

681+
/*
682+
* Performs the actual reload. Must be called on the main queue, and only once the
683+
* current instance has settled or the wait for it timed out. See -loadBundle.
684+
*/
685+
- (void)performBundleReload
686+
{
687+
// If the current bundle URL is using http(s), then assume the dev
688+
// is debugging and therefore, shouldn't be redirected to a local
689+
// file (since Chrome wouldn't support it). Otherwise, update
690+
// the current bundle URL to point at the latest update
691+
if ([CodePush isUsingTestConfiguration] || ![super.bridge.bundleURL.scheme hasPrefix:@"http"]) {
692+
[super.bridge setValue:[CodePush bundleURL] forKey:@"bundleURL"];
693+
}
694+
695+
RCTTriggerReloadCommandListeners(@"react-native-code-push: Restart");
696+
}
697+
562698
/*
563699
* This method is used when a pending update never finished loading (i.e. it
564700
* crashed before calling notifyApplicationReady) and needs to be rolled back
@@ -804,7 +940,7 @@ - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending
804940

805941
_restartInProgress = NO;
806942
if ([_restartQueue count] > 0) {
807-
BOOL buf = [_restartQueue valueForKey: @"@firstObject"];
943+
BOOL buf = [[_restartQueue firstObject] boolValue];
808944
[_restartQueue removeObjectAtIndex:0];
809945
[self restartAppInternal:buf];
810946
}
@@ -1008,9 +1144,15 @@ - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending
10081144
CPLog(@"Re-allowing restarts.");
10091145
_allowed = YES;
10101146

1147+
// A reload parked by -loadBundle never reached _restartQueue, so it needs
1148+
// its own release. See -releasePendingReload.
1149+
dispatch_async(dispatch_get_main_queue(), ^{
1150+
[self releasePendingReload];
1151+
});
1152+
10111153
if ([_restartQueue count] > 0) {
10121154
CPLog(@"Executing pending restart.");
1013-
BOOL buf = [_restartQueue valueForKey: @"@firstObject"];
1155+
BOOL buf = [[_restartQueue firstObject] boolValue];
10141156
[_restartQueue removeObjectAtIndex:0];
10151157
[self restartAppInternal:buf];
10161158
}
@@ -1022,6 +1164,11 @@ - (void)restartAppInternal:(BOOL)onlyIfUpdateIsPending
10221164
rejecter:(RCTPromiseRejectBlock)reject)
10231165
{
10241166
[_restartQueue removeAllObjects];
1167+
1168+
dispatch_async(dispatch_get_main_queue(), ^{
1169+
[self cancelPendingReload];
1170+
});
1171+
10251172
resolve(nil);
10261173
}
10271174

0 commit comments

Comments
 (0)