Description
Description
Hello!
I have just encountered an issue where the effects of a presented reducer/route were not cancelled when the associated UIViewController was dismissed, but only when Reducer.State
conforms to Identifiable
.
Preconditions:
- The UIViewController is presented via the
present(item:id:content:)
helper fromswift-navigation
. - The state of the associated reducer is part of a CaseReducer route stored in a
@Presents
-wrapped property and combined via theifLet
helper.
Apparently there are 3 ways how effects can get cancelled when a route is changed/ViewController is dismissed:
- The route is changed or reset programmatically - this is picked up by the
_PresentationReducer
which cancels the effect of the former route - The Reducer runs the
DismissEffect
- theDismissEffect
is provided by the_PresentationReducer
which again cancels the effects and afterwards sends thePresentationAction.dismiss
action - The ViewController is removed from the view hierarchy. This could happen e.g. when the user pulls down a presented ViewController, taps "Back" in a Navigation bar or when the VC is dismissed by calling
.dismiss(animated:)
- this is picked up by the UIViewController extensions inswift-navigation
by swizzlingviewDidDisappear
which then sets the value of theUIBinding
tonil
which then subscripts into theStore
which then would send aPresentationAction.dismiss
action which would trigger the_PresentationReducer
again to cancel the effects.
The problem is in the 3. path here…
if newValue == nil,
let childState = self.state[keyPath: state],
id == _identifiableID(childState),
!self._isInvalidated()
{
self.send(action(.dismiss))
It seems the way id
is derived here, that it doesn't match the formerly presented state and consequently doesn't send .dismiss
and thus the effects won't get cancelled.
Checklist
- I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
- If possible, I've reproduced the issue using the
main
branch of this package. - This issue hasn't been addressed in an existing GitHub issue or discussion.
Expected behavior
Manually dismissing a ViewController should cancel the effects of its associated Reducer/Store, even if the Reducer.State
conforms to Identifiable
.
Actual behavior
The effects are not cancelled.
Reproducing project
The attached project contains a "RootFeature" with two child features "Feature1" and "Feature2". Feature1.State
conforms to Identifiable
so you can reproduce the bug there and also see that "Feature2" - whose state does not conform to Identifiable
- works as expected.
Both features start a task which waits for its cancellation:
return .run { [id = state.id] _ in
do {
try await withTaskCancellation(id: CancelID.effect) {
print("Feature 1.\(id) started")
try await Task.never()
}
} catch {}
print("Feature 1 ('\(id)') cancelled")
}
So "Feature 1/2 … cancelled" should be printed to the Console when the respective ViewController was dismissed.
To actually reproduce the bug…
- Tap "Present Feature 1.a"
- Tap "Dismiss via self.dismiss(animated:)" in the presented VC or pull down the VC manually
The Composable Architecture version information
1.18.0
Destination operating system
= iOS 14
Xcode version information
16.2 (16C5032a)
Swift Compiler version information
Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)
Target: arm64-apple-macosx15.0