From 2f66166adb2f82855a5dcc6c7f99fd883cf5578a Mon Sep 17 00:00:00 2001 From: Oguz Yuksel Date: Mon, 17 Nov 2025 11:31:25 +0100 Subject: [PATCH 1/2] When PresentationState storage is copied (COW), clear effectNavigationIDPath. This signals that presentation was moved to a new parent context. Detect this as parentContextChanged and recreate effect. --- .../Reducers/PresentationReducer.swift | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift index b8b65a3a8864..26bada688281 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift @@ -57,6 +57,8 @@ public struct PresentationState { private var storage: Storage @usableFromInline var presentedID: NavigationIDPath? + // Tracks navigation path when effect was created. Cleared on COW to detect parent context changes. + @usableFromInline var effectNavigationIDPath: NavigationIDPath? = nil public init(wrappedValue: State?) { self.storage = Storage(state: wrappedValue) @@ -67,6 +69,7 @@ public struct PresentationState { set { if !isKnownUniquelyReferenced(&self.storage) { self.storage = Storage(state: newValue) + self.effectNavigationIDPath = nil // Clear on COW to trigger effect recreation } else { self.storage.state = newValue } @@ -657,9 +660,16 @@ public struct _PresentationReducer: Reducer baseEffects = self.base.reduce(into: &state, action: action) } + let currentNavigationID = state[keyPath: self.toPresentationState].wrappedValue.map(self.navigationIDPath(for:)) + let storedEffectPath = initialPresentationState.effectNavigationIDPath + + // Detect parent context change: if effectNavigationIDPath is nil but presentation exists, + // it means COW occurred (effectNavigationIDPath was cleared in wrappedValue setter) + let parentContextChanged = currentNavigationID != nil + && (storedEffectPath == nil || storedEffectPath != currentNavigationID) + let presentationIdentityChanged = - initialPresentationState.presentedID - != state[keyPath: self.toPresentationState].wrappedValue.map(self.navigationIDPath(for:)) + initialPresentationState.presentedID != currentNavigationID let dismissEffects: Effect if presentationIdentityChanged, @@ -676,14 +686,22 @@ public struct _PresentationReducer: Reducer if presentationIdentityChanged, state[keyPath: self.toPresentationState].wrappedValue == nil { state[keyPath: self.toPresentationState].presentedID = nil + state[keyPath: self.toPresentationState].effectNavigationIDPath = nil } let presentEffects: Effect - if presentationIdentityChanged || state[keyPath: self.toPresentationState].presentedID == nil, + // Recreate Empty effect if: + // - Parent context changed (COW occurred during enum transition) + // - Presentation identity changed (normal navigation) + // - First time presenting (presentedID is nil) + if (parentContextChanged + || presentationIdentityChanged + || state[keyPath: self.toPresentationState].presentedID == nil), let presentationState = state[keyPath: self.toPresentationState].wrappedValue, !isEphemeral(presentationState) { let presentationDestinationID = self.navigationIDPath(for: presentationState) + state[keyPath: self.toPresentationState].effectNavigationIDPath = presentationDestinationID state[keyPath: self.toPresentationState].presentedID = presentationDestinationID presentEffects = .concatenate( .publisher { Empty(completeImmediately: false) } From c8c7b4a7dcb6afa10cd2cf5ba3a6b4ffd896b777 Mon Sep 17 00:00:00 2001 From: Oguz Yuksel Date: Mon, 17 Nov 2025 13:34:46 +0100 Subject: [PATCH 2/2] add unit tests --- .../Reducers/PresentationReducerTests.swift | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift index 2c2465eb354b..64b5fa7e9588 100644 --- a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift @@ -2657,6 +2657,41 @@ final class PresentationReducerTests: BaseTCATestCase { // XCTAssertNil(store.alert) // } #endif + + @MainActor + func testPresentation_dismissWorksAfterParentEnumTransition() async { + let scheduler = DispatchQueue.test + let store = TestStore(initialState: EnumTransitionParent.State.online(.init())) { + EnumTransitionParent() + } withDependencies: { + $0.mainQueue = scheduler.eraseToAnyScheduler() + } + + // Present modal in .online state + await store.send(.online(.presentModal)) { + $0.online?.modal = .init() + } + + // Start timer that will dismiss + await store.send(.online(.modal(.presented(.startTimer)))) + + // Transition to .offline (timer cancelled, modal stays presented) + await store.send(.setOnline(false)) { + $0 = .offline($0.online ?? .init()) + } + + // Transition back to .online (modal still presented, timer restarts) + await store.send(.setOnline(true)) { + $0 = .online($0.offline ?? .init()) + } + await store.send(.online(.modal(.presented(.startTimer)))) + + // Timer completes and dismiss should work + await scheduler.advance(by: .milliseconds(100)) + await store.receive(\.online.modal.dismiss) { + $0.online?.modal = nil + } + } } @Reducer @@ -2687,3 +2722,112 @@ private struct NestedDismissFeature { } } } + +@Reducer +private struct EnumTransitionModal { + struct State: Equatable {} + enum Action { + case startTimer + } + @Dependency(\.dismiss) var dismiss + @Dependency(\.mainQueue) var mainQueue + + var body: some Reducer { + Reduce { state, action in + switch action { + case .startTimer: + return .run { _ in + try await mainQueue.sleep(for: .milliseconds(100)) + await dismiss() + } + } + } + } +} + +@Reducer +private struct EnumTransitionChild { + struct State: Equatable { + @PresentationState var modal: EnumTransitionModal.State? + } + enum Action { + case presentModal + case modal(PresentationAction) + } + var body: some Reducer { + Reduce { state, action in + switch action { + case .presentModal: + state.modal = .init() + return .none + case .modal: + return .none + } + } + .ifLet(\.$modal, action: \.modal) { + EnumTransitionModal() + } + } +} + +@Reducer +private struct EnumTransitionParent { + @ObservableState + enum State: Equatable { + case online(EnumTransitionChild.State) + case offline(EnumTransitionChild.State) + + var online: EnumTransitionChild.State? { + get { + guard case let .online(state) = self else { return nil } + return state + } + set { + guard let newValue else { return } + self = .online(newValue) + } + } + + var offline: EnumTransitionChild.State? { + get { + guard case let .offline(state) = self else { return nil } + return state + } + set { + guard let newValue else { return } + self = .offline(newValue) + } + } + } + + enum Action { + case setOnline(Bool) + case online(EnumTransitionChild.Action) + case offline(EnumTransitionChild.Action) + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .setOnline(true): + if case let .offline(offlineState) = state { + state = .online(offlineState) + } + return .none + case .setOnline(false): + if case let .online(onlineState) = state { + state = .offline(onlineState) + } + return .none + default: + return .none + } + } + .ifCaseLet(\.online, action: \.online) { + EnumTransitionChild() + } + .ifCaseLet(\.offline, action: \.offline) { + EmptyReducer() + } + } +}