feat(iOS, Stack v5): Align Stack implementation with RFC 753#3774
feat(iOS, Stack v5): Align Stack implementation with RFC 753#3774
Conversation
There was a problem hiding this comment.
Pull request overview
This PR migrates the iOS “gamma/stack” implementation to an Objective-C(/ObjC++) navigation-controller–based stack and introduces an operation queue/coordinator to batch apply push/pop changes in a consistent way (per RFC 753).
Changes:
- Replaces Swift
RNSStackController/RNSStackScreenControllerwith Objective-C equivalents. - Adds
RNSStackOperationCoordinator+ operation objects to queue and order push/pop updates. - Updates
RNSStackHostComponentView/RNSStackScreenComponentViewto use the new coordinator and track native dismissals.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| ios/gamma/stack/screen/RNSStackScreenController.swift | Removes Swift screen controller implementation. |
| ios/gamma/stack/screen/RNSStackScreenController.h | Adds ObjC header for the new screen controller. |
| ios/gamma/stack/screen/RNSStackScreenController.mm | Adds ObjC implementation for lifecycle + dismiss event emission. |
| ios/gamma/stack/screen/RNSStackScreenComponentView.mm | Initializes isNativelyDismissed and updates default props type for the stack screen component. |
| ios/gamma/stack/screen/RNSStackScreenComponentView.h | Adds isNativelyDismissed property to support native-dismiss handling. |
| ios/gamma/stack/host/RNSStackOperationCoordinator.h | Declares coordinator interface for pending operations. |
| ios/gamma/stack/host/RNSStackOperationCoordinator.mm | Implements ordering/execution of queued push/pop operations. |
| ios/gamma/stack/host/RNSStackOperation.h | Defines push/pop operation types and shared protocol. |
| ios/gamma/stack/host/RNSStackOperation.mm | Implements push/pop operation initializers. |
| ios/gamma/stack/host/RNSStackNavigationController.h | Declares navigation controller API for enqueuing operations and applying updates. |
| ios/gamma/stack/host/RNSStackNavigationController.mm | Implements queued push/pop application and stack model logging. |
| ios/gamma/stack/host/RNSStackHostComponentView.mm | Replaces old controller usage with navigation controller + coordinator and wires operation enqueueing to Fabric lifecycle. |
| ios/gamma/stack/host/RNSStackHostComponentView.h | Removes old Swift controller exposure from the host view public interface. |
| ios/gamma/stack/host/RNSStackController.swift | Removes Swift stack controller implementation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @property (nonatomic, nonnull, strong, readonly) RNSStackController *stackController; | ||
|
|
||
| - (nonnull NSMutableArray<RNSStackScreenComponentView *> *)reactSubviews; | ||
|
|
There was a problem hiding this comment.
These weren't used outside the implementation or anywhere
| - (void)addPopOperationIfNeeded:(nonnull RNSStackScreenComponentView *)stackScreen | ||
| { | ||
| if (stackScreen.activityMode == RNSStackScreenActivityModeAttached && !stackScreen.isNativelyDismissed) { | ||
| // This shouldn't happen in typical scenarios but it can happen with fast-refresh. |
There was a problem hiding this comment.
Comment taken from Android, should still be relevant
| @protocol RNSStackOperation | ||
|
|
||
| @property (nonatomic, strong, readonly, nonnull) RNSStackScreenComponentView *stackScreen; | ||
|
|
||
| @end | ||
|
|
||
| @interface RNSPushOperation : NSObject <RNSStackOperation> | ||
|
|
||
| @property (nonatomic, strong, readonly, nonnull) RNSStackScreenComponentView *stackScreen; | ||
|
|
||
| - (instancetype)initWithScreen:(nonnull RNSStackScreenComponentView *)stackScreen; | ||
|
|
||
| @end | ||
|
|
||
| @interface RNSPopOperation : NSObject <RNSStackOperation> | ||
|
|
||
| @property (nonatomic, strong, readonly, nonnull) RNSStackScreenComponentView *stackScreen; | ||
|
|
||
| - (instancetype)initWithScreen:(nonnull RNSStackScreenComponentView *)stackScreen; | ||
|
|
||
| @end |
There was a problem hiding this comment.
Let me know if there's a better way to do this comparing to sealed class implementation in kotlin
| NSMutableArray<NSDictionary *> *operationsWithIndices = [NSMutableArray array]; | ||
| for (id<RNSStackOperation> operation in operations) { | ||
| NSInteger index = [stackScreens indexOfObject:operation.stackScreen]; | ||
| [operationsWithIndices addObject:@{@"index" : @(index), @"operation" : operation}]; | ||
| } | ||
|
|
||
| [operationsWithIndices sortUsingComparator:^NSComparisonResult(NSDictionary *obj1, NSDictionary *obj2) { | ||
| return [obj1[@"index"] compare:obj2[@"index"]]; | ||
| }]; | ||
|
|
||
| NSMutableArray<id<RNSStackOperation>> *orderedOperations = [NSMutableArray new]; | ||
| for (NSDictionary *dict in operationsWithIndices) { | ||
| [orderedOperations addObject:dict[@"operation"]]; | ||
| } | ||
|
|
||
| return orderedOperations; |
There was a problem hiding this comment.
In comparison to kotlin stream, this is 10x worse. Is there a better way to sort by indices from other array?
There was a problem hiding this comment.
maybe you could try: https://developer.apple.com/documentation/foundation/nsarray/sortedarray(comparator:)?language=objc to avoid intermediate allocations in operationsWithIndices and compare indices inside comparator?
| _reactEventEmitter = [RNSStackScreenComponentEventEmitter new]; | ||
|
|
||
| _hasUpdatedActivityMode = NO; | ||
| self.isNativelyDismissed = NO; |
There was a problem hiding this comment.
Should this be reset somewhere? This is set to YES when native dismiss is triggered
There was a problem hiding this comment.
I think that we currently assume that screens won't be re-used so I don't think that we need to reset it for now.
|
There is another thing. For now, I didn't set up |
| - (void)stackScreenChangedActivityMode:(nonnull RNSStackScreenComponentView *)stackScreen | ||
| { | ||
| [_controller setNeedsUpdateOfChildViewControllers]; | ||
| switch (stackScreen.activityMode) { |
There was a problem hiding this comment.
should we add error handling?
There was a problem hiding this comment.
What do you mean?
There was a problem hiding this comment.
I've added an assertion to this method, to check that the argument is indeed nonnull as declared.
| - (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction &)transaction | ||
| withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry | ||
| { | ||
| _hasModifiedReactSubviewsInCurrentTransaction = false; | ||
| [_controller reactMountingTransactionWillMount]; | ||
| // noop | ||
| } |
There was a problem hiding this comment.
can we remove this method?
|
|
||
| NS_ASSUME_NONNULL_BEGIN | ||
|
|
||
| @protocol RNSStackOperation |
There was a problem hiding this comment.
you might consider a class here, which will allow to move up both stackScreen and initWithScreen to the base
There was a problem hiding this comment.
I mean, they're both equally meh. I need some base for both i.e. in common sort function. If I had base class here, I would have two derived classes that don't change anything. It's few lines either way. I think we could leave it be.
There was a problem hiding this comment.
I did that refactor. We could even consider removing RNSPushOperation and RNSPopOperation entirely. In lieu of them, we could simply add a type member to the RNSStackOperation. Might be even simpler.
|
|
||
| @property (nonatomic, strong, readonly, nonnull) RNSStackScreenComponentView *stackScreen; | ||
|
|
||
| - (instancetype)initWithScreen:(nonnull RNSStackScreenComponentView *)stackScreen; |
There was a problem hiding this comment.
nonnull seems to be redundant, as we have NS_ASSUME_NONNULL_BEGIN
| NSMutableArray<NSDictionary *> *operationsWithIndices = [NSMutableArray array]; | ||
| for (id<RNSStackOperation> operation in operations) { | ||
| NSInteger index = [stackScreens indexOfObject:operation.stackScreen]; | ||
| [operationsWithIndices addObject:@{@"index" : @(index), @"operation" : operation}]; | ||
| } | ||
|
|
||
| [operationsWithIndices sortUsingComparator:^NSComparisonResult(NSDictionary *obj1, NSDictionary *obj2) { | ||
| return [obj1[@"index"] compare:obj2[@"index"]]; | ||
| }]; | ||
|
|
||
| NSMutableArray<id<RNSStackOperation>> *orderedOperations = [NSMutableArray new]; | ||
| for (NSDictionary *dict in operationsWithIndices) { | ||
| [orderedOperations addObject:dict[@"operation"]]; | ||
| } | ||
|
|
||
| return orderedOperations; |
There was a problem hiding this comment.
maybe you could try: https://developer.apple.com/documentation/foundation/nsarray/sortedarray(comparator:)?language=objc to avoid intermediate allocations in operationsWithIndices and compare indices inside comparator?
|
|
||
| - (void)didMoveToParentViewController:(UIViewController *)parent | ||
| { | ||
| NSLog(@"[RNScreens] Screen view with tag=%ld didMoveToParentViewController %@", (long)_screenView.tag, parent); |
kligarski
left a comment
There was a problem hiding this comment.
Looks good, I left some minor suggestions.
| _reactEventEmitter = [RNSStackScreenComponentEventEmitter new]; | ||
|
|
||
| _hasUpdatedActivityMode = NO; | ||
| self.isNativelyDismissed = NO; |
There was a problem hiding this comment.
I think that we currently assume that screens won't be re-used so I don't think that we need to reset it for now.
34cb54d to
425972c
Compare
d8a04d2 to
2571b25
Compare
|
During testing I've found a bug. bug-1-quick-push-pop-seq.movMy initial read is that it is related to how our |
|
Yeah, I've managed to fix this issue with a simple patch to I now simply prevent JS-push operation of a screen that is during a dismissal. See the video below bug-1-quick-push-pop-seq-fixed.mov |
Guards against unhandled activityMode values reaching the switch in didRemoveChild. Fails loudly in debug instead of falling through.
|
I've found another problem, see the video: bug-2-crash-when-popping-not-top-screen.movWhat happens here is on some point when pushing consecutive B screens, we push twice from a single one. E.g. from B-14, we push both B-15 and B-16. They're animated in sequentially => on the B-16 we can click pop (it'll try to pop B-15), when B-16 is already mounted on top of it, leading to crash. I'm not sure yet how we might solve it. One approach would be to reject (ignore) such operation and emit appropriate event. |
|
I've decided for now, to ignore such pop requests on |
|
There is yet another bug. See the video. bug-3-crash-of-pop-conflict.movWhat happens here likely:
I've managed to also reproduce it once on Android (but with much worse reproducibility rate). |
Consolidate duplicated initializer logic from RNSPushOperation and RNSPopOperation into a shared RNSStackOperation base class. Adds a nonnull assertion on screen parameter. Also applies early return guard in navigation controller and fixes true -> YES for ObjC style.
Guard against popping a route when a higher-indexed attached route exists on the stack. Also replace magic -1 with NOT_FOUND_INDEX.
|
I've created ticket for that last issue. It requires some additional handling. Currently in stack v4, thanks to @t0maboro we handle similar interaction via early return. This is a heuristic, that works, unless somebody really intents to push / pop screen in quick succession. We should handle such cases robustly, maybe in similar manner that we've recently implemented for tabs. |
kkafar
left a comment
There was a problem hiding this comment.
I've got some remarks regarding ownership model - but it is not a blocking problem. For context see the ticket: https://github.com/software-mansion/react-native-screens-labs/issues/1142.
I've pushed all desired changes, including patches for discovered problems.
Let's proceed here.
| - (void)stackScreenChangedActivityMode:(nonnull RNSStackScreenComponentView *)stackScreen | ||
| { | ||
| [_controller setNeedsUpdateOfChildViewControllers]; | ||
| switch (stackScreen.activityMode) { |
There was a problem hiding this comment.
I've added an assertion to this method, to check that the argument is indeed nonnull as declared.
|
|
||
| NS_ASSUME_NONNULL_BEGIN | ||
|
|
||
| @protocol RNSStackOperation |
There was a problem hiding this comment.
I did that refactor. We could even consider removing RNSPushOperation and RNSPopOperation entirely. In lieu of them, we could simply add a type member to the RNSStackOperation. Might be even simpler.
| return [operations sortedArrayUsingComparator:^NSComparisonResult(RNSStackOperation *obj1, RNSStackOperation *obj2) { | ||
| return [@([stackScreens indexOfObject:obj1.stackScreen]) compare:@([stackScreens indexOfObject:obj2.stackScreen])]; | ||
| }]; |
There was a problem hiding this comment.
The complexity here is
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1050
Closes https://github.com/software-mansion/react-native-screens-labs/issues/864
Closes https://github.com/software-mansion/react-native-screens-labs/issues/816
Description
This PR aligns the current Stack implementation with RFC 753 by adding an operations queue that enables us to defer the application of JS differences in a batch that moves the stack from one consistent state to another. The implementation has been adapted from Android implementation, with key difference being the absence of FragmentManager along with its FragmentOperations. These are handled within RNSStackNavigationController.
Changes
Before & after - visual documentation
no visual changes
Test plan
Use
Tests.Issue.TestScreenStackto test various scenarios, verify the state in the logs. RunTests.Issue.Test3576to check a specific scenario that caused problems on the android.Checklist