@onekeyfe/react-native-pager-view 6.7.0-alpha.366 → 6.7.0-alpha.374

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/SPEC.md CHANGED
@@ -817,6 +817,51 @@ fallback debounce, including replacement-state restoration. Other collection
817
817
  fallbacks keep that safety delay. Caller insets and retained logical offsets
818
818
  remain governed by the existing restoration policy.
819
819
 
820
+ When the active scroll owner is removed before its replacement becomes usable,
821
+ the pager MUST retain bounded recovery (at most 12 retries per recovery round,
822
+ spaced 100 ms apart). Clearing the completed mount's reattachment flag MUST NOT
823
+ cancel that recovery. A replacement that becomes usable during that round,
824
+ without another Fabric insertion, MUST receive the observer, shared headers,
825
+ insets and retained collapse state. Page selection changes and recycling
826
+ invalidate stale retry callbacks. Repeated mount attempts MUST NOT reset the
827
+ retry budget. Once all 12 retries have been scheduled, an Insert whose parent is
828
+ in the current page subtree MUST reopen recovery if that page retains a released
829
+ scroll owner, including when the final retry callback is still pending. Reopening
830
+ invalidates the old callback before clearing its scheduled state. The Insert
831
+ MUST be recorded during a horizontal drag or transition, while observer and shared
832
+ header attachment remain deferred until selection settles. Cancelling the drag
833
+ on the same page MUST retain the reopened recovery round. Empty transactions,
834
+ removals and inserts outside the current page MUST NOT reopen the budget. Owned
835
+ `NativeScroller` and `NativeList` hosts additionally emit push readiness
836
+ notifications when their scroll views attach and obtain positive bounds so late
837
+ replacements recover immediately even without another Fabric insertion or after
838
+ the retry budget expires; static pages with neither an unready scroll host nor a
839
+ released scroll owner do not schedule retry timers.
840
+
841
+ Status of transition-time and pending-final-retry recovery: implemented and
842
+ UIKit regression verified on iOS 26.5 (2026-10-27). Both new regressions fail
843
+ before the fix and pass afterward. Cancellation settles on the source page
844
+ before the replacement becomes usable; the pending-final-retry case makes the
845
+ replacement usable after the old timer's deadline. Observer and shared header
846
+ ownership stay detached during the drag, then recover with the retained collapse.
847
+ The full Pager suite executes 85 tests, with one iOS-27-only skip and no failures.
848
+
849
+ Status of exhausted-budget recovery: implemented and UIKit regression verified
850
+ on iOS 26.5 (2026-10-27). The regression exhausts the initial round, checks that
851
+ empty transactions, removals and offscreen inserts do not reopen it, then inserts
852
+ a current-page replacement whose scroller appears after did-mount. It fails
853
+ before the fix and passes afterward, including preservation of an active round
854
+ and restoration of the observer, shared headers and retained collapse. The full
855
+ Pager suite executes 83 tests, with one iOS-27-only skip and no failures.
856
+
857
+ Status of replacement recovery: implemented and runtime verified on iOS 26.5
858
+ (2026-10-23). The late-replacement UIKit regression fails before the fix and
859
+ passes afterward; the full Pager suite executes 82 tests, with one iOS-27-only
860
+ skip and no failures. In the consuming app based on x at b0789571d8 with SDK
861
+ 6.7.0-9 and this local native change, two empty Favorites-to-Wallet-add-to-Market
862
+ cycles and a Trending/Favorites round trip keep the first row aligned with the
863
+ sticky header. The previously observed 212-point gap is absent.
864
+
820
865
  This is a UI-thread, main-runtime native coordination change with no new props,
821
866
  events, background-runtime work, persistence or retained strong view cache.
822
867
  Android already prepares discovered scroll children before draw and is unchanged;
@@ -157,6 +157,20 @@ static NSString *const RNCCollapsiblePagerNativeScrollerIDPrefix =
157
157
 
158
158
  static const CFTimeInterval RNCCollapsiblePagerFallbackDebounceInterval = 0.5;
159
159
 
160
+ typedef NS_ENUM(NSInteger, RNCScrollCandidateTier) {
161
+ RNCScrollCandidateTierNone = 0,
162
+ RNCScrollCandidateTierGenericFallback = 1,
163
+ RNCScrollCandidateTierVerifiedNativeList = 2,
164
+ RNCScrollCandidateTierFabricScrollView = 3,
165
+ RNCScrollCandidateTierExplicitScroller = 4,
166
+ };
167
+
168
+ struct RNCScrollCandidateResolution {
169
+ UIScrollView *scrollView = nil;
170
+ RNCScrollCandidateTier tier = RNCScrollCandidateTierNone;
171
+ BOOL hasUnreadyScrollHost = NO;
172
+ };
173
+
160
174
  // OneKey patch: native scroll views can survive a move or be reused for a new
161
175
  // Fabric component. Keep weak identity until their page is attached again.
162
176
  @interface RNCCollapsiblePagerReleasedScrollState : NSObject
@@ -1475,9 +1489,12 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1475
1489
  - (void)updatePinnedStickyHeader;
1476
1490
  - (UIScrollView *)currentVerticalScrollViewForParentPager;
1477
1491
  - (UIScrollView *)verticalScrollViewForPageAtIndex:(NSInteger)pageIndex;
1492
+ - (RNCScrollCandidateResolution)resolveScrollCandidateForPageAtIndex:(NSInteger)pageIndex;
1493
+ - (RNCScrollCandidateResolution)resolveScrollCandidateInView:(UIView *)view;
1478
1494
  - (UIScrollView *)findExplicitNativeScrollerInView:(UIView *)view;
1479
1495
  - (UIScrollView *)findFabricScrollViewInView:(UIView *)view;
1480
1496
  - (UIScrollView *)findVerticalScrollViewInView:(UIView *)view;
1497
+ - (void)scheduleCoalescedScrollReconcile;
1481
1498
  - (void)scheduleScrollObserverRetryForCurrentPage;
1482
1499
  - (void)preparePageForHorizontalTransitionAtIndex:(NSInteger)pageIndex;
1483
1500
  - (void)updateSharedHeaderPressCancellationGesture;
@@ -1644,6 +1661,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1644
1661
  RNCCollapsiblePagerOuterPagerPanGestureRecognizer *_sharedHeaderOuterPagerGesture;
1645
1662
  RNCCollapsiblePagerVerticalPagerGuardGestureRecognizer *_verticalPagerGesture;
1646
1663
  BOOL _observingContentOffset;
1664
+ BOOL _awaitingScrollOwnerForCurrentPage;
1665
+ BOOL _scrollReconcileScheduled;
1647
1666
  BOOL _scrollResolveRetryScheduled;
1648
1667
  CFTimeInterval _pendingFallbackScrollViewDetectedAt;
1649
1668
  NSInteger _scrollResolveRetryPageIndex;
@@ -1675,6 +1694,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1675
1694
  _displayBodyOverrides = [NSMutableDictionary new];
1676
1695
  [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nativeListScrollCommandAccepted:)
1677
1696
  name:@"OneKeyNativeListScrollCommandAccepted" object:nil];
1697
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nativeScrollViewDidBecomeReadyNotification:)
1698
+ name:@"OneKeyNativeScrollViewDidBecomeReady" object:nil];
1678
1699
  _pendingPageRestoreKeys = [NSMutableSet new];
1679
1700
  _releasedPageScrollStates = [NSMutableDictionary new];
1680
1701
  _mountingRemovedScrollViews = [NSHashTable weakObjectsHashTable];
@@ -2332,6 +2353,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2332
2353
  [self updateTopScrollEdgeForScrollView:nil];
2333
2354
  [self cancelHeaderSettledCheck];
2334
2355
  [[NSNotificationCenter defaultCenter] removeObserver:self name:@"OneKeyNativeListScrollCommandAccepted" object:nil];
2356
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:@"OneKeyNativeScrollViewDidBecomeReady" object:nil];
2335
2357
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
2336
2358
  [self updateNativeScrollerViewport:scrollView stickyHeight:0];
2337
2359
  }
@@ -2874,6 +2896,23 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2874
2896
  [self applyInsetsToScrollView:scrollView pageIndex:index restore:YES];
2875
2897
  }
2876
2898
  }
2899
+ UIView *page = _pageControllers[_currentIndex].view;
2900
+ if (_scrollResolveRetryAttempt >= 12 &&
2901
+ _releasedPageScrollStates[self.currentPageKey] != nil) {
2902
+ for (const auto &mutation : transaction.getMutations()) {
2903
+ if (mutation.type == ShadowViewMutation::Insert && mutation.parentTag > 0 &&
2904
+ [page viewWithTag:mutation.parentTag] != nil) {
2905
+ // A new current-page insertion starts a bounded recovery round after
2906
+ // the previous replacement search has scheduled its final retry.
2907
+ _scrollResolveRetryToken += 1;
2908
+ _scrollResolveRetryScheduled = NO;
2909
+ _scrollResolveRetryAttempt = 0;
2910
+ break;
2911
+ }
2912
+ }
2913
+ }
2914
+
2915
+ // Record recovery during transitions; ownership still waits for settlement.
2877
2916
  if (_transitioning || _isPagerDragging) return;
2878
2917
 
2879
2918
  // OneKey patch: a release must recover even when the changed parent is above
@@ -2882,7 +2921,6 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2882
2921
  [self attachScrollObserverForCurrentPage];
2883
2922
  return;
2884
2923
  }
2885
- UIView *page = _pageControllers[_currentIndex].view;
2886
2924
  BOOL headerIsAttached = [_sharedHeaderHostView isDescendantOfView:self];
2887
2925
  BOOL currentListIsAttached = _observedScrollView != nil &&
2888
2926
  [_observedScrollView isDescendantOfView:page] && headerIsAttached;
@@ -3056,12 +3094,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3056
3094
  }
3057
3095
  if (self.hasFixedTopOverlay) [_containerView bringSubviewToFront:_topOverlayHostView];
3058
3096
  [self setNeedsLayout];
3059
- dispatch_async(dispatch_get_main_queue(), ^{
3060
- if (!self->_isBeingRecycled) {
3061
- [self prepareAdjacentPageInsets];
3062
- [self attachScrollObserverForCurrentPage];
3063
- }
3064
- });
3097
+ [self scheduleCoalescedScrollReconcile];
3065
3098
  }
3066
3099
 
3067
3100
  - (void)layoutSubviews
@@ -3138,12 +3171,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3138
3171
  [self updateScrollEdgeContentMasks];
3139
3172
  [self updateHeaderSettledStateForceBusy:NO];
3140
3173
  [self logHomeVisualGeometry:@"layout" scrollView:_observedScrollView];
3141
- dispatch_async(dispatch_get_main_queue(), ^{
3142
- if (!self->_isBeingRecycled) {
3143
- [self prepareAdjacentPageInsets];
3144
- [self attachScrollObserverForCurrentPage];
3145
- }
3146
- });
3174
+ [self scheduleCoalescedScrollReconcile];
3147
3175
  }
3148
3176
 
3149
3177
  - (void)prepareForRecycle
@@ -3161,6 +3189,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3161
3189
  _generation++;
3162
3190
  [_mountingRemovedScrollViews removeAllObjects];
3163
3191
  _needsScrollObserverReattachAfterMount = NO;
3192
+ _awaitingScrollOwnerForCurrentPage = NO;
3193
+ _scrollReconcileScheduled = NO;
3164
3194
  [_releasedPageScrollStates removeAllObjects];
3165
3195
  [self detachSharedHeaderPressCancellationGesture];
3166
3196
  [self detachScrollObserver];
@@ -3501,12 +3531,105 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3501
3531
 
3502
3532
  - (UIScrollView *)verticalScrollViewForPageAtIndex:(NSInteger)pageIndex
3503
3533
  {
3504
- if (pageIndex < 0 || pageIndex >= _pageControllers.count) return nil;
3505
- UIView *pageView = _pageControllers[pageIndex].view;
3506
- UIScrollView *explicitScroller = [self findExplicitNativeScrollerInView:pageView];
3507
- if (explicitScroller != nil) return explicitScroller;
3508
- UIScrollView *fabricScrollView = [self findFabricScrollViewInView:pageView];
3509
- return fabricScrollView ?: [self findVerticalScrollViewInView:pageView];
3534
+ return [self resolveScrollCandidateForPageAtIndex:pageIndex].scrollView;
3535
+ }
3536
+
3537
+ - (RNCScrollCandidateResolution)resolveScrollCandidateForPageAtIndex:(NSInteger)pageIndex
3538
+ {
3539
+ if (pageIndex < 0 || pageIndex >= _pageControllers.count) return {};
3540
+ return [self resolveScrollCandidateInView:_pageControllers[pageIndex].view];
3541
+ }
3542
+
3543
+ // Single-pass tiered resolution replaces three separate subtree walks while
3544
+ // distinguishing genuinely static pages from pages with unready scroll hosts.
3545
+ - (RNCScrollCandidateResolution)resolveScrollCandidateInView:(UIView *)view
3546
+ {
3547
+ RNCScrollCandidateResolution resolution;
3548
+ if (view == nil || view == _sharedHeaderHostView) return resolution;
3549
+ if (view != self &&
3550
+ [view isKindOfClass:RNCCollapsiblePagerViewComponentView.class]) {
3551
+ UIScrollView *nested = [(RNCCollapsiblePagerViewComponentView *)view
3552
+ currentVerticalScrollViewForParentPager];
3553
+ if (nested != nil) {
3554
+ resolution.scrollView = nested;
3555
+ resolution.tier = RNCScrollCandidateTierExplicitScroller;
3556
+ } else {
3557
+ resolution.hasUnreadyScrollHost = YES;
3558
+ }
3559
+ return resolution;
3560
+ }
3561
+
3562
+ Class nativeListClass = NSClassFromString(@"HybridNativeListComponent");
3563
+ if (nativeListClass != Nil && [view isKindOfClass:nativeListClass]) {
3564
+ resolution.hasUnreadyScrollHost = YES;
3565
+ }
3566
+
3567
+ if ([view isKindOfClass:RNCNativeScrollerComponentView.class]) {
3568
+ UIScrollView *scrollView = ((RNCNativeScrollerComponentView *)view).scrollView;
3569
+ if (![_mountingRemovedScrollViews containsObject:scrollView]) {
3570
+ resolution.scrollView = scrollView;
3571
+ resolution.tier = RNCScrollCandidateTierExplicitScroller;
3572
+ return resolution;
3573
+ }
3574
+ } else if ([view isKindOfClass:RCTScrollViewComponentView.class]) {
3575
+ RCTScrollViewComponentView *componentView = (RCTScrollViewComponentView *)view;
3576
+ UIScrollView *scrollView = componentView.scrollView;
3577
+ if (![_mountingRemovedScrollViews containsObject:scrollView]) {
3578
+ if ([componentView.nativeId hasPrefix:RNCCollapsiblePagerNativeScrollerIDPrefix]) {
3579
+ resolution.scrollView = scrollView;
3580
+ resolution.tier = RNCScrollCandidateTierExplicitScroller;
3581
+ return resolution;
3582
+ }
3583
+ if (!scrollView.alwaysBounceHorizontal) {
3584
+ resolution.scrollView = scrollView;
3585
+ resolution.tier = RNCScrollCandidateTierFabricScrollView;
3586
+ }
3587
+ }
3588
+ } else if ([view isKindOfClass:UICollectionView.class]) {
3589
+ UICollectionView *collectionView = (UICollectionView *)view;
3590
+ if (![_mountingRemovedScrollViews containsObject:collectionView]) {
3591
+ UICollectionViewLayout *layout = collectionView.collectionViewLayout;
3592
+ BOOL isVerticalLayout = ![layout isKindOfClass:UICollectionViewFlowLayout.class] ||
3593
+ ((UICollectionViewFlowLayout *)layout).scrollDirection == UICollectionViewScrollDirectionVertical;
3594
+ if (isVerticalLayout) {
3595
+ BOOL hasNativeListHost = RNCNativeListComponentForScrollView(collectionView) != nil;
3596
+ BOOL isMountedNativeList = hasNativeListHost && RNCIsMountedNativeListScrollView(collectionView);
3597
+ BOOL hasUsableBounds = CGRectGetWidth(collectionView.bounds) > 0 &&
3598
+ CGRectGetHeight(collectionView.bounds) > 0;
3599
+ if (hasNativeListHost && !isMountedNativeList) {
3600
+ resolution.hasUnreadyScrollHost = YES;
3601
+ } else if (!hasUsableBounds) {
3602
+ resolution.hasUnreadyScrollHost = YES;
3603
+ } else {
3604
+ resolution.scrollView = collectionView;
3605
+ resolution.tier = isMountedNativeList
3606
+ ? RNCScrollCandidateTierVerifiedNativeList
3607
+ : RNCScrollCandidateTierGenericFallback;
3608
+ }
3609
+ }
3610
+ }
3611
+ } else if ([view isKindOfClass:UIScrollView.class]) {
3612
+ UIScrollView *scrollView = (UIScrollView *)view;
3613
+ if (![_mountingRemovedScrollViews containsObject:scrollView] &&
3614
+ !scrollView.alwaysBounceHorizontal &&
3615
+ scrollView.contentSize.width <= scrollView.bounds.size.width + 1) {
3616
+ resolution.scrollView = scrollView;
3617
+ resolution.tier = RNCScrollCandidateTierGenericFallback;
3618
+ }
3619
+ }
3620
+
3621
+ for (UIView *subview in view.subviews) {
3622
+ RNCScrollCandidateResolution child = [self resolveScrollCandidateInView:subview];
3623
+ resolution.hasUnreadyScrollHost |= child.hasUnreadyScrollHost;
3624
+ if (child.tier > resolution.tier) {
3625
+ resolution.scrollView = child.scrollView;
3626
+ resolution.tier = child.tier;
3627
+ if (resolution.tier == RNCScrollCandidateTierExplicitScroller) {
3628
+ return resolution;
3629
+ }
3630
+ }
3631
+ }
3632
+ return resolution;
3510
3633
  }
3511
3634
 
3512
3635
  - (UIScrollView *)findExplicitNativeScrollerInView:(UIView *)view
@@ -3602,6 +3725,19 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3602
3725
  return nil;
3603
3726
  }
3604
3727
 
3728
+ - (void)scheduleCoalescedScrollReconcile
3729
+ {
3730
+ if (_isBeingRecycled || _scrollReconcileScheduled) return;
3731
+ _scrollReconcileScheduled = YES;
3732
+ NSUInteger generation = _generation;
3733
+ dispatch_async(dispatch_get_main_queue(), ^{
3734
+ self->_scrollReconcileScheduled = NO;
3735
+ if (self->_isBeingRecycled || self->_generation != generation) return;
3736
+ [self prepareAdjacentPageInsets];
3737
+ [self attachScrollObserverForCurrentPage];
3738
+ });
3739
+ }
3740
+
3605
3741
  - (void)scheduleScrollObserverRetryForCurrentPage
3606
3742
  {
3607
3743
  if (_scrollResolveRetryScheduled || _scrollResolveRetryAttempt >= 12 ||
@@ -3654,29 +3790,35 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3654
3790
  _pendingFallbackScrollView = nil;
3655
3791
  _pendingFallbackScrollViewDetectedAt = 0;
3656
3792
  }
3657
- UIScrollView *candidate = [self verticalScrollViewForPageAtIndex:_currentIndex];
3793
+ RNCScrollCandidateResolution resolution = [self resolveScrollCandidateForPageAtIndex:_currentIndex];
3794
+ UIScrollView *candidate = resolution.scrollView;
3658
3795
  if (_nativeSmoothHeaderScrollEnabled && _currentIndex >= 0 && _currentIndex < _pageControllers.count) {
3659
3796
  RNCGuardNestedHorizontalPans(_pageControllers[_currentIndex].view, _verticalPagerGesture);
3660
3797
  }
3661
3798
  if (candidate == nil) {
3662
3799
  [self detachScrollObserver];
3663
3800
  [self restoreSharedHeadersToContainer];
3664
- // Stop retrying released lists on unrelated commits without discarding a
3665
- // surviving identity or offset. A relevant insertion resumes recovery.
3666
- if (_needsScrollObserverReattachAfterMount) {
3667
- _needsScrollObserverReattachAfterMount = NO;
3801
+ _needsScrollObserverReattachAfterMount = NO;
3802
+ _pendingFallbackScrollView = nil;
3803
+ _pendingFallbackScrollViewDetectedAt = 0;
3804
+ _awaitingScrollOwnerForCurrentPage = YES;
3805
+ // Only schedule bounded retry when the page has an unready scroll host in
3806
+ // its subtree or a released scroll owner awaiting replacement. Static pages
3807
+ // without a scroll host do not spin retry timers, and late replacements on
3808
+ // any page also attach deterministically via readiness notifications.
3809
+ BOOL hasReleasedOwnerAwaitingReplacement = _releasedPageScrollStates[self.currentPageKey] != nil;
3810
+ if (resolution.hasUnreadyScrollHost || hasReleasedOwnerAwaitingReplacement) {
3811
+ [self scheduleScrollObserverRetryForCurrentPage];
3812
+ } else {
3668
3813
  _scrollResolveRetryToken += 1;
3669
3814
  _scrollResolveRetryScheduled = NO;
3670
3815
  _scrollResolveRetryAttempt = 0;
3671
- _pendingFallbackScrollView = nil;
3672
- _pendingFallbackScrollViewDetectedAt = 0;
3673
- } else {
3674
- [self scheduleScrollObserverRetryForCurrentPage];
3675
3816
  }
3676
3817
  return;
3677
3818
  }
3678
3819
  if ([candidate isKindOfClass:UICollectionView.class] &&
3679
3820
  candidate != _observedScrollView && !RNCIsMountedNativeListScrollView(candidate)) {
3821
+ _awaitingScrollOwnerForCurrentPage = YES;
3680
3822
  if (_pendingFallbackScrollView != candidate) {
3681
3823
  _pendingFallbackScrollView = candidate;
3682
3824
  _pendingFallbackScrollViewDetectedAt = CACurrentMediaTime();
@@ -3688,6 +3830,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3688
3830
  return;
3689
3831
  }
3690
3832
  }
3833
+ _awaitingScrollOwnerForCurrentPage = NO;
3691
3834
  _scrollResolveRetryAttempt = 0;
3692
3835
  _scrollResolveRetryPageIndex = _currentIndex;
3693
3836
  _scrollResolveRetryToken += 1;
@@ -4511,6 +4654,66 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
4511
4654
  }
4512
4655
 
4513
4656
 
4657
+ - (void)nativeScrollViewDidBecomeReadyNotification:(NSNotification *)notification
4658
+ {
4659
+ if ([notification.object isKindOfClass:UIScrollView.class]) {
4660
+ [self nativeScrollViewDidBecomeReady:(UIScrollView *)notification.object];
4661
+ }
4662
+ }
4663
+
4664
+ - (void)nativeScrollViewDidBecomeReady:(UIScrollView *)scrollView
4665
+ {
4666
+ if (_isBeingRecycled || self.window == nil || scrollView == nil ||
4667
+ [_mountingRemovedScrollViews containsObject:scrollView]) return;
4668
+ for (UIView *ancestor = scrollView.superview;
4669
+ ancestor != nil && ancestor != self;
4670
+ ancestor = ancestor.superview) {
4671
+ if ([ancestor isKindOfClass:RNCCollapsiblePagerViewComponentView.class]) {
4672
+ RNCCollapsiblePagerViewComponentView *nestedPager =
4673
+ (RNCCollapsiblePagerViewComponentView *)ancestor;
4674
+ if ([nestedPager currentVerticalScrollViewForParentPager] != scrollView) return;
4675
+ break;
4676
+ }
4677
+ }
4678
+ NSInteger pageIndex = NSNotFound;
4679
+ for (NSInteger index = 0; index < _pageControllers.count; index++) {
4680
+ if ([scrollView isDescendantOfView:_pageControllers[index].view]) {
4681
+ pageIndex = index;
4682
+ break;
4683
+ }
4684
+ }
4685
+ if (pageIndex == NSNotFound) return;
4686
+
4687
+ NSString *pageKey = [self pageKeyForIndex:pageIndex];
4688
+ BOOL hasReleasedStateForPage = _releasedPageScrollStates[pageKey] != nil;
4689
+ BOOL currentPageNeedsOwner = pageIndex == _currentIndex &&
4690
+ (_awaitingScrollOwnerForCurrentPage || _needsScrollObserverReattachAfterMount ||
4691
+ hasReleasedStateForPage ||
4692
+ (_observedScrollView != nil &&
4693
+ [_observedScrollView isKindOfClass:UICollectionView.class] &&
4694
+ !RNCIsMountedNativeListScrollView(_observedScrollView) &&
4695
+ _observedScrollView != scrollView));
4696
+
4697
+ if (!hasReleasedStateForPage && !currentPageNeedsOwner &&
4698
+ !_needsScrollObserverReattachAfterMount) return;
4699
+
4700
+ if (_nativeSmoothHeaderScrollEnabled) {
4701
+ NSInteger first = MAX(0, _currentIndex - 1);
4702
+ NSInteger last = MIN((NSInteger)_pageControllers.count - 1, _currentIndex + 1);
4703
+ BOOL isOwnedScroller = [scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class] &&
4704
+ ((RNCNativeScrollerComponentView *)scrollView.superview).scrollView == scrollView;
4705
+ if ((pageIndex >= first && pageIndex <= last || pageIndex == _destinationIndex) &&
4706
+ (RNCIsMountedNativeListScrollView(scrollView) || isOwnedScroller) &&
4707
+ [_originalInsets objectForKey:scrollView] == nil) {
4708
+ [self applyInsetsToScrollView:scrollView pageIndex:pageIndex restore:YES];
4709
+ }
4710
+ }
4711
+
4712
+ if (currentPageNeedsOwner && !_transitioning && !_isPagerDragging) {
4713
+ [self attachScrollObserverForCurrentPage];
4714
+ }
4715
+ }
4716
+
4514
4717
  - (void)nativeScroller:(RNCNativeScrollerComponentView *)scroller
4515
4718
  callerInsetChangedFrom:(UIEdgeInsets)previous to:(UIEdgeInsets)next
4516
4719
  {
@@ -13,6 +13,8 @@ NS_ASSUME_NONNULL_BEGIN
13
13
  - (void)nativeScrollerWillReceiveScrollCommand:(RNCNativeScrollerComponentView *)scroller;
14
14
  - (void)nativeScroller:(RNCNativeScrollerComponentView *)scroller
15
15
  callerInsetChangedFrom:(UIEdgeInsets)previous to:(UIEdgeInsets)next;
16
+ @optional
17
+ - (void)nativeScrollViewDidBecomeReady:(UIScrollView *)scrollView;
16
18
  @end
17
19
 
18
20
  NS_ASSUME_NONNULL_END
@@ -298,10 +298,23 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
298
298
  }
299
299
  }
300
300
 
301
+ - (void)notifyCoordinatorScrollViewReadyIfNeeded
302
+ {
303
+ if (_recycled || self.window == nil ||
304
+ CGRectGetWidth(_scrollView.bounds) <= 0 || CGRectGetHeight(_scrollView.bounds) <= 0) return;
305
+ for (UIView *owner = self.superview; owner != nil; owner = owner.superview) {
306
+ if ([owner conformsToProtocol:@protocol(RNCNativeScrollerInsetCoordinator)] &&
307
+ [owner respondsToSelector:@selector(nativeScrollViewDidBecomeReady:)]) {
308
+ [(id<RNCNativeScrollerInsetCoordinator>)owner nativeScrollViewDidBecomeReady:_scrollView];
309
+ }
310
+ }
311
+ }
312
+
301
313
  - (void)didMoveToSuperview
302
314
  {
303
315
  [super didMoveToSuperview];
304
316
  [self updateTopScrollEdge];
317
+ [self notifyCoordinatorScrollViewReadyIfNeeded];
305
318
  }
306
319
 
307
320
  - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
@@ -314,6 +327,10 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
314
327
  if (!CGSizeEqualToSize(previous, _scrollView.bounds.size)) {
315
328
  _needsContentClamp = YES;
316
329
  [self scheduleContentClamp];
330
+ if ((previous.width <= 0 || previous.height <= 0) &&
331
+ _scrollView.bounds.size.width > 0 && _scrollView.bounds.size.height > 0) {
332
+ [self notifyCoordinatorScrollViewReadyIfNeeded];
333
+ }
317
334
  }
318
335
  }
319
336
 
@@ -574,6 +591,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
574
591
  const auto &props = *std::static_pointer_cast<const RNCNativeScrollerProps>(_props);
575
592
  if (props.refreshing) [_refreshControl beginRefreshing];
576
593
  [self scheduleContentClamp];
594
+ [self notifyCoordinatorScrollViewReadyIfNeeded];
577
595
  }
578
596
  }
579
597
 
@@ -459,6 +459,138 @@ using namespace facebook::react;
459
459
  XCTAssertTrue([[pager valueForKey:@"pendingPageRestoreKeys"] containsObject:@"page-1"]);
460
460
  }
461
461
 
462
+ - (void)testReleasedOwnerRecoversReplacementWithoutAnotherFabricInsertion
463
+ {
464
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
465
+ // The fixture mounts page 0 directly; UIKit must not select it again.
466
+ [pager setValue:@YES forKey:@"hasAppliedInitialPage"];
467
+ RNCNativeScrollerComponentView *source = [self addScrollerToPager:pager atIndex:0];
468
+ source.tag = 91001;
469
+ [pager.window layoutIfNeeded];
470
+ [pager layoutIfNeeded];
471
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.15]];
472
+ [pager setValue:@120 forKey:@"headerOffset"];
473
+ [pager attachScrollObserverForCurrentPage];
474
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], source.scrollView);
475
+
476
+ // Finish the old owner's removal before the replacement becomes available.
477
+ ShadowView removedView;
478
+ removedView.tag = (Tag)source.tag;
479
+ MountingTransaction removal(1, 1, {ShadowViewMutation::RemoveMutation(1, removedView, 0)}, {});
480
+ SurfaceTelemetry telemetry;
481
+ [pager mountingTransactionWillMount:removal withSurfaceTelemetry:telemetry];
482
+ [source removeFromSuperview];
483
+ [self finishMount:pager];
484
+ XCTAssertNil([pager valueForKey:@"observedScrollView"]);
485
+
486
+ RNCNativeScrollerComponentView *replacement = [self addScrollerToPager:pager atIndex:0];
487
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]];
488
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], replacement.scrollView);
489
+ XCTAssertEqual([pager valueForKey:@"sharedHeaderScrollView"], replacement.scrollView);
490
+ XCTAssertEqualWithAccuracy(replacement.scrollView.contentOffset.y + replacement.scrollView.contentInset.top,
491
+ 120, 0.01);
492
+ XCTAssertEqualWithAccuracy([[pager valueForKey:@"headerOffset"] doubleValue], 120, 0.01);
493
+ }
494
+
495
+ - (void)testCurrentPageInsertionRestartsExhaustedReplacementRecovery
496
+ {
497
+ [self assertReplacementRecoveryWithCancelledDrag:NO finalRetryPending:NO];
498
+ }
499
+
500
+ - (void)testCancelledDragRetainsReplacementRecoveryFromInsertion
501
+ {
502
+ [self assertReplacementRecoveryWithCancelledDrag:YES finalRetryPending:NO];
503
+ }
504
+
505
+ - (void)testInsertionInvalidatesPendingFinalReplacementRetry
506
+ {
507
+ [self assertReplacementRecoveryWithCancelledDrag:NO finalRetryPending:YES];
508
+ }
509
+
510
+ - (void)assertReplacementRecoveryWithCancelledDrag:(BOOL)cancelDrag
511
+ finalRetryPending:(BOOL)finalRetryPending
512
+ {
513
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:2];
514
+ [pager setValue:@YES forKey:@"hasAppliedInitialPage"];
515
+ RNCNativeScrollerComponentView *source = [self addScrollerToPager:pager atIndex:0];
516
+ source.tag = 91001;
517
+ UIView *page = source.superview;
518
+ page.tag = 92001;
519
+ UIViewController *otherPage = [pager valueForKey:@"pageControllers"][1];
520
+ otherPage.view.tag = 92002;
521
+ [pager.window layoutIfNeeded];
522
+ [pager layoutIfNeeded];
523
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.15]];
524
+ [pager setValue:@120 forKey:@"headerOffset"];
525
+ [pager attachScrollObserverForCurrentPage];
526
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], source.scrollView);
527
+
528
+ ShadowView removedView;
529
+ removedView.tag = (Tag)source.tag;
530
+ MountingTransaction removal(1, 1, {ShadowViewMutation::RemoveMutation((Tag)page.tag, removedView, 0)}, {});
531
+ SurfaceTelemetry telemetry;
532
+ [pager mountingTransactionWillMount:removal withSurfaceTelemetry:telemetry];
533
+ [source removeFromSuperview];
534
+ [self finishMount:pager];
535
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:3];
536
+ while (([[pager valueForKey:@"scrollResolveRetryAttempt"] integerValue] < 12 ||
537
+ (!finalRetryPending && [[pager valueForKey:@"scrollResolveRetryScheduled"] boolValue])) &&
538
+ deadline.timeIntervalSinceNow > 0) {
539
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
540
+ }
541
+ XCTAssertEqualObjects([pager valueForKey:@"scrollResolveRetryAttempt"], @12);
542
+ XCTAssertEqualObjects([pager valueForKey:@"scrollResolveRetryScheduled"], @(finalRetryPending));
543
+ XCTAssertNotNil([pager valueForKey:@"releasedPageScrollStates"][@"page-0"]);
544
+
545
+ // Only a new insertion in the current page can reopen the exhausted budget.
546
+ [self finishMount:pager];
547
+ [pager mountingTransactionDidMount:removal withSurfaceTelemetry:telemetry];
548
+ UIView *replacementHost = [UIView new];
549
+ replacementHost.tag = 93001;
550
+ [otherPage.view addSubview:replacementHost];
551
+ ShadowView insertedView;
552
+ insertedView.tag = (Tag)replacementHost.tag;
553
+ MountingTransaction unrelated(1, 2, {ShadowViewMutation::InsertMutation((Tag)otherPage.view.tag, insertedView, 0)}, {});
554
+ [pager mountingTransactionDidMount:unrelated withSurfaceTelemetry:telemetry];
555
+ XCTAssertEqualObjects([pager valueForKey:@"scrollResolveRetryAttempt"], @12);
556
+ XCTAssertEqualObjects([pager valueForKey:@"scrollResolveRetryScheduled"], @(finalRetryPending));
557
+
558
+ if (cancelDrag) {
559
+ [pager beginPageStateTransition];
560
+ [pager setValue:@YES forKey:@"transitioning"];
561
+ [pager setValue:@YES forKey:@"isPagerDragging"];
562
+ }
563
+ [page addSubview:replacementHost];
564
+ MountingTransaction insertion(1, 3, {ShadowViewMutation::InsertMutation((Tag)page.tag, insertedView, 0)}, {});
565
+ [pager mountingTransactionDidMount:insertion withSurfaceTelemetry:telemetry];
566
+ XCTAssertNil([pager valueForKey:@"observedScrollView"]);
567
+ NSNumber *retryToken = [pager valueForKey:@"scrollResolveRetryToken"];
568
+ [pager mountingTransactionDidMount:insertion withSurfaceTelemetry:telemetry];
569
+ XCTAssertEqualObjects([pager valueForKey:@"scrollResolveRetryToken"], retryToken);
570
+ if (cancelDrag) {
571
+ XCTAssertNil([pager valueForKey:@"sharedHeaderScrollView"]);
572
+ XCTAssertFalse([[pager valueForKey:@"scrollResolveRetryScheduled"] boolValue]);
573
+ // Settle on the source page while the replacement is still unavailable.
574
+ [pager attachScrollObserverForCurrentPageAfterTransition:YES];
575
+ [pager setValue:@NO forKey:@"isPagerDragging"];
576
+ [pager setValue:@NO forKey:@"transitioning"];
577
+ [pager attachScrollObserverForCurrentPage];
578
+ XCTAssertEqualObjects([pager valueForKey:@"currentIndex"], @0);
579
+ }
580
+ __block RNCNativeScrollerComponentView *replacement;
581
+ int64_t replacementDelay = finalRetryPending ? 150 : 50;
582
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, replacementDelay * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{
583
+ replacement = [self addScrollerToPager:pager atIndex:0];
584
+ [replacementHost addSubview:replacement];
585
+ });
586
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]];
587
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], replacement.scrollView);
588
+ XCTAssertEqual([pager valueForKey:@"sharedHeaderScrollView"], replacement.scrollView);
589
+ XCTAssertEqualWithAccuracy(replacement.scrollView.contentOffset.y + replacement.scrollView.contentInset.top,
590
+ 120, 0.01);
591
+ XCTAssertEqualWithAccuracy([[pager valueForKey:@"headerOffset"] doubleValue], 120, 0.01);
592
+ }
593
+
462
594
  - (void)testRenamedPageDoesNotResurrectPrunedObserverCache
463
595
  {
464
596
  RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
@@ -603,4 +735,58 @@ using namespace facebook::react;
603
735
  XCTAssertEqual(removed.count, 0u);
604
736
  }
605
737
 
738
+ - (void)testEmptyPageWithoutReleasedOwnerOrScrollHostDoesNotScheduleRetry
739
+ {
740
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
741
+ [pager attachScrollObserverForCurrentPage];
742
+ XCTAssertNil([pager valueForKey:@"observedScrollView"]);
743
+ XCTAssertFalse([[pager valueForKey:@"scrollResolveRetryScheduled"] boolValue]);
744
+ XCTAssertEqual([[pager valueForKey:@"scrollResolveRetryAttempt"] integerValue], 0);
745
+ }
746
+
747
+ - (void)testLateReplacementRecoversImmediatelyOnReadinessEvenAfterRetryBudgetExpires
748
+ {
749
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
750
+ RNCNativeScrollerComponentView *previous = [self addScrollerToPager:pager atIndex:0];
751
+ previous.tag = 91003;
752
+ [pager attachScrollObserverForCurrentPage];
753
+ UIView *sharedHeader = [pager valueForKey:@"sharedHeaderHostView"];
754
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], previous.scrollView);
755
+
756
+ ShadowView removedView;
757
+ removedView.tag = (Tag)previous.tag;
758
+ MountingTransaction removal(1, 1, {ShadowViewMutation::RemoveMutation(1, removedView, 0)}, {});
759
+ SurfaceTelemetry telemetry;
760
+ [pager mountingTransactionWillMount:removal withSurfaceTelemetry:telemetry];
761
+ [previous removeFromSuperview];
762
+ [self finishMount:pager];
763
+ XCTAssertNil([pager valueForKey:@"observedScrollView"]);
764
+
765
+ // Simulate retry budget exhaustion before the replacement becomes ready.
766
+ [pager setValue:@12 forKey:@"scrollResolveRetryAttempt"];
767
+ [pager setValue:@NO forKey:@"scrollResolveRetryScheduled"];
768
+ NSUInteger oldToken = [[pager valueForKey:@"scrollResolveRetryToken"] unsignedIntegerValue];
769
+ [pager setValue:@(oldToken + 1) forKey:@"scrollResolveRetryToken"];
770
+
771
+ UIViewController *page = [pager valueForKey:@"pageControllers"][0];
772
+ UICollectionView *lateList = [[UICollectionView alloc]
773
+ initWithFrame:CGRectMake(0, 0, 320, 600)
774
+ collectionViewLayout:[UICollectionViewFlowLayout new]];
775
+ [page.view addSubview:lateList];
776
+
777
+ // Readiness notification triggers immediate reconcile even after retry budget expired.
778
+ [[NSNotificationCenter defaultCenter]
779
+ postNotificationName:@"OneKeyNativeScrollViewDidBecomeReady"
780
+ object:lateList];
781
+ XCTAssertEqualObjects([pager valueForKey:@"pendingFallbackScrollView"], lateList);
782
+
783
+ // Explicit scroller readiness attaches synchronously with zero debounce delay.
784
+ [lateList removeFromSuperview];
785
+ RNCNativeScrollerComponentView *lateScroller = [self addScrollerToPager:pager atIndex:0];
786
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], lateScroller.scrollView);
787
+ XCTAssertEqual(sharedHeader.superview, lateScroller.scrollView);
788
+ XCTAssertEqualWithAccuracy(lateScroller.scrollView.contentInset.top, 164, 0.01);
789
+ XCTAssertNil([pager valueForKey:@"releasedPageScrollStates"][@"page-0"]);
790
+ }
791
+
606
792
  @end
@@ -668,6 +668,7 @@ typedef struct __IOHIDEvent *RNCTestHIDEventRef;
668
668
  @property (nonatomic, strong) RNCSynthesizedTouchDriver *driver;
669
669
  @property (nonatomic, strong) UIView *firstPage;
670
670
  @property (nonatomic) NSUInteger longPressBegins;
671
+ @property (nonatomic, strong) XCTestExpectation *longPressExpectation;
671
672
  @property (nonatomic) NSUInteger pagerPanBegins;
672
673
  @end
673
674
 
@@ -1001,7 +1002,10 @@ typedef struct __IOHIDEvent *RNCTestHIDEventRef;
1001
1002
 
1002
1003
  - (void)nestedLongPress:(UILongPressGestureRecognizer *)recognizer
1003
1004
  {
1004
- if (recognizer.state == UIGestureRecognizerStateBegan) self.longPressBegins += 1;
1005
+ if (recognizer.state == UIGestureRecognizerStateBegan) {
1006
+ self.longPressBegins += 1;
1007
+ [self.longPressExpectation fulfill];
1008
+ }
1005
1009
  }
1006
1010
 
1007
1011
  - (CGPoint)pressPointInNestedScrollView:(UIScrollView *)scroll
@@ -1022,14 +1026,12 @@ typedef struct __IOHIDEvent *RNCTestHIDEventRef;
1022
1026
  [pager addGestureRecognizer:longPress];
1023
1027
  // Exercise production wiring after a root recognizer is attached.
1024
1028
  ((void (*)(id, SEL))objc_msgSend)(self.pager, NSSelectorFromString(@"connectVerticalPagerGuard"));
1029
+ self.longPressExpectation = [self expectationWithDescription:@"root long press recognized before finger-up"];
1025
1030
  UITouch *touch = [self.driver beginAt:[self pressPoint]];
1026
1031
  UIGestureRecognizer *guard = [self.pager valueForKey:@"verticalPagerGesture"];
1027
1032
  XCTAssertEqual(guard.state, UIGestureRecognizerStatePossible);
1028
1033
  XCTAssertEqual(pager.panGestureRecognizer.minimumNumberOfTouches, 2);
1029
- XCTestExpectation *hold = [self expectationWithDescription:@"root stationary finger held"];
1030
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)),
1031
- dispatch_get_main_queue(), ^{ [hold fulfill]; });
1032
- [self waitForExpectations:@[hold] timeout:2];
1034
+ [self waitForExpectations:@[self.longPressExpectation] timeout:2];
1033
1035
  NSLog(@"[PagerPressDelivery] rootLongPress begins=%lu state=%ld touchPhase=%ld guardState=%ld pagerPanMinTouches=%lu",
1034
1036
  (unsigned long)self.longPressBegins, (long)longPress.state, (long)touch.phase,
1035
1037
  (long)guard.state, (unsigned long)pager.panGestureRecognizer.minimumNumberOfTouches);
@@ -1045,14 +1047,12 @@ typedef struct __IOHIDEvent *RNCTestHIDEventRef;
1045
1047
  - (void)testNestedStationaryNativeLongPressRecognizesBeforeFingerUp
1046
1048
  {
1047
1049
  UIScrollView *scroll = [self mountNestedHorizontalScrollView];
1050
+ self.longPressExpectation = [self expectationWithDescription:@"nested long press recognized before finger-up"];
1048
1051
  UITouch *touch = [self.driver beginAt:[self pressPointInNestedScrollView:scroll]];
1049
1052
  UIGestureRecognizer *guard = [self.pager valueForKey:@"verticalPagerGesture"];
1050
1053
  XCTAssertEqual(guard.state, UIGestureRecognizerStatePossible);
1051
1054
  XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 2);
1052
- XCTestExpectation *hold = [self expectationWithDescription:@"stationary finger held"];
1053
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)),
1054
- dispatch_get_main_queue(), ^{ [hold fulfill]; });
1055
- [self waitForExpectations:@[hold] timeout:2];
1055
+ [self waitForExpectations:@[self.longPressExpectation] timeout:2];
1056
1056
  // The real UIKit target action must arrive while the finger is still down.
1057
1057
  XCTAssertEqual(self.longPressBegins, 1);
1058
1058
  XCTAssertEqual(touch.phase, UITouchPhaseBegan);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-pager-view",
3
- "version": "6.7.0-alpha.366",
3
+ "version": "6.7.0-alpha.374",
4
4
  "description": "React Native wrapper for Android and iOS ViewPager",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
@@ -73,7 +73,7 @@
73
73
  "typescript": "^5.9.2"
74
74
  },
75
75
  "peerDependencies": {
76
- "@onekeyfe/react-native-native-logger": "6.7.0-alpha.366",
76
+ "@onekeyfe/react-native-native-logger": "6.7.0-alpha.374",
77
77
  "react": "*",
78
78
  "react-native": "*"
79
79
  },