diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved index 148b94ec1204..e56aca305172 100644 --- a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "f623901b4bcc97f59c36704f81583f169b228e51", - "version" : "0.13.0" + "revision" : "870133b7b2387df136ad301ec67b2e864b51dda1", + "version" : "0.14.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "dd86159e25c749873f144577e5d18309bf57534f", - "version" : "0.8.0" + "revision" : "de8ba65649e7ee317b9daf27dd5eebf34bd4be57", + "version" : "0.9.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "8282b0c59662eb38946afe30eb403663fc2ecf76", - "version" : "0.1.4" + "revision" : "6bb1034e8a1bfbf46dfb766b6c09b7b17e1cba10", + "version" : "0.2.0" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "270a754308f5440be52fc295242eb7031638bd15", - "version" : "0.6.1" + "revision" : "0a0e1b321d70ee6a464ecfe6b0136d9eff77ebfc", + "version" : "0.7.0" } }, { diff --git a/Examples/Integration/Integration/PresentationTestCase.swift b/Examples/Integration/Integration/PresentationTestCase.swift index 3ba85e756ded..1d8af000e99d 100644 --- a/Examples/Integration/Integration/PresentationTestCase.swift +++ b/Examples/Integration/Integration/PresentationTestCase.swift @@ -39,10 +39,12 @@ private struct PresentationTestCase: Reducer { enum AlertAction { case ok case showDialog + case showSheet } enum DialogAction { case ok case showAlert + case showSheet } var body: some ReducerOf { Scope(state: /State.fullScreenCover, action: /Action.fullScreenCover) { @@ -85,6 +87,9 @@ private struct PresentationTestCase: Reducer { ButtonState(action: .showDialog) { TextState("Show dialog") } + ButtonState(action: .showSheet) { + TextState("Show sheet") + } ButtonState(role: .cancel) { TextState("Cancel") } @@ -92,7 +97,6 @@ private struct PresentationTestCase: Reducer { ) return .none case .destination(.presented(.fullScreenCover(.parentSendDismissActionButtonTapped))), - .destination(.presented(.navigationDestination(.parentSendDismissActionButtonTapped))), .destination(.presented(.sheet(.parentSendDismissActionButtonTapped))), .destination(.presented(.popover(.parentSendDismissActionButtonTapped))): return .send(.destination(.dismiss)) @@ -104,13 +108,24 @@ private struct PresentationTestCase: Reducer { } ) return .none - case .destination(.presented(.dialog(.showAlert))): + case .destination(.presented(.alert(.showSheet))): + state.destination = .sheet(ChildFeature.State()) + return .none + case + .destination(.presented(.dialog(.showAlert))), + .destination(.presented(.fullScreenCover(.dismissAndAlert))), + .destination(.presented(.popover(.dismissAndAlert))), + .destination(.presented(.navigationDestination(.dismissAndAlert))), + .destination(.presented(.sheet(.dismissAndAlert))): state.destination = .alert( AlertState { TextState("Hello!") } ) return .none + case .destination(.presented(.dialog(.showSheet))): + state.destination = .sheet(ChildFeature.State()) + return .none case .destination(.dismiss): state.message = "Dismiss action sent" return .none @@ -127,6 +142,9 @@ private struct PresentationTestCase: Reducer { ButtonState(action: .showAlert) { TextState("Show alert") } + ButtonState(action: .showSheet) { + TextState("Show sheet") + } } ) return .none @@ -157,11 +175,13 @@ private struct ChildFeature: Reducer { struct State: Equatable, Identifiable { var id = UUID() var count = 0 + var isDismissed = false @BindingState var text = "" } enum Action: BindableAction, Equatable { case binding(BindingAction) case childDismissButtonTapped + case dismissAndAlert case incrementButtonTapped case parentSendDismissActionButtonTapped case resetIdentity @@ -176,11 +196,15 @@ private struct ChildFeature: Reducer { case .binding: return .none case .childDismissButtonTapped: + state.isDismissed = true return .fireAndForget { await self.dismiss() } + case .dismissAndAlert: + return .none case .incrementButtonTapped: state.count += 1 return .none case .parentSendDismissActionButtonTapped: + state.isDismissed = true return .none case .resetIdentity: state.id = UUID() @@ -308,6 +332,7 @@ struct PresentationTestCaseView: View { } private struct ChildView: View { + @Environment(\.dismiss) var dismiss let store: StoreOf var body: some View { @@ -324,6 +349,9 @@ private struct ChildView: View { Button("Parent dismiss") { viewStore.send(.parentSendDismissActionButtonTapped) } + Button("Dismiss and alert") { + viewStore.send(.dismissAndAlert) + } Button("Start effect") { viewStore.send(.startButtonTapped) } @@ -331,6 +359,9 @@ private struct ChildView: View { viewStore.send(.resetIdentity) } } + .onChange(of: viewStore.isDismissed) { _ in + self.dismiss() + } } } } diff --git a/Examples/Integration/IntegrationUITests/PresentationTests.swift b/Examples/Integration/IntegrationUITests/PresentationTests.swift index 5976e0e5f8c8..968be515cc51 100644 --- a/Examples/Integration/IntegrationUITests/PresentationTests.swift +++ b/Examples/Integration/IntegrationUITests/PresentationTests.swift @@ -52,9 +52,6 @@ final class PresentationTests: XCTestCase { } func testSheet_IdentityChange() async throws { - // TODO: Remove this XCTExpectFailure once the destination identifiable problem is fixed. - XCTExpectFailure() - self.app.buttons["Open sheet"].tap() XCTAssertEqual(true, self.app.staticTexts["Count: 0"].exists) @@ -157,15 +154,19 @@ final class PresentationTests: XCTestCase { } func testAlertThenDialog() { - // TODO: Remove this XCTExpectFailure once the destination identifiable problem is fixed. - XCTExpectFailure() - self.app.buttons["Open alert"].tap() self.app.buttons["Show dialog"].tap() _ = self.app.staticTexts["Hello!"].waitForExistence(timeout: 1) XCTAssertEqual(true, self.app.staticTexts["Hello!"].exists) } + func testAlertThenSheet() { + self.app.buttons["Open alert"].tap() + self.app.buttons["Show sheet"].tap() + _ = self.app.staticTexts["Count: 0"].waitForExistence(timeout: 1) + XCTAssertEqual(true, self.app.staticTexts["Count: 0"].exists) + } + func testDialogActionDoesNotSendExtraDismiss() { self.app.buttons["Open dialog"].tap() self.app.buttons["OK"].tap() @@ -181,9 +182,6 @@ final class PresentationTests: XCTestCase { } func testShowDialogThenAlert() { - // TODO: Remove this XCTExpectFailure once the destination identifiable problem is fixed. - XCTExpectFailure() - self.app.buttons["Open dialog"].tap() self.app.buttons["Show alert"].tap() _ = self.app.staticTexts["Hello!"].waitForExistence(timeout: 1) @@ -253,4 +251,42 @@ final class PresentationTests: XCTestCase { self.app.buttons["Parent dismiss"].tap() XCTAssertEqual(self.app.staticTexts["Action sent while state nil."].exists, false) } + + + func testNavigationDestination_ChildDismiss() { + self.app.buttons["Open navigation destination"].tap() + XCTAssertEqual(true, self.app.staticTexts["Count: 0"].exists) + + self.app.buttons["Increment"].tap() + XCTAssertEqual(true, self.app.staticTexts["Count: 1"].exists) + self.app.buttons["Increment"].tap() + XCTAssertEqual(true, self.app.staticTexts["Count: 2"].exists) + + self.app.buttons["Child dismiss"].tap() + XCTAssertEqual(false, self.app.staticTexts["Count: 2"].exists) + } + + func testNavigationDestination_ParentDismiss() { + self.app.buttons["Open navigation destination"].tap() + XCTAssertEqual(true, self.app.staticTexts["Count: 0"].exists) + + self.app.buttons["Parent dismiss"].tap() + XCTAssertEqual(false, self.app.staticTexts["Count: 0"].exists) + } + + func testNavigationDestination_EffectsCancelOnDismiss() async throws { + self.app.buttons["Open navigation destination"].tap() + XCTAssertEqual(true, self.app.staticTexts["Count: 0"].exists) + + self.app.buttons["Start effect"].tap() + XCTAssertEqual(true, self.app.staticTexts["Count: 1"].exists) + + self.app.buttons["Parent dismiss"].tap() + XCTAssertEqual(false, self.app.staticTexts["Count: 1"].exists) + + self.app.buttons["Open navigation destination"].tap() + XCTAssertEqual(true, self.app.staticTexts["Count: 0"].exists) + try await Task.sleep(for: .seconds(3)) + XCTAssertEqual(false, self.app.staticTexts["Count: 999"].exists) + } } diff --git a/Package.resolved b/Package.resolved index b87e344a2e43..d75b887b5e29 100644 --- a/Package.resolved +++ b/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "f623901b4bcc97f59c36704f81583f169b228e51", - "version" : "0.13.0" + "revision" : "870133b7b2387df136ad301ec67b2e864b51dda1", + "version" : "0.14.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "dd86159e25c749873f144577e5d18309bf57534f", - "version" : "0.8.0" + "revision" : "de8ba65649e7ee317b9daf27dd5eebf34bd4be57", + "version" : "0.9.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "8282b0c59662eb38946afe30eb403663fc2ecf76", - "version" : "0.1.4" + "revision" : "6bb1034e8a1bfbf46dfb766b6c09b7b17e1cba10", + "version" : "0.2.0" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "270a754308f5440be52fc295242eb7031638bd15", - "version" : "0.6.1" + "revision" : "0a0e1b321d70ee6a464ecfe6b0136d9eff77ebfc", + "version" : "0.7.0" } }, { diff --git a/Package.swift b/Package.swift index 79e8646c41b5..096cdb5b7590 100644 --- a/Package.swift +++ b/Package.swift @@ -17,15 +17,15 @@ let package = Package( ) ], dependencies: [ + .package(url: "https://github.com/apple/swift-collections", from: "1.0.2"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/google/swift-benchmark", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.8.0"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.13.0"), - .package(url: "https://github.com/apple/swift-collections", from: "1.0.2"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.7.0"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.1.2"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.14.0"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.9.1"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.2.0"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.7.0"), - .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.6.0"), + .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.7.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.5.0"), ], targets: [ diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md index c4a9f49a218d..63f4d43b671a 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md @@ -28,6 +28,10 @@ - ``EffectPublisher/unimplemented(_:)`` +### Combine integration + +- ``EffectPublisher/publisher(_:)`` + ### SwiftUI integration - ``EffectPublisher/animation(_:)`` diff --git a/Sources/ComposableArchitecture/Effects/Publisher.swift b/Sources/ComposableArchitecture/Effects/Publisher.swift index 528714140fc3..0c4a0c1cfa96 100644 --- a/Sources/ComposableArchitecture/Effects/Publisher.swift +++ b/Sources/ComposableArchitecture/Effects/Publisher.swift @@ -1,5 +1,18 @@ import Combine +extension EffectPublisher where Failure == Never { + /// Creates an effect from a Combine publisher. + /// + /// - Parameter createPublisher: The closure to execute when the effect is performed. + /// - Returns: An effect wrapping a Combine publisher. + public static func publisher(_ createPublisher: @escaping () -> P) -> Self + where P.Output == Action, P.Failure == Never { + Self( + operation: .publisher(Deferred(createPublisher: createPublisher).eraseToAnyPublisher()) + ) + } +} + @available(*, deprecated) extension EffectPublisher: Publisher { public typealias Output = Action @@ -75,7 +88,28 @@ extension EffectPublisher { /// /// - Parameter publisher: A publisher. @available( +<<<<<<< HEAD *, deprecated, message: "Iterate over 'Publisher.values' in an 'Effect.run', instead." +======= + iOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + macOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + tvOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + watchOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." +>>>>>>> navigation-beta ) public init(_ publisher: P) where P.Output == Output, P.Failure == Failure { self.operation = .publisher(publisher.eraseToAnyPublisher()) @@ -304,8 +338,29 @@ extension Publisher { /// /// - Returns: An effect that wraps `self`. @available( +<<<<<<< HEAD *, deprecated, message: "Iterate over 'Publisher.values' in an 'Effect.run', instead." +======= + iOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + macOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + tvOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + watchOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." +>>>>>>> navigation-beta ) public func eraseToEffect() -> EffectPublisher { EffectPublisher(self) @@ -327,7 +382,28 @@ extension Publisher { /// - transform: A mapping function that converts `Output` to another type. /// - Returns: An effect that wraps `self` after mapping `Output` values. @available( +<<<<<<< HEAD *, deprecated, message: "Iterate over 'Publisher.values' in an 'Effect.run', instead." +======= + iOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + macOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + tvOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + watchOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." +>>>>>>> navigation-beta ) public func eraseToEffect( _ transform: @escaping (Output) -> T @@ -359,9 +435,32 @@ extension Publisher { /// /// - Returns: An effect that wraps `self`. @available( +<<<<<<< HEAD *, deprecated, message: "Iterate over 'Publisher.values' in an 'Effect.run', instead." ) public func catchToEffect() -> Effect> { +======= + iOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + macOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + tvOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + watchOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + public func catchToEffect() -> EffectTask> { +>>>>>>> navigation-beta self.catchToEffect { $0 } } @@ -381,7 +480,28 @@ extension Publisher { /// - transform: A mapping function that converts `Result` to another type. /// - Returns: An effect that wraps `self`. @available( +<<<<<<< HEAD *, deprecated, message: "Iterate over 'Publisher.values' in an 'Effect.run', instead." +======= + iOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + macOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + tvOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." + ) + @available( + watchOS, deprecated: 9999.0, + message: + "Iterate over 'Publisher.values' in an 'EffectTask.run', instead, or use 'EffectTask.publisher'." +>>>>>>> navigation-beta ) public func catchToEffect( _ transform: @escaping (Result) -> T diff --git a/Sources/ComposableArchitecture/Internal/NavigationID.swift b/Sources/ComposableArchitecture/Internal/NavigationID.swift index 048580f3dd86..a846040cdad6 100644 --- a/Sources/ComposableArchitecture/Internal/NavigationID.swift +++ b/Sources/ComposableArchitecture/Internal/NavigationID.swift @@ -72,14 +72,9 @@ struct AnyID: Hashable, Identifiable, Sendable { self.objectIdentifier = ObjectIdentifier(Base.self) self.tag = EnumMetadata(Base.self)?.tag(of: base) - if let id = _id(base) { + if let id = _id(base) ?? EnumMetadata.project(base).flatMap(_id) { self.identifier = AnyHashableSendable(id) } - // TODO: Extract identifiable enum payload and assign id - // else if let metadata = EnumMetadata(type(of: base)), - // metadata.associatedValueType(forTag: metadata.tag(of: base)) is any Identifiable.Type - // { - // } } @usableFromInline diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/Presentation.swift b/Sources/ComposableArchitecture/Reducer/Reducers/Presentation.swift index 35d72819ee03..7b3fed75e7dc 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/Presentation.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/Presentation.swift @@ -226,11 +226,14 @@ public struct _PresentationReducer: Reducer .dependency(\.navigationID, id) .reduce( into: &state[keyPath: self.toPresentationState].wrappedValue!, action: destinationAction - ) + ) .map { self.toPresentationAction.embed(.presented($0)) } .cancellable(id: id) baseEffects = self.base.reduce(into: &state, action: action) - if isEphemeral(destinationState) { + if isEphemeral(destinationState), + self.id(for: destinationState) + == state[keyPath: self.toPresentationState].wrappedValue.map(self.id(for:)) + { state[keyPath: self.toPresentationState].wrappedValue = nil } diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index 5764df04e636..dbd250a92d97 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -116,13 +116,14 @@ private struct PresentationAlertModifier: ViewModif let fromDestinationAction: (ButtonAction) -> Action func body(content: Content) -> some View { + let id = self.viewStore.id let alertState = self.viewStore.wrappedValue.flatMap(self.toDestinationState) content.alert( (alertState?.title).map(Text.init) ?? Text(""), isPresented: Binding( // TODO: do proper binding get: { self.viewStore.wrappedValue.flatMap(self.toDestinationState) != nil }, set: { newState in - if !newState, self.viewStore.wrappedValue != nil { + if !newState, self.viewStore.wrappedValue != nil, self.viewStore.id == id { self.viewStore.send(.dismiss) } } diff --git a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift index d70c722712a6..166393b6c472 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift @@ -119,13 +119,14 @@ private struct PresentationConfirmationDialogModifier Action func body(content: Content) -> some View { + let id = self.viewStore.id let confirmationDialogState = self.viewStore.wrappedValue.flatMap(self.toDestinationState) content.confirmationDialog( (confirmationDialogState?.title).map(Text.init) ?? Text(""), isPresented: Binding( // TODO: do proper binding get: { self.viewStore.wrappedValue.flatMap(self.toDestinationState) != nil }, set: { newState in - if !newState, self.viewStore.wrappedValue != nil { + if !newState, self.viewStore.wrappedValue != nil, self.viewStore.id == id { self.viewStore.send(.dismiss) } } diff --git a/Sources/ComposableArchitecture/SwiftUI/FullscreenCover.swift b/Sources/ComposableArchitecture/SwiftUI/FullscreenCover.swift index d033c7c9d025..0931ce3c3afe 100644 --- a/Sources/ComposableArchitecture/SwiftUI/FullscreenCover.swift +++ b/Sources/ComposableArchitecture/SwiftUI/FullscreenCover.swift @@ -59,10 +59,19 @@ private struct PresentationFullScreenCoverModifier< } func body(content: Content) -> some View { + let id = self.viewStore.id content.fullScreenCover( - item: self.viewStore.binding( - get: { $0.wrappedValue.flatMap(self.toDestinationState) != nil ? $0.id : nil }, - send: .dismiss + item: Binding( // TODO: do proper binding + get: { + self.viewStore.wrappedValue.flatMap(self.toDestinationState) != nil + ? self.viewStore.id + : nil + }, + set: { newState in + if newState == nil, self.viewStore.wrappedValue != nil, self.viewStore.id == id { + self.viewStore.send(.dismiss) + } + } ) ) { _ in IfLetStore( diff --git a/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift b/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift index 42c457fcd16a..b43edde059b6 100644 --- a/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift +++ b/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift @@ -68,7 +68,10 @@ import SwiftUI } func body(content: Content) -> some View { - content.navigationDestination(isPresented: self.viewStore.binding(send: .dismiss)) { + content.navigationDestination( + // TODO: do binding with ID check + isPresented: self.viewStore.binding(send: .dismiss) + ) { IfLetStore( self.store.scope( state: returningLastNonNilValue { $0.wrappedValue.flatMap(self.toDestinationState) }, diff --git a/Sources/ComposableArchitecture/SwiftUI/Popover.swift b/Sources/ComposableArchitecture/SwiftUI/Popover.swift index 78245c84b859..d890fdaa7cf7 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Popover.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Popover.swift @@ -78,11 +78,19 @@ private struct PresentationPopoverModifier< } func body(content: Content) -> some View { + let id = self.viewStore.id content.popover( - item: self.viewStore.binding( - get: { $0.wrappedValue.flatMap(self.toDestinationState) != nil ? $0.id : nil }, - send: .dismiss - + item: Binding( // TODO: do proper binding + get: { + self.viewStore.wrappedValue.flatMap(self.toDestinationState) != nil + ? self.viewStore.id + : nil + }, + set: { newState in + if newState == nil, self.viewStore.wrappedValue != nil, self.viewStore.id == id { + self.viewStore.send(.dismiss) + } + } ), attachmentAnchor: self.attachmentAnchor, arrowEdge: self.arrowEdge diff --git a/Sources/ComposableArchitecture/SwiftUI/Sheet.swift b/Sources/ComposableArchitecture/SwiftUI/Sheet.swift index db4f02f0e84b..d650ad45b74b 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Sheet.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Sheet.swift @@ -53,10 +53,19 @@ private struct PresentationSheetModifier< } func body(content: Content) -> some View { + let id = self.viewStore.id content.sheet( - item: self.viewStore.binding( - get: { $0.wrappedValue.flatMap(self.toDestinationState) != nil ? $0.id : nil }, - send: .dismiss + item: Binding( // TODO: do proper binding + get: { + self.viewStore.wrappedValue.flatMap(self.toDestinationState) != nil + ? self.viewStore.id + : nil + }, + set: { newState in + if newState == nil, self.viewStore.wrappedValue != nil, self.viewStore.id == id { + self.viewStore.send(.dismiss) + } + } ) ) { _ in IfLetStore( diff --git a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift index ae81455d3433..0635b9264f83 100644 --- a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift @@ -46,7 +46,7 @@ import SwiftUI /// instead of using ``WithViewStore``: /// /// 1. When ``WithViewStore`` wraps complex views the Swift compiler can quickly become bogged down, -/// leading to degraded compiler performance and diagnostics. If you are experience such instability +/// leading to degraded compiler performance and diagnostics. If you are experiencing such instability /// you should consider manually setting up observation with an `@ObservedObject` property as /// described above. /// diff --git a/Tests/ComposableArchitectureTests/PresentationReducerTests.swift b/Tests/ComposableArchitectureTests/PresentationReducerTests.swift index c898ca20e815..df15b23ee3e5 100644 --- a/Tests/ComposableArchitectureTests/PresentationReducerTests.swift +++ b/Tests/ComposableArchitectureTests/PresentationReducerTests.swift @@ -1049,21 +1049,23 @@ import XCTest } } - let store = TestStore( - initialState: Parent.State(), - reducer: Parent() - ) - let childPresentationTask = await store.send(.presentChild) { - $0.child = Child.State() - } - let grandchildPresentationTask = await store.send(.child(.presented(.presentGrandchild))) { - $0.child?.grandchild = Grandchild.State() + await _withMainSerialExecutor { + let store = TestStore( + initialState: Parent.State(), + reducer: Parent() + ) + let childPresentationTask = await store.send(.presentChild) { + $0.child = Child.State() + } + let grandchildPresentationTask = await store.send(.child(.presented(.presentGrandchild))) { + $0.child?.grandchild = Grandchild.State() + } + await store.send(.child(.presented(.startButtonTapped))) + await store.send(.child(.presented(.grandchild(.presented(.startButtonTapped))))) + await store.send(.stopButtonTapped) + await grandchildPresentationTask.cancel() + await childPresentationTask.cancel() } - await store.send(.child(.presented(.startButtonTapped))) - await store.send(.child(.presented(.grandchild(.presented(.startButtonTapped))))) - await store.send(.stopButtonTapped) - await grandchildPresentationTask.cancel() - await childPresentationTask.cancel() } func testNavigation_cancelID_parentCancelTwoChildren() async { @@ -1881,9 +1883,6 @@ import XCTest } func testPresentation_DestinationEnum_IdentityChange() async { - // TODO: Remove this XCTExpectFailure once the destination identifiable problem is fixed. - XCTExpectFailure() - struct Child: Reducer { struct State: Equatable, Identifiable { var id = DependencyValues._current.uuid() @@ -2057,25 +2056,25 @@ import XCTest reducer: Feature() ) - // TODO: remove this XCTExpectFailure when the destination identifiable stuff is fixed - XCTExpectFailure() - await store.send(.showAlert) { $0.destination = .alert(Feature.alert) } await store.send(.destination(.presented(.alert(.showDialog)))) { - $0.destination = .dialog(Feature.dialog) + $0.destination = .dialog(ConfirmationDialogState { TextState("Hello!") } actions: {}) + } + await store.send(.destination(.dismiss)) { + $0.destination = nil } - await store.send(.destination(.dismiss)) await store.send(.showDialog) { $0.destination = .dialog(Feature.dialog) } - // TODO: remove this XCTExpectFailure when the destination identifiable stuff is fixed await store.send(.destination(.presented(.dialog(.showAlert)))) { - $0.destination = .alert(Feature.alert) + $0.destination = .alert(AlertState { TextState("Hello!") }) + } + await store.send(.destination(.dismiss)) { + $0.destination = nil } - await store.send(.destination(.dismiss)) } }