Skip to content

Memory Leak in StackState When Pushing Programmatically vs. Using NavigationLink #3766

@doxuto

Description

@doxuto

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

No one assigned

    Labels

    bugSomething isn't working due to a bug in the library.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions