-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Open
Labels
bugSomething isn't working due to a bug in the library.Something isn't working due to a bug in the library.
Description
Description
Summary
I’ve encountered a potential memory leak when using StackState with NavigationStack. A child feature’s state is not deinitialized after dismissal, regardless of whether it is pushed onto the stack programmatically (via a button sending an action) or via a standard NavigationLink(state: ...).
In both cases the state is removed from the path array, but the child feature’s state (and its store) remains in memory and deinit is never called. This suggests an issue with how the store’s lifecycle is managed after a dismissal initiated by the child using @dependency(.dismiss).
Steps to Reproduce
Below is a minimal, reproducible example. It consists of a Root
feature, a child ScreenA
feature, and a DeinitTracker
class to observe init
and deinit
calls.
import ComposableArchitecture
import SwiftUI
// MARK: - Root Feature
@Reducer
struct Root {
@Reducer
struct Path {
enum Action {
case screenA(ScreenA.Action)
}
enum State: Equatable {
case screenA(ScreenA.State)
}
var body: some ReducerOf<Self> {
Scope(state: \.screenA, action: \.screenA) {
ScreenA()
}
}
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action {
case goToScreenAButtonTapped
case path(StackAction<Path.State, Path.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .goToScreenAButtonTapped:
state.path.append(.screenA(.init()))
return .none
case .path:
return .none
}
}
.forEach(\.path, action: \.path)
._printChanges()
}
}
struct RootView: View {
@Bindable var store: StoreOf<Root>
var body: some View {
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
Form {
// Method 1: Causes a memory leak (deinit is NOT called)
// NavigationLink("Go to Screen A", state: Root.Path.State.screenA(.init()))
// Method 2: Causes a memory leak (deinit is NOT called)
Section {
Button("Go to Screen A") {
store.send(.goToScreenAButtonTapped)
}
}
}
.navigationTitle("Root")
} destination: { store in
switch store.state {
case .screenA:
if let store = store.scope(state: \.screenA, action: \.screenA) {
ScreenAView(store: store)
}
}
}
.navigationTitle("Navigation Stack")
}
}
// MARK: - ScreenA Feature
@Reducer
struct ScreenA {
@ObservableState
struct State: Equatable {
var tracker = DeinitTracker(screenName: "ScreenA")
}
enum Action {
case closeButtonTapped
}
@Dependency(\.dismiss) var dismiss
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .closeButtonTapped:
return .run { _ in
await self.dismiss()
}
}
}
}
}
struct ScreenAView: View {
@Bindable var store: StoreOf<ScreenA>
var body: some View {
Form {
Section {
Button("Dismiss") {
store.send(.closeButtonTapped)
}
}
}
.navigationTitle("Screen A")
}
}
// MARK: - Deinit Tracker
public final class DeinitTracker: Sendable, Equatable {
public let screenName: String
public init(screenName: String) {
self.screenName = screenName
print("✅ \(screenName): INIT")
}
deinit {
print("❌ \(screenName): DEINIT - SUCCESSFULLY DEALLOCATED!")
}
public static func == (rhs: DeinitTracker, lhs: DeinitTracker) -> Bool {
rhs.screenName == lhs.screenName
}
}
### 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.
- [x] This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swift-composable-architecture/issues) or [discussion](https://github.com/pointfreeco/swift-composable-architecture/discussions).
### Expected behavior
After tapping "Dismiss" on ScreenAView and the view is popped, the DeinitTracker's deinit method should be called, printing ❌ ScreenA: DEINIT - SUCCESSFULLY DEALLOCATED! to the console.
### Actual behavior
The view is correctly popped, and the TCA logs show the ScreenA.State being removed from the path array. However, the deinit method of DeinitTracker is never called, indicating that ScreenA.State (and its associated Store) is being retained in memory.
Here are the logs from ._printChanges():
✅ ScreenA: INIT
received action:
Root.Action.goToScreenAButtonTapped
Root.State(
_path: [
+ #0: .screenA(
+ ScreenA.State(
+ _tracker: DeinitTracker(screenName: "ScreenA")
+ )
+ )
]
)
received action:
Root.Action.path(
.element(
id: #0,
action: .screenA(.closeButtonTapped)
)
)
(No state changes)
received action:
Root.Action.path(
.popFrom(id: #0)
)
Root.State(
_path: [
- #0: .screenA(
- ScreenA.State(
- _tracker: DeinitTracker(screenName: "ScreenA")
- )
- )
]
)
### Reproducing project
_No response_
### The Composable Architecture version information
1.22.1
### Destination operating system
_No response_
### Xcode version information
_No response_
### Swift Compiler version information
```shell
Metadata
Metadata
Assignees
Labels
bugSomething isn't working due to a bug in the library.Something isn't working due to a bug in the library.