EP 225 · Composable Navigation · Mar 6, 2023 ·Members

Video #225: Composable Navigation: Effect Cancellation

smart_display

Loading stream…

Video #225: Composable Navigation: Effect Cancellation

Episode: Video #225 Date: Mar 6, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep225-composable-navigation-behavior

Episode thumbnail

Description

We add superpowers to the navigation tools of the Composable Architecture, including automatically cancelling a child feature’s effects upon dismissal, and even letting child features dismiss themselves! Plus, we look at how “non-exhaustive” testing simplifies navigation-based tests.

Video

Cloudflare Stream video ID: aa03753361d7159809f91bc79660f131 Local file: video_225_composable-navigation-behavior.mp4 *(download with --video 225)*

References

Transcript

0:05

So things are looking pretty great already, but now we can really start to flex our muscles. Because we have this sheet reducer operator that handles all the details of how to integrate the parent and child domains, we get to layer on super powers with very little work. Stephen

0:21

Take effect cancellation as an example. It is very common to bring up a sheet in an application, and for that sheet to fire off effects. Those effects could be long-living, like timers, socket connections, etc., or the effect may just take a long time to finish, such as a slow network request.

0:38

We would love if those effects would just be automatically torn down and canceled when the sheet is dismissed. After all, if those effects produce any actions to be fed back into the system after the sheet is dismissed, then those actions will just go into the void. The child feature can’t react to those actions because there is no state to reduce on.

0:54

Now currently, with the tools that the Composable Architecture ships today, you do get a little bit of help in this area. If your effect is started from the .task view modifier, which executes when the view appears, then that effect will be torn down when the view disappears.

1:09

However, it does not help with all of the effects that can happen at other times, such as when you tap a button to start a timer. That effect is not tied to the lifecycle of the view.

1:18

But this sheet operator we have just developed does have the capability to coordinate all of this, and it’s super cool. Effect cancellation

1:29

To see why effects can be so problematic in sheets, let’s add a quick silly feature to our “item form” that uses effects. We are going to make it so that when you tap a button in the form a timer starts, and for each tick of the timer the quantity of the item goes up by one.

1:43

First we’ll add some state to the feature that represents whether or not the timer is currently running: struct ItemFormFeature: Reducer { struct State: Equatable, Identifiable { var isTimerOn = false … } … }

1:53

Technically we will need an all new action to send from the view in order to toggle this state on and off, but we can actually just make use of the infrastructure we have in place for binding actions. We will mark this state as @BindingState : struct State: Equatable, Identifiable { @BindingState var isTimerOn = false … }

2:09

And with just that we can already get a toggle component in the view that mutates this piece of state: Toggle("Timer", isOn: viewStore.binding(\.$isTimerOn))

2:28

Now we just have to react to the isTimerOn state changing so that we can start and stop the timer. The way this is done with BindingReducer and BindingAction is to pattern match on the key path of the state that you want to monitor changes to, in this case isTimerOn : switch action { case .binding(\.$isTimerOn): if state.isTimerOn { } else { } This case is executed whenever the $isTimerOn binding is written to in the view.

3:17

So, inside the if branch of this condition we will return an effect that starts up a timer. The easiest way to do this is to reach for a clock, such as the ContinuousClock , and call the timer method: if state.isTimerOn { return .run { send in for await _ in ContinuousClock().timer(interval: .seconds(1)) { } } }

3:53

It’s worth noting that this timer method is not something that comes with the Standard Library, but rather is a helper defined in our swift-clocks library, which comes transitively with the Composable Architecture.

4:04

The inside of the for await is called with each tick of the timer, and so we should send an action so that we can perform some logic. Let’s add a new action: enum Action: BindableAction, Equatable { … case timerTick } And send it from the effect: for await _ in ContinuousClock().timer(interval: .seconds(1)) { await send(.timerTick) }

4:17

Now, before continuing on, long-time viewers of Point-Free will know that this code is not ideal.

4:23

By reaching out to a live, uncontrolled ContinuousClock we will make this code hard to test and it will start to wreak havoc on our code base. It’s far better to use a dependency on the clock: struct ItemFormFeature: Reducer { @Dependency(\.continuousClock) var clock … }

4:43

And then use that in the effect: for await _ in self.clock.timer(interval: .seconds(1)) { await send(.timerTick) }

4:49

This should work exactly the same, but now we have a chance at controlling it in tests and other situations.

4:55

Next let’s figure out what to do about the else of this if conditional if state.isTimerOn { … } else { <#???#> }

4:58

If the else is executed it means that the timer was turned off, and so we should stop the timer somehow. To accomplish this we can leverage the effect cancellation tools that come with the library.

0:00

In particular, we can tag the timer effect with an identifier: return .run { send in … } .cancellable(id: <#AnyHashable#>)

5:14

That identifier can be a little private type we define directly in the reducer: private enum CancelID { case timer } … return .run { send in … } .cancellable(id: CancelID.timer)

5:31

And then whenever we want to cancel the effect we can use the Effect.cancel helper: if state.isTimerOn { … } else { return .cancel(id: CancelID.timer) }

5:50

And finally we can implement the timerTick action so that it increments the quantity when the item is in stock: case .timerTick: guard case let .inStock(quantity) = state.item.status else { return .none } state.item.status = .inStock(quantity: quantity + 1) return .none

6:23

And with just those few lines of code we have a working feature.

6:26

We can tap “Add”, start the timer, wait a bit to see the quantity count up, and then tap “Stop” to stop the timer. If we then tap “Add” we will see that a new item was added with the increased quantity.

6:37

So, this all seems great, but there is a gnarly bug lurking in the shadows. Let’s do that flow again, except this time let’s not stop the timer before confirming adding it.

6:55

Everything seems to work OK, but secretly there are actions being sent in the background. We are even getting purple runtime Xcode warnings to let us know something is wrong: A sheet action was sent while child state was nil. And we can even open up the logs to this because previously we instrumented the reducer at the root of the application using the _printChanges operator. We see that timerTick actions are still occurring, even though we closed the “item form” feature: received action: AppFeature.Action.inventory( .addItem( .presented(.timerTick) ) ) (No state changes)

7:08

The timer effect is still going because nothing cancelled it. Even weirder, if we re-open the sheet we will see that the item quantity starts counting up.

7:12

Fixing this problem is really annoying. We can’t tap into onDisappear in the ItemFormView to cancel the effect: .onDisappear { viewStore.send(.onDisappear) }

7:24

…because by that time the state for the feature is already nil and so the child reducer can’t possibly run to cancel its own effects. And we could tap into the dismiss action in the parent to cancel: case .addItem(.dismiss): return .cancel(id: <#AnyHashable#>)

7:53

…but then we need access to the CancelID type, which we chose to be private because it seems like an implementation detail, but now this is requiring us to make it public. And even if that wasn’t weird, it’s still weird that the parent would need to know to cancel the effects in the child.

8:17

This weirdness also manifests itself in tests. Since effects are not automatically cancelled it can be hard to get a passing test since the Composable Architecture prefers for you to exhaustively assert on how your feature evolves over time.

8:30

For example, let’s copy-and-paste the testAddItem case we wrote earlier, and update it to start the timer while the sheet is up: func testAddItem_Timer() async { let store = TestStore( initialState: InventoryFeature.State(), reducer: InventoryFeature() ) { $0.uuid = .incrementing } await store.send(.addButtonTapped) { $0.addItem = ItemFormFeature.State( item: Item( id: UUID( uuidString: "00000000-0000-0000-0000-000000000000" )!, name: "", status: .inStock(quantity: 1) ) ) } await store.send( .addItem(.presented(.set(\.$item.name, "Headphones"))) ) { $0.addItem?.item.name = "Headphones" } await store.send( .addItem(.presented(.set(\.$isTimerOn, true))) ) { $0.addItem?.isTimerOn = true } await store.send(.confirmAddItemButtonTapped) { $0.addItem = nil $0.items = [ Item( id: UUID( uuidString: "00000000-0000-0000-0000-000000000000" )!, name: "Headphones", status: .inStock(quantity: 1) ) ] } }

9:09

Unfortunately this test fails. Well, first there is a failure because we are using a dependency in the feature that hasn’t been overridden: testAddItem_Timer(): Unimplemented: ContinuousClock.now

9:18

That’s easy enough to fix, we just need to substitute in a test clock: func testAddItem_Timer() async { let clock = TestClock() let store = TestStore( initialState: InventoryFeature.State(), reducer: InventoryFeature() ) { $0.continuousClock = clock $0.uuid = .incrementing } … }

9:35

OK now we have just one single test failure, and it’s letting us know that when we tapped the toggle timer button an effect was started, and it is still running by the end of the test: An effect returned for this action is still running. It must complete before the end of the test. … To fix, inspect any effects the reducer returns for this action and ensure that all of them complete by the end of the test. There are a few reasons why an effect may not have completed: If using async/await in your effect, it may need a little bit of time to properly finish. To fix you can simply perform “await store.finish()” at the end of your test. If an effect uses a clock/scheduler (via “receive(on:)”, “delay”, “debounce”, etc.), make sure that you wait enough time for it to perform the effect. If you are using a test clock/scheduler, advance it so that the effects may complete, or consider using an immediate clock/scheduler to immediately perform the effect instead. If you are returning a long-living effect (timers, notifications, subjects, etc.), then make sure those effects are torn down by marking the effect “.cancellable” and returning a corresponding cancellation effect (“Effect.cancel”) from another action, or, if your effect is driven by a Combine subject, send it a completion.

9:43

This is a good test failure to have because it is proving what we saw when running the application in the simulator too. This shows that an effect is left running in the background, and that’s not good.

9:56

One way to fix the test is to send another action to stop the timer: await store.send( .addItem(.presented(.set(\.$isTimerOn, false))) ) { $0.addItem?.isTimerOn = false }

10:07

But that’s not good because maybe in the user flow that you are testing the user doesn’t actually do that step.

10:13

So, instead of that you can also capture the task that is returned from store.send : let toggleTask = await store.send( .addItem(.presented(.toggleTimerButtonTapped)) ) { $0.addItem?.isTimerOn = true } // await store.send( // .addItem(.presented(.toggleTimerButtonTapped)) // ) { // $0.addItem?.isTimerOn = false // }

10:23

…and then manually cancel it at the end of the test: await toggleTask.cancel()

10:32

That gets the test passing, but this still isn’t great because this doesn’t actually represent reality. We saw in the simulator the effect does not cancel, and so we are just doing it manually to shut up test failures.

10:46

So, we are just seeing over and over that child effects are problematic when it comes to navigation.

10:51

Ideally you would never have to think about cancellation like this. Instead, once the child feature was dismissed by making its state nil , all effects should be cancelled automatically without having to do anything special on your part. And amazingly this is possible, and it can all be done directly inside our sheet reducer operator.

11:10

Currently there is a place where we can see all effects that the child can emit, and that is when a child action comes into the system and the child state is non- nil : return .merge( childEffects.map { actionCasePath.embed(.presented($0)) }, effects )

11:18

It is possible for us to mark every single one of these effects as cancellable: return .merge( childEffects .map { actionCasePath.embed(.presented($0)) } .cancellable(id: <#AnyHashable#>) effects )

11:26

In that one single line of code we will be able to make every effect the child produces cancellable. That even includes the child’s child features, and child’s grandchild features, and on and on.

11:38

We just need a stable identifier to uniquely identify those effects.

11:42

Now, this is more difficult than it may seem at first. How can we uniquely identify the presentation of a child feature? Well, one thing that would help is if the child state was Identifiable ! After all, the SwiftUI sheet view modifier requires that the item you are presenting to be identifiable, so maybe it’s not that big of a leap for us to also require that: extension Reducer { func sheet<ChildState: Identifiable, ChildAction>( … ) … }

12:03

Now anywhere we have access to an honest piece of child state, we will have a unique identifier we can refer to.

12:09

In particular, when we get access to the child feature’s effects, lets mark them as cancellable via the child’s ID childEffects .map { actionCasePath.embed(.presented($0)) } .cancellable(id: childState.id),

12:14

Now technically that ID alone will not necessarily uniquely identify the presentation of the child feature. After all, if the ID is just a plain integer then there could be another completely different type out there that also uses a plain integer for its ID and there could be clashes.

12:30

To strengthen we could mix in the object identifier of the type of the child: .cancellable( id: [ childState.id as AnyHashable, ObjectIdentifier(ChildState.self) ] ),

12:46

That will be a lot stronger, but also still not as strong as it could be.

12:51

We aren’t going to worry about these details right now, so let’s just go back to using only the child ID: .cancellable(id: childState.id),

12:57

OK, we have now tagged every effect coming from the child with an identifier, so now the question is when and how do we cancel those effects?

13:05

There’s a few spots we need to do this. The first and most obvious place is when the explicit .dismiss action is sent while child state is non- nil : case (.some, .dismiss): state[keyPath: stateKeyPath] = nil return self.reduce(into: &state, action: action)

13:12

This is a great time to cancel the child’s effects: case let (.some(childState), .dismiss): let effects = self.reduce(into: &state, action: action) state[keyPath: stateKeyPath] = nil return .merge( effects, .cancel(id: childState.id) )

13:30

The next two cases are for when a sheet action is received while the child state is nil : case (.none, .some(.presented)), (.none, .some(.dismiss)): XCTFail( """ A sheet action was sent while child state was nil. """ ) return self.reduce(into: &state, action: action)

13:37

There’s nothing to do here with effect cancellation because we don’t even have the child state in order to figure out how to cancel effects.

13:46

The final case is when a non-sheet action comes through and we just run the parent’s reducer: case (_, .none): return self.reduce(into: &state, action: action)

13:50

When the parent runs its logic it may decide to nil out the child state, and in such a case we would want to cancel the child’s effects. So, we just want to inspect the child state before and after the parent reducer is run, and see if something changed that should cause the child’s effects to be cancelled.

13:57

One way to do this is just grab a copy of the child state before and after running the reducer and see if it flipped from non- nil to nil : if childStateBefore != nil && childStateAfter == nil { // cancel effect } else { // do nothing }

14:40

We can define an effect that is assigned in each branch of this conditional: let cancelEffect: Effect<Action> if let childStateBefore, childStateAfter == nil { cancelEffect = .cancel(id: childStateBefore.id) } else { cancelEffect = .none }

15:02

And then we can merge this effect with the parent’s effects: return .merge( cancelEffect, effects )

15:16

We can even strengthen this more. The time to cancel isn’t just when the child state goes from non- nil to nil , but also when the child ID changes. Earlier in the episode we showed what happens in SwiftUI when the identity of the presented view changes. SwiftUI does the correct thing by dismissing the sheet and then reshowing it.

15:32

Well, if that happens here we won’t cancel the old child’s effects because it’s not true that the child state went to nil . Its identity changed.

15:41

So, we can strengthen this by checking if the child state’s ID changes from before to after the parent reducer is run: let cancelEffect: Effect<Action> if let childStateBefore, childStateBefore.id != childStateAfter?.id { cancelEffect = .cancel(id: childStateBefore.id) } else { cancelEffect = .none }

15:56

And believe it or not, that is all there is to it.

15:59

We can start up the app in the simulator, open the item for sheet, start the timer, and doing anything that causes the sheet to dismiss will cancel the timer. Whether that is from cancelling, or confirming the addition, or even just swiping it away to dismiss.

16:39

And amazingly we can now write a test that passes without force cancelling the effect, which was not ideal since that’s not what actually happens when running the app: // let toggleTask = await store.send( .addItem(.presented(.toggleTimerButtonTapped)) ) { … }

17:03

The test now just naturally passes because when the user confirms adding the item, the child state is nil ’d out, and that immediately cancels the child effects. This test is testing how the application really does behave in real life. No tricks or hacks. It’s actually just incredible. Child dismissal

17:20

This is all looking pretty incredible. We now have powerful tools for integrating a child feature into a parent feature so that the child can be presented in a sheet. We just do a bit of domain modeling to add the child state and actions to the parent, we make use of a reducer operator to integrate their logic and behavior together, and we make use of a view modifier to interface with SwiftUI.

17:40

And once those few steps are done we are capable of working on each feature’s logic in complete isolation, but there are still integration points available in case they need to interact in nuanced ways. And child effects are automatically torn down when the child feature is dismissed. Oh and on top of that, all of this is completely unit testable. We can write deep tests on how the parent and child features interact with each other, and the Composable Architecture will keep us in check each step of the way to make sure we are definitely asserting on how the full system evolves over time. Brandon

18:12

But would you believe that we have barely even scratched the surface of what the tools are capable of? Having a reducer operator, a view modifier and effect cancellation was the bare minimum of what we wanted to accomplish with the navigation tools.

18:26

Let’s discuss something amazing we can do with this set up that we didn’t think was even possible when we first started developing these tools. And that’s giving the child feature a quick and easy way to dismiss itself.

18:38

Let’s take a look at what that even means.

18:39

SwiftUI has this really great feature called dismiss , and it’s an environment variable. It allows a presented view to dismiss itself without needing to interact with the parent at all. It works for sheets, popovers, fullscreen covers, and even navigation stacks.

18:58

To use it you add the environment variable to your presented view. So, let’s add it to the ItemFormView : public struct ItemFormView: View { @Environment(\.dismiss) var dismiss … }

19:12

And then in the view we can add a button that when tapped invokes the dismiss action we just added: Section { Button("Dismiss") { self.dismiss() } }

19:30

If we run this in the simulator we will see that we can present the item form, and then tap the new “Dismiss” button to make it automatically animate away. The way this works is that when the dismiss() action is invoked, SwiftUI searches through the view hierarchy to find the binding that is responsible for presenting that view, and then writes nil or false to that binding, causing the view to go away.

20:02

So, in this app, when dismiss() is invoked the binding that is showing the sheet has nil written, which we reroute to send the dimiss action to the view store, which then clears the state, and finally makes the sheet animate away.

20:19

This is a really cool and powerful tool, but also it’s completely relegated to the view layer, which means that it’s mostly untestable and we can’t invoke the action based on subtle and nuanced logic in our feature.

20:50

What if there was a tool like dismiss , but for reducers instead of views? Maybe it could even be modeled with the Composable Architecture’s dependency system: struct ItemFormFeature: Reducer { … @Dependency(\.dismiss) var dismiss … }

21:12

And then anytime you want to dismiss the child you just invoke that action.

21:15

For example, what if we wanted to dismiss this feature after the 3rd timer tick. Maybe we could do something like this: case .timerTick: … if quantity == 3 { self.dismiss() } return .none

21:37

That would be really cool, but also what is written here is not right, and it’s a very subtle thing of why it is not right.

21:54

When we first started developing the Composable Architecture in episodes over 3 years ago, we made it a point to describe reducers as “pure” functions. Now, Swift doesn’t actually have a notion of “pure” function. Nothing is ever going to stop you from making a network request directly in a reducer action: case .buttonTapped: URLSession.shared .dataTask(URL(string: "http://pointfree.co")!) { data, _, _ in } .resume() …

22:19

This compiles just fine even though it is definitely not a pure function, and that’s because to Swift this is perfectly fine code. Swift is not a purely functional language.

22:44

So, instead, it’s on us to be strict with ourselves to not do silly things like this. The only thing you should do directly in a reducer is make mutations to state. If you need to perform effectful work, such as making network requests, then you should bundle that work up in an Effect and return it from the reducer.

23:02

This principle also holds in SwiftUI. It is not recommended to perform side effects directly in the body of the view, but rather only in the action closures exposed to us from various UI components.

23:15

For example, code like this: var body: some View { VStack { let _ = self.dismiss() Text("Hi") } } …is completely non-sensical in SwiftUI. The dismiss action should only be invoked from an escaping, action closure.

23:38

And this is why it isn’t correct to call dismiss directly in the reducer. The act of calling dismiss() must perform some kind of side effect because it has to communicate with something outside our immediate domain to nil out our state. Whatever dismiss needs to do under the hood it definitely is not just a simple state mutation.,

23:58

So, rather than interacting with dismiss like that, it should probably look more like this: return quantity == 3 ? .fireAndForget { self.dismiss() } : .none

24:18

That is, dismiss should only be invoked from with an effect, and the fireAndForget is a nice function to use since we don’t actually need a return value from dismiss() . We just need it to do its thing.

24:25

But even better, what if we made dismiss() async so that you were not even allowed to call it from within the reducer even if you wanted to: return quantity == 3 ? .fireAndForget { await self.dismiss() } : .none That would force you to wrap it up in an effect.

24:45

You can even invoke dismiss() from some other effect so that you could have very complex and nuanced logic around its invocation. For example, maybe we call it directly from within our timer effect: 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() } } }

25:11

That would be really cool, and hopefully it’s all still testable.

25:23

OK, so what does it take to make this theoretical syntax a reality? Well, surprisingly, not much!

25:30

Let’s start by designing this little dismiss dependency. It’s just going to be a wrapper around an async Void -to- Void closure, and we want it to be Sendable since, in general, only sendable dependencies can be registered with the library: struct DismissEffect: Sendable { private var dismiss: @Sendable () async -> Void }

26:03

Then we will expose invoking this closure through the callAsFunction interface, which is what allows us to do something like dismiss() : func callAsFunction() async { await self.dismiss() }

26:30

Even better we should probably perform a runtime warning here if anyone ever tries dismiss when not in a presentation context, but we won’t do that right now.

26:46

And because the closure held on the inside is private we need to make an initializer: extension DismissEffect { init(_ dismiss: @escaping @Sendable () async -> Void) { self.dismiss = dismiss } }

27:04

Next we will conform this type to the DependencyKey protocol: extension DismissEffect: DependencyKey { static let liveValue = DismissEffect(dismiss: {}) static var testValue = DismissEffect(dismiss: {}) }

27:34

And finally we will register the dependency inside the global blob of dependencies: extension DependencyValues { var dismiss: DismissEffect { get { self[DismissEffect.self] } set { self[DismissEffect.self] = newValue } } }

28:00

So, that actually makes every compile, but of course it’s not actually doing anything right now. By default the closure held inside DismissEffect is nil , which means in our reducer when we invoke dismiss() it’s just a no-op.

28:18

We now need some way of making the act of invoking dismiss() communicate all the way back to the parent so that the parent can perform the dismissing logic. But how can it possibly do that?

28:33

Well, by overriding dependencies!

28:35

Right now we run the child reducer before running the parent reducer: let childEffects = child .reduce(into: &childState, action: childAction) … let effects = self.reduce(into: &state, action: action)

28:45

We have the opportunity to override dependencies on the child before it’s run, and doing so alters its execution context. In particular, we can override its dismiss dependency by providing a fresh DismissEffect value: let childEffects = child .dependency(\.dismiss, DismissEffect { }) .reduce(into: &childState, action: childAction)

29:11

It may not seem like much, but this little closure is a wormhole that facilitates communication between parent and child domains. Through the dependency system, we are able to pass a closure to the child, and when the child invokes it this code right here will execute. That’s kind of amazing.

29:33

But what can we do in this closure?

29:35

We’d love if we could send the dismiss action into the system: .dependency(\.dismiss, DismissEffect { await send(.dismiss) })

29:43

This would automatically cause the child state to be nil ’d out and the child’s effects would be cancelled.

29:49

Unfortunately we are not in an effect context at this point, and we do not have access to a send . In fact, we don’t access to much in this closure. We do have the child state ID that is presented, so I guess we could try canceling the effects associated with that ID: let childEffects = child .dependency(\.dismiss, DismissEffect { [id = childState.id] in Task.cancel(id: id) }) .reduce(into: &childState, action: childAction)

30:30

It’s definitely not the full story of what needs to be accomplished, but it’s at least something. Let’s see what happens when we run the app in the preview. We can start the timer and see the quantity starts counting up, but after the 3rd tick of the timer we see it stops.

31:06

However, the toggle didn’t switch off. That’s because the isTimerOn state is still set to true . We can flip that state off quite easily: if tickCount == 3 { await send(.binding(.set(\.$isTimerOn, false))) await self.dismiss() }

31:36

And now the toggle animates off right when the timer is cancelled.

31:45

So, amazingly, when the child invoked dismiss() it communicated back to the parent, and the parent decided to cancel effects. So there is some parent-child communication happening here.

32:00

But, how can we intercept this dismiss action in the parent and in an effect context where we have access to send ? Well, what if when the child feature was first presented we started up a cancellable effect that simply suspended forever, and when we detect it’s cancelled we can then send the dismiss action?

32:32

Sounds pretty wild. Let’s try it.

32:34

We have an easy way to detect when the child feature is first presented. It can only happen when a parent action is sent into the system, because only the parent can create the child state, and that happens in this case: case (_, .none):

32:49

In here we can check the child state’s ID before and after running the parent reducer: if let childStateAfter, childStateAfter.id != childStateBefore?.id { // Child state was just created }

33:28

If we get into this if conditional it means the child state was just created, and we need to fire up that forever-suspending effect. But, we have to be able to keep track of that effect so we need to forward declare an effect, and then assign in the branches of the if / else : let onFirstAppearEffect: Effect<Action> if let childStateAfter, childStateBefore?.id != childStateAfter.id { onFirstAppearEffect = .run { send in } } else { onFirstAppearEffect = .none }

34:00

Now inside this effect we can suspend forever. In fact, the dependencies library that the Composable Architecture uses even comes with such a tool: .run { send in try await Task.never() }

34:07

This will now just suspend forever until it’s cancelled, and cancellation is exactly what we want to listen for since that is what happens over in our DismissEffect : .dependency(\.dismiss, DismissEffect { [id = childState.id] in Task.cancel(id: id) })

34:28

So what we can do is mark just this little Task.never has being cancellable with the child state’s ID: try await withTaskCancellation(id: childStateAfter.id) { try await Task.never() }

34:55

And we can detect when it is cancelled via that ID by wrapping the whole thing in a do / catch to catch a CancellationError : do { try await withTaskCancellation(id: childAfter.id) { try await Task.never() } } catch is CancellationError { }

35:15

And finally this catch branch is exactly where we can send the dismiss action: } catch is CancellationError { await send(.dismiss) }

35:24

But that doesn’t work because dismiss is not in the parent domain. Instead, we have a case path which is capable of embedding presentation actions into its domain: } catch is CancellationError { await send(actionCasePath.embed(.dismiss)) }

35:36

We are seeing another use case for the embed part of a case path, which shows yet again that adding key path-like functionality to enums in Swift isn’t going to be nearly as powerful as it could be unless both parts are added to the language.

35:52

And now that the onFirstAppearEffect is properly constructed, we can merge it in with the other effects that need to run: return .merge( effects, cancelEffect, onFirstAppearEffect )

36:00

And it may be hard to believe, but that is all it takes to get the basics of the feature into place.

36:12

We can even give it a spin. If we tap the “Add” button to bring up the sheet, and then start the timer, we will see after a few seconds the sheet automatically dismisses. And it’s all happening from within the child feature. The ItemFormFeature gets to decide when it wants to be dismissed without having to directly tell the parent, and without the parent having to snoop on what is going on in the child. It’s pretty incredible.

36:46

But even better, all of this is still 100% testable. It may seem like we are doing some really wild things to facilitate communication between child and parent, but everything is still within the realm of the closed system that the Store provides us, so we can actually get test coverage on this behavior.

37:13

For example, we should be able to test that when we open the “item form” sheet, start the timer, and just wait around, that eventually the sheet is dismissed.

37:22

We can copy and paste most of the previous test: func testAddItem_Timer_Dismissal() async { let clock = TestClock() let store = TestStore( initialState: InventoryFeature.State(), reducer: InventoryFeature() ) { $0.continuousClock = clock $0.uuid = .incrementing } }

37:40

Then we can simulate tapping the “Add” button to make the sheet come up: await store.send(.addButtonTapped) { $0.addItem = ItemFormFeature.State( item: Item( id: UUID( uuidString: "00000000-0000-0000-0000-000000000000" )!, name: "", status: .inStock(quantity: 1) ) ) }

37:43

And next we can simulate tapping the toggle button to start the timer: await store.send( .addItem(.presented(.toggleTimerButtonTapped)) ) { $0.addItem?.isTimerOn = true } And here’s where things get interesting.

37:55

We expect the timer to have three ticks, so we can advance our test clock three seconds: await clock.advance(by: .seconds(3))

38:00

And then we expect to receive the first timer tick to make sure that the item’s quantity goes up by one: await store.receive(.addItem(.presented(.timerTick))) { $0.addItem?.item.status = .inStock(quantity: 2) }

38:31

And we expect to get another timer tick: await store.receive(.addItem(.presented(.timerTick))) { $0.addItem?.item.status = .inStock(quantity: 3) } And we expect to get one last timer tick: await store.receive(.addItem(.presented(.timerTick))) { $0.addItem?.item.status = .inStock(quantity: 4) }

38:36

But after that last tick we expect the child feature to dismiss itself, which means the dismiss action should be sent, causing the child state to be nil ’d out: await store.receive(.addItem(.dismiss)) { $0.addItem = nil }

39:20

Interestingly this test does not pass because we haven’t actually accounted for all of the behavior in the feature. Remember we added the behavior that just before the feature is officially dismissed we also reset the isTimerOn state back to false .

40:00

We only did that because it looked weird to stop the timer but not update the state, and now that we have the real dismiss functionality I don’t think it’s really needed, so let’s remove it.

40:12

And now amazingly this test passes. And because it passes we are proving definitively that we know how our entire feature behaves in the real world. If we left out any part of these assertions we would immediately get a test failure.

0:00

For example, suppose we didn’t think we were going to get that last timer tick: // await store.receive(.addItem(.presented(.timerTick))) { // $0.addItem?.item.status = .inStock(quantity: 4) // }

40:32

Well, then we get a test failure telling us that we said we were going to receive a dismiss action, but in reality there was actually a timerTick received: testAddItem_TimerDismissal(): Received unexpected action: … − InventoryFeature.Action.addItem(.dismiss) + InventoryFeature.Action.addItem( + .presented(.timerTick) + ) (Expected: −, Received: +)

40:40

Or, if we forgot to receive that last dismiss action that happens thanks to the child feature dismissing itself: // await store.receive(.addItem(.dismiss)) { // $0.addItem = nil // }

40:46

This now fails letting us know that there were actions received that we didn’t assert on: testAddItem_TimerDismissal(): The store received 1 unexpected action after this one: … Unhandled actions: [ [0]: .addItem(.dismiss) ]

40:51

The library is forcing us to truly prove we know everything that is happening inside our features. But let’s revert that change before we forget.

41:36

Let’s also go ahead and run the full test suite to make sure all our other tests still pass.

41:45

Huh, looks like we have a few errors. For example in testAddItem it looks like we are receiving an additional action that we didn’t account for when the .confirmAddItemButtonTapped action is sent: testAddItem(): The store received 1 unexpected action after this one: … Unhandled actions: [ [0]: .addItem(.dismiss) ]

42:13

And indeed we actually have a bug in our navigation code that we just introduced with this new dismiss effect. We are sending the dismiss action when it is not necessary, such as when we nil out the child state manually. This is happening because the sheet operator detects the state going nil , and so cancels all the child effects, but then that cancellation is observed by the onFirstAppearEffect we wrote a moment ago.

42:31

That’s not right. We only want that effect to pick up the cancellation that happens in one very specific spot: when the DismissEffect is invoked.

42:40

So, it just was not right for us to use the same cancellation ID for the dismiss effect and the child effects. We can fix this by introducing a new type that acts as a wrapper around the cancel ID in order to differentiate itself from other cancel IDs. We can even make it private: private struct DismissID: Hashable { let id: AnyHashable }

43:04

Then that is the ID we will use in the dismiss effect: .dependency(\.dismiss, DismissEffect { [id = childState.id] in Task.cancel(id: DismissID(id: id)) })

43:11

And this will be the ID we use in the onFirstAppearEffect : try await withTaskCancellation( id: DismissID(id: childAfter.id) ) { try await Task.never() }

43:17

Now when we run the test again, that particular assertion passes, but the whole test still fails. It looks like we still have an effect in flight: testAddItem(): An effect returned for this action is still running. It must complete before the end of the test. …

43:31

The library forces all effects to finish in order for the test to pass. This is because an effect being left inflight may be indicative of a bug, and indeed there is yet another bug in our sheet operator. The onFirstAppearEffect we create lives forever unless cancelled, and the only way to cancel it is via the DismissID , which only happens if the DismissEffect closure is invoked.

44:04

However, in the testAddItem test, that closure is never invoked. The sheet is being closed by just tapping a button. So, we need to mark that effect has been cancellable, and we need to use the same ID that all the child effects are tagged with: onFirstAppearEffect = .run { send in … } .cancellable(id: childStateAfter.id)

44:16

Now that one test passes, but also the entire test suite passes.

44:31

We think it’s absolutely incredible that the Composable Architecture’s testing tools were able to detect these very subtle bugs in our reducer operator. There is a reason we chose exhaustive testing tools for the library, and this is it right here. When you want to fully test how the entire system evolves over time, and be sure you are capturing absolutely everything on the inside, then exhaustive testing is a blessing. Non-exhaustive testing Stephen

44:55

This is absolutely incredible and super powerful, but it also gives us an opportunity to explore something we’ve never had a chance to discuss in episodes in the past, and that’s “non-exhaustive” testing.

45:07

By default the Composable Architecture wants you to prove exhaustively how a feature behaves during tests. This means you have to assert on every single state change, as well as every single action fed into the system from an effect, and all effects must complete by the end of the test. This helps make sure you truly know what is happening in your feature, and can be great for tracking down bugs.

45:31

However, sometimes exhaustive testing can be a little onerous, especially when testing the integration of many features, which is going to happen a lot with our navigation tools. You may want to write a test on some high level logic executed at the root of the application without worrying about all the nitty-gritty details happening in each child feature.

45:49

This idea is called “non-exhaustive testing”, and it was an idea first presented by Krzysztof Zabłocki in a blog post and later a conference talk . We then worked with him and his employer, The Browser Company , in order to bring this tool to the library.

46:11

One of the tests we just wrote, where we exercise the flow of bring up a sheet, starting the timer, and then observing that the sheet dismisses after 3 ticks, is an example where this tool can be pretty handy. Maybe we just want to make sure that when the timer is started that eventually the child dismisses itself, but it doesn’t actually care about all the things that happen in the child to get to that point.

46:41

Let copy-and-paste the test and rename it: func testAddItem_TimerDismissal_NonExhaustive() async { … }

46:51

And then let’s turn off exhaustivity on the test store: store.exhaustivity = .off

46:57

With that change the test still passes, but we can actually start asserting on fewer things and still get a passing test.

47:13

For example, what if we didn’t want to assert on how state changed with each timer tick: await store.receive(.addItem(.presented(.timerTick))) await store.receive(.addItem(.presented(.timerTick))) await store.receive(.addItem(.presented(.timerTick)))

47:25

This test still passes. With test exhaustivity turned off it’s fine to not assert on how state changes.

47:31

You can even omit asserting on the received actions entirely: // await store.receive(.addItem(.presented(.timerTick))) // await store.receive(.addItem(.presented(.timerTick))) // await store.receive(.addItem(.presented(.timerTick)))

47:38

This test still passes, and at a very high level just confirms that when we show the sheet and start a timer, that eventually it dismisses itself. And if we break that logic we will still get a test failure.

47:50

For example, suppose we commented out the dismiss() logic in the item form feature: if tickCount == 3 { // await self.dismiss() }

47:59

With this change tests now fail: testAddItem_TimerDismissal(): Expected to receive the following action, but didn’t: … InventoryFeature.Action.addItem(.dismiss)

48:09

And we would of course want this failure. We have explicitly said that we expect to receive an action, yet one was not received, and so clearly there is a bug somewhere.

48:17

Let’s revert that change.

48:24

You will also get a test failure if you mutate some state in the wrong way. For example, suppose we thought isTimerOn would flip to false when the toggle button is tapped: await store.send( .addItem(.presented(.set(\.$isTimerOn, true))) ) { $0.addItem?.isTimerOn = false }

48:35

This causes a test failure because we mutated the state into the wrong value: testAddItem_TimerDismissal(): A state change does not match expectation: … InventoryFeature.State( addItem: ItemFormFeature.State( _item: Item( id: UUID(00000000-0000-0000-0000-000000000000), name: "Headphones", color: nil, status: .inStock(quantity: 1) ), − isTimerOn: false + isTimerOn: true ), alert: nil, confirmationDialog: nil, items: [] ) (Expected: −, Actual: +)

48:49

We can make this test even less exhaustive by swapping the test clock out for another clock that ships with the library, ImmediateClock , which means we don’t even have to worry about advancing the clock. // let clock = TestClock() … // $0.continuousClock = clock $0.continuousClock = ImmediateClock() … // await clock.advance(by: .seconds(3))

49:34

So this is a really powerful testing tool, especially when testing how lots of features integrate together. It can really clear away the fog and allow you to concentrate on what exactly you want to test.

49:44

There’s also a middle ground between fully exhaustive and non-exhaustive. We can tell the store to be non-exhaustive when making assertions, but also notify you of the expectations that it skipped while running the test. You can do this like so: store.exhaustivity = .off(showSkippedAssertions: true)

49:56

And now we get notified of all the assertions that we did not make during the test: Skipped assertions: … 3 received actions were skipped: [ [0]: .addItem( .presented(.timerTick) ), [1]: .addItem( .presented(.timerTick) ), [2]: .addItem( .presented(.timerTick) ) ]

50:07

In particular, we skipped 3 timer tick actions. This can also be really powerful for tracking down bugs that are not being caught in the non-exhaustive test.

50:23

For example, suppose you are seeing a bug when running in the application in the simulator, yet your full test suite is passing. You could even have test coverage on the exact code that is producing bugs, but because of the non-exhaustive test store you are not actually seeing the failure. Well, this middle ground gives you instant access into what is happening inside the feature, even though it isn’t causing a test failure, and that is valuable information to have to track down the bug. Next time: unification

50:45

So this is all looking really incredible. We have substantially improved the sheet navigation tool we built previously by making sure that child effects are automatically torn down when the child feature goes away, and we provided a new tool that allows child features to dismiss themselves in a really lightweight way. It can be entirely encapsulated in the child feature. The parent doesn’t need to know about it at all.

51:07

And on top of that we dipped our toes into non-exhaustive testing. This tool is becoming more and more important because we keep making it easier to compose features together, and so there are going to be more times we want to write high level tests on how features interact with each other without needing to assert on literally everything happening in each feature. Brandon

51:26

There are even more powerful features we could continue adding to these presentation APIs, and we will soon, but let’s also take a moment to remember how we got here. A few episodes back we first dipped our toes into the waters of new navigation APIs by creating some tools for alerts. And since then we have basically copied and pasted code a bunch of times, first for confirmation dialogs and then again for sheets.

51:49

Let’s finally start unifying these APIs because soon we will want to generalize them even further for popovers, fullscreen covers, and navigation links, and I don’t think we want to copy-and-paste code 3 more times. References Exhaustive testing in TCA Krzysztof Zabłocki • Mar 21, 2022 The first exploration of “non-exhaustive” testing in the Composable Architecture. This work would eventually be included in the library itself. https://www.merowing.info/exhaustive-testing-in-tca/ Composable Architecture @ Scale Krzysztof Zabłocki • Sep 20, 2022 An NSSpain talk in which Krzysztof covers the topic of scaling an application built in the Composable Architecture, including the use of non-exhaustive testing. https://www.merowing.info/composable-architecture-scale/ 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 0225-composable-navigation-pt4 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 .