Skip to content

Commit f02fab5

Browse files
authored
Allow an alert to present another alert (#3309)
* Allow an alert to present another alert When we added support for vanilla SwiftUI modifiers, we lost the ability to present one alert after another because `nil` writes to the alert bindings unconditionally dismissed the feature, even if the feature was freshly presented. This fixes things by suppressing dismissal when the identity of a presented item has changed. Fix #3272. * wip
1 parent 181b9d2 commit f02fab5

File tree

14 files changed

+174
-21
lines changed

14 files changed

+174
-21
lines changed

Examples/Integration/Integration.xcodeproj/project.pbxproj

+24
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
CAF5802729A567BB0042FB62 /* LegacyPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF5802629A567BB0042FB62 /* LegacyPresentationTests.swift */; };
5656
DC140CC529E0BB2C006DF553 /* SwitchStoreTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC140CC429E0BB2C006DF553 /* SwitchStoreTestCase.swift */; };
5757
DC140CC729E0E8F3006DF553 /* SwitchStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC140CC629E0E8F3006DF553 /* SwitchStoreTests.swift */; };
58+
DC44CFC12C751C1E009F9FE4 /* MultipleAlertsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC44CFBF2C751BDA009F9FE4 /* MultipleAlertsTestCase.swift */; };
5859
DC6268502AD1C85E00F2E2EF /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = DC62684F2AD1C85E00F2E2EF /* InlineSnapshotTesting */; };
5960
DC6268532AD1E06300F2E2EF /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = DC6268522AD1E06300F2E2EF /* InlineSnapshotTesting */; };
6061
DC6E2D942AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D8D2AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift */; };
@@ -73,6 +74,7 @@
7374
DC6E2DAB2AD5C677005ACC26 /* ObservablePresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2DA42AD5C677005ACC26 /* ObservablePresentationTests.swift */; };
7475
DC808D6529E91FAA0072B4A9 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC808D6429E91FAA0072B4A9 /* ComposableArchitecture */; };
7576
DC92799B2A1E59D500B2031A /* PresentationItemTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC92799A2A1E59D500B2031A /* PresentationItemTestCase.swift */; };
77+
DCA6716B2C7CEC550086F359 /* MultipleAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA6716A2C7CEC4D0086F359 /* MultipleAlertsTests.swift */; };
7678
DCFFB8E72A156488006AF839 /* BindingLocalTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFFB8E62A156488006AF839 /* BindingLocalTestCase.swift */; };
7779
DCFFB8E92A15792C006AF839 /* BindingLocalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFFB8E82A15792C006AF839 /* BindingLocalTests.swift */; };
7880
E9919D3E296E28C800C8716B /* EscapedWithViewStoreTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9919D3D296E28C800C8716B /* EscapedWithViewStoreTestCase.swift */; };
@@ -168,6 +170,7 @@
168170
CAF5802629A567BB0042FB62 /* LegacyPresentationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyPresentationTests.swift; sourceTree = "<group>"; };
169171
DC140CC429E0BB2C006DF553 /* SwitchStoreTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchStoreTestCase.swift; sourceTree = "<group>"; };
170172
DC140CC629E0E8F3006DF553 /* SwitchStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchStoreTests.swift; sourceTree = "<group>"; };
173+
DC44CFBF2C751BDA009F9FE4 /* MultipleAlertsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleAlertsTestCase.swift; sourceTree = "<group>"; };
171174
DC6E2D8D2AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableIdentifiedListTestCase.swift; sourceTree = "<group>"; };
172175
DC6E2D8E2AD5C56F005ACC26 /* ObservableBasicsTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableBasicsTestCase.swift; sourceTree = "<group>"; };
173176
DC6E2D8F2AD5C56F005ACC26 /* ObservableNavigationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableNavigationTestCase.swift; sourceTree = "<group>"; };
@@ -183,6 +186,7 @@
183186
DC6E2DA32AD5C677005ACC26 /* ObservableBasicsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableBasicsTests.swift; sourceTree = "<group>"; };
184187
DC6E2DA42AD5C677005ACC26 /* ObservablePresentationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservablePresentationTests.swift; sourceTree = "<group>"; };
185188
DC92799A2A1E59D500B2031A /* PresentationItemTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationItemTestCase.swift; sourceTree = "<group>"; };
189+
DCA6716A2C7CEC4D0086F359 /* MultipleAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleAlertsTests.swift; sourceTree = "<group>"; };
186190
DCFFB8E62A156488006AF839 /* BindingLocalTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindingLocalTestCase.swift; sourceTree = "<group>"; };
187191
DCFFB8E82A15792C006AF839 /* BindingLocalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindingLocalTests.swift; sourceTree = "<group>"; };
188192
E9919D3D296E28C800C8716B /* EscapedWithViewStoreTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscapedWithViewStoreTestCase.swift; sourceTree = "<group>"; };
@@ -293,6 +297,7 @@
293297
DC6E2D8C2AD5C525005ACC26 /* iOS 17 */,
294298
CA8B2E932AC57518008272E0 /* Legacy */,
295299
CAA1CAFA296DEE79000665B1 /* Preview Content */,
300+
DC44CFC02C751BEA009F9FE4 /* Test Cases */,
296301
);
297302
path = Integration;
298303
sourceTree = "<group>";
@@ -313,6 +318,7 @@
313318
CAA6BEAC2ADADE4300FF83BC /* iOS 16+17 */,
314319
DC6E2D9D2AD5C64C005ACC26 /* iOS 17 */,
315320
CA8B2E9D2AC576CE008272E0 /* Legacy */,
321+
DCA671692C7CEC380086F359 /* Test Cases */,
316322
);
317323
path = IntegrationUITests;
318324
sourceTree = "<group>";
@@ -356,6 +362,14 @@
356362
path = TestCases;
357363
sourceTree = "<group>";
358364
};
365+
DC44CFC02C751BEA009F9FE4 /* Test Cases */ = {
366+
isa = PBXGroup;
367+
children = (
368+
DC44CFBF2C751BDA009F9FE4 /* MultipleAlertsTestCase.swift */,
369+
);
370+
path = "Test Cases";
371+
sourceTree = "<group>";
372+
};
359373
DC6E2D8B2AD5C512005ACC26 /* iOS 16 */ = {
360374
isa = PBXGroup;
361375
children = (
@@ -416,6 +430,14 @@
416430
path = "iOS 17";
417431
sourceTree = "<group>";
418432
};
433+
DCA671692C7CEC380086F359 /* Test Cases */ = {
434+
isa = PBXGroup;
435+
children = (
436+
DCA6716A2C7CEC4D0086F359 /* MultipleAlertsTests.swift */,
437+
);
438+
path = "Test Cases";
439+
sourceTree = "<group>";
440+
};
419441
/* End PBXGroup section */
420442

421443
/* Begin PBXHeadersBuildPhase section */
@@ -582,6 +604,7 @@
582604
CA4BA5E929E76A7F0004FF9D /* NavigationStackTestCase.swift in Sources */,
583605
E9919D42296E47A400C8716B /* BindingsAnimationsTestBench.swift in Sources */,
584606
CAE2E9232B23417000EE370B /* IfLetStoreTestCase.swift in Sources */,
607+
DC44CFC12C751C1E009F9FE4 /* MultipleAlertsTestCase.swift in Sources */,
585608
DCFFB8E72A156488006AF839 /* BindingLocalTestCase.swift in Sources */,
586609
DC6E2D992AD5C56F005ACC26 /* ObservableOptionalTestCase.swift in Sources */,
587610
CA7BDDA12ADB543400277984 /* NewContainsOldTestCase.swift in Sources */,
@@ -623,6 +646,7 @@
623646
CA8B2E9B2AC576CA008272E0 /* EnumTests.swift in Sources */,
624647
DC6E2DA62AD5C677005ACC26 /* ObservableIdentifiedListTests.swift in Sources */,
625648
CAF5802729A567BB0042FB62 /* LegacyPresentationTests.swift in Sources */,
649+
DCA6716B2C7CEC550086F359 /* MultipleAlertsTests.swift in Sources */,
626650
CA487B2C2A15185300F54A79 /* BaseIntegrationTests.swift in Sources */,
627651
CA8B2EA72AC584BE008272E0 /* SiblingTests.swift in Sources */,
628652
DC6E2DA72AD5C677005ACC26 /* ObservableNavigationTests.swift in Sources */,

Examples/Integration/Integration/IntegrationApp.swift

+18-2
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ final class IntegrationAppDelegate: NSObject, UIApplicationDelegate {
5656
configurationForConnecting connectingSceneSession: UISceneSession,
5757
options: UIScene.ConnectionOptions
5858
) -> UISceneConfiguration {
59-
UIView.setAnimationsEnabled(false)
59+
if ProcessInfo.processInfo.environment["UI_TEST"] != nil {
60+
UIView.setAnimationsEnabled(false)
61+
}
6062
Logger.shared.isEnabled = true
6163
IssueReporters.current.append(NotificationReporter())
6264
let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
@@ -185,9 +187,23 @@ struct ContentView: View {
185187
.navigationTitle(Text("iOS 16"))
186188
}
187189

190+
NavigationLink("Test cases") {
191+
List {
192+
ForEach(TestCase.Cases.allCases) { test in
193+
switch test {
194+
case .multipleAlerts:
195+
NavigationLink(test.rawValue) {
196+
MultipleAlertsTestCaseView()
197+
}
198+
}
199+
}
200+
}
201+
.navigationTitle(Text("Test cases"))
202+
}
203+
188204
NavigationLink("Legacy") {
189205
List {
190-
ForEach(TestCase.allCases) { test in
206+
ForEach(TestCase.Legacy.allCases) { test in
191207
switch test {
192208
case .escapedWithViewStore:
193209
NavigationLink(test.rawValue) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import ComposableArchitecture
2+
import SwiftUI
3+
4+
@Reducer
5+
private struct MultipleAlertsTestCase {
6+
@ObservableState
7+
struct State: Equatable {
8+
@Presents var alert: AlertState<Action.Alert>?
9+
}
10+
enum Action {
11+
case alert(PresentationAction<Alert>)
12+
case showAlertButtonTapped
13+
14+
@CasePathable
15+
enum Alert {
16+
case anotherButtonTapped
17+
}
18+
}
19+
var body: some ReducerOf<Self> {
20+
Reduce { state, action in
21+
switch action {
22+
case .alert(.presented(.anotherButtonTapped)):
23+
if let title = state.alert?.title {
24+
state.alert = AlertState {
25+
title + TextState("!")
26+
} actions: {
27+
ButtonState(action: .anotherButtonTapped) {
28+
TextState("Another!")
29+
}
30+
ButtonState(role: .cancel) {
31+
TextState("I'm done")
32+
}
33+
}
34+
}
35+
return .none
36+
37+
case .alert:
38+
return .none
39+
40+
case .showAlertButtonTapped:
41+
state.alert = AlertState {
42+
TextState("Hello")
43+
} actions: {
44+
ButtonState(action: .anotherButtonTapped) {
45+
TextState("Another!")
46+
}
47+
ButtonState(role: .cancel) {
48+
TextState("I'm done")
49+
}
50+
}
51+
return .none
52+
}
53+
}
54+
.ifLet(\.$alert, action: \.alert)
55+
._printChanges()
56+
}
57+
}
58+
59+
struct MultipleAlertsTestCaseView: View {
60+
@Perception.Bindable private var store = Store(initialState: MultipleAlertsTestCase.State()) {
61+
MultipleAlertsTestCase()
62+
}
63+
64+
var body: some View {
65+
WithPerceptionTracking {
66+
VStack {
67+
Button("Show alert") {
68+
store.send(.showAlertButtonTapped)
69+
}
70+
}
71+
.alert($store.scope(state: \.alert, action: \.alert))
72+
}
73+
}
74+
}

Examples/Integration/IntegrationUITests/Legacy/BindingLocalTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ final class BindingLocalTests: BaseIntegrationTests {
77
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil)
88
try super.setUpWithError()
99
self.app.buttons["Legacy"].tap()
10-
app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap()
10+
app.collectionViews.buttons[TestCase.Legacy.bindingLocal.rawValue].tap()
1111
}
1212

1313
@MainActor

Examples/Integration/IntegrationUITests/Legacy/EscapedWithViewStoreTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ final class EscapedWithViewStoreTests: BaseIntegrationTests {
77
override func setUpWithError() throws {
88
try super.setUpWithError()
99
self.app.buttons["Legacy"].tap()
10-
app.collectionViews.buttons[TestCase.escapedWithViewStore.rawValue].tap()
10+
app.collectionViews.buttons[TestCase.Legacy.escapedWithViewStore.rawValue].tap()
1111
}
1212

1313
@MainActor

Examples/Integration/IntegrationUITests/Legacy/ForEachBindingTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ final class ForEachBindingTests: BaseIntegrationTests {
66
override func setUpWithError() throws {
77
try super.setUpWithError()
88
self.app.buttons["Legacy"].tap()
9-
app.collectionViews.buttons[TestCase.forEachBinding.rawValue].tap()
9+
app.collectionViews.buttons[TestCase.Legacy.forEachBinding.rawValue].tap()
1010
}
1111

1212
@MainActor

Examples/Integration/IntegrationUITests/Legacy/IfLetStoreTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ final class IfLetStoreTests: BaseIntegrationTests {
66
override func setUpWithError() throws {
77
try super.setUpWithError()
88
self.app.buttons["Legacy"].tap()
9-
self.app.buttons[TestCase.ifLetStore.rawValue].tap()
9+
self.app.buttons[TestCase.Legacy.ifLetStore.rawValue].tap()
1010
}
1111

1212
@MainActor

Examples/Integration/IntegrationUITests/Legacy/LegacyNavigationTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ final class LegacyNavigationTests: BaseIntegrationTests {
77
override func setUpWithError() throws {
88
try super.setUpWithError()
99
self.app.buttons["Legacy"].tap()
10-
self.app.buttons[TestCase.navigationStack.rawValue].tap()
10+
self.app.buttons[TestCase.Legacy.navigationStack.rawValue].tap()
1111
}
1212

1313
@MainActor

Examples/Integration/IntegrationUITests/Legacy/LegacyPresentationTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ final class LegacyPresentationTests: BaseIntegrationTests {
77
override func setUpWithError() throws {
88
try super.setUpWithError()
99
self.app.buttons["Legacy"].tap()
10-
self.app.buttons[TestCase.presentation.rawValue].tap()
10+
self.app.buttons[TestCase.Legacy.presentation.rawValue].tap()
1111
}
1212

1313
@MainActor

Examples/Integration/IntegrationUITests/Legacy/SwitchStoreTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ final class SwitchStoreTests: BaseIntegrationTests {
77
override func setUpWithError() throws {
88
try super.setUpWithError()
99
app.buttons["Legacy"].tap()
10-
app.collectionViews.buttons[TestCase.switchStore.rawValue].tap()
10+
app.collectionViews.buttons[TestCase.Legacy.switchStore.rawValue].tap()
1111
}
1212

1313
@MainActor
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import TestCases
2+
import XCTest
3+
4+
final class MultipleAlertsTests: BaseIntegrationTests {
5+
@MainActor
6+
override func setUpWithError() throws {
7+
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil)
8+
try super.setUpWithError()
9+
self.app.buttons["Test cases"].tap()
10+
app.collectionViews.buttons[TestCase.Cases.multipleAlerts.rawValue].tap()
11+
}
12+
13+
@MainActor
14+
func testMultipleAlerts() {
15+
app.buttons["Show alert"].tap()
16+
17+
app.buttons["Another!"].tap()
18+
19+
app.buttons["I'm done"].tap()
20+
}
21+
}
+22-11
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
public enum TestCase: String, CaseIterable, Identifiable, RawRepresentable {
2-
case escapedWithViewStore = "Escaped WithViewStore"
3-
case ifLetStore = "IfLetStore"
4-
case forEachBinding = "ForEach Binding"
5-
case navigationStack = "NavigationStack"
6-
case presentation = "Presentation APIs"
7-
case presentationItem = "Presentation Item"
8-
case switchStore = "SwitchStore/CaseLet Warning"
9-
case bindingLocal = "BindingLocal Warning"
10-
11-
public var id: Self { self }
1+
public enum TestCase {
2+
case cases(Cases)
3+
case legacy(Legacy)
4+
5+
public enum Cases: String, CaseIterable, Identifiable, RawRepresentable {
6+
case multipleAlerts = "Multiple alerts"
7+
8+
public var id: Self { self }
9+
}
10+
11+
public enum Legacy: String, CaseIterable, Identifiable, RawRepresentable {
12+
case escapedWithViewStore = "Escaped WithViewStore"
13+
case ifLetStore = "IfLetStore"
14+
case forEachBinding = "ForEach Binding"
15+
case navigationStack = "NavigationStack"
16+
case presentation = "Presentation APIs"
17+
case presentationItem = "Presentation Item"
18+
case switchStore = "SwitchStore/CaseLet Warning"
19+
case bindingLocal = "BindingLocal Warning"
20+
21+
public var id: Self { self }
22+
}
1223
}

Sources/ComposableArchitecture/Observation/Store+Observation.swift

+7-1
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
) -> Binding<Store<ChildState, ChildAction>?>
166166
where Value == Store<State, Action> {
167167
self[
168+
id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID),
168169
state: state,
169170
action: action,
170171
isInViewBody: _isInPerceptionTracking,
@@ -234,6 +235,7 @@
234235
) -> Binding<Store<ChildState, ChildAction>?>
235236
where Value == Store<State, Action> {
236237
self[
238+
id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID),
237239
state: state,
238240
action: action,
239241
isInViewBody: _isInPerceptionTracking,
@@ -306,6 +308,7 @@
306308
) -> Binding<Store<ChildState, ChildAction>?>
307309
where Value == Store<State, Action> {
308310
self[
311+
id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID),
309312
state: state,
310313
action: action,
311314
isInViewBody: _isInPerceptionTracking,
@@ -328,6 +331,7 @@
328331
) -> UIBinding<Store<ChildState, ChildAction>?>
329332
where Value == Store<State, Action> {
330333
self[
334+
id: wrappedValue.currentState[keyPath: state].flatMap(_identifiableID),
331335
state: state,
332336
action: action,
333337
isInViewBody: _isInPerceptionTracking,
@@ -342,6 +346,7 @@
342346
extension Store where State: ObservableState {
343347
@_spi(Internals)
344348
public subscript<ChildState, ChildAction>(
349+
id id: AnyHashable?,
345350
state state: KeyPath<State, ChildState?>,
346351
action action: CaseKeyPath<Action, PresentationAction<ChildAction>>,
347352
isInViewBody isInViewBody: Bool,
@@ -375,7 +380,8 @@
375380
}
376381
set {
377382
if newValue == nil,
378-
self.state[keyPath: state] != nil,
383+
let childState = self.state[keyPath: state],
384+
id == _identifiableID(childState),
379385
!self._isInvalidated()
380386
{
381387
self.send(action(.dismiss))

Tests/ComposableArchitectureTests/RuntimeWarningTests.swift

+1
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@
294294

295295
XCTExpectFailure {
296296
store[
297+
id: nil,
297298
state: \.destination,
298299
action: \.destination,
299300
isInViewBody: false,

0 commit comments

Comments
 (0)