RCTNavigator.m 24.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632
/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

#import "RCTNavigator.h"

#import "RCTAssert.h"
#import "RCTBridge.h"
#import "RCTConvert.h"
#import "RCTEventDispatcher.h"
#import "RCTLog.h"
#import "RCTNavItem.h"
#import "RCTScrollView.h"
#import "RCTUtils.h"
#import "RCTView.h"
#import "RCTWrapperViewController.h"
#import "UIView+React.h"

typedef NS_ENUM(NSUInteger, RCTNavigationLock) {
  RCTNavigationLockNone,
  RCTNavigationLockNative,
  RCTNavigationLockJavaScript
};

// By default the interactive pop gesture will be enabled when the navigation bar is displayed
// and disabled when hidden
// RCTPopGestureStateDefault maps to the default behavior (mentioned above). Once popGestureState
// leaves this value, it can never be returned back to it. This is because, due to a limitation in
// the iOS APIs, once we override the default behavior of the gesture recognizer, we cannot return
// back to it.
// RCTPopGestureStateEnabled will enable the gesture independent of nav bar visibility
// RCTPopGestureStateDisabled will disable the gesture independent of nav bar visibility
typedef NS_ENUM(NSUInteger, RCTPopGestureState) {
  RCTPopGestureStateDefault = 0,
  RCTPopGestureStateEnabled,
  RCTPopGestureStateDisabled
};

NSInteger kNeverRequested = -1;
NSInteger kNeverProgressed = -10000;


@interface UINavigationController ()

// need to declare this since `UINavigationController` doesn't publicly declare the fact that it implements
// UINavigationBarDelegate :(
- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item;

@end

// http://stackoverflow.com/questions/5115135/uinavigationcontroller-how-to-cancel-the-back-button-event
// There's no other way to do this unfortunately :(
@interface RCTNavigationController : UINavigationController <UINavigationBarDelegate>
{
  dispatch_block_t _scrollCallback;
}

@property (nonatomic, assign) RCTNavigationLock navigationLock;

@end

/**
 * In general, `RCTNavigator` examines `_currentViews` (which are React child
 * views), and compares them to `_navigationController.viewControllers` (which
 * are controlled by UIKit).
 *
 * It is possible for JavaScript (`_currentViews`) to "get ahead" of native
 * (`navigationController.viewControllers`) and vice versa. JavaScript gets
 * ahead by adding/removing React subviews. Native gets ahead by swiping back,
 * or tapping the back button. In both cases, the other system is initially
 * unaware. And in both cases, `RCTNavigator` helps the other side "catch up".
 *
 * If `RCTNavigator` sees the number of React children have changed, it
 * pushes/pops accordingly. If `RCTNavigator` sees a `UIKit` driven push/pop, it
 * notifies JavaScript that this has happened, and expects that JavaScript will
 * eventually render more children to match `UIKit`. There's no rush for
 * JavaScript to catch up. But if it does render anything, it must catch up to
 * UIKit. It cannot deviate.
 *
 * To implement this, we need a lock, which we store on the native thread. This
 * lock allows one of the systems to push/pop views. Whoever wishes to
 * "get ahead" must obtain the lock. Whoever wishes to "catch up" must obtain
 * the lock. One thread may not "get ahead" or "catch up" when the other has
 * the lock. Once a thread has the lock, it can only do the following:
 *
 * 1. If it is behind, it may only catch up.
 * 2. If it is caught up or ahead, it may push or pop.
 *
 *
 * ========= Acquiring The Lock ==========
 *
 * JavaScript asynchronously acquires the lock using a native hook. It might be
 * rejected and receive the return value `false`.
 *
 * We acquire the native lock in `shouldPopItem`, which is called right before
 * native tries to push/pop, but only if JavaScript doesn't already have the
 * lock.
 *
 * ========  While JavaScript Has Lock ====
 *
 * When JavaScript has the lock, we have to block all `UIKit` driven pops:
 *
 * 1. Block back button navigation:
 *   - Back button will invoke `shouldPopItem`, from which we return `NO` if
 *   JavaScript has the lock.
 *   - Back button will respect the return value `NO` and not permit
 *   navigation.
 *
 * 2. Block swipe-to-go-back navigation:
 *   - Swipe will trigger `shouldPopItem`, but swipe won't respect our `NO`
 *   return value so we must disable the gesture recognizer while JavaScript
 *   has the lock.
 *
 * ========  While Native Has Lock =======
 *
 * We simply deny JavaScript the right to acquire the lock.
 *
 *
 * ======== Releasing The Lock ===========
 *
 * Recall that the lock represents who has the right to either push/pop (or
 * catch up). As soon as we recognize that the side that has locked has carried
 * out what it scheduled to do, we can release the lock, but only after any
 * possible animations are completed.
 *
 * *IF* a scheduled operation results in a push/pop (not all do), then we can
 * only release the lock after the push/pop animation is complete because
 * UIKit. `didMoveToNavigationController` is invoked when the view is done
 * pushing/popping/animating. Native swipe-to-go-back interactions can be
 * aborted, however, and you'll never see that method invoked. So just to cover
 * that case, we also put an animation complete hook in
 * `animateAlongsideTransition` to make sure we free the lock, in case the
 * scheduled native push/pop never actually happened.
 *
 * For JavaScript:
 * - When we see that JavaScript has "caught up" to `UIKit`, and no pushes/pops
 * were needed, we can release the lock.
 * - When we see that JavaScript requires *some* push/pop, it's not yet done
 * carrying out what it scheduled to do. Just like with `UIKit` push/pops, we
 * still have to wait for it to be done animating
 * (`didMoveToNavigationController` is a suitable hook).
 *
 */
@implementation RCTNavigationController

/**
 * @param callback Callback that is invoked when a "scroll" interaction begins
 * so that `RCTNavigator` can notify `JavaScript`.
 */
- (instancetype)initWithScrollCallback:(dispatch_block_t)callback
{
  if ((self = [super initWithNibName:nil bundle:nil])) {
    _scrollCallback = callback;
  }
  return self;
}

/**
 * Invoked when either a navigation item has been popped off, or when a
 * swipe-back gesture has began. The swipe-back gesture doesn't respect the
 * return value of this method. The back button does. That's why we have to
 * completely disable the gesture recognizer for swipe-back while JS has the
 * lock.
 */
- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
{
#if !TARGET_OS_TV
  if (self.interactivePopGestureRecognizer.state == UIGestureRecognizerStateBegan) {
    if (self.navigationLock == RCTNavigationLockNone) {
      self.navigationLock = RCTNavigationLockNative;
      if (_scrollCallback) {
        _scrollCallback();
      }
    } else if (self.navigationLock == RCTNavigationLockJavaScript) {
      // This should never happen because we disable/enable the gesture
      // recognizer when we lock the navigation.
      RCTAssert(NO, @"Should never receive gesture start while JS locks navigator");
    }
  } else
#endif //TARGET_OS_TV
  {
    if (self.navigationLock == RCTNavigationLockNone) {
      // Must be coming from native interaction, lock it - it will be unlocked
      // in `didMoveToNavigationController`
      self.navigationLock = RCTNavigationLockNative;
      if (_scrollCallback) {
        _scrollCallback();
      }
    } else if (self.navigationLock == RCTNavigationLockJavaScript) {
      // This should only occur when JS has the lock, and
      // - JS is driving the pop
      // - Or the back button was pressed
      // TODO: We actually want to disable the backbutton while JS has the
      // lock, but it's not so easy. Even returning `NO` wont' work because it
      // will also block JS driven pops. We simply need to disallow a standard
      // back button, and instead use a custom one that tells JS to pop to
      // length (`currentReactCount` - 1).
      return [super navigationBar:navigationBar shouldPopItem:item];
    }
  }
  return [super navigationBar:navigationBar shouldPopItem:item];
}

@end

@interface RCTNavigator() <RCTWrapperViewControllerNavigationListener, UINavigationControllerDelegate, UIGestureRecognizerDelegate>

@property (nonatomic, copy) RCTDirectEventBlock onNavigationProgress;
@property (nonatomic, copy) RCTBubblingEventBlock onNavigationComplete;

@property (nonatomic, assign) NSInteger previousRequestedTopOfStack;

@property (nonatomic, assign) RCTPopGestureState popGestureState;

// Previous views are only mainted in order to detect incorrect
// addition/removal of views below the `requestedTopOfStack`
@property (nonatomic, copy, readwrite) NSArray<RCTNavItem *> *previousViews;
@property (nonatomic, readwrite, strong) RCTNavigationController *navigationController;
/**
 * Display link is used to get high frequency sample rate during
 * interaction/animation of view controller push/pop.
 *
 * - The run loop retains the displayLink.
 * - `displayLink` retains its target.
 * - We use `invalidate` to remove the `RCTNavigator`'s reference to the
 * `displayLink` and remove the `displayLink` from the run loop.
 *
 *
 * `displayLink`:
 * --------------
 *
 * - Even though we could implement the `displayLink` cleanup without the
 * `invalidate` hook by adding and removing it from the run loop at the
 * right times (begin/end animation), we need to account for the possibility
 * that the view itself is destroyed mid-interaction. So we always keep it
 * added to the run loop, but start/stop it with interactions/animations. We
 * remove it from the run loop when the view will be destroyed by React.
 *
 * +----------+              +--------------+
 * | run loop o----strong--->|  displayLink |
 * +----------+              +--o-----------+
 *                              |        ^
 *                              |        |
 *                            strong   strong
 *                              |        |
 *                              v        |
 *                             +---------o---+
 *                             | RCTNavigator |
 *                             +-------------+
 *
 * `dummyView`:
 * ------------
 * There's no easy way to get a callback that fires when the position of a
 * navigation item changes. The actual layers that are moved around during the
 * navigation transition are private. Our only hope is to use
 * `animateAlongsideTransition`, to set a dummy view's position to transition
 * anywhere from -1.0 to 1.0. We later set up a `CADisplayLink` to poll the
 * `presentationLayer` of that dummy view and report the value as a "progress"
 * percentage.
 *
 * It was critical that we added the dummy view as a subview of the
 * transitionCoordinator's `containerView`, otherwise the animations would not
 * work correctly when reversing the gesture direction etc. This seems to be
 * undocumented behavior/requirement.
 *
 */
@property (nonatomic, readonly, assign) CGFloat mostRecentProgress;
@property (nonatomic, readonly, strong) NSTimer *runTimer;
@property (nonatomic, readonly, assign) NSInteger currentlyTransitioningFrom;
@property (nonatomic, readonly, assign) NSInteger currentlyTransitioningTo;

// Dummy view that we make animate with the same curve/interaction as the
// navigation animation/interaction.
@property (nonatomic, readonly, strong) UIView *dummyView;

@end

@implementation RCTNavigator
{
  __weak RCTBridge *_bridge;
  NSInteger _numberOfViewControllerMovesToIgnore;
}

@synthesize paused = _paused;
@synthesize pauseCallback = _pauseCallback;

- (instancetype)initWithBridge:(RCTBridge *)bridge
{
  RCTAssertParam(bridge);

  if ((self = [super initWithFrame:CGRectZero])) {
    _paused = YES;

    _bridge = bridge;
    _mostRecentProgress = kNeverProgressed;
    _dummyView = [[UIView alloc] initWithFrame:CGRectZero];
    _previousRequestedTopOfStack = kNeverRequested; // So that we initialize with a push.
    _previousViews = @[];
    __weak RCTNavigator *weakSelf = self;
    _navigationController = [[RCTNavigationController alloc] initWithScrollCallback:^{
      [weakSelf dispatchFakeScrollEvent];
    }];
    _navigationController.delegate = self;
    RCTAssert([self requestSchedulingJavaScriptNavigation], @"Could not acquire JS navigation lock on init");

    [self addSubview:_navigationController.view];
    [_navigationController.view addSubview:_dummyView];
  }
  return self;
}

RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)

- (void)didUpdateFrame:(__unused RCTFrameUpdate *)update
{
  if (_currentlyTransitioningFrom != _currentlyTransitioningTo) {
    UIView *topView = _dummyView;
    id presentationLayer = [topView.layer presentationLayer];
    CGRect frame = [presentationLayer frame];
    CGFloat nextProgress = ABS(frame.origin.x);
    // Don't want to spam the bridge, when the user holds their finger still mid-navigation.
    if (nextProgress == _mostRecentProgress) {
      return;
    }
    _mostRecentProgress = nextProgress;
    if (_onNavigationProgress) {
      _onNavigationProgress(@{
        @"fromIndex": @(_currentlyTransitioningFrom),
        @"toIndex": @(_currentlyTransitioningTo),
        @"progress": @(nextProgress),
      });
    }
  }
}

- (void)setPaused:(BOOL)paused
{
  if (_paused != paused) {
    _paused = paused;
    if (_pauseCallback) {
      _pauseCallback();
    }
  }
}

- (void)setInteractivePopGestureEnabled:(BOOL)interactivePopGestureEnabled
{
#if !TARGET_OS_TV
  _interactivePopGestureEnabled = interactivePopGestureEnabled;

  _navigationController.interactivePopGestureRecognizer.delegate = self;
  _navigationController.interactivePopGestureRecognizer.enabled = interactivePopGestureEnabled;

  _popGestureState = interactivePopGestureEnabled ? RCTPopGestureStateEnabled : RCTPopGestureStateDisabled;
#endif
}

- (void)dealloc
{
#if !TARGET_OS_TV
  if (_navigationController.interactivePopGestureRecognizer.delegate == self) {
    _navigationController.interactivePopGestureRecognizer.delegate = nil;
  }
#endif
  _navigationController.delegate = nil;
  [_navigationController removeFromParentViewController];
}

- (UIViewController *)reactViewController
{
  return _navigationController;
}

- (BOOL)gestureRecognizerShouldBegin:(__unused UIGestureRecognizer *)gestureRecognizer
{
  return _navigationController.viewControllers.count > 1;
}

/**
 * See documentation about lock lifecycle. This is only here to clean up
 * swipe-back abort interaction, which leaves us *no* other way to clean up
 * locks aside from the animation complete hook.
 */
- (void)navigationController:(UINavigationController *)navigationController
      willShowViewController:(__unused UIViewController *)viewController
                    animated:(__unused BOOL)animated
{
  id<UIViewControllerTransitionCoordinator> tc =
    navigationController.topViewController.transitionCoordinator;
  __weak RCTNavigator *weakSelf = self;
  [tc.containerView addSubview: _dummyView];
  [tc animateAlongsideTransition: ^(id<UIViewControllerTransitionCoordinatorContext> context) {
    RCTWrapperViewController *fromController =
      (RCTWrapperViewController *)[context viewControllerForKey:UITransitionContextFromViewControllerKey];
    RCTWrapperViewController *toController =
      (RCTWrapperViewController *)[context viewControllerForKey:UITransitionContextToViewControllerKey];

    // This may be triggered by a navigation controller unrelated to me: if so, ignore.
    if (fromController.navigationController != self->_navigationController ||
        toController.navigationController != self->_navigationController) {
      return;
    }

    NSUInteger indexOfFrom = [self.reactSubviews indexOfObject:fromController.navItem];
    NSUInteger indexOfTo = [self.reactSubviews indexOfObject:toController.navItem];
    CGFloat destination = indexOfFrom < indexOfTo ? 1.0 : -1.0;
    self->_dummyView.frame = (CGRect){{destination, 0}, CGSizeZero};
    self->_currentlyTransitioningFrom = indexOfFrom;
    self->_currentlyTransitioningTo = indexOfTo;
    self.paused = NO;
  }
  completion:^(__unused id<UIViewControllerTransitionCoordinatorContext> context) {
    [weakSelf freeLock];
    self->_currentlyTransitioningFrom = 0;
    self->_currentlyTransitioningTo = 0;
    self->_dummyView.frame = CGRectZero;
    self.paused = YES;
    // Reset the parallel position tracker
  }];
}

- (BOOL)requestSchedulingJavaScriptNavigation
{
  if (_navigationController.navigationLock == RCTNavigationLockNone) {
    _navigationController.navigationLock = RCTNavigationLockJavaScript;
#if !TARGET_OS_TV
    _navigationController.interactivePopGestureRecognizer.enabled = NO;
#endif
    return YES;
  }
  return NO;
}

- (void)freeLock
{
  _navigationController.navigationLock = RCTNavigationLockNone;

  // Unless the pop gesture has been explicitly disabled (RCTPopGestureStateDisabled),
  // Set interactivePopGestureRecognizer.enabled to YES
  // If the popGestureState is RCTPopGestureStateDefault the default behavior will be maintained
#if !TARGET_OS_TV
  _navigationController.interactivePopGestureRecognizer.enabled = self.popGestureState != RCTPopGestureStateDisabled;
#endif
}

/**
 * A React subview can be inserted/removed at any time, however if the
 * `requestedTopOfStack` changes, there had better be enough subviews present
 * to satisfy the push/pop.
 */
- (void)insertReactSubview:(RCTNavItem *)view atIndex:(NSInteger)atIndex
{
  RCTAssert([view isKindOfClass:[RCTNavItem class]], @"RCTNavigator only accepts RCTNavItem subviews");
  RCTAssert(
    _navigationController.navigationLock == RCTNavigationLockJavaScript,
    @"Cannot change subviews from JS without first locking."
  );
  [super insertReactSubview:view atIndex:atIndex];
}

- (void)didUpdateReactSubviews
{
  // Do nothing, as subviews are managed by `uiManagerDidPerformMounting`
}

- (void)layoutSubviews
{
  [super layoutSubviews];
  [self reactAddControllerToClosestParent:_navigationController];
  _navigationController.view.frame = self.bounds;
}

- (void)removeReactSubview:(RCTNavItem *)subview
{
  if (self.reactSubviews.count <= 0 || subview == self.reactSubviews[0]) {
    RCTLogError(@"Attempting to remove invalid RCT subview of RCTNavigator");
    return;
  }
  [super removeReactSubview:subview];
}

- (void)handleTopOfStackChanged
{
  if (_onNavigationComplete) {
    _onNavigationComplete(@{
      @"stackLength":@(_navigationController.viewControllers.count)
    });
  }
}

- (void)dispatchFakeScrollEvent
{
  [_bridge.eventDispatcher sendFakeScrollEvent:self.reactTag];
}

/**
 * Must be overridden because UIKit removes the view's superview when used
 * as a navigator - it's considered outside the view hierarchy.
 */
- (UIView *)reactSuperview
{
  RCTAssert(!_bridge.isValid || self.superview != nil, @"put reactNavSuperviewLink back");
  UIView *superview = [super reactSuperview];
  return superview ?: self.reactNavSuperviewLink;
}

- (void)uiManagerDidPerformMounting
{
  // we can't hook up the VC hierarchy in 'init' because the subviews aren't
  // hooked up yet, so we do it on demand here
  [self reactAddControllerToClosestParent:_navigationController];

  NSUInteger viewControllerCount = _navigationController.viewControllers.count;
  // The "react count" is the count of views that are visible on the navigation
  // stack.  There may be more beyond this - that aren't visible, and may be
  // deleted/purged soon.
  NSUInteger previousReactCount =
    _previousRequestedTopOfStack == kNeverRequested ? 0 : _previousRequestedTopOfStack + 1;
  NSUInteger currentReactCount = _requestedTopOfStack + 1;

  BOOL jsGettingAhead =
    //    ----- previously caught up ------          ------ no longer caught up -------
    viewControllerCount == previousReactCount && currentReactCount != viewControllerCount;
  BOOL jsCatchingUp =
    //    --- previously not caught up ----          --------- now caught up ----------
    viewControllerCount != previousReactCount && currentReactCount == viewControllerCount;
  BOOL jsMakingNoProgressButNeedsToCatchUp =
    //    --- previously not caught up ----          ------- still the same -----------
    viewControllerCount != previousReactCount && currentReactCount == previousReactCount;
  BOOL jsMakingNoProgressAndDoesntNeedTo =
    //    --- previously caught up --------          ------- still caught up ----------
    viewControllerCount == previousReactCount && currentReactCount == previousReactCount;

BOOL jsGettingtooSlow =
  //    --- previously not caught up --------          ------- no longer caught up ----------
  viewControllerCount < previousReactCount && currentReactCount < previousReactCount;

  BOOL reactPushOne = jsGettingAhead && currentReactCount == previousReactCount + 1;
  BOOL reactPopN = jsGettingAhead && currentReactCount < previousReactCount;

  // We can actually recover from this situation, but it would be nice to know
  // when this error happens. This simply means that JS hasn't caught up to a
  // back navigation before progressing. It's likely a bug in the JS code that
  // catches up/schedules navigations.
  if (!(jsGettingAhead ||
        jsCatchingUp ||
        jsMakingNoProgressButNeedsToCatchUp ||
        jsMakingNoProgressAndDoesntNeedTo ||
        jsGettingtooSlow)) {
    RCTLogError(@"JS has only made partial progress to catch up to UIKit");
  }
  if (currentReactCount > self.reactSubviews.count) {
    RCTLogError(@"Cannot adjust current top of stack beyond available views");
  }

  // Views before the previous React count must not have changed. Views greater than previousReactCount
  // up to currentReactCount may have changed.
  for (NSUInteger i = 0; i < MIN(self.reactSubviews.count, MIN(_previousViews.count, previousReactCount)); i++) {
    if (self.reactSubviews[i] != _previousViews[i]) {
      RCTLogError(@"current view should equal previous view");
    }
  }
  if (currentReactCount < 1) {
    RCTLogError(@"should be at least one current view");
  }
  if (jsGettingAhead) {
    if (reactPushOne) {
      UIView *lastView = self.reactSubviews.lastObject;
      RCTWrapperViewController *vc = [[RCTWrapperViewController alloc] initWithNavItem:(RCTNavItem *)lastView];
      vc.navigationListener = self;
      _numberOfViewControllerMovesToIgnore = 1;
      [_navigationController pushViewController:vc animated:(currentReactCount > 1)];
    } else if (reactPopN) {
      UIViewController *viewControllerToPopTo = _navigationController.viewControllers[(currentReactCount - 1)];
      _numberOfViewControllerMovesToIgnore = viewControllerCount - currentReactCount;
      [_navigationController popToViewController:viewControllerToPopTo animated:YES];
    } else {
      RCTLogError(@"Pushing or popping more than one view at a time from JS");
    }
  } else if (jsCatchingUp) {
    [self freeLock]; // Nothing to push/pop
  } else {
    // Else, JS making no progress, could have been unrelated to anything nav.
    return;
  }

  // Only make a copy of the subviews whose validity we expect to be able to check (in the loop, above),
  // otherwise we would unnecessarily retain a reference to view(s) no longer on the React navigation stack:
  NSUInteger expectedCount = MIN(currentReactCount, self.reactSubviews.count);
  _previousViews = [[self.reactSubviews subarrayWithRange: NSMakeRange(0, expectedCount)] copy];
  _previousRequestedTopOfStack = _requestedTopOfStack;
}

// TODO: This will likely fail when performing multiple pushes/pops. We must
// free the lock only after the *last* push/pop.
- (void)wrapperViewController:(RCTWrapperViewController *)wrapperViewController
didMoveToNavigationController:(UINavigationController *)navigationController
{
  if (self.superview == nil) {
    // If superview is nil, then a JS reload (Cmd+R) happened
    // while a push/pop is in progress.
    return;
  }

  RCTAssert(
    (navigationController == nil || [_navigationController.viewControllers containsObject:wrapperViewController]),
    @"if navigation controller is not nil, it should contain the wrapper view controller"
  );
  RCTAssert(_navigationController.navigationLock == RCTNavigationLockJavaScript ||
           _numberOfViewControllerMovesToIgnore == 0,
           @"If JS doesn't have the lock there should never be any pending transitions");
  /**
   * When JS has the lock we want to keep track of when the request completes
   * the pending transition count hitting 0 signifies this, and should always
   * remain at 0 when JS does not have the lock
   */
  if (_numberOfViewControllerMovesToIgnore > 0) {
    _numberOfViewControllerMovesToIgnore -= 1;
  }
  if (_numberOfViewControllerMovesToIgnore == 0) {
    [self handleTopOfStackChanged];
    [self freeLock];
  }
}

@end