Video #236: Composable Stacks: Effect Cancellation
Episode: Video #236 Date: May 22, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep236-composable-stacks-effect-cancellation

Description
We round out the functionality of the Composable Architecture’s stack navigation tools. This includes automatic cancellation of a child feature’s effects when it’s popped off the stack, and the ability for a child feature to pop itself. Along the way we will clean up the domain modeling and user experience of working with these tools.
Video
Cloudflare Stream video ID: f428524f038ace71fa3c694813a5ad6d Local file: video_236_composable-stacks-effect-cancellation.mp4 *(download with --video 236)*
References
- Discussions
- Custom Dump
- Composable navigation beta GitHub discussion
- 0236-composable-navigation-pt15
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
So, we think it’s pretty incredible how easy it is to add new features to an existing navigation stack. It only took about 3 steps: you add the feature’s domain to the Path reducer, you add a scope to the Path reducer’s body, and finally you add the view to the NavigationStackStore ’s destination closure.
— 0:44
And once you complete those few steps you get immediate and infinite introspection into everything that is happening in the feature when it is on the stack. This includes being able to traverse and aggregate data across the elements of the stack, and the ability to see every single action sent into the child feature. Stephen
— 1:07
But, as amazing as this all seems, there are some serious problems lurking in the shadows, and they are reminiscent of what we experienced with our presentations APIs for sheets, popovers and covers.
— 1:17
One of the big problems we saw with those forms of navigation, and in particular the ifLet operator, is that when the child feature was dismissed, its effects were not cancelled. That allowed effects to continue feeding data into the system even long after the child feature had gone away.
— 1:31
This problem also exists in our navigation stack, and the forEach operator, so let’s see how it can happen and what it takes to fix it. Automatic effect cancellation
— 1:41
The most obvious problem is that a feature’s effects are not torn down when the feature is popped off the stack. To reproduce this we can simply drill down to the counter feature, start the timer, and then go back to the root. We are immediately met with some purple warnings letting us know something is wrong: Action was sent for element that does not exist.
— 2:05
This is the warning we added to our custom forEach operator for when we detect a child action is sent into the system when there is no child state in the stack corresponding to that action: case let .element(id: id, action: elementAction): if state[keyPath: toElementsState].elements[id: id] == nil { XCTFail( "Action was sent for element that does not exist." ) return self.reduce(into: &state, action: action) }
— 2:23
This bit of code says that we received an action for a child feature with a particular ID, yet there was no value in the stack with that ID. That is almost always a logical error in the application as it means actions are being sent into the system that we cannot react to, and it’s similar to what we saw in the ifLet reducer operator many episodes back and fixed. Most often it happens due to not tearing down child effects when the child feature goes away, as is the case here.
— 2:46
The way we fixed it over in the ifLet operator was to tag every child effect with a unique identifier: childEffects .map { actionCasePath.embed(.presented($0)) } .cancellable(id: childState.id)
— 3:05
And then when we detected the feature was being dismissed, we cancelled that effect: .cancel(id: childState.id)
— 3:11
That worked out quite well, so let’s see what it takes to do the same for stacks.
— 3:16
The cool thing about the forEach and ifLet operators is that they have a very global view into what is happening in both the child feature and the parent feature that is embedding the child feature. We get to inspect the state of things both before and after the parent and child features run, and that gives us a ton of power.
— 3:31
For example, we could compute the IDs of all the running features in the stack before the parent and child reducers run, and then compare that to the IDs of all the running features after the parent and child reducers run, and use that information to figure out which effects to cancel.
— 3:47
So, let’s start by first marking all effects coming from the child reducer as cancellable, and we can just use the child’s ID as the cancellable identifier: return .merge( element .reduce( into: &state[keyPath: toElementsState] .elements[id: id]!.element, action: elementAction ) .map { toStackAction.embed(.element(id: id, action: $0)) } .cancellable(id: id), self.reduce(into: &state, action: action) )
— 4:09
Then right at the top of our reducer we can compute the IDs before we do any work: return Reduce { state, action in let idsBefore = state[keyPath: toElementsState] .elements.ids … }
— 4:38
This is an ordered set of all the features that are currently running before we process this new action.
— 4:42
Then we want to compare this ordered set with the IDs after we do all our work in the reducer, so we could compute the IDs at the end of the reducer: return Reduce { state, action in let idsBefore = state[keyPath: toElementsState] .elements.ids switch toStackAction.extract(from: action) { … } let idsAfter = state[keyPath: toElementsState] .elements.ids }
— 4:56
But then we are met with a warning: Will never be executed
— 4:58
This is happening because every branch of the switch has a return statement, and so we can never actually get to this line.
— 5:06
Let’s do a quick refactor where we don’t return in each case, and instead hoist out the effects that should be run from each case: return Reduce { state, action in let effects: Effect<Action> … }
— 5:17
Then we will assign those effects in each case of the switch rather than returning. First we have an if with an early out in the element case. Let’s turn that into a break instead: if state[keyPath: toElementsState].elements[id: id] == nil { XCTFail( "Action was sent for element that does not exist." ) effects = self.reduce(into: &state, action: action) break }
— 5:26
And everywhere else we can simply assign effects instead of returning: switch toStackAction.extract(from: action) { case let .element(id: id, action: elementAction): … effects = .merge( … ) case let .setPath(path): … effects = self.reduce(into: &state, action: action) case .none: effects = self.reduce(into: &state, action: action) }
— 5:31
And return those effects at the very end: return Reduce { state, action in let effects: EffectTask<Action> … return effects }
— 5:35
Now we have the IDs of running features before and after running the forEach logic, and so we can simply subtract the after IDs from the before IDs to get all the IDs of features that were removed from the stack, and construct an effect that cancels them all: let cancelEffects: Effect<Action> = .merge( idsBefore.subtracting(idsAfter).map { id in .cancel(id: id) } )
— 6:10
And finally we will merge that effect with the rest of the effects that want to be run: return .merge(effects, cancelEffects)
— 6:18
Amazingly, that is all it takes. I can run the app in the simulator, drill down to a counter feature, start a timer, and then pop back and see that we no longer get those purple runtime warnings. That must mean that the effects were cancelled and are no longer feeding actions back into the system.
— 6:37
But, just to be very sure let’s take a look at the information printed to the console as we perform these actions. Remember that we have a _printChanges() attached to the reducer in the entry point, and so it will print every action and every state change that occurs in the entire application.
— 6:50
We’ll clear the logs, drill down to the counter feature, start the timer and leave it going for a few ticks, and then pop back to the root. We see the following logs: received action: RootFeature.Action.path( .element( id: UUID(B69D33A4-6615-4460-90B2-58C00EFBCC91), action: .counter(.toggleTimerButtonTapped) ) ) RootFeature.State( // Not equal but no difference detected: - path: StackState(…) + path: StackState(…) ) received action: RootFeature.Action.path( .element( id: UUID(B69D33A4-6615-4460-90B2-58C00EFBCC91), action: .counter(.timerTick) ) ) RootFeature.State( // Not equal but no difference detected: - path: StackState(…) + path: StackState(…) ) received action: RootFeature.Action.path( .element( id: UUID(B69D33A4-6615-4460-90B2-58C00EFBCC91), action: .counter(.timerTick) ) ) RootFeature.State( // Not equal but no difference detected: - path: StackState(…) + path: StackState(…) ) received action: RootFeature.Action.path( .setPath( StackState(elements: []) ) ) RootFeature.State( - path: StackState( - elements: [ - [0]: StackState.Component( - id: UUID(B69D33A4-6615-4460-90B2-58C00EFBCC91), - element: .counter( - CounterFeature.State( - id: UUID( - AA692E0F-DB65-43A5-AD89-B8EF9FAC9AA1 - ), - count: 2, - isLoading: false, - isTimerOn: true - ) - ) - ) - ] - ) + path: StackState(elements: []) )
— 7:14
OK, there are some strange things in the logs, such as this “// Not equal but no difference detected:” comment. The state is definitely changing since the count is incrementing and did display in the UI. So something funky is going on here.
— 7:33
The reason this is happening is due to a confluence of problems with how we turn state changes into this nicely formatted message. Recall that these messages are produced by our open source library, Custom Dump , which is capable of turning large, complex data structures into easy-to-read, nicely formatted strings, and given two values it can even print out a nicely formatted string of the difference between the two values.
— 7:54
However, due to our strange custom conformances to the Equatable protocol, we are in an awkward spot. The Custom Dump library detects that two stack values are not equal, but when it uses a mirror to traverse the data structure and print out a nicely formatted string, it doesn’t see any difference between the data types. This is because the Component data type, which is the thing that hides away the ID of features in the stack, declares its equality as only caring about the IDs, and not the value of the element: extension StackState.Component: Hashable { static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } }
— 8:36
Luckily the fix is quite easy. We just need to provide a custom mirror for Custom Dump to use so that we can expose more of the internals of the stack state for diffing, even when two Component values are “equal”: extension StackState: CustomDumpReflectable { var customDumpMirror: Mirror { Mirror( reflecting: self.elements.map { (id: $0.id, element: $0.element) } ) } }
— 9:18
Now when we run the app again in the simulator we will see that the logs print out how we expect, and the logs definitely do show that when we pop a counter feature off the stack, its timer effect is properly cancelled. Child-driven dismissal
— 9:38
OK, so things are looking pretty good now. We are automatically cancelling effects in child features when they are dismissed, and we have fixed some bugs around how data is printed and formatted in our debugging tools. Brandon
— 9:49
Let’s continue beefing up the way parent and child features can interact with each other. Many episodes ago we implemented a super cool feature in our presentation tools that allowed a child feature to dismiss itself without any interaction with the parent feature. This allowed the child to execute very complex and nuanced logic around its dismissal without the parent needing to intervene at all, and it was incredibly powerful.
— 10:14
Let’s take a look at what it takes to support child dismissal in a navigation stack.
— 10:20
Well, first of all, maybe there’s a chance that things just work already? That seems highly unlikely, but we might as well try.
— 10:27
If you recall, the way child dismissal currently works in the other tools we’ve built so far is that you add a dependency to the DismissEffect to your feature, which we did over in the ItemFormFeature : struct ItemFormFeature: Reducer { … @Dependency(\.dismiss) var dismiss … }
— 10:37
And then you invoke that dependency from an effect once you decide you want to dismiss the child. In the case of the ItemFormFeature we decided to dismiss after the timer ticked 3 times: return .run { send in var tickCount = 0 for await _ in self.clock.timer(interval: .seconds(1)) { await send(.timerTick) tickCount += 1 if tickCount == 3 { await self.dismiss() } } }
— 10:56
It’d be nice if we could do something similar in the features that run inside the stack. For example, what if we could add the DismissEffect to the CounterFeature : struct CounterFeature: Reducer { … @Dependency(\.dismiss) var dismiss … }
— 11:15
And say that when we detect the timer counts up to 100 we will dismiss: case .timerTick: state.count += 1 if state.count >= 100 { return .fireAndForget { await self.dismiss() } } else { return .none }
— 11:32
That would be pretty cool if this just somehow magically works. To check it out let’s alter the entry point of the application to already be deep linked into a counter feature with a count of 97: RootView( store: Store( initialState: RootFeature.State( path: StackState([ .counter(CounterFeature.State(count: 97)), ]) ) ) )
— 11:50
We can run the app, start the timer, and wait a few ticks to see… well nothing happens. The timer keeps going and the screen does not pop off the stack.
— 12:04
This honestly shouldn’t be too surprising because we have done literally zero work in the forEach operator to support this.
— 12:10
But, at the same time, it is a bit of a bummer that the library doesn’t give us any warnings about why things are not quite right. We can actually improve this a bit. We can easily detect when one invokes the dismiss effect in a context for which it can’t possibly work. In particular, this is the case when the dismiss dependency hasn’t been overridden, so we can cause a runtime warning in the default version of the DismissEffect dependency: extension DismissEffect: DependencyKey { static var liveValue = DismissEffect(dismiss: { XCTFail( """ Trying to dismiss from a context that does not \ support dismissal. """ ) }) static var testValue = DismissEffect(dismiss: {}) }
— 12:58
And now when we run the application and try to dismiss we get a nice, in-your-face purple warning letting you know that something isn’t right. So, let’s fix it.
— 13:20
We had to do quite a bit of work to support it in the ifLet operator, so let’s take a look at that real quick to remind ourselves how that all went down.
— 13:28
There were two main steps to supporting this. First we override the DismissEffect dependency on the child reducer so that we could handle what happens when the child domain invokes it: let childEffects = child .dependency( \.dismiss, DismissEffect { [id = childState.id] in Task.cancel(id: DismissID(id: id)) } ) .reduce(into: &childState, action: childAction) In particular we cancel any effects tagged with a specific ID that is derived from the child feature’s ID.
— 13:53
So that’s the first step. The next step is to create a long-living effect when the child feature first appears, and it will stay alive for as long as the child feature is alive: onFirstAppearEffect = .run { send in … }
— 13:57
We do this because it allows us to listen for when the DismissID is cancelled, and when we detect that situation we can send the PresentationAction.dismiss action into the system in order to clear out state: onFirstAppearEffect = .run { send in do { try await withTaskCancellation( id: DismissID(id: childStateAfter.id) ) { try await Task.never() } } catch is CancellationError { await send(actionCasePath.embed(.dismiss)) } }
— 14:33
That’s pretty cool stuff.
— 14:35
Let’s see what it takes to port this idea over to the forEach operator.
— 14:39
We can start by overriding the DimissEffect dependency on the child element feature that is about to run so that it can communicate back to this parent domain: element .dependency(\.dismiss, DismissEffect { Task.cancel(id: <#???#>) }) .reduce( into: &state[keyPath: toElementsState] .elements[id: id]!.element, action: elementAction ) .map { toStackAction.embed(.element(id: id, action: $0)) } .cancellable(id: id),
— 15:21
But what ID do we use?
— 15:24
In the ifLet operator we used a special DismissID , but for a quick moment let’s just use the child feature’s ID: .dependency(\.dismiss, DismissEffect { Task.cancel(id: id) })
— 15:36
This means when this closure is invoked it will cancel all of the child effects.
— 15:43
We can give this a spin by starting the timer, waiting a few ticks, and then seeing it stops. So this is proving that the child feature is indeed invoking this closure, and it acts as a kind of communication wormhole between the child and parent domains.
— 16:09
Now we just need to actually react to this closure being called in a different part of the reducer. The way we do this is to cancel a different ID, the DismissID : .dependency(\.dismiss, DismissEffect { Task.cancel(id: DismissID(id: id)) })
— 16:21
And then we can listen for this cancellation inside an effect.
— 16:26
In particular, we need to be able to detect when a feature is pushed onto the stack, start up one of those long-living effects that listens for the DismissID being cancelled, and then sends an action back into the system to pop the feature off the stack.
— 16:39
That sounds complicated, but luckily for us we can again take advantage of the fact that our forEach operator has a global view of everything happening inside the stack. Previously we were taking advantage of the fact that we can see what features were on the stack before the child and parent features ran and compared that against the features on the stack after the child and parent ran in order to cancel child effects. We did that by subtracting the IDs in the stack after from the IDs in the stack before, and then cancelled all effects associated with those IDs since those are the features that were just popped off the stack: let cancelEffects: Effect<Action> = .merge( idsBefore.subtracting(idsAfter).map { id in .cancel(id: id) } )
— 17:16
Now we want the opposite. We want to subtract the IDs before from the IDs after to find all the features just pushed onto the stack: idsAfter.subtracting(idsBefore)
— 17:32
We can then spin up a long living effect for each of these IDs to get the onFirstAppearEffects : let onFirstAppearEffects: Effect<Action> = .merge( idsAfter.subtracting(idsBefore).map { id in .run { send in } } )
— 17:51
And those effects will be merged into the main set of effects we return: return .merge( effects, cancelEffects, onFirstAppearEffects )
— 17:57
Now we just have to implement the long-living effect.
— 18:00
It’s going to be quite similar to what we did in the ifLet operator. First we will spin up a forever suspending unit of async work so that this effect can live for as long as the child feature is in the stack: .run { send in try await Task.never() }
— 18:16
Then we will wrap that work in withTaskCancellation(id:) so that we can detect when the DismissID is cancelled: .run { send in try await withTaskCancellation(id: DismissID(id: id)) { try await Task.never() } }
— 18:32
And when that async unit of work is cancelled we know it is time to send an action back into the system to pop the feature off the stack: .run { send in do { try await withTaskCancellation( id: DismissID(id: id) ) { try await Task.never() } } catch is CancellationError { <#???#> } }
— 18:40
But now the question is what do we do when we detect cancellation. Previously in the ifLet operator we were able to send a dismiss action by first embedding it into the parent domain and then sending it: await send(actionCasePath.embed(.dismiss))
— 18:48
In particular, this dismiss action is an actual case of the PresentationAction enum which the ifLet operator requires you to use in order to invoke the operator.
— 18:56
However, we don’t use PresentationAction s in the forEach operator. We use StackAction s, and it doesn’t have a dismiss . It only has setPath : enum StackAction<State, Action> { case element(id: UUID, action: Action) case setPath(StackState<State>) }
— 19:32
So, does that mean we need to somehow send the setPath action with the full path of the stack minus the feature that is being dismissed? } catch is CancellationError { await send( toStackAction.embed( .setPath(<#StackState<ElementState>#>) ) ) }
— 19:44
But how is even that possible? We need access to the freshest version of the stack in order to send it back into the system with one element removed, but the best we can do is capture a static version of it the moment the effect is created: .run { [stack = state[keyPath: toElementsState]] send in … }
— 20:09
But that’s no good. It would mean at the moment of dismiss the child we are taking a stale version of the state and sending it back into the system. That would undo any changes that had transpired since the state was captured. A more precise StackAction
— 20:22
What we are seeing here is that although using setPath in our StackAction enum did help us build a powerful version of these tools at first, now it is actually holding us back. A full-blown setPath action for sending the entire stack back into the system is just too restrictive, and we don’t even need all of that power. We don’t ever need to be able to send an action with an arbitrary stack of data. All that can ever happen is that a new screen is pushed onto the stack or a screen is popped off the stack. Those are the two fundamental operations that can be done in the stack, and also all that the NavigationStack is capable of doing with the binding we hand to it.
— 21:02
So, maybe we went a little overboard with the setPath action. Perhaps we can break it up into simpler units that will also unlock our ability to implement this dismissal effect.
— 21:13
Rather than having a single, all-encompassing way to fully replace the current stack with any other kind of stack, what if instead we exposed 2 simpler atomic units: the ability to push a new piece of state onto the stack, and the ability to pop from the stack: enum StackAction<State, Action> { case element(id: UUID, action: Action) // case setPath(StackState<State>) case push(State) case pop }
— 21:35
This helps a bit, but it isn’t quite powerful enough. We need the ability to pop any feature in the stack, not just the last one. Not only do we need that to support a DismissEffect that can pop any feature, but even iOS demands this behavior. Recall that if you push many features onto the stack you can tap and hold the back button and see a whole list of every element in the stack. If you select one of those titles it will pop you back to that feature.
— 22:21
So, we need to beef up the pop case so that it can holds the ID of the screen we want to pop off the stack: enum StackAction<State, Action> { case element(id: UUID, action: Action) // case setPath(StackState<State>) case push(State) case popFrom(id: UUID) }
— 22:31
That way we can pop any screen of the stack we want.
— 22:40
This is of course going to break a number of things, so let’s see what it takes to get everything compiling again. First of all, in the forEach reducer we now need to split the single setPath action into two: case let .push(elementState): return .none case let .popFrom(id: id): return .none // case let .setPath(path): // state[keyPath: toElementsState] = path // effects = self.reduce(into: &state, action: action)
— 23:03
For the popFrom action we will first run the parent reducer on the state, that way it gets one last chance to inspect the state being popped before it fully goes away: case let .popFrom(id: id): effects = self.reduce(into: &state, action: action)
— 23:23
Then we will actually do the work to pop the feature of the stack, as well as everything that comes after it. This takes a bit of work to get right, so let’s make it a helper on StackState : struct StackState<Element> { … mutating func pop(from id: UUID) { } }
— 23:42
We first can compute the index of the ID we are popping off amongst all the IDs in the identified array: guard let index = self.elements.ids.firstIndex(of: id) else { return }
— 24:02
And then we can remove all elements from that index on to the end: self.elements.removeSubrange(index...)
— 24:12
This allows us to complete the popFrom action: case let .popFrom(id: id): effects = self.reduce(into: &state, action: action) state[keyPath: toElementsState].pop(from: id)
— 24:31
Even better, we should probably warn the user and fail the test suite when one tries to pop an element of the stack that does not exist. That would definitely be a programmer error, and so we should be notified and figure out what is going on: if !state[keyPath: toElementsState].elements.ids .contains(id) { XCTFail( """ Tried popping an element off the stack that does \ not exist. """ ) } else { state[keyPath: toElementsState].pop(from: id) }
— 25:17
Next we have the push action. This is a little simpler. We just need to append a new element to the stack state and then run the parent reducer: case let .push(element): state[keyPath: toElementsState].append(element) effects = self.reduce(into: &state, action: action)
— 25:38
Note that we are specifically choosing this order. That is, we want to append the child state first and then run the parent reducer. We want to do this because it allows the parent to perform some additional mutations to the child state after it has been inserted, if it wants to do that.
— 26:07
It’s pretty cool how subtle we can be with this logic. In the case of popping we run the parent reducer first and then perform the pop. And in the case of pushing we do the opposite: we push the new state onto the stack and then run the parent reducer.
— 26:12
OK, I believe the forEach reducer is now in compiling order. The next error is down in our NavigationStackStore . We no longer have a setPath action to use in the binding we derive for the stack: send: { .setPath(StackState(elements: $0)) } Type ‘StackAction<PathState, PathAction>’ has no member ’setPath’
— 26:31
Now the only thing that can actually write to this binding is the NavigationStack , and it really only supports two very specific kinds of writes: when the user interacts with the back button some number of screens will be popped off the stack, and when a user taps a NavigationLink a screen will be pushed onto the stack.
— 27:04
We are going to assume that those are the only two types of writes that can ever happen, and so all we have to do is interpret the new data being written and compare it to the existing data to figure out if a push or pop is happening.
— 27:26
So, in the send we have the new stack of elements: send: { newStack in } We need to detect if this newStack has fewer or more elements than what is currently in the stack, and that will dictate if we return a push or popFrom action. The easiest way to get access to the freshest version of the current stack is to use the hack that we employed earlier in the get of the binding: send: { newStack in let currentStack = ViewStore( self.store, observe: { $0 }, removeDuplicates: { _, _ in true } ) .state .elements }
— 27:37
We want to iterate again that this hack is not ideal, and luckily for us it will not actually be necessary in the final form of these tools that we ship to everyone.
— 27:46
Now that we got the new stack and the current stack we can start comparing them. If the new stack has more elements, then that means we are pushing onto the stack: if newStack.count > currentStack.count, let component = newStack.last { return .push(component.element) }
— 28:20
And otherwise it means we are popping: } else { return .popFrom(id: currentStack[newStack.count].id) }
— 28:30
And that’s all it takes. The application should work exactly as it did before, but we have now split the setPath action into two simpler, smaller and more atomic units.
— 28:57
This change has even greatly improved the ergonomics of our _printChanges helper. Previously, each time we pushed a screen onto the stack it would send the entire stack through the system inside the action. That was incredibly repetitive since only one single thing was added to the end of the stack.
— 29:13
Now we get a much clearer picture of what is actually going on. We can clear see when something is pushed onto the stack and when something is popped. This will make it much easier to analyze our logs for large, complex features.
— 29:45
Even better, with this change we can now properly implement the child dismissal feature. The popFrom is exactly the action we need to send when we detect cancellation of the DismissID , and we have access to the exact ID that we want to pop from: .run { send in do { try await withTaskCancellation( id: DismissID(id: id) ) { try await Task.never() } } catch is CancellationError { await send(toStackAction.embed(.popFrom(id: id))) } }
— 30:13
That’s all it takes. Now we can launch the app, and let’s drill down another layer, start the timer, wait a few ticks and we will see the feature automatically pops itself off the stack.
— 30:45
We can add another silly use of the DismissEffect . Remember that part of the application where we simulated some effectful work being done before we drilled down to the counter. We introduced a bit of uncertainty into that process by having it drill-down half the time, and the other half it would do nothing. It kinda simulated a “failure” to load some data for the next screen, and demonstrated that we can have very nuanced logic dictating how we push new screens onto the stack.
— 31:30
Let’s add a twist where instead of doing nothing half the time we will pop to the parent feature: if Bool.random() { return .send(.delegate(.goToCounter(state.count))) } else { return .fireAndForget { await self.dismiss() } }
— 31:38
Now we can play with this an see that half the time we drill down deeper, and half we pop back. So that’s pretty cool.
— 31:57
I also just want to reiterate how amazing it is that we have a single DismissEffect dependency that works in both tree-based navigation contexts and stack-based navigation contexts. Your child features don’t even need to think about the manner in which they are being presented. They can just request that they be dismissed, and the parent handles it. Fixing a child dismissal bug So, this is all looking pretty incredible. We have decided to split the setPath action from our StackAction enum into the two fundamental actions that one can generally do with a navigation stack: you can either push a new piece of state onto the stack or you can pop a particular screen off, including everything after it. That gave us the super power of being able to extend the DismissEffect capabilities to navigation stacks, which allows any child feature to automatically pop itself off the stack.
— 32:18
However, there is a bug in our code currently, and it is the exact same bug we encountered when building the child dismissal tool for the ifLet operator, and it has to do with deep-linking. Let’s show what the bug is, and then quickly fix it.
— 32:33
To reproduce the bug we can simply launch the app since we have deep-linking set up already. We are immediately launched into the counter feature with the count set to 97. The problem is that if we start the timer, wait for 3 ticks, the counter does not pop itself off the stack.
— 32:45
This did work a moment ago, but only because we deliberately drilled down one more layer, in which case the bug does not appear. It only happens when deep linked into a child feature and that feature tries dismissing itself. In that exact situation we never get an opportunity to check the before and after IDs to see that the feature was presented since all the state is there from the very beginning.
— 32:53
The way we fixed this in the ifLet operator was to squirrel away a little bit of extra data inside the @PresentationState property wrapper that determined whether or not the feature had been presented or not: @propertyWrapper struct PresentationState<State> { private var value: [State] fileprivate var isPresented = false … }
— 33:08
This allows us to start up the onFirstAppearEffect later on, when an action came into the system and we saw the feature had not yet been “presented” as far as the reducer was concerned: if let childStateAfter, !isEphemeral(childStateAfter), childStateAfter.id != childStateBefore?.id || !state[keyPath: stateKeyPath].isPresented { state[keyPath: stateKeyPath].isPresented = true onFirstAppearEffect = .run { send in … } }
— 33:31
We can follow this same pattern for navigation stacks with just a few small changes.
— 33:35
First, we can’t just store a simple isPresented boolean in StackState like we did for PresentationState since there can be many features presented in the stack. So instead we will hold onto a set of feature IDs that have been presented so far: struct StackState<Element> { fileprivate var elements: IdentifiedArrayOf<Component> = [] fileprivate var idsPresented: Set<UUID> = [] … }
— 33:59
Then when an element is removed from stack state we will want to make sure to clean up the presented IDs. Luckily there is only one single method exposed that can remove elements from a stack, and it’s pop(from:) : mutating func pop(from id: UUID) { guard let index = self.elements.ids.firstIndex(of: id) else { return } for id in self.elements.ids[index...] { self.idsPresented.remove(id) } self.elements.removeSubrange(index...) }
— 34:28
Next we can alter the forEach operator to make use of this new idsPresented data.
— 34:37
In particular, just before computing the onFirstAppearEffects we can grab the presented IDs: let idsPresented = state[keyPath: toElementsState] .idsPresented let onFirstAppearEffects: Effect<Action> = .merge( … )
— 34:45
And subtracting those IDs from the IDs after running the reducers are now the exact features we want to spin up the long living effects for: idsAfter.subtracting(idsPresented).map { id in
— 35:03
But further we must also keep track of which feature IDs have been presented: state[keyPath: toElementsState].idsPresented.insert(id)
— 35:22
That is all it takes. Now when we launch the application we are deep linked into the counter feature, and we can start the timer, wait a few ticks, and the feature pops itself off the stack. So we have fixed this bug, and it went basically the same as last time.
— 35:43
It’s worth taking a moment to reflect on how powerful the ifLet and forEach operators are, and also how similar they are. They are capable of integrating many child features into a parent feature, they manage the effects of all the child features, including automatic cancellation when the child goes away, they allow child features to automatically dismiss themselves, and on top of that they allow the parent domain to immediately react to anything happening in the child feature.
— 36:09
And at a high level, both ifLet and forEach accomplish this in basically the same way. They inspect the incoming action to figure out how to run the child and parent domains, and they inspect the state before and after running the reducers in order to figure out to manage effects. It’s absolutely incredible to see, and these two tools are the fundamental ways in which we handle tree-based and stack-based navigation in Composable Architecture applications.
— 36:39
It’s absolutely incredible to see, and these two tools are the fundamental ways in which we handle tree-based and stack-based navigation in Composable Architecture applications. Next time: testing stack navigation
— 36:47
Now what would be really cool is if we could write a test that proves this works as we expect. If you remember, last time we uncovered this bug we first wrote a test to demonstrate the problem before even trying to fix it. Then we fixed the bug, saw it fixed the test, and saw that it fixed the behavior in the simulator too. That was really cool to see because it shows just how much of the behavior of our tools is unit testable, and doesn’t even need to be run in the simulator most of the time. Stephen
— 37:14
However, we haven’t even discussed testing when it comes to navigation stacks. But there’s a good reason. The navigation stack tools are a lot more complicated than the presentation tools, and so it was good to just focus on the tools in isolation to start. And on top of that, testing features in a navigation stack is quite a bit more complicated that testing features presented with optional or enum state, and so that’s yet another reason to delay the discussion a bit.
— 37:38
But we are now ready to face it head on. We are going to start by showing what it’s like to test our little toy application as it exists right now, and then see how we can make testing navigation stacks more ergonomic and more powerful…next time! References Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0236-composable-navigation-pt15 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .