diff --git a/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c5988f707cd..5ceef855178b 100644 --- a/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c", - "version" : "0.10.0" + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", - "version" : "0.14.1" + "revision" : "bba1111185863c9288c5f047770f421c3b7793a4", + "version" : "1.1.3" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d", - "version" : "0.3.0" + "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version" : "1.0.2" } }, { @@ -54,13 +54,22 @@ "version" : "1.0.4" } }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "505aa98716275fbd045d8f934fee3337c82ffbd3", - "version" : "0.10.3" + "revision" : "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", + "version" : "1.1.2" } }, { @@ -68,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "25c9b6789b4b7ada649a3808e6d8de1489082a33", - "version" : "0.5.0" + "revision" : "c31b1445c4fae49e6fdb75496b895a3653f6aefc", + "version" : "1.1.5" } }, { @@ -95,8 +104,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "ad3932d28c2e0a009a0167089619526709ef6497", - "version" : "0.7.0" + "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing", + "state" : { + "revision" : "10dcef36314ddfea6f60442169b0b320204cbd35", + "version" : "0.2.2" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "59b663f68e69f27a87b45de48cb63264b8194605", + "version" : "1.15.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" } }, { @@ -104,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "47dd574b900ba5ba679f56ea00d4d282fc7305a6", - "version" : "0.7.1" + "revision" : "78f9d72cf667adb47e2040aa373185c88c63f0dc", + "version" : "1.2.0" } }, { @@ -113,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "4af50b38daf0037cfbab15514a241224c3f62f98", - "version" : "0.8.5" + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" } } ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5602d2b79ee8..9f9e92ad28d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: - release steps: - uses: actions/checkout@v4 - - name: Select Xcode 15.1 - run: sudo xcode-select -s /Applications/Xcode_15.1.app + - name: Select Xcode 15.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app - name: Run ${{ matrix.config }} tests run: make CONFIG=${{ matrix.config }} test-library @@ -34,8 +34,8 @@ jobs: runs-on: macos-13 steps: - uses: actions/checkout@v4 - - name: Select Xcode 15.1 - run: sudo xcode-select -s /Applications/Xcode_15.1.app + - name: Select Xcode 15.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app - name: Build for library evolution run: make build-for-library-evolution @@ -59,8 +59,8 @@ jobs: runs-on: macos-13 steps: - uses: actions/checkout@v4 - - name: Select Xcode 15.1 - run: sudo xcode-select -s /Applications/Xcode_15.1.app + - name: Select Xcode 15.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app - name: Run benchmark run: make benchmark @@ -69,7 +69,7 @@ jobs: runs-on: macos-13 steps: - uses: actions/checkout@v4 - - name: Select Xcode 15.1 - run: sudo xcode-select -s /Applications/Xcode_15.1.app + - name: Select Xcode 15.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app - name: Run tests run: make test-examples diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8c9f1c19b828..6a817126f01c 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" : "40773cbaf8d71ed5357f297b1ba4073f5b24faaa", - "version" : "1.1.0" + "revision" : "bba1111185863c9288c5f047770f421c3b7793a4", + "version" : "1.1.3" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", - "version" : "1.0.5" + "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", + "version" : "1.0.6" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "63301f4a181ed9aefb46dccef2dfb66466798341", - "version" : "1.1.1" + "revision" : "c31b1445c4fae49e6fdb75496b895a3653f6aefc", + "version" : "1.1.5" } }, { @@ -117,13 +117,22 @@ "version" : "0.2.2" } }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "42240120b2a8797595433288ab4118f8042214c3", + "version" : "1.1.1" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", "state" : { - "revision" : "4862d48562483d274a2ac7522d905c9237a31a48", - "version" : "1.15.0" + "revision" : "59b663f68e69f27a87b45de48cb63264b8194605", + "version" : "1.15.1" } }, { diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj index 528d43bc9403..d154d6637c02 100644 --- a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -55,7 +55,6 @@ DC4C6EB62450DD380066A05D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EB42450DD380066A05D /* LaunchScreen.storyboard */; }; DC4C6EC12450DD390066A05D /* UIKitCaseStudiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */; }; DC4C6ED62450E1050066A05D /* CounterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED52450E1050066A05D /* CounterViewController.swift */; }; - DC4C6ED82450E4570066A05D /* UIViewRepresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */; }; DC4C6EDA2450E6050066A05D /* NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */; }; DC5B505125C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5B505025C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift */; }; DC630FDA2451016B00BAECBA /* ListsOfState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC630FD92451016B00BAECBA /* ListsOfState.swift */; }; @@ -199,7 +198,6 @@ DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitCaseStudiesTests.swift; sourceTree = ""; }; DC4C6EC22450DD390066A05D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DC4C6ED52450E1050066A05D /* CounterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterViewController.swift; sourceTree = ""; }; - DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewRepresented.swift; sourceTree = ""; }; DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigateAndLoad.swift; sourceTree = ""; }; DC5B505025C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Bindings-Forms.swift"; sourceTree = ""; }; DC630FD92451016B00BAECBA /* ListsOfState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsOfState.swift; sourceTree = ""; }; @@ -313,7 +311,6 @@ children = ( DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */, DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */, - DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */, ); path = Internal; sourceTree = ""; @@ -706,7 +703,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DC4C6ED82450E4570066A05D /* UIViewRepresented.swift in Sources */, DC25DC642450F2DF00082E81 /* ActivityIndicatorViewController.swift in Sources */, DC4C6ED62450E1050066A05D /* CounterViewController.swift in Sources */, DC4C6EDA2450E6050066A05D /* NavigateAndLoad.swift in Sources */, @@ -835,7 +831,6 @@ SDKROOT = appletvos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 13.3; }; name = Debug; }; @@ -855,7 +850,6 @@ SDKROOT = appletvos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 13.3; }; name = Release; }; @@ -877,7 +871,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tvOSCaseStudies.app/tvOSCaseStudies"; - TVOS_DEPLOYMENT_TARGET = 13.3; }; name = Debug; }; @@ -899,7 +892,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tvOSCaseStudies.app/tvOSCaseStudies"; - TVOS_DEPLOYMENT_TARGET = 13.3; }; name = Release; }; diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift index 58994fb66c28..071f96f85488 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift @@ -23,9 +23,10 @@ private let readMe = """ @Reducer struct AlertAndConfirmationDialog { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? - @PresentationState var confirmationDialog: ConfirmationDialogState? + @Presents var alert: AlertState? + @Presents var confirmationDialog: ConfirmationDialogState? var count = 0 } @@ -106,29 +107,23 @@ struct AlertAndConfirmationDialog { // MARK: - Feature view struct AlertAndConfirmationDialogView: View { - @State var store = Store(initialState: AlertAndConfirmationDialog.State()) { + @Bindable var store = Store(initialState: AlertAndConfirmationDialog.State()) { AlertAndConfirmationDialog() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } - - Text("Count: \(viewStore.count)") - Button("Alert") { viewStore.send(.alertButtonTapped) } - Button("Confirmation Dialog") { viewStore.send(.confirmationDialogButtonTapped) } + Form { + Section { + AboutView(readMe: readMe) } + + Text("Count: \(store.count)") + Button("Alert") { store.send(.alertButtonTapped) } + Button("Confirmation Dialog") { store.send(.confirmationDialogButtonTapped) } } .navigationTitle("Alerts & Dialogs") - .alert( - store: self.store.scope(state: \.$alert, action: \.alert) - ) - .confirmationDialog( - store: self.store.scope(state: \.$confirmationDialog, action: \.confirmationDialog) - ) + .alert($store.scope(state: \.alert, action: \.alert)) + .confirmationDialog($store.scope(state: \.confirmationDialog, action: \.confirmationDialog)) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift index 57b1fab3d145..918115abf602 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -22,8 +22,9 @@ private let readMe = """ @Reducer struct Animations { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? var circleCenter: CGPoint? var circleColor = Color.black var isCircleScaled = false @@ -101,55 +102,52 @@ struct Animations { // MARK: - Feature view struct AnimationsView: View { - @State var store = Store(initialState: Animations.State()) { + @Bindable var store = Store(initialState: Animations.State()) { Animations() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack(alignment: .leading) { - Text(template: readMe, .body) - .padding() - .gesture( - DragGesture(minimumDistance: 0).onChanged { gesture in - viewStore.send( - .tapped(gesture.location), - animation: .interactiveSpring(response: 0.25, dampingFraction: 0.1) - ) - } - ) - .overlay { - GeometryReader { proxy in - Circle() - .fill(viewStore.circleColor) - .colorInvert() - .blendMode(.difference) - .frame(width: 50, height: 50) - .scaleEffect(viewStore.isCircleScaled ? 2 : 1) - .position( - x: viewStore.circleCenter?.x ?? proxy.size.width / 2, - y: viewStore.circleCenter?.y ?? proxy.size.height / 2 - ) - .offset(y: viewStore.circleCenter == nil ? 0 : -44) - } - .allowsHitTesting(false) + VStack(alignment: .leading) { + Text(template: readMe, .body) + .padding() + .gesture( + DragGesture(minimumDistance: 0).onChanged { gesture in + store.send( + .tapped(gesture.location), + animation: .interactiveSpring(response: 0.25, dampingFraction: 0.1) + ) } - Toggle( - "Big mode", - isOn: - viewStore - .binding(get: \.isCircleScaled, send: { .circleScaleToggleChanged($0) }) - .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.1)) ) - .padding() - Button("Rainbow") { viewStore.send(.rainbowButtonTapped, animation: .linear) } - .padding([.horizontal, .bottom]) - Button("Reset") { viewStore.send(.resetButtonTapped) } - .padding([.horizontal, .bottom]) - } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) - .navigationBarTitleDisplayMode(.inline) + .overlay { + GeometryReader { proxy in + Circle() + .fill(store.circleColor) + .colorInvert() + .blendMode(.difference) + .frame(width: 50, height: 50) + .scaleEffect(store.isCircleScaled ? 2 : 1) + .position( + x: store.circleCenter?.x ?? proxy.size.width / 2, + y: store.circleCenter?.y ?? proxy.size.height / 2 + ) + .offset(y: store.circleCenter == nil ? 0 : -44) + } + .allowsHitTesting(false) + } + Toggle( + "Big mode", + isOn: + $store.isCircleScaled.sending(\.circleScaleToggleChanged) + .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.1)) + ) + .padding() + Button("Rainbow") { store.send(.rainbowButtonTapped, animation: .linear) } + .padding([.horizontal, .bottom]) + Button("Reset") { store.send(.resetButtonTapped) } + .padding([.horizontal, .bottom]) } + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationBarTitleDisplayMode(.inline) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift index cc14a93d59ae..950120b1699a 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift @@ -22,6 +22,7 @@ private let readMe = """ @Reducer struct BindingBasics { + @ObservableState struct State: Equatable { var sliderValue = 5.0 var stepCount = 10 @@ -63,51 +64,45 @@ struct BindingBasics { // MARK: - Feature view struct BindingBasicsView: View { - @State var store = Store(initialState: BindingBasics.State()) { + @Bindable var store = Store(initialState: BindingBasics.State()) { BindingBasics() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } + Form { + Section { + AboutView(readMe: readMe) + } - HStack { - TextField( - "Type here", - text: viewStore.binding(get: \.text, send: { .textChanged($0) }) - ) + HStack { + TextField("Type here", text: $store.text.sending(\.textChanged)) .disableAutocorrection(true) - .foregroundStyle(viewStore.toggleIsOn ? Color.secondary : .primary) - Text(alternate(viewStore.text)) - } - .disabled(viewStore.toggleIsOn) + .foregroundStyle(store.toggleIsOn ? Color.secondary : .primary) + Text(alternate(store.text)) + } + .disabled(store.toggleIsOn) - Toggle( - "Disable other controls", - isOn: viewStore.binding(get: \.toggleIsOn, send: { .toggleChanged(isOn: $0) }) - .resignFirstResponder() - ) + Toggle( + "Disable other controls", + isOn: $store.toggleIsOn.sending(\.toggleChanged).resignFirstResponder() + ) + + Stepper( + "Max slider value: \(store.stepCount)", + value: $store.stepCount.sending(\.stepCountChanged), + in: 0...100 + ) + .disabled(store.toggleIsOn) - Stepper( - "Max slider value: \(viewStore.stepCount)", - value: viewStore.binding(get: \.stepCount, send: { .stepCountChanged($0) }), - in: 0...100 + HStack { + Text("Slider value: \(Int(store.sliderValue))") + Slider( + value: $store.sliderValue.sending(\.sliderValueChanged), + in: 0...Double(store.stepCount) ) - .disabled(viewStore.toggleIsOn) - - HStack { - Text("Slider value: \(Int(viewStore.sliderValue))") - Slider( - value: viewStore.binding(get: \.sliderValue, send: { .sliderValueChanged($0) }), - in: 0...Double(viewStore.stepCount) - ) - .tint(.accentColor) - } - .disabled(viewStore.toggleIsOn) + .tint(.accentColor) } + .disabled(store.toggleIsOn) } .monospacedDigit() .navigationTitle("Bindings basics") diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift index 0cc6fcae7a88..d161a10fc520 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift @@ -17,11 +17,12 @@ private let readMe = """ @Reducer struct BindingForm { + @ObservableState struct State: Equatable { - @BindingState var sliderValue = 5.0 - @BindingState var stepCount = 10 - @BindingState var text = "" - @BindingState var toggleIsOn = false + var sliderValue = 5.0 + var stepCount = 10 + var text = "" + var toggleIsOn = false } enum Action: BindableAction { @@ -33,7 +34,7 @@ struct BindingForm { BindingReducer() Reduce { state, action in switch action { - case .binding(\.$stepCount): + case .binding(\.stepCount): state.sliderValue = .minimum(state.sliderValue, Double(state.stepCount)) return .none @@ -51,47 +52,45 @@ struct BindingForm { // MARK: - Feature view struct BindingFormView: View { - @State var store = Store(initialState: BindingForm.State()) { + @Bindable var store = Store(initialState: BindingForm.State()) { BindingForm() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } + Form { + Section { + AboutView(readMe: readMe) + } - HStack { - TextField("Type here", text: viewStore.$text) - .disableAutocorrection(true) - .foregroundStyle(viewStore.toggleIsOn ? Color.secondary : .primary) - Text(alternate(viewStore.text)) - } - .disabled(viewStore.toggleIsOn) + HStack { + TextField("Type here", text: $store.text) + .disableAutocorrection(true) + .foregroundStyle(store.toggleIsOn ? Color.secondary : .primary) + Text(alternate(store.text)) + } + .disabled(store.toggleIsOn) - Toggle("Disable other controls", isOn: viewStore.$toggleIsOn.resignFirstResponder()) + Toggle("Disable other controls", isOn: $store.toggleIsOn.resignFirstResponder()) - Stepper( - "Max slider value: \(viewStore.stepCount)", - value: viewStore.$stepCount, - in: 0...100 - ) - .disabled(viewStore.toggleIsOn) + Stepper( + "Max slider value: \(store.stepCount)", + value: $store.stepCount, + in: 0...100 + ) + .disabled(store.toggleIsOn) - HStack { - Text("Slider value: \(Int(viewStore.sliderValue))") + HStack { + Text("Slider value: \(Int(store.sliderValue))") - Slider(value: viewStore.$sliderValue, in: 0...Double(viewStore.stepCount)) - .tint(.accentColor) - } - .disabled(viewStore.toggleIsOn) + Slider(value: $store.sliderValue, in: 0...Double(store.stepCount)) + .tint(.accentColor) + } + .disabled(store.toggleIsOn) - Button("Reset") { - viewStore.send(.resetButtonTapped) - } - .tint(.red) + Button("Reset") { + store.send(.resetButtonTapped) } + .tint(.red) } .monospacedDigit() .navigationTitle("Bindings form") diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift index e619cc486aad..d284a4f48e5c 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift @@ -11,6 +11,7 @@ private let readMe = """ @Reducer struct TwoCounters { + @ObservableState struct State: Equatable { var counter1 = Counter.State() var counter2 = Counter.State() @@ -34,7 +35,7 @@ struct TwoCounters { // MARK: - Feature view struct TwoCountersView: View { - @State var store = Store(initialState: TwoCounters.State()) { + var store = Store(initialState: TwoCounters.State()) { TwoCounters() } @@ -47,13 +48,13 @@ struct TwoCountersView: View { HStack { Text("Counter 1") Spacer() - CounterView(store: self.store.scope(state: \.counter1, action: \.counter1)) + CounterView(store: store.scope(state: \.counter1, action: \.counter1)) } HStack { Text("Counter 2") Spacer() - CounterView(store: self.store.scope(state: \.counter2, action: \.counter2)) + CounterView(store: store.scope(state: \.counter2, action: \.counter2)) } } .buttonStyle(.borderless) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift index 67a1fce8d5bd..adc1b9fdc7c4 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift @@ -13,6 +13,7 @@ private let readMe = """ @Reducer struct Counter { + @ObservableState struct State: Equatable { var count = 0 } @@ -42,29 +43,27 @@ struct CounterView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - HStack { - Button { - viewStore.send(.decrementButtonTapped) - } label: { - Image(systemName: "minus") - } + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } - Text("\(viewStore.count)") - .monospacedDigit() + Text("\(store.count)") + .monospacedDigit() - Button { - viewStore.send(.incrementButtonTapped) - } label: { - Image(systemName: "plus") - } + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") } } } } struct CounterDemoView: View { - @State var store = Store(initialState: Counter.State()) { + var store = Store(initialState: Counter.State()) { Counter() } @@ -75,7 +74,7 @@ struct CounterDemoView: View { } Section { - CounterView(store: self.store) + CounterView(store: store) .frame(maxWidth: .infinity) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift index d1ca9170084c..ff9e1aeda395 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift @@ -11,10 +11,11 @@ private let readMe = """ @Reducer struct FocusDemo { + @ObservableState struct State: Equatable { - @BindingState var focusedField: Field? - @BindingState var password: String = "" - @BindingState var username: String = "" + var focusedField: Field? + var password: String = "" + var username: String = "" enum Field: String, Hashable { case username, password @@ -48,31 +49,29 @@ struct FocusDemo { // MARK: - Feature view struct FocusDemoView: View { - @State var store = Store(initialState: FocusDemo.State()) { + @Bindable var store = Store(initialState: FocusDemo.State()) { FocusDemo() } @FocusState var focusedField: FocusDemo.State.Field? var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - AboutView(readMe: readMe) + Form { + AboutView(readMe: readMe) - VStack { - TextField("Username", text: viewStore.$username) - .focused($focusedField, equals: .username) - SecureField("Password", text: viewStore.$password) - .focused($focusedField, equals: .password) - Button("Sign In") { - viewStore.send(.signInButtonTapped) - } - .buttonStyle(.borderedProminent) + VStack { + TextField("Username", text: $store.username) + .focused($focusedField, equals: .username) + SecureField("Password", text: $store.password) + .focused($focusedField, equals: .password) + Button("Sign In") { + store.send(.signInButtonTapped) } - .textFieldStyle(.roundedBorder) + .buttonStyle(.borderedProminent) } - // Synchronize store focus state and local focus state. - .bind(viewStore.$focusedField, to: self.$focusedField) + .textFieldStyle(.roundedBorder) } + // Synchronize store focus state and local focus state. + .bind($store.focusedField, to: $focusedField) .navigationTitle("Focus demo") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift index 06a61a9b4382..0bcd1feaaa9b 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift @@ -16,6 +16,7 @@ private let readMe = """ @Reducer struct OptionalBasics { + @ObservableState struct State: Equatable { var optionalCounter: Counter.State? } @@ -47,7 +48,7 @@ struct OptionalBasics { // MARK: - Feature view struct OptionalBasicsView: View { - @State var store = Store(initialState: OptionalBasics.State()) { + var store = Store(initialState: OptionalBasics.State()) { OptionalBasics() } @@ -58,17 +59,15 @@ struct OptionalBasicsView: View { } Button("Toggle counter state") { - self.store.send(.toggleCounterButtonTapped) + store.send(.toggleCounterButtonTapped) } - IfLetStore( - self.store.scope(state: \.optionalCounter, action: \.optionalCounter) - ) { store in + if let store = store.scope(state: \.optionalCounter, action: \.optionalCounter) { Text(template: "`Counter.State` is non-`nil`") CounterView(store: store) .buttonStyle(.borderless) .frame(maxWidth: .infinity) - } else: { + } else { Text(template: "`Counter.State` is `nil`") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift index a7b866740653..0ffedc1cc4e2 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift @@ -17,274 +17,249 @@ private let readMe = """ // MARK: - Feature domain @Reducer -struct SharedState { - enum Tab { case stats, profile } - +struct CounterTab { + @ObservableState struct State: Equatable { - var currentTab = Tab.stats - var stats = Stats.State() - - /// The Profile.State can be derived from the Stats.State by getting and setting the parts it - /// cares about. This allows the profile feature to operate on a subset of app state instead of - /// the whole thing. - var profile: Profile.State { - get { - Profile.State( - currentTab: self.currentTab, - count: self.stats.count, - maxCount: self.stats.maxCount, - minCount: self.stats.minCount, - numberOfCounts: self.stats.numberOfCounts - ) - } - set { - self.currentTab = newValue.currentTab - self.stats.count = newValue.count - self.stats.maxCount = newValue.maxCount - self.stats.minCount = newValue.minCount - self.stats.numberOfCounts = newValue.numberOfCounts - } - } + @Presents var alert: AlertState? + var stats = Stats() } enum Action { - case profile(Profile.Action) - case selectTab(Tab) - case stats(Stats.Action) + case alert(PresentationAction) + case decrementButtonTapped + case incrementButtonTapped + case isPrimeButtonTapped + + enum Alert: Equatable {} } var body: some Reducer { - Scope(state: \.stats, action: \.stats) { - Stats() - } - - Scope(state: \.profile, action: \.profile) { - Profile() - } - Reduce { state, action in switch action { - case .profile, .stats: - return .none - case let .selectTab(tab): - state.currentTab = tab + case .alert: return .none - } - } - } -} - -// MARK: - Feature view -struct SharedStateView: View { - @State var store = Store(initialState: SharedState.State()) { - SharedState() - } - - var body: some View { - WithViewStore(self.store, observe: \.currentTab) { viewStore in - VStack { - Picker("Tab", selection: viewStore.binding(send: { .selectTab($0) })) { - Text("Stats") - .tag(SharedState.Tab.stats) - - Text("Profile") - .tag(SharedState.Tab.profile) - } - .pickerStyle(.segmented) + case .decrementButtonTapped: + state.stats.decrement() + return .none - if viewStore.state == .stats { - StatsView( - store: self.store.scope(state: \.stats, action: \.stats) - ) - } + case .incrementButtonTapped: + state.stats.increment() + return .none - if viewStore.state == .profile { - ProfileView( - store: self.store.scope(state: \.profile, action: \.profile) + case .isPrimeButtonTapped: + state.alert = AlertState { + TextState( + isPrime(state.stats.count) + ? "👍 The number \(state.stats.count) is prime!" + : "👎 The number \(state.stats.count) is not prime :(" ) } - - Spacer() + return .none } } - .padding() + .ifLet(\.$alert, action: \.alert) } } -private struct StatsView: View { - let store: StoreOf +struct CounterTabView: View { + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack(spacing: 64) { - Text(template: readMe, .caption) - - VStack(spacing: 16) { - HStack { - Button { - viewStore.send(.decrementButtonTapped) - } label: { - Image(systemName: "minus") - } - - Text("\(viewStore.count)") - .monospacedDigit() - - Button { - viewStore.send(.incrementButtonTapped) - } label: { - Image(systemName: "plus") - } + Form { + Text(template: readMe, .caption) + + VStack(spacing: 16) { + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") } - Button("Is this prime?") { viewStore.send(.isPrimeButtonTapped) } + Text("\(store.stats.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } } + + Button("Is this prime?") { store.send(.isPrimeButtonTapped) } } - .padding(.top) - .navigationTitle("Shared State Demo") - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) } + .buttonStyle(.borderless) + .navigationTitle("Shared State Demo") + .alert($store.scope(state: \.alert, action: \.alert)) } } -private struct ProfileView: View { - let store: StoreOf +@Reducer +struct ProfileTab { + @ObservableState + struct State: Equatable { + var stats = Stats() + } - var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack(spacing: 64) { - Text( - template: """ - This tab shows state from the previous tab, and it is capable of reseting all of the \ - state back to 0. - - This shows that it is possible for each screen to model its state in the way that makes \ - the most sense for it, while still allowing the state and mutations to be shared \ - across independent screens. - """, - .caption - ) + enum Action { + case resetStatsButtonTapped + } - VStack(spacing: 16) { - Text("Current count: \(viewStore.count)") - Text("Max count: \(viewStore.maxCount)") - Text("Min count: \(viewStore.minCount)") - Text("Total number of count events: \(viewStore.numberOfCounts)") - Button("Reset") { viewStore.send(.resetStatsButtonTapped) } - } + var body: some Reducer { + Reduce { state, action in + switch action { + case .resetStatsButtonTapped: + state.stats.reset() + return .none } - .padding(.top) - .navigationTitle("Profile") } } } -// MARK: - SwiftUI previews +struct ProfileTabView: View { + let store: StoreOf -struct SharedState_Previews: PreviewProvider { - static var previews: some View { - SharedStateView( - store: Store(initialState: SharedState.State()) { - SharedState() + var body: some View { + Form { + Text( + template: """ + This tab shows state from the previous tab, and it is capable of reseting all of the \ + state back to 0. + + This shows that it is possible for each screen to model its state in the way that makes \ + the most sense for it, while still allowing the state and mutations to be shared \ + across independent screens. + """, + .caption + ) + + VStack(spacing: 16) { + Text("Current count: \(store.stats.count)") + Text("Max count: \(store.stats.maxCount)") + Text("Min count: \(store.stats.minCount)") + Text("Total number of count events: \(store.stats.numberOfCounts)") + Button("Reset") { store.send(.resetStatsButtonTapped) } } - ) - } -} - -// MARK: - Private helpers - -/// Checks if a number is prime or not. -private func isPrime(_ p: Int) -> Bool { - if p <= 1 { return false } - if p <= 3 { return true } - for i in 2...Int(sqrtf(Float(p))) { - if p % i == 0 { return false } + } + .buttonStyle(.borderless) + .navigationTitle("Profile") } - return true } @Reducer -struct Stats { +struct SharedState { + enum Tab { case counter, profile } + + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? - var count = 0 - var maxCount = 0 - var minCount = 0 - var numberOfCounts = 0 + var currentTab = Tab.counter + var counter = CounterTab.State() + var profile = ProfileTab.State() } enum Action { - case alert(PresentationAction) - case decrementButtonTapped - case incrementButtonTapped - case isPrimeButtonTapped - - enum Alert: Equatable {} + case counter(CounterTab.Action) + case profile(ProfileTab.Action) + case selectTab(Tab) } var body: some Reducer { - Reduce { state, action in - switch action { - case .alert: + Scope(state: \.counter, action: \.counter) { + CounterTab() + } + .onChange(of: \.counter.stats) { _, stats in + Reduce { state, _ in + state.profile.stats = stats return .none + } + } - case .decrementButtonTapped: - state.count -= 1 - state.numberOfCounts += 1 - state.minCount = min(state.minCount, state.count) + Scope(state: \.profile, action: \.profile) { + ProfileTab() + } + .onChange(of: \.profile.stats) { _, stats in + Reduce { state, _ in + state.counter.stats = stats return .none + } + } - case .incrementButtonTapped: - state.count += 1 - state.numberOfCounts += 1 - state.maxCount = max(state.maxCount, state.count) + Reduce { state, action in + switch action { + case .counter, .profile: return .none - - case .isPrimeButtonTapped: - state.alert = AlertState { - TextState( - isPrime(state.count) - ? "👍 The number \(state.count) is prime!" - : "👎 The number \(state.count) is not prime :(" - ) - } + case let .selectTab(tab): + state.currentTab = tab return .none } } - .ifLet(\.$alert, action: \.alert) } } -@Reducer -struct Profile { - struct State: Equatable { - private(set) var currentTab: SharedState.Tab - private(set) var count = 0 - private(set) var maxCount: Int - private(set) var minCount: Int - private(set) var numberOfCounts: Int - - fileprivate mutating func resetCount() { - self.currentTab = .stats - self.count = 0 - self.maxCount = 0 - self.minCount = 0 - self.numberOfCounts = 0 - } +struct SharedStateView: View { + @State var store = Store(initialState: SharedState.State()) { + SharedState() } - enum Action { - case resetStatsButtonTapped - } + var body: some View { + TabView(selection: $store.currentTab.sending(\.selectTab)) { + NavigationStack { + CounterTabView( + store: self.store.scope(state: \.counter, action: \.counter) + ) + } + .tag(SharedState.Tab.counter) + .tabItem { Text("Counter") } - var body: some Reducer { - Reduce { state, action in - switch action { - case .resetStatsButtonTapped: - state.resetCount() - return .none + NavigationStack { + ProfileTabView( + store: self.store.scope(state: \.profile, action: \.profile) + ) } + .tag(SharedState.Tab.profile) + .tabItem { Text("Profile") } } } } + +struct Stats: Equatable { + private(set) var count = 0 + private(set) var maxCount = 0 + private(set) var minCount = 0 + private(set) var numberOfCounts = 0 + mutating func increment() { + count += 1 + numberOfCounts += 1 + maxCount = max(minCount, count) + } + mutating func decrement() { + count -= 1 + numberOfCounts += 1 + minCount = min(minCount, count) + } + mutating func reset() { + self = Self() + } +} + +// MARK: - SwiftUI previews + +struct SharedState_Previews: PreviewProvider { + static var previews: some View { + SharedStateView() + } +} + +// MARK: - Private helpers + +/// Checks if a number is prime or not. +private func isPrime(_ p: Int) -> Bool { + if p <= 1 { return false } + if p <= 3 { return true } + for i in 2...Int(sqrtf(Float(p))) { + if p % i == 0 { return false } + } + return true +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift index f822e5fd7711..f4d632be3592 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift @@ -22,6 +22,7 @@ private let readMe = """ @Reducer struct EffectsBasics { + @ObservableState struct State: Equatable { var count = 0 var isNumberFactRequestInFlight = false @@ -94,63 +95,61 @@ struct EffectsBasics { // MARK: - Feature view struct EffectsBasicsView: View { - @State var store = Store(initialState: EffectsBasics.State()) { + var store = Store(initialState: EffectsBasics.State()) { EffectsBasics() } @Environment(\.openURL) var openURL var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } + Form { + Section { + AboutView(readMe: readMe) + } - Section { - HStack { - Button { - viewStore.send(.decrementButtonTapped) - } label: { - Image(systemName: "minus") - } - - Text("\(viewStore.count)") - .monospacedDigit() - - Button { - viewStore.send(.incrementButtonTapped) - } label: { - Image(systemName: "plus") - } + Section { + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") } - .frame(maxWidth: .infinity) - Button("Number fact") { viewStore.send(.numberFactButtonTapped) } - .frame(maxWidth: .infinity) + Text("\(store.count)") + .monospacedDigit() - if viewStore.isNumberFactRequestInFlight { - ProgressView() - .frame(maxWidth: .infinity) - // NB: There seems to be a bug in SwiftUI where the progress view does not show - // a second time unless it is given a new identity. - .id(UUID()) - } - - if let numberFact = viewStore.numberFact { - Text(numberFact) + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") } } + .frame(maxWidth: .infinity) - Section { - Button("Number facts provided by numbersapi.com") { - self.openURL(URL(string: "http://numbersapi.com")!) - } - .foregroundStyle(.secondary) + Button("Number fact") { store.send(.numberFactButtonTapped) } .frame(maxWidth: .infinity) + + if store.isNumberFactRequestInFlight { + ProgressView() + .frame(maxWidth: .infinity) + // NB: There seems to be a bug in SwiftUI where the progress view does not show + // a second time unless it is given a new identity. + .id(UUID()) + } + + if let numberFact = store.numberFact { + Text(numberFact) + } + } + + Section { + Button("Number facts provided by numbersapi.com") { + openURL(URL(string: "http://numbersapi.com")!) } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) } - .buttonStyle(.borderless) } + .buttonStyle(.borderless) .navigationTitle("Effects") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift index 07f0f292f13f..41bfd662714e 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift @@ -16,6 +16,7 @@ private let readMe = """ @Reducer struct EffectsCancellation { + @ObservableState struct State: Equatable { var count = 0 var currentFact: String? @@ -70,53 +71,48 @@ struct EffectsCancellation { // MARK: - Feature view struct EffectsCancellationView: View { - @State var store = Store(initialState: EffectsCancellation.State()) { + @Bindable var store = Store(initialState: EffectsCancellation.State()) { EffectsCancellation() } @Environment(\.openURL) var openURL var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } + Form { + Section { + AboutView(readMe: readMe) + } - Section { - Stepper( - "\(viewStore.count)", - value: viewStore.binding(get: \.count, send: { .stepperChanged($0) }) - ) - - if viewStore.isFactRequestInFlight { - HStack { - Button("Cancel") { viewStore.send(.cancelButtonTapped) } - Spacer() - ProgressView() - // NB: There seems to be a bug in SwiftUI where the progress view does not show - // a second time unless it is given a new identity. - .id(UUID()) - } - } else { - Button("Number fact") { viewStore.send(.factButtonTapped) } - .disabled(viewStore.isFactRequestInFlight) + Section { + Stepper("\(store.count)", value: $store.count.sending(\.stepperChanged)) + + if store.isFactRequestInFlight { + HStack { + Button("Cancel") { store.send(.cancelButtonTapped) } + Spacer() + ProgressView() + // NB: There seems to be a bug in SwiftUI where the progress view does not show + // a second time unless it is given a new identity. + .id(UUID()) } + } else { + Button("Number fact") { store.send(.factButtonTapped) } + .disabled(store.isFactRequestInFlight) + } - viewStore.currentFact.map { - Text($0).padding(.vertical, 8) - } + if let fact = store.currentFact { + Text(fact).padding(.vertical, 8) } + } - Section { - Button("Number facts provided by numbersapi.com") { - self.openURL(URL(string: "http://numbersapi.com")!) - } - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) + Section { + Button("Number facts provided by numbersapi.com") { + self.openURL(URL(string: "http://numbersapi.com")!) } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) } - .buttonStyle(.borderless) } + .buttonStyle(.borderless) .navigationTitle("Effect cancellation") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift index 41c69f593a8a..ea301997bf95 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift @@ -18,6 +18,7 @@ private let readMe = """ @Reducer struct LongLivingEffects { + @ObservableState struct State: Equatable { var screenshotCount = 0 } @@ -68,29 +69,29 @@ private enum ScreenshotsKey: DependencyKey { // MARK: - Feature view struct LongLivingEffectsView: View { - @State var store = Store(initialState: LongLivingEffects.State()) { + var store = Store(initialState: LongLivingEffects.State()) { LongLivingEffects() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } + Form { + Section { + AboutView(readMe: readMe) + } - Text("A screenshot of this screen has been taken \(viewStore.screenshotCount) times.") - .font(.headline) + Text("A screenshot of this screen has been taken \(store.screenshotCount) times.") + .font(.headline) - Section { - NavigationLink(destination: self.detailView) { - Text("Navigate to another screen") - } + Section { + NavigationLink { + detailView + } label: { + Text("Navigate to another screen") } } - .navigationTitle("Long-living effects") - .task { await viewStore.send(.task).finish() } } + .navigationTitle("Long-living effects") + .task { await store.send(.task).finish() } } var detailView: some View { diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift index 054f1de28ac6..79aaf1625522 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift @@ -15,6 +15,7 @@ private let readMe = """ @Reducer struct Refreshable { + @ObservableState struct State: Equatable { var count = 0 var fact: String? @@ -70,53 +71,51 @@ struct Refreshable { // MARK: - Feature view struct RefreshableView: View { - @State var store = Store(initialState: Refreshable.State()) { + var store = Store(initialState: Refreshable.State()) { Refreshable() } @State var isLoading = false var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - List { - Section { - AboutView(readMe: readMe) - } + List { + Section { + AboutView(readMe: readMe) + } - HStack { - Button { - viewStore.send(.decrementButtonTapped) - } label: { - Image(systemName: "minus") - } - - Text("\(viewStore.count)") - .monospacedDigit() - - Button { - viewStore.send(.incrementButtonTapped) - } label: { - Image(systemName: "plus") - } + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") } - .frame(maxWidth: .infinity) - .buttonStyle(.borderless) - if let fact = viewStore.fact { - Text(fact) - .bold() - } - if self.isLoading { - Button("Cancel") { - viewStore.send(.cancelButtonTapped, animation: .default) - } + Text("\(store.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") } } - .refreshable { - self.isLoading = true - defer { self.isLoading = false } - await viewStore.send(.refresh).finish() + .frame(maxWidth: .infinity) + .buttonStyle(.borderless) + + if let fact = store.fact { + Text(fact) + .bold() + } + if self.isLoading { + Button("Cancel") { + store.send(.cancelButtonTapped, animation: .default) + } } } + .refreshable { + isLoading = true + defer { isLoading = false } + await store.send(.refresh).finish() + } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift index f0e9ebb4be1a..ab44e8b40844 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift @@ -13,6 +13,7 @@ private let readMe = """ @Reducer struct Timers { + @ObservableState struct State: Equatable { var isTimerActive = false var secondsElapsed = 0 @@ -54,67 +55,65 @@ struct Timers { // MARK: - Feature view struct TimersView: View { - @State var store = Store(initialState: Timers.State()) { + var store = Store(initialState: Timers.State()) { Timers() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - AboutView(readMe: readMe) - - ZStack { - Circle() - .fill( - AngularGradient( - gradient: Gradient( - colors: [ - .blue.opacity(0.3), - .blue, - .blue, - .green, - .green, - .yellow, - .yellow, - .red, - .red, - .purple, - .purple, - .purple.opacity(0.3), - ] - ), - center: .center - ) + Form { + AboutView(readMe: readMe) + + ZStack { + Circle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [ + .blue.opacity(0.3), + .blue, + .blue, + .green, + .green, + .yellow, + .yellow, + .red, + .red, + .purple, + .purple, + .purple.opacity(0.3), + ] + ), + center: .center ) - .rotationEffect(.degrees(-90)) - GeometryReader { proxy in - Path { path in - path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) - path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0)) - } - .stroke(.primary, lineWidth: 3) - .rotationEffect(.degrees(Double(viewStore.secondsElapsed) * 360 / 60)) + ) + .rotationEffect(.degrees(-90)) + GeometryReader { proxy in + Path { path in + path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) + path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0)) } + .stroke(.primary, lineWidth: 3) + .rotationEffect(.degrees(Double(store.secondsElapsed) * 360 / 60)) } - .aspectRatio(1, contentMode: .fit) - .frame(maxWidth: 280) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - - Button { - viewStore.send(.toggleTimerButtonTapped) - } label: { - Text(viewStore.isTimerActive ? "Stop" : "Start") - .padding(8) - } - .frame(maxWidth: .infinity) - .tint(viewStore.isTimerActive ? Color.red : .accentColor) - .buttonStyle(.borderedProminent) } - .navigationTitle("Timers") - .onDisappear { - viewStore.send(.onDisappear) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 280) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + + Button { + store.send(.toggleTimerButtonTapped) + } label: { + Text(store.isTimerActive ? "Stop" : "Start") + .padding(8) } + .frame(maxWidth: .infinity) + .tint(store.isTimerActive ? Color.red : .accentColor) + .buttonStyle(.borderedProminent) + } + .navigationTitle("Timers") + .onDisappear { + store.send(.onDisappear) } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift index 46cf0e852d88..20a7b02ce315 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift @@ -13,8 +13,9 @@ private let readMe = """ @Reducer struct WebSocket { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? var connectivityState = ConnectivityState.disconnected var messageToSend = "" var receivedMessages: [String] = [] @@ -140,57 +141,55 @@ struct WebSocket { // MARK: - Feature view struct WebSocketView: View { - @State var store = Store(initialState: WebSocket.State()) { + @Bindable var store = Store(initialState: WebSocket.State()) { WebSocket() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } + Form { + Section { + AboutView(readMe: readMe) + } - Section { - VStack(alignment: .leading) { - Button( - viewStore.connectivityState == .connected - ? "Disconnect" - : viewStore.connectivityState == .disconnected - ? "Connect" - : "Connecting..." - ) { - viewStore.send(.connectButtonTapped) - } - .buttonStyle(.bordered) - .tint(viewStore.connectivityState == .connected ? .red : .green) - - HStack { - TextField( - "Type message here", - text: viewStore.binding(get: \.messageToSend, send: { .messageToSendChanged($0) }) - ) - .textFieldStyle(.roundedBorder) - - Button("Send") { - viewStore.send(.sendButtonTapped) - } - .buttonStyle(.borderless) + Section { + VStack(alignment: .leading) { + Button( + store.connectivityState == .connected + ? "Disconnect" + : store.connectivityState == .disconnected + ? "Connect" + : "Connecting..." + ) { + store.send(.connectButtonTapped) + } + .buttonStyle(.bordered) + .tint(store.connectivityState == .connected ? .red : .green) + + HStack { + TextField( + "Type message here", + text: $store.messageToSend.sending(\.messageToSendChanged) + ) + .textFieldStyle(.roundedBorder) + + Button("Send") { + store.send(.sendButtonTapped) } + .buttonStyle(.borderless) } } + } - Section { - Text("Status: \(viewStore.connectivityState.rawValue)") - .foregroundStyle(.secondary) - Text(viewStore.receivedMessages.reversed().joined(separator: "\n")) - } header: { - Text("Received messages") - } + Section { + Text("Status: \(store.connectivityState.rawValue)") + .foregroundStyle(.secondary) + Text(store.receivedMessages.reversed().joined(separator: "\n")) + } header: { + Text("Received messages") } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) - .navigationTitle("Web Socket") } + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationTitle("Web Socket") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift index 1804665a5728..b36c2b3b4f2f 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift @@ -74,7 +74,7 @@ struct NavigateAndLoadList { // MARK: - Feature view struct NavigateAndLoadListView: View { - @State var store = Store(initialState: NavigateAndLoadList.State()) { + @Bindable var store = Store(initialState: NavigateAndLoadList.State()) { NavigateAndLoadList() } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift index 2cbf18b1beb8..a2da6f8b29f8 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift @@ -10,6 +10,7 @@ private let readMe = """ struct MultipleDestinations { @Reducer public struct Destination { + @ObservableState public enum State: Equatable { case drillDown(Counter.State) case popover(Counter.State) @@ -35,8 +36,9 @@ struct MultipleDestinations { } } + @ObservableState struct State: Equatable { - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { @@ -69,41 +71,39 @@ struct MultipleDestinations { } struct MultipleDestinationsView: View { - @State var store = Store(initialState: MultipleDestinations.State()) { + @Bindable var store = Store(initialState: MultipleDestinations.State()) { MultipleDestinations() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } - Button("Show drill-down") { - viewStore.send(.showDrillDown) - } - Button("Show popover") { - viewStore.send(.showPopover) - } - Button("Show sheet") { - viewStore.send(.showSheet) - } + Form { + Section { + AboutView(readMe: readMe) } - .navigationDestination( - store: self.store.scope(state: \.$destination.drillDown, action: \.destination.drillDown) - ) { store in - CounterView(store: store) + Button("Show drill-down") { + store.send(.showDrillDown) } - .popover( - store: self.store.scope(state: \.$destination.popover, action: \.destination.popover) - ) { store in - CounterView(store: store) + Button("Show popover") { + store.send(.showPopover) } - .sheet( - store: self.store.scope(state: \.$destination.sheet, action: \.destination.sheet) - ) { store in - CounterView(store: store) + Button("Show sheet") { + store.send(.showSheet) } } + .navigationDestination( + item: $store.scope(state: \.destination?.drillDown, action: \.destination.drillDown) + ) { store in + CounterView(store: store) + } + .popover( + item: $store.scope(state: \.destination?.popover, action: \.destination.popover) + ) { store in + CounterView(store: store) + } + .sheet( + item: $store.scope(state: \.destination?.sheet, action: \.destination.sheet) + ) { store in + CounterView(store: store) + } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift index 6229fff2ba20..c831ef42aba8 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift @@ -12,6 +12,7 @@ private let readMe = """ @Reducer struct NavigateAndLoad { + @ObservableState struct State: Equatable { var isNavigationActive = false var optionalCounter: Counter.State? @@ -59,30 +60,23 @@ struct NavigateAndLoad { // MARK: - Feature view struct NavigateAndLoadView: View { - @State var store = Store(initialState: NavigateAndLoad.State()) { + @Bindable var store = Store(initialState: NavigateAndLoad.State()) { NavigateAndLoad() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } - NavigationLink( - "Load optional counter", - isActive: viewStore.binding( - get: \.isNavigationActive, - send: { .setNavigation(isActive: $0) } - ) - ) { - IfLetStore( - self.store.scope(state: \.optionalCounter, action: \.optionalCounter) - ) { - CounterView(store: $0) - } else: { - ProgressView() - } + Form { + Section { + AboutView(readMe: readMe) + } + NavigationLink( + "Load optional counter", + isActive: $store.isNavigationActive.sending(\.setNavigation) + ) { + if let store = store.scope(state: \.optionalCounter, action: \.optionalCounter) { + CounterView(store: store) + } else { + ProgressView() } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift index 26c3a27d2ea1..93af9f1ffcbc 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift @@ -13,8 +13,9 @@ private let readMe = """ @Reducer struct LoadThenPresent { + @ObservableState struct State: Equatable { - @PresentationState var counter: Counter.State? + @Presents var counter: Counter.State? var isActivityIndicatorVisible = false } @@ -55,33 +56,31 @@ struct LoadThenPresent { // MARK: - Feature view struct LoadThenPresentView: View { - @State var store = Store(initialState: LoadThenPresent.State()) { + @Bindable var store = Store(initialState: LoadThenPresent.State()) { LoadThenPresent() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } - Button { - viewStore.send(.counterButtonTapped) - } label: { - HStack { - Text("Load optional counter") - if viewStore.isActivityIndicatorVisible { - Spacer() - ProgressView() - } + Form { + Section { + AboutView(readMe: readMe) + } + Button { + store.send(.counterButtonTapped) + } label: { + HStack { + Text("Load optional counter") + if store.isActivityIndicatorVisible { + Spacer() + ProgressView() } } } - .sheet(store: self.store.scope(state: \.$counter, action: \.counter)) { store in - CounterView(store: store) - } - .navigationTitle("Load and present") } + .sheet(item: $store.scope(state: \.counter, action: \.counter)) { store in + CounterView(store: store) + } + .navigationTitle("Load and present") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift index c35d715be7a0..7da1bff07eba 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift @@ -12,6 +12,7 @@ private let readMe = """ @Reducer struct PresentAndLoad { + @ObservableState struct State: Equatable { var optionalCounter: Counter.State? var isSheetPresented = false @@ -59,34 +60,27 @@ struct PresentAndLoad { // MARK: - Feature view struct PresentAndLoadView: View { - @State var store = Store(initialState: PresentAndLoad.State()) { + @Bindable var store = Store(initialState: PresentAndLoad.State()) { PresentAndLoad() } var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } - Button("Load optional counter") { - viewStore.send(.setSheet(isPresented: true)) - } + Form { + Section { + AboutView(readMe: readMe) } - .sheet( - isPresented: viewStore.binding( - get: \.isSheetPresented, - send: { .setSheet(isPresented: $0) } - ) - ) { - IfLetStore(self.store.scope(state: \.optionalCounter, action: \.optionalCounter)) { - CounterView(store: $0) - } else: { - ProgressView() - } + Button("Load optional counter") { + store.send(.setSheet(isPresented: true)) + } + } + .sheet(isPresented: $store.isSheetPresented.sending(\.setSheet)) { + if let store = store.scope(state: \.optionalCounter, action: \.optionalCounter) { + CounterView(store: store) + } else { + ProgressView() } - .navigationTitle("Present and load") } + .navigationTitle("Present and load") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift index 9701d420c055..dcd29d5bfd50 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift @@ -7,6 +7,7 @@ private let readMe = """ @Reducer struct NavigationDemo { + @ObservableState struct State: Equatable { var path = StackState() } @@ -61,6 +62,7 @@ struct NavigationDemo { @Reducer struct Path { + @ObservableState enum State: Codable, Equatable, Hashable { case screenA(ScreenA.State = .init()) case screenB(ScreenB.State = .init()) @@ -88,12 +90,12 @@ struct NavigationDemo { } struct NavigationDemoView: View { - @State var store = Store(initialState: NavigationDemo.State()) { + @Bindable var store = Store(initialState: NavigationDemo.State()) { NavigationDemo() } var body: some View { - NavigationStackStore(self.store.scope(state: \.path, action: \.path)) { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { Form { Section { Text(template: readMe) } @@ -114,35 +116,29 @@ struct NavigationDemoView: View { Section { Button("Go to A → B → C") { - self.store.send(.goToABCButtonTapped) + store.send(.goToABCButtonTapped) } } } .navigationTitle("Root") - } destination: { - switch $0 { + } destination: { store in + switch store.state { case .screenA: - CaseLet( - \NavigationDemo.Path.State.screenA, - action: NavigationDemo.Path.Action.screenA, - then: ScreenAView.init(store:) - ) + if let store = store.scope(state: \.screenA, action: \.screenA) { + ScreenAView(store: store) + } case .screenB: - CaseLet( - \NavigationDemo.Path.State.screenB, - action: NavigationDemo.Path.Action.screenB, - then: ScreenBView.init(store:) - ) + if let store = store.scope(state: \.screenB, action: \.screenB) { + ScreenBView(store: store) + } case .screenC: - CaseLet( - \NavigationDemo.Path.State.screenC, - action: NavigationDemo.Path.Action.screenC, - then: ScreenCView.init(store:) - ) + if let store = store.scope(state: \.screenC, action: \.screenC) { + ScreenCView(store: store) + } } } .safeAreaInset(edge: .bottom) { - FloatingMenuView(store: self.store) + FloatingMenuView(store: store) } .navigationTitle("Navigation Stack") } @@ -180,32 +176,31 @@ struct FloatingMenuView: View { } var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - if viewStore.currentStack.count > 0 { - VStack(alignment: .center) { - Text("Total count: \(viewStore.total)") - Button("Pop to root") { - viewStore.send(.popToRoot, animation: .default) - } - Menu("Current stack") { - ForEach(viewStore.currentStack) { screen in - Button("\(String(describing: screen.id))) \(screen.name)") { - viewStore.send(.goBackToScreen(id: screen.id)) - } - .disabled(screen == viewStore.currentStack.first) - } - Button("Root") { - viewStore.send(.popToRoot, animation: .default) + let viewState = ViewState(state: store.state) + if viewState.currentStack.count > 0 { + VStack(alignment: .center) { + Text("Total count: \(viewState.total)") + Button("Pop to root") { + store.send(.popToRoot, animation: .default) + } + Menu("Current stack") { + ForEach(viewState.currentStack) { screen in + Button("\(String(describing: screen.id))) \(screen.name)") { + store.send(.goBackToScreen(id: screen.id)) } + .disabled(screen == viewState.currentStack.first) + } + Button("Root") { + store.send(.popToRoot, animation: .default) } } - .padding() - .background(Color(.systemBackground)) - .padding(.bottom, 1) - .transition(.opacity.animation(.default)) - .clipped() - .shadow(color: .black.opacity(0.2), radius: 5, y: 5) } + .padding() + .background(Color(.systemBackground)) + .padding(.bottom, 1) + .transition(.opacity.animation(.default)) + .clipped() + .shadow(color: .black.opacity(0.2), radius: 5, y: 5) } } } @@ -214,6 +209,7 @@ struct FloatingMenuView: View { @Reducer struct ScreenA { + @ObservableState struct State: Codable, Equatable, Hashable { var count = 0 var fact: String? @@ -271,72 +267,70 @@ struct ScreenAView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Text( - """ - This screen demonstrates a basic feature hosted in a navigation stack. + Form { + Text( + """ + This screen demonstrates a basic feature hosted in a navigation stack. - You can also have the child feature dismiss itself, which will communicate back to the \ - root stack view to pop the feature off the stack. - """ - ) + You can also have the child feature dismiss itself, which will communicate back to the \ + root stack view to pop the feature off the stack. + """ + ) - Section { - HStack { - Text("\(viewStore.count)") - Spacer() - Button { - viewStore.send(.decrementButtonTapped) - } label: { - Image(systemName: "minus") - } - Button { - viewStore.send(.incrementButtonTapped) - } label: { - Image(systemName: "plus") - } + Section { + HStack { + Text("\(store.count)") + Spacer() + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") } - .buttonStyle(.borderless) - Button { - viewStore.send(.factButtonTapped) + store.send(.incrementButtonTapped) } label: { - HStack { - Text("Get fact") - if viewStore.isLoading { - Spacer() - ProgressView() - } - } + Image(systemName: "plus") } + } + .buttonStyle(.borderless) - if let fact = viewStore.fact { - Text(fact) + Button { + store.send(.factButtonTapped) + } label: { + HStack { + Text("Get fact") + if store.isLoading { + Spacer() + ProgressView() + } } } - Section { - Button("Dismiss") { - viewStore.send(.dismissButtonTapped) - } + if let fact = store.fact { + Text(fact) } + } - Section { - NavigationLink( - "Go to screen A", - state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count)) - ) - NavigationLink( - "Go to screen B", - state: NavigationDemo.Path.State.screenB() - ) - NavigationLink( - "Go to screen C", - state: NavigationDemo.Path.State.screenC(.init(count: viewStore.count)) - ) + Section { + Button("Dismiss") { + store.send(.dismissButtonTapped) } } + + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA(.init(count: store.count)) + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB() + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC(.init(count: store.count)) + ) + } } .navigationTitle("Screen A") } @@ -346,6 +340,7 @@ struct ScreenAView: View { @Reducer struct ScreenB { + @ObservableState struct State: Codable, Equatable, Hashable {} enum Action { @@ -383,13 +378,13 @@ struct ScreenBView: View { ) } Button("Decoupled navigation to screen A") { - self.store.send(.screenAButtonTapped) + store.send(.screenAButtonTapped) } Button("Decoupled navigation to screen B") { - self.store.send(.screenBButtonTapped) + store.send(.screenBButtonTapped) } Button("Decoupled navigation to screen C") { - self.store.send(.screenCButtonTapped) + store.send(.screenCButtonTapped) } } .navigationTitle("Screen B") @@ -400,6 +395,7 @@ struct ScreenBView: View { @Reducer struct ScreenC { + @ObservableState struct State: Codable, Equatable, Hashable { var count = 0 var isTimerRunning = false @@ -443,40 +439,38 @@ struct ScreenCView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Text( - """ - This screen demonstrates that if you start a long-living effects in a stack, then it \ - will automatically be torn down when the screen is dismissed. - """ - ) - Section { - Text("\(viewStore.count)") - if viewStore.isTimerRunning { - Button("Stop timer") { viewStore.send(.stopButtonTapped) } - } else { - Button("Start timer") { viewStore.send(.startButtonTapped) } - } + Form { + Text( + """ + This screen demonstrates that if you start a long-living effects in a stack, then it \ + will automatically be torn down when the screen is dismissed. + """ + ) + Section { + Text("\(store.count)") + if store.isTimerRunning { + Button("Stop timer") { store.send(.stopButtonTapped) } + } else { + Button("Start timer") { store.send(.startButtonTapped) } } + } - Section { - NavigationLink( - "Go to screen A", - state: NavigationDemo.Path.State.screenA(ScreenA.State(count: viewStore.count)) - ) - NavigationLink( - "Go to screen B", - state: NavigationDemo.Path.State.screenB() - ) - NavigationLink( - "Go to screen C", - state: NavigationDemo.Path.State.screenC() - ) - } + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA(ScreenA.State(count: store.count)) + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB() + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC() + ) } - .navigationTitle("Screen C") } + .navigationTitle("Screen C") } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift index 6c42b6c4574f..00ef90d2dbb4 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift @@ -12,6 +12,7 @@ private let readMe = """ @Reducer struct Nested { + @ObservableState struct State: Equatable, Identifiable { let id: UUID var name: String = "" @@ -55,41 +56,35 @@ struct Nested { // MARK: - Feature view struct NestedView: View { - @State var store = Store(initialState: Nested.State(id: UUID())) { + @Bindable var store = Store(initialState: Nested.State(id: UUID())) { Nested() } var body: some View { - WithViewStore(self.store, observe: \.name) { viewStore in - Form { - Section { - AboutView(readMe: readMe) - } + Form { + Section { + AboutView(readMe: readMe) + } - ForEachStore(self.store.scope(state: \.rows, action: \.rows)) { rowStore in - WithViewStore(rowStore, observe: \.name) { rowViewStore in - NavigationLink( - destination: NestedView(store: rowStore) - ) { - HStack { - TextField( - "Untitled", - text: rowViewStore.binding(send: { .nameTextFieldChanged($0) }) - ) - Text("Next") - .font(.callout) - .foregroundStyle(.secondary) - } - } + ForEach(store.scope(state: \.rows, action: \.rows)) { rowStore in + @Bindable var rowStore = rowStore + NavigationLink { + NestedView(store: rowStore) + } label: { + HStack { + TextField("Untitled", text: $rowStore.name.sending(\.nameTextFieldChanged)) + Text("Next") + .font(.callout) + .foregroundStyle(.secondary) } } - .onDelete { viewStore.send(.onDelete($0)) } } - .navigationTitle(viewStore.state.isEmpty ? "Untitled" : viewStore.state) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Add row") { viewStore.send(.addRowButtonTapped) } - } + .onDelete { store.send(.onDelete($0)) } + } + .navigationTitle(store.name.isEmpty ? "Untitled" : store.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Add row") { store.send(.addRowButtonTapped) } } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift index 068f122293fd..9ad101fd4864 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift @@ -27,13 +27,11 @@ struct DownloadComponent { Reduce { state, action in switch action { case .alert(.presented(.deleteButtonTapped)): - state.alert = nil state.mode = .notDownloaded return .none case .alert(.presented(.stopButtonTapped)): state.mode = .notDownloaded - state.alert = nil return .cancel(id: state.id) case .alert: diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift index 19ad34708f05..99fd5e971c56 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift @@ -153,7 +153,7 @@ struct MapApp { } struct CitiesView: View { - @State var store = Store(initialState: MapApp.State(cityMaps: .mocks)) { + var store = Store(initialState: MapApp.State(cityMaps: .mocks)) { MapApp() } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift index d82badb8f663..0fffaac82470 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -156,7 +156,7 @@ struct Episodes { } struct EpisodesView: View { - @State var store = Store(initialState: Episodes.State(episodes: .mocks)) { + var store = Store(initialState: Episodes.State(episodes: .mocks)) { Episodes(favorite: favorite(id:isFavorite:)) } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift index f76874770129..2de902e00a9d 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift @@ -10,17 +10,17 @@ final class BindingFormTests: XCTestCase { BindingForm() } - await store.send(.set(\.$sliderValue, 2)) { + await store.send(.set(\.sliderValue, 2)) { $0.sliderValue = 2 } - await store.send(.set(\.$stepCount, 1)) { + await store.send(.set(\.stepCount, 1)) { $0.sliderValue = 1 $0.stepCount = 1 } - await store.send(.set(\.$text, "Blob")) { + await store.send(.set(\.text, "Blob")) { $0.text = "Blob" } - await store.send(.set(\.$toggleIsOn, true)) { + await store.send(.set(\.toggleIsOn, true)) { $0.toggleIsOn = true } await store.send(.resetButtonTapped) { diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift index c49a642d4b66..9eed7e3c8d4c 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift @@ -5,25 +5,6 @@ import XCTest @MainActor final class SharedStateTests: XCTestCase { - func testTabRestoredOnReset() async { - let store = TestStore(initialState: SharedState.State()) { - SharedState() - } - - await store.send(.selectTab(.profile)) { - $0.currentTab = .profile - $0.profile = Profile.State( - currentTab: .profile, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 - ) - } - await store.send(.profile(.resetStatsButtonTapped)) { - $0.currentTab = .stats - $0.profile = Profile.State( - currentTab: .stats, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 - ) - } - } - func testTabSelection() async { let store = TestStore(initialState: SharedState.State()) { SharedState() @@ -31,15 +12,9 @@ final class SharedStateTests: XCTestCase { await store.send(.selectTab(.profile)) { $0.currentTab = .profile - $0.profile = Profile.State( - currentTab: .profile, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 - ) } - await store.send(.selectTab(.stats)) { - $0.currentTab = .stats - $0.profile = Profile.State( - currentTab: .stats, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 - ) + await store.send(.selectTab(.counter)) { + $0.currentTab = .counter } } @@ -48,57 +23,29 @@ final class SharedStateTests: XCTestCase { SharedState() } - await store.send(.stats(.incrementButtonTapped)) { - $0.stats.count = 1 - $0.stats.maxCount = 1 - $0.stats.numberOfCounts = 1 + await store.send(.counter(.incrementButtonTapped)) { + $0.counter.stats.increment() + $0.profile.stats.increment() } - await store.send(.stats(.decrementButtonTapped)) { - $0.stats.count = 0 - $0.stats.numberOfCounts = 2 + await store.send(.counter(.decrementButtonTapped)) { + $0.counter.stats.decrement() + $0.profile.stats.decrement() } - await store.send(.stats(.decrementButtonTapped)) { - $0.stats.count = -1 - $0.stats.minCount = -1 - $0.stats.numberOfCounts = 3 - } - } - - func testIsPrimeWhenPrime() async { - let store = TestStore( - initialState: Stats.State( - alert: nil, count: 3, maxCount: 0, minCount: 0, numberOfCounts: 0 - ) - ) { - Stats() - } - - await store.send(.isPrimeButtonTapped) { - $0.alert = AlertState { - TextState("👍 The number 3 is prime!") - } - } - await store.send(.alert(.dismiss)) { - $0.alert = nil + await store.send(.profile(.resetStatsButtonTapped)) { + $0.counter.stats = Stats() + $0.profile.stats = Stats() } } - func testIsPrimeWhenNotPrime() async { - let store = TestStore( - initialState: Stats.State( - alert: nil, count: 6, maxCount: 0, minCount: 0, numberOfCounts: 0 - ) - ) { - Stats() + func testAlert() async { + let store = TestStore(initialState: SharedState.State()) { + SharedState() } - await store.send(.isPrimeButtonTapped) { - $0.alert = AlertState { - TextState("👎 The number 6 is not prime :(") + await store.send(.counter(.isPrimeButtonTapped)) { + $0.counter.alert = AlertState { + TextState("👎 The number 0 is not prime :(") } } - await store.send(.alert(.dismiss)) { - $0.alert = nil - } } } diff --git a/Examples/CaseStudies/UIKitCaseStudies/CounterViewController.swift b/Examples/CaseStudies/UIKitCaseStudies/CounterViewController.swift index 4404014767b6..fcfae8c6df66 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/CounterViewController.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/CounterViewController.swift @@ -1,10 +1,9 @@ -import Combine import ComposableArchitecture -import SwiftUI import UIKit @Reducer struct Counter { + @ObservableState struct State: Equatable, Identifiable { let id = UUID() var count = 0 @@ -31,7 +30,6 @@ struct Counter { final class CounterViewController: UIViewController { let store: StoreOf - private var cancellables: Set = [] init(store: StoreOf) { self.store = store @@ -45,7 +43,7 @@ final class CounterViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - self.view.backgroundColor = .systemBackground + view.backgroundColor = .systemBackground let decrementButton = UIButton(type: .system) decrementButton.addTarget(self, action: #selector(decrementButtonTapped), for: .touchUpInside) @@ -64,35 +62,32 @@ final class CounterViewController: UIViewController { incrementButton, ]) rootStackView.translatesAutoresizingMaskIntoConstraints = false - self.view.addSubview(rootStackView) + view.addSubview(rootStackView) NSLayoutConstraint.activate([ - rootStackView.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - rootStackView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), + rootStackView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), ]) - self.store.publisher - .map { "\($0.count)" } - .assign(to: \.text, on: countLabel) - .store(in: &self.cancellables) + observe { [weak self] in + guard let self else { return } + countLabel.text = "\(store.count)" + } } @objc func decrementButtonTapped() { - self.store.send(.decrementButtonTapped) + store.send(.decrementButtonTapped) } @objc func incrementButtonTapped() { - self.store.send(.incrementButtonTapped) + store.send(.incrementButtonTapped) } } -struct CounterViewController_Previews: PreviewProvider { - static var previews: some View { - let vc = CounterViewController( - store: Store(initialState: Counter.State()) { - Counter() - } - ) - return UIViewRepresented(makeUIView: { _ in vc.view }) - } +#Preview { + CounterViewController( + store: Store(initialState: Counter.State()) { + Counter() + } + ) } diff --git a/Examples/CaseStudies/UIKitCaseStudies/Internal/UIViewRepresented.swift b/Examples/CaseStudies/UIKitCaseStudies/Internal/UIViewRepresented.swift deleted file mode 100644 index 3b5ce85f2d1d..000000000000 --- a/Examples/CaseStudies/UIKitCaseStudies/Internal/UIViewRepresented.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -struct UIViewRepresented: UIViewRepresentable { - let makeUIView: (Context) -> UIViewType - let updateUIView: (UIViewType, Context) -> Void = { _, _ in } - - func makeUIView(context: Context) -> UIViewType { - self.makeUIView(context) - } - - func updateUIView(_ uiView: UIViewType, context: Context) { - self.updateUIView(uiView, context) - } -} diff --git a/Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift b/Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift index 6036cdcf86bd..608db316e88f 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift @@ -1,10 +1,9 @@ -import Combine import ComposableArchitecture -import SwiftUI import UIKit @Reducer struct CounterList { + @ObservableState struct State: Equatable { var counters: IdentifiedArrayOf = [] } @@ -25,12 +24,9 @@ let cellIdentifier = "Cell" final class CountersTableViewController: UITableViewController { let store: StoreOf - let viewStore: ViewStoreOf - var cancellables: Set = [] init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: { $0 }) super.init(nibName: nil, bundle: nil) } @@ -41,17 +37,13 @@ final class CountersTableViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - self.title = "Lists" + title = "Lists" - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier) - - self.viewStore.publisher.counters - .sink(receiveValue: { [weak self] _ in self?.tableView.reloadData() }) - .store(in: &self.cancellables) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - self.viewStore.counters.count + store.counters.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) @@ -59,42 +51,35 @@ final class CountersTableViewController: UITableViewController { { let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) cell.accessoryType = .disclosureIndicator - cell.textLabel?.text = "\(self.viewStore.counters[indexPath.row].count)" + observe { [weak self] in + guard let self else { return } + cell.textLabel?.text = "\(store.counters[indexPath.row].count)" + } return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let indexPathRow = indexPath.row - let counter = self.viewStore.counters[indexPathRow] - self.navigationController?.pushViewController( - CounterViewController( - store: self.store.scope( - state: \.counters[indexPathRow], - action: \.counters[id:counter.id] - ) - ), - animated: true - ) + let id = store.counters[indexPath.row].id + if let store = store.scope(state: \.counters[id:id], action: \.counters[id:id]) { + navigationController?.pushViewController(CounterViewController(store: store), animated: true) + } } } -struct CountersTableViewController_Previews: PreviewProvider { - static var previews: some View { - let vc = UINavigationController( - rootViewController: CountersTableViewController( - store: Store( - initialState: CounterList.State( - counters: [ - Counter.State(), - Counter.State(), - Counter.State(), - ] - ) - ) { - CounterList() - } - ) +#Preview { + UINavigationController( + rootViewController: CountersTableViewController( + store: Store( + initialState: CounterList.State( + counters: [ + Counter.State(), + Counter.State(), + Counter.State(), + ] + ) + ) { + CounterList() + } ) - return UIViewRepresented(makeUIView: { _ in vc.view }) - } + ) } diff --git a/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift b/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift index 172b0fb235d0..e8f0c5880672 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift @@ -1,10 +1,9 @@ -import Combine import ComposableArchitecture -import SwiftUI import UIKit @Reducer struct LazyNavigation { + @ObservableState struct State: Equatable { var optionalCounter: Counter.State? var isActivityIndicatorHidden = true @@ -54,7 +53,6 @@ struct LazyNavigation { } class LazyNavigationViewController: UIViewController { - var cancellables: [AnyCancellable] = [] let store: StoreOf init(store: StoreOf) { @@ -69,9 +67,9 @@ class LazyNavigationViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - self.title = "Load then navigate" + title = "Load then navigate" - self.view.backgroundColor = .systemBackground + view.backgroundColor = .systemBackground let button = UIButton(type: .system) button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside) @@ -85,56 +83,51 @@ class LazyNavigationViewController: UIViewController { activityIndicator, ]) rootStackView.translatesAutoresizingMaskIntoConstraints = false - self.view.addSubview(rootStackView) + view.addSubview(rootStackView) NSLayoutConstraint.activate([ - rootStackView.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - rootStackView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), + rootStackView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), ]) - self.store.publisher.isActivityIndicatorHidden - .assign(to: \.isHidden, on: activityIndicator) - .store(in: &self.cancellables) - - self.store - .scope(state: \.optionalCounter, action: \.optionalCounter) - .ifLet { [weak self] store in - self?.navigationController?.pushViewController( - CounterViewController(store: store), animated: true) - } else: { [weak self] in - guard let self = self else { return } - _ = self.navigationController?.popToViewController(self, animated: true) + observe { [weak self] in + guard let self else { return } + activityIndicator.isHidden = store.isActivityIndicatorHidden + + if let store = store.scope(state: \.optionalCounter, action: \.optionalCounter) { + navigationController?.pushViewController( + CounterViewController(store: store), animated: true + ) + } else { + navigationController?.popToViewController(self, animated: true) } - .store(in: &self.cancellables) + } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if !self.isMovingToParent { - self.store.send(.setNavigation(isActive: false)) + if !isMovingToParent { + store.send(.setNavigation(isActive: false)) } } @objc private func loadOptionalCounterTapped() { - self.store.send(.setNavigation(isActive: true)) + store.send(.setNavigation(isActive: true)) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - self.store.send(.onDisappear) + store.send(.onDisappear) } } -struct LazyNavigationViewController_Previews: PreviewProvider { - static var previews: some View { - let vc = UINavigationController( - rootViewController: LazyNavigationViewController( - store: Store(initialState: LazyNavigation.State()) { - LazyNavigation() - } - ) +#Preview { + UINavigationController( + rootViewController: LazyNavigationViewController( + store: Store(initialState: LazyNavigation.State()) { + LazyNavigation() + } ) - return UIViewRepresented(makeUIView: { _ in vc.view }) - } + ) } diff --git a/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift b/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift index ff0c459c06e2..c6562ac6d7b2 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift @@ -1,10 +1,9 @@ -import Combine import ComposableArchitecture -import SwiftUI import UIKit @Reducer struct EagerNavigation { + @ObservableState struct State: Equatable { var isNavigationActive = false var optionalCounter: Counter.State? @@ -50,7 +49,6 @@ struct EagerNavigation { } class EagerNavigationViewController: UIViewController { - var cancellables: [AnyCancellable] = [] let store: StoreOf init(store: StoreOf) { @@ -65,27 +63,27 @@ class EagerNavigationViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - self.title = "Navigate and load" + title = "Navigate and load" - self.view.backgroundColor = .systemBackground + view.backgroundColor = .systemBackground let button = UIButton(type: .system) button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside) button.setTitle("Load optional counter", for: .normal) button.translatesAutoresizingMaskIntoConstraints = false - self.view.addSubview(button) + view.addSubview(button) NSLayoutConstraint.activate([ - button.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor), - button.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor), + button.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + button.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), ]) - self.store.publisher.isNavigationActive.sink { [weak self] isNavigationActive in - guard let self = self else { return } - if isNavigationActive { - self.navigationController?.pushViewController( + observe { [weak self] in + guard let self else { return } + if store.isNavigationActive { + navigationController?.pushViewController( IfLetStoreController( - self.store.scope(state: \.optionalCounter, action: \.optionalCounter) + store.scope(state: \.optionalCounter, action: \.optionalCounter) ) { CounterViewController(store: $0) } else: { @@ -94,34 +92,30 @@ class EagerNavigationViewController: UIViewController { animated: true ) } else { - _ = self.navigationController?.popToViewController(self, animated: true) + navigationController?.popToViewController(self, animated: true) } } - .store(in: &self.cancellables) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if !self.isMovingToParent { - self.store.send(.setNavigation(isActive: false)) + if !isMovingToParent { + store.send(.setNavigation(isActive: false)) } } @objc private func loadOptionalCounterTapped() { - self.store.send(.setNavigation(isActive: true)) + store.send(.setNavigation(isActive: true)) } } -struct EagerNavigationViewController_Previews: PreviewProvider { - static var previews: some View { - let vc = UINavigationController( - rootViewController: EagerNavigationViewController( - store: Store(initialState: EagerNavigation.State()) { - EagerNavigation() - } - ) +#Preview { + UINavigationController( + rootViewController: EagerNavigationViewController( + store: Store(initialState: EagerNavigation.State()) { + EagerNavigation() + } ) - return UIViewRepresented(makeUIView: { _ in vc.view }) - } + ) } diff --git a/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift b/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift index 5e079115a344..cc407b7d2423 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import SwiftUI import UIKit struct CaseStudy { @@ -60,8 +59,8 @@ final class RootViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - self.title = "Case Studies" - self.navigationController?.navigationBar.prefersLargeTitles = true + title = "Case Studies" + navigationController?.navigationBar.prefersLargeTitles = true } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -80,13 +79,10 @@ final class RootViewController: UITableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let caseStudy = dataSource[indexPath.row] - self.navigationController?.pushViewController(caseStudy.viewController(), animated: true) + navigationController?.pushViewController(caseStudy.viewController(), animated: true) } } -struct RootViewController_Previews: PreviewProvider { - static var previews: some View { - let vc = UINavigationController(rootViewController: RootViewController()) - return UIViewRepresented(makeUIView: { _ in vc.view }) - } +#Preview { + UINavigationController(rootViewController: RootViewController()) } diff --git a/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift b/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift index 3a01d3e9ac22..6c20ce072a57 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift @@ -1,4 +1,3 @@ -import SwiftUI import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { diff --git a/Examples/CaseStudies/tvOSCaseStudies/FocusView.swift b/Examples/CaseStudies/tvOSCaseStudies/FocusView.swift index 27ee5c53c8d0..c62228d17748 100644 --- a/Examples/CaseStudies/tvOSCaseStudies/FocusView.swift +++ b/Examples/CaseStudies/tvOSCaseStudies/FocusView.swift @@ -12,6 +12,7 @@ private let readMe = """ @Reducer struct Focus { + @ObservableState struct State: Equatable { var currentFocus = 1 } @@ -35,7 +36,6 @@ struct Focus { } } -@available(tvOS 14.0, *) struct FocusView: View { let store: StoreOf @@ -43,38 +43,35 @@ struct FocusView: View { @Namespace private var namespace var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack(spacing: 100) { - Text(readMe) - .font(.headline) - .multilineTextAlignment(.leading) - .padding() + VStack(spacing: 100) { + Text(readMe) + .font(.headline) + .multilineTextAlignment(.leading) + .padding() - HStack(spacing: 40) { - ForEach(1..<6) { index in - Button(numbers[index]) {} - .prefersDefaultFocus(viewStore.currentFocus == index, in: self.namespace) - } + HStack(spacing: 40) { + ForEach(1..<6) { index in + Button(numbers[index]) {} + .prefersDefaultFocus(store.currentFocus == index, in: namespace) } - HStack(spacing: 40) { - ForEach(6..<11) { index in - Button(numbers[index]) {} - .prefersDefaultFocus(viewStore.currentFocus == index, in: self.namespace) - } - } - - Button("Focus Random") { viewStore.send(.randomButtonClicked) } } - .onChange(of: viewStore.currentFocus) { _ in - // Update the view's focus when the state tells us the focus changed. - self.resetFocus(in: self.namespace) + HStack(spacing: 40) { + ForEach(6..<11) { index in + Button(numbers[index]) {} + .prefersDefaultFocus(store.currentFocus == index, in: namespace) + } } - .focusScope(self.namespace) + + Button("Focus Random") { store.send(.randomButtonClicked) } + } + .onChange(of: store.currentFocus) { + // Update the view's focus when the state tells us the focus changed. + resetFocus(in: namespace) } + .focusScope(self.namespace) } } -@available(tvOS 14.0, *) struct FocusView_Previews: PreviewProvider { static var previews: some View { FocusView( diff --git a/Examples/CaseStudies/tvOSCaseStudies/RootView.swift b/Examples/CaseStudies/tvOSCaseStudies/RootView.swift index 8857c8f7620c..420a6c4faaeb 100644 --- a/Examples/CaseStudies/tvOSCaseStudies/RootView.swift +++ b/Examples/CaseStudies/tvOSCaseStudies/RootView.swift @@ -8,11 +8,9 @@ struct RootView: View { NavigationView { Form { Section { - if #available(tvOS 14, *) { - FocusView( - store: self.store.scope(state: \.focus, action: \.focus) - ) - } + FocusView( + store: store.scope(state: \.focus, action: \.focus) + ) } } } diff --git a/Examples/Integration/Integration.xcodeproj/project.pbxproj b/Examples/Integration/Integration.xcodeproj/project.pbxproj index d5909c7c0ab3..366259e2fdb4 100644 --- a/Examples/Integration/Integration.xcodeproj/project.pbxproj +++ b/Examples/Integration/Integration.xcodeproj/project.pbxproj @@ -12,6 +12,10 @@ CA4BA5E929E76A7F0004FF9D /* NavigationStackTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4BA5E829E76A7F0004FF9D /* NavigationStackTestCase.swift */; }; CA4BA5EB29E76F110004FF9D /* LegacyNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4BA5EA29E76F110004FF9D /* LegacyNavigationTests.swift */; }; CA595278296DF67E00B5B695 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = CA595277296DF67E00B5B695 /* ComposableArchitecture */; }; + CA7BDDA12ADB543400277984 /* NewContainsOldTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BDDA02ADB543400277984 /* NewContainsOldTestCase.swift */; }; + CA7BDDA32ADB575F00277984 /* OldContainsNewTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BDDA22ADB575F00277984 /* OldContainsNewTestCase.swift */; }; + CA7BDDA52ADB5E5700277984 /* OldContainsNewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BDDA42ADB5E5700277984 /* OldContainsNewTests.swift */; }; + CA7BDDA72ADB637300277984 /* NewContainsOldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BDDA62ADB637300277984 /* NewContainsOldTests.swift */; }; CA8B2E9A2AC576CA008272E0 /* BasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8B2E962AC576B5008272E0 /* BasicsTests.swift */; }; CA8B2E9B2AC576CA008272E0 /* EnumTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8B2E942AC576B5008272E0 /* EnumTests.swift */; }; CA8B2E9C2AC576CA008272E0 /* OptionalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8B2E952AC576B5008272E0 /* OptionalTests.swift */; }; @@ -31,6 +35,14 @@ CAA1CAFC296DEE79000665B1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAA1CAFB296DEE79000665B1 /* Preview Assets.xcassets */; }; CAA1CB10296DEE79000665B1 /* ForEachBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */; }; CAA1CB1F296DEEAC000665B1 /* ForEachBindingTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */; }; + CAA6BEAE2ADADE5900FF83BC /* NewOldSiblingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA6BEAD2ADADE5900FF83BC /* NewOldSiblingsTests.swift */; }; + CAA6BEB12ADADE7700FF83BC /* NewOldSiblingsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA6BEB02ADADE7700FF83BC /* NewOldSiblingsTestCase.swift */; }; + CAA6BEB32ADAE08300FF83BC /* OldPresentsNewTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA6BEB22ADAE08300FF83BC /* OldPresentsNewTestCase.swift */; }; + CAA6BEB52ADAE08A00FF83BC /* NewPresentsOldTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA6BEB42ADAE08A00FF83BC /* NewPresentsOldTestCase.swift */; }; + CAA6BEB72ADAF0DF00FF83BC /* NewPresentsOldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA6BEB62ADAF0DF00FF83BC /* NewPresentsOldTests.swift */; }; + CAA6BEB92ADAF61A00FF83BC /* OldPresentsNewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA6BEB82ADAF61A00FF83BC /* OldPresentsNewTests.swift */; }; + CAE961792B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE961782B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift */; }; + CAE9617B2B0EE169007A66F5 /* ObservableBindingLocalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE9617A2B0EE169007A66F5 /* ObservableBindingLocalTests.swift */; }; CAE2E9232B23417000EE370B /* IfLetStoreTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE2E9222B23417000EE370B /* IfLetStoreTestCase.swift */; }; CAE2E9252B2341AB00EE370B /* IfLetStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE2E9242B2341AB00EE370B /* IfLetStoreTests.swift */; }; CAF5801B29A5642B0042FB62 /* TestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF5801A29A5642B0042FB62 /* TestCase.swift */; }; @@ -44,6 +56,20 @@ DC140CC729E0E8F3006DF553 /* SwitchStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC140CC629E0E8F3006DF553 /* SwitchStoreTests.swift */; }; DC6268502AD1C85E00F2E2EF /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = DC62684F2AD1C85E00F2E2EF /* InlineSnapshotTesting */; }; DC6268532AD1E06300F2E2EF /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = DC6268522AD1E06300F2E2EF /* InlineSnapshotTesting */; }; + DC6E2D942AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D8D2AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift */; }; + DC6E2D952AD5C56F005ACC26 /* ObservableBasicsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D8E2AD5C56F005ACC26 /* ObservableBasicsTestCase.swift */; }; + DC6E2D962AD5C56F005ACC26 /* ObservableNavigationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D8F2AD5C56F005ACC26 /* ObservableNavigationTestCase.swift */; }; + DC6E2D972AD5C56F005ACC26 /* ObservableSiblingTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D902AD5C56F005ACC26 /* ObservableSiblingTestCase.swift */; }; + DC6E2D982AD5C56F005ACC26 /* ObservablePresentationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D912AD5C56F005ACC26 /* ObservablePresentationTestCase.swift */; }; + DC6E2D992AD5C56F005ACC26 /* ObservableOptionalTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D922AD5C56F005ACC26 /* ObservableOptionalTestCase.swift */; }; + DC6E2D9A2AD5C56F005ACC26 /* ObservableEnumTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D932AD5C56F005ACC26 /* ObservableEnumTestCase.swift */; }; + DC6E2DA52AD5C677005ACC26 /* ObservableEnumTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D9E2AD5C677005ACC26 /* ObservableEnumTests.swift */; }; + DC6E2DA62AD5C677005ACC26 /* ObservableIdentifiedListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2D9F2AD5C677005ACC26 /* ObservableIdentifiedListTests.swift */; }; + DC6E2DA72AD5C677005ACC26 /* ObservableNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2DA02AD5C677005ACC26 /* ObservableNavigationTests.swift */; }; + DC6E2DA82AD5C677005ACC26 /* ObservableSiblingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2DA12AD5C677005ACC26 /* ObservableSiblingTests.swift */; }; + DC6E2DA92AD5C677005ACC26 /* ObservableOptionalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2DA22AD5C677005ACC26 /* ObservableOptionalTests.swift */; }; + DC6E2DAA2AD5C677005ACC26 /* ObservableBasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2DA32AD5C677005ACC26 /* ObservableBasicsTests.swift */; }; + DC6E2DAB2AD5C677005ACC26 /* ObservablePresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6E2DA42AD5C677005ACC26 /* ObservablePresentationTests.swift */; }; DC808D6529E91FAA0072B4A9 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC808D6429E91FAA0072B4A9 /* ComposableArchitecture */; }; DC92799B2A1E59D500B2031A /* PresentationItemTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC92799A2A1E59D500B2031A /* PresentationItemTestCase.swift */; }; DCFFB8E72A156488006AF839 /* BindingLocalTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFFB8E62A156488006AF839 /* BindingLocalTestCase.swift */; }; @@ -98,6 +124,10 @@ CA4BA5E829E76A7F0004FF9D /* NavigationStackTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackTestCase.swift; sourceTree = ""; }; CA4BA5EA29E76F110004FF9D /* LegacyNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyNavigationTests.swift; sourceTree = ""; }; CA595276296DF66B00B5B695 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; + CA7BDDA02ADB543400277984 /* NewContainsOldTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewContainsOldTestCase.swift; sourceTree = ""; }; + CA7BDDA22ADB575F00277984 /* OldContainsNewTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldContainsNewTestCase.swift; sourceTree = ""; }; + CA7BDDA42ADB5E5700277984 /* OldContainsNewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldContainsNewTests.swift; sourceTree = ""; }; + CA7BDDA62ADB637300277984 /* NewContainsOldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewContainsOldTests.swift; sourceTree = ""; }; CA8B2E942AC576B5008272E0 /* EnumTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnumTests.swift; sourceTree = ""; }; CA8B2E952AC576B5008272E0 /* OptionalTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptionalTests.swift; sourceTree = ""; }; CA8B2E962AC576B5008272E0 /* BasicsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasicsTests.swift; sourceTree = ""; }; @@ -119,6 +149,14 @@ CAA1CB0B296DEE79000665B1 /* IntegrationUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAA1CB0F296DEE79000665B1 /* ForEachBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForEachBindingTests.swift; sourceTree = ""; }; CAA1CB1E296DEEAC000665B1 /* ForEachBindingTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForEachBindingTestCase.swift; sourceTree = ""; }; + CAA6BEAD2ADADE5900FF83BC /* NewOldSiblingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewOldSiblingsTests.swift; sourceTree = ""; }; + CAA6BEB02ADADE7700FF83BC /* NewOldSiblingsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewOldSiblingsTestCase.swift; sourceTree = ""; }; + CAA6BEB22ADAE08300FF83BC /* OldPresentsNewTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldPresentsNewTestCase.swift; sourceTree = ""; }; + CAA6BEB42ADAE08A00FF83BC /* NewPresentsOldTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPresentsOldTestCase.swift; sourceTree = ""; }; + CAA6BEB62ADAF0DF00FF83BC /* NewPresentsOldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPresentsOldTests.swift; sourceTree = ""; }; + CAA6BEB82ADAF61A00FF83BC /* OldPresentsNewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldPresentsNewTests.swift; sourceTree = ""; }; + CAE961782B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableBindingLocalTest.swift; sourceTree = ""; }; + CAE9617A2B0EE169007A66F5 /* ObservableBindingLocalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableBindingLocalTests.swift; sourceTree = ""; }; CAE2E9222B23417000EE370B /* IfLetStoreTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfLetStoreTestCase.swift; sourceTree = ""; }; CAE2E9242B2341AB00EE370B /* IfLetStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfLetStoreTests.swift; sourceTree = ""; }; CAF57FFC29A564210042FB62 /* TestCases.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TestCases.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -127,6 +165,20 @@ CAF5802629A567BB0042FB62 /* LegacyPresentationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyPresentationTests.swift; sourceTree = ""; }; DC140CC429E0BB2C006DF553 /* SwitchStoreTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchStoreTestCase.swift; sourceTree = ""; }; DC140CC629E0E8F3006DF553 /* SwitchStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchStoreTests.swift; sourceTree = ""; }; + DC6E2D8D2AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableIdentifiedListTestCase.swift; sourceTree = ""; }; + DC6E2D8E2AD5C56F005ACC26 /* ObservableBasicsTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableBasicsTestCase.swift; sourceTree = ""; }; + DC6E2D8F2AD5C56F005ACC26 /* ObservableNavigationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableNavigationTestCase.swift; sourceTree = ""; }; + DC6E2D902AD5C56F005ACC26 /* ObservableSiblingTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableSiblingTestCase.swift; sourceTree = ""; }; + DC6E2D912AD5C56F005ACC26 /* ObservablePresentationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservablePresentationTestCase.swift; sourceTree = ""; }; + DC6E2D922AD5C56F005ACC26 /* ObservableOptionalTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableOptionalTestCase.swift; sourceTree = ""; }; + DC6E2D932AD5C56F005ACC26 /* ObservableEnumTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableEnumTestCase.swift; sourceTree = ""; }; + DC6E2D9E2AD5C677005ACC26 /* ObservableEnumTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableEnumTests.swift; sourceTree = ""; }; + DC6E2D9F2AD5C677005ACC26 /* ObservableIdentifiedListTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableIdentifiedListTests.swift; sourceTree = ""; }; + DC6E2DA02AD5C677005ACC26 /* ObservableNavigationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableNavigationTests.swift; sourceTree = ""; }; + DC6E2DA12AD5C677005ACC26 /* ObservableSiblingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableSiblingTests.swift; sourceTree = ""; }; + DC6E2DA22AD5C677005ACC26 /* ObservableOptionalTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableOptionalTests.swift; sourceTree = ""; }; + DC6E2DA32AD5C677005ACC26 /* ObservableBasicsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableBasicsTests.swift; sourceTree = ""; }; + DC6E2DA42AD5C677005ACC26 /* ObservablePresentationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservablePresentationTests.swift; sourceTree = ""; }; DC92799A2A1E59D500B2031A /* PresentationItemTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationItemTestCase.swift; sourceTree = ""; }; DCFFB8E62A156488006AF839 /* BindingLocalTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindingLocalTestCase.swift; sourceTree = ""; }; DCFFB8E82A15792C006AF839 /* BindingLocalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindingLocalTests.swift; sourceTree = ""; }; @@ -232,15 +284,11 @@ isa = PBXGroup; children = ( CA2DBB282ACB0520009FBCC9 /* Info.plist */, - CA8B2E9E2AC57706008272E0 /* BasicsTestCase.swift */, - CA8B2EA02AC57752008272E0 /* EnumTestCase.swift */, - CA8B2EA82AC5878B008272E0 /* IdentifiedListTestCase.swift */, CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */, - CA8B2EB12AC5AD63008272E0 /* NavigationTestCase.swift */, - CA8B2EA22AC5788B008272E0 /* OptionalTestCase.swift */, - CA8B2EAD2AC5A068008272E0 /* PresentationTestCase.swift */, - CA8B2EA42AC57907008272E0 /* SiblingTestCase.swift */, CAA1CAF8296DEE79000665B1 /* Assets.xcassets */, + DC6E2D8B2AD5C512005ACC26 /* iOS 16 */, + CAA6BEAF2ADADE6200FF83BC /* iOS 16+17 */, + DC6E2D8C2AD5C525005ACC26 /* iOS 17 */, CA8B2E932AC57518008272E0 /* Legacy */, CAA1CAFA296DEE79000665B1 /* Preview Content */, ); @@ -258,14 +306,10 @@ CAA1CB0E296DEE79000665B1 /* IntegrationUITests */ = { isa = PBXGroup; children = ( - CA8B2E962AC576B5008272E0 /* BasicsTests.swift */, - CA8B2E942AC576B5008272E0 /* EnumTests.swift */, - CA8B2EAB2AC58AD9008272E0 /* IdentifiedListTests.swift */, - CA8B2EB32AC5AF70008272E0 /* NavigationTests.swift */, - CA8B2E952AC576B5008272E0 /* OptionalTests.swift */, - CA8B2EAF2AC5A8CC008272E0 /* PresentationTests.swift */, - CA8B2EA62AC584BE008272E0 /* SiblingTests.swift */, CA3A7B9B29CB61E3002CD272 /* Internal */, + DC6E2D9B2AD5C61B005ACC26 /* iOS 16 */, + CAA6BEAC2ADADE4300FF83BC /* iOS 16+17 */, + DC6E2D9D2AD5C64C005ACC26 /* iOS 17 */, CA8B2E9D2AC576CE008272E0 /* Legacy */, ); path = IntegrationUITests; @@ -278,6 +322,30 @@ name = Frameworks; sourceTree = ""; }; + CAA6BEAC2ADADE4300FF83BC /* iOS 16+17 */ = { + isa = PBXGroup; + children = ( + CA7BDDA62ADB637300277984 /* NewContainsOldTests.swift */, + CAA6BEAD2ADADE5900FF83BC /* NewOldSiblingsTests.swift */, + CAA6BEB62ADAF0DF00FF83BC /* NewPresentsOldTests.swift */, + CA7BDDA42ADB5E5700277984 /* OldContainsNewTests.swift */, + CAA6BEB82ADAF61A00FF83BC /* OldPresentsNewTests.swift */, + ); + path = "iOS 16+17"; + sourceTree = ""; + }; + CAA6BEAF2ADADE6200FF83BC /* iOS 16+17 */ = { + isa = PBXGroup; + children = ( + CA7BDDA02ADB543400277984 /* NewContainsOldTestCase.swift */, + CAA6BEB02ADADE7700FF83BC /* NewOldSiblingsTestCase.swift */, + CAA6BEB42ADAE08A00FF83BC /* NewPresentsOldTestCase.swift */, + CA7BDDA22ADB575F00277984 /* OldContainsNewTestCase.swift */, + CAA6BEB22ADAE08300FF83BC /* OldPresentsNewTestCase.swift */, + ); + path = "iOS 16+17"; + sourceTree = ""; + }; CAF57FFD29A564210042FB62 /* TestCases */ = { isa = PBXGroup; children = ( @@ -286,6 +354,64 @@ path = TestCases; sourceTree = ""; }; + DC6E2D8B2AD5C512005ACC26 /* iOS 16 */ = { + isa = PBXGroup; + children = ( + CA8B2E9E2AC57706008272E0 /* BasicsTestCase.swift */, + CA8B2EA02AC57752008272E0 /* EnumTestCase.swift */, + CA8B2EA82AC5878B008272E0 /* IdentifiedListTestCase.swift */, + CA8B2EB12AC5AD63008272E0 /* NavigationTestCase.swift */, + CA8B2EA22AC5788B008272E0 /* OptionalTestCase.swift */, + CA8B2EAD2AC5A068008272E0 /* PresentationTestCase.swift */, + CA8B2EA42AC57907008272E0 /* SiblingTestCase.swift */, + ); + path = "iOS 16"; + sourceTree = ""; + }; + DC6E2D8C2AD5C525005ACC26 /* iOS 17 */ = { + isa = PBXGroup; + children = ( + DC6E2D8E2AD5C56F005ACC26 /* ObservableBasicsTestCase.swift */, + CAE961782B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift */, + DC6E2D932AD5C56F005ACC26 /* ObservableEnumTestCase.swift */, + DC6E2D8D2AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift */, + DC6E2D8F2AD5C56F005ACC26 /* ObservableNavigationTestCase.swift */, + DC6E2D922AD5C56F005ACC26 /* ObservableOptionalTestCase.swift */, + DC6E2D912AD5C56F005ACC26 /* ObservablePresentationTestCase.swift */, + DC6E2D902AD5C56F005ACC26 /* ObservableSiblingTestCase.swift */, + ); + path = "iOS 17"; + sourceTree = ""; + }; + DC6E2D9B2AD5C61B005ACC26 /* iOS 16 */ = { + isa = PBXGroup; + children = ( + CA8B2E962AC576B5008272E0 /* BasicsTests.swift */, + CA8B2E942AC576B5008272E0 /* EnumTests.swift */, + CA8B2EAB2AC58AD9008272E0 /* IdentifiedListTests.swift */, + CA8B2EB32AC5AF70008272E0 /* NavigationTests.swift */, + CA8B2E952AC576B5008272E0 /* OptionalTests.swift */, + CA8B2EAF2AC5A8CC008272E0 /* PresentationTests.swift */, + CA8B2EA62AC584BE008272E0 /* SiblingTests.swift */, + ); + path = "iOS 16"; + sourceTree = ""; + }; + DC6E2D9D2AD5C64C005ACC26 /* iOS 17 */ = { + isa = PBXGroup; + children = ( + DC6E2DA32AD5C677005ACC26 /* ObservableBasicsTests.swift */, + CAE9617A2B0EE169007A66F5 /* ObservableBindingLocalTests.swift */, + DC6E2D9E2AD5C677005ACC26 /* ObservableEnumTests.swift */, + DC6E2D9F2AD5C677005ACC26 /* ObservableIdentifiedListTests.swift */, + DC6E2DA02AD5C677005ACC26 /* ObservableNavigationTests.swift */, + DC6E2DA22AD5C677005ACC26 /* ObservableOptionalTests.swift */, + DC6E2DA42AD5C677005ACC26 /* ObservablePresentationTests.swift */, + DC6E2DA12AD5C677005ACC26 /* ObservableSiblingTests.swift */, + ); + path = "iOS 17"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -444,8 +570,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DC6E2D972AD5C56F005ACC26 /* ObservableSiblingTestCase.swift in Sources */, CAA1CB1F296DEEAC000665B1 /* ForEachBindingTestCase.swift in Sources */, DC140CC529E0BB2C006DF553 /* SwitchStoreTestCase.swift in Sources */, + DC6E2D962AD5C56F005ACC26 /* ObservableNavigationTestCase.swift in Sources */, CA8B2EA52AC57907008272E0 /* SiblingTestCase.swift in Sources */, DC92799B2A1E59D500B2031A /* PresentationItemTestCase.swift in Sources */, CA8B2E9F2AC57706008272E0 /* BasicsTestCase.swift in Sources */, @@ -453,13 +581,24 @@ E9919D42296E47A400C8716B /* BindingsAnimationsTestBench.swift in Sources */, CAE2E9232B23417000EE370B /* IfLetStoreTestCase.swift in Sources */, DCFFB8E72A156488006AF839 /* BindingLocalTestCase.swift in Sources */, + DC6E2D992AD5C56F005ACC26 /* ObservableOptionalTestCase.swift in Sources */, + CA7BDDA12ADB543400277984 /* NewContainsOldTestCase.swift in Sources */, CAF5802529A5651D0042FB62 /* LegacyPresentationTestCase.swift in Sources */, + DC6E2D9A2AD5C56F005ACC26 /* ObservableEnumTestCase.swift in Sources */, + CA7BDDA32ADB575F00277984 /* OldContainsNewTestCase.swift in Sources */, CA8B2EB22AC5AD63008272E0 /* NavigationTestCase.swift in Sources */, + CAA6BEB32ADAE08300FF83BC /* OldPresentsNewTestCase.swift in Sources */, + DC6E2D952AD5C56F005ACC26 /* ObservableBasicsTestCase.swift in Sources */, + DC6E2D942AD5C56F005ACC26 /* ObservableIdentifiedListTestCase.swift in Sources */, E9919D3E296E28C800C8716B /* EscapedWithViewStoreTestCase.swift in Sources */, CA8B2EA32AC5788B008272E0 /* OptionalTestCase.swift in Sources */, CA8B2EAA2AC5878D008272E0 /* IdentifiedListTestCase.swift in Sources */, CA8B2EAE2AC5A068008272E0 /* PresentationTestCase.swift in Sources */, CA8B2EA12AC57752008272E0 /* EnumTestCase.swift in Sources */, + DC6E2D982AD5C56F005ACC26 /* ObservablePresentationTestCase.swift in Sources */, + CAA6BEB12ADADE7700FF83BC /* NewOldSiblingsTestCase.swift in Sources */, + CAA6BEB52ADAE08A00FF83BC /* NewPresentsOldTestCase.swift in Sources */, + CAE961792B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift in Sources */, CAA1CAF5296DEE78000665B1 /* IntegrationApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -470,20 +609,33 @@ files = ( CA8B2EAC2AC58AD9008272E0 /* IdentifiedListTests.swift in Sources */, CA8B2EB02AC5A8CC008272E0 /* PresentationTests.swift in Sources */, + DC6E2DA92AD5C677005ACC26 /* ObservableOptionalTests.swift in Sources */, CA4BA5EB29E76F110004FF9D /* LegacyNavigationTests.swift in Sources */, CA8B2EB42AC5AF70008272E0 /* NavigationTests.swift in Sources */, CAE2E9252B2341AB00EE370B /* IfLetStoreTests.swift in Sources */, E9919D40296E3EF400C8716B /* EscapedWithViewStoreTests.swift in Sources */, CA3A7B9D29CB61E9002CD272 /* TestHelpers.swift in Sources */, + CA7BDDA52ADB5E5700277984 /* OldContainsNewTests.swift in Sources */, CA8B2E9B2AC576CA008272E0 /* EnumTests.swift in Sources */, + DC6E2DA62AD5C677005ACC26 /* ObservableIdentifiedListTests.swift in Sources */, CAF5802729A567BB0042FB62 /* LegacyPresentationTests.swift in Sources */, CA487B2C2A15185300F54A79 /* BaseIntegrationTests.swift in Sources */, CA8B2EA72AC584BE008272E0 /* SiblingTests.swift in Sources */, + DC6E2DA72AD5C677005ACC26 /* ObservableNavigationTests.swift in Sources */, + DC6E2DAA2AD5C677005ACC26 /* ObservableBasicsTests.swift in Sources */, CAA1CB10296DEE79000665B1 /* ForEachBindingTests.swift in Sources */, DCFFB8E92A15792C006AF839 /* BindingLocalTests.swift in Sources */, + DC6E2DA52AD5C677005ACC26 /* ObservableEnumTests.swift in Sources */, + DC6E2DAB2AD5C677005ACC26 /* ObservablePresentationTests.swift in Sources */, CA8B2E9A2AC576CA008272E0 /* BasicsTests.swift in Sources */, DC140CC729E0E8F3006DF553 /* SwitchStoreTests.swift in Sources */, CA8B2E9C2AC576CA008272E0 /* OptionalTests.swift in Sources */, + CA7BDDA72ADB637300277984 /* NewContainsOldTests.swift in Sources */, + DC6E2DA82AD5C677005ACC26 /* ObservableSiblingTests.swift in Sources */, + CAA6BEAE2ADADE5900FF83BC /* NewOldSiblingsTests.swift in Sources */, + CAA6BEB72ADAF0DF00FF83BC /* NewPresentsOldTests.swift in Sources */, + CAE9617B2B0EE169007A66F5 /* ObservableBindingLocalTests.swift in Sources */, + CAA6BEB92ADAF61A00FF83BC /* OldPresentsNewTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -570,6 +722,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -623,6 +776,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/Examples/Integration/Integration/IntegrationApp.swift b/Examples/Integration/Integration/IntegrationApp.swift index c7a7f96c05c2..2daf390a03c9 100644 --- a/Examples/Integration/Integration/IntegrationApp.swift +++ b/Examples/Integration/Integration/IntegrationApp.swift @@ -73,10 +73,73 @@ struct ContentView: View { @State var isBindingLocalTestCasePresented = false @State var isNavigationStackTestCasePresented = false @State var isNavigationTestCasePresented = false + @State var isObservableBindingLocalTestCasePresented = false + @State var isObservableNavigationTestCasePresented = false var body: some View { NavigationStack { List { + NavigationLink("iOS 17") { + List { + Section { + NavigationLink("Basics") { + Form { + ObservableBasicsView(showExtraButtons: true) + } + } + Button("Binding local") { + self.isObservableBindingLocalTestCasePresented.toggle() + } + .sheet(isPresented: self.$isObservableBindingLocalTestCasePresented) { + ObservableBindingLocalTestCaseView() + } + NavigationLink("Enum") { + ObservableEnumView() + } + NavigationLink("Optional") { + ObservableOptionalView() + } + NavigationLink("Identified list") { + ObservableIdentifiedListView() + } + Button("Navigation") { + self.isObservableNavigationTestCasePresented = true + } + .sheet(isPresented: self.$isObservableNavigationTestCasePresented) { + ObservableNavigationTestCaseView() + } + NavigationLink("Siblings") { + ObservableSiblingFeaturesView() + } + NavigationLink("Presentation") { + ObservablePresentationView() + } + } + } + .navigationTitle("iOS 17") + } + + NavigationLink("iOS 16 + 17") { + List { + NavigationLink("New containing old") { + NewContainsOldTestCase() + } + NavigationLink("Siblings") { + NewOldSiblingsView() + } + NavigationLink("New presents old") { + NewPresentsOldTestCase() + } + NavigationLink("Old containing new") { + OldContainsNewTestCase() + } + NavigationLink("Old presents new") { + OldPresentsNewTestCase() + } + } + .navigationTitle(Text("iOS 16 + 17")) + } + NavigationLink("iOS 16") { List { Section { @@ -213,7 +276,7 @@ struct RuntimeWarnings: View { .transition(.opacity.animation(.default)) } } - .onReceive(NotificationCenter.default.publisher(for: .runtimeWarning)) { notification in + .onReceive(NotificationCenter.default.publisher(for: ._runtimeWarning)) { notification in if let message = notification.userInfo?["message"] as? String { self.runtimeWarnings.append(message) } diff --git a/Examples/Integration/Integration/iOS 16+17/NewContainsOldTestCase.swift b/Examples/Integration/Integration/iOS 16+17/NewContainsOldTestCase.swift new file mode 100644 index 000000000000..71fb2023862b --- /dev/null +++ b/Examples/Integration/Integration/iOS 16+17/NewContainsOldTestCase.swift @@ -0,0 +1,69 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct NewContainsOldTestCase: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + Text(self.store.count.description) + Button("Increment") { self.store.send(.incrementButtonTapped) } + } header: { + Text("iOS 17") + } + Section { + if self.store.isObservingChildCount { + Text("Child count: \(self.store.child.count)") + } + Button("Toggle observe child count") { + self.store.send(.toggleIsObservingChildCount) + } + } + Section { + BasicsView(store: self.store.scope(state: \.child, action: \.child)) + } header: { + Text("iOS 16") + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State { + var child = BasicsView.Feature.State() + var count = 0 + var isObservingChildCount = false + } + enum Action { + case child(BasicsView.Feature.Action) + case incrementButtonTapped + case toggleIsObservingChildCount + } + var body: some ReducerOf { + Scope(state: \.child, action: \.child) { + BasicsView.Feature() + } + Reduce { state, action in + switch action { + case .child: + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + case .toggleIsObservingChildCount: + state.isObservingChildCount.toggle() + return .none + } + } + } + } +} + +#Preview { + NewContainsOldTestCase() +} diff --git a/Examples/Integration/Integration/SiblingTestCase.swift b/Examples/Integration/Integration/iOS 16+17/NewOldSiblingsTestCase.swift similarity index 69% rename from Examples/Integration/Integration/SiblingTestCase.swift rename to Examples/Integration/Integration/iOS 16+17/NewOldSiblingsTestCase.swift index 0581a3593698..209b027b15a6 100644 --- a/Examples/Integration/Integration/SiblingTestCase.swift +++ b/Examples/Integration/Integration/iOS 16+17/NewOldSiblingsTestCase.swift @@ -1,34 +1,37 @@ @_spi(Logging) import ComposableArchitecture import SwiftUI -struct SiblingFeaturesView: View { +struct NewOldSiblingsView: View { @State var store = Store(initialState: Feature.State()) { Feature() } var body: some View { - VStack { - Form { + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { BasicsView( store: self.store.scope(state: \.child1, action: \.child1) ) + } header: { + Text("iOS 16") } - Form { - BasicsView( + + Section { + ObservableBasicsView( store: self.store.scope(state: \.child2, action: \.child2) ) + } header: { + Text("iOS 17") } - Spacer() - Form { + + Section { Button("Reset all") { self.store.send(.resetAllButtonTapped) } Button("Reset self") { self.store.send(.resetSelfButtonTapped) } - Button("Swap") { - self.store.send(.swapButtonTapped) - } } } } @@ -37,21 +40,20 @@ struct SiblingFeaturesView: View { struct Feature { struct State: Equatable { var child1 = BasicsView.Feature.State() - var child2 = BasicsView.Feature.State() + var child2 = ObservableBasicsView.Feature.State() } enum Action { case child1(BasicsView.Feature.Action) - case child2(BasicsView.Feature.Action) + case child2(ObservableBasicsView.Feature.Action) case resetAllButtonTapped case resetSelfButtonTapped - case swapButtonTapped } var body: some ReducerOf { Scope(state: \.child1, action: \.child1) { BasicsView.Feature() } Scope(state: \.child2, action: \.child2) { - BasicsView.Feature() + ObservableBasicsView.Feature() } Reduce { state, action in switch action { @@ -61,25 +63,20 @@ struct SiblingFeaturesView: View { return .none case .resetAllButtonTapped: state.child1 = BasicsView.Feature.State() - state.child2 = BasicsView.Feature.State() + state.child2 = ObservableBasicsView.Feature.State() return .none case .resetSelfButtonTapped: state = State() return .none - case .swapButtonTapped: - let copy = state.child1 - state.child1 = state.child2 - state.child2 = copy - return .none } } } } } -struct SiblingPreviews: PreviewProvider { +struct NewOldSiblingsPreviews: PreviewProvider { static var previews: some View { let _ = Logger.shared.isEnabled = true - SiblingFeaturesView() + NewOldSiblingsView() } } diff --git a/Examples/Integration/Integration/iOS 16+17/NewPresentsOldTestCase.swift b/Examples/Integration/Integration/iOS 16+17/NewPresentsOldTestCase.swift new file mode 100644 index 000000000000..30577251fcd6 --- /dev/null +++ b/Examples/Integration/Integration/iOS 16+17/NewPresentsOldTestCase.swift @@ -0,0 +1,79 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct NewPresentsOldTestCase: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + Text(self.store.count.description) + Button("Increment") { + self.store.send(.incrementButtonTapped) + } + } + Section { + if self.store.isObservingChildCount { + Text("Child count: " + (store.child?.count.description ?? "N/A")) + } + Button("Toggle observe child count") { + self.store.send(.toggleObservingChildCount) + } + } + Section { + Button("Present child") { self.store.send(.presentChildButtonTapped) } + } + } + .sheet(store: self.store.scope(state: \.$child, action: \.child)) { store in + Form { + BasicsView(store: store) + } + .presentationDetents([.medium]) + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State { + @Presents var child: BasicsView.Feature.State? + var count = 0 + var isObservingChildCount = false + } + enum Action { + case child(PresentationAction) + case incrementButtonTapped + case presentChildButtonTapped + case toggleObservingChildCount + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .child: + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + case .presentChildButtonTapped: + state.child = BasicsView.Feature.State() + return .none + case .toggleObservingChildCount: + state.isObservingChildCount.toggle() + return .none + } + } + .ifLet(\.$child, action: \.child) { + BasicsView.Feature() + } + } + } +} + +#Preview { + NewPresentsOldTestCase() +} diff --git a/Examples/Integration/Integration/iOS 16+17/OldContainsNewTestCase.swift b/Examples/Integration/Integration/iOS 16+17/OldContainsNewTestCase.swift new file mode 100644 index 000000000000..2632f7ed0ef1 --- /dev/null +++ b/Examples/Integration/Integration/iOS 16+17/OldContainsNewTestCase.swift @@ -0,0 +1,81 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct OldContainsNewTestCase: View { + @State var store = Store(initialState: Feature.State()) { + Feature() + } + + struct ViewState: Equatable { + let childCount: Int + let count: Int + let isObservingChildCount: Bool + init(state: Feature.State) { + self.childCount = state.child.count + self.count = state.count + self.isObservingChildCount = state.isObservingChildCount + } + } + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + Text(viewStore.count.description) + Button("Increment") { self.store.send(.incrementButtonTapped) } + } header: { + Text("iOS 16") + } + Section { + if viewStore.isObservingChildCount { + Text("Child count: \(viewStore.childCount)") + } + Button("Toggle observing child count") { + self.store.send(.toggleIsObservingChildCount) + } + } + Section { + ObservableBasicsView(store: self.store.scope(state: \.child, action: \.child)) + } header: { + Text("iOS 17") + } + } + } + } + + @Reducer + struct Feature { + struct State { + var child = ObservableBasicsView.Feature.State() + var count = 0 + var isObservingChildCount = false + } + enum Action { + case child(ObservableBasicsView.Feature.Action) + case incrementButtonTapped + case toggleIsObservingChildCount + } + var body: some ReducerOf { + Scope(state: \.child, action: \.child) { + ObservableBasicsView.Feature() + } + Reduce { state, action in + switch action { + case .child: + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + case .toggleIsObservingChildCount: + state.isObservingChildCount.toggle() + return .none + } + } + } + } +} + +#Preview { + OldContainsNewTestCase() +} diff --git a/Examples/Integration/Integration/iOS 16+17/OldPresentsNewTestCase.swift b/Examples/Integration/Integration/iOS 16+17/OldPresentsNewTestCase.swift new file mode 100644 index 000000000000..00921c2087b3 --- /dev/null +++ b/Examples/Integration/Integration/iOS 16+17/OldPresentsNewTestCase.swift @@ -0,0 +1,87 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct OldPresentsNewTestCase: View { + @State var store = Store(initialState: Feature.State()) { + Feature() + } + + struct ViewState: Equatable { + let childCount: Int? + let count: Int + let isObservingChildCount: Bool + init(state: Feature.State) { + self.childCount = state.child?.count + self.count = state.count + self.isObservingChildCount = state.isObservingChildCount + } + } + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + Text(viewStore.count.description) + Button("Increment") { self.store.send(.incrementButtonTapped) } + } + Section { + if viewStore.isObservingChildCount { + Text("Child count: " + (viewStore.childCount?.description ?? "N/A")) + } + Button("Toggle observe child count") { + self.store.send(.toggleObservingChildCount) + } + } + Section { + Button("Present child") { self.store.send(.presentChildButtonTapped) } + } + } + } + .sheet(store: self.store.scope(state: \.$child, action: \.child)) { store in + Form { + ObservableBasicsView(store: store) + } + .presentationDetents([.medium]) + } + } + + @Reducer + struct Feature { + struct State { + @PresentationState var child: ObservableBasicsView.Feature.State? + var count = 0 + var isObservingChildCount = false + } + enum Action { + case child(PresentationAction) + case incrementButtonTapped + case presentChildButtonTapped + case toggleObservingChildCount + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .child: + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + case .presentChildButtonTapped: + state.child = ObservableBasicsView.Feature.State() + return .none + case .toggleObservingChildCount: + state.isObservingChildCount.toggle() + return .none + } + } + .ifLet(\.$child, action: \.child) { + ObservableBasicsView.Feature() + } + } + } +} + +#Preview { + OldPresentsNewTestCase() +} diff --git a/Examples/Integration/Integration/BasicsTestCase.swift b/Examples/Integration/Integration/iOS 16/BasicsTestCase.swift similarity index 100% rename from Examples/Integration/Integration/BasicsTestCase.swift rename to Examples/Integration/Integration/iOS 16/BasicsTestCase.swift diff --git a/Examples/Integration/Integration/EnumTestCase.swift b/Examples/Integration/Integration/iOS 16/EnumTestCase.swift similarity index 100% rename from Examples/Integration/Integration/EnumTestCase.swift rename to Examples/Integration/Integration/iOS 16/EnumTestCase.swift diff --git a/Examples/Integration/Integration/IdentifiedListTestCase.swift b/Examples/Integration/Integration/iOS 16/IdentifiedListTestCase.swift similarity index 100% rename from Examples/Integration/Integration/IdentifiedListTestCase.swift rename to Examples/Integration/Integration/iOS 16/IdentifiedListTestCase.swift diff --git a/Examples/Integration/Integration/NavigationTestCase.swift b/Examples/Integration/Integration/iOS 16/NavigationTestCase.swift similarity index 100% rename from Examples/Integration/Integration/NavigationTestCase.swift rename to Examples/Integration/Integration/iOS 16/NavigationTestCase.swift diff --git a/Examples/Integration/Integration/OptionalTestCase.swift b/Examples/Integration/Integration/iOS 16/OptionalTestCase.swift similarity index 100% rename from Examples/Integration/Integration/OptionalTestCase.swift rename to Examples/Integration/Integration/iOS 16/OptionalTestCase.swift diff --git a/Examples/Integration/Integration/PresentationTestCase.swift b/Examples/Integration/Integration/iOS 16/PresentationTestCase.swift similarity index 100% rename from Examples/Integration/Integration/PresentationTestCase.swift rename to Examples/Integration/Integration/iOS 16/PresentationTestCase.swift diff --git a/Examples/Integration/Integration/iOS 16/SiblingTestCase.swift b/Examples/Integration/Integration/iOS 16/SiblingTestCase.swift new file mode 100644 index 000000000000..232532f6e72d --- /dev/null +++ b/Examples/Integration/Integration/iOS 16/SiblingTestCase.swift @@ -0,0 +1,83 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct SiblingFeaturesView: View { + @State var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + VStack { + Form { + Section { + BasicsView( + store: self.store.scope(state: \.child1, action: \.child1) + ) + } header: { + Text("Child 1") + } + Section { + BasicsView( + store: self.store.scope(state: \.child2, action: \.child2) + ) + } header: { + Text("Child 2") + } + Section { + Button("Reset all") { + self.store.send(.resetAllButtonTapped) + } + Button("Reset self") { + self.store.send(.resetSelfButtonTapped) + } + Button("Swap") { + self.store.send(.swapButtonTapped) + } + } + } + } + } + + @Reducer + struct Feature { + struct State: Equatable { + var child1 = BasicsView.Feature.State() + var child2 = BasicsView.Feature.State() + } + enum Action { + case child1(BasicsView.Feature.Action) + case child2(BasicsView.Feature.Action) + case resetAllButtonTapped + case resetSelfButtonTapped + case swapButtonTapped + } + var body: some ReducerOf { + Scope(state: \.child1, action: \.child1) { + BasicsView.Feature() + } + Scope(state: \.child2, action: \.child2) { + BasicsView.Feature() + } + Reduce { state, action in + switch action { + case .child1: + return .none + case .child2: + return .none + case .resetAllButtonTapped: + state.child1 = BasicsView.Feature.State() + state.child2 = BasicsView.Feature.State() + return .none + case .resetSelfButtonTapped: + state = State() + return .none + case .swapButtonTapped: + let copy = state.child1 + state.child1 = state.child2 + state.child2 = copy + return .none + } + } + } + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservableBasicsTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservableBasicsTestCase.swift new file mode 100644 index 000000000000..ae4ccac1f5d2 --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableBasicsTestCase.swift @@ -0,0 +1,68 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservableBasicsView: View { + var showExtraButtons = false + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + Text(self.store.count.description) + Button("Decrement") { self.store.send(.decrementButtonTapped) } + Button("Increment") { self.store.send(.incrementButtonTapped) } + Button("Dismiss") { self.store.send(.dismissButtonTapped) } + if self.showExtraButtons { + Button("Copy, increment, discard") { self.store.send(.copyIncrementDiscard) } + Button("Copy, increment, set") { self.store.send(.copyIncrementSet) } + Button("Reset") { self.store.send(.resetButtonTapped) } + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State: Equatable, Identifiable { + let id = UUID() + var count = 0 + } + enum Action { + case copyIncrementDiscard + case copyIncrementSet + case decrementButtonTapped + case dismissButtonTapped + case incrementButtonTapped + case resetButtonTapped + } + @Dependency(\.dismiss) var dismiss + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .copyIncrementDiscard: + var copy = state + copy.count += 1 + return .none + case .copyIncrementSet: + var copy = state + copy.count += 1 + state = copy + return .none + case .decrementButtonTapped: + state.count -= 1 + return .none + case .dismissButtonTapped: + return .run { _ in await self.dismiss() } + case .incrementButtonTapped: + state.count += 1 + return .none + case .resetButtonTapped: + state = State() + return .none + } + } + } + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservableBindingLocalTest.swift b/Examples/Integration/Integration/iOS 17/ObservableBindingLocalTest.swift new file mode 100644 index 000000000000..024d77a3f4c2 --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableBindingLocalTest.swift @@ -0,0 +1,152 @@ +import ComposableArchitecture +import SwiftUI + +@Reducer +private struct ObservableBindingLocalTestCase { + @ObservableState + struct State: Equatable { + @Presents var fullScreenCover: Child.State? + @Presents var navigationDestination: Child.State? + var path = StackState() + @Presents var popover: Child.State? + @Presents var sheet: Child.State? + } + enum Action { + case fullScreenCover(PresentationAction) + case fullScreenCoverButtonTapped + case navigationDestination(PresentationAction) + case navigationDestinationButtonTapped + case path(StackAction) + case popover(PresentationAction) + case popoverButtonTapped + case sheet(PresentationAction) + case sheetButtonTapped + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .fullScreenCover: + return .none + case .fullScreenCoverButtonTapped: + state.fullScreenCover = Child.State() + return .none + case .navigationDestination: + return .none + case .navigationDestinationButtonTapped: + state.navigationDestination = Child.State() + return .none + case .path: + return .none + case .popover: + return .none + case .popoverButtonTapped: + state.popover = Child.State() + return .none + case .sheet: + return .none + case .sheetButtonTapped: + state.sheet = Child.State() + return .none + } + } + .forEach(\.path, action: \.path) { + Child() + } + .ifLet(\.$fullScreenCover, action: \.fullScreenCover) { + Child() + } + .ifLet(\.$navigationDestination, action: \.navigationDestination) { + Child() + } + .ifLet(\.$popover, action: \.popover) { + Child() + } + .ifLet(\.$sheet, action: \.sheet) { + Child() + } + } +} + +@Reducer +private struct Child { + @ObservableState + struct State: Equatable { + var sendOnDisappear = false + var text = "" + } + enum Action: BindableAction { + case binding(BindingAction) + case onDisappear + } + var body: some ReducerOf { + BindingReducer() + } +} + +struct ObservableBindingLocalTestCaseView: View { + @State fileprivate var store = Store(initialState: ObservableBindingLocalTestCase.State()) { + ObservableBindingLocalTestCase() + } + + var body: some View { + WithPerceptionTracking { + NavigationStack(path: self.$store.scope(state: \.path, action: \.path)) { + VStack { + Button("Full-screen-cover") { + self.store.send(.fullScreenCoverButtonTapped) + } + Button("Navigation destination") { + self.store.send(.navigationDestinationButtonTapped) + } + NavigationLink("Path", state: Child.State()) + Button("Popover") { + self.store.send(.popoverButtonTapped) + } + Button("Sheet") { + self.store.send(.sheetButtonTapped) + } + } + .fullScreenCover( + item: self.$store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + ) { store in + ChildView(store: store) + } + .navigationDestination( + store: self.store.scope(state: \.$navigationDestination, action: \.navigationDestination) + ) { store in + ChildView(store: store) + } + .popover(item: self.$store.scope(state: \.popover, action: \.popover)) { store in + ChildView(store: store) + } + .sheet(item: self.$store.scope(state: \.sheet, action: \.sheet)) { store in + ChildView(store: store) + } + } destination: { store in + ChildView(store: store) + } + } + } +} + +private struct ChildView: View { + @State fileprivate var store: StoreOf + @Environment(\.dismiss) var dismiss + + var body: some View { + Form { + Button("Dismiss") { + self.dismiss() + } + TextField("Text", text: self.$store.text) + Button(self.store.sendOnDisappear ? "Don't send onDisappear" : "Send onDisappear") { + self.store.sendOnDisappear.toggle() + } + } + .onDisappear { + if self.store.sendOnDisappear { + self.store.send(.onDisappear) + } + } + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservableEnumTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservableEnumTestCase.swift new file mode 100644 index 000000000000..ad5aa341bdbb --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableEnumTestCase.swift @@ -0,0 +1,137 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservableEnumView: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + switch store.destination { + case .feature1: + Button("Toggle feature 1 off") { + self.store.send(.toggle1ButtonTapped) + } + Button("Toggle feature 2 on") { + self.store.send(.toggle2ButtonTapped) + } + case .feature2: + Button("Toggle feature 1 on") { + self.store.send(.toggle1ButtonTapped) + } + Button("Toggle feature 2 off") { + self.store.send(.toggle2ButtonTapped) + } + case .none: + Button("Toggle feature 1 on") { + self.store.send(.toggle1ButtonTapped) + } + Button("Toggle feature 2 on") { + self.store.send(.toggle2ButtonTapped) + } + } + } + if let store = self.store.scope(state: \.destination, action: \.destination.presented) { + switch store.state { + case .feature1: + if let store = store.scope(state: \.feature1, action: \.feature1) { + Section { + ObservableBasicsView(store: store) + } header: { + Text("Feature 1") + } + } + case .feature2: + if let store = store.scope(state: \.feature2, action: \.feature2) { + Section { + ObservableBasicsView(store: store) + } header: { + Text("Feature 2") + } + } + } + } + } + // NB: Conditional child views of `Form` that use `@State` are stale when they reappear. + // This `id` forces a refresh. + // + // Feedback filed: https://gist.github.com/stephencelis/fd078ca2d260c316b70dfc1e0f29883f + .id(UUID()) + } + } + + @Reducer + struct Feature { + @ObservableState + struct State: Equatable { + @Presents var destination: Destination.State? + } + enum Action { + case destination(PresentationAction) + case toggle1ButtonTapped + case toggle2ButtonTapped + } + @Reducer + struct Destination { + @ObservableState + enum State: Equatable { + case feature1(ObservableBasicsView.Feature.State) + case feature2(ObservableBasicsView.Feature.State) + } + enum Action { + case feature1(ObservableBasicsView.Feature.Action) + case feature2(ObservableBasicsView.Feature.Action) + } + var body: some ReducerOf { + Scope(state: \.feature1, action: \.feature1) { + ObservableBasicsView.Feature() + } + Scope(state: \.feature2, action: \.feature2) { + ObservableBasicsView.Feature() + } + } + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .destination: + return .none + case .toggle1ButtonTapped: + switch state.destination { + case .feature1: + state.destination = nil + case .feature2: + state.destination = .feature1(ObservableBasicsView.Feature.State()) + case .none: + state.destination = .feature1(ObservableBasicsView.Feature.State()) + } + return .none + case .toggle2ButtonTapped: + switch state.destination { + case .feature1: + state.destination = .feature2(ObservableBasicsView.Feature.State()) + case .feature2: + state.destination = nil + case .none: + state.destination = .feature2(ObservableBasicsView.Feature.State()) + } + return .none + } + } + .ifLet(\.$destination, action: \.destination) { + Destination() + } + } + } +} + +struct ObservableEnumTestCase_Previews: PreviewProvider { + static var previews: some View { + let _ = Logger.shared.isEnabled = true + ObservableEnumView() + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservableIdentifiedListTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservableIdentifiedListTestCase.swift new file mode 100644 index 000000000000..14a145f4fc98 --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableIdentifiedListTestCase.swift @@ -0,0 +1,81 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservableIdentifiedListView: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + List { + Section { + if let firstCount = self.store.rows.first?.count { + HStack { + Button("Increment First") { + self.store.send(.incrementFirstButtonTapped) + } + Spacer() + Text("Count: \(firstCount)") + } + } + } + ForEach(self.store.scope(state: \.rows, action: \.rows)) { store in + let _ = Logger.shared.log("\(Self.self).body.ForEach") + Section { + HStack { + VStack { + ObservableBasicsView(store: store) + } + Spacer() + Button(action: { self.store.send(.removeButtonTapped(id: store.state.id)) }) { + Image(systemName: "trash") + } + } + } + .buttonStyle(.borderless) + } + } + .toolbar { + ToolbarItem { + Button("Add") { self.store.send(.addButtonTapped) } + } + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State: Equatable { + var rows: IdentifiedArrayOf = [] + } + enum Action { + case addButtonTapped + case incrementFirstButtonTapped + case removeButtonTapped(id: ObservableBasicsView.Feature.State.ID) + case rows(IdentifiedActionOf) + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .addButtonTapped: + state.rows.append(ObservableBasicsView.Feature.State()) + return .none + case .incrementFirstButtonTapped: + state.rows[id: state.rows.ids[0]]?.count += 1 + return .none + case let .removeButtonTapped(id: id): + state.rows.remove(id: id) + return .none + case .rows: + return .none + } + } + .forEach(\.rows, action: \.rows) { + ObservableBasicsView.Feature() + } + } + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservableNavigationTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservableNavigationTestCase.swift new file mode 100644 index 000000000000..b6c17e322a9b --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableNavigationTestCase.swift @@ -0,0 +1,50 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservableNavigationTestCaseView: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + NavigationStack(path: self.$store.scope(state: \.path, action: \.path)) { + NavigationLink(state: ObservableBasicsView.Feature.State()) { + Text("Push feature") + } + } destination: { store in + Form { + Section { + ObservableBasicsView(store: store) + } + Section { + NavigationLink(state: ObservableBasicsView.Feature.State()) { + Text("Push feature") + } + } + } + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State: Equatable { + var path = StackState() + } + enum Action { + case path( + StackAction + ) + } + var body: some ReducerOf { + Reduce { state, action in + .none + } + .forEach(\.path, action: \.path) { + ObservableBasicsView.Feature() + } + } + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservableOptionalTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservableOptionalTestCase.swift new file mode 100644 index 000000000000..23468346cc60 --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableOptionalTestCase.swift @@ -0,0 +1,67 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservableOptionalView: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + Button("Toggle") { + self.store.send(.toggleButtonTapped) + } + } + if self.store.child != nil { + Section { + if self.store.isObservingCount { + Button("Stop observing count") { self.store.send(.toggleIsObservingCount) } + Text("Count: \(self.store.child?.count ?? 0)") + } else { + Button("Observe count") { self.store.send(.toggleIsObservingCount) } + } + } + } + } + if let store = self.store.scope(state: \.child, action: \.child.presented) { + Form { + ObservableBasicsView(store: store) + } + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State: Equatable { + @Presents var child: ObservableBasicsView.Feature.State? + var isObservingCount = false + } + enum Action { + case child(PresentationAction) + case toggleButtonTapped + case toggleIsObservingCount + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .child: + return .none + case .toggleButtonTapped: + state.child = state.child == nil ? ObservableBasicsView.Feature.State() : nil + return .none + case .toggleIsObservingCount: + state.isObservingCount.toggle() + return .none + } + } + .ifLet(\.$child, action: \.child) { + ObservableBasicsView.Feature() + } + } + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservablePresentationTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservablePresentationTestCase.swift new file mode 100644 index 000000000000..20d44dbc5714 --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservablePresentationTestCase.swift @@ -0,0 +1,165 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservablePresentationView: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + Button("Present full-screen cover") { + self.store.send(.presentFullScreenCoverButtonTapped) + } + Button("Present popover") { + self.store.send(.presentPopoverButtonTapped) + } + } header: { + Text("Enum") + } + Section { + Button("Present sheet") { + self.store.send(.presentSheetButtonTapped) + } + if self.store.isObservingChildCount, let sheetCount = self.store.sheet?.count { + Text("Count: \(sheetCount)") + } + } header: { + Text("Optional") + } + } + .fullScreenCover( + item: self.$store.scope( + state: \.destination?.fullScreenCover, + action: \.destination.fullScreenCover + ) + ) { store in + NavigationStack { + Form { + ObservableBasicsView(store: store) + } + .navigationTitle("Full-screen cover") + .toolbar { + ToolbarItem { + Button("Dismiss") { + self.store.send(.dismissButtonTapped) + } + } + } + } + } + .popover( + item: self.$store.scope(state: \.destination?.popover, action: \.destination.popover) + ) { store in + NavigationStack { + Form { + ObservableBasicsView(store: store) + } + .navigationTitle("Popover") + .toolbar { + ToolbarItem { + Button("Dismiss") { + self.store.send(.dismissButtonTapped) + } + } + } + } + } + .sheet(item: self.$store.scope(state: \.sheet, action: \.sheet)) { store in + NavigationStack { + Form { + ObservableBasicsView(store: store) + } + .navigationTitle("Sheet") + .toolbar { + ToolbarItem { + Button("Dismiss") { + self.store.send(.dismissButtonTapped) + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Observe child count") { + self.store.send(.toggleObserveChildCountButtonTapped) + } + } + } + } + .presentationDetents([.medium]) + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State: Equatable { + var isObservingChildCount = false + @Presents var destination: Destination.State? + @Presents var sheet: ObservableBasicsView.Feature.State? + } + enum Action { + case destination(PresentationAction) + case dismissButtonTapped + case presentFullScreenCoverButtonTapped + case presentPopoverButtonTapped + case presentSheetButtonTapped + case sheet(PresentationAction) + case toggleObserveChildCountButtonTapped + } + @Reducer + struct Destination { + @ObservableState + enum State: Equatable { + case fullScreenCover(ObservableBasicsView.Feature.State) + case popover(ObservableBasicsView.Feature.State) + } + enum Action { + case fullScreenCover(ObservableBasicsView.Feature.Action) + case popover(ObservableBasicsView.Feature.Action) + } + var body: some ReducerOf { + Scope(state: \.fullScreenCover, action: \.fullScreenCover) { + ObservableBasicsView.Feature() + } + Scope(state: \.popover, action: \.popover) { + ObservableBasicsView.Feature() + } + } + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .destination: + return .none + case .dismissButtonTapped: + state.destination = nil + state.sheet = nil + return .none + case .presentFullScreenCoverButtonTapped: + state.destination = .fullScreenCover(ObservableBasicsView.Feature.State()) + return .none + case .presentPopoverButtonTapped: + state.destination = .popover(ObservableBasicsView.Feature.State()) + return .none + case .presentSheetButtonTapped: + state.sheet = ObservableBasicsView.Feature.State() + return .none + case .sheet: + return .none + case .toggleObserveChildCountButtonTapped: + state.isObservingChildCount.toggle() + return .none + } + } + .ifLet(\.$destination, action: \.destination) { + Destination() + } + .ifLet(\.$sheet, action: \.sheet) { + ObservableBasicsView.Feature() + } + } + } +} diff --git a/Examples/Integration/Integration/iOS 17/ObservableSiblingTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservableSiblingTestCase.swift new file mode 100644 index 000000000000..64727ccca13d --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableSiblingTestCase.swift @@ -0,0 +1,92 @@ +@_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservableSiblingFeaturesView: View { + @Perception.Bindable var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + VStack { + Form { + Section { + ObservableBasicsView( + store: self.store.scope(state: \.child1, action: \.child1) + ) + } header: { + Text("Child 1") + } + Section { + ObservableBasicsView( + store: self.store.scope(state: \.child2, action: \.child2) + ) + } header: { + Text("Child 2") + } + Section { + Button("Reset all") { + self.store.send(.resetAllButtonTapped) + } + Button("Reset self") { + self.store.send(.resetSelfButtonTapped) + } + Button("Swap") { + self.store.send(.swapButtonTapped) + } + } + } + // NB: Conditional child views of `Form` that use `@State` are stale when they reappear. + // This `id` forces a refresh. + // + // Feedback filed: https://gist.github.com/stephencelis/fd078ca2d260c316b70dfc1e0f29883f + .id(UUID()) + } + } + } + + @Reducer + struct Feature { + @ObservableState + struct State: Equatable { + var child1 = ObservableBasicsView.Feature.State() + var child2 = ObservableBasicsView.Feature.State() + } + enum Action { + case child1(ObservableBasicsView.Feature.Action) + case child2(ObservableBasicsView.Feature.Action) + case resetAllButtonTapped + case resetSelfButtonTapped + case swapButtonTapped + } + var body: some ReducerOf { + Scope(state: \.child1, action: \.child1) { + ObservableBasicsView.Feature() + } + Scope(state: \.child2, action: \.child2) { + ObservableBasicsView.Feature() + } + Reduce { state, action in + switch action { + case .child1: + return .none + case .child2: + return .none + case .resetAllButtonTapped: + state.child1 = ObservableBasicsView.Feature.State() + state.child2 = ObservableBasicsView.Feature.State() + return .none + case .resetSelfButtonTapped: + state = State() + return .none + case .swapButtonTapped: + let copy = state.child1 + state.child1 = state.child2 + state.child2 = copy + return .none + } + } + } + } +} diff --git a/Examples/Integration/IntegrationUITests/Internal/BaseIntegrationTests.swift b/Examples/Integration/IntegrationUITests/Internal/BaseIntegrationTests.swift index ed124160a999..bfae1b2683b3 100644 --- a/Examples/Integration/IntegrationUITests/Internal/BaseIntegrationTests.swift +++ b/Examples/Integration/IntegrationUITests/Internal/BaseIntegrationTests.swift @@ -33,7 +33,10 @@ class BaseIntegrationTests: XCTestCase { line: line ) } else { - XCTAssertFalse(self.app.staticTexts["Runtime warning"].exists) + XCTAssertFalse( + self.app.staticTexts["Runtime warning"].exists, + "\(self.name) emitted an unexpected runtime warning" + ) } SnapshotTesting.isRecording = false } diff --git a/Examples/Integration/IntegrationUITests/Legacy/BindingLocalTests.swift b/Examples/Integration/IntegrationUITests/Legacy/BindingLocalTests.swift index bf51edc3beaa..aae1a60c6aff 100644 --- a/Examples/Integration/IntegrationUITests/Legacy/BindingLocalTests.swift +++ b/Examples/Integration/IntegrationUITests/Legacy/BindingLocalTests.swift @@ -7,11 +7,10 @@ final class BindingLocalTests: BaseIntegrationTests { try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil) try super.setUpWithError() self.app.buttons["Legacy"].tap() + app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() } func testNoBindingWarning_FullScreenCover() { - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Full-screen-cover"].tap() app.textFields["Text"].tap() @@ -22,8 +21,6 @@ final class BindingLocalTests: BaseIntegrationTests { func testOnDisappearWarning_FullScreenCover() { self.expectRuntimeWarnings() - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Full-screen-cover"].tap() app.buttons["Send onDisappear"].tap() @@ -34,8 +31,6 @@ final class BindingLocalTests: BaseIntegrationTests { } func testNoBindingWarning_NavigationDestination() { - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Navigation destination"].tap() app.textFields["Text"].tap() @@ -46,8 +41,6 @@ final class BindingLocalTests: BaseIntegrationTests { func testOnDisappearWarning_NavigationDestination() { self.expectRuntimeWarnings() - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Navigation destination"].tap() app.buttons["Send onDisappear"].tap() @@ -58,8 +51,6 @@ final class BindingLocalTests: BaseIntegrationTests { } func testNoBindingWarning_Path() { - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Path"].tap() app.textFields["Text"].tap() @@ -70,8 +61,6 @@ final class BindingLocalTests: BaseIntegrationTests { func testOnDisappearWarning_Path() { self.expectRuntimeWarnings() - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Path"].tap() app.buttons["Send onDisappear"].tap() @@ -82,8 +71,6 @@ final class BindingLocalTests: BaseIntegrationTests { } func testNoBindingWarning_Popover() { - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Popover"].tap() app.textFields["Text"].tap() @@ -94,8 +81,6 @@ final class BindingLocalTests: BaseIntegrationTests { func testOnDisappearWarning_Popover() { self.expectRuntimeWarnings() - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Popover"].tap() app.buttons["Send onDisappear"].tap() @@ -106,8 +91,6 @@ final class BindingLocalTests: BaseIntegrationTests { } func testNoBindingWarning_Sheet() { - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Sheet"].tap() app.textFields["Text"].tap() @@ -118,8 +101,6 @@ final class BindingLocalTests: BaseIntegrationTests { func testOnDisappearWarning_Sheet() { self.expectRuntimeWarnings() - app.collectionViews.buttons[TestCase.bindingLocal.rawValue].tap() - app.buttons["Sheet"].tap() app.buttons["Send onDisappear"].tap() diff --git a/Examples/Integration/IntegrationUITests/Legacy/EscapedWithViewStoreTests.swift b/Examples/Integration/IntegrationUITests/Legacy/EscapedWithViewStoreTests.swift index cb86f21e9516..1d93fc249a42 100644 --- a/Examples/Integration/IntegrationUITests/Legacy/EscapedWithViewStoreTests.swift +++ b/Examples/Integration/IntegrationUITests/Legacy/EscapedWithViewStoreTests.swift @@ -7,11 +7,10 @@ final class EscapedWithViewStoreTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["Legacy"].tap() + app.collectionViews.buttons[TestCase.escapedWithViewStore.rawValue].tap() } func testExample() async throws { - app.collectionViews.buttons[TestCase.escapedWithViewStore.rawValue].tap() - XCTAssertEqual(app.staticTexts["Label"].value as? String, "10") XCTAssertEqual(app.staticTexts["EscapedLabel"].value as? String, "10") diff --git a/Examples/Integration/IntegrationUITests/Legacy/ForEachBindingTests.swift b/Examples/Integration/IntegrationUITests/Legacy/ForEachBindingTests.swift index fab8722e0dd8..28e196e6a8a7 100644 --- a/Examples/Integration/IntegrationUITests/Legacy/ForEachBindingTests.swift +++ b/Examples/Integration/IntegrationUITests/Legacy/ForEachBindingTests.swift @@ -6,10 +6,10 @@ final class ForEachBindingTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["Legacy"].tap() + app.collectionViews.buttons[TestCase.forEachBinding.rawValue].tap() } func testExample() async throws { - app.collectionViews.buttons[TestCase.forEachBinding.rawValue].tap() app.buttons["Remove last"].tap() XCTAssertFalse(app.textFields["C"].exists) app.buttons["Remove last"].tap() diff --git a/Examples/Integration/IntegrationUITests/Legacy/LegacyNavigationTests.swift b/Examples/Integration/IntegrationUITests/Legacy/LegacyNavigationTests.swift index 8696935c1b93..c9b4e4c45e70 100644 --- a/Examples/Integration/IntegrationUITests/Legacy/LegacyNavigationTests.swift +++ b/Examples/Integration/IntegrationUITests/Legacy/LegacyNavigationTests.swift @@ -7,7 +7,7 @@ final class LegacyNavigationTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["Legacy"].tap() - self.app.collectionViews.buttons[TestCase.navigationStack.rawValue].tap() + self.app.buttons[TestCase.navigationStack.rawValue].tap() } func testChildLogic() { diff --git a/Examples/Integration/IntegrationUITests/Legacy/LegacyPresentationTests.swift b/Examples/Integration/IntegrationUITests/Legacy/LegacyPresentationTests.swift index 16a48433d904..b97cd77db543 100644 --- a/Examples/Integration/IntegrationUITests/Legacy/LegacyPresentationTests.swift +++ b/Examples/Integration/IntegrationUITests/Legacy/LegacyPresentationTests.swift @@ -7,7 +7,7 @@ final class LegacyPresentationTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["Legacy"].tap() - self.app.collectionViews.buttons[TestCase.presentation.rawValue].tap() + self.app.buttons[TestCase.presentation.rawValue].tap() } func testSheet_ChildDismiss() { diff --git a/Examples/Integration/IntegrationUITests/Legacy/SwitchStoreTests.swift b/Examples/Integration/IntegrationUITests/Legacy/SwitchStoreTests.swift index c65ca0df9db2..cc53364fdef1 100644 --- a/Examples/Integration/IntegrationUITests/Legacy/SwitchStoreTests.swift +++ b/Examples/Integration/IntegrationUITests/Legacy/SwitchStoreTests.swift @@ -7,13 +7,12 @@ final class SwitchStoreTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["Legacy"].tap() + app.collectionViews.buttons[TestCase.switchStore.rawValue].tap() } func testExample() async throws { self.expectRuntimeWarnings() - app.collectionViews.buttons[TestCase.switchStore.rawValue].tap() - XCTAssertFalse( app.staticTexts .containing(NSPredicate(format: #"label CONTAINS[c] "Warning: ""#)) diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/NewContainsOldTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/NewContainsOldTests.swift new file mode 100644 index 000000000000..731517491cc6 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/NewContainsOldTests.swift @@ -0,0 +1,92 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS16_17_NewContainsOldTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 16 + 17"].tap() + self.app.buttons["New containing old"].tap() + self.clearLogs() + //SnapshotTesting.isRecording = true + } + + func testIncrementDecrement() { + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + NewContainsOldTestCase.body + """ + } + + self.app.buttons.matching(identifier: "Decrement").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["-1"].exists, true) + self.assertLogs { + """ + BasicsView.body + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + WithViewStoreOf.body + """ + } + } + + func testObserveChildCount() { + self.app.buttons["Toggle observe child count"].tap() + XCTAssertEqual(self.app.staticTexts["Child count: 0"].exists, true) + self.assertLogs { + """ + NewContainsOldTestCase.body + """ + } + } + + func testIncrementChild_ObservingChildCount() { + self.app.buttons["Toggle observe child count"].tap() + self.clearLogs() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 1).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + XCTAssertEqual(self.app.staticTexts["Child count: 1"].exists, true) + self.assertLogs { + """ + BasicsView.body + BasicsView.body + NewContainsOldTestCase.body + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testDeinit() { + self.app.buttons["Toggle observe child count"].tap() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 1).tap() + self.clearLogs() + self.app.buttons["iOS 16 + 17"].tap() + self.assertLogs { + """ + StoreOf.deinit + StoreOf.deinit + ViewStoreOf.deinit + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/NewOldSiblingsTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/NewOldSiblingsTests.swift new file mode 100644 index 000000000000..cedf47c39f2e --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/NewOldSiblingsTests.swift @@ -0,0 +1,77 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS16_17_NewOldSiblingsTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 16 + 17"].tap() + self.app.buttons["Siblings"].tap() + self.clearLogs() + //SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + BasicsView.body + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStoreOf.body + """ + } + + self.app.buttons.matching(identifier: "Decrement").element(boundBy: 1).tap() + XCTAssertEqual(self.app.staticTexts["-1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testResetAll() { + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.app.buttons.matching(identifier: "Decrement").element(boundBy: 1).tap() + XCTAssertEqual(self.app.staticTexts["-1"].exists, true) + self.clearLogs() + self.app.buttons["Reset all"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, false) + XCTAssertEqual(self.app.staticTexts["-1"].exists, false) + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + self.assertLogs { + """ + BasicsView.body + ObservableBasicsView.body + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStoreOf.body + """ + } + } + + func testResetSelf() { + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.app.buttons.matching(identifier: "Decrement").element(boundBy: 1).tap() + XCTAssertEqual(self.app.staticTexts["-1"].exists, true) + self.clearLogs() + self.app.buttons["Reset self"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, false) + XCTAssertEqual(self.app.staticTexts["-1"].exists, false) + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + self.assertLogs { + """ + BasicsView.body + ObservableBasicsView.body + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStoreOf.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/NewPresentsOldTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/NewPresentsOldTests.swift new file mode 100644 index 000000000000..75caf19fd96d --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/NewPresentsOldTests.swift @@ -0,0 +1,401 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 16 + 17"].tap() + self.app.buttons["New presents old"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + NewPresentsOldTestCase.body + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.init + PresentationStoreOf.init + StoreOf.deinit + StoreOf.init + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.init + """ + } + } + + func testPresentChild_NotObservingChildCount() { + self.app.buttons["Present child"].tap() + self.assertLogs { + """ + BasicsView.body + NewPresentsOldTestCase.body + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.init + PresentationStoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testDismissChild_NotObservingChildCount() { + self.app.buttons["Present child"].tap() + self.clearLogs() + self.app.buttons["Dismiss"].tap() + self.assertLogs { + """ + BasicsView.body + BasicsView.body + BasicsView.body + NewPresentsOldTestCase.body + NewPresentsOldTestCase.body + PresentationStoreOf.init + PresentationStoreOf.init + PresentationStoreOf.init + PresentationStoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewPresentationStoreOf.init + ViewPresentationStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testObserveChildCount() { + self.app.buttons["Toggle observe child count"].tap() + XCTAssertEqual(self.app.staticTexts["Child count: N/A"].exists, true) + self.assertLogs { + """ + NewPresentsOldTestCase.body + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.init + PresentationStoreOf.init + StoreOf.deinit + StoreOf.init + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.init + """ + } + } + + func testPresentChild_ObservingChildCount() { + self.app.buttons["Toggle observe child count"].tap() + self.clearLogs() + self.app.buttons["Present child"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + XCTAssertEqual(self.app.staticTexts["Child count: 0"].exists, true) + self.assertLogs { + """ + BasicsView.body + NewPresentsOldTestCase.body + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.init + PresentationStoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testIncrementChild_ObservingChildCount() { + self.app.buttons["Toggle observe child count"].tap() + self.app.buttons["Present child"].tap() + self.clearLogs() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + XCTAssertEqual(self.app.staticTexts["Child count: 1"].exists, true) + self.assertLogs { + """ + BasicsView.body + BasicsView.body + BasicsView.body + NewPresentsOldTestCase.body + PresentationStoreOf.init + PresentationStoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewPresentationStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testDismissChild_ObservingChildCount() { + self.app.buttons["Toggle observe child count"].tap() + self.app.buttons["Present child"].tap() + self.clearLogs() + self.app.buttons["Dismiss"].tap() + self.assertLogs { + """ + BasicsView.body + BasicsView.body + BasicsView.body + NewPresentsOldTestCase.body + NewPresentsOldTestCase.body + PresentationStoreOf.init + PresentationStoreOf.init + PresentationStoreOf.init + PresentationStoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewPresentationStoreOf.init + ViewPresentationStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testDeinit() { + self.app.buttons["Toggle observe child count"].tap() + self.app.buttons["Present child"].tap() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + self.app.buttons["Dismiss"].tap() + self.clearLogs() + self.app.buttons["iOS 16 + 17"].tap() + self.assertLogs { + """ + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.deinit + PresentationStoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/OldContainsNewTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/OldContainsNewTests.swift new file mode 100644 index 000000000000..5bcfc5ece90b --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/OldContainsNewTests.swift @@ -0,0 +1,103 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS16_17_OldContainsNewTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 16 + 17"].tap() + self.app.buttons["Old containing new"].tap() + self.clearLogs() + //SnapshotTesting.isRecording = true + } + + func testIncrementDecrement() { + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + OldContainsNewTestCase.body + ViewStore.deinit + ViewStore.init + WithViewStore.body + """ + } + + self.app.buttons.matching(identifier: "Decrement").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["-1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + OldContainsNewTestCase.body + OldContainsNewTestCase.body + Store.deinit + Store.deinit + Store.init + Store.init + ViewStore.deinit + ViewStore.deinit + ViewStore.deinit + ViewStore.init + ViewStore.init + ViewStore.init + WithViewStore.body + WithViewStore.body + """ + } + } + + func testObserveChildCount() { + self.app.buttons["Toggle observing child count"].tap() + XCTAssertEqual(self.app.staticTexts["Child count: 0"].exists, true) + self.assertLogs { + """ + OldContainsNewTestCase.body + ViewStore.deinit + ViewStore.init + WithViewStore.body + """ + } + } + + func testIncrementChild_ObservingChildCount() { + self.app.buttons["Toggle observing child count"].tap() + self.clearLogs() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 1).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + XCTAssertEqual(self.app.staticTexts["Child count: 1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + OldContainsNewTestCase.body + OldContainsNewTestCase.body + Store.deinit + Store.deinit + Store.init + Store.init + ViewStore.deinit + ViewStore.deinit + ViewStore.deinit + ViewStore.init + ViewStore.init + ViewStore.init + WithViewStore.body + WithViewStore.body + """ + } + } + + func testDeinit() { + self.app.buttons["Toggle observing child count"].tap() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 1).tap() + self.clearLogs() + self.app.buttons["iOS 16 + 17"].tap() + self.assertLogs { + """ + Store.deinit + Store.deinit + ViewStore.deinit + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/OldPresentsNewTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/OldPresentsNewTests.swift new file mode 100644 index 000000000000..35195d86db1f --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/OldPresentsNewTests.swift @@ -0,0 +1,163 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS16_17_OldPresentsNewTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 16 + 17"].tap() + self.app.buttons["Old presents new"].tap() + self.clearLogs() + //SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + OldPresentsNewTestCase.body + ViewStore.deinit + ViewStore.init + WithViewStore.body + """ + } + } + + // TODO: Flakey test + func testPresentChild_NotObservingChildCount() { + self.app.buttons["Present child"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + OldPresentsNewTestCase.body + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + WithViewStore.body + WithViewStoreOf.body + """ + } + } + + // TODO: Flakey test + func testDismissChild_NotObservingChildCount() { + self.app.buttons["Present child"].tap() + self.clearLogs() + self.app.buttons["Dismiss"].tap() + self.assertLogs { + """ + OldPresentsNewTestCase.body + ViewStore.deinit + ViewStore.init + WithViewStore.body + """ + } + } + + func testObserveChildCount() { + self.app.buttons["Toggle observe child count"].tap() + XCTAssertEqual(self.app.staticTexts["Child count: N/A"].exists, true) + self.assertLogs { + """ + OldPresentsNewTestCase.body + ViewStore.deinit + ViewStore.init + WithViewStore.body + """ + } + } + + // TODO: Flakey test + func testPresentChild_ObservingChildCount() { + self.app.buttons["Toggle observe child count"].tap() + self.clearLogs() + self.app.buttons["Present child"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + OldPresentsNewTestCase.body + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + WithViewStore.body + WithViewStoreOf.body + """ + } + } + + func testIncrementChild_ObservingChildCount() { + self.app.buttons["Toggle observe child count"].tap() + self.app.buttons["Present child"].tap() + self.clearLogs() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + XCTAssertEqual(self.app.staticTexts["Child count: 1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + OldPresentsNewTestCase.body + ViewStore.deinit + ViewStore.init + WithViewStore.body + """ + } + } + + // TODO: Flakey test + func testDismissChild_ObservingChildCount() { + self.app.buttons["Toggle observe child count"].tap() + self.app.buttons["Present child"].tap() + self.clearLogs() + self.app.buttons["Dismiss"].tap() + self.assertLogs { + """ + OldPresentsNewTestCase.body + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + WithViewStore.body + """ + } + } + + func testDeinit() { + self.app.buttons["Toggle observe child count"].tap() + self.app.buttons["Present child"].tap() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 0).tap() + self.app.buttons["Dismiss"].tap() + self.clearLogs() + self.app.buttons["iOS 16 + 17"].tap() + self.assertLogs { + """ + PresentationStoreOf.deinit + PresentationStoreOf.deinit + Store.deinit + Store.deinit + StoreOf.deinit + ViewPresentationStoreOf.deinit + ViewStore.deinit + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/BasicsTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/BasicsTests.swift similarity index 66% rename from Examples/Integration/IntegrationUITests/BasicsTests.swift rename to Examples/Integration/IntegrationUITests/iOS 16/BasicsTests.swift index ed1289960976..d42b584a121f 100644 --- a/Examples/Integration/IntegrationUITests/BasicsTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16/BasicsTests.swift @@ -3,7 +3,7 @@ import TestCases import XCTest @MainActor -final class BasicsTests: BaseIntegrationTests { +final class iOS16_BasicsTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["iOS 16"].tap() @@ -13,7 +13,11 @@ final class BasicsTests: BaseIntegrationTests { } func testBasics() { + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + XCTAssertEqual(self.app.staticTexts["1"].exists, false) self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, false) + XCTAssertEqual(self.app.staticTexts["1"].exists, true) self.assertLogs { """ BasicsView.body @@ -23,6 +27,8 @@ final class BasicsTests: BaseIntegrationTests { """ } self.app.buttons["Decrement"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + XCTAssertEqual(self.app.staticTexts["1"].exists, false) self.assertLogs { """ BasicsView.body diff --git a/Examples/Integration/IntegrationUITests/iOS 16/EnumTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/EnumTests.swift new file mode 100644 index 000000000000..ee02f2530314 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 16/EnumTests.swift @@ -0,0 +1,175 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS16_EnumTests: BaseIntegrationTests { + override func setUpWithError() throws { + try super.setUpWithError() + self.app.buttons["iOS 16"].tap() + self.app.buttons["Enum"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.assertLogs { + """ + BasicsView.body + EnumView.body + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStore.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + BasicsView.body + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStoreOf.body + """ + } + } + + func testToggle1On_Toggle1Off() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.clearLogs() + self.app.buttons["Toggle feature 1 off"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, false) + self.assertLogs { + """ + EnumView.body + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStore.body + WithViewStoreOf.body + """ + } + } + + func testToggle1On_Toggle2On() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.clearLogs() + self.app.buttons["Toggle feature 2 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 2"].exists, true) + self.assertLogs { + """ + BasicsView.body + EnumView.body + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + StoreOf.init + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStore.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testToggle1On_Increment_Toggle1OffOn() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.app.buttons["Decrement"].tap() + XCTAssertEqual(self.app.staticTexts["-1"].exists, true) + self.app.buttons["Toggle feature 1 off"].tap() + self.clearLogs() + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + XCTAssertEqual(self.app.staticTexts["-1"].exists, false) + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + self.assertLogs { + """ + BasicsView.body + EnumView.body + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStore.body + WithViewStoreOf.body + WithViewStoreOf.body + """ + } + } + + func testDismiss() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.clearLogs() + self.app.buttons["Dismiss"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, false) + self.assertLogs { + """ + EnumView.body + ViewStore.deinit + ViewStore.init + ViewStoreOf.deinit + ViewStoreOf.init + WithViewStore.body + WithViewStoreOf.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/IdentifiedListTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/IdentifiedListTests.swift similarity index 97% rename from Examples/Integration/IntegrationUITests/IdentifiedListTests.swift rename to Examples/Integration/IntegrationUITests/iOS 16/IdentifiedListTests.swift index e5ab6f393edb..d2b275c609c7 100644 --- a/Examples/Integration/IntegrationUITests/IdentifiedListTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16/IdentifiedListTests.swift @@ -3,7 +3,7 @@ import TestCases import XCTest @MainActor -final class IdentifiedListTests: BaseIntegrationTests { +final class iOS16_IdentifiedListTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["iOS 16"].tap() @@ -111,7 +111,7 @@ final class IdentifiedListTests: BaseIntegrationTests { self.app.buttons["Add"].tap() self.app.buttons["Add"].tap() self.clearLogs() - self.app.cells.element(boundBy: 2).buttons["Increment"].tap() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 1).tap() XCTAssertEqual(self.app.staticTexts["Count: 0"].exists, true) self.assertLogs { """ diff --git a/Examples/Integration/IntegrationUITests/NavigationTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/NavigationTests.swift similarity index 96% rename from Examples/Integration/IntegrationUITests/NavigationTests.swift rename to Examples/Integration/IntegrationUITests/iOS 16/NavigationTests.swift index d340464b0233..b29beb16d26f 100644 --- a/Examples/Integration/IntegrationUITests/NavigationTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16/NavigationTests.swift @@ -3,7 +3,7 @@ import TestCases import XCTest @MainActor -final class NavigationTests: BaseIntegrationTests { +final class iOS16_NavigationTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["iOS 16"].tap() diff --git a/Examples/Integration/IntegrationUITests/OptionalTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/OptionalTests.swift similarity index 94% rename from Examples/Integration/IntegrationUITests/OptionalTests.swift rename to Examples/Integration/IntegrationUITests/iOS 16/OptionalTests.swift index 52b668a88c50..3c9a4d9b91e3 100644 --- a/Examples/Integration/IntegrationUITests/OptionalTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16/OptionalTests.swift @@ -3,9 +3,9 @@ import TestCases import XCTest @MainActor -final class OptionalTests: BaseIntegrationTests { - override func setUpWithError() throws { - try super.setUpWithError() +final class iOS16_OptionalTests: BaseIntegrationTests { + override func setUp() { + super.setUp() self.app.buttons["iOS 16"].tap() self.app.buttons["Optional"].tap() self.clearLogs() diff --git a/Examples/Integration/IntegrationUITests/PresentationTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/PresentationTests.swift similarity index 98% rename from Examples/Integration/IntegrationUITests/PresentationTests.swift rename to Examples/Integration/IntegrationUITests/iOS 16/PresentationTests.swift index 676f82dd3ac9..a65800edd4de 100644 --- a/Examples/Integration/IntegrationUITests/PresentationTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16/PresentationTests.swift @@ -3,7 +3,7 @@ import TestCases import XCTest @MainActor -final class PresentationTests: BaseIntegrationTests { +final class iOS16_PresentationTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["iOS 16"].tap() diff --git a/Examples/Integration/IntegrationUITests/SiblingTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/SiblingTests.swift similarity index 97% rename from Examples/Integration/IntegrationUITests/SiblingTests.swift rename to Examples/Integration/IntegrationUITests/iOS 16/SiblingTests.swift index 888a9f988389..f7f6fb9f2b73 100644 --- a/Examples/Integration/IntegrationUITests/SiblingTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16/SiblingTests.swift @@ -3,7 +3,7 @@ import TestCases import XCTest @MainActor -final class SiblingsTests: BaseIntegrationTests { +final class iOS16_SiblingsTests: BaseIntegrationTests { override func setUpWithError() throws { try super.setUpWithError() self.app.buttons["iOS 16"].tap() diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableBasicsTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableBasicsTests.swift new file mode 100644 index 000000000000..ae3657128570 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableBasicsTests.swift @@ -0,0 +1,101 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableBasicsTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Basics"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + XCTAssertEqual(self.app.staticTexts["1"].exists, false) + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, false) + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + self.app.buttons["Decrement"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + XCTAssertEqual(self.app.staticTexts["1"].exists, false) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testReset() { + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.clearLogs() + + self.app.buttons["Reset"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testCopyIncrementDiscard() { + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.clearLogs() + + self.app.buttons["Copy, increment, discard"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["2"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testCopyIncrementSet() { + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.clearLogs() + + self.app.buttons["Copy, increment, set"].tap() + XCTAssertEqual(self.app.staticTexts["2"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["3"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableBindingLocalTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableBindingLocalTests.swift new file mode 100644 index 000000000000..02a3fa43858e --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableBindingLocalTests.swift @@ -0,0 +1,114 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableBindingLocalTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Binding local"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testNoBindingWarning_FullScreenCover() { + self.app.buttons["Full-screen-cover"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testOnDisappearWarning_FullScreenCover() { + self.expectRuntimeWarnings() + + self.app.buttons["Full-screen-cover"].tap() + + self.app.buttons["Send onDisappear"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testNoBindingWarning_NavigationDestination() { + self.app.buttons["Navigation destination"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testOnDisappearWarning_NavigationDestination() { + self.expectRuntimeWarnings() + + self.app.buttons["Navigation destination"].tap() + + self.app.buttons["Send onDisappear"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testNoBindingWarning_Path() { + self.app.buttons["Path"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testOnDisappearWarning_Path() { + self.expectRuntimeWarnings() + + self.app.buttons["Path"].tap() + + self.app.buttons["Send onDisappear"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testNoBindingWarning_Popover() { + self.app.buttons["Popover"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testOnDisappearWarning_Popover() { + self.expectRuntimeWarnings() + + self.app.buttons["Popover"].tap() + + self.app.buttons["Send onDisappear"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testNoBindingWarning_Sheet() { + self.app.buttons["Sheet"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } + + func testOnDisappearWarning_Sheet() { + self.expectRuntimeWarnings() + + self.app.buttons["Sheet"].tap() + + self.app.buttons["Send onDisappear"].tap() + + self.app.textFields["Text"].tap() + + self.app.buttons["Dismiss"].tap() + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableEnumTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableEnumTests.swift new file mode 100644 index 000000000000..09308dcec5af --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableEnumTests.swift @@ -0,0 +1,78 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableEnumTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Enum"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableEnumView.body + StoreOf.init + StoreOf.init + """ + } + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testToggle1On_Toggle1Off() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.clearLogs() + self.app.buttons["Toggle feature 1 off"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, false) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableEnumView.body + """ + } + } + + func testToggle1On_Toggle2On() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.clearLogs() + self.app.buttons["Toggle feature 2 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 2"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableBasicsView.body + ObservableEnumView.body + StoreOf.init + """ + } + } + + func testDismiss() { + self.app.buttons["Toggle feature 1 on"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, true) + self.clearLogs() + self.app.buttons["Dismiss"].tap() + XCTAssertEqual(self.app.staticTexts["FEATURE 1"].exists, false) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableEnumView.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableIdentifiedListTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableIdentifiedListTests.swift new file mode 100644 index 000000000000..667a5deba2c5 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableIdentifiedListTests.swift @@ -0,0 +1,56 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableIdentifiedListTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Identified list"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Add"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + ObservableIdentifiedListView.body + ObservableIdentifiedListView.body.ForEach + ObservableIdentifiedListView.body.ForEach + StoreOf.init + """ + } + } + + func testAddTwoIncrementFirst() { + self.app.buttons["Add"].tap() + self.app.buttons["Add"].tap() + self.clearLogs() + self.app.buttons["Increment"].firstMatch.tap() + XCTAssertEqual(self.app.staticTexts["Count: 1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableIdentifiedListView.body + ObservableIdentifiedListView.body.ForEach + ObservableIdentifiedListView.body.ForEach + """ + } + } + + func testAddTwoIncrementSecond() { + self.app.buttons["Add"].tap() + self.app.buttons["Add"].tap() + self.clearLogs() + self.app.buttons.matching(identifier: "Increment").element(boundBy: 1).tap() + XCTAssertEqual(self.app.staticTexts["Count: 0"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableNavigationTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableNavigationTests.swift new file mode 100644 index 000000000000..eeda36db8f6b --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableNavigationTests.swift @@ -0,0 +1,45 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableNavigationTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Navigation"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Push feature"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + StoreOf.init + """ + } + self.app.buttons["Increment"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testDeepStack() { + self.app.buttons["Push feature"].tap() + self.app.buttons["Push feature"].tap() + self.app.buttons["Push feature"].tap() + self.app.buttons["Push feature"].tap() + self.app.buttons["Push feature"].tap() + self.clearLogs() + self.app.buttons["Increment"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableOptionalTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableOptionalTests.swift new file mode 100644 index 000000000000..63761ee0ed05 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableOptionalTests.swift @@ -0,0 +1,55 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableOptionalTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Optional"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Toggle"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, true) + XCTAssertEqual(self.app.staticTexts["1"].exists, false) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableOptionalView.body + StoreOf.init + """ + } + self.app.buttons["Increment"].tap() + XCTAssertEqual(self.app.staticTexts["0"].exists, false) + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testParentObserveChild() { + self.app.buttons["Toggle"].tap() + self.app.buttons["Increment"].tap() + self.clearLogs() + self.app.buttons["Observe count"].tap() + XCTAssertEqual(self.app.staticTexts["Count: 1"].exists, true) + self.assertLogs { + """ + ObservableOptionalView.body + """ + } + self.app.buttons["Increment"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + ObservableOptionalView.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservablePresentationTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservablePresentationTests.swift new file mode 100644 index 000000000000..3101a4440634 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservablePresentationTests.swift @@ -0,0 +1,70 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservablePresentationTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Presentation"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testOptional() { + self.app.buttons["Present sheet"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + ObservablePresentationView.body + StoreOf.init + """ + } + self.app.buttons["Increment"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + self.app.buttons["Dismiss"].firstMatch.tap() + self.assertLogs { + """ + ObservableBasicsView.body + ObservablePresentationView.body + """ + } + } + + func testOptional_ObserveChildCount() { + self.app.buttons["Present sheet"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + ObservablePresentationView.body + StoreOf.init + """ + } + self.app.buttons["Observe child count"].tap() + self.assertLogs { + """ + ObservablePresentationView.body + """ + } + self.app.buttons["Increment"].tap() + self.assertLogs { + """ + ObservableBasicsView.body + ObservablePresentationView.body + """ + } + XCTAssertEqual(self.app.staticTexts["Count: 1"].exists, true) + self.app.buttons["Dismiss"].firstMatch.tap() + self.assertLogs { + """ + ObservableBasicsView.body + ObservablePresentationView.body + """ + } + } +} diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableSiblingTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableSiblingTests.swift new file mode 100644 index 000000000000..c7d317a9f113 --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableSiblingTests.swift @@ -0,0 +1,72 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableSiblingsTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Siblings"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testBasics() { + self.app.buttons["Increment"].firstMatch.tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + """ + } + } + + func testResetAll() { + self.app.buttons["Increment"].firstMatch.tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.clearLogs() + self.app.buttons["Reset all"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, false) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableBasicsView.body + ObservableBasicsView.body + ObservableBasicsView.body + ObservableSiblingFeaturesView.body + """ + } + } + + func testResetSelf() { + self.app.buttons["Increment"].firstMatch.tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.clearLogs() + self.app.buttons["Reset self"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, false) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableBasicsView.body + """ + } + } + + func testResetSwap() { + self.app.buttons["Increment"].firstMatch.tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.clearLogs() + self.app.buttons["Swap"].tap() + XCTAssertEqual(self.app.staticTexts["1"].exists, true) + self.assertLogs { + """ + ObservableBasicsView.body + ObservableBasicsView.body + ObservableBasicsView.body + ObservableBasicsView.body + ObservableSiblingFeaturesView.body + """ + } + } +} diff --git a/Examples/Search/Search/SearchView.swift b/Examples/Search/Search/SearchView.swift index 6f57e49c8599..a1b7d7191f5b 100644 --- a/Examples/Search/Search/SearchView.swift +++ b/Examples/Search/Search/SearchView.swift @@ -11,6 +11,7 @@ private let readMe = """ @Reducer struct Search { + @ObservableState struct State: Equatable { var results: [GeocodingSearch.Result] = [] var resultForecastRequestInFlight: GeocodingSearch.Result? @@ -71,7 +72,7 @@ struct Search { // When the query is cleared we can clear the search results, but we have to make sure to // cancel any in-flight search requests too, otherwise we may get data coming in later. - guard !query.isEmpty else { + guard !state.searchQuery.isEmpty else { state.results = [] state.weather = nil return .cancel(id: CancelID.location) @@ -115,63 +116,60 @@ struct Search { // MARK: - Search feature view struct SearchView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - NavigationStack { - VStack(alignment: .leading) { - Text(readMe) - .padding() - - HStack { - Image(systemName: "magnifyingglass") - TextField( - "New York, San Francisco, ...", - text: viewStore.binding(get: \.searchQuery, send: { .searchQueryChanged($0) }) - ) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - } - .padding(.horizontal, 16) - - List { - ForEach(viewStore.results) { location in - VStack(alignment: .leading) { - Button { - viewStore.send(.searchResultTapped(location)) - } label: { - HStack { - Text(location.name) - - if viewStore.resultForecastRequestInFlight?.id == location.id { - ProgressView() - } + NavigationStack { + VStack(alignment: .leading) { + Text(readMe) + .padding() + + HStack { + Image(systemName: "magnifyingglass") + TextField( + "New York, San Francisco, ...", text: $store.searchQuery.sending(\.searchQueryChanged) + ) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + .padding(.horizontal, 16) + + List { + ForEach(store.results) { location in + VStack(alignment: .leading) { + Button { + store.send(.searchResultTapped(location)) + } label: { + HStack { + Text(location.name) + + if store.resultForecastRequestInFlight?.id == location.id { + ProgressView() } } + } - if location.id == viewStore.weather?.id { - self.weatherView(locationWeather: viewStore.weather) - } + if location.id == store.weather?.id { + weatherView(locationWeather: store.weather) } } } + } - Button("Weather API provided by Open-Meteo") { - UIApplication.shared.open(URL(string: "https://open-meteo.com/en")!) - } - .foregroundColor(.gray) - .padding(.all, 16) + Button("Weather API provided by Open-Meteo") { + UIApplication.shared.open(URL(string: "https://open-meteo.com/en")!) } - .navigationTitle("Search") - } - .task(id: viewStore.searchQuery) { - do { - try await Task.sleep(for: .milliseconds(300)) - await viewStore.send(.searchQueryChangeDebounced).finish() - } catch {} + .foregroundColor(.gray) + .padding(.all, 16) } + .navigationTitle("Search") + } + .task(id: store.searchQuery) { + do { + try await Task.sleep(for: .milliseconds(300)) + await store.send(.searchQueryChangeDebounced).finish() + } catch {} } } diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift index 2b268ca5aff2..66aea76793c0 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift @@ -10,8 +10,9 @@ private let readMe = """ @Reducer struct SpeechRecognition { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? var isRecording = false var transcribedText = "" } @@ -109,47 +110,45 @@ struct SpeechRecognition { } struct SpeechRecognitionView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - VStack(alignment: .leading) { - Text(readMe) - .padding(.bottom, 32) - } + VStack { + VStack(alignment: .leading) { + Text(readMe) + .padding(.bottom, 32) + } - ScrollView { - ScrollViewReader { proxy in - Text(viewStore.transcribedText) - .font(.largeTitle) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } + ScrollView { + ScrollViewReader { proxy in + Text(store.transcribedText) + .font(.largeTitle) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - - Spacer() - - Button { - viewStore.send(.recordButtonTapped) - } label: { - HStack { - Image( - systemName: viewStore.isRecording - ? "stop.circle.fill" : "arrowtriangle.right.circle.fill" - ) - .font(.title) - Text(viewStore.isRecording ? "Stop Recording" : "Start Recording") - } - .foregroundColor(.white) - .padding() - .background(viewStore.isRecording ? Color.red : .green) - .cornerRadius(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + Spacer() + + Button { + store.send(.recordButtonTapped) + } label: { + HStack { + Image( + systemName: store.isRecording + ? "stop.circle.fill" : "arrowtriangle.right.circle.fill" + ) + .font(.title) + Text(store.isRecording ? "Stop Recording" : "Start Recording") } + .foregroundColor(.white) + .padding() + .background(store.isRecording ? Color.red : .green) + .cornerRadius(16) } - .padding() - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) } + .padding() + .alert($store.scope(state: \.alert, action: \.alert)) } } diff --git a/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj b/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj index 916f7da0c5d9..bde4a86fef2a 100644 --- a/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - CA0934B62A12A9680020DEF5 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA0934B52A12A9680020DEF5 /* SwiftUINavigation */; }; + CA14FF052B361C7400104A70 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA14FF042B361C7400104A70 /* SwiftUINavigation */; }; DC7CE4E729E9E6E4006B6263 /* ding.wav in Resources */ = {isa = PBXBuildFile; fileRef = DC7CE4E629E9E6E4006B6263 /* ding.wav */; }; DC808D7329E9C3AC0072B4A9 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D7229E9C3AC0072B4A9 /* App.swift */; }; DC808D7729E9C3AD0072B4A9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC808D7629E9C3AD0072B4A9 /* Assets.xcassets */; }; @@ -82,7 +82,7 @@ files = ( DC808DB629E9C58F0072B4A9 /* Tagged in Frameworks */, DC808DA329E9C4490072B4A9 /* ComposableArchitecture in Frameworks */, - CA0934B62A12A9680020DEF5 /* SwiftUINavigation in Frameworks */, + CA14FF052B361C7400104A70 /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -208,7 +208,7 @@ packageProductDependencies = ( DC808DA229E9C4490072B4A9 /* ComposableArchitecture */, DC808DB529E9C58F0072B4A9 /* Tagged */, - CA0934B52A12A9680020DEF5 /* SwiftUINavigation */, + CA14FF042B361C7400104A70 /* SwiftUINavigation */, ); productName = SyncUps; productReference = DC808D6F29E9C3AC0072B4A9 /* SyncUps.app */; @@ -284,7 +284,7 @@ mainGroup = DC808D6629E9C3AC0072B4A9; packageReferences = ( DC808DB429E9C58F0072B4A9 /* XCRemoteSwiftPackageReference "swift-tagged" */, - CA0934B42A12A9680020DEF5 /* XCRemoteSwiftPackageReference "swiftui-navigation" */, + CA14FF032B361C7400104A70 /* XCRemoteSwiftPackageReference "swiftui-navigation" */, ); productRefGroup = DC808D7029E9C3AC0072B4A9 /* Products */; projectDirPath = ""; @@ -428,7 +428,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -483,7 +482,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -561,7 +559,6 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = VFRXY8HC3H; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -581,7 +578,6 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = VFRXY8HC3H; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -670,12 +666,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - CA0934B42A12A9680020DEF5 /* XCRemoteSwiftPackageReference "swiftui-navigation" */ = { + CA14FF032B361C7400104A70 /* XCRemoteSwiftPackageReference "swiftui-navigation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swiftui-navigation.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.1.0; + minimumVersion = 1.2.0; }; }; DC808DB429E9C58F0072B4A9 /* XCRemoteSwiftPackageReference "swift-tagged" */ = { @@ -689,9 +685,9 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - CA0934B52A12A9680020DEF5 /* SwiftUINavigation */ = { + CA14FF042B361C7400104A70 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; - package = CA0934B42A12A9680020DEF5 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; + package = CA14FF032B361C7400104A70 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; productName = SwiftUINavigation; }; DC808DA229E9C4490072B4A9 /* ComposableArchitecture */ = { diff --git a/Examples/SyncUps/SyncUps/App.swift b/Examples/SyncUps/SyncUps/App.swift index dcf087d014c1..b71f1d941932 100644 --- a/Examples/SyncUps/SyncUps/App.swift +++ b/Examples/SyncUps/SyncUps/App.swift @@ -3,37 +3,27 @@ import SwiftUI @main struct SyncUpsApp: App { + let store = Store(initialState: AppFeature.State()) { + AppFeature() + ._printChanges() + } withDependencies: { + if ProcessInfo.processInfo.environment["UITesting"] == "true" { + $0.dataManager = .mock() + } + } + var body: some Scene { WindowGroup { // NB: This conditional is here only to facilitate UI testing so that we can mock out certain // dependencies for the duration of the test (e.g. the data manager). We do not really // recommend performing UI tests in general, but we do want to demonstrate how it can be // done. - if ProcessInfo.processInfo.environment["UITesting"] == "true" { - UITestingView() - } else if _XCTIsTesting { + if _XCTIsTesting { // NB: Don't run application when testing so that it doesn't interfere with tests. EmptyView() } else { - AppView( - store: Store(initialState: AppFeature.State()) { - AppFeature() - ._printChanges() - } - ) + AppView(store: store) } } } } - -struct UITestingView: View { - var body: some View { - AppView( - store: Store(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.dataManager = .mock() - } - ) - } -} diff --git a/Examples/SyncUps/SyncUps/AppFeature.swift b/Examples/SyncUps/SyncUps/AppFeature.swift index 004a6d72e089..07cb65bda1b4 100644 --- a/Examples/SyncUps/SyncUps/AppFeature.swift +++ b/Examples/SyncUps/SyncUps/AppFeature.swift @@ -3,6 +3,7 @@ import SwiftUI @Reducer struct AppFeature { + @ObservableState struct State: Equatable { var path = StackState() var syncUpsList = SyncUpsList.State() @@ -97,6 +98,7 @@ struct AppFeature { @Reducer struct Path { + @ObservableState enum State: Equatable { case detail(SyncUpDetail.State) case meeting(Meeting, syncUp: SyncUp) @@ -120,29 +122,25 @@ struct AppFeature { } struct AppView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - NavigationStackStore(self.store.scope(state: \.path, action: \.path)) { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { SyncUpsListView( - store: self.store.scope(state: \.syncUpsList, action: \.syncUpsList) + store: store.scope(state: \.syncUpsList, action: \.syncUpsList) ) - } destination: { - switch $0 { + } destination: { store in + switch store.state { case .detail: - CaseLet( - \AppFeature.Path.State.detail, - action: AppFeature.Path.Action.detail, - then: SyncUpDetailView.init(store:) - ) + if let store = store.scope(state: \.detail, action: \.detail) { + SyncUpDetailView(store: store) + } case let .meeting(meeting, syncUp: syncUp): MeetingView(meeting: meeting, syncUp: syncUp) case .record: - CaseLet( - \AppFeature.Path.State.record, - action: AppFeature.Path.Action.record, - then: RecordMeetingView.init(store:) - ) + if let store = store.scope(state: \.record, action: \.record) { + RecordMeetingView(store: store) + } } } } diff --git a/Examples/SyncUps/SyncUps/RecordMeeting.swift b/Examples/SyncUps/SyncUps/RecordMeeting.swift index 1bf735f2799f..65a8e2ae64b9 100644 --- a/Examples/SyncUps/SyncUps/RecordMeeting.swift +++ b/Examples/SyncUps/SyncUps/RecordMeeting.swift @@ -4,8 +4,9 @@ import SwiftUI @Reducer struct RecordMeeting { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? var secondsElapsed = 0 var speakerIndex = 0 var syncUp: SyncUp @@ -147,60 +148,45 @@ struct RecordMeeting { } struct RecordMeetingView: View { - let store: StoreOf - - struct ViewState: Equatable { - let durationRemaining: Duration - let secondsElapsed: Int - let speakerIndex: Int - let syncUp: SyncUp - init(state: RecordMeeting.State) { - self.durationRemaining = state.durationRemaining - self.secondsElapsed = state.secondsElapsed - self.syncUp = state.syncUp - self.speakerIndex = state.speakerIndex - } - } + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(viewStore.syncUp.theme.mainColor) - - VStack { - MeetingHeaderView( - secondsElapsed: viewStore.secondsElapsed, - durationRemaining: viewStore.durationRemaining, - theme: viewStore.syncUp.theme - ) - MeetingTimerView( - syncUp: viewStore.syncUp, - speakerIndex: viewStore.speakerIndex - ) - MeetingFooterView( - syncUp: viewStore.syncUp, - nextButtonTapped: { - viewStore.send(.nextButtonTapped) - }, - speakerIndex: viewStore.speakerIndex - ) - } + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(store.syncUp.theme.mainColor) + + VStack { + MeetingHeaderView( + secondsElapsed: store.secondsElapsed, + durationRemaining: store.durationRemaining, + theme: store.syncUp.theme + ) + MeetingTimerView( + syncUp: store.syncUp, + speakerIndex: store.speakerIndex + ) + MeetingFooterView( + syncUp: store.syncUp, + nextButtonTapped: { + store.send(.nextButtonTapped) + }, + speakerIndex: store.speakerIndex + ) } - .padding() - .foregroundColor(viewStore.syncUp.theme.accentColor) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("End meeting") { - viewStore.send(.endMeetingButtonTapped) - } + } + .padding() + .foregroundColor(store.syncUp.theme.accentColor) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("End meeting") { + store.send(.endMeetingButtonTapped) } } - .navigationBarBackButtonHidden(true) - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) - .task { await viewStore.send(.onTask).finish() } } + .navigationBarBackButtonHidden(true) + .alert($store.scope(state: \.alert, action: \.alert)) + .task { await store.send(.onTask).finish() } } } diff --git a/Examples/SyncUps/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUps/SyncUpDetail.swift index 5709d178794b..9c627f330349 100644 --- a/Examples/SyncUps/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUps/SyncUpDetail.swift @@ -3,8 +3,9 @@ import SwiftUI @Reducer struct SyncUpDetail { + @ObservableState struct State: Equatable { - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var syncUp: SyncUp } @@ -32,6 +33,7 @@ struct SyncUpDetail { @Reducer struct Destination { + @ObservableState enum State: Equatable { case alert(AlertState) case edit(SyncUpForm.State) @@ -132,108 +134,97 @@ struct SyncUpDetail { } struct SyncUpDetailView: View { - let store: StoreOf - - struct ViewState: Equatable { - let syncUp: SyncUp - init(state: SyncUpDetail.State) { - self.syncUp = state.syncUp - } - } + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - List { - Section { - Button { - viewStore.send(.startMeetingButtonTapped) - } label: { - Label("Start Meeting", systemImage: "timer") - .font(.headline) - .foregroundColor(.accentColor) - } - HStack { - Label("Length", systemImage: "clock") - Spacer() - Text(viewStore.syncUp.duration.formatted(.units())) - } + Form { + Section { + Button { + store.send(.startMeetingButtonTapped) + } label: { + Label("Start Meeting", systemImage: "timer") + .font(.headline) + .foregroundColor(.accentColor) + } + HStack { + Label("Length", systemImage: "clock") + Spacer() + Text(store.syncUp.duration.formatted(.units())) + } - HStack { - Label("Theme", systemImage: "paintpalette") - Spacer() - Text(viewStore.syncUp.theme.name) - .padding(4) - .foregroundColor(viewStore.syncUp.theme.accentColor) - .background(viewStore.syncUp.theme.mainColor) - .cornerRadius(4) - } - } header: { - Text("Sync-up Info") + HStack { + Label("Theme", systemImage: "paintpalette") + Spacer() + Text(store.syncUp.theme.name) + .padding(4) + .foregroundColor(store.syncUp.theme.accentColor) + .background(store.syncUp.theme.mainColor) + .cornerRadius(4) } + } header: { + Text("Sync-up Info") + } - if !viewStore.syncUp.meetings.isEmpty { - Section { - ForEach(viewStore.syncUp.meetings) { meeting in - NavigationLink( - state: AppFeature.Path.State.meeting(meeting, syncUp: viewStore.syncUp) - ) { - HStack { - Image(systemName: "calendar") - Text(meeting.date, style: .date) - Text(meeting.date, style: .time) - } + if !store.syncUp.meetings.isEmpty { + Section { + ForEach(store.syncUp.meetings) { meeting in + NavigationLink( + state: AppFeature.Path.State.meeting(meeting, syncUp: store.syncUp) + ) { + HStack { + Image(systemName: "calendar") + Text(meeting.date, style: .date) + Text(meeting.date, style: .time) } } - .onDelete { indices in - viewStore.send(.deleteMeetings(atOffsets: indices)) - } - } header: { - Text("Past meetings") } - } - - Section { - ForEach(viewStore.syncUp.attendees) { attendee in - Label(attendee.name, systemImage: "person") + .onDelete { indices in + store.send(.deleteMeetings(atOffsets: indices)) } } header: { - Text("Attendees") + Text("Past meetings") } + } - Section { - Button("Delete") { - viewStore.send(.deleteButtonTapped) - } - .foregroundColor(.red) - .frame(maxWidth: .infinity) + Section { + ForEach(store.syncUp.attendees) { attendee in + Label(attendee.name, systemImage: "person") } + } header: { + Text("Attendees") } - .navigationTitle(viewStore.syncUp.title) - .toolbar { - Button("Edit") { - viewStore.send(.editButtonTapped) + + Section { + Button("Delete") { + store.send(.deleteButtonTapped) } + .foregroundColor(.red) + .frame(maxWidth: .infinity) } - .alert(store: self.store.scope(state: \.$destination.alert, action: \.destination.alert)) - .sheet( - store: self.store.scope(state: \.$destination.edit, action: \.destination.edit) - ) { store in - NavigationStack { - SyncUpFormView(store: store) - .navigationTitle(viewStore.syncUp.title) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - viewStore.send(.cancelEditButtonTapped) - } + } + .toolbar { + Button("Edit") { + store.send(.editButtonTapped) + } + } + .navigationTitle(store.syncUp.title) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .sheet(item: $store.scope(state: \.destination?.edit, action: \.destination.edit)) { store in + NavigationStack { + SyncUpFormView(store: store) + .navigationTitle(self.store.syncUp.title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.store.send(.cancelEditButtonTapped) } - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - viewStore.send(.doneEditingButtonTapped) - } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + self.store.send(.doneEditingButtonTapped) } } - } + } } } } diff --git a/Examples/SyncUps/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUps/SyncUpForm.swift index ac223454877b..e24a3b62e0cb 100644 --- a/Examples/SyncUps/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUps/SyncUpForm.swift @@ -4,9 +4,10 @@ import SwiftUINavigation @Reducer struct SyncUpForm { + @ObservableState struct State: Equatable, Sendable { - @BindingState var focus: Field? = .title - @BindingState var syncUp: SyncUp + var focus: Field? = .title + var syncUp: SyncUp init(focus: Field? = .title, syncUp: SyncUp) { self.focus = focus @@ -60,44 +61,42 @@ struct SyncUpForm { } struct SyncUpFormView: View { - let store: StoreOf + @Bindable var store: StoreOf @FocusState var focus: SyncUpForm.State.Field? var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Section { - TextField("Title", text: viewStore.$syncUp.title) - .focused(self.$focus, equals: .title) - HStack { - Slider(value: viewStore.$syncUp.duration.minutes, in: 5...30, step: 1) { - Text("Length") - } - Spacer() - Text(viewStore.syncUp.duration.formatted(.units())) + Form { + Section { + TextField("Title", text: $store.syncUp.title) + .focused($focus, equals: .title) + HStack { + Slider(value: $store.syncUp.duration.minutes, in: 5...30, step: 1) { + Text("Length") } - ThemePicker(selection: viewStore.$syncUp.theme) - } header: { - Text("Sync-up Info") + Spacer() + Text(store.syncUp.duration.formatted(.units())) + } + ThemePicker(selection: $store.syncUp.theme) + } header: { + Text("Sync-up Info") + } + Section { + ForEach($store.syncUp.attendees) { $attendee in + TextField("Name", text: $attendee.name) + .focused($focus, equals: .attendee(attendee.id)) + } + .onDelete { indices in + store.send(.deleteAttendees(atOffsets: indices)) } - Section { - ForEach(viewStore.$syncUp.attendees) { $attendee in - TextField("Name", text: $attendee.name) - .focused(self.$focus, equals: .attendee(attendee.id)) - } - .onDelete { indices in - viewStore.send(.deleteAttendees(atOffsets: indices)) - } - Button("New attendee") { - viewStore.send(.addAttendeeButtonTapped) - } - } header: { - Text("Attendees") + Button("New attendee") { + store.send(.addAttendeeButtonTapped) } + } header: { + Text("Attendees") } - .bind(viewStore.$focus, to: self.$focus) } + .bind($store.focus, to: $focus) } } diff --git a/Examples/SyncUps/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUps/SyncUpsList.swift index f746b3c698eb..d8fd373debf9 100644 --- a/Examples/SyncUps/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUps/SyncUpsList.swift @@ -3,8 +3,9 @@ import SwiftUI @Reducer struct SyncUpsList { + @ObservableState struct State: Equatable { - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var syncUps: IdentifiedArrayOf = [] init( @@ -27,10 +28,12 @@ struct SyncUpsList { case confirmAddSyncUpButtonTapped case destination(PresentationAction) case dismissAddSyncUpButtonTapped + case onDelete(IndexSet) } @Reducer struct Destination { + @ObservableState enum State: Equatable { case add(SyncUpForm.State) case alert(AlertState) @@ -93,6 +96,10 @@ struct SyncUpsList { case .dismissAddSyncUpButtonTapped: state.destination = nil return .none + + case let .onDelete(indexSet): + state.syncUps.remove(atOffsets: indexSet) + return .none } } .ifLet(\.$destination, action: \.destination) { @@ -102,48 +109,47 @@ struct SyncUpsList { } struct SyncUpsListView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: \.syncUps) { viewStore in - List { - ForEach(viewStore.state) { syncUp in - NavigationLink( - state: AppFeature.Path.State.detail(SyncUpDetail.State(syncUp: syncUp)) - ) { - CardView(syncUp: syncUp) - } - .listRowBackground(syncUp.theme.mainColor) + List { + ForEach(store.syncUps) { syncUp in + NavigationLink( + state: AppFeature.Path.State.detail(SyncUpDetail.State(syncUp: syncUp)) + ) { + CardView(syncUp: syncUp) } + .listRowBackground(syncUp.theme.mainColor) } - .toolbar { - Button { - viewStore.send(.addSyncUpButtonTapped) - } label: { - Image(systemName: "plus") - } + .onDelete { indexSet in + store.send(.onDelete(indexSet)) } - .navigationTitle("Daily Sync-ups") - .alert(store: self.store.scope(state: \.$destination.alert, action: \.destination.alert)) - .sheet( - store: self.store.scope(state: \.$destination.add, action: \.destination.add) - ) { store in - NavigationStack { - SyncUpFormView(store: store) - .navigationTitle("New sync-up") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Dismiss") { - viewStore.send(.dismissAddSyncUpButtonTapped) - } + } + .toolbar { + Button { + store.send(.addSyncUpButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .navigationTitle("Daily Sync-ups") + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .sheet(item: $store.scope(state: \.destination?.add, action: \.destination.add)) { store in + NavigationStack { + SyncUpFormView(store: store) + .navigationTitle("New sync-up") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Dismiss") { + self.store.send(.dismissAddSyncUpButtonTapped) } - ToolbarItem(placement: .confirmationAction) { - Button("Add") { - viewStore.send(.confirmAddSyncUpButtonTapped) - } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { + self.store.send(.confirmAddSyncUpButtonTapped) } } - } + } } } } @@ -229,3 +235,16 @@ struct SyncUpsList_Previews: PreviewProvider { .previewDisplayName("Load data failure") } } + +#Preview { + CardView( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [], + duration: .seconds(60), + meetings: [], + theme: .bubblegum, + title: "Point-Free Morning Sync" + ) + ) +} diff --git a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift index 7426e23854dc..afb4efe08c81 100644 --- a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift +++ b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift @@ -71,7 +71,7 @@ final class AppFeatureTests: XCTestCase { .path( .element( id: 0, - action: .detail(.destination(.presented(.edit(.set(\.$syncUp, syncUp))))) + action: .detail(.destination(.presented(.edit(.set(\.syncUp, syncUp))))) ) ) ) { diff --git a/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift b/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift index 147e4f65e29a..75b6134a3a5e 100644 --- a/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift +++ b/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift @@ -96,7 +96,7 @@ final class SyncUpDetailTests: XCTestCase { } syncUp.title = "Blob's Meeting" - await store.send(.destination(.presented(.edit(.set(\.$syncUp, syncUp))))) { + await store.send(.destination(.presented(.edit(.set(\.syncUp, syncUp))))) { $0.$destination[case: \.edit]?.syncUp.title = "Blob's Meeting" } @@ -107,4 +107,26 @@ final class SyncUpDetailTests: XCTestCase { await store.receive(\.delegate.syncUpUpdated) } + + func testDelete() async { + let didDismiss = LockIsolated(false) + defer { XCTAssertEqual(didDismiss.value, true) } + + let syncUp = SyncUp.mock + let store = TestStore(initialState: SyncUpDetail.State(syncUp: syncUp)) { + SyncUpDetail() + } withDependencies: { + $0.dismiss = DismissEffect { + didDismiss.setValue(true) + } + } + + await store.send(.deleteButtonTapped) { + $0.destination = .alert(.deleteSyncUp) + } + await store.send(.destination(.presented(.alert(.confirmDeletion)))) { + $0.destination = nil + } + await store.receive(\.delegate.deleteSyncUp) + } } diff --git a/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift b/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift index 77453af5ce6e..c5b655aaad6f 100644 --- a/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift +++ b/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift @@ -25,7 +25,7 @@ final class SyncUpsListTests: XCTestCase { } syncUp.title = "Engineering" - await store.send(.destination(.presented(.add(.set(\.$syncUp, syncUp))))) { + await store.send(.destination(.presented(.add(.set(\.syncUp, syncUp))))) { $0.$destination[case: \.add]?.syncUp.title = "Engineering" } diff --git a/Examples/TicTacToe/App/RootView.swift b/Examples/TicTacToe/App/RootView.swift index 1b722f166085..8c4247a2777d 100644 --- a/Examples/TicTacToe/App/RootView.swift +++ b/Examples/TicTacToe/App/RootView.swift @@ -40,15 +40,15 @@ struct RootView: View { Text(readMe) Section { - Button("SwiftUI version") { self.showGame = .swiftui } - Button("UIKit version") { self.showGame = .uikit } + Button("SwiftUI version") { showGame = .swiftui } + Button("UIKit version") { showGame = .uikit } } } - .sheet(item: self.$showGame) { gameType in + .sheet(item: $showGame) { gameType in if gameType == .swiftui { - AppView(store: self.store) + AppView(store: store) } else { - UIKitAppView(store: self.store) + UIKitAppView(store: store) } } .navigationTitle("Tic-Tac-Toe") diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj b/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj index 8cd2b345039b..a165513244ed 100644 --- a/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ diff --git a/Examples/TicTacToe/tic-tac-toe/Package.swift b/Examples/TicTacToe/tic-tac-toe/Package.swift index 69e1807a6154..174a9a0a8ea0 100644 --- a/Examples/TicTacToe/tic-tac-toe/Package.swift +++ b/Examples/TicTacToe/tic-tac-toe/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.9 import PackageDescription let package = Package( name: "tic-tac-toe", platforms: [ - .iOS(.v16) + .iOS(.v17) ], products: [ .library(name: "AppCore", targets: ["AppCore"]), diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift index ab759fbf7da4..ea109246df8d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift @@ -6,6 +6,7 @@ import NewGameCore @Reducer public struct TicTacToe { + @ObservableState public enum State: Equatable { case login(Login.State) case newGame(NewGame.State) diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift index 0231467eb65d..aae6c5195838 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift @@ -12,19 +12,17 @@ public struct AppView: View { } public var body: some View { - SwitchStore(self.store) { state in - switch state { - case .login: - CaseLet(\TicTacToe.State.login, action: TicTacToe.Action.login) { store in - NavigationStack { - LoginView(store: store) - } + switch self.store.state { + case .login: + if let store = self.store.scope(state: \.login, action: \.login) { + NavigationStack { + LoginView(store: store) } - case .newGame: - CaseLet(\TicTacToe.State.newGame, action: TicTacToe.Action.newGame) { store in - NavigationStack { - NewGameView(store: store) - } + } + case .newGame: + if let store = self.store.scope(state: \.newGame, action: \.newGame) { + NavigationStack { + NewGameView(store: store) } } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift index ce15bc75247b..7280355d097b 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift @@ -1,5 +1,4 @@ import AppCore -import Combine import ComposableArchitecture import LoginUIKit import NewGameUIKit @@ -27,7 +26,6 @@ public struct UIKitAppView: UIViewControllerRepresentable { class AppViewController: UINavigationController { let store: StoreOf - private var cancellables: Set = [] init(store: StoreOf) { self.store = store @@ -41,18 +39,18 @@ class AppViewController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() - self.store - .scope(state: \.login, action: \.login) - .ifLet { [weak self] loginStore in - self?.setViewControllers([LoginViewController(store: loginStore)], animated: false) + observe { [weak self] in + guard let self else { return } + switch store.state { + case .login: + if let store = store.scope(state: \.login, action: \.login) { + setViewControllers([LoginViewController(store: store)], animated: false) + } + case .newGame: + if let store = store.scope(state: \.newGame, action: \.newGame) { + setViewControllers([NewGameViewController(store: store)], animated: false) + } } - .store(in: &self.cancellables) - - self.store - .scope(state: \.newGame, action: \.newGame) - .ifLet { [weak self] newGameStore in - self?.setViewControllers([NewGameViewController(store: newGameStore)], animated: false) - } - .store(in: &self.cancellables) + } } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift index ee08270d2e20..e4f93bed9002 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift @@ -3,11 +3,12 @@ import SwiftUI @Reducer public struct Game: Sendable { + @ObservableState public struct State: Equatable { public var board: Three> = .empty public var currentPlayer: Player = .x - public var oPlayerName: String - public var xPlayerName: String + public let oPlayerName: String + public let xPlayerName: String public init(oPlayerName: String, xPlayerName: String) { self.oPlayerName = oPlayerName diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift index 71b3686f172f..419d8e7f440f 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift @@ -5,83 +5,60 @@ import SwiftUI public struct GameView: View { let store: StoreOf - struct ViewState: Equatable, Sendable { - var board: [[String]] - var isGameDisabled: Bool - var isPlayAgainButtonVisible: Bool - var title: String - - init(state: Game.State) { - self.board = state.board.map { $0.map { $0?.label ?? "" } } - self.isGameDisabled = state.board.hasWinner || state.board.isFilled - self.isPlayAgainButtonVisible = state.board.hasWinner || state.board.isFilled - self.title = - state.board.hasWinner - ? "Winner! Congrats \(state.currentPlayerName)!" - : state.board.isFilled - ? "Tied game!" - : "\(state.currentPlayerName), place your \(state.currentPlayer.label)" - } - } - public init(store: StoreOf) { self.store = store } public var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - GeometryReader { proxy in - VStack(spacing: 0.0) { - VStack { - Text(viewStore.title) - .font(.title) + GeometryReader { proxy in + VStack(spacing: 0.0) { + VStack { + Text(store.title) + .font(.title) - if viewStore.isPlayAgainButtonVisible { - Button("Play again?") { - viewStore.send(.playAgainButtonTapped) - } - .padding(.top, 12) - .font(.title) + if store.isPlayAgainButtonVisible { + Button("Play again?") { + store.send(.playAgainButtonTapped) } + .padding(.top, 12) + .font(.title) } - .padding(.bottom, 48) + } + .padding(.bottom, 48) - VStack { - self.rowView(row: 0, proxy: proxy, viewStore: viewStore) - self.rowView(row: 1, proxy: proxy, viewStore: viewStore) - self.rowView(row: 2, proxy: proxy, viewStore: viewStore) - } - .disabled(viewStore.isGameDisabled) + VStack { + rowView(row: 0, proxy: proxy) + rowView(row: 1, proxy: proxy) + rowView(row: 2, proxy: proxy) } - .navigationTitle("Tic-tac-toe") - .navigationBarItems(leading: Button("Quit") { viewStore.send(.quitButtonTapped) }) - .navigationBarBackButtonHidden(true) + .disabled(store.isGameDisabled) } + .navigationTitle("Tic-tac-toe") + .navigationBarItems(leading: Button("Quit") { store.send(.quitButtonTapped) }) + .navigationBarBackButtonHidden(true) } } func rowView( row: Int, - proxy: GeometryProxy, - viewStore: ViewStore + proxy: GeometryProxy ) -> some View { HStack(spacing: 0.0) { - self.cellView(row: row, column: 0, proxy: proxy, viewStore: viewStore) - self.cellView(row: row, column: 1, proxy: proxy, viewStore: viewStore) - self.cellView(row: row, column: 2, proxy: proxy, viewStore: viewStore) + cellView(row: row, column: 0, proxy: proxy) + cellView(row: row, column: 1, proxy: proxy) + cellView(row: row, column: 2, proxy: proxy) } } func cellView( row: Int, column: Int, - proxy: GeometryProxy, - viewStore: ViewStore + proxy: GeometryProxy ) -> some View { Button { - viewStore.send(.cellTapped(row: row, column: column)) + store.send(.cellTapped(row: row, column: column)) } label: { - Text(viewStore.board[row][column]) + Text(store.rows[row][column]) .frame(width: proxy.size.width / 3, height: proxy.size.width / 3) .background( (row + column).isMultiple(of: 2) @@ -92,6 +69,19 @@ public struct GameView: View { } } +extension Game.State { + fileprivate var rows: [[String]] { self.board.map { $0.map { $0?.label ?? "" } } } + fileprivate var isGameDisabled: Bool { self.board.hasWinner || self.board.isFilled } + fileprivate var isPlayAgainButtonVisible: Bool { self.board.hasWinner || self.board.isFilled } + fileprivate var title: String { + self.board.hasWinner + ? "Winner! Congrats \(self.currentPlayerName)!" + : self.board.isFilled + ? "Tied game!" + : "\(self.currentPlayerName), place your \(self.currentPlayer.label)" + } +} + struct Game_Previews: PreviewProvider { static var previews: some View { NavigationStack { diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift index be7f933eb20f..b457fc9147f6 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift @@ -1,35 +1,12 @@ -import Combine import ComposableArchitecture import GameCore import UIKit public final class GameViewController: UIViewController { let store: StoreOf - let viewStore: ViewStore - private var cancellables: Set = [] - - struct ViewState: Equatable { - let board: Three> - let isGameEnabled: Bool - let isPlayAgainButtonHidden: Bool - let title: String? - - init(state: Game.State) { - self.board = state.board.map { $0.map { $0?.label ?? "" } } - self.isGameEnabled = !state.board.hasWinner && !state.board.isFilled - self.isPlayAgainButtonHidden = !state.board.hasWinner && !state.board.isFilled - self.title = - state.board.hasWinner - ? "Winner! Congrats \(state.currentPlayerName)!" - : state.board.isFilled - ? "Tied game!" - : "\(state.currentPlayerName), place your \(state.currentPlayer.label)" - } - } public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: ViewState.init) super.init(nibName: nil, bundle: nil) } @@ -129,43 +106,49 @@ public final class GameViewController: UIViewController { ]) } - self.viewStore.publisher.title - .assign(to: \.text, on: titleLabel) - .store(in: &self.cancellables) - - self.viewStore.publisher.isPlayAgainButtonHidden - .assign(to: \.isHidden, on: playAgainButton) - .store(in: &self.cancellables) - - self.viewStore.publisher.map(\.board, \.isGameEnabled) - .removeDuplicates(by: ==) - .sink { board, isGameEnabled in - board.enumerated().forEach { rowIdx, row in - row.enumerated().forEach { colIdx, label in - let button = cells[rowIdx][colIdx] - button.setTitle(label, for: .normal) - button.isEnabled = isGameEnabled - } + observe { [weak self] in + guard let self else { return } + titleLabel.text = self.store.title + playAgainButton.isHidden = self.store.isPlayAgainButtonHidden + + for (rowIdx, row) in self.store.rows.enumerated() { + for (colIdx, label) in row.enumerated() { + let button = cells[rowIdx][colIdx] + button.setTitle(label, for: .normal) + button.isEnabled = self.store.isGameEnabled } } - .store(in: &self.cancellables) + } } - @objc private func gridCell11Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 0)) } - @objc private func gridCell12Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 1)) } - @objc private func gridCell13Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 2)) } - @objc private func gridCell21Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 0)) } - @objc private func gridCell22Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 1)) } - @objc private func gridCell23Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 2)) } - @objc private func gridCell31Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 0)) } - @objc private func gridCell32Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 1)) } - @objc private func gridCell33Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 2)) } + @objc private func gridCell11Tapped() { self.store.send(.cellTapped(row: 0, column: 0)) } + @objc private func gridCell12Tapped() { self.store.send(.cellTapped(row: 0, column: 1)) } + @objc private func gridCell13Tapped() { self.store.send(.cellTapped(row: 0, column: 2)) } + @objc private func gridCell21Tapped() { self.store.send(.cellTapped(row: 1, column: 0)) } + @objc private func gridCell22Tapped() { self.store.send(.cellTapped(row: 1, column: 1)) } + @objc private func gridCell23Tapped() { self.store.send(.cellTapped(row: 1, column: 2)) } + @objc private func gridCell31Tapped() { self.store.send(.cellTapped(row: 2, column: 0)) } + @objc private func gridCell32Tapped() { self.store.send(.cellTapped(row: 2, column: 1)) } + @objc private func gridCell33Tapped() { self.store.send(.cellTapped(row: 2, column: 2)) } @objc private func quitButtonTapped() { - self.viewStore.send(.quitButtonTapped) + self.store.send(.quitButtonTapped) } @objc private func playAgainButtonTapped() { - self.viewStore.send(.playAgainButtonTapped) + self.store.send(.playAgainButtonTapped) + } +} + +extension Game.State { + fileprivate var rows: Three> { self.board.map { $0.map { $0?.label ?? "" } } } + fileprivate var isGameEnabled: Bool { !self.board.hasWinner && !self.board.isFilled } + fileprivate var isPlayAgainButtonHidden: Bool { !self.board.hasWinner && !self.board.isFilled } + fileprivate var title: String { + self.board.hasWinner + ? "Winner! Congrats \(self.currentPlayerName)!" + : self.board.isFilled + ? "Tied game!" + : "\(self.currentPlayerName), place your \(self.currentPlayer.label)" } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift index b02efa76c151..09d4526035e6 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift @@ -5,18 +5,19 @@ import TwoFactorCore @Reducer public struct Login: Sendable { + @ObservableState public struct State: Equatable { - @PresentationState public var alert: AlertState? - @BindingState public var email = "" + @Presents public var alert: AlertState? + public var email = "" public var isFormValid = false public var isLoginRequestInFlight = false - @BindingState public var password = "" - @PresentationState public var twoFactor: TwoFactor.State? + public var password = "" + @Presents public var twoFactor: TwoFactor.State? public init() {} } - public enum Action: Sendable { + public enum Action: Sendable, ViewAction { case alert(PresentationAction) case loginResponse(Result) case twoFactor(PresentationAction) diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift index 6fb6752f8215..f616e70394af 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift @@ -5,82 +5,66 @@ import SwiftUI import TwoFactorCore import TwoFactorSwiftUI +@ViewAction(for: Login.self) public struct LoginView: View { - let store: StoreOf - - struct ViewState: Equatable { - @BindingViewState var email: String - var isActivityIndicatorVisible: Bool - var isFormDisabled: Bool - var isLoginButtonDisabled: Bool - @BindingViewState var password: String - } + @Bindable public var store: StoreOf public init(store: StoreOf) { self.store = store } public var body: some View { - WithViewStore(self.store, observe: \.view, send: { .view($0) }) { viewStore in - Form { - Text( - """ - To login use any email and "password" for the password. If your email contains the \ - characters "2fa" you will be taken to a two-factor flow, and on that screen you can \ - use "1234" for the code. - """ - ) + Form { + Text( + """ + To login use any email and "password" for the password. If your email contains the \ + characters "2fa" you will be taken to a two-factor flow, and on that screen you can \ + use "1234" for the code. + """ + ) - Section { - TextField("blob@pointfree.co", text: viewStore.$email) - .autocapitalization(.none) - .keyboardType(.emailAddress) - .textContentType(.emailAddress) + Section { + TextField("blob@pointfree.co", text: $store.email) + .autocapitalization(.none) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) - SecureField("••••••••", text: viewStore.$password) - } + SecureField("••••••••", text: $store.password) + } - Button { - // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" if - // you disable a text field while it is focused. This hack will force all fields to - // unfocus before we send the action to the view store. - // CF: https://stackoverflow.com/a/69653555 - _ = UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil - ) - viewStore.send(.loginButtonTapped) - } label: { - HStack { - Text("Log in") - if viewStore.isActivityIndicatorVisible { - Spacer() - ProgressView() - } + Button { + // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" if + // you disable a text field while it is focused. This hack will force all fields to + // unfocus before we send the action to the view store. + // CF: https://stackoverflow.com/a/69653555 + _ = UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil + ) + send(.loginButtonTapped) + } label: { + HStack { + Text("Log in") + if store.isActivityIndicatorVisible { + Spacer() + ProgressView() } } - .disabled(viewStore.isLoginButtonDisabled) } - .disabled(viewStore.isFormDisabled) - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) - .navigationDestination( - store: self.store.scope(state: \.$twoFactor, action: \.twoFactor), - destination: TwoFactorView.init - ) + .disabled(store.isLoginButtonDisabled) + } + .disabled(store.isFormDisabled) + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationDestination(item: $store.scope(state: \.twoFactor, action: \.twoFactor)) { store in + TwoFactorView(store: store) } .navigationTitle("Login") } } -extension BindingViewStore { - var view: LoginView.ViewState { - LoginView.ViewState( - email: self.$email, - isActivityIndicatorVisible: self.isLoginRequestInFlight, - isFormDisabled: self.isLoginRequestInFlight, - isLoginButtonDisabled: !self.isFormValid, - password: self.$password - ) - } +extension Login.State { + fileprivate var isActivityIndicatorVisible: Bool { self.isLoginRequestInFlight } + fileprivate var isFormDisabled: Bool { self.isLoginRequestInFlight } + fileprivate var isLoginButtonDisabled: Bool { !self.isFormValid } } struct LoginView_Previews: PreviewProvider { diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift index b66b1a925fec..b1886332d6b5 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift @@ -1,46 +1,15 @@ -import Combine import ComposableArchitecture import LoginCore import TwoFactorCore import TwoFactorUIKit import UIKit +@ViewAction(for: Login.self) public class LoginViewController: UIViewController { - let store: StoreOf - let viewStore: ViewStore - private var cancellables: Set = [] - - struct ViewState: Equatable { - let alert: AlertState? - let email: String? - let isActivityIndicatorHidden: Bool - let isEmailTextFieldEnabled: Bool - let isLoginButtonEnabled: Bool - let isPasswordTextFieldEnabled: Bool - let password: String? - - init(state: Login.State) { - self.alert = state.alert - self.email = state.email - self.isActivityIndicatorHidden = !state.isLoginRequestInFlight - self.isEmailTextFieldEnabled = !state.isLoginRequestInFlight - self.isLoginButtonEnabled = state.isFormValid && !state.isLoginRequestInFlight - self.isPasswordTextFieldEnabled = !state.isLoginRequestInFlight - self.password = state.password - } - } - - enum ViewAction { - case alertDismissed - case emailChanged(String?) - case loginButtonTapped - case passwordChanged(String?) - case twoFactorDismissed - } + public let store: StoreOf public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: ViewState.init, send: Login.Action.init) super.init(nibName: nil, bundle: nil) } @@ -51,8 +20,8 @@ public class LoginViewController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - self.navigationItem.title = "Login" - self.view.backgroundColor = .systemBackground + navigationItem.title = "Login" + view.backgroundColor = .systemBackground let disclaimerLabel = UILabel() disclaimerLabel.text = """ @@ -76,13 +45,15 @@ public class LoginViewController: UIViewController { emailTextField.borderStyle = .roundedRect emailTextField.autocapitalizationType = .none emailTextField.addTarget( - self, action: #selector(emailTextFieldChanged(sender:)), for: .editingChanged) + self, action: #selector(emailTextFieldChanged(sender:)), for: .editingChanged + ) let passwordTextField = UITextField() passwordTextField.placeholder = "**********" passwordTextField.borderStyle = .roundedRect passwordTextField.addTarget( - self, action: #selector(passwordTextFieldChanged(sender:)), for: .editingChanged) + self, action: #selector(passwordTextFieldChanged(sender:)), for: .editingChanged + ) passwordTextField.isSecureTextEntry = true let loginButton = UIButton(type: .system) @@ -107,106 +78,82 @@ public class LoginViewController: UIViewController { rootStackView.axis = .vertical rootStackView.spacing = 24 - self.view.addSubview(rootStackView) + view.addSubview(rootStackView) NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), divider.heightAnchor.constraint(equalToConstant: 1), ]) - self.viewStore.publisher.isLoginButtonEnabled - .assign(to: \.isEnabled, on: loginButton) - .store(in: &self.cancellables) - - self.viewStore.publisher.email - .assign(to: \.text, on: emailTextField) - .store(in: &self.cancellables) - - self.viewStore.publisher.isEmailTextFieldEnabled - .assign(to: \.isEnabled, on: emailTextField) - .store(in: &self.cancellables) - - self.viewStore.publisher.password - .assign(to: \.text, on: passwordTextField) - .store(in: &self.cancellables) - - self.viewStore.publisher.isPasswordTextFieldEnabled - .assign(to: \.isEnabled, on: passwordTextField) - .store(in: &self.cancellables) - - self.viewStore.publisher.isActivityIndicatorHidden - .assign(to: \.isHidden, on: activityIndicator) - .store(in: &self.cancellables) - - self.viewStore.publisher.alert - .sink { [weak self] alert in - guard let self = self else { return } - guard let alert = alert else { return } - - let alertController = UIAlertController( - title: String(state: alert.title), message: nil, preferredStyle: .alert) - alertController.addAction( - UIAlertAction(title: "Ok", style: .default) { _ in - self.viewStore.send(.alertDismissed) - } + var alertController: UIAlertController? + var twoFactorViewController: TwoFactorViewController? + + observe { [weak self] in + guard let self = self else { return } + emailTextField.text = store.email + emailTextField.isEnabled = store.isEmailTextFieldEnabled + passwordTextField.text = store.password + passwordTextField.isEnabled = store.isPasswordTextFieldEnabled + loginButton.isEnabled = store.isLoginButtonEnabled + activityIndicator.isHidden = store.isActivityIndicatorHidden + + if let store = store.scope(state: \.alert, action: \.alert), + alertController == nil + { + alertController = UIAlertController(store: store) + present(alertController!, animated: true, completion: nil) + } else if alertController != nil { + alertController?.dismiss(animated: true) + alertController = nil + } + + if let store = store.scope(state: \.twoFactor, action: \.twoFactor.presented), + twoFactorViewController == nil + { + twoFactorViewController = TwoFactorViewController(store: store) + navigationController?.pushViewController( + twoFactorViewController!, + animated: true ) - self.present(alertController, animated: true, completion: nil) + } else if store.alert == nil, twoFactorViewController != nil { + twoFactorViewController?.dismiss(animated: true) + twoFactorViewController = nil } - .store(in: &self.cancellables) - - self.store - .scope(state: \.twoFactor, action: \.twoFactor.presented) - .ifLet( - then: { [weak self] twoFactorStore in - self?.navigationController?.pushViewController( - TwoFactorViewController(store: twoFactorStore), - animated: true - ) - }, - else: { [weak self] in - guard let self = self else { return } - self.navigationController?.popToViewController(self, animated: true) - } - ) - .store(in: &self.cancellables) + } } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if !self.isMovingToParent { - self.viewStore.send(.twoFactorDismissed) + if !isMovingToParent { + store.twoFactorDismissed() } } @objc private func loginButtonTapped(sender: UIButton) { - self.viewStore.send(.loginButtonTapped) + send(.loginButtonTapped) } @objc private func emailTextFieldChanged(sender: UITextField) { - self.viewStore.send(.emailChanged(sender.text)) + store.email = sender.text ?? "" } @objc private func passwordTextFieldChanged(sender: UITextField) { - self.viewStore.send(.passwordChanged(sender.text)) + store.password = sender.text ?? "" } } -extension Login.Action { - init(action: LoginViewController.ViewAction) { - switch action { - case .alertDismissed: - self = .alert(.dismiss) - case let .emailChanged(email): - self = .view(.set(\.$email, email ?? "")) - case .loginButtonTapped: - self = .view(.loginButtonTapped) - case let .passwordChanged(password): - self = .view(.set(\.$password, password ?? "")) - case .twoFactorDismissed: - self = .twoFactor(.dismiss) - } +extension Login.State { + fileprivate var isActivityIndicatorHidden: Bool { !isLoginRequestInFlight } + fileprivate var isEmailTextFieldEnabled: Bool { !isLoginRequestInFlight } + fileprivate var isLoginButtonEnabled: Bool { isFormValid && !isLoginRequestInFlight } + fileprivate var isPasswordTextFieldEnabled: Bool { !isLoginRequestInFlight } +} + +extension StoreOf { + fileprivate func twoFactorDismissed() { + send(.twoFactor(.dismiss)) } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift index 3929e7cc0b09..a82b5a3a1ae1 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift @@ -3,27 +3,31 @@ import GameCore @Reducer public struct NewGame { + @ObservableState public struct State: Equatable { - @PresentationState public var game: Game.State? + @Presents public var game: Game.State? public var oPlayerName = "" public var xPlayerName = "" public init() {} } - public enum Action { + public enum Action: BindableAction { + case binding(BindingAction) case game(PresentationAction) case letsPlayButtonTapped case logoutButtonTapped - case oPlayerNameChanged(String) - case xPlayerNameChanged(String) } public init() {} public var body: some Reducer { + BindingReducer() Reduce { state, action in switch action { + case .binding: + return .none + case .game: return .none @@ -36,14 +40,6 @@ public struct NewGame { case .logoutButtonTapped: return .none - - case let .oPlayerNameChanged(name): - state.oPlayerName = name - return .none - - case let .xPlayerNameChanged(name): - state.xPlayerName = name - return .none } } .ifLet(\.$game, action: \.game) { diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift index 5ca856981c48..315314ffb3fd 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift @@ -5,85 +5,48 @@ import NewGameCore import SwiftUI public struct NewGameView: View { - let store: StoreOf - - struct ViewState: Equatable { - var isLetsPlayButtonDisabled: Bool - var oPlayerName: String - var xPlayerName: String - - init(state: NewGame.State) { - self.isLetsPlayButtonDisabled = state.oPlayerName.isEmpty || state.xPlayerName.isEmpty - self.oPlayerName = state.oPlayerName - self.xPlayerName = state.xPlayerName - } - } - - enum ViewAction { - case letsPlayButtonTapped - case logoutButtonTapped - case oPlayerNameChanged(String) - case xPlayerNameChanged(String) - } + @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store } public var body: some View { - WithViewStore(self.store, observe: ViewState.init, send: NewGame.Action.init) { viewStore in - Form { - Section { - TextField( - "Blob Sr.", - text: viewStore.binding(get: \.xPlayerName, send: { .xPlayerNameChanged($0) }) - ) + Form { + Section { + TextField("Blob Sr.", text: $store.xPlayerName) .autocapitalization(.words) .disableAutocorrection(true) .textContentType(.name) - } header: { - Text("X Player Name") - } + } header: { + Text("X Player Name") + } - Section { - TextField( - "Blob Jr.", - text: viewStore.binding(get: \.oPlayerName, send: { .oPlayerNameChanged($0) }) - ) + Section { + TextField("Blob Jr.", text: $store.oPlayerName) .autocapitalization(.words) .disableAutocorrection(true) .textContentType(.name) - } header: { - Text("O Player Name") - } + } header: { + Text("O Player Name") + } - Button("Let's play!") { - viewStore.send(.letsPlayButtonTapped) - } - .disabled(viewStore.isLetsPlayButtonDisabled) + Button("Let's play!") { + store.send(.letsPlayButtonTapped) } - .navigationTitle("New Game") - .navigationBarItems(trailing: Button("Logout") { viewStore.send(.logoutButtonTapped) }) - .navigationDestination( - store: self.store.scope(state: \.$game, action: \.game), - destination: GameView.init - ) + .disabled(store.isLetsPlayButtonDisabled) + } + .navigationTitle("New Game") + .navigationBarItems(trailing: Button("Logout") { store.send(.logoutButtonTapped) }) + .navigationDestination(item: $store.scope(state: \.game, action: \.game)) { store in + GameView(store: store) } } } -extension NewGame.Action { - init(action: NewGameView.ViewAction) { - switch action { - case .letsPlayButtonTapped: - self = .letsPlayButtonTapped - case .logoutButtonTapped: - self = .logoutButtonTapped - case let .oPlayerNameChanged(name): - self = .oPlayerNameChanged(name) - case let .xPlayerNameChanged(name): - self = .xPlayerNameChanged(name) - } +extension NewGame.State { + fileprivate var isLetsPlayButtonDisabled: Bool { + self.oPlayerName.isEmpty || self.xPlayerName.isEmpty } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift index a643b386f7a3..ea77eebeab4d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift @@ -1,4 +1,3 @@ -import Combine import ComposableArchitecture import GameUIKit import NewGameCore @@ -6,31 +5,9 @@ import UIKit public class NewGameViewController: UIViewController { let store: StoreOf - let viewStore: ViewStore - private var cancellables: Set = [] - - struct ViewState: Equatable { - let isLetsPlayButtonEnabled: Bool - let oPlayerName: String? - let xPlayerName: String? - - public init(state: NewGame.State) { - self.isLetsPlayButtonEnabled = !state.oPlayerName.isEmpty && !state.xPlayerName.isEmpty - self.oPlayerName = state.oPlayerName - self.xPlayerName = state.xPlayerName - } - } - - enum ViewAction { - case letsPlayButtonTapped - case logoutButtonTapped - case oPlayerNameChanged(String?) - case xPlayerNameChanged(String?) - } public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: ViewState.init, send: NewGame.Action.init) super.init(nibName: nil, bundle: nil) } @@ -41,9 +18,9 @@ public class NewGameViewController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - self.navigationItem.title = "New Game" + navigationItem.title = "New Game" - self.navigationItem.rightBarButtonItem = UIBarButtonItem( + navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Logout", style: .done, target: self, @@ -99,71 +76,53 @@ public class NewGameViewController: UIViewController { rootStackView.axis = .vertical rootStackView.spacing = 24 - self.view.addSubview(rootStackView) + view.addSubview(rootStackView) NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) - self.viewStore.publisher.isLetsPlayButtonEnabled - .assign(to: \.isEnabled, on: letsPlayButton) - .store(in: &self.cancellables) - - self.viewStore.publisher.oPlayerName - .assign(to: \.text, on: playerOTextField) - .store(in: &self.cancellables) - - self.viewStore.publisher.xPlayerName - .assign(to: \.text, on: playerXTextField) - .store(in: &self.cancellables) - - self.store - .scope(state: \.game, action: \.game.presented) - .ifLet( - then: { [weak self] gameStore in - self?.navigationController?.pushViewController( - GameViewController(store: gameStore), - animated: true - ) - }, - else: { [weak self] in - guard let self = self else { return } - self.navigationController?.popToViewController(self, animated: true) - } - ) - .store(in: &self.cancellables) + var gameController: GameViewController? + + observe { [weak self] in + guard let self = self else { return } + playerOTextField.text = store.oPlayerName + playerXTextField.text = store.xPlayerName + letsPlayButton.isEnabled = store.isLetsPlayButtonEnabled + + if let store = store.scope(state: \.game, action: \.game.presented), + gameController == nil + { + gameController = GameViewController(store: store) + navigationController?.pushViewController(gameController!, animated: true) + } else if gameController != nil { + gameController?.dismiss(animated: true) + gameController = nil + } + } } @objc private func logoutButtonTapped() { - self.viewStore.send(.logoutButtonTapped) + store.send(.logoutButtonTapped) } @objc private func playerXTextChanged(sender: UITextField) { - self.viewStore.send(.xPlayerNameChanged(sender.text)) + store.xPlayerName = sender.text ?? "" } @objc private func playerOTextChanged(sender: UITextField) { - self.viewStore.send(.oPlayerNameChanged(sender.text)) + store.oPlayerName = sender.text ?? "" } @objc private func letsPlayTapped() { - self.viewStore.send(.letsPlayButtonTapped) + store.send(.letsPlayButtonTapped) } } -extension NewGame.Action { - init(action: NewGameViewController.ViewAction) { - switch action { - case .letsPlayButtonTapped: - self = .letsPlayButtonTapped - case .logoutButtonTapped: - self = .logoutButtonTapped - case let .oPlayerNameChanged(name): - self = .oPlayerNameChanged(name ?? "") - case let .xPlayerNameChanged(name): - self = .xPlayerNameChanged(name ?? "") - } +extension NewGame.State { + fileprivate var isLetsPlayButtonEnabled: Bool { + !oPlayerName.isEmpty && !xPlayerName.isEmpty } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift index 1e0395e51619..737838cbd0dc 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift @@ -5,9 +5,10 @@ import Dispatch @Reducer public struct TwoFactor: Sendable { + @ObservableState public struct State: Equatable { - @PresentationState public var alert: AlertState? - @BindingState public var code = "" + @Presents public var alert: AlertState? + public var code = "" public var isFormValid = false public var isTwoFactorRequestInFlight = false public let token: String @@ -17,7 +18,7 @@ public struct TwoFactor: Sendable { } } - public enum Action: Sendable { + public enum Action: Sendable, ViewAction { case alert(PresentationAction) case twoFactorResponse(Result) case view(View) diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift index 686e7ac3792f..80cdcb0b4a3e 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift @@ -3,65 +3,52 @@ import ComposableArchitecture import SwiftUI import TwoFactorCore +@ViewAction(for: TwoFactor.self) public struct TwoFactorView: View { - let store: StoreOf - - struct ViewState: Equatable { - @BindingViewState var code: String - var isActivityIndicatorVisible: Bool - var isFormDisabled: Bool - var isSubmitButtonDisabled: Bool - } + @Bindable public var store: StoreOf public init(store: StoreOf) { self.store = store } public var body: some View { - WithViewStore(self.store, observe: \.view, send: { .view($0) }) { viewStore in - Form { - Text(#"To confirm the second factor enter "1234" into the form."#) + Form { + Text(#"To confirm the second factor enter "1234" into the form."#) - Section { - TextField("1234", text: viewStore.$code) - .keyboardType(.numberPad) - } + Section { + TextField("1234", text: $store.code) + .keyboardType(.numberPad) + } - HStack { - Button("Submit") { - // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" - // if you disable a text field while it is focused. This hack will force all - // fields to unfocus before we send the action to the view store. - // CF: https://stackoverflow.com/a/69653555 - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil - ) - viewStore.send(.submitButtonTapped) - } - .disabled(viewStore.isSubmitButtonDisabled) + HStack { + Button("Submit") { + // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" + // if you disable a text field while it is focused. This hack will force all + // fields to unfocus before we send the action to the view store. + // CF: https://stackoverflow.com/a/69653555 + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil + ) + send(.submitButtonTapped) + } + .disabled(store.isSubmitButtonDisabled) - if viewStore.isActivityIndicatorVisible { - Spacer() - ProgressView() - } + if store.isActivityIndicatorVisible { + Spacer() + ProgressView() } } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) - .disabled(viewStore.isFormDisabled) - .navigationTitle("Confirmation Code") } + .alert($store.scope(state: \.alert, action: \.alert)) + .disabled(store.isFormDisabled) + .navigationTitle("Confirmation Code") } } -extension BindingViewStore { - var view: TwoFactorView.ViewState { - TwoFactorView.ViewState( - code: self.$code, - isActivityIndicatorVisible: self.isTwoFactorRequestInFlight, - isFormDisabled: self.isTwoFactorRequestInFlight, - isSubmitButtonDisabled: !self.isFormValid - ) - } +extension TwoFactor.State { + fileprivate var isActivityIndicatorVisible: Bool { self.isTwoFactorRequestInFlight } + fileprivate var isFormDisabled: Bool { self.isTwoFactorRequestInFlight } + fileprivate var isSubmitButtonDisabled: Bool { !self.isFormValid } } struct TwoFactorView_Previews: PreviewProvider { diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift index 3ad37f0608a1..ad1cc3f780e0 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift @@ -1,36 +1,13 @@ -import Combine import ComposableArchitecture import TwoFactorCore import UIKit +@ViewAction(for: TwoFactor.self) public final class TwoFactorViewController: UIViewController { - let store: StoreOf - let viewStore: ViewStore - private var cancellables: Set = [] - - struct ViewState: Equatable { - let alert: AlertState? - let code: String? - let isActivityIndicatorHidden: Bool - let isLoginButtonEnabled: Bool - - init(state: TwoFactor.State) { - self.alert = state.alert - self.code = state.code - self.isActivityIndicatorHidden = !state.isTwoFactorRequestInFlight - self.isLoginButtonEnabled = state.isFormValid && !state.isTwoFactorRequestInFlight - } - } - - enum ViewAction { - case alertDismissed - case codeChanged(String?) - case loginButtonTapped - } + public let store: StoreOf public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: ViewState.init, send: TwoFactor.Action.init) super.init(nibName: nil, bundle: nil) } @@ -41,7 +18,7 @@ public final class TwoFactorViewController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - self.view.backgroundColor = .systemBackground + view.backgroundColor = .systemBackground let titleLabel = UILabel() titleLabel.text = "Enter the one time code to continue" @@ -72,61 +49,44 @@ public final class TwoFactorViewController: UIViewController { rootStackView.axis = .vertical rootStackView.spacing = 24 - self.view.addSubview(rootStackView) + view.addSubview(rootStackView) NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) - self.viewStore.publisher.isActivityIndicatorHidden - .assign(to: \.isHidden, on: activityIndicator) - .store(in: &self.cancellables) - - self.viewStore.publisher.code - .assign(to: \.text, on: codeTextField) - .store(in: &self.cancellables) - - self.viewStore.publisher.isLoginButtonEnabled - .assign(to: \.isEnabled, on: loginButton) - .store(in: &self.cancellables) - - self.viewStore.publisher.alert - .sink { [weak self] alert in - guard let self = self else { return } - guard let alert = alert else { return } - - let alertController = UIAlertController( - title: String(state: alert.title), message: nil, preferredStyle: .alert) - alertController.addAction( - UIAlertAction(title: "Ok", style: .default) { _ in - self.viewStore.send(.alertDismissed) - } - ) - self.present(alertController, animated: true, completion: nil) + var alertController: UIAlertController? + + observe { [weak self] in + guard let self = self else { return } + activityIndicator.isHidden = store.isActivityIndicatorHidden + codeTextField.text = store.code + loginButton.isEnabled = store.isLoginButtonEnabled + + if let store = store.scope(state: \.alert, action: \.alert), + alertController == nil + { + alertController = UIAlertController(store: store) + present(alertController!, animated: true, completion: nil) + } else if store.alert == nil, alertController != nil { + alertController?.dismiss(animated: true) + alertController = nil } - .store(in: &self.cancellables) + } } @objc private func codeTextFieldChanged(sender: UITextField) { - self.viewStore.send(.codeChanged(sender.text)) + store.code = sender.text ?? "" } @objc private func loginButtonTapped() { - self.viewStore.send(.loginButtonTapped) + send(.submitButtonTapped) } } -extension TwoFactor.Action { - init(action: TwoFactorViewController.ViewAction) { - switch action { - case .alertDismissed: - self = .alert(.dismiss) - case let .codeChanged(code): - self = .view(.set(\.$code, code ?? "")) - case .loginButtonTapped: - self = .view(.submitButtonTapped) - } - } +extension TwoFactor.State { + fileprivate var isActivityIndicatorHidden: Bool { !isTwoFactorRequestInFlight } + fileprivate var isLoginButtonEnabled: Bool { isFormValid && !isTwoFactorRequestInFlight } } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift index 5e1575a15abe..577040158d08 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift @@ -17,12 +17,12 @@ final class AppCoreTests: XCTestCase { } } - await store.send(.login(.view(.set(\.$email, "blob@pointfree.co")))) { + await store.send(.login(.view(.set(\.email, "blob@pointfree.co")))) { $0.modify(\.login) { $0.email = "blob@pointfree.co" } } - await store.send(.login(.view(.set(\.$password, "bl0bbl0b")))) { + await store.send(.login(.view(.set(\.password, "bl0bbl0b")))) { $0.modify(\.login) { $0.password = "bl0bbl0b" $0.isFormValid = true @@ -36,7 +36,7 @@ final class AppCoreTests: XCTestCase { await store.receive(\.login.loginResponse.success) { $0 = .newGame(NewGame.State()) } - await store.send(.newGame(.oPlayerNameChanged("Blob Sr."))) { + await store.send(.newGame(.set(\.oPlayerName, "Blob Sr."))) { $0.modify(\.newGame) { $0.oPlayerName = "Blob Sr." } @@ -58,13 +58,13 @@ final class AppCoreTests: XCTestCase { } } - await store.send(.login(.view(.set(\.$email, "blob@pointfree.co")))) { + await store.send(.login(.view(.set(\.email, "blob@pointfree.co")))) { $0.modify(\.login) { $0.email = "blob@pointfree.co" } } - await store.send(.login(.view(.set(\.$password, "bl0bbl0b")))) { + await store.send(.login(.view(.set(\.password, "bl0bbl0b")))) { $0.modify(\.login) { $0.password = "bl0bbl0b" $0.isFormValid = true @@ -83,7 +83,7 @@ final class AppCoreTests: XCTestCase { } } - await store.send(.login(.twoFactor(.presented(.view(.set(\.$code, "1234")))))) { + await store.send(.login(.twoFactor(.presented(.view(.set(\.code, "1234")))))) { $0.modify(\.login) { $0.twoFactor?.code = "1234" $0.twoFactor?.isFormValid = true diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift index d8fa0145e871..ce34b55c07bd 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift @@ -18,10 +18,10 @@ final class LoginCoreTests: XCTestCase { } } - await store.send(.view(.set(\.$email, "2fa@pointfree.co"))) { + await store.send(.view(.set(\.email, "2fa@pointfree.co"))) { $0.email = "2fa@pointfree.co" } - await store.send(.view(.set(\.$password, "password"))) { + await store.send(.view(.set(\.password, "password"))) { $0.password = "password" $0.isFormValid = true } @@ -32,7 +32,7 @@ final class LoginCoreTests: XCTestCase { $0.isLoginRequestInFlight = false $0.twoFactor = TwoFactor.State(token: "deadbeefdeadbeef") } - await store.send(.twoFactor(.presented(.view(.set(\.$code, "1234"))))) { + await store.send(.twoFactor(.presented(.view(.set(\.code, "1234"))))) { $0.twoFactor?.code = "1234" $0.twoFactor?.isFormValid = true } @@ -58,10 +58,10 @@ final class LoginCoreTests: XCTestCase { } } - await store.send(.view(.set(\.$email, "2fa@pointfree.co"))) { + await store.send(.view(.set(\.email, "2fa@pointfree.co"))) { $0.email = "2fa@pointfree.co" } - await store.send(.view(.set(\.$password, "password"))) { + await store.send(.view(.set(\.password, "password"))) { $0.password = "password" $0.isFormValid = true } @@ -72,7 +72,7 @@ final class LoginCoreTests: XCTestCase { $0.isLoginRequestInFlight = false $0.twoFactor = TwoFactor.State(token: "deadbeefdeadbeef") } - await store.send(.twoFactor(.presented(.view(.set(\.$code, "1234"))))) { + await store.send(.twoFactor(.presented(.view(.set(\.code, "1234"))))) { $0.twoFactor?.code = "1234" $0.twoFactor?.isFormValid = true } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift index 6cf2997c753a..ea7a524d2a7f 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift @@ -10,10 +10,10 @@ final class NewGameCoreTests: XCTestCase { } func testFlow_NewGame_Integration() async { - await self.store.send(.oPlayerNameChanged("Blob Sr.")) { + await self.store.send(.set(\.oPlayerName, "Blob Sr.")) { $0.oPlayerName = "Blob Sr." } - await self.store.send(.xPlayerNameChanged("Blob Jr.")) { + await self.store.send(.set(\.xPlayerName, "Blob Jr.")) { $0.xPlayerName = "Blob Jr." } await self.store.send(.letsPlayButtonTapped) { diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift index aee5b5dd9c7e..44a57aede7c5 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift @@ -14,16 +14,16 @@ final class TwoFactorCoreTests: XCTestCase { } } - await store.send(.view(.set(\.$code, "1"))) { + await store.send(.view(.set(\.code, "1"))) { $0.code = "1" } - await store.send(.view(.set(\.$code, "12"))) { + await store.send(.view(.set(\.code, "12"))) { $0.code = "12" } - await store.send(.view(.set(\.$code, "123"))) { + await store.send(.view(.set(\.code, "123"))) { $0.code = "123" } - await store.send(.view(.set(\.$code, "1234"))) { + await store.send(.view(.set(\.code, "1234"))) { $0.code = "1234" $0.isFormValid = true } @@ -44,7 +44,7 @@ final class TwoFactorCoreTests: XCTestCase { } } - await store.send(.view(.set(\.$code, "1234"))) { + await store.send(.view(.set(\.code, "1234"))) { $0.code = "1234" $0.isFormValid = true } diff --git a/Examples/Todos/Todos/Todo.swift b/Examples/Todos/Todos/Todo.swift index f83e93acdff6..fdebac6a49ca 100644 --- a/Examples/Todos/Todos/Todo.swift +++ b/Examples/Todos/Todos/Todo.swift @@ -3,10 +3,11 @@ import SwiftUI @Reducer struct Todo { + @ObservableState struct State: Equatable, Identifiable { - @BindingState var description = "" + var description = "" let id: UUID - @BindingState var isComplete = false + var isComplete = false } enum Action: BindableAction, Sendable { @@ -19,21 +20,19 @@ struct Todo { } struct TodoView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - HStack { - Button { - viewStore.$isComplete.wrappedValue.toggle() - } label: { - Image(systemName: viewStore.isComplete ? "checkmark.square" : "square") - } - .buttonStyle(.plain) - - TextField("Untitled Todo", text: viewStore.$description) + HStack { + Button { + store.isComplete.toggle() + } label: { + Image(systemName: store.isComplete ? "checkmark.square" : "square") } - .foregroundColor(viewStore.isComplete ? .gray : nil) + .buttonStyle(.plain) + + TextField("Untitled Todo", text: $store.description) } + .foregroundColor(store.isComplete ? .gray : nil) } } diff --git a/Examples/Todos/Todos/Todos.swift b/Examples/Todos/Todos/Todos.swift index b65d819db79d..1b2084c803b8 100644 --- a/Examples/Todos/Todos/Todos.swift +++ b/Examples/Todos/Todos/Todos.swift @@ -9,9 +9,10 @@ enum Filter: LocalizedStringKey, CaseIterable, Hashable { @Reducer struct Todos { + @ObservableState struct State: Equatable { - @BindingState var editMode: EditMode = .inactive - @BindingState var filter: Filter = .all + var editMode: EditMode = .inactive + var filter: Filter = .all var todos: IdentifiedArrayOf = [] var filteredTodos: IdentifiedArrayOf { @@ -84,7 +85,7 @@ struct Todos { state.todos.sort { $1.isComplete && !$0.isComplete } return .none - case .todos(.element(id: _, action: .binding(\.$isComplete))): + case .todos(.element(id: _, action: .binding(\.isComplete))): return .run { send in try await self.clock.sleep(for: .seconds(1)) await send(.sortCompletedTodos, animation: .default) @@ -102,53 +103,39 @@ struct Todos { } struct AppView: View { - let store: StoreOf - - struct ViewState: Equatable { - @BindingViewState var editMode: EditMode - @BindingViewState var filter: Filter - let isClearCompletedButtonDisabled: Bool - - init(store: BindingViewStore) { - self._editMode = store.$editMode - self._filter = store.$filter - self.isClearCompletedButtonDisabled = !store.todos.contains(where: \.isComplete) - } - } + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - NavigationStack { - VStack(alignment: .leading) { - Picker("Filter", selection: viewStore.$filter.animation()) { - ForEach(Filter.allCases, id: \.self) { filter in - Text(filter.rawValue).tag(filter) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - - List { - ForEachStore(self.store.scope(state: \.filteredTodos, action: \.todos)) { store in - TodoView(store: store) - } - .onDelete { viewStore.send(.delete($0)) } - .onMove { viewStore.send(.move($0, $1)) } + NavigationStack { + VStack(alignment: .leading) { + Picker("Filter", selection: $store.filter.animation()) { + ForEach(Filter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) } } - .navigationTitle("Todos") - .navigationBarItems( - trailing: HStack(spacing: 20) { - EditButton() - Button("Clear Completed") { - viewStore.send(.clearCompletedButtonTapped, animation: .default) - } - .disabled(viewStore.isClearCompletedButtonDisabled) - Button("Add Todo") { viewStore.send(.addTodoButtonTapped, animation: .default) } + .pickerStyle(.segmented) + .padding(.horizontal) + + List { + ForEach(store.scope(state: \.filteredTodos, action: \.todos)) { store in + TodoView(store: store) } - ) - .environment(\.editMode, viewStore.$editMode) + .onDelete { store.send(.delete($0)) } + .onMove { store.send(.move($0, $1)) } + } } + .navigationTitle("Todos") + .navigationBarItems( + trailing: HStack(spacing: 20) { + EditButton() + Button("Clear Completed") { + store.send(.clearCompletedButtonTapped, animation: .default) + } + .disabled(!store.todos.contains(where: \.isComplete)) + Button("Add Todo") { store.send(.addTodoButtonTapped, animation: .default) } + } + ) + .environment(\.editMode, $store.editMode) } } } diff --git a/Examples/Todos/Todos/TodosApp.swift b/Examples/Todos/Todos/TodosApp.swift index d3e4c2e00e27..f4c394a33135 100644 --- a/Examples/Todos/Todos/TodosApp.swift +++ b/Examples/Todos/Todos/TodosApp.swift @@ -7,7 +7,7 @@ struct TodosApp: App { WindowGroup { AppView( store: Store(initialState: Todos.State()) { - Todos()._printChanges() + Todos() } ) } diff --git a/Examples/Todos/TodosTests/TodosTests.swift b/Examples/Todos/TodosTests/TodosTests.swift index af252c482c7c..bed72d61aa43 100644 --- a/Examples/Todos/TodosTests/TodosTests.swift +++ b/Examples/Todos/TodosTests/TodosTests.swift @@ -59,7 +59,7 @@ final class TodosTests: XCTestCase { await store.send( .todos( .element( - id: state.todos[0].id, action: .set(\.$description, "Learn Composable Architecture") + id: state.todos[0].id, action: .set(\.description, "Learn Composable Architecture") ) ) ) { @@ -89,7 +89,7 @@ final class TodosTests: XCTestCase { $0.continuousClock = self.clock } - await store.send(.todos(.element(id: state.todos[0].id, action: .set(\.$isComplete, true)))) { + await store.send(.todos(.element(id: state.todos[0].id, action: .set(\.isComplete, true)))) { $0.todos[id: state.todos[0].id]?.isComplete = true } await self.clock.advance(by: .seconds(1)) @@ -123,11 +123,11 @@ final class TodosTests: XCTestCase { $0.continuousClock = self.clock } - await store.send(.todos(.element(id: state.todos[0].id, action: .set(\.$isComplete, true)))) { + await store.send(.todos(.element(id: state.todos[0].id, action: .set(\.isComplete, true)))) { $0.todos[id: state.todos[0].id]?.isComplete = true } await self.clock.advance(by: .milliseconds(500)) - await store.send(.todos(.element(id: state.todos[0].id, action: .set(\.$isComplete, false)))) { + await store.send(.todos(.element(id: state.todos[0].id, action: .set(\.isComplete, false)))) { $0.todos[id: state.todos[0].id]?.isComplete = false } await self.clock.advance(by: .seconds(1)) @@ -255,7 +255,7 @@ final class TodosTests: XCTestCase { $0.continuousClock = self.clock } - await store.send(.set(\.$editMode, .active)) { + await store.send(.set(\.editMode, .active)) { $0.editMode = .active } await store.send(.move([0], 2)) { @@ -302,10 +302,10 @@ final class TodosTests: XCTestCase { $0.uuid = .incrementing } - await store.send(.set(\.$editMode, .active)) { + await store.send(.set(\.editMode, .active)) { $0.editMode = .active } - await store.send(.set(\.$filter, .completed)) { + await store.send(.set(\.filter, .completed)) { $0.filter = .completed } await store.send(.move([0], 2)) { @@ -340,11 +340,11 @@ final class TodosTests: XCTestCase { Todos() } - await store.send(.set(\.$filter, .completed)) { + await store.send(.set(\.filter, .completed)) { $0.filter = .completed } await store.send( - .todos(.element(id: state.todos[1].id, action: .set(\.$description, "Did this already"))) + .todos(.element(id: state.todos[1].id, action: .set(\.description, "Did this already"))) ) { $0.todos[id: state.todos[1].id]?.description = "Did this already" } diff --git a/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift b/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift index 4be5796ef62c..5de28b8482ef 100644 --- a/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift +++ b/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift @@ -3,7 +3,8 @@ import SwiftUI @Reducer struct RecordingMemo { - struct State: Equatable { + @ObservableState + struct State: Equatable, Sendable { var date: Date var duration: TimeInterval = 0 var mode: Mode = .recording @@ -15,7 +16,7 @@ struct RecordingMemo { } } - enum Action { + enum Action: Sendable { case audioRecorderDidFinish(Result) case delegate(Delegate) case finalRecordingTime(TimeInterval) @@ -24,7 +25,7 @@ struct RecordingMemo { case stopButtonTapped @CasePathable - enum Delegate { + enum Delegate: Sendable { case didFinish(Result) } } @@ -87,37 +88,35 @@ struct RecordingMemoView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack(spacing: 12) { - Text("Recording") - .font(.title) - .colorMultiply(Color(Int(viewStore.duration).isMultiple(of: 2) ? .systemRed : .label)) - .animation(.easeInOut(duration: 0.5), value: viewStore.duration) - - if let formattedDuration = dateComponentsFormatter.string(from: viewStore.duration) { - Text(formattedDuration) - .font(.body.monospacedDigit().bold()) - .foregroundColor(.black) - } + VStack(spacing: 12) { + Text("Recording") + .font(.title) + .colorMultiply(Color(Int(store.duration).isMultiple(of: 2) ? .systemRed : .label)) + .animation(.easeInOut(duration: 0.5), value: store.duration) + + if let formattedDuration = dateComponentsFormatter.string(from: store.duration) { + Text(formattedDuration) + .font(.body.monospacedDigit().bold()) + .foregroundColor(.black) + } - ZStack { - Circle() - .foregroundColor(Color(.label)) - .frame(width: 74, height: 74) - - Button { - viewStore.send(.stopButtonTapped, animation: .default) - } label: { - RoundedRectangle(cornerRadius: 4) - .foregroundColor(Color(.systemRed)) - .padding(17) - } - .frame(width: 70, height: 70) + ZStack { + Circle() + .foregroundColor(Color(.label)) + .frame(width: 74, height: 74) + + Button { + store.send(.stopButtonTapped, animation: .default) + } label: { + RoundedRectangle(cornerRadius: 4) + .foregroundColor(Color(.systemRed)) + .padding(17) } + .frame(width: 70, height: 70) } - .task { - await viewStore.send(.onTask).finish() - } + } + .task { + await store.send(.onTask).finish() } } } diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift index 6f038f7ce3b0..b5c07d6a49d3 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift @@ -3,6 +3,7 @@ import SwiftUI @Reducer struct VoiceMemo { + @ObservableState struct State: Equatable, Identifiable { var date: Date var duration: TimeInterval @@ -100,47 +101,45 @@ struct VoiceMemo { } struct VoiceMemoView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - let currentTime = - viewStore.mode.playing.map { $0 * viewStore.duration } ?? viewStore.duration - HStack { - TextField( - "Untitled, \(viewStore.date.formatted(date: .numeric, time: .shortened))", - text: viewStore.binding(get: \.title, send: { .titleTextFieldChanged($0) }) - ) + let currentTime = + store.mode.playing.map { $0 * store.duration } ?? store.duration + HStack { + TextField( + "Untitled, \(store.date.formatted(date: .numeric, time: .shortened))", + text: $store.title.sending(\.titleTextFieldChanged) + ) - Spacer() + Spacer() - dateComponentsFormatter.string(from: currentTime).map { - Text($0) - .font(.footnote.monospacedDigit()) - .foregroundColor(Color(.systemGray)) - } + dateComponentsFormatter.string(from: currentTime).map { + Text($0) + .font(.footnote.monospacedDigit()) + .foregroundColor(Color(.systemGray)) + } - Button { - viewStore.send(.playButtonTapped) - } label: { - Image(systemName: viewStore.mode.is(\.playing) ? "stop.circle" : "play.circle") - .font(.system(size: 22)) - } + Button { + store.send(.playButtonTapped) + } label: { + Image(systemName: store.mode.is(\.playing) ? "stop.circle" : "play.circle") + .font(.system(size: 22)) } - .buttonStyle(.borderless) - .frame(maxHeight: .infinity, alignment: .center) - .padding(.horizontal) - .listRowBackground(viewStore.mode.is(\.playing) ? Color(.systemGray6) : .clear) - .listRowInsets(EdgeInsets()) - .background( - Color(.systemGray5) - .frame(maxWidth: viewStore.mode.is(\.playing) ? .infinity : 0) - .animation( - viewStore.mode.is(\.playing) ? .linear(duration: viewStore.duration) : nil, - value: viewStore.mode.is(\.playing) - ), - alignment: .leading - ) } + .buttonStyle(.borderless) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.horizontal) + .listRowBackground(store.mode.is(\.playing) ? Color(.systemGray6) : .clear) + .listRowInsets(EdgeInsets()) + .background( + Color(.systemGray5) + .frame(maxWidth: store.mode.is(\.playing) ? .infinity : 0) + .animation( + store.mode.is(\.playing) ? .linear(duration: store.duration) : nil, + value: store.mode.is(\.playing) + ), + alignment: .leading + ) } } diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift index 23c37c085694..07a5fb22ecc0 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift @@ -4,10 +4,11 @@ import SwiftUI @Reducer struct VoiceMemos { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? var audioRecorderPermission = RecorderPermission.undetermined - @PresentationState var recordingMemo: RecordingMemo.State? + @Presents var recordingMemo: RecordingMemo.State? var voiceMemos: IdentifiedArrayOf = [] enum RecorderPermission { @@ -17,7 +18,7 @@ struct VoiceMemos { } } - enum Action { + enum Action: Sendable { case alert(PresentationAction) case onDelete(IndexSet) case openSettingsButtonTapped @@ -132,37 +133,37 @@ struct VoiceMemos { } struct VoiceMemosView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - NavigationStack { - VStack { - List { - ForEachStore(self.store.scope(state: \.voiceMemos, action: \.voiceMemos)) { store in - VoiceMemoView(store: store) - } - .onDelete { viewStore.send(.onDelete($0)) } + NavigationStack { + VStack { + List { + ForEach(store.scope(state: \.voiceMemos, action: \.voiceMemos)) { store in + VoiceMemoView(store: store) } + .onDelete { store.send(.onDelete($0)) } + } - IfLetStore( - self.store.scope(state: \.$recordingMemo, action: \.recordingMemo) - ) { store in + Group { + if let store = store.scope( + state: \.recordingMemo, action: \.recordingMemo.presented + ) { RecordingMemoView(store: store) - } else: { - RecordButton(permission: viewStore.audioRecorderPermission) { - viewStore.send(.recordButtonTapped, animation: .spring()) + } else { + RecordButton(permission: store.audioRecorderPermission) { + store.send(.recordButtonTapped, animation: .spring()) } settingsAction: { - viewStore.send(.openSettingsButtonTapped) + store.send(.openSettingsButtonTapped) } } - .padding() - .frame(maxWidth: .infinity) - .background(Color.init(white: 0.95)) } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) - .navigationTitle("Voice memos") + .padding() + .frame(maxWidth: .infinity) + .background(Color.init(white: 0.95)) } + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationTitle("Voice memos") } } } @@ -179,20 +180,20 @@ struct RecordButton: View { .foregroundColor(Color(.label)) .frame(width: 74, height: 74) - Button(action: self.action) { + Button(action: action) { RoundedRectangle(cornerRadius: 35) .foregroundColor(Color(.systemRed)) .padding(2) } .frame(width: 70, height: 70) } - .opacity(self.permission == .denied ? 0.1 : 1) + .opacity(permission == .denied ? 0.1 : 1) - if self.permission == .denied { + if permission == .denied { VStack(spacing: 10) { Text("Recording requires microphone access.") .multilineTextAlignment(.center) - Button("Open Settings", action: self.settingsAction) + Button("Open Settings", action: settingsAction) } .frame(maxWidth: .infinity, maxHeight: 74) } diff --git a/Package.resolved b/Package.resolved index 9c99fe4539f9..a1e0f0f9f377 100644 --- a/Package.resolved +++ b/Package.resolved @@ -117,6 +117,15 @@ "version" : "0.2.2" } }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "42240120b2a8797595433288ab4118f8042214c3", + "version" : "1.1.1" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -149,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version" : "1.0.2" + "revision" : "b58e6627149808b40634c4552fcf2f44d0b3ca87", + "version" : "1.1.0" } } ], diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index fdcc04698271..bb9808906d73 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -29,6 +29,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.2.0"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.1.1"), .package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.1.0"), ], @@ -45,6 +46,7 @@ let package = Package( .product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "IdentifiedCollections", package: "swift-identified-collections"), .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "Perception", package: "swift-perception"), .product(name: "SwiftUINavigationCore", package: "swiftui-navigation"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), ] diff --git a/README.md b/README.md index 0c5ae5d2bc92..34392ab70105 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,6 @@ SwiftUI, UIKit, and more, and on any Apple platform (iOS, macOS, tvOS, and watch ## What is the Composable Architecture? -> [!Important] -> We are currently running a [public beta](https://github.com/pointfreeco/swift-composable-architecture/discussions/2594) -> for the new observation tools being introduced to the library. Be sure to check it out to get a peek -> at what the future of the library looks like. - This library provides a few core tools that can be used to build applications of varying purpose and complexity. It provides compelling stories that you can follow to solve many problems you encounter day-to-day when building applications, such as: @@ -91,7 +86,7 @@ iOS word search game built in SwiftUI and the Composable Architecture. ## Basic Usage -> **Note** +> [!Note] > For a step-by-step interactive tutorial, be sure to check out [Meet the Composable > Architecture][meet-tca]. @@ -114,8 +109,7 @@ will be able to break large, complex features into smaller domains that can be g As a basic example, consider a UI that shows a number along with "+" and "−" buttons that increment and decrement the number. To make things interesting, suppose there is also a button that when -tapped makes an API request to fetch a random fact about that number and then displays the fact in -an alert. +tapped makes an API request to fetch a random fact about that number and displays it in the view. To implement this feature we create a new type that will house the domain and behavior of the feature, and it will be annotated with the `@Reducer` macro: @@ -129,30 +123,34 @@ struct Feature { ``` In here we need to define a type for the feature's state, which consists of an integer for the -current count, as well as an optional string that represents the title of the alert we want to show -(optional because `nil` represents not showing an alert): +current count, as well as an optional string that represents the fact being presented: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { var count = 0 - var numberFactAlert: String? + var numberFact: String? } } ``` +> [!Note] +> We've applied the `@ObservableState` macro to `State` in order to take advantage of the +> observation tools in the library. + We also need to define a type for the feature's actions. There are the obvious actions, such as tapping the decrement button, increment button, or fact button. But there are also some slightly -non-obvious ones, such as the action of the user dismissing the alert, and the action that occurs -when we receive a response from the fact API request: +non-obvious ones, such as the action that occurs when we receive a response from the fact API +request: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { /* ... */ } enum Action { - case factAlertDismissed case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped @@ -169,16 +167,13 @@ execute effects, and they can return `.none` to represent that: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { /* ... */ } enum Action { /* ... */ } var body: some Reducer { Reduce { state, action in switch action { - case .factAlertDismissed: - state.numberFactAlert = nil - return .none - case .decrementButtonTapped: state.count -= 1 return .none @@ -198,7 +193,7 @@ struct Feature { } case let .numberFactResponse(fact): - state.numberFactAlert = fact + state.numberFact = fact return .none } } @@ -208,55 +203,45 @@ struct Feature { And then finally we define the view that displays the feature. It holds onto a `StoreOf` so that it can observe all changes to the state and re-render, and we can send all user actions to -the store so that state changes. We must also introduce a struct wrapper around the fact alert to -make it `Identifiable`, which the `.alert` view modifier requires: +the store so that state changes: ```swift struct FeatureView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - HStack { - Button("−") { viewStore.send(.decrementButtonTapped) } - Text("\(viewStore.count)") - Button("+") { viewStore.send(.incrementButtonTapped) } - } + Form { + Section { + Text("\(store.count)") + Button("Decrement") { store.send(.decrementButtonTapped) } + Button("Increment") { store.send(.incrementButtonTapped) } + } - Button("Number fact") { viewStore.send(.numberFactButtonTapped) } + Section { + Button("Number fact") { store.send(.numberFactButtonTapped) } + } + + if let fact = store.numberFact { + Text(fact) } - .alert( - item: viewStore.binding( - get: { $0.numberFactAlert.map(FactAlert.init(title:)) }, - send: .factAlertDismissed - ), - content: { Alert(title: Text($0.title)) } - ) } } } - -struct FactAlert: Identifiable { - var title: String - var id: String { self.title } -} ``` -It is also straightforward to have a UIKit controller driven off of this store. You subscribe to the -store in `viewDidLoad` in order to update the UI and show alerts. The code is a bit longer than the -SwiftUI version, so we have collapsed it here: +It is also straightforward to have a UIKit controller driven off of this store. You can observe +state changes in the store in `viewDidLoad`, and then populate the UI components with data from +the store. The code is a bit longer than the SwiftUI version, so we have collapsed it here:
Click to expand! ```swift class FeatureViewController: UIViewController { - let viewStore: ViewStoreOf - var cancellables: Set = [] + let store: StoreOf init(store: StoreOf) { - self.viewStore = ViewStore(store, observe: { $0 }) + self.store = store super.init(nibName: nil, bundle: nil) } @@ -268,32 +253,19 @@ SwiftUI version, so we have collapsed it here: super.viewDidLoad() let countLabel = UILabel() - let incrementButton = UIButton() let decrementButton = UIButton() - let factButton = UIButton() - + let incrementButton = UIButton() + let factLabel = UILabel() + // Omitted: Add subviews and set up constraints... - - self.viewStore.publisher - .map { "\($0.count)" } - .assign(to: \.text, on: countLabel) - .store(in: &self.cancellables) - - self.viewStore.publisher.numberFactAlert - .sink { [weak self] numberFactAlert in - let alertController = UIAlertController( - title: numberFactAlert, message: nil, preferredStyle: .alert - ) - alertController.addAction( - UIAlertAction( - title: "Ok", - style: .default, - handler: { _ in self?.viewStore.send(.factAlertDismissed) } - ) - ) - self?.present(alertController, animated: true, completion: nil) - } - .store(in: &self.cancellables) + + observe { [weak self] in + guard let self + else { return } + + countLabel.text = "\(self.store.text)" + factLabel.text = self.store.numberFact + } } @objc private func incrementButtonTapped() { @@ -339,7 +311,7 @@ doing much additional work. ### Testing -> **Note** +> [!Note] > For more in-depth information on testing, see the dedicated [testing][testing-article] article. To test use a `TestStore`, which can be created with the same information as the `Store`, but it @@ -370,13 +342,14 @@ await store.send(.decrementButtonTapped) { Further, if a step causes an effect to be executed, which feeds data back into the store, we must assert on that. For example, if we simulate the user tapping on the fact button we expect to -receive a fact response back with the fact, which then causes the alert to show: +receive a fact response back with the fact, which then causes the `numberFact` state to be +populated: ```swift await store.send(.numberFactButtonTapped) await store.receive(\.numberFactResponse) { - $0.numberFactAlert = ??? + $0.numberFact = ??? } ``` @@ -446,18 +419,13 @@ func testFeature() async { ``` With that little bit of upfront work we can finish the test by simulating the user tapping on the -fact button, receiving the response from the dependency to trigger the alert, and then dismissing -the alert: +fact button, and thenreceiving the response from the dependency to present the fact: ```swift await store.send(.numberFactButtonTapped) await store.receive(\.numberFactResponse) { - $0.numberFactAlert = "0 is a good number Brent" -} - -await store.send(.factAlertDismissed) { - $0.numberFactAlert = nil + $0.numberFact = "0 is a good number Brent" } ``` @@ -467,7 +435,7 @@ to `numberFact`, and explicitly passing it through all layers can get annoying. you can follow to “register” dependencies with the library, making them instantly available to any layer in the application. -> **Note** +> [!Note] > For more in-depth information on dependency management, see the dedicated > [dependencies][dependencies-article] article. @@ -507,7 +475,7 @@ With that little bit of upfront work done you can instantly start making use of any feature by using the `@Dependency` property wrapper: ```diff -@Reducer + @Reducer struct Feature { - let numberFact: (Int) async throws -> String + @Dependency(\.numberFact) var numberFact diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md index b9bf430a8bbd..3e482a6b2a6c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Bindings.md @@ -10,19 +10,16 @@ such communication with your application's store. ### Ad hoc bindings -The simplest tool for creating bindings that communicate with your store is -``ViewStore/binding(get:send:)-65xes``, which is handed two closures: one that describes how to -transform state into the binding's value, and one that describes how to transform the binding's -value into an action that can be fed back into the store. - -For example, a reducer may have a domain that tracks if user has enabled haptic feedback. First, it -can define a boolean property on state: +The simplest tool for creating bindings that communicate with your store is to create a dedicated +action that can change a piece of state in your feature. For example, a reducer may have a domain +that tracks if the user has enabled haptic feedback. First, it can define a boolean property on +state: ```swift @Reducer struct Settings { struct State: Equatable { - var isHapticFeedbackEnabled = true + var isHapticsEnabled = true // ... } @@ -39,7 +36,7 @@ struct Settings { struct State: Equatable { /* ... */ } enum Action { - case isHapticFeedbackEnabledChanged(Bool) + case isHapticsEnabledChanged(Bool) // ... } @@ -58,8 +55,8 @@ struct Settings { var body: some Reducer { Reduce { state, action in switch action { - case let .isHapticFeedbackEnabledChanged(isEnabled): - state.isHapticFeedbackEnabled = isEnabled + case let .isHapticsEnabledChanged(isEnabled): + state.isHapticsEnabled = isEnabled return .none // ... } @@ -68,43 +65,54 @@ struct Settings { } ``` -And finally, in the view, we can derive a binding from the domain that allows a toggle to -communicate with our Composable Architecture feature: +And finally, in the view, we can derive a binding from the domain that allows a toggle to +communicate with our Composable Architecture feature. First you must hold onto the store in a +bindable way, which can be done using the `@Bindable` property wrapper from SwiftUI: ```swift struct SettingsView: View { - let store: StoreOf - - var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Toggle( - "Haptic feedback", - isOn: viewStore.binding( - get: \.isHapticFeedbackEnabled, - send: { .isHapticFeedbackEnabledChanged($0) } - ) - ) - - // ... - } - } + @Bindable var store: StoreOf + // ... +} +``` + +> Important: If you are targeting older Apple platforms (iOS 16, macOS 13, tvOS 16, watchOS 9, or +> less), then you must use our backport of the `@Bindable` property wrapper: +> +> ```diff +> -@Bindable var store: StoreOf +> +@Perception.Bindable var store: StoreOf +> ``` + +Once that is done you can derive a binding to a piece of state that sends an action when the +binding is mutated: + +```swift +var body: some View { + Form { + Toggle( + "Haptic feedback", + isOn: $store.isHapticsEnabled.sending(\.isHapticsEnabledChanged) + ) + + // ... } } ``` -### Binding state, actions, and reducers +### Binding actions and reducers Deriving ad hoc bindings requires many manual steps that can feel tedious, especially for screens with many controls driven by many bindings. Because of this, the Composable Architecture comes with -a collection of tools that can be applied to a reducer's domain and logic to make this easier. +tools that can be applied to a reducer's domain and logic to make this easier. For example, a settings screen may model its state with the following struct: ```swift @Reducer struct Settings { - struct State: Equatable { + @ObservableState + struct State { var digest = Digest.daily var displayName = "" var enableNotifications = false @@ -125,7 +133,8 @@ comes in the form of an enum with a case per field: ```swift @Reducer struct Settings { - struct State: Equatable { /* ... */ } + @ObservableState + struct State { /* ... */ } enum Action { case digestChanged(Digest) @@ -146,7 +155,8 @@ the state at each field with a new value: ```swift @Reducer struct Settings { - struct State: Equatable { /* ... */ } + @ObservableState + struct State { /* ... */ } enum Action { /* ... */ } var body: some Reducer { @@ -182,39 +192,17 @@ struct Settings { ``` This is a _lot_ of boilerplate for something that should be simple. Luckily, we can dramatically -eliminate this boilerplate using ``BindingState``, ``BindableAction``, and ``BindingReducer``. +eliminate this boilerplate using ``BindableAction`` and ``BindingReducer``. -First, we can annotate each bindable value of state with the ``BindingState`` property wrapper: +First, we can conform the action type to ``BindableAction`` by collapsing all of the individual, +field-mutating actions into a single case that holds a ``BindingAction`` that is generic over the +reducer's state: ```swift @Reducer struct Settings { - struct State: Equatable { - @BindingState var digest = Digest.daily - @BindingState var displayName = "" - @BindingState var enableNotifications = false - var isLoading = false - @BindingState var protectMyPosts = false - @BindingState var sendEmailNotifications = false - @BindingState var sendMobileNotifications = false - } - - // ... -} -``` - -Each annotated field is directly bindable to SwiftUI controls, like pickers, toggles, and text -fields. Notably, the `isLoading` property is _not_ annotated as being bindable, which prevents the -view from mutating this value directly. - -Next, we can conform the action type to ``BindableAction`` by collapsing all of the individual, -field-mutating actions into a single case that holds a ``BindingAction`` generic over the reducer's -state: - -```swift -@Reducer -struct Settings { - struct State: Equatable { /* ... */ } + @ObservableState + struct State { /* ... */ } enum Action: BindableAction { case binding(BindingAction) @@ -224,13 +212,14 @@ struct Settings { } ``` -And then, we can simplify the settings reducer by allowing the ``BindingReducer`` to handle these +And then, we can simplify the settings reducer by adding a ``BindingReducer`` that handles these field mutations for us: ```swift @Reducer struct Settings { - struct State: Equatable { /* ... */ } + @ObservableState + struct State { /* ... */ } enum Action: BindableAction { /* ... */ } var body: some Reducer { @@ -239,14 +228,27 @@ struct Settings { } ``` -Binding actions are constructed and sent to the store by invoking dynamic member lookup on the view: +Then in the view you must hold onto the store in a bindable manner, which can be done using the +`@Bindable` property wrapper (or the backported tool `@Perception.Bindable` if targeting older +Apple platforms): ```swift -TextField("Display name", text: viewStore.$displayName) +struct SettingsView: View { + @Bindable var store: StoreOf + // ... +} ``` -Should you need to layer additional functionality over these bindings, your reducer can pattern -match the action for a given key path: +Then bindings can be derived from the store using familiar `$` syntax: + +```swift +TextField("Display name", text: $store.displayName) +Toggle("Notifications", text: $store.enableNotifications) +// ... +``` + +Should you need to layer additional functionality over these bindings, your can pattern match the +action for a given key path in the reducer: ```swift var body: some Reducer { @@ -254,11 +256,11 @@ var body: some Reducer { Reduce { state, action in switch action - case .binding(\.$displayName): + case .binding(\.displayName): // Validate display name - case .binding(\.$enableNotifications): - // Return an authorization request effect + case .binding(\.enableNotifications): + // Return an effect to request authorization from UNUserNotificationCenter // ... } @@ -266,121 +268,37 @@ var body: some Reducer { } ``` +Or you can apply ``Reducer/onChange(of:_:)`` to the ``BindingReducer`` to react to changes to +particular fields: + +```swift +var body: some Reducer { + BindingReducer() + .onChange(of: \.displayName) { oldValue, newValue in + // Validate display name + } + .onChange(of: \.enableNotifications) { oldValue, newValue in + // Return an authorization request effect + } + + // ... +} +``` + Binding actions can also be tested in much the same way regular actions are tested. Rather than send a specific action describing how a binding changed, such as `.displayNameChanged("Blob")`, you will send a ``BindingAction`` action that describes which key path is being set to what value, such as -`.set(\.$displayName, "Blob")`: +`.set(\.displayName, "Blob")`: ```swift let store = TestStore(initialState: Settings.State()) { Settings() } -store.send(.set(\.$displayName, "Blob")) { +store.send(.set(\.displayName, "Blob")) { $0.displayName = "Blob" } -store.send(.set(\.$protectMyPosts, true)) { +store.send(.set(\.protectMyPosts, true)) { $0.protectMyPosts = true ) ``` - -> Tip: If you use `@BindingState` on a larger struct and would like to observe changes to smaller -> fields, apply the ``Reducer/onChange(of:_:)`` modifier to the ``BindingReducer``: -> -> ```swift -> @Reducer -> struct Settings { -> struct State { -> @BindingState var developerSettings: DeveloperSettings -> // ... -> } -> // ... -> var body: some Reducer { -> BindingReducer() -> .onChange(of: \.developerSettings.showDiagnostics) { oldValue, newValue in -> // Logic for when `showDiagnostics` changes... -> } -> -> // ... -> } -> } -> ``` - -### Binding view state and binding view stores - -When a view store observes state bundled up in a "view state" struct (as described in -), a couple additional tools are required. First, the `ViewState` -struct must annotate the fields it will hold onto with the ``BindingViewState`` property wrapper: - -```swift -struct NotificationSettingsView: View { - let store: StoreOf - - struct ViewState: Equatable { - @BindingViewState var enableNotifications: Bool - @BindingViewState var sendEmailNotifications: Bool - @BindingViewState var sendMobileNotifications: Bool - } - - // ... -} -``` - -And then, when the view store is constructed, we can invoke the -``WithViewStore/init(_:observe:content:file:line:)-4gpoj`` initializer, which is handed a -``BindingViewStore`` that can produce ``BindingViewState`` values from a store: - -```swift -struct NotificationSettingsView: View { - // ... - - var body: some View { - WithViewStore( - self.store, - observe: { bindingViewStore in - ViewState( - enableNotifications: bindingViewStore.$enableNotifications, - sendEmailNotifications: bindingViewStore.$sendEmailNotifications, - sendMobileNotifications: bindingViewStore.$sendMobileNotifications - ) - } - ) { - // ... - } - } -} -``` - -We recommend extracting this work to simplify the call site, _e.g._ with an initializer on your -`ViewState` struct: - -```swift -struct NotificationSettingsView: View { - // ... - struct ViewState: Equatable { - // ... - - init(bindingViewStore: BindingViewStore) { - self._enableNotifications = bindingViewStore.$enableNotifications - self._sendEmailNotifications = bindingViewStore.$sendEmailNotifications - self._sendMobileNotifications = bindingViewStore.$sendMobileNotifications - } - } - - var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - // ... - } - } -} -``` - -Finally, you can use dynamic member lookup on the view store to pluck out any view state bindings: - -```swift -Form { - Toggle("Enable notifications", isOn: viewStore.$enableNotifications) - - // ... -} -``` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md index 6ec593638244..a788d9a4d00e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md @@ -33,6 +33,9 @@ let package = Package( ## Writing your first feature +> Note: For a step-by-step interactive tutorial, be sure to check out +> + To build a feature using the Composable Architecture you define some types and values that model your domain: @@ -52,43 +55,47 @@ will be able to break large, complex features into smaller domains that can be g As a basic example, consider a UI that shows a number along with "+" and "−" buttons that increment and decrement the number. To make things interesting, suppose there is also a button that when -tapped makes an API request to fetch a random fact about that number and then displays the fact in -an alert. +tapped makes an API request to fetch a random fact about that number and displays it in the view. To implement this feature we create a new type that will house the domain and behavior of the -feature, and you will annotate the type with the ``Reducer()`` macro: +feature, and it will be annotated with the `@Reducer` macro: ```swift +import ComposableArchitecture + @Reducer struct Feature { } ``` In here we need to define a type for the feature's state, which consists of an integer for the -current count, as well as an optional string that represents the title of the alert we want to show -(optional because `nil` represents not showing an alert): +current count, as well as an optional string that represents the fact being presented: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { var count = 0 - var numberFactAlert: String? + var numberFact: String? } } ``` +> Note: We've applied the `@ObservableState` macro to `State` in order to take advantage of the +> observation tools in the library. + We also need to define a type for the feature's actions. There are the obvious actions, such as tapping the decrement button, increment button, or fact button. But there are also some slightly -non-obvious ones, such as the action of the user dismissing the alert, and the action that occurs -when we receive a response from the fact API request: +non-obvious ones, such as the action that occurs when we receive a response from the fact API +request: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { /* ... */ } enum Action { - case factAlertDismissed case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped @@ -97,24 +104,21 @@ struct Feature { } ``` -And then we implement the ``Reducer/body-swift.property`` property, which is responsible for -handling the actual logic and behavior for the feature. In it we can use the ``Reduce`` reducer to -describe how to change the current state to the next state, and what effects need to be executed. -Some actions don't need to execute effects, and they can return ``Effect/none`` to represent that: +And then we implement the `body` property, which is responsible for composing the actual logic and +behavior for the feature. In it we can use the `Reduce` reducer to describe how to change the +current state to the next state, and what effects need to be executed. Some actions don't need to +execute effects, and they can return `.none` to represent that: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { /* ... */ } enum Action { /* ... */ } var body: some Reducer { Reduce { state, action in switch action { - case .factAlertDismissed: - state.numberFactAlert = nil - return .none - case .decrementButtonTapped: state.count -= 1 return .none @@ -134,7 +138,7 @@ struct Feature { } case let .numberFactResponse(fact): - state.numberFactAlert = fact + state.numberFact = fact return .none } } @@ -144,52 +148,42 @@ struct Feature { And then finally we define the view that displays the feature. It holds onto a `StoreOf` so that it can observe all changes to the state and re-render, and we can send all user actions to -the store so that state changes. We must also introduce a struct wrapper around the fact alert to -make it `Identifiable`, which the `.alert` view modifier requires: +the store so that state changes: ```swift struct FeatureView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - HStack { - Button("−") { viewStore.send(.decrementButtonTapped) } - Text("\(viewStore.count)") - Button("+") { viewStore.send(.incrementButtonTapped) } - } + Form { + Section { + Text("\(store.count)") + Button("Decrement") { store.send(.decrementButtonTapped) } + Button("Increment") { store.send(.incrementButtonTapped) } + } - Button("Number fact") { viewStore.send(.numberFactButtonTapped) } + Section { + Button("Number fact") { store.send(.numberFactButtonTapped) } + } + + if let fact = store.numberFact { + Text(fact) } - .alert( - item: viewStore.binding( - get: { $0.numberFactAlert.map(FactAlert.init(title:)) }, - send: .factAlertDismissed - ), - content: { Alert(title: Text($0.title)) } - ) } } } - -struct FactAlert: Identifiable { - var title: String - var id: String { self.title } -} ``` -It is also straightforward to have a UIKit controller driven off of this store. You subscribe to the -store in `viewDidLoad` in order to update the UI and show alerts. The code is a bit longer than the -SwiftUI version: +It is also straightforward to have a UIKit controller driven off of this store. You can observe +state changes in the store in `viewDidLoad`, and then populate the UI components with data from +the store. The code is a bit longer than the SwiftUI version, so we have collapsed it here: ```swift class FeatureViewController: UIViewController { - let viewStore: ViewStoreOf - var cancellables: Set = [] + let store: StoreOf init(store: StoreOf) { - self.viewStore = ViewStore(store, observe: { $0 }) + self.store = store super.init(nibName: nil, bundle: nil) } @@ -201,32 +195,19 @@ class FeatureViewController: UIViewController { super.viewDidLoad() let countLabel = UILabel() - let incrementButton = UIButton() let decrementButton = UIButton() - let factButton = UIButton() - + let incrementButton = UIButton() + let factLabel = UILabel() + // Omitted: Add subviews and set up constraints... - - self.viewStore.publisher - .map { "\($0.count)" } - .assign(to: \.text, on: countLabel) - .store(in: &self.cancellables) - - self.viewStore.publisher.numberFactAlert - .sink { [weak self] numberFactAlert in - let alertController = UIAlertController( - title: numberFactAlert, message: nil, preferredStyle: .alert - ) - alertController.addAction( - UIAlertAction( - title: "OK", - style: .default, - handler: { _ in self?.viewStore.send(.factAlertDismissed) } - ) - ) - self?.present(alertController, animated: true, completion: nil) - } - .store(in: &self.cancellables) + + observe { [weak self] in + guard let self + else { return } + + countLabel.text = "\(self.store.text)" + factLabel.text = self.store.numberFact + } } @objc private func incrementButtonTapped() { @@ -242,18 +223,22 @@ class FeatureViewController: UIViewController { ``` Once we are ready to display this view, for example in the app's entry point, we can construct a -store. This can be done by specifying the initial state to start the application in, as well as the -reducer that will power the application: +store. This can be done by specifying the initial state to start the application in, as well as +the reducer that will power the application: ```swift +import ComposableArchitecture + @main struct MyApp: App { var body: some Scene { - FeatureView( - store: Store(initialState: Feature.State()) { - Feature() - } - ) + WindowGroup { + FeatureView( + store: Store(initialState: Feature.State()) { + Feature() + } + ) + } } } ``` @@ -267,7 +252,10 @@ doing much additional work. ## Testing your feature -To test use a ``TestStore``, which can be created with the same information as the ``Store``, but it +> Note: For more in-depth information on testing, see the dedicated +article. + +To test use a `TestStore`, which can be created with the same information as the `Store`, but it does extra work to allow you to assert how your feature evolves as actions are sent: ```swift @@ -280,8 +268,8 @@ func testFeature() async { ``` Once the test store is created we can use it to make an assertion of an entire user flow of steps. -Each step of the way we need to prove that state changed how we expect. For example, we can simulate -the user flow of tapping on the increment and decrement buttons: +Each step of the way we need to prove that state changed how we expect. For example, we can +simulate the user flow of tapping on the increment and decrement buttons: ```swift // Test that tapping on the increment/decrement buttons changes the count @@ -295,13 +283,14 @@ await store.send(.decrementButtonTapped) { Further, if a step causes an effect to be executed, which feeds data back into the store, we must assert on that. For example, if we simulate the user tapping on the fact button we expect to -receive a fact response back with the fact, which then causes the alert to show: +receive a fact response back with the fact, which then causes the `numberFact` state to be +populated: ```swift await store.send(.numberFactButtonTapped) await store.receive(\.numberFactResponse) { - $0.numberFactAlert = "???" + $0.numberFact = ??? } ``` @@ -312,8 +301,8 @@ and that means we have no way to control its behavior. We are at the whims of ou connectivity and the availability of the API server in order to write this test. It would be better for this dependency to be passed to the reducer so that we can use a live -dependency when running the application on a device, but use a mocked dependency for tests. We -can do this by adding a property to the `Feature` reducer: +dependency when running the application on a device, but use a mocked dependency for tests. We can +do this by adding a property to the `Feature` reducer: ```swift @Reducer @@ -327,7 +316,7 @@ Then we can use it in the `reduce` implementation: ```swift case .numberFactButtonTapped: - return .run { [count = state.count] send in + return .run { [count = state.count] send in let fact = try await self.numberFact(count) await send(.numberFactResponse(fact)) } @@ -340,23 +329,26 @@ interacts with the real world API server: @main struct MyApp: App { var body: some Scene { - FeatureView( - store: Store(initialState: Feature.State()) { - Feature( - numberFact: { number in - let (data, _) = try await URLSession.shared.data( - from: .init(string: "http://numbersapi.com/\(number)")! - ) - return String(decoding: data, as: UTF8.self) - } - ) - } - ) + WindowGroup { + FeatureView( + store: Store(initialState: Feature.State()) { + Feature( + numberFact: { number in + let (data, _) = try await URLSession.shared.data( + from: URL(string: "http://numbersapi.com/\(number)")! + ) + return String(decoding: data, as: UTF8.self) + } + ) + } + ) + } } } ``` -But in tests we can use a mock dependency that immediately returns a deterministic, predictable fact: +But in tests we can use a mock dependency that immediately returns a deterministic, predictable +fact: ```swift @MainActor @@ -368,18 +360,13 @@ func testFeature() async { ``` With that little bit of upfront work we can finish the test by simulating the user tapping on the -fact button, receiving the response from the dependency to trigger the alert, and then dismissing -the alert: +fact button, and thenreceiving the response from the dependency to present the fact: ```swift await store.send(.numberFactButtonTapped) await store.receive(\.numberFactResponse) { - $0.numberFactAlert = "0 is a good number Brent" -} - -await store.send(.factAlertDismissed) { - $0.numberFactAlert = nil + $0.numberFact = "0 is a good number Brent" } ``` @@ -389,6 +376,9 @@ to `numberFact`, and explicitly passing it through all layers can get annoying. you can follow to “register” dependencies with the library, making them instantly available to any layer in the application. +> Note: For more in-depth information on dependency management, see the dedicated + article. + We can start by wrapping the number fact functionality in a new type: ```swift @@ -397,16 +387,16 @@ struct NumberFactClient { } ``` -And then registering that type with the dependency management system, which is quite similar to -how SwiftUI's environment values works, except you specify the live implementation of the -dependency to be used by default: +And then registering that type with the dependency management system by conforming the client to +the `DependencyKey` protocol, which requires you to specify the live value to use when running the +application in simulators or devices: ```swift -private enum NumberFactClientKey: DependencyKey { - static let liveValue = NumberFactClient( +extension NumberFactClient: DependencyKey { + static let liveValue = Self( fetch: { number in - let (data, _) = try await URLSession.shared.data( - from: .init(string: "http://numbersapi.com/\(number)")! + let (data, _) = try await URLSession.shared + .data(from: URL(string: "http://numbersapi.com/\(number)")! ) return String(decoding: data, as: UTF8.self) } @@ -415,23 +405,26 @@ private enum NumberFactClientKey: DependencyKey { extension DependencyValues { var numberFact: NumberFactClient { - get { self[NumberFactClientKey.self] } - set { self[NumberFactClientKey.self] = newValue } + get { self[NumberFactClient.self] } + set { self[NumberFactClient.self] = newValue } } } ``` With that little bit of upfront work done you can instantly start making use of the dependency in -any feature: - -```swift -@Reducer -struct Feature { - struct State { /* ... */ } - enum Action { /* ... */ } - @Dependency(\.numberFact) var numberFact - // ... -} +any feature by using the `@Dependency` property wrapper: + +```diff + @Reducer + struct Feature { +- let numberFact: (Int) async throws -> String ++ @Dependency(\.numberFact) var numberFact + + … + +- try await self.numberFact(count) ++ try await self.numberFact.fetch(count) + } ``` This code works exactly as it did before, but you no longer have to explicitly pass the dependency @@ -445,11 +438,13 @@ This means the entry point to the application no longer needs to construct depen @main struct MyApp: App { var body: some Scene { - FeatureView( - store: Store(initialState: Feature.State()) { - Feature() - } - ) + WindowGroup { + FeatureView( + store: Store(initialState: Feature.State()) { + Feature() + } + ) + } } } ``` @@ -464,15 +459,13 @@ let store = TestStore(initialState: Feature.State()) { $0.numberFact.fetch = { "\($0) is a good number Brent" } } -await store.send(.numberFactButtonTapped) -await store.receive(\.numberFactResponse) { - $0.numberFactAlert = "0 is a good number Brent" -} +// ... ``` That is the basics of building and testing a feature in the Composable Architecture. There are -_a lot_ more things to be explored, such as , , - and more about . Also, the [Examples][examples] directory has +_a lot_ more things to be explored. Be sure to check out the +tutorial, as well as dedicated articles on , , +, , and more. Also, the [Examples][examples] directory has a bunch of projects to explore to see more advanced usages. [examples]: https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md index 9996ca4cd199..541e8c65760e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md @@ -147,7 +147,7 @@ store.receive(\.child.presented.response.success) > } > ``` -And in the case of ``PresentationAction`` you can even omit the ``PresentationAction/presented(_:)`` +And in the case of ``PresentationAction`` you can even omit the ``PresentationAction/presented(_:)`` path component: ```swift diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.5.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.5.md index b0befca284e2..4f900ab6fdfe 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.5.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.5.md @@ -37,7 +37,7 @@ can be promoted to closures. That means often scoping looked something like this ```swift // ⚠️ Deprecated API ChildView( - store: self.store.scope( + store: store.scope( state: \.child, action: { .child($0) } ) @@ -57,7 +57,7 @@ The above construction of `ChildView` now becomes: ```swift // ✅ New API ChildView( - store: self.store.scope( + store: store.scope( state: \.child, action: \.child ) @@ -75,7 +75,7 @@ you perform additional work in your scoping closure so that a simple key path do ```swift ChildView( - store: self.store.scope( + store: store.scope( state: { ChildFeature(state: $0.child) }, action: { .child($0) } ) @@ -96,7 +96,7 @@ And now the key path syntax works just fine: ```swift ChildView( - store: self.store.scope( + store: store.scope( state: \.childFeature, action: \.child ) @@ -107,7 +107,7 @@ Another complication is if you are using data from _outside_ the closure, _insid ```swift ChildView( - store: self.store.scope( + store: store.scope( state: { ChildFeature( settings: viewStore.settings, @@ -136,7 +136,7 @@ Then you can use a subscript key path to perform the scoping: ```swift ChildView( - store: self.store.scope( + store: store.scope( state: \.[settings: viewStore.settings], action: \.child ) @@ -215,7 +215,7 @@ actions back into the destination domain: ```swift // ⚠️ Deprecated API .sheet( - store: self.store.scope(state: \.$destination, action: { .destination($0) }), + store: store.scope(state: \.$destination, action: { .destination($0) }), state: \.editForm, action: { .editForm($0) } ) @@ -227,7 +227,7 @@ and instead you can do it all with a single `store` argument: ```swift // ✅ New API .sheet( - store: self.store.scope( + store: store.scope( state: \.$destination.editForm, action: \.destination.editForm ) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.7.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.7.md new file mode 100644 index 000000000000..91cb587577f1 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.7.md @@ -0,0 +1,895 @@ +# Migrating to 1.7 + +Update your code to make use of the new observation tools in the library and get rid of legacy +APIs such as ``WithViewStore``, ``IfLetStore``, ``ForEachStore``, and more. + +## Overview + +The Composable Architecture is under constant development, and we are always looking for ways to +simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs +in favor of newer ones. We recommend people update their code as quickly as possible to the newest +APIs, and this article contains some tips for doing so. + +> Important: Before following this migration guide be sure you have fully migrated to the newest +tools of version 1.6. See , , and for +more information. + +> Note: The following migration guide mostly assumes you are targeting iOS 17, macOS 14, tvOS 17, +watchOS 10 or higher, but the tools do work for older platforms too. See the dedicated + article for more information on how to use the new observation tools if +you are targeting older platforms. + +### Topics + +* [Using @ObservableState](#Using-ObservableState) +* [Replacing IfLetStore with ‘if let’](#Replacing-IfLetStore-with-if-let) +* [Replacing ForEachStore with ForEach](#Replacing-ForEachStore-with-ForEach) +* [Replacing SwitchStore and CaseLet with ‘switch’ and ‘case’](#Replacing-SwitchStore-and-CaseLet-with-switch-and-case) +* [Replacing @PresentationState with @Presentation](#Replacing-PresentationState-with-Presentation) +* [Replacing navigation view modifiers with SwiftUI modifiers](#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers) +* [Updating alert and confirmationDialog](#Updating-alert-and-confirmationDialog) +* [Replacing NavigationStackStore with NavigationStack](#Replacing-NavigationStackStore-with-NavigationStack) +* [@BindingState](#BindingState) +* [ViewStore.binding](#ViewStorebinding) +* [Computed view state](#Computed-view-state) +* [View actions](#View-actions) +* [Observing for UIKit](#Observing-for-UIKit) +* [Incrementally migrating](#Incrementally-migrating) + +## Using @ObservableState + +There are two ways to update existing code to use the new ``ObservableState()`` macro depending on +your minimum deployment target. Take, for example, the following scaffolding of a typical feature +built with the Composable Architecture prior to version 1.7 and the new observation tools: + +```swift +@Reducer +struct Feature { + struct State { /* ... */ } + enum Action { /* ... */ } + var body: some ReducerOf { + // ... + } +} + +struct FeatureView: View { + let store: StoreOf + + struct ViewState: Equatable { + // ... + init(state: Feature.State) { /* ... */ } + } + + var body: some View { + WithViewStore(store, observe: ViewState.init) { viewStore in + Form { + Text(viewStore.count.description) + Button("+") { viewStore.send(.incrementButtonTapped) } + } + } + } +} +``` + +This feature is manually managing a `ViewState` struct and using ``WithViewStore`` in order to +minimize the state being observed in the view. + +If you are still targeting iOS 16, macOS 13, tvOS 16, watchOS 9 or _lower_, then you can update the +code in the following way: + +```diff + @Reducer + struct Feature { ++ @ObservableState + struct State { /* ... */ } + enum Action { /* ... */ } + var body: some ReducerOf { + // ... + } + } + + struct FeatureView: View { + let store: StoreOf + +- struct ViewState: Equatable { +- // ... +- init(state: Feature.State) { /* ... */ } +- } + + var body: some View { +- WithViewStore(store, observe: ViewState.init) { store in ++ WithPerceptionTracking { + Form { +- Text(viewStore.count.description) +- Button("+") { viewStore.send(.incrementButtonTapped) } ++ Text(store.count.description) ++ Button("+") { store.send(.incrementButtonTapped) } + } + } + } + } +``` + +In particular, the following changes must be made: + + * Mark your `State` with the ``ObservableState()`` macro. + * Delete any view state type you have defined. + * Replace the use of ``WithViewStore`` with `WithPerceptionTracking`, and the trailing closure + does not take an argument. The view constructed inside the trailing closure will automatically + observe state accessed inside the closure. + * Access state directly in the `store` rather than in the `viewStore`. + * Send actions directly to the `store` rather than to the `viewStore`. + +If you are able to target iOS 17, macOS 14, tvOS 17, watchOS 10 or _higher_, then you will still +apply all of the updates above, but with one additional simplification to the `body` of the view: + +```diff + var body: some View { +- WithViewStore(store, observe: ViewState.init) { store in + Form { +- Text(viewStore.count.description) +- Button("+") { viewStore.send(.incrementButtonTapped) } ++ Text(store.count.description) ++ Button("+") { store.send(.incrementButtonTapped) } + } +- } + } +``` + +You no longer need the ``WithViewStore`` or `WithPerceptionTracking` views at all. + +## Replacing IfLetStore with 'if let' + +The ``IfLetStore`` view was a helper for transforming a ``Store`` of optional state into a store of +non-optional state so that it can be handed off to a child view. It is no longer needed when using +the new observation tools, and so it is **soft-deprecated**. + +For example, if your feature's reducer looks roughly like this: + +```swift +@Reducer +struct Feature { + @ObservableState + struct State { + var child: Child.State? + } + enum Action { + case child(Child.Action) + } + var body: some ReducerOf { /* ... */ } +} +``` + +Then previously you would make use of ``IfLetStore`` in the view like this: + +```swift +IfLetStore(store: store.scope(state: \.child, action: \.child)) { childStore in + ChildView(store: childStore) +} else: { + Text("Nothing to show") +} +``` + +This can now be updated to use plain `if let` syntax with ``Store/scope(state:action:)-36e72``: + +```swift +if let childStore = store.scope(state: \.child, action: \.child)) { + ChildView(store: childStore) +} else { + Text("Nothing to show") +} +``` + +## Replacing ForEachStore with ForEach + +The ``ForEachStore`` view was a helper for deriving a store for each element of a collection. It is +no longer needed when using the new observation tools, and so it is **soft-deprecated**. + +For example, if your feature's reducer looks roughly like this: + +```swift +@Reducer +struct Feature { + @ObservableState + struct State { + var rows: IdentifiedArrayOf = [] + } + enum Action { + case rows(IdentifiedActionOf) + } + var body: some ReducerOf { /* ... */ } +} +``` + +Then you would have made use of ``ForEachStore`` in the view like this: + +```swift +ForEachStore(store.scope(state: \.rows, action: \.rows)) { childStore in + ChildView(store: childStore) +} +``` + +This can now be updated to use the vanilla `ForEach` view in SwiftUI, along with +``Store/scope(state:action:)-1nelp``: + +```swift +ForEach(store.scope(state: \.rows, action: \.rows)) { childStore in + ChildView(store: childStore) +} +``` + +If your usage of `ForEachStore` relied on the identity of the state of each row (_e.g._, the state's +`id` is also associated with a selection binding), you must explicitly use the `id` parameter: + +```diff + ForEach( + store.scope(state: \.rows, action: \.rows), ++ id: \.state.id + ) { childStore in + ChildView(store: childStore) + } +``` + +> Tip: You can now use collection-based operators with store scoping. For example, use +> `Array.enumerated` in order to enumerate the rows so that you can provide custom styling based on +> the row being even or odd: +> +> ```swift +> ForEach( +> Array(store.scope(state: \.rows, action: \.rows).enumerated()), +> id: \.element +> ) { position, childStore in +> ChildView(store: childStore) +> .background { +> position.isMultiple(of: 2) ? Color.white : Color.gray +> } +> } +> ``` + +## Replacing SwitchStore and CaseLet with 'switch' and 'case' + +The ``SwitchStore`` and ``CaseLet`` views are helpers for driving a ``Store`` for each case of +an enum. These views are no longer needed when using the new observation tools, and so they are +**soft-deprecated**. + +For example, if your feature's reducer looks roughly like this: + +```swift +@Reducer +struct Feature { + @ObservableState + enum State { + case activity(ActivityFeature.State) + case settings(SettingsFeature.State) + } + enum Action { + case activity(ActivityFeature.Action) + case settings(SettingsFeature.Action) + } + var body: some ReducerOf { /* ... */ } +} +``` + +Then you would have used ``SwitchStore`` and ``CaseLet`` in the view like this: + +```swift +SwitchStore(store) { + switch $0 { + case .activity: + CaseLet(/Feature.State.activity, action: Feature.Action.activity) { store in + ActivityView(store: store) + } + case .settings: + CaseLet(/Feature.State.settings, action: Feature.Action.settings) { store in + SettingsView(store: store) + } + } +} +``` + +This can now be updated to use a vanilla `switch` and `case` in the view: + +```swift +switch store.state { +case .activity: + if let store = store.scope(state: \.activity, action: \.activity) { + ActivityView(store: store) + } +case .settings: + if let store = store.scope(state: \.settings, action: \.settings) { + SettingsView(store: store) + } +} +``` + +## Replacing @PresentationState with @Presentation + +It is a well-known limitation of Swift macros that they cannot be used with property wrappers. +This means that if your feature uses ``PresentationState`` you will get compiler errors when +applying the ``ObservableState()`` macro: + +```swift +@ObservableState +struct State { + @PresentationState var child: Child.State? // 🛑 +} +``` + +Instead of using the ``PresentationState`` property wrapper you can now use the new ``Presents()`` +macro: + +```swift +@ObservableState +struct State { + @Presents var child: Child.State? // ✅ +} +``` + +## Replacing navigation view modifiers with SwiftUI modifiers + +The library has shipped many navigation view modifiers that mimic what SwiftUI provides, but are +tuned specifically for driving navigation from a ``Store``. All of these view modifiers can be +updated to instead use the vanilla SwiftUI version of the view modifier, and so the modifier that +ship with this library are now soft-deprecated. + +For example, if your feature's reducer looks roughly like this: + +```swift +@Reducer +struct Feature { + @ObservableState + struct State { + @Presents var child: Child.State? + } + enum Action { + case child(PresentationAction) + } + var body: some ReducerOf { /* ... */ } +} +``` + +Then previously you would drive a sheet presentation from the view like so: + +```swift +.sheet(store: store.scope(state: \.$child, action: \.child)) { store in + ChildView(store: store) +} +``` + +You can now replace `sheet(store:)` with the vanilla SwiftUI modifier, `sheet(item:)`. First you +must hold onto the store in your view in a bindable manner, using the `@Bindable` property wrapper: + +```swift +@Bindable var store: StoreOf +``` + +…or, if you're targeting older platforms, using `@Perception.Bindable`: + +```swift +@Perception.Bindable var store: StoreOf +``` + +Then you can use `sheet(item:)` like so: + +```swift +.sheet(item: $store.scope(state: \.child, action: \.child)) { store in + ChildView(store: store) +} +``` + +Note that the state key path is simply `state: \.child`, and not `state: \.$child`. The projected +value of the presentation state is no longer needed. + +This also applies to popovers, full screen covers, and navigation destinations. + +Also, if you are driving navigation from an enum of destinations, then currently your code may +look something like this: + +```swift +.sheet( + store: store.scope( + state: \.$destination.editForm, + action: \.destination.editForm + ) +) { store in + ChildView(store: store) +} +``` + +This can now be changed to this: + +```swift +.sheet( + item: $store.scope( + state: \.destination?.editForm, + action: \.destination.editForm + ) +) { store in + ChildView(store: store) +} +``` + +Note that the state key path is now simply `\.destination?.editForm`, and not +`\.$destination.editForm`. + +Also note that `navigationDestination(item:)` is not available on older platforms, but can be made +available as far back as iOS 15 using a wrapper. See + for more information. + +## Updating alert and confirmationDialog + +The ``SwiftUI/View/alert(store:)`` and ``SwiftUI/View/confirmationDialog(store:)`` modifiers have +been used to drive alerts and dialogs from stores, but new modifiers are now available that can +drive alerts and dialogs from the same store binding scope operation that can power vanilla SwiftUI +presentation, like `sheet(item:)`. + +For example, if your feature's reducer presents an alert: + +```swift +@Reducer +struct Feature { + @ObservableState + struct State { + @Presents var alert: AlertState? + } + enum Action { + case alert(PresentationAction) + enum Alert { /* ... */ } + } + var body: some ReducerOf { /* ... */ } +} +``` + +Then previously you would drive it from the feature's view like so: + +```swift +.alert(store: store.scope(state: \.$alert, action: \.alert)) +``` + +You can now replace `alert(store:)` with a new modifier, ``SwiftUI/View/alert(_:)``: + +```swift +.alert($store.scope(state: \.alert, action: \.alert)) +``` + +## Replacing NavigationStackStore with NavigationStack + +The ``NavigationStackStore`` view was a helper for driving a navigation stack from a ``Store``. It +is no longer needed when using the new observation tools, and so it is **soft-deprecated**. + +For example, if your feature's reducer looks roughly like this: + +```swift +@Reducer +struct Feature { + struct State { + var path: StackState = [] + } + enum Action { + case path(StackAction) + } + var body: some ReducerOf { /* ... */ } +} +``` + +Then you would have made use of ``NavigationStackStore`` in the view like this: + +```swift +NavigationStackStore(store.scope(state: \.path, action: \.path)) { + RootView() +} destination: { + switch $0 { + case .activity: + CaseLet(/Feature.State.activity, action: Feature.Action.activity) { store in + ActivityView(store: store) + } + case .settings: + CaseLet(/Feature.State.settings, action: Feature.Action.settings) { store in + SettingsView(store: store) + } + } +} +``` + +To update this code, first mark your feature's state with ``ObservableState()``: + +```swift +@Reducer +struct Feature { + @ObservableState + struct State { + // ... + } + // ... +} +``` + +As well as the `Path` reducer's state: + +```swift +@Reducer +struct Path { + @ObservableState + enum State { + // ... + } + // ... +} +``` + +Then in the view you must start holding onto the `store` in a bindable manner, using the `@Bindable` +property wrapper: + +```swift +@Bindable var store: StoreOf +``` + +…or using `@Perception.Bindable` if targeting older platforms: + +```swift +@Perception.Bindable var store: StoreOf +``` + +And the original code can now be updated to our custom initializer +``SwiftUI/NavigationStack/init(path:root:destination:)`` on `NavigationStack`: + +```swift +NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + RootView() +} destination: { store in + switch store.state { + case .activity: + if let store = store.scope(state: \.activity, action: \.activity) { + ActivityView(store: store) + } + case .settings: + if let store = store.scope(state: \.settings, action: \.settings) { + SettingsView(store: store) + } + } +} +``` + +## @BindingState + +Bindings in the Composable Architecture have historically been handled by a zoo of types, including +, ``BindableAction``, ``BindingAction``, ``BindingViewState`` and +``BindingViewStore``. For example, if your view needs to be able to derive bindings to many fields +on your state, you may have the reducer built somewhat like this: + +```swift +@Reducer +struct Feature { + struct State { + @BindingState var text = "" + @BindingState var isOn = false + } + enum Action: BindableAction { + case binding(BindingAction) + } + var body: some ReducerOf { /* ... */ } +} +``` + +And in the view you derive bindings using ``ViewStore/subscript(dynamicMember:)-3q4xh`` defined on +``ViewStore``: + +```swift +WithViewStore(store, observe: { $0 }) { viewStore in + Form { + TextField("Text", text: viewStore.$text) + Toggle(isOn: viewStore.$isOn) + } +} +``` + +But if you have view state in your view, then you have a lot more steps to take: + +```swift +struct ViewState: Equatable { + @BindingViewState var text: String + @BindingViewState var isOn: Bool + init(store: BindingViewStore) { + self._text = store.$text + self._isOn = store.$isOn + } +} + +var body: some View { + WithViewStore(store, observe: ViewState.init) { viewStore in + Form { + TextField("Text", text: viewStore.$text) + Toggle(isOn: viewStore.$isOn) + } + } +} +``` + +Most of this goes away when using the ``ObservableState()`` macro. You can start by annotating +your feature's state with ``ObservableState()`` and removing all instances of : + +```diff ++@ObservableState + struct State { +- @BindingState var text = "" +- @BindingState isOn = false ++ var text = "" ++ var isOn = false + } +``` + +> Important: Do not remove the ``BindableAction`` conformance from your feature's `Action` or the +> ``BindingReducer`` from your reducer. Those are still required for bindings. + +In the view you must start holding onto the `store` in a bindable manner, which means using the +`@Bindable` property wrapper: + +```swift +@Bindable var store: StoreOf +``` + +> Note: If targeting older Apple platorms where `@Bindable` is not available, you can use our +backport of the property wrapper: +> +> ```swift +> @Perception.Bindable var store: StoreOf +> ``` + +Then in the `body` of the view you can stop using ``WithViewStore`` and instead derive bindings +directly from the store: + +```swift +var body: some View { + Form { + TextField("Text", text: $store.text) + Toggle(isOn: $store.isOn) + } +} +``` + +## ViewStore.binding + +There's another way to derive bindings from a view store that involves fewer tools than +`@BindingState` as shown above, but does involve more boilerplate. You can add an explicit action +for the binding to your domain, such as an action for setting the tab in a tab-based application: + +```swift +@Reducer +struct Feature { + struct State { + var tab = 0 + } + enum Action { + case tabChanged(Int) + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .tabChanged(tab): + state.tab = tab + return .none + } + } + } +} +``` + +And then in the view you can use ``ViewStore/binding(get:send:)-65xes`` to derive a binding from +the `tab` state and the `tabChanged` action: + +```swift +TabView( + selection: viewStore.binding(get: \.tab, send: { .tabChanged($0) }) +) { + // ... +} +``` + +Since the ``ViewStore`` type is now soft-deprecated, you can update this code to do something much +simpler. If you make your feature's state observable with the ``ObservableState`` macro: + +```swift +@Reducer +struct Feature { + @ObservableState + struct State { + // ... + } + // ... +} +``` + +In the view you must start holding onto the `store` in a bindable manner, which means using the +`@Bindable` (or `@Perception.Bindable`) property wrapper: + +```swift +@Bindable var store: StoreOf +``` + +Then you can derive a binding directly from a ``Store`` binding like so: + +```swift +TabView(selection: $store.tab.sending(\.tabChanged)) { + // ... +} +``` + +## Computed view state + +If you are using the `ViewState` pattern in your application, then you may be computing values +inside the initializer to be used in the view like so: + +```swift +struct ViewState: Equatable { + let fullName: String + init(state: Feature.State) { + self.fullName = "\(state.firstName) \(state.lastName)" + } +} +``` + +In version 1.7 of the library the `ViewState` struct goes away, and so you can move these kinds of +computations to be directly on your feature's state: + +```swift +struct State { + // State fields + + var fullName: String { + "\(self.firstName) \(self.lastName)" + } +} +``` + +## View actions + +There is a common pattern in the Composable Architecture community to separate actions that are +sent in the view from actions that are used internally in the feature, such as emissions of effects. +Typically this looks like the following: + +```swift +@Reducer +struct Feature + struct State { /* ... */ } + enum Action { + case loginResponse(Bool) + case view(View) + + enum View { + case loginButtonTapped + } + } + // ... +} +``` + +And then in the view you would use ``WithViewStore`` with the `send` argument to specify which +actions the view has access to: + +```swift +struct FeatureView: View { + let store: StoreOf + + var body: some View { + WithViewStore( + store, + observe: { $0 }, + send: Feature.Action.view // 👈 + ) { viewStore in + Button("Login") { + viewStore.send(.loginButtonTapped) + } + } + } +} +``` + +That makes it so that you can send `view` actions without wrapping the action in `.view(…)`, and +it makes it so that you can only send `view` actions. For example, the view cannot send the +`loginResponse` action: + +```swift +viewStore.send(.loginResponse(false)) +// 🛑 Type 'Feature.Action.View' has no member 'loginResponse' +``` + +This pattern is still possible with version 1.7 of the library, but requires a few small changes. +First, you must make your `View` action enum conform to the ``ViewAction`` protocol: + +```swift +@Reducer +struct Feature + // ... + enum Action: ViewAction { // 👈 + // ... + } + // ... +} +``` + +And second, you can use the ``ViewAction(for:)`` macro on your view by specifying the reducer that +powers the view. This gives you access to a `send` method in the view for sending view actions +rather than going through ``Store/send(_:)``: + +```diff ++@ViewAction(for: Feature.self) + struct FeatureView: View { + let store: StoreOf + + var body: some View { +- WithViewStore( +- store, +- observe: { $0 }, +- send: Feature.Action.view +- ) { viewStore in + Button("Login") { +- viewStore.send(.loginButtonTapped) ++ send(.loginButtonTapped) + } + } +- } + } +``` + +## Observing for UIKit + +Prior to the observation tools one would typically subscribe to changes in the store via a Combine +publisher in the entry point of a view, such as `viewDidLoad` in a `UIViewController` subclass: + +```swift +func viewDidLoad() { + super.viewDidLoad() + + store.publisher.count + .sink { [weak self] in self?.countLabel.text = "\($0)" } + .store(in: &cancellables) +} +``` + +This can now be done more simply using the ``ObjectiveC/NSObject/observe(_:)`` method defined on +all `NSObject`s: + +```swift +func viewDidLoad() { + super.viewDidLoad() + + observe { [weak self] in + guard let self + else { return } + + self.countLabel.text = "\(self.store.count)" + } +} +``` + +Be sure to read the documentation for ``ObjectiveC/NSObject/observe(_:)`` to learn how to best +wield this tool. + +## Incrementally migrating + +You are most likely going to want to incrementally your application to the new observation tools, +rather than doing everything all at once. That is possible, but there are some gotchas to be aware +of when mixing "legacy" features (_i.e._ features using ``ViewStore`` and ``WithViewStore``) with +"modern" features (_i.e._ features using ``ObservableState()``). + +The most common problem one will encounter is that when legacy and modern features are mixed +together, their view bodies can be re-computed more often than necessary. This is due to the +mixed modes of observation. Legacy features use the `objectWillChange` publisher to synchronously +invalidate the view, whereas modern features use +[`withObservationTracking`][with-obs-tracking-docs]. These are two fundamentally different tools, +and it can create a situation where views are invalidated multiple times separated by a thread hop, +making it impossible to coalesce the validations into a single one. That is what causes the body +to re-compute multiple times. + +Typically a few extra body re-computations shouldn't be a big deal, but they can put strain on +SwiftUI's ability to figure out what state changed in a view, and can cause glitchiness and +exacerbate navigation bugs. If you are noticing problems after converting one feature to use +``ObservableState()``, then we recommend trying to convert a few more features that it interacts +with to see if the problems go away. + +We have also found that modern features that contain legacy features as child features tend to +behave better than the opposite. For this reason we recommend updating your features to use +``ObservableState()`` from the outside in. That is, start with the root feature, update it to +use the new observation tools, and then work you way towards the leaf features. + +[with-obs-tracking-docs]: https://developer.apple.com/documentation/observation/withobservationtracking(_:onchange:) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Navigation.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Navigation.md index aacfaf647c2e..61585cb77e01 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Navigation.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Navigation.md @@ -19,7 +19,7 @@ use these tools. ### Tree-based navigation - -- ``PresentationState`` +- ``Presents()`` - ``PresentationAction`` - ``Reducer/ifLet(_:action:destination:fileID:line:)-4f2at`` @@ -34,3 +34,5 @@ use these tools. ### Dismissal - ``DismissEffect`` +- ``Dependencies/DependencyValues/dismiss`` +- ``Dependencies/DependencyValues/isPresented`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/ObservationBackport.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/ObservationBackport.md new file mode 100644 index 000000000000..f1c17a25cff9 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/ObservationBackport.md @@ -0,0 +1,103 @@ +# Observation backport + +Learn how the Observation framework from Swift 5.9 was backported to support iOS 16 and earlier, +as well as the caveats of using the backported tools. + +## Overview + +With version 1.7 of the Composable Architecture we have introduced support for Swift 5.9's +observation tools, _and_ we have backported those tools to work in iOS 13 and later. Using the +observation tools in pre-iOS 17 does require a few additional steps and there are some gotchas to be +aware of. + +## The Perception framework + +The Composable Architecture comes with a framework known as Perception, which is our backport of +Swift 5.9's Observation to iOS 13, macOS 12, tvOS 13 and watchOS 6. For all of the tools in the +Observation framework there is a corresponding tool in Perception. + +For example, instead of the `@Observable` macro, there is the `@Perceptible` macro: + +```swift +@Perceptible +class CounterModel { + var count = 0 +} +``` + +However, in order for a view to properly observe changes to a "perceptible" model, you must +remember to wrap the contents of your view in the `WithPerceptionTracking` view: + +```swift +struct CounterView: View { + let model = CounterModel() + + var body: some View { + WithPerceptionTracking { + Form { + Text(self.model.count.description) + Button("Decrement") { self.model.count -= 1 } + Button("Increment") { self.model.count += 1 } + } + } + } +} +``` + +This will make sure that the view subscribes to any fields accessed in the `@Perceptible` model so +that changes to those fields invalidate the view and cause it to re-render. + +If a field of a `@Percetible` model is accessed in a view while _not_ inside +`WithPerceptionTracking`, then a runtime warning will be triggered: + +> 🟣 Runtime Warning: Perceptible state was accessed but is not being tracked. Track changes to +> state by wrapping your view in a 'WithPerceptionTracking' view. + +To debug this, expand the warning in the Issue Navigator of Xcode (⌘5), and click through the stack +frames displayed to find the line in your view where you are accessing state without being inside +`WithPerceptionTracking`. + +## Gotchas + +There are a few gotchas to be aware of when using `WithPerceptionTracking`. + +### Lazy view closures + +There are many "lazy" closures in SwiftUI that evaluate only when something happens in the view, and +not necessarily in the same stack frames as the `body` of the view. For example, the trailing +closure of `ForEach` is called _after_ the `body` of the view has been computed. + +This means that even if you wrap the body of the view in `WithPerceptionTracking`: + +```swift +WithPerceptionTracking { + ForEach(store.scope(state: \.rows, action: \.rows) { store in + Text(store.title) + } +} +``` + +…the access to the row's `store.title` happens _outside_ `WithPerceptionTracking`, and hence will +not work and will trigger a runtime warning as described above. + +The fix for this is to wrap the content of the trailing closure in another `WithPerceptionTracking`: + +```swift +WithPerceptionTracking { + ForEach(store.scope(state: \.rows, action: \.rows) { store in + WithPerceptionTracking { + Text(store.title) + } + } +} +``` + +### Mixing legacy and modern features together + +Some problems can arise when mixing together features built in the "legacy" style, using +``ViewStore`` and ``WithViewStore``, and features built in the "modern" style, using the +``ObservableState()`` macro. The problems mostly manifest themselves as re-computing view bodies +more often than necessary, but that can also put strain on SwiftUI's ability to figure out what +state changed, and can cause glitches or exacerbate navigation bugs. + +See for more information about this. diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md index 615ab4f86617..5ecce134cce2 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md @@ -7,214 +7,10 @@ becoming slow to execute, SwiftUI view bodies executing more often than expected article outlines a few common pitfalls when developing features in the library, and how to fix them. -* [View stores](#View-stores) * [Sharing logic with actions](#Sharing-logic-with-actions) * [CPU-intensive calculations](#CPU-intensive-calculations) * [High-frequency actions](#High-frequency-actions) * [Store scoping](#Store-scoping) -* [Compiler performance](#Compiler-performance) - -### View stores - -A common performance pitfall when using the library comes from constructing ``ViewStore``s, which -is the object that observes changes to your feature's state. When constructed naively, using either -view store's initializer ``ViewStore/init(_:observe:)-3ak1y`` or the SwiftUI helper -``WithViewStore``, it will observe every change to state in the store: - -```swift -WithViewStore(self.store, observe: { $0 }) { viewStore in - // This is executed for every action sent into the system - // that causes self.store.state to change. -} -``` - -Most of the time this observes far too much state. A typical feature in the Composable Architecture -holds onto not only the state the view needs to present UI, but also state that the feature only -needs internally, as well as state of child features embedded in the feature. Changes to the -internal and child state should not cause the view's body to re-compute since that state is not -needed in the view. - -For example, if the root of our application was a tab view, then we could model that in state as a -struct that holds each tab's state as a property: - -```swift -@Reducer -struct AppFeature { - struct State { - var activity: Activity.State - var search: Search.State - var profile: Profile.State - } - // ... -} -``` - -If the view only needs to construct the views for each tab, then no view store is even needed -because we can pass scoped stores to each child feature view: - -```swift -struct AppView: View { - let store: StoreOf - - var body: some View { - // No need to observe state changes because the view does - // not need access to the state. - - TabView { - ActivityView( - store: self.store - .scope(state: \.activity, action: \.activity) - ) - SearchView( - store: self.store - .scope(state: \.search, action: \.search) - ) - ProfileView( - store: self.store - .scope(state: \.profile, action: \.profile) - ) - } - } -} -``` - -This means `AppView` does not actually need to observe any state changes. This view will only be -created a single time, whereas if we observed the store then it would re-compute every time a single -thing changed in either the activity, search or profile child features. - -If sometime in the future we do actually need some state from the store, we can start to observe -only the bare essentials of state necessary for the view to do its job. For example, suppose that -we need access to the currently selected tab in state: - -```swift -@Reducer -struct AppFeature { - enum Tab { case activity, search, profile } - struct State { - var activity: Activity.State - var search: Search.State - var profile: Profile.State - var selectedTab: Tab - } - // ... -} -``` - -Then we can observe this state so that we can construct a binding to `selectedTab` for the tab view: - -```swift -struct AppView: View { - let store: StoreOf - - var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - TabView( - selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) - ) { - ActivityView( - store: self.store.scope(state: \.activity, action: \.activity) - ) - .tag(AppFeature.Tab.activity) - SearchView( - store: self.store.scope(state: \.search, action: \.search) - ) - .tag(AppFeature.Tab.search) - ProfileView( - store: self.store.scope(state: \.profile, action: \.profile) - ) - .tag(AppFeature.Tab.profile) - } - } - } -} -``` - -However, this style of state observation is terribly inefficient since _every_ change to -`AppFeature.State` will cause the view to re-compute even though the only piece of state we actually -care about is the `selectedTab`. The reason we are observing too much state is because we use -`observe: { $0 }` in the construction of the ``WithViewStore``, which means the view store will -observe all of state. - -To chisel away at the observed state you can provide a closure for that argument that plucks out -the state the view needs. In this case the view only needs a single field: - -```swift -WithViewStore(self.store, observe: \.selectedTab) { viewStore in - TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { - // ... - } -} -``` - -In the future, the view may need access to more state. For example, suppose `Activity.State` holds -onto an `unreadCount` integer to represent how many new activities you have. There's no need to -observe _all_ of `Activity.State` to get access to this one field. You can observe just the one -field. - -Technically you can do this by mapping your state into a tuple, but because tuples are not -`Equatable` you will need to provide an explicit `removeDuplicates` argument: - -```swift -WithViewStore( - self.store, - observe: { (selectedTab: $0.selectedTab, unreadActivityCount: $0.activity.unreadCount) }, - removeDuplicates: == -) { viewStore in - TabView(selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) { - ActivityView( - store: self.store.scope(state: \.activity, action: \.activity) - ) - .tag(AppFeature.Tab.activity) - .badge("\(viewStore.unreadActivityCount)") - - // ... - } -} -``` - -Alternatively, and recommended, you can introduce a lightweight, equatable `ViewState` struct -nested inside your view whose purpose is to transform the `Store`'s full state into the bare -essentials of what the view needs: - -```swift -struct AppView: View { - let store: StoreOf - - struct ViewState: Equatable { - let selectedTab: AppFeature.Tab - let unreadActivityCount: Int - init(state: AppFeature.State) { - self.selectedTab = state.selectedTab - self.unreadActivityCount = state.activity.unreadCount - } - } - - var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - TabView { - ActivityView( - store: self.store - .scope(state: \.activity, action: \.activity) - ) - .badge("\(viewStore.unreadActivityCount)") - - // ... - } - } - } -} -``` - -This gives you maximum flexibility in the future for adding new fields to `ViewState` without making -your view convoluted. - -This technique for reducing view re-computations is most effective towards the root of your app -hierarchy and least effective towards the leaf nodes of your app. Root features tend to hold lots -of state that its view does not need, such as child features, and leaf features tend to only hold -what's necessary. If you are going to employ this technique you will get the most benefit by -applying it to views closer to the root. At leaf features and views that need access to most -of the state, it is fine to continue using `observe: { $0 }` to observe all of the state in the -store. ### Sharing logic with actions @@ -236,6 +32,7 @@ each user action can return an effect that immediately emits that shared action: ```swift @Reducer struct Feature { + @ObservableState struct State { /* ... */ } enum Action { /* ... */ } @@ -316,6 +113,7 @@ The above example can be refactored like so: ```swift @Reducer struct Feature { + @ObservableState struct State { /* ... */ } enum Action { /* ... */ } @@ -613,54 +411,3 @@ performance. If you are using a computed property in a scope, then reconsider if be done along a plain, stored property and moving the computed logic into the child view. The further you push the computation towards the leaf nodes of your application, the less performance problems you will see. - -### Compiler performance - -In very large SwiftUI applications you may experience degraded compiler performance causing long -compile times, and possibly even compiler failures due to "complex expressions." The -``WithViewStore`` helpers that come with the library can exacerbate that problem for very complex -views. If you are running into issues using ``WithViewStore``, there are two options for fixing -the problem. - -For example, if your view looks like this: - -```swift -struct FeatureView: View { - let store: StoreOf - - var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - // A large, complex view inside here... - } - } -} -``` - -…and you start running into compiler troubles, then you can explicitly specify the type of the -view store in the closure: - -```swift -WithViewStore(self.store, observe: { $0 }) { (viewStore: ViewStoreOf) in - // A large, complex view inside here... -} -``` - -Or you can refactor the view to use an `@ObservedObject`: - -```swift -struct FeatureView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } - - var body: some View { - // A large, complex view inside here... - } -} -``` - -Both of these options should greatly improve the compiler's ability to type-check your view. diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/StackBasedNavigation.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/StackBasedNavigation.md index fe3b4a8e3f93..3cc822d5e062 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/StackBasedNavigation.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/StackBasedNavigation.md @@ -20,14 +20,15 @@ It also allows for complex and recursive navigation paths in your application. The tools for this style of navigation include ``StackState``, ``StackAction`` and the ``Reducer/forEach(_:action:destination:fileID:line:)-yz3v`` operator, as well as a new -``NavigationStackStore`` view that behaves like `NavigationStack` but is tuned specifically for the -Composable Architecture. +initializer ``SwiftUI/NavigationStack/init(path:root:destination:)`` on +`NavigationStack` that behaves like the normal initializer, but is tuned specifically for +the Composable Architecture. The process of integrating features into a navigation stack largely consists of 2 steps: -integrating the features' domains together, and constructing a ``NavigationStackStore`` for -describing all the views in the stack. One typically starts by integrating the features' domains -together. This consists of defining a new reducer, typically called `Path`, that holds the domains -of all the features that can be pushed onto the stack: +integrating the features' domains together, and constructing a `NavigationStack` for a +store describing all the views in the stack. One typically starts by integrating the features' +domains together. This consists of defining a new reducer, typically called `Path`, that holds the +domains of all the features that can be pushed onto the stack: ```swift @Reducer @@ -36,6 +37,7 @@ struct RootFeature { @Reducer struct Path { + @ObservableState enum State { case addItem(AddFeature.State) case detailItem(DetailFeature.State) @@ -70,6 +72,7 @@ feature that manages the navigation stack: ```swift @Reducer struct RootFeature { + @ObservableState struct State { var path = StackState() // ... @@ -106,34 +109,33 @@ struct RootFeature { That completes the steps to integrate the child and parent features together for a navigation stack. -Next we must integrate the child and parent views together. This is done by constructing a special -version of SwiftUI's `NavigationStack` view that comes with this library, called -``NavigationStackStore``. This view takes 3 arguments: a store focused in on ``StackState`` -and ``StackAction`` in your domain, a trailing view builder for the root view of the stack, and -another trailing view builder for all of the views that can be pushed onto the stack: +Next we must integrate the child and parent views together. This is done by a +`NavigationStack` using a special initializer that comes with this library, called +``SwiftUI/NavigationStack/init(path:root:destination:)``. This initializer takes 3 arguments: a +binding of a store focused in on ``StackState`` and ``StackAction`` in your domain, a trailing view +builder for the root view of the stack, and another trailing view builder for all of the views that +can be pushed onto the stack: ```swift -NavigationStackStore( - // Store focused on StackState and StackAction +NavigationStack( + path: // Store focused on StackState and StackAction ) { // Root view of the navigation stack -} destination: { state in - switch state { - // A view for each case of the Path.State enum - } +} destination: { store in + // A view for each case of the Path.State enum } ``` -To fill in the first argument you only need to scope your store to the `path` state and `path` -action you already hold in the root feature: +To fill in the first argument you only need to scope a binding of your store to the `path` state and +`path` action you already hold in the root feature: ```swift struct RootView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - NavigationStackStore( - self.store.scope(state: \.path, action: \.path) + NavigationStack( + path: $store.scope(state: \.path, action: \.path) ) { // Root view of the navigation stack } destination: { state in @@ -146,12 +148,11 @@ struct RootView: View { The root view can be anything you want, and would typically have some `NavigationLink`s or other buttons that push new data onto the ``StackState`` held in your domain. -And the last trailing closure is provided a single piece of the `Path.State` enum so that you can -switch on it: +And the last trailing closure is provided a store of `Path` domain so that you can switch on it: ```swift -} destination: { state in - switch state { +} destination: { store in + switch store.state { case .addItem: case .detailItem: case .editItem: @@ -163,30 +164,23 @@ This will give you compile-time guarantees that you have handled each case of th which can be nice for when you add new types of destinations to the stack. In each of these cases you can return any kind of view that you want, but ultimately you want to -make use of the library's ``CaseLet`` view in order to scope down to a specific case of the -`Path.State` enum: +scope the store down to a specific case of the `Path.State` enum: ```swift -} destination: { state in - switch state { +} destination: { store in + switch store.state { case .addItem: - CaseLet( - /RootFeature.Path.State.addItem, - action: RootFeature.Path.Action.addItem, - then: AddView.init(store:) - ) + if let store = store.scope(state: \.addItem, action: \.addItem) { + AddView(store: store) + } case .detailItem: - CaseLet( - /RootFeature.Path.State.detailItem, - action: RootFeature.Path.Action.detailItem, - then: DetailView.init(store:) - ) + if let store = store.scope(state: \.detailItem, action: \.detailItem) { + DetailView(store: store) + } case .editItem: - CaseLet( - /RootFeature.Path.State.editItem, - action: RootFeature.Path.Action.editItem, - then: EditView.init(store:) - ) + if let store = store.scope(state: \.editItem, action: \.editItem) { + EditView(store: store) + } } } ``` @@ -273,6 +267,7 @@ dependency management system (see ) using ``DismissEff ```swift @Reducer struct Feature { + @ObservableState struct State { /* ... */ } enum Action { case closeButtonTapped @@ -331,6 +326,7 @@ count is greater than or equal to 5: ```swift @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 } @@ -364,6 +360,7 @@ And then let's embed that feature into a parent feature: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { var path = StackState() } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftConcurrency.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftConcurrency.md index 88cf92e13d34..c2be575e3976 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftConcurrency.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftConcurrency.md @@ -25,6 +25,7 @@ from within `@Sendable` closures: ```swift @Reducer struct Feature { + @ObservableState struct State { /* ... */ } enum Action { /* ... */ } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md index b894f31a5cfb..8d4220ab9810 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md @@ -22,6 +22,7 @@ then assert on how it changed after, like this: ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { var count = 0 } @@ -199,6 +200,7 @@ an asynchronous context to operate in and can send multiple actions back into th ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { var count = 0 } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md index 4ed0ddf24783..dfa27d2419cb 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/TreeBasedNavigation.md @@ -36,8 +36,9 @@ form for adding a new item. We can integrate state and actions together by utili ```swift @Reducer struct InventoryFeature { + @ObservableState struct State: Equatable { - @PresentationState var addItem: ItemFormFeature.State? + @Presents var addItem: ItemFormFeature.State? var items: IdentifiedArrayOf = [] // ... } @@ -61,6 +62,7 @@ action in the parent domain for populating the child's state to drive navigation ```swift @Reducer struct InventoryFeature { + @ObservableState struct State: Equatable { /* ... */ } enum Action { /* ... */ } @@ -88,23 +90,23 @@ struct InventoryFeature { > tuned for enums, and uses the forward slash syntax. That's all that it takes to integrate the domains and logic of the parent and child features. Next -we need to integrate the features' views. This is done using view modifiers that look similar to -SwiftUI's, but are tuned specifically to work with the Composable Architecture. +we need to integrate the features' views. This is done by passing a binding of a store to one +of SwiftUI's view modifiers. -For example, to show a sheet from the `addItem` state in the `InventoryFeature`, we can use -the `sheet(store:)` modifier that takes a ``Store`` as an argument that is focused on presentation +For example, to show a sheet from the `addItem` state in the `InventoryFeature`, we can hand +the `sheet(item:)` modifier a binding of a ``Store`` as an argument that is focused on presentation state and actions: ```swift struct InventoryView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { List { // ... } .sheet( - store: self.store.scope(state: \.$addItem, action: \.addItem) + item: $store.scope(state: \.addItem, action: \.addItem) ) { store in ItemFormView(store: store) } @@ -112,26 +114,17 @@ struct InventoryView: View { } ``` -> Note: We again must specify a key path to the `@PresentationState` projected value, _i.e._ -`\.$addItem`. +> Note: We use SwiftUI's `@Bindable` property wrapper to produce a binding to a store, which can be +> further scoped using ``SwiftUI/Binding/scope(state:action:)-4mj4d``. With those few steps completed the domains and views of the parent and child features are now integrated together, and when the `addItem` state flips to a non-`nil` value the sheet will be presented, and when it is `nil`'d out it will be dismissed. -In this example we are using the `.sheet` view modifier, but the library ships with overloads for -all of SwiftUI's navigation APIs that take stores of presentation domain, including: - - * `alert(store:)` - * `confirmationDialog(store:)` - * `sheet(store:)` - * `popover(store:)` - * `fullScreenCover(store:)` - * `navigationDestination(store:)` - * ``NavigationLinkStore`` - -This should make it possible to use optional state to drive any kind of navigation in a SwiftUI -application. +In this example we are using the `.sheet` view modifier, but every view modifier SwiftUI ships can +be handed a store in this fashion, including `popover(item:)`, `fullScreenCover(item:), +`navigationDestination(item:)`, and more. This should make it possible to use optional state to +drive any kind of navigation in a SwiftUI application. ## Enum state @@ -140,10 +133,11 @@ modeled domains. In particular, if a feature can navigate to multiple screens th tempted to model that with multiple optional values: ```swift +@ObservableState struct State { - @PresentationState var detailItem: DetailFeature.State? - @PresentationState var editItem: EditFeature.State? - @PresentationState var addItem: AddFeature.State? + @Presents var detailItem: DetailFeature.State? + @Presents var editItem: EditFeature.State? + @Presents var addItem: AddFeature.State? // ... } ``` @@ -188,6 +182,7 @@ struct InventoryFeature { @Reducer struct Destination { + @ObservableState enum State { case addItem(AddFeature.State) case detailItem(DetailFeature.State) @@ -225,8 +220,9 @@ With that done we can now hold onto a _single_ piece of optional state in our fe ```swift @Reducer struct InventoryFeature { + @ObservableState struct State { - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? // ... } enum Action { @@ -276,41 +272,51 @@ domain and further isolate a particular case of the state and action enums via d For example, suppose the "add" screen is presented as a sheet, the "edit" screen is presented by a popover, and the "detail" screen is presented in a drill-down. Then we can use the -`.sheet(store:)`, `.popover(store:)`, and `.navigationDestination(store:)` view modifiers to have -each of those styles of presentation powered by the respective case of the destination enum: +`.sheet(item:)`, `.popover(item:)`, and `.navigationDestination(item:)` view modifiers that come +from SwiftUI to have each of those styles of presentation powered by the respective case of the +destination enum. + +To do this you must first hold onto the store in a bindable manner by using the `@Bindable` property +wrapper: ```swift struct InventoryView: View { - let store: StoreOf + @Bindable var store: StoreOf + // ... +} +``` - var body: some View { - List { - // ... - } - .sheet( - store: self.store.scope( - state: \.$destination.addItem, - action: \.destination.addItem - ) - ) { store in - AddFeatureView(store: store) - } - .popover( - store: self.store.scope( - state: \.$destination.editItem, - action: \.destination.editItem - ) - ) { store in - EditFeatureView(store: store) - } - .navigationDestination( - store: self.store.scope( - state: \.$destination.detailItem, - action: \.destination.detailItem - ) - ) { store in - DetailFeatureView(store: store) - } +And then in the `body` of the view you can use the ``SwiftUI/Binding/scope(state:action:)-4mj4d`` +operator to derive bindings from `$store`: + +```swift +var body: some View { + List { + // ... + } + .sheet( + item: $store.scope( + state: \.destination?.addItem, + action: \.destination.addItem + ) + ) { store in + AddFeatureView(store: store) + } + .popover( + item: $store.scope( + state: \.destination?.editItem, + action: \.destination.editItem + ) + ) { store in + EditFeatureView(store: store) + } + .navigationDestination( + item: $store.scope( + state: \.destination?.detailItem, + action: \.destination.detailItem + ) + ) { store in + DetailFeatureView(store: store) } } ``` @@ -320,13 +326,13 @@ If the "add" item sheet was presented, and you decided to mutate the `destinatio to the `.detailItem` case, then you can be certain that the sheet will be dismissed and the drill-down will occur immediately. -#### API Unification +### API Unification One of the best features of tree-based navigation is that it unifies all forms of navigation with a single style of API. First of all, regardless of the type of navigation you plan on performing, integrating the parent and child features together can be done with the single -``Reducer/ifLet(_:action:destination:fileID:line:)-4f2at`` operator. This one single API services all -forms of optional-driven navigation. +``Reducer/ifLet(_:action:destination:fileID:line:)-4f2at`` operator. This one single API services +all forms of optional-driven navigation. And then in the view, whether you are wanting to perform a drill-down, show a sheet, display an alert, or even show a custom navigation component, all you need to do is invoke an API that @@ -340,25 +346,25 @@ forms of navigation could be as simple as this: ```swift .sheet( - store: self.store.scope(state: \.addItem, action: \.addItem) + item: $store.scope(state: \.addItem, action: \.addItem) ) { store in AddFeatureView(store: store) } .popover( - store: self.store.scope(state: \.editItem, action: \.editItem) + item: $store.scope(state: \.editItem, action: \.editItem) ) { store in EditFeatureView(store: store) } .navigationDestination( - store: self.store.scope(state: \.detailItem, action: \.detailItem) + item: $store.scope(state: \.detailItem, action: \.detailItem) ) { store in DetailFeatureView(store: store) } .alert( - store: self.store.scope(state: \.alert, action: \.alert) + $store.scope(state: \.alert, action: \.alert) ) .confirmationDialog( - store: self.store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) ``` @@ -366,6 +372,79 @@ In each case we provide a store scoped to the presentation domain, and a view th when its corresponding state flips to non-`nil`. It is incredibly powerful to see that so many seemingly disparate forms of navigation can be unified under a single style of API. +#### Backwards compatible availability + +Depending on your deployment target, certain APIs may be unavailable. For example, if you target +iOS 16, you will not have access to iOS 17's `navigationDestination(item:)` view modifier. You can +easily backport the tool to work on older platforms by defining a wrapper for the API that calls +down to the available `navigationDestination(isPresented:)` API. Just paste the following into your +project: + +```swift +extension View { + @available(iOS, introduced: 13, deprecated: 16) + @available(macOS, introduced: 10.15, deprecated: 13) + @available(tvOS, introduced: 13, deprecated: 16) + @available(watchOS, introduced: 6, deprecated: 9) + @ViewBuilder + func navigationDestinationWrapper( + item: Binding, + @ViewBuilder destination: @escaping (D) -> C + ) -> some View { + if #available(iOS 17, macOS 14, tvOS 17, visionOS 1, watchOS 10, *) { + navigationDestination(item: item, destination: destination) + } else { + navigationDestination( + isPresented: Binding( + get: { item.wrappedValue != nil }, + set: { isPresented, transaction in + if !isPresented { + item.transaction(transaction).wrappedValue = nil + } + } + ) + ) { + if let item = item.wrappedValue { + destination(item) + } + } + } + } +} +``` + +If you target platforms earlier than iOS 16, macOS 13, tvOS 16 and watchOS 9, then you cannot use +`navigationDestination` at all. Instead you can use `NavigationLink`, but you must define another +helper for driving navigation off of a binding of data rather than just a simple boolean. Just paste +the following into your project: + +```swift +@available(iOS, introduced: 13, deprecated: 16) +@available(macOS, introduced: 10.15, deprecated: 13) +@available(tvOS, introduced: 13, deprecated: 16) +@available(watchOS, introduced: 6, deprecated: 9) +extension NavigationLink { + public init( + item: Binding, + @ViewBuilder destination: (D) -> C, + @ViewBuilder label: () -> Label + ) where Destination == C? { + self.init( + destination: item.wrappedValue.map(destination), + isActive: Binding( + get: { item.wrappedValue != nil }, + set: { isActive, transaction in + if !isActive { + item.transaction(transaction).wrappedValue = nil + } + } + ), + label: label + ) + } +} +``` + ## Integration Once your features are integrated together using the steps above, your parent feature gets instant @@ -435,6 +514,7 @@ dependency management system (see ) using ``DismissEff ```swift @Reducer struct Feature { + @ObservableState struct State { /* ... */ } enum Action { case closeButtonTapped @@ -491,6 +571,7 @@ count is greater than or equal to 5: ```swift @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 } @@ -525,8 +606,9 @@ And then let's embed that feature into a parent feature using ``PresentationStat ```swift @Reducer struct Feature { + @ObservableState struct State: Equatable { - @PresentationState var counter: CounterFeature.State? + @Presents var counter: CounterFeature.State? } enum Action { case counter(PresentationAction) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/WhatIsNavigation.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/WhatIsNavigation.md index 307038ce9de7..5f8bbfabcd1f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/WhatIsNavigation.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/WhatIsNavigation.md @@ -23,9 +23,9 @@ their strengths and weaknesses. The word "navigation" can mean a lot of different things to different people. For example, most people would say that an example of "navigation" is the drill-down style of navigation afforded to -us by `NavigationStack` in SwiftUI and `UINavigationController` in UIKit "navigation". However, -if drill-downs are considered navigation, then surely sheets and fullscreen covers should be too. -The only difference is that sheets and covers animate from bottom-to-top instead of from +us by `NavigationStack` in SwiftUI and `UINavigationController` in UIKit "navigation". +However, if drill-downs are considered navigation, then surely sheets and fullscreen covers should +be too. The only difference is that sheets and covers animate from bottom-to-top instead of from right-to-left, but is that actually substantive? And if sheets and covers are considered navigation, then certainly popovers should be too. We can @@ -64,13 +64,14 @@ of navigation are nested they form a tree-like structure. For example, suppose you have an inventory feature with a list of items such that tapping one of those items performs a drill-down navigation to a detail screen for the item. Then that can be -modeled with the ``PresentationState`` property wrapper pointing to some optional state: +modeled with the ``Presents()`` macro pointing to some optional state: ```swift @Reducer struct InventoryFeature { + @ObservableState struct State { - @PresentationState var detailItem: DetailItemFeature.State? + @Presents var detailItem: DetailItemFeature.State? // ... } // ... @@ -78,13 +79,14 @@ struct InventoryFeature { ``` Then, inside that detail screen there may be a button to edit the item in a sheet, and that too can -be modeled with a ``PresentationState`` property wrapper pointing to a piece of optional state: +be modeled with the ``Presents()`` macro pointing to a piece of optional state: ```swift @Reducer struct DetailItemFeature { + @ObservableState struct State { - @PresentationState var editItem: EditItemFeature.State? + @Presents var editItem: EditItemFeature.State? // ... } // ... @@ -98,7 +100,7 @@ whether or not an alert is displayed: @Reducer struct EditItemFeature { struct State { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? // ... } // ... @@ -146,10 +148,10 @@ of modeling the presentation of a child feature with optional state. This takes structure in which a deeply nested feature is represented by a deeply nested piece of state. There is another powerful tool for modeling the existence and non-existence of state for driving -navigation: collections. This is most used with SwiftUI's `NavigationStack` view in which an entire -stack of features are represented by a collection of data. When an item is added to the collection -it represents a new feature being pushed onto the stack, and when an item is removed from the -collection it represents popping the feature off the stack. +navigation: collections. This is most used with SwiftUI's `NavigationStack` view in which +an entire stack of features are represented by a collection of data. When an item is added to the +collection it represents a new feature being pushed onto the stack, and when an item is removed from +the collection it represents popping the feature off the stack. Typically one defines an enum that holds all of the possible features that can be navigated to on the stack, so continuing the analogy from the previous section, if an inventory list can navigate to @@ -184,10 +186,10 @@ Composable Architecture to implement stack-based navigation in your application. ## Tree-based vs stack-based navigation Most real-world applications will use a mixture of tree-based and stack-based navigation. For -example, the root of your application may use stack-based navigation with a `NavigationStack` view, -but then each feature inside the stack may use tree-based navigation for showing sheets, popovers, -alerts, etc. But, there are pros and cons to each form of navigation, and so it can be important to -be aware of their differences when modeling your domains. +example, the root of your application may use stack-based navigation with a +`NavigationStack` view, but then each feature inside the stack may use tree-based +navigation for showing sheets, popovers, alerts, etc. But, there are pros and cons to each form of +navigation, and so it can be important to be aware of their differences when modeling your domains. #### Pros of tree-based navigation @@ -198,8 +200,9 @@ be aware of their differences when modeling your domains. feature needs only to hold onto a piece of optional edit state: ```swift + @ObservableState struct State { - @PresentationState var editItem: EditItemFeature.State? + @Presents var editItem: EditItemFeature.State? // ... } ``` @@ -268,9 +271,10 @@ recursion in this navigation since it is just a flat array. stack. This means the features can be put into their own modules with no dependencies on each other, and can be compiled without compiling any other features. -* The `NavigationStack` API in SwiftUI typically has fewer bugs than `NavigationLink(isActive:)` and -`navigationDestination(isPresented:)`, which are used in tree-based navigation. There are still a -few bugs in `NavigationStack`, but on average it is a lot more stable. +* The `NavigationStack` API in SwiftUI typically has fewer bugs than +`NavigationLink(isActive:)` and `navigationDestination(isPresented:)`, which are used in tree-based +navigation. There are still a few bugs in `NavigationStack`, but on average it is a lot +more stable. #### Cons of stack-based navigation @@ -314,9 +318,9 @@ few bugs in `NavigationStack`, but on average it is a lot more stable. edit feature interact with each other. The only way to write that test is to compile and run the entire application. - * And finally, stack-based navigation and `NavigationStack` only applies to drill-downs and does - not address at all other forms of navigation, such as sheets, popovers, alerts, etc. It's still - on you to do the work to decouple those kinds of navigations. + * And finally, stack-based navigation and `NavigationStack` only applies to drill-downs + and does not address at all other forms of navigation, such as sheets, popovers, alerts, etc. + It's still on you to do the work to decouple those kinds of navigations. --- diff --git a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md index 94a81823fd3a..e44bbfb312e0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md +++ b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md @@ -47,19 +47,21 @@ day-to-day when building applications, such as: ### Essentials -- - - - - - +### Tutorials + +- + ### State management - ``Reducer()`` - ``Effect`` - ``Store`` -- ``ViewStore`` ### Testing @@ -69,10 +71,12 @@ day-to-day when building applications, such as: - - +- - ### Migration guides +- - - - diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Action.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Action.md new file mode 100644 index 000000000000..dc017517568d --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Action.md @@ -0,0 +1,9 @@ +# ``ComposableArchitecture/Reducer/Action`` + +## Topics + +### View actions + +- ``ViewAction`` +- ``ViewAction(for:)`` +- ``ViewActionSending`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md index 746b92e58260..e5bf3197824c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md @@ -8,15 +8,38 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement ## Topics -### NavigationLinkStore +### View stores +- ``ViewStore`` + +### View containers + +- ``WithViewStore`` +- ``IfLetStore`` +- ``ForEachStore`` +- ``SwitchStore`` - ``NavigationLinkStore`` +- ``NavigationStackStore`` ### View modifiers +- ``SwiftUI/View/alert(store:)`` - ``SwiftUI/View/alert(store:state:action:)`` +- ``SwiftUI/View/confirmationDialog(store:)`` - ``SwiftUI/View/confirmationDialog(store:state:action:)`` +- ``SwiftUI/View/fullScreenCover(store:onDismiss:content:)`` - ``SwiftUI/View/fullScreenCover(store:state:action:onDismiss:content:)`` +- ``SwiftUI/View/legacyAlert(store:)`` +- ``SwiftUI/View/legacyAlert(store:state:action:)`` +- ``SwiftUI/View/navigationDestination(store:destination:)`` - ``SwiftUI/View/navigationDestination(store:state:action:destination:)`` +- ``SwiftUI/View/popover(store:attachmentAnchor:arrowEdge:content:)`` - ``SwiftUI/View/popover(store:state:action:attachmentAnchor:arrowEdge:content:)`` +- ``SwiftUI/View/sheet(store:onDismiss:content:)`` - ``SwiftUI/View/sheet(store:state:action:onDismiss:content:)`` + +### Bindings + +- ``BindingState`` +- ``BindingViewState`` +- ``BindingViewStore`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md index 94a94ad177f4..25cfbe316e8e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md @@ -19,9 +19,15 @@ instead. - ``TestStore/receive(_:timeout:assert:file:line:)-5vi0x`` - ``TestStore/receive(_:timeout:assert:file:line:)-7hcfs`` - ``TestStore/receive(_:timeout:assert:file:line:)-8r59i`` +- ``TestStore/receive(_:_:timeout:assert:file:line:)-8g1si`` ### Case path deprecations - ``TestStore/receive(_:timeout:assert:file:line:)-7608x`` - ``TestStore/receive(_:timeout:assert:file:line:)-42avx`` - ``TestStore/bindings(action:)-7aomj`` + +### Bindings + +- ``TestStore/bindings`` +- ``TestStore/bindings(action:)-2nhb5`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md index 43572651be8e..dc5804ea05c2 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md @@ -15,6 +15,7 @@ - ``cancellable(id:cancelInFlight:)`` - ``cancel(id:)`` - ``withTaskCancellation(id:cancelInFlight:operation:)`` +- ``_Concurrency/Task/cancel(id:)`` ### Composition diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/NavigationLinkState.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/NavigationLinkState.md new file mode 100644 index 000000000000..a80976760f95 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/NavigationLinkState.md @@ -0,0 +1,8 @@ +# ``SwiftUI/NavigationLink/init(state:label:fileID:line:)`` + +## Topics + +### Overloads + +- ``SwiftUI/NavigationLink/init(_:state:fileID:line:)-1fmz8`` +- ``SwiftUI/NavigationLink/init(_:state:fileID:line:)-3xjq3`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/ObservableState.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/ObservableState.md new file mode 100644 index 000000000000..cf311275baff --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/ObservableState.md @@ -0,0 +1,17 @@ +# ``ComposableArchitecture/ObservableState()`` + +## Topics + +### Conformance + +- ``ObservableState`` + +### Change tracking + +- ``ObservableStateID`` +- ``ObservationStateRegistrar`` + +### Supporting macros + +- ``ObservationStateTracked()`` +- ``ObservationStateIgnored()`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Presents.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Presents.md new file mode 100644 index 000000000000..5379f98d4e11 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Presents.md @@ -0,0 +1,7 @@ +# ``ComposableArchitecture/Presents()`` + +## Topics + +### Property wrapper + +- ``PresentationState`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/State.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/State.md new file mode 100644 index 000000000000..d611bdda5e5f --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/State.md @@ -0,0 +1,7 @@ +# ``ComposableArchitecture/Reducer/State`` + +## Topics + +### Observing state + +- ``ObservableState()`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md index f1f59997b007..b9c1f1efd90d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md @@ -7,12 +7,10 @@ - ``init(initialState:reducer:withDependencies:)`` - ``StoreOf`` -### Scoping stores - -- ``scope(state:action:)-90255`` - ### Accessing state +- ``state-1qxwl`` +- ``subscript(dynamicMember:)-655ef`` - ``withState(_:)`` ### Sending actions @@ -22,6 +20,17 @@ - ``send(_:transaction:)`` - ``StoreTask`` +### Scoping stores + +- ``scope(state:action:)-90255`` +- ``scope(state:action:)-36e72`` +- ``scope(state:action:)-1nelp`` + +### Scoping store bindings + +- ``SwiftUI/Binding/scope(state:action:)-4mj4d`` +- ``SwiftUI/Binding/scope(state:action:)-35r82`` + ### Combine integration - ``StorePublisher`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreDynamicMemberLookup.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreDynamicMemberLookup.md new file mode 100644 index 000000000000..2bba01620926 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreDynamicMemberLookup.md @@ -0,0 +1,8 @@ +# ``ComposableArchitecture/Store/subscript(dynamicMember:)-655ef`` + +## Topics + +### Writable, bindable state + +- ``Store/subscript(dynamicMember:)-6ilk2`` +- ``Store/subscript(dynamicMember:)-85nex`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreState.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreState.md new file mode 100644 index 000000000000..d480edf8c9d2 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreState.md @@ -0,0 +1,8 @@ +# ``ComposableArchitecture/Store/state-1qxwl`` + +## Topics + +### Writable, bindable state + +- ``Store/state-3ppqv`` +- ``Store/state-7k27v`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md index 73c92f2b66fd..bd60d7779f1b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/SwiftUIIntegration.md @@ -9,33 +9,27 @@ designed with SwiftUI in mind, and comes with many powerful tools to integrate i ## Topics -### View containers +### Alerts and dialogs -- ``WithViewStore`` -- ``IfLetStore`` -- ``ForEachStore`` -- ``SwitchStore`` -- ``NavigationStackStore`` +- ``SwiftUI/View/alert(_:)`` +- ``SwiftUI/View/confirmationDialog(_:)`` + +### Presentation + +- ``SwiftUI/Binding/scope(state:action:)-4mj4d`` + +### Navigation stacks and links + +- ``SwiftUI/Binding/scope(state:action:)-35r82`` +- ``SwiftUI/NavigationStack/init(path:root:destination:)`` +- ``SwiftUI/NavigationLink/init(state:label:fileID:line:)`` ### Bindings - -- ``ViewStore/binding(get:send:)-65xes`` -- ``BindingState`` - ``BindableAction`` - ``BindingAction`` - ``BindingReducer`` -- ``BindingViewState`` -- ``BindingViewStore`` - -### View Modifiers - -- ``SwiftUI/View/alert(store:)`` -- ``SwiftUI/View/confirmationDialog(store:)`` -- ``SwiftUI/View/fullScreenCover(store:onDismiss:content:)`` -- ``SwiftUI/View/navigationDestination(store:destination:)`` -- ``SwiftUI/View/popover(store:attachmentAnchor:arrowEdge:content:)`` -- ``SwiftUI/View/sheet(store:onDismiss:content:)`` ### Deprecations diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TaskResult.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TaskResult.md index 809a12eb866e..4548d5b8a941 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TaskResult.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TaskResult.md @@ -20,3 +20,4 @@ - ``map(_:)`` - ``flatMap(_:)`` - ``init(_:)`` +- ``Swift/Result/init(_:)`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md index dab7d8fe7f5d..b0f521115c59 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md @@ -5,6 +5,7 @@ ### Creating a test store - ``init(initialState:reducer:withDependencies:file:line:)-3zio1`` +- ``TestStoreOf`` ### Configuring a test store @@ -36,8 +37,6 @@ While the most common way of interacting with a test store's state is via its also access it directly throughout a test. - ``state`` -- ``bindings`` -- ``bindings(action:)-2nhb5`` ### Supporting types diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md index 3d2566743985..d6b1c7f3431b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md @@ -8,11 +8,16 @@ While the Composable Architecture was designed with SwiftUI in mind, it comes wi ## Topics -### Scoping stores +### Subscribing to state changes -- ``Store/ifLet(then:else:)`` +- ``ObjectiveC/NSObject/observe(_:)`` -### Subscribing to state changes +### Presenting alerts and action sheets +- ``UIKit/UIAlertController/init(store:)`` + +### Combine integration + +- ``Store/ifLet(then:else:)`` - ``Store/publisher`` - ``ViewStore/publisher`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0003.swift index efbd26680f4e..08277aa62778 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0003.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State { } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0004.swift index 3473ea4a42cb..36c27456d2c0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0004.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State { var count = 0 } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0005.swift index c04de342bc9b..febd9f68366c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0005.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State { var count = 0 } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0006.swift index 057db93ed196..53f94ec198c6 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-01-code-0006.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State { var count = 0 } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0004-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0004-previous.swift deleted file mode 100644 index 877e9f5e3fb5..000000000000 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0004-previous.swift +++ /dev/null @@ -1,28 +0,0 @@ -struct CounterView: View { - let store: StoreOf - - var body: some View { - VStack { - Text("0") - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - HStack { - Button("-") { - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - - Button("+") { - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - } - } - } -} diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0004.swift index 993b9f56b6a6..61dd932fd75b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0004.swift @@ -1,33 +1,29 @@ -extension CounterFeature.State: Equatable {} - struct CounterView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - Text("\(viewStore.count)") - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - HStack { - Button("-") { - viewStore.send(.decrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - - Button("+") { - viewStore.send(.incrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) + VStack { + Text("\(store.count)") + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + HStack { + Button("-") { + store.send(.decrementButtonTapped) } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + + Button("+") { + store.send(.incrementButtonTapped) + } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0005.swift index e20096d0d327..ca61c2e61871 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0005.swift @@ -1,9 +1,7 @@ -struct CounterPreview: PreviewProvider { - static var previews: some View { - CounterView( - store: Store(initialState: CounterFeature.State()) { - CounterFeature() - } - ) - } +#Preview { + CounterView( + store: Store(initialState: CounterFeature.State()) { + CounterFeature() + } + ) } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0006.swift index 82e6cd3041c0..3118477ea173 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0006.swift @@ -1,9 +1,7 @@ -struct CounterPreview: PreviewProvider { - static var previews: some View { - CounterView( - store: Store(initialState: CounterFeature.State()) { - // CounterFeature() - } - ) - } +#Preview { + CounterView( + store: Store(initialState: CounterFeature.State()) { + // CounterFeature() + } + ) } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0007.swift index e20096d0d327..ca61c2e61871 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-02-code-0007.swift @@ -1,9 +1,7 @@ -struct CounterPreview: PreviewProvider { - static var previews: some View { - CounterView( - store: Store(initialState: CounterFeature.State()) { - CounterFeature() - } - ) - } +#Preview { + CounterView( + store: Store(initialState: CounterFeature.State()) { + CounterFeature() + } + ) } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-YourFirstFeature.tutorial b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-YourFirstFeature.tutorial index c789e237a38e..6a12ffb11a83 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-YourFirstFeature.tutorial +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/01-YourFirstFeature/01-01-YourFirstFeature.tutorial @@ -46,9 +46,13 @@ to do its job, typically a struct. Then you will create an `Action` type that holds all the actions the user can perform in the feature, typically an enum. + Further, if your feature is to be observed by SwiftUI, which is usually the case, you must + annotate its state with the ``ComposableArchitecture/ObservableState()`` macro. It is the + Composable Architecture's version of `@Observable`, but tuned to value types. + @Code(name: "CounterFeature.swift", file: 01-01-01-code-0003.swift) } - + @Step { For the purpose of a simple counter feature, the state consists of just a single integer, the current count, and the actions consist of tapping buttons to either increment or @@ -109,7 +113,7 @@ It is our personal preference to keep the reducer and view in the same file until it is untenable, but others prefer to split the types into their own files. For this tutorial we will continue putting everything in CounterFeature.swift, and we will now import SwiftUI - and get a basic view into place: + and get a basic view into place. @Code(name: "CounterFeature.swift", file: 01-01-02-code-0001.swift) } @@ -120,7 +124,8 @@ of your feature. That is, it is the object that can process actions in order to update state, and it can execute effects and feed data from those effects back into the system. - > Tip: The store can be held onto as a `let`. It does not need to be observed by the view. + > Tip: The store can be held onto as a `let`. Observation of the data in the store happens + > automatically with the ``ComposableArchitecture/ObservableState()`` macro. @Code(name: "CounterFeature.swift", file: 01-01-02-code-0002.swift) } @@ -128,11 +133,7 @@ @Step { Next, we can implement some basic view hierarchy for displaying the count and providing buttons for incrementing and decrementing. - - > Note: You cannot read state from a ``ComposableArchitecture/Store`` directly, nor can you - send actions to it directly. So, for now we will provide stubs for that behavior, but once - a ``ComposableArchitecture/ViewStore`` is added we can provide the real implementations. - + @Code(name: "CounterFeature.swift", file: 01-01-02-code-0003.swift) { @Image( source: "01-02-image-0003.png", @@ -141,22 +142,14 @@ } } - With some basic view scaffolding in place we can now start actually observing state in the - `store`. This is done by constructing a ``ComposableArchitecture/ViewStore``, and for - SwiftUI views there is a convenience view called a ``ComposableArchitecture/WithViewStore`` - that provides a lightweight syntax for constructing a view store. + With some basic view scaffolding in place we can now start actually reading state from, and + sending actions to, the `store`. @Step { - View stores require that `State` be `Equatable`, so we must do that first. Once the view - store is constructed we can access the feature's state and send it actions when the user - taps on buttons. - - > Tip: Currently we are observing _all_ state in the store by using `observe: { $0 }`, - > but typically features hold onto a lot more state than what is needed in the view. See - > our article for more information on how to best observe only the bare - > essentials a view needs to do its job. - - @Code(name: "CounterFeature.swift", file: 01-01-02-code-0004.swift, previousFile: 01-01-02-code-0004-previous.swift) + We can read read a property of state directly from the `store` via dynamic member lookup, + and we can send actions to the `store` via ``ComposableArchitecture/Store/send(_:)``. + + @Code(name: "CounterFeature.swift", file: 01-01-02-code-0004.swift) } @Step { @@ -235,18 +228,17 @@ } We can demonstrate another super power of the Composable Architecture. Reducers have a - method called ``ComposableArchitecture/Reducer/_printChanges(_:)`` that is similar - to a tool that SwiftUI provides. When used it will print every action that the reducer - processes to the console, and it will print how the state changed after processing the - action. The method will also go through great lengths to collapse the state difference to a - compact form. This includes not displaying nested state if it hasn't changed, and not showing - elements in collections that haven't changed. + method called `_printChanges` that is similar to a tool that SwiftUI provides. When used it + will print every action that the reducer processes to the console, and it will print how the + state changed after processing the action. The method will also go through great lengths to + collapse the state difference to a compact form. This includes not displaying nested state if + it hasn't changed, and not showing elements in collections that haven't changed. @Step { Update the entry point of the application to invoke - ``ComposableArchitecture/Reducer/_printChanges(_:)`` on the reducer. + `_printChanges(_:)` on the reducer. - @Code(name: "App.swift", file: 01-01-03-code-0004.swift) + @Code(name: "App.swift", file: 01-01-03-code-0004.swift) } @Step { Now when you run the application in the simulator and tap the "+" and "-" buttons a few diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001-previous.swift index 161673f97757..61dd932fd75b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001-previous.swift @@ -2,30 +2,28 @@ struct CounterView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - Text("\(viewStore.count)") - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - HStack { - Button("-") { - viewStore.send(.decrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - - Button("+") { - viewStore.send(.incrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) + VStack { + Text("\(store.count)") + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + HStack { + Button("-") { + store.send(.decrementButtonTapped) } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + + Button("+") { + store.send(.incrementButtonTapped) + } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001.swift index 84e5f53ab5d6..c00bcfff851b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0001.swift @@ -2,38 +2,36 @@ struct CounterView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - Text("\(viewStore.count)") - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - HStack { - Button("-") { - viewStore.send(.decrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - - Button("+") { - viewStore.send(.incrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) + VStack { + Text("\(store.count)") + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + HStack { + Button("-") { + store.send(.decrementButtonTapped) } - Button("Fact") { - viewStore.send(.factButtonTapped) + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + + Button("+") { + store.send(.incrementButtonTapped) } .font(.largeTitle) .padding() .background(Color.black.opacity(0.1)) .cornerRadius(10) } + Button("Fact") { + store.send(.factButtonTapped) + } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0002.swift index 3344be11676f..7fa5f6b1a3ad 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0002.swift @@ -2,46 +2,44 @@ struct CounterView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - Text("\(viewStore.count)") - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - HStack { - Button("-") { - viewStore.send(.decrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - - Button("+") { - viewStore.send(.incrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - } - Button("Fact") { - viewStore.send(.factButtonTapped) + VStack { + Text("\(store.count)") + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + HStack { + Button("-") { + store.send(.decrementButtonTapped) } .font(.largeTitle) .padding() .background(Color.black.opacity(0.1)) .cornerRadius(10) - if viewStore.isLoading { - ProgressView() - } else if let fact = viewStore.fact { - Text(fact) - .font(.largeTitle) - .multilineTextAlignment(.center) - .padding() + Button("+") { + store.send(.incrementButtonTapped) } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + } + Button("Fact") { + store.send(.factButtonTapped) + } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + + if store.isLoading { + ProgressView() + } else if let fact = store.fact { + Text(fact) + .font(.largeTitle) + .multilineTextAlignment(.center) + .padding() } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0003.swift index 6d39bf1da31a..8f0bce2eb070 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0003.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0004.swift index 570ba0b9b25b..637ccbe38aad 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0004.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0005.swift index 8fe77e6f14e4..4b35cad23a9f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-01-code-0005.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0001.swift index 1eed402dd2a6..080e55971fe0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0001.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0002.swift index d99b51394b2c..7aafe563898d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0002.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0003.swift index 9f15068e5287..95ca6e3183ae 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0003.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0004.swift index 4ae4c1e4e4e9..096436ba51c7 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0004.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0005.swift index 4ae4c1e4e4e9..096436ba51c7 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-02-code-0005.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0001.swift index 03508cb0c109..42e587235ec6 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0001.swift @@ -2,54 +2,52 @@ struct CounterView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - Text("\(viewStore.count)") - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - HStack { - Button("-") { - viewStore.send(.decrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - - Button("+") { - viewStore.send(.incrementButtonTapped) - } - .font(.largeTitle) - .padding() - .background(Color.black.opacity(0.1)) - .cornerRadius(10) - } - Button(viewStore.isTimerRunning ? "Stop timer" : "Start timer") { - viewStore.send(.toggleTimerButtonTapped) - } + VStack { + Text("\(store.count)") .font(.largeTitle) .padding() .background(Color.black.opacity(0.1)) .cornerRadius(10) - - Button("Fact") { - viewStore.send(.factButtonTapped) + HStack { + Button("-") { + store.send(.decrementButtonTapped) } .font(.largeTitle) .padding() .background(Color.black.opacity(0.1)) .cornerRadius(10) - if viewStore.isLoading { - ProgressView() - } else if let fact = viewStore.fact { - Text(fact) - .font(.largeTitle) - .multilineTextAlignment(.center) - .padding() + Button("+") { + store.send(.incrementButtonTapped) } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + } + Button(store.isTimerRunning ? "Stop timer" : "Start timer") { + store.send(.toggleTimerButtonTapped) + } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + + Button("Fact") { + store.send(.factButtonTapped) + } + .font(.largeTitle) + .padding() + .background(Color.black.opacity(0.1)) + .cornerRadius(10) + + if store.isLoading { + ProgressView() + } else if let fact = store.fact { + Text(fact) + .font(.largeTitle) + .multilineTextAlignment(.center) + .padding() } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0002.swift index 73f1861efa6d..578f192b5b98 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0002.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0003.swift index b79903217b26..17c4aeaef048 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0003.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0004.swift index da70685bc6b9..514057b515a5 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0004.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0005.swift index 26b136b8dc4a..b1059c2f45d9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0005.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0006.swift index 26b136b8dc4a..b1059c2f45d9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/02-AddingSideEffects/01-02-03-code-0006.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0006.swift index 391c73ab6428..52bd865a03de 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0006.swift @@ -14,7 +14,7 @@ final class CounterFeatureTests: XCTestCase { await store.receive(\.timerTick) { $0.count = 1 } - // ✅ Test Suite 'Selected tests' passed at 2023-08-04 11:17:44.823. + // ✅ Test Suite 'Selected tests' passed. // Executed 1 test, with 0 failures (0 unexpected) in 1.044 (1.046) seconds // or: // ❌ Expected to receive an action, but received none after 0.1 seconds. diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0008.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0008.swift index a0bc5d94493c..e4f86b731381 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0008.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-02-code-0008.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0005.swift index 58755f63bed4..0f36d6dced30 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0005.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct CounterFeature { + @ObservableState struct State: Equatable { var count = 0 var fact: String? diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0006.swift index d5dca3fe7215..6261c982a630 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/01-Essentials/03-TestingYourFeatures/01-03-04-code-0006.swift @@ -19,7 +19,7 @@ final class CounterFeatureTests: XCTestCase { // accessed from a test context: // // Location: - // TCATest/CounterFeature.swift:70 + // CounterFeature.swift:70 // Dependency: // NumberFactClient // diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0000.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0000.swift index bacabfbf7965..8a88eb3fc598 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0000.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0000.swift @@ -8,6 +8,7 @@ struct Contact: Equatable, Identifiable { @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0001.swift index 2cd8ae0071cb..6c187126498c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0001.swift @@ -5,20 +5,18 @@ struct ContactsView: View { var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - Text(contact.name) - } + List { + ForEach(store.contacts) { contact in + Text(contact.name) } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { - Button { - viewStore.send(.addButtonTapped) - } label: { - Image(systemName: "plus") - } + } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0002.swift index 9db612def222..aa78e51a3df4 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0002.swift @@ -1,17 +1,15 @@ -struct ContactsView_Previews: PreviewProvider { - static var previews: some View { - ContactsView( - store: Store( - initialState: ContactsFeature.State( - contacts: [ - Contact(id: UUID(), name: "Blob"), - Contact(id: UUID(), name: "Blob Jr"), - Contact(id: UUID(), name: "Blob Sr"), - ] - ) - ) { - ContactsFeature() - } - ) - } +#Preview { + ContactsView( + store: Store( + initialState: ContactsFeature.State( + contacts: [ + Contact(id: UUID(), name: "Blob"), + Contact(id: UUID(), name: "Blob Jr"), + Contact(id: UUID(), name: "Blob Sr"), + ] + ) + ) { + ContactsFeature() + } + ) } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0003.swift index f4df2236d652..8ae67b966504 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0003.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0004.swift index 9b53b7f6c2e3..bddc6886482e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0004.swift @@ -1,23 +1,5 @@ import SwiftUI struct AddContactView: View { - let store: StoreOf - - var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - TextField("Name", text: viewStore.binding(get: \.contact.name, send: { .setName($0) })) - Button("Save") { - viewStore.send(.saveButtonTapped) - } - } - .toolbar { - ToolbarItem { - Button("Cancel") { - viewStore.send(.cancelButtonTapped) - } - } - } - } - } + @Bindable var store: StoreOf } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0005.swift index e2c2a488aa52..938424d2d0d8 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0005.swift @@ -1,18 +1,11 @@ -struct AddContactPreviews: PreviewProvider { - static var previews: some View { - NavigationStack { - AddContactView( - store: Store( - initialState: AddContactFeature.State( - contact: Contact( - id: UUID(), - name: "Blob" - ) - ) - ) { - AddContactFeature() - } - ) +import SwiftUI + +struct AddContactView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + TextField("Name", text: $store.contact.name.sending(\.setName)) } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0006.swift new file mode 100644 index 000000000000..a4958bedb4a6 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0006.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct AddContactView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + TextField("Name", text: $store.contact.name.sending(\.setName)) + Button("Save") { + store.send(.saveButtonTapped) + } + } + .toolbar { + ToolbarItem { + Button("Cancel") { + store.send(.cancelButtonTapped) + } + } + } + } +} diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0007.swift new file mode 100644 index 000000000000..370eaaa33b2a --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-01-code-0007.swift @@ -0,0 +1,16 @@ +#Preview { + NavigationStack { + AddContactView( + store: Store( + initialState: AddContactFeature.State( + contact: Contact( + id: UUID(), + name: "Blob" + ) + ) + ) { + AddContactFeature() + } + ) + } +} diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0000.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0000.swift index 83140db9afcd..988742166c00 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0000.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0000.swift @@ -1,5 +1,6 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0001.swift index 4e49081cf137..ce4836879c3b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0001.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0002.swift index 6480af237a82..733069179aa0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0002.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0003.swift index 7d015504320b..7a652c871bf7 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0003.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0004.swift index 2ede7673b9d5..0cf1f0317415 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0004.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0005.swift index fcaa6ce6b5cd..a04434b34aa4 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0005.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0006.swift index 1e9af6006c6c..1d99e8f6708e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0006.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0007.swift index f4ea0c5ea51d..d4c04a56535c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0007.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0008.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0008.swift index c6f4566b6540..28809a7b9fcc 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0008.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0008.swift @@ -3,20 +3,18 @@ struct ContactsView: View { var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - Text(contact.name) - } + List { + ForEach(store.contacts) { contact in + Text(contact.name) } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { - Button { - viewStore.send(.addButtonTapped) - } label: { - Image(systemName: "plus") - } + } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0009.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0009.swift index 209ef6704b5d..2a8c34921254 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0009.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-02-code-0009.swift @@ -1,31 +1,26 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - Text(contact.name) - } + List { + ForEach(store.contacts) { contact in + Text(contact.name) } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { - Button { - viewStore.send(.addButtonTapped) - } label: { - Image(systemName: "plus") - } + } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") } } } } .sheet( - store: self.store.scope( - state: \.$addContact, - action: \.addContact - ) + item: $store.scope(state: \.addContact, action: \.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000-previous.swift index f4df2236d652..8ae67b966504 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000-previous.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000.swift index ca6a486dbbb2..659ce9c7872c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0000.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0001.swift index 2429008201fe..cc4ba6f79d38 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0001.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0002.swift index f580aa7cacc3..18e36c99f162 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0002.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003-previous.swift index f4ea0c5ea51d..d4c04a56535c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003-previous.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003.swift index c1832d0070f5..385cea0747bc 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0003.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004-previous.swift index fc690e76c51a..2564087c86cf 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004-previous.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004.swift index 7c6f30a9b43d..78a8a94d5958 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0004.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0005.swift index 58b0fc66ca4b..80ae24d38c8d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0005.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0006.swift index dacb0ab16edb..7791c265f49a 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0006.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct AddContactFeature { + @ObservableState struct State: Equatable { var contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007-previous.swift index f076e0127675..9cc614b5b23f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007-previous.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007.swift index 638cb8419e43..4315830f3295 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-04-code-0007.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-YourFirstPresentation.tutorial b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-YourFirstPresentation.tutorial index 091354537210..a5efb3778029 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-YourFirstPresentation.tutorial +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/01-YourFirstPresentation/02-01-YourFirstPresentation.tutorial @@ -22,8 +22,7 @@ data type, and a simple reducer with a collection of contacts, and a single action for when the "+" button is tapped. Currently that action is not yet implemented. - > Note: We conform `State` and `Action` to the `Equatable` protocol in order to test this - > feature later. + > Note: We conform `State` to the `Equatable` protocol in order to test this feature later. @Code(name: "ContactsFeature.swift", file: 02-01-01-code-0000.swift) } @@ -50,20 +49,46 @@ button for dismissing, and a "Save" button that when tapped should dismiss the feature _and_ add the contact to the list of contacts in the parent. + > Note: There is nothing to do in the `cancelButtonTapped` and `saveButtonTapped` actions + > right now. They will be implemented later. + @Code(name: "AddContactFeature.swift", file: 02-01-01-code-0003.swift, reset: true) } @Step { - Add a view that holds onto a ``ComposableArchitecture/Store`` of the `AddContactFeature` - and observes the state in order to show a text field for the contact name and send actions. + Add a view that holds onto a ``ComposableArchitecture/Store`` of the `AddContactFeature`. + Since our view is going to have a text field, we will need to be able to derive bindings + from the store. To do this we use the `@Bindable` property wrapper from SwiftUI. + > Note: If you are targeting older platforms and do not have access to `@Bindable`, you can + > instead use `@Perception.Bindable`, which comes with the library. + @Code(name: "AddContactFeature.swift", file: 02-01-01-code-0004.swift, reset: true) } + @Step { + Add a form to the view with a text fielid for editing the name of the contact. We can + use the dynamic member lookup on `$store` to describe what piece of state you want to + drive the binding, and then you can use the `sending` method to describe which action + you want to send when the binding is written to. + + > Note: See for more information on using bindings in the Composable + Architecture. + + @Code(name: "AddContactFeature.swift", file: 02-01-01-code-0005.swift) + } + + @Step { + Add a "Save" and "Cancel" button to the view and send the corresponding actions when those + buttons are tapped. + + @Code(name: "AddContactFeature.swift", file: 02-01-01-code-0006.swift) + } + @Step { Add a preview so that we can see what the feature looks like. - @Code(name: "AddContactFeature.swift", file: 02-01-01-code-0005.swift, reset: true) { + @Code(name: "AddContactFeature.swift", file: 02-01-01-code-0007.swift, reset: true) { @Image(source: "ch02-sub01-sec01-image-0002") } } @@ -74,8 +99,8 @@ @ContentAndMedia { Now that we have our two isolated features built, it is time to integrate them together so that you can navigate to the "Add Contact" screen from the contacts list screen. To do this we - will first integrate the features' reducers, which consists of utilizing - ``ComposableArchitecture/PresentationState`` and ``ComposableArchitecture/PresentationAction`` + will first integrate the features' reducers, which consists of utilizing + ``ComposableArchitecture/Presents()`` and ``ComposableArchitecture/PresentationAction`` to integrate the domains, and the reducer operator ``ComposableArchitecture/Reducer/ifLet(_:action:fileID:line:)-2eczg`` to integrate the reducers. @@ -91,9 +116,8 @@ } @Step { - Integrate the features' states together by using the - ``ComposableArchitecture/PresentationState`` property wrapper to hold onto an optional - value. + Integrate the features' states together by using the ``ComposableArchitecture/Presents()`` + macro to hold onto an optional value. A `nil` value represents that the "Add Contacts" feature is not presented, and a non-`nil` value represents that it is presented. @@ -182,7 +206,7 @@ and we have a navigation title and toolbar. We need to figure out how to present a sheet in this view whenever the `addContact` state flips to non-`nil`. - @Code(name: "ContactsFeature.swift", file: 02-01-02-code-0008.swift, reset: true) + @Code(name: "ContactsFeature.swift", file: 02-01-02-code-0008.swift, reset: true) } The library comes with a variety of tools that mimic SwiftUI's native navigation tools (such @@ -190,12 +214,16 @@ ``ComposableArchitecture/Store``s instead of bindings. @Step { - Use the `sheet(store:)` view modifier by scoping your store down to just the presentation - domain of the `addContact` feature. When the `addContact` state becomes non-`nil`, a new - store will be derived focused only on the `AddContactFeature` domain, which is what you can - pass to the `AddContactView`. + Use the `@Bindable` property wrapper to derive a binding to a store, which can be scoped + down to just the presentation domain of the `addContact` feature, and passed to the + `sheet(item:)` view modifier. When the `addContact` state becomes non-`nil`, a new store + will be derived focused only on the `AddContactFeature` domain, which is what you can pass + to the `AddContactView`. + + > Note: If you are targeting older platforms and do not have access to `@Bindable`, you can + > instead use `@Perception.Bindable``, which comes with the library. - @Code(name: "ContactsFeature.swift", file: 02-01-02-code-0009.swift) + @Code(name: "ContactsFeature.swift", file: 02-01-02-code-0009.swift) } @Step { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000-previous.swift index 316ece5c963b..57b282f0510f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000-previous.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000.swift index 942f9bea824c..e8ecdb8ba83e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0000.swift @@ -1,7 +1,8 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? + @Presents var addContact: AddContactFeature.State? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0001.swift index ad51f69ad02a..c4d1a32d516b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0001.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? - @PresentationState var alert: AlertState? + @Presents var addContact: AddContactFeature.State? + @Presents var alert: AlertState? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0002.swift index e6c8bb29765a..0562db2b6398 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0002.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? - @PresentationState var alert: AlertState? + @Presents var addContact: AddContactFeature.State? + @Presents var alert: AlertState? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0003.swift index 2ed6803d66c0..ac7b5e6dd53b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0003.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? - @PresentationState var alert: AlertState? + @Presents var addContact: AddContactFeature.State? + @Presents var alert: AlertState? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0004.swift index 2e4f28371208..44f88238cfc6 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0004.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? - @PresentationState var alert: AlertState? + @Presents var addContact: AddContactFeature.State? + @Presents var alert: AlertState? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0005.swift index 72ba73fa5e55..0394eec77fd1 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0005.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? - @PresentationState var alert: AlertState? + @Presents var addContact: AddContactFeature.State? + @Presents var alert: AlertState? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006-previous.swift index 209ef6704b5d..2a8c34921254 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006-previous.swift @@ -1,31 +1,26 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - Text(contact.name) - } + List { + ForEach(store.contacts) { contact in + Text(contact.name) } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { - Button { - viewStore.send(.addButtonTapped) - } label: { - Image(systemName: "plus") - } + } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") } } } } .sheet( - store: self.store.scope( - state: \.$addContact, - action: \.addContact - ) + item: $store.scope(state: \.addContact, action: \.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006.swift index ea40df2f644f..b038562b5adc 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0006.swift @@ -1,41 +1,31 @@ struct ContactsView: View { - let store: StoreOf - + @Bindable var store: StoreOf + var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - Text(contact.name) - } + List { + ForEach(store.contacts) { contact in + Text(contact.name) } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { - Button { - viewStore.send(.addButtonTapped) - } label: { - Image(systemName: "plus") - } + } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") } } } } .sheet( - store: self.store.scope( - state: \.$addContact, - action: \.addContact - ) + item: $store.scope(state: \.addContact, action: \.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$alert, - action: \.alert - ) - ) + .alert($store.scope(state: \.alert, action: \.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0007.swift index c9f13ac740cc..90d9e46641d9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-01-code-0007.swift @@ -1,50 +1,40 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } .sheet( - store: self.store.scope( - state: \.$addContact, - action: \.addContact - ) + item: $store.scope(state: \.addContact, action: \.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$alert, - action: \.alert - ) - ) + .alert($store.scope(state: \.alert, action: \.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0001.swift index e19199e6f917..074680b53ff4 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0001.swift @@ -1,6 +1,7 @@ extension ContactsFeature { @Reducer struct Destination { + @ObservableState enum State: Equatable { } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0002.swift index 163312d5ff05..036d3717186c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0002.swift @@ -1,6 +1,7 @@ extension ContactsFeature { @Reducer struct Destination { + @ObservableState enum State: Equatable { case addContact(AddContactFeature.State) case alert(AlertState) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0003.swift index 93fa7a33f920..1cc216955cfb 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0003.swift @@ -1,6 +1,7 @@ extension ContactsFeature { @Reducer struct Destination { + @ObservableState enum State: Equatable { case addContact(AddContactFeature.State) case alert(AlertState) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0004.swift index 5aa64beb0c32..648c50a08bef 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0004.swift @@ -1,6 +1,7 @@ extension ContactsFeature { @Reducer struct Destination { + @ObservableState enum State: Equatable { case addContact(AddContactFeature.State) case alert(AlertState) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0005.swift index 13b8ee8d138c..3f1231c59ade 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0005.swift @@ -1,6 +1,7 @@ extension ContactsFeature { @Reducer struct Destination { + @ObservableState enum State: Equatable { case addContact(AddContactFeature.State) case alert(AlertState) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0006.swift index e3e82a2e50ed..9b835586aa56 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0006.swift @@ -1,6 +1,7 @@ extension ContactsFeature { @Reducer struct Destination { + @ObservableState enum State: Equatable { case addContact(AddContactFeature.State) case alert(AlertState) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007-previous.swift index 72ba73fa5e55..0394eec77fd1 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007-previous.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { - @PresentationState var addContact: AddContactFeature.State? - @PresentationState var alert: AlertState? + @Presents var addContact: AddContactFeature.State? + @Presents var alert: AlertState? var contacts: IdentifiedArrayOf = [] } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007.swift index 1c27ac52711e..9c64e3db085d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0007.swift @@ -1,8 +1,11 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + // @Presents var addContact: AddContactFeature.State? + // @Presents var alert: AlertState? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0008.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0008.swift index f344a38adeb6..86aa113d1347 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0008.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0008.swift @@ -1,12 +1,15 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped case deleteButtonTapped(id: Contact.ID) + // case addContact(PresentationAction) + // case alert(PresentationAction) case destination(PresentationAction) enum Alert: Equatable { case confirmDeletion(id: Contact.ID) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0009.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0009.swift index 24ee623e04db..366f625d1d63 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0009.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0009.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0010.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0010.swift index d3e11baaad88..19be6f9d2084 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0010.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0010.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0011.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0011.swift index 343ec93608cf..0b83874074c7 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0011.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0011.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0012.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0012.swift index 58f13213f90c..2d1002c9b3e9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0012.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0012.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0013.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0013.swift index 49324f4a9ed1..95327687b187 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0013.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0013.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0014.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0014.swift index 95bb2c7ed4f4..f2a8e7616157 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0014.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0014.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015-previous.swift index c9f13ac740cc..90d9e46641d9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015-previous.swift @@ -1,50 +1,40 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } .sheet( - store: self.store.scope( - state: \.$addContact, - action: \.addContact - ) + item: $store.scope(state: \.addContact, action: \.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$alert, - action: \.alert - ) - ) + .alert($store.scope(state: \.alert, action: \.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015.swift index 9f5cbae276e6..1d37a63e2ad9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0015.swift @@ -1,50 +1,40 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } .sheet( - store: self.store.scope( - state: \.$destination.addContact, - action: \.destination.addContact - ) + item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$alert, - action: \.alert - ) - ) + .alert($store.scope(state: \.alert, action: \.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0016.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0016.swift index 731c7df05c88..1236c20d5add 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0016.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-02-code-0016.swift @@ -1,50 +1,40 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } .sheet( - store: self.store.scope( - state: \.$destination.addContact, - action: \.destination.addContact - ) + item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$destination.alert, - action: \.destination.alert - ) - ) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-MultipleDestinations.tutorial b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-MultipleDestinations.tutorial index ec60467fb896..2972049c217b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-MultipleDestinations.tutorial +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/02-MultipleDestinations/02-02-MultipleDestinations.tutorial @@ -9,9 +9,10 @@ @ContentAndMedia { Let's add a new feature to the contacts list that allows you to delete a contact, but first you must confirm deletion. We will implement the confirmation step using an alert. The tools - that we used last section, such as ``ComposableArchitecture/PresentationState``, - ``ComposableArchitecture/PresentationAction`` and `ifLet`, all work for presenting alerts - from optional state too. + that we used last section, such as ``ComposableArchitecture/Presents()``, + ``ComposableArchitecture/PresentationAction`` and + ``ComposableArchitecture/Reducer/ifLet(_:action:destination:fileID:line:)-4f2at``, all work + for presenting alerts from optional state too. } @Steps { @@ -26,7 +27,7 @@ of the contact. @Step { - Add a piece of optional ``ComposableArchitecture/PresentationState`` state to the + Add a piece of optional ``ComposableArchitecture/Presents()`` state to the `ContactsFeature` state. We will further use `AlertState` as it allows us to describe all of the details of the alert in a manner that is test friendly since it is `Equatable`. @@ -68,14 +69,17 @@ } That's all it takes to integrate the alert it the `ContactsFeature` and implement all of its - logic. Next we need to integrate the alert into the view. Just as the library ships a special - `sheet(store:)` view modifier that is tuned specifically for - ``ComposableArchitecture/Store``s, it also comes with a `alert(store:)` that serves a similar - purpose. + logic. Next we need to integrate the alert into the view. The library ships a special + ``SwiftUI/View/alert(store:)`` view modifier that is tuned specifically for + ``ComposableArchitecture/Store``s. @Step { - Add the `alert(store:)` view modifier to the `ContactsView`, and hand it a store that is - scoped to the alert domain. + Add the ``SwiftUI/View/alert(_:)`` view modifier to the `ContactsView`, and hand it a + store that is scoped to the alert domain. + + > Note: In order for the `$store.scope` syntax to work you must hold onto the store in the + > view using the `@Bindable` property wrapper, or `@Perception.Bindable` if you are + > targeting older platforms. @Code(name: "ContactsFeatures.swift", file: 02-02-01-code-0006.swift, previousFile: 02-02-01-code-0006-previous.swift) } @@ -104,7 +108,7 @@ Currently the `ContactsFeature` can navigate to two possible destinations: either the "Add Contact" sheet or the delete alert. Importantly, it is not possible to be navigated to both destinations at once. However, that currently is possible since we are representing each of - those destinations as optional pieces of ``ComposableArchitecture/PresentationState``. + those destinations as optional pieces of ``ComposableArchitecture/Presents()``. The number of invalid states explodes exponentially when you use optionals to represent features you can navigate to. For example, 2 optionals has 1 invalid state, but 3 optionals @@ -168,8 +172,8 @@ } @Step { - Replace the two pieces of optional ``ComposableArchitecture/PresentationState`` with a - single option pointed at `Destination.State`. + Replace the two pieces of optional ``ComposableArchitecture/Presents()`` with a single + optional pointed at `Destination.State`. @Code(name: "ContactsFeatures.swift", file: 02-02-02-code-0007.swift, previousFile: 02-02-02-code-0007-previous.swift) } @@ -235,15 +239,15 @@ @Step { When you model all of your destinations in a single optional values, you start by scoping to the destination domain and then you further scope into the the state and action cases - associated with the specific destination. This can be done with familiar dot syntax because - the ``ComposableArchitecture/Reducer()`` macro applies the `@CasePathable` macro to each - enum. + associated with the specific destination using familiar key path dot-chaining syntax. This + can be done with familiar dot syntax because the ``ComposableArchitecture/Reducer()`` + macro applies the `@CasePathable` macro to each enum. @Code(name: "ContactsFeatures.swift", file: 02-02-02-code-0015.swift, previousFile: 02-02-02-code-0015-previous.swift) } @Step { - The same can be done for the `alert(store:)` view modifier. + The same can be done for the `alert` view modifier. @Code(name: "ContactsFeatures.swift", file: 02-02-02-code-0016.swift) } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006-previous.swift index da6491b42282..4a6d159af6dd 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006-previous.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006.swift index ea32d2b6063c..356aa9929dae 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0006.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0007.swift index 098fe24c454c..38b569f74a8e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0007.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0013.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0013.swift index 8d2e9e01d020..23286caa73c8 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0013.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0013.swift @@ -11,7 +11,7 @@ final class ContactsFeatureTests: XCTestCase { } withDependencies: { $0.uuid = .incrementing } - + await store.send(.addButtonTapped) { $0.destination = .addContact( AddContactFeature.State( diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0015-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0015-previous.swift new file mode 100644 index 000000000000..a30e139e448a --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0015-previous.swift @@ -0,0 +1,40 @@ +import ComposableArchitecture + +@Reducer +struct AddContactFeature { + @ObservableState + struct State: Equatable { + var contact: Contact + } + enum Action { + case cancelButtonTapped + case delegate(Delegate) + case saveButtonTapped + case setName(String) + enum Delegate { + case saveContact(Contact) + } + } + @Dependency(\.dismiss) var dismiss + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .cancelButtonTapped: + return .run { _ in await self.dismiss() } + + case .delegate: + return .none + + case .saveButtonTapped: + return .run { [contact = state.contact] send in + await send(.delegate(.saveContact(contact))) + await self.dismiss() + } + + case let .setName(name): + state.contact.name = name + return .none + } + } + } +} diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0015.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0015.swift index 05cde54f81b8..94df439121f0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0015.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0015.swift @@ -1,38 +1,41 @@ import ComposableArchitecture -import XCTest -@testable import ContactsApp - -@MainActor -final class ContactsFeatureTests: XCTestCase { - func testAddFlow() async { - let store = TestStore(initialState: ContactsFeature.State()) { - ContactsFeature() - } withDependencies: { - $0.uuid = .incrementing - } - - await store.send(.addButtonTapped) { - $0.destination = .addContact( - AddContactFeature.State( - contact: Contact(id: UUID(0), name: "") - ) - ) - } - await store.send(.destination(.presented(.addContact(.setName("Blob Jr."))))) { - $0.$destination[case: \.addContact]?.contact.name = "Blob Jr." - } - await store.send(.destination(.presented(.addContact(.saveButtonTapped)))) - await store.receive( - \.destination.addContact.delegate.saveContact, - Contact(id: UUID(0), name: "Blob Jr.") - ) { - $0.contacts = [ - Contact(id: UUID(0), name: "Blob Jr.") - ] +@Reducer +struct AddContactFeature { + @ObservableState + struct State: Equatable { + var contact: Contact + } + enum Action { + case cancelButtonTapped + case delegate(Delegate) + case saveButtonTapped + case setName(String) + @CasePathable + enum Delegate { + case saveContact(Contact) } - await store.receive(\.destination.dismiss) { - $0.destination = nil + } + @Dependency(\.dismiss) var dismiss + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .cancelButtonTapped: + return .run { _ in await self.dismiss() } + + case .delegate: + return .none + + case .saveButtonTapped: + return .run { [contact = state.contact] send in + await send(.delegate(.saveContact(contact))) + await self.dismiss() + } + + case let .setName(name): + state.contact.name = name + return .none + } } } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0016.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0016.swift new file mode 100644 index 000000000000..553dfd427b5d --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0016.swift @@ -0,0 +1,35 @@ +import ComposableArchitecture +import XCTest + +@testable import ContactsApp + +@MainActor +final class ContactsFeatureTests: XCTestCase { + func testAddFlow() async { + let store = TestStore(initialState: ContactsFeature.State()) { + ContactsFeature() + } withDependencies: { + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.destination = .addContact( + AddContactFeature.State( + contact: Contact(id: UUID(0), name: "") + ) + ) + } + await store.send(.destination(.presented(.addContact(.setName("Blob Jr."))))) { + $0.$destination[case: \.addContact]?.contact.name = "Blob Jr." + } + await store.send(.destination(.presented(.addContact(.saveButtonTapped)))) + await store.receive( + \.destination.addContact.delegate.saveContact, + Contact(id: UUID(0), name: "Blob Jr.") + ) { + $0.contacts = [ + Contact(id: UUID(0), name: "Blob Jr.") + ] + } + } +} diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0017.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0017.swift new file mode 100644 index 000000000000..3ecdfd844999 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-01-code-0017.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import XCTest + +@testable import ContactsApp + +@MainActor +final class ContactsFeatureTests: XCTestCase { + func testAddFlow() async { + let store = TestStore(initialState: ContactsFeature.State()) { + ContactsFeature() + } withDependencies: { + $0.uuid = .incrementing + } + + await store.send(.addButtonTapped) { + $0.destination = .addContact( + AddContactFeature.State( + contact: Contact(id: UUID(0), name: "") + ) + ) + } + await store.send(.destination(.presented(.addContact(.setName("Blob Jr."))))) { + $0.$destination[case: \.addContact]?.contact.name = "Blob Jr." + } + await store.send(.destination(.presented(.addContact(.saveButtonTapped)))) + await store.receive( + \.destination.addContact.delegate.saveContact, + Contact(id: UUID(0), name: "Blob Jr.") + ) { + $0.contacts = [ + Contact(id: UUID(0), name: "Blob Jr.") + ] + } + await store.receive(\.destination.dismiss) + } +} diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007-previous.swift index 098fe24c454c..38b569f74a8e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007-previous.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007.swift index 966175bc0862..4945fdb541b3 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-03-code-0007.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-TestingPresentation.tutorial b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-TestingPresentation.tutorial index 738d5b9e5c63..340956a7ad8b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-TestingPresentation.tutorial +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/03-TestingPresentation/02-03-TestingPresentation.tutorial @@ -136,18 +136,31 @@ } @Step { - Assert that when the `saveContact` delegate action is received that state mutates by adding - a contact to the array. + Assert that when a delegate action is received that state mutates by adding a contact to the + array. @Code(name: "ContactsFeatureTests.swift", file: 02-03-01-code-0014.swift) } + @Step { + To further assert that when the `saveContact` delegate action was received, you must + annotate `AddContactFeature.Action.Delegate` with the `@CasePathable` macro. + + @Code(name: "ContactsFeatureTests.swift", file: 02-03-01-code-0015.swift, previousFile: 02-03-01-code-0015-previous.swift) + } + + @Step { + Now you can further dive into the `saveContact` case and even assert against its payload. + + @Code(name: "ContactsFeatureTests.swift", file: 02-03-01-code-0016.swift, previousFile: 02-03-01-code-0014.swift) + } + @Step { Finally assert that the test store receives a ``ComposableArchitecture/PresentationAction/dismiss`` action, which causes the "Add Contact" feature to be dismissed. - @Code(name: "ContactsFeatureTests.swift", file: 02-03-01-code-0015.swift) + @Code(name: "ContactsFeatureTests.swift", file: 02-03-01-code-0017.swift) } This is a fully passing test, and proves the end-to-end lifecycle of presenting a child diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0001.swift index c675a53f98ab..72d4cffb86fa 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0001.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct ContactDetailFeature { + @ObservableState struct State: Equatable { let contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0002.swift index 00c9effe5d4e..85e480c587fa 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0002.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct ContactDetailFeature { + @ObservableState struct State: Equatable { let contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0003.swift index f91e0b0316be..9e7c876ed075 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0003.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct ContactDetailFeature { + @ObservableState struct State: Equatable { let contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0006.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0006.swift index 8d903635fdb0..568e8152057e 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0006.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0006.swift @@ -4,10 +4,8 @@ struct ContactDetailView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - } - .navigationBarTitle(Text(viewStore.contact.name)) + Form { } + .navigationBarTitle(Text(store.contact.name)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0007.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0007.swift index 2109d69c7111..6c65434a761a 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0007.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-01-code-0007.swift @@ -4,26 +4,22 @@ struct ContactDetailView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - } - .navigationBarTitle(Text(viewStore.contact.name)) + Form { } + .navigationBarTitle(Text(store.contact.name)) } } -struct ContactDetailPreviews: PreviewProvider { - static var previews: some View { - NavigationStack { - ContactDetailView( - store: Store( - initialState: ContactDetailFeature.State( - contact: Contact(id: UUID(), name: "Blob") - ) - ) { - ContactDetailFeature() - } - ) - } +#Preview { + NavigationStack { + ContactDetailView( + store: Store( + initialState: ContactDetailFeature.State( + contact: Contact(id: UUID(), name: "Blob") + ) + ) { + ContactDetailFeature() + } + ) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000-previous.swift index 966175bc0862..4945fdb541b3 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000-previous.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? } enum Action { case addButtonTapped diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000.swift index a5ea240004bc..3290f5bf2418 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0000.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var path = StackState() } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0001.swift index bf25b68260c2..b1261225d6e0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0001.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var path = StackState() } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0002.swift index 2113343520fe..4ccc3e5607e0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0002.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var path = StackState() } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003-previous.swift index 731c7df05c88..1236c20d5add 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003-previous.swift @@ -1,50 +1,40 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { NavigationStack { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } .sheet( - store: self.store.scope( - state: \.$destination.addContact, - action: \.destination.addContact - ) + item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$destination.alert, - action: \.destination.alert - ) - ) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003.swift index 46154be26904..93992c6a2b31 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0003.swift @@ -1,50 +1,40 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - NavigationStackStore(self.store.scope(state: \.path, action: \.path)) { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } .sheet( - store: self.store.scope( - state: \.$destination.addContact, - action: \.destination.addContact - ) + item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$destination.alert, - action: \.destination.alert - ) - ) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0004.swift index e352680581de..c9706c744217 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0004.swift @@ -1,52 +1,42 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - NavigationStackStore(self.store.scope(state: \.path, action: \.path)) { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } destination: { store in ContactDetailView(store: store) } .sheet( - store: self.store.scope( - state: \.$destination.addContact, - action: \.destination.addContact - ) + item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$destination.alert, - action: \.destination.alert - ) - ) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005-previous.swift index 2b2b47309957..c9706c744217 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005-previous.swift @@ -1,52 +1,42 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - NavigationStackStore(self.store.scope(state: \.path, action: \.path)) { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + ForEach(store.contacts) { contact in + HStack { + Text(contact.name) + Spacer() Button { - viewStore.send(.addButtonTapped) + store.send(.deleteButtonTapped(id: contact.id)) } label: { - Image(systemName: "plus") + Image(systemName: "trash") + .foregroundColor(.red) } } } } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } } destination: { store in ContactDetailView(store: store) } .sheet( - store: self.store.scope( - state: \.$destination.addContact, - action: \.destination.addContact - ) + item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$destination.alert, - action: \.destination.alert - ) - ) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005.swift index 6bf13b3bdde0..9cf99baeb23f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-02-code-0005.swift @@ -1,34 +1,32 @@ struct ContactsView: View { - let store: StoreOf + @Bindable var store: StoreOf var body: some View { - NavigationStackStore(self.store.scope(state: \.path, action: \.path)) { - WithViewStore(self.store, observe: \.contacts) { viewStore in - List { - ForEach(viewStore.state) { contact in - NavigationLink(state: ContactDetailFeature.State(contact: contact)) { - HStack { - Text(contact.name) - Spacer() - Button { - viewStore.send(.deleteButtonTapped(id: contact.id)) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + ForEach(store.contacts) { contact in + NavigationLink(state: ContactDetailFeature.State(contact: contact)) { + HStack { + Text(contact.name) + Spacer() + Button { + store.send(.deleteButtonTapped(id: contact.id)) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) } } - .buttonStyle(.borderless) } + .buttonStyle(.borderless) } - .navigationTitle("Contacts") - .toolbar { - ToolbarItem { - Button { - viewStore.send(.addButtonTapped) - } label: { - Image(systemName: "plus") - } + } + .navigationTitle("Contacts") + .toolbar { + ToolbarItem { + Button { + store.send(.addButtonTapped) + } label: { + Image(systemName: "plus") } } } @@ -36,20 +34,12 @@ struct ContactsView: View { ContactDetailView(store: store) } .sheet( - store: self.store.scope( - state: \.$destination.addContact, - action: \.destination.addContact - ) + item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact) ) { addContactStore in NavigationStack { AddContactView(store: addContactStore) } } - .alert( - store: self.store.scope( - state: \.$destination.alert, - action: \.destination.alert - ) - ) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000-previous.swift index f91e0b0316be..9e7c876ed075 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000-previous.swift @@ -2,6 +2,7 @@ import ComposableArchitecture @Reducer struct ContactDetailFeature { + @ObservableState struct State: Equatable { let contact: Contact } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000.swift index d3df56370652..89fe11e82058 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0000.swift @@ -2,8 +2,9 @@ import ComposableArchitecture @Reducer struct ContactDetailFeature { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? let contact: Contact } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0001.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0001.swift index 3542561fb8e5..2f5bbd7b0a16 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0001.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0001.swift @@ -2,8 +2,9 @@ import ComposableArchitecture @Reducer struct ContactDetailFeature { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? let contact: Contact } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0002.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0002.swift index 239ac6092047..0525fc78682f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0002.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0002.swift @@ -2,8 +2,9 @@ import ComposableArchitecture @Reducer struct ContactDetailFeature { + @ObservableState struct State: Equatable { - @PresentationState var alert: AlertState? + @Presents var alert: AlertState? let contact: Contact } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003-previous.swift index 2109d69c7111..6c65434a761a 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003-previous.swift @@ -4,26 +4,22 @@ struct ContactDetailView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - } - .navigationBarTitle(Text(viewStore.contact.name)) + Form { } + .navigationBarTitle(Text(store.contact.name)) } } -struct ContactDetailPreviews: PreviewProvider { - static var previews: some View { - NavigationStack { - ContactDetailView( - store: Store( - initialState: ContactDetailFeature.State( - contact: Contact(id: UUID(), name: "Blob") - ) - ) { - ContactDetailFeature() - } - ) - } +#Preview { + NavigationStack { + ContactDetailView( + store: Store( + initialState: ContactDetailFeature.State( + contact: Contact(id: UUID(), name: "Blob") + ) + ) { + ContactDetailFeature() + } + ) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003.swift index 78161b5e1272..9c06bc0a3121 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0003.swift @@ -4,30 +4,26 @@ struct ContactDetailView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Form { - Button("Delete") { - viewStore.send(.deleteButtonTapped) - } + Form { + Button("Delete") { + store.send(.deleteButtonTapped) } - .navigationBarTitle(Text(viewStore.contact.name)) } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) + .navigationBarTitle(Text(store.contact.name)) + .alert($store.scope(state: \.alert, action: \.alert)) } } -struct ContactDetailPreviews: PreviewProvider { - static var previews: some View { - NavigationStack { - ContactDetailView( - store: Store( - initialState: ContactDetailFeature.State( - contact: Contact(id: UUID(), name: "Blob") - ) - ) { - ContactDetailFeature() - } - ) - } +#Preview { + NavigationStack { + ContactDetailView( + store: Store( + initialState: ContactDetailFeature.State( + contact: Contact(id: UUID(), name: "Blob") + ) + ) { + ContactDetailFeature() + } + ) } } diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004-previous.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004-previous.swift index 2113343520fe..4ccc3e5607e0 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004-previous.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004-previous.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var path = StackState() } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004.swift b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004.swift index 3e1e9df28b7f..c35ee8b169f8 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004.swift +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-03-code-0004.swift @@ -1,8 +1,9 @@ @Reducer struct ContactsFeature { + @ObservableState struct State: Equatable { var contacts: IdentifiedArrayOf = [] - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var path = StackState() } enum Action { diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-NavigationStacks.tutorial b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-NavigationStacks.tutorial index b1cfea3962af..d4f29d3dbdfc 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-NavigationStacks.tutorial +++ b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/02-Navigation/04-NavigationStacks/02-04-NavigationStacks.tutorial @@ -57,9 +57,8 @@ } @Step { - Observe the store so that we can get access to state using - ``ComposableArchitecture/WithViewStore``. There is no real information to show in this - view now other than the contact's name, but more will be added later. + Stub out a basic form. There is no real information to show in this view now other than the + contact's name, but more will be added later. @Code(name: "ContactDetailFeature.swift", file: 02-04-01-code-0006.swift) } @@ -122,30 +121,34 @@ @Step { Go to the `ContactsView` that holds the view for the contacts list. Swap out the - `NavigationStack` for a ``ComposableArchitecture/NavigationStackStore``. This is a type - specifically tuned for driving stacks from a ``ComposableArchitecture/Store``. - You hand it a store that is scoped down to ``ComposableArchitecture/StackState`` and - ``ComposableArchitecture/StackAction``, and it handles the rest. + `NavigationStack` for ``SwiftUI/NavigationStack/init(path:root:destination:)``, which is a + special initializer that is specifically tuned for driving stacks from a + ``ComposableArchitecture/Store``. + You hand it a binding to a store that is scoped down to + ``ComposableArchitecture/StackState`` and ``ComposableArchitecture/StackAction``, and it + handles the rest. @Code(name: "ContactsFeature.swift", file: 02-04-02-code-0003.swift, previousFile: 02-04-02-code-0003-previous.swift) } @Step { - ``ComposableArchitecture/NavigationStackStore`` takes two trailing closures. The first is - for the root of the stack, which is our list of contacts. The second is to describe the - destinations that can be navigated to. It is handled a store that is focused on the domain - of just a single element in the stack. + ``SwiftUI/NavigationStack/init(path:root:destination:)`` takes two trailing closures. The + first is for the root of the stack, which is our list of contacts. The second is to describe + the destinations that can be navigated to. It is handled a store that is focused on the + domain of just a single element in the stack. @Code(name: "ContactsFeature.swift", file: 02-04-02-code-0004.swift) } @Step { Wrap the row in the contacts list in a `NavigationLink`, using the special - `NavigationLink(state:)` initializer that ships with this library. We are also applying a - `.borderless` button style so that we can have the delete button in the row too. + ``SwiftUI/NavigationLink/init(state:label:fileID:line:)`` initializer that ships with this + library. We are also applying a `.borderless` button style so that we can have the delete + button in the row too. - > Warning: It is necessary to use the `init(state)` initializer on `NavigationLink`, - > instead of the `init(value:)` that comes with SwiftUI. + > Warning: It is necessary to use the + > ``SwiftUI/NavigationLink/init(state:label:fileID:line:)`` initializer on `NavigationLink` + > instead of the `init(value:)` initializer that comes with SwiftUI. @Code(name: "ContactsFeature.swift", file: 02-04-02-code-0005.swift, previousFile: 02-04-02-code-0005-previous.swift) } diff --git a/Sources/ComposableArchitecture/Effects/Cancellation.swift b/Sources/ComposableArchitecture/Effects/Cancellation.swift index ec61eefc4901..d036d665253c 100644 --- a/Sources/ComposableArchitecture/Effects/Cancellation.swift +++ b/Sources/ComposableArchitecture/Effects/Cancellation.swift @@ -279,11 +279,11 @@ public class CancellablesCollection { at id: ID, path: NavigationIDPath ) -> Bool { - return self.storage[_CancelID(id: id, navigationIDPath: path)] != nil + self.storage[_CancelID(id: id, navigationIDPath: path)] != nil } public var count: Int { - return self.storage.count + self.storage.count } public func removeAll() { diff --git a/Sources/ComposableArchitecture/Internal/EphemeralState.swift b/Sources/ComposableArchitecture/Internal/EphemeralState.swift index 4c70341ef0a5..a7fb53c03911 100644 --- a/Sources/ComposableArchitecture/Internal/EphemeralState.swift +++ b/Sources/ComposableArchitecture/Internal/EphemeralState.swift @@ -8,14 +8,25 @@ public protocol _EphemeralState { static var actionType: Any.Type { get } } -extension AlertState: _EphemeralState { - public static var actionType: Any.Type { Action.self } -} - -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) -extension ConfirmationDialogState: _EphemeralState { - public static var actionType: Any.Type { Action.self } -} +#if swift(>=5.8) + @_documentation(visibility:private) + extension AlertState: _EphemeralState { + public static var actionType: Any.Type { Action.self } + } + @_documentation(visibility:private) + @available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) + extension ConfirmationDialogState: _EphemeralState { + public static var actionType: Any.Type { Action.self } + } +#else + extension AlertState: _EphemeralState { + public static var actionType: Any.Type { Action.self } + } + @available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) + extension ConfirmationDialogState: _EphemeralState { + public static var actionType: Any.Type { Action.self } + } +#endif @usableFromInline func ephemeralType(of state: State) -> (any _EphemeralState.Type)? { diff --git a/Sources/ComposableArchitecture/Internal/Exports.swift b/Sources/ComposableArchitecture/Internal/Exports.swift index ec35aff89be7..31d60c696a2f 100644 --- a/Sources/ComposableArchitecture/Internal/Exports.swift +++ b/Sources/ComposableArchitecture/Internal/Exports.swift @@ -7,6 +7,12 @@ @_exported import IdentifiedCollections @_exported import SwiftUINavigationCore -#if swift(>=5.9) +#if canImport(DependenciesMacros) @_exported import DependenciesMacros #endif +#if canImport(Observation) + @_exported import Observation +#endif +#if canImport(Perception) + @_exported import Perception +#endif diff --git a/Sources/ComposableArchitecture/Internal/Logger.swift b/Sources/ComposableArchitecture/Internal/Logger.swift index 8c3edfa61d24..1af4708fa435 100644 --- a/Sources/ComposableArchitecture/Internal/Logger.swift +++ b/Sources/ComposableArchitecture/Internal/Logger.swift @@ -13,7 +13,7 @@ public final class Logger { public func log(level: OSLogType = .default, _ string: @autoclosure () -> String) { guard self.isEnabled else { return } let string = string() - if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + if isRunningForPreviews { print("\(string)") } else { if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { @@ -34,3 +34,6 @@ public final class Logger { } #endif } + +private var isRunningForPreviews = + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" diff --git a/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift index 67b39f8d09bb..0b255cc3f858 100644 --- a/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift +++ b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift @@ -1,7 +1,16 @@ import Foundation extension Notification.Name { - public static let runtimeWarning = Self("ComposableArchitecture.runtimeWarning") + #if swift(>=5.8) + @_documentation(visibility:private) + @available(*, deprecated, renamed: "_runtimeWarning") + public static let runtimeWarning = Self("ComposableArchitecture.runtimeWarning") + #else + @available(*, deprecated, renamed: "_runtimeWarning") + public static let runtimeWarning = Self("ComposableArchitecture.runtimeWarning") + #endif + /// A notification that is posted when a runtime warning is emitted. + public static let _runtimeWarning = Self("ComposableArchitecture.runtimeWarning") } @_transparent @@ -14,7 +23,7 @@ func runtimeWarn( #if DEBUG let message = message() NotificationCenter.default.post( - name: .runtimeWarning, + name: ._runtimeWarning, object: nil, userInfo: ["message": message] ) diff --git a/Sources/ComposableArchitecture/Macros.swift b/Sources/ComposableArchitecture/Macros.swift index 647ac66f5736..6fb85a176da0 100644 --- a/Sources/ComposableArchitecture/Macros.swift +++ b/Sources/ComposableArchitecture/Macros.swift @@ -1,4 +1,6 @@ #if swift(>=5.9) + import Observation + /// Helps implement the conformance to the ``Reducer`` protocol for a type. /// /// To use this macro you will define a new type, typically a struct, and add inner types for the @@ -41,7 +43,7 @@ /// ```diff /// +@CasePathable /// enum Action { - /// // … + /// // ... /// } /// ``` /// @@ -57,22 +59,22 @@ /// +@CasePathable /// +@dynamicMemberLookup /// enum State { - /// // … + /// // ... /// } /// ``` /// /// This will allow you to use key path syntax for specifying case paths to the `State`'s cases, /// as well as allow you to use dot-chaining syntax for optionally extracting a case from the - /// state. This can be useful when using the view modifiers that come with the library that allow - /// for driving navigation from an enum of options: + /// state. This can be useful when using the operators that come with the library that allow for + /// driving navigation from an enum of options: /// /// ```swift /// .sheet( - /// store: self.store.scope(state: \.$destination.editForm, action: \.destination.editForm) + /// item: $store.scope(state: \.destination?.editForm, action: \.destination.editForm) /// ) /// ``` /// - /// The syntax `state: \.$destination.editForm` is only possible due to both + /// The syntax `state: \.destination?.editForm` is only possible due to both /// `@dynamicMemberLookup` and `@CasePathable` being applied to the `State` enum. /// /// ## Gotchas @@ -130,12 +132,9 @@ /// /// > Error: CasePathsMacros Target 'CasePathsMacros' must be enabled before it can be used. /// > - /// > /// > ComposableArchitectureMacros Target 'ComposableArchitectureMacros' must be enabled /// before it can be used. /// - /// - /// /// You can fix this in one of two ways. You can write a default to the CI machine that allows /// Xcode to skip macro validation: /// @@ -155,4 +154,87 @@ #externalMacro( module: "ComposableArchitectureMacros", type: "ReducerMacro" ) + + /// Defines and implements conformance of the Observable protocol. + @attached(extension, conformances: Observable, ObservableState) + @attached(member, names: named(_$id), named(_$observationRegistrar), named(_$willModify)) + @attached(memberAttribute) + public macro ObservableState() = + #externalMacro(module: "ComposableArchitectureMacros", type: "ObservableStateMacro") + + @attached(accessor, names: named(init), named(get), named(set)) + @attached(peer, names: prefixed(_)) + public macro ObservationStateTracked() = + #externalMacro(module: "ComposableArchitectureMacros", type: "ObservationStateTrackedMacro") + + @attached(accessor, names: named(willSet)) + public macro ObservationStateIgnored() = + #externalMacro(module: "ComposableArchitectureMacros", type: "ObservationStateIgnoredMacro") + + /// Wraps a property with ``PresentationState`` and observes it. + /// + /// Use this macro instead of ``PresentationState`` when you adopt the ``ObservableState()`` + /// macro, which is incompatible with property wrappers like ``PresentationState``. + @attached(accessor, names: named(init), named(get), named(set)) + @attached(peer, names: prefixed(`$`), prefixed(_)) + public macro Presents() = + #externalMacro(module: "ComposableArchitectureMacros", type: "PresentsMacro") + + /// Provides a view with access to a feature's ``ViewAction``s. + /// + /// If you want to restrict what actions can be sent from the view you can use this macro along + /// the ``ViewAction`` protocol. You start by conforming your reducer's `Action` enum to the + /// ``ViewAction`` protocol, and moving view-specific actions to its own inner enum: + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// struct State { /* ... */ } + /// enum Action: ViewAction { + /// case loginResponse(Bool) + /// case view(View) + /// + /// enum View { + /// case loginButtonTapped + /// } + /// } + /// // ... + /// } + /// ``` + /// + /// Then you can apply the ``ViewAction(for:)`` macro to your view by specifying the type of the + /// reducer that powers the view: + /// + /// ```swift + /// @ViewAction(for: Feature.self) + /// struct FeatureView: View { + /// let store: StoreOf + /// // ... + /// } + /// ``` + /// + /// The macro does two things: + /// + /// * It adds a `send` method to the view that you can use instead of `store.send`. This allows + /// you to send view actions more simply, without wrapping the action in `.view(…)`: + /// ```diff + /// Button("Login") { + /// - store.send(.view(.loginButtonTapped)) + /// + send(.loginButtonTapped) + /// } + /// ``` + /// * It creates warning diagnostics if you try sending actions through `store.send` rather than + /// using the `send` method on the view: + /// ```swift + /// Button("Login") { + /// store.send(.view(.loginButtonTapped)) + /// //┬───────── + /// //╰─ ⚠️ Do not use 'store.send' directly when using '@ViewAction' + /// } + /// ``` + @attached(extension, conformances: ViewActionSending) + public macro ViewAction(for: R.Type) = + #externalMacro( + module: "ComposableArchitectureMacros", type: "ViewActionMacro" + ) where R.Action: ViewAction #endif diff --git a/Sources/ComposableArchitecture/Observation/Alert+Observation.swift b/Sources/ComposableArchitecture/Observation/Alert+Observation.swift new file mode 100644 index 000000000000..2f742483c942 --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/Alert+Observation.swift @@ -0,0 +1,75 @@ +import SwiftUI + +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +extension View { + /// Presents an alert when a piece of optional state held in a store becomes non-`nil`. + public func alert(_ item: Binding, Action>?>) -> some View { + let store = item.wrappedValue + let alertState = store?.withState { $0 } + return self.alert( + (alertState?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + presenting: alertState, + actions: { alertState in + ForEach(alertState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case let .send(action): + if let action = action { + store?.send(action) + } + case let .animatedSend(action, animation): + if let action = action { + store?.send(action, animation: animation) + } + } + } label: { + Text(button.label) + } + } + }, + message: { + $0.message.map(Text.init) + } + ) + } +} + +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +extension View { + /// Presents an alert when a piece of optional state held in a store becomes non-`nil`. + public func confirmationDialog( + _ item: Binding, Action>?> + ) -> some View { + let store = item.wrappedValue + let confirmationDialogState = store?.withState { $0 } + return self.confirmationDialog( + (confirmationDialogState?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + titleVisibility: (confirmationDialogState?.titleVisibility).map(Visibility.init) + ?? .automatic, + presenting: confirmationDialogState, + actions: { confirmationDialogState in + ForEach(confirmationDialogState.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + switch button.action.type { + case let .send(action): + if let action = action { + store?.send(action) + } + case let .animatedSend(action, animation): + if let action = action { + store?.send(action, animation: animation) + } + } + } label: { + Text(button.label) + } + } + }, + message: { + $0.message.map(Text.init) + } + ) + } +} diff --git a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift new file mode 100644 index 000000000000..7a7871f4b7b3 --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift @@ -0,0 +1,273 @@ +#if canImport(Perception) + import SwiftUI + + @dynamicMemberLookup + public struct _StoreBinding { + fileprivate let wrappedValue: Store + + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding { + _StoreBinding( + wrappedValue: self.wrappedValue.scope(state: keyPath, action: \.self) + ) + } + + /// Creates a binding to the value by sending new values through the given action. + /// + /// - Parameter action: An action for the binding to send values through. + /// - Returns: A binding. + public func sending(_ action: CaseKeyPath) -> Binding { + Binding( + get: { self.wrappedValue.withState { $0 } }, + set: { newValue, transaction in + BindingLocal.$isActive.withValue(true) { + _ = self.wrappedValue.send(action(newValue), transaction: transaction) + } + } + ) + } + } + + extension Binding { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding + where Value == Store { + _StoreBinding(wrappedValue: self.wrappedValue.scope(state: keyPath, action: \.self)) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SwiftUI.Bindable { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding + where Value == Store { + _StoreBinding(wrappedValue: self.wrappedValue.scope(state: keyPath, action: \.self)) + } + } + + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + extension Perception.Bindable { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> _StoreBinding + where Value == Store { + _StoreBinding(wrappedValue: self.wrappedValue.scope(state: keyPath, action: \.self)) + } + } + + extension BindingAction { + public static func set( + _ keyPath: WritableKeyPath, + _ value: Value + ) -> Self where Root: ObservableState { + .init( + keyPath: keyPath, + set: { $0[keyPath: keyPath] = value }, + value: AnySendable(value), + valueIsEqualTo: { ($0 as? AnySendable)?.base as? Value == value } + ) + } + + public static func ~= ( + keyPath: WritableKeyPath, + bindingAction: Self + ) -> Bool where Root: ObservableState { + keyPath == bindingAction.keyPath + } + } + + extension BindableAction where State: ObservableState { + public static func set( + _ keyPath: WritableKeyPath, + _ value: Value + ) -> Self { + self.binding(.set(keyPath, value)) + } + } + + extension Store where State: ObservableState, Action: BindableAction, Action.State == State { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Value { + get { self.state[keyPath: keyPath] } + set { self.send(.binding(.set(keyPath, newValue))) } + } + } + + extension Store + where + State: Equatable, + State: ObservableState, + Action: BindableAction, + Action.State == State + { + @_disfavoredOverload + public var state: State { + get { self.state } + set { self.send(.binding(.set(\.self, newValue))) } + } + } + + extension Store + where + State: ObservableState, + Action: ViewAction, + Action.ViewAction: BindableAction, + Action.ViewAction.State == State + { + @_disfavoredOverload + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Value { + get { self.state[keyPath: keyPath] } + set { self.send(.view(.binding(.set(keyPath, newValue)))) } + } + } + + extension Store + where + State: Equatable, + State: ObservableState, + Action: ViewAction, + Action.ViewAction: BindableAction, + Action.ViewAction.State == State + { + @_disfavoredOverload + public var state: State { + get { self.state } + set { self.send(.view(.binding(.set(\.self, newValue)))) } + } + } + + // NB: These overloads ensure runtime warnings aren't emitted for errant SwiftUI bindings. + #if DEBUG + extension Binding { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where Value == Store, Action.State == State { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { newValue, transaction in + BindingLocal.$isActive.withValue(true) { + _ = self.wrappedValue.send( + .binding(.set(keyPath, newValue)), transaction: transaction + ) + } + } + ) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where + Value == Store, + Action.ViewAction: BindableAction, + Action.ViewAction.State == State + { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { newValue, transaction in + BindingLocal.$isActive.withValue(true) { + _ = self.wrappedValue.send( + .view(.binding(.set(keyPath, newValue))), transaction: transaction + ) + } + } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SwiftUI.Bindable { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where Value == Store, Action.State == State { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { newValue, transaction in + BindingLocal.$isActive.withValue(true) { + _ = self.wrappedValue.send( + .binding(.set(keyPath, newValue)), transaction: transaction + ) + } + } + ) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where + Value == Store, + Action.ViewAction: BindableAction, + Action.ViewAction.State == State + { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { newValue, transaction in + BindingLocal.$isActive.withValue(true) { + _ = self.wrappedValue.send( + .view(.binding(.set(keyPath, newValue))), transaction: transaction + ) + } + } + ) + } + } + + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + extension Perception.Bindable { + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where Value == Store, Action.State == State { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { newValue, transaction in + BindingLocal.$isActive.withValue(true) { + _ = self.wrappedValue.send( + .binding(.set(keyPath, newValue)), transaction: transaction + ) + } + } + ) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Binding + where + Value == Store, + Action.ViewAction: BindableAction, + Action.ViewAction.State == State + { + Binding( + get: { self.wrappedValue.state[keyPath: keyPath] }, + set: { newValue, transaction in + BindingLocal.$isActive.withValue(true) { + _ = self.wrappedValue.send( + .view(.binding(.set(keyPath, newValue))), transaction: transaction + ) + } + } + ) + } + } + #endif +#endif diff --git a/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift b/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift new file mode 100644 index 000000000000..d637b88604cc --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift @@ -0,0 +1,102 @@ +#if canImport(Perception) + import OrderedCollections + import SwiftUI + + extension Store where State: ObservableState { + /// Scopes the store of an identified collection to a collection of stores. + /// + /// This operator is most often used with SwiftUI's `ForEach` view. For example, suppose you have + /// a feature that contains an `IdentifiedArray` of child features like so: + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// var rows: IdentifiedArrayOf = [] + /// } + /// enum Action { + /// case rows(IdentifiedActionOf) + /// } + /// var body: some ReducerOf { + /// Reduce { state, action in + /// // Core feature logic + /// } + /// .forEach(\.rows, action: \.rows) { + /// Child() + /// } + /// } + /// } + /// ``` + /// + /// Then in the view you can use this operator, with `ForEach`, to derive a store for + /// each element in the identified collection: + /// + /// ```swift + /// struct FeatureView: View { + /// let store: StoreOf + /// + /// var body: some View { + /// List { + /// ForEach(store.scope(state: \.rows, action: \.rows) { store in + /// ChildView(store: store) + /// } + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: A key path to an identified array of child state. + /// - action: A case key path to an identified child action. + /// - Returns: An collection of stores of child state. + @_disfavoredOverload + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> some RandomAccessCollection> { + #if DEBUG + if !self.canCacheChildren { + runtimeWarn( + """ + Scoping from uncached \(self) is not compatible with observation. Ensure that all \ + parent store scoping operations take key paths and case key paths instead of transform \ + functions, which have been deprecated. + """ + ) + } + #endif + return _StoreCollection(self.scope(state: state, action: action)) + } + } + + public struct _StoreCollection: RandomAccessCollection { + private let store: Store, IdentifiedAction> + private let data: IdentifiedArray + + fileprivate init(_ store: Store, IdentifiedAction>) { + self.store = store + self.data = store.withState { $0 } + } + + public var startIndex: Int { self.data.startIndex } + public var endIndex: Int { self.data.endIndex } + public subscript(position: Int) -> Store { + guard self.data.indices.contains(position) + else { + return Store() + } + let id = self.data.ids[position] + var element = self.data[position] + return self.store.scope( + id: self.store.id(state: \.[id:id]!, action: \.[id:id]), + state: ToState { + element = $0[id: id] ?? element + return element + }, + action: { .element(id: id, action: $0) }, + isInvalid: { !$0.ids.contains(id) } + ) + } + } +#endif diff --git a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift new file mode 100644 index 000000000000..74ecd4337f86 --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift @@ -0,0 +1,384 @@ +import SwiftUI + +#if canImport(Perception) + extension Binding { + /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. + /// + /// This operator is most used in conjunction with `NavigationStack`, and in particular + /// the initializer ``SwiftUI/NavigationStack/init(path:root:destination:)`` that ships with + /// this library. + /// + /// For example, suppose you have a feature that holds onto ``StackState`` in its state in order + /// to represent all the screens that can be pushed onto a navigation stack: + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// var path: StackState = [] + /// } + /// enum Action { + /// case path(StackActionOf) + /// } + /// var body: some ReducerOf { + /// Reduce { state, action in + /// // Core feature logic + /// } + /// .forEach(\.rows, action: \.rows) { + /// Child() + /// } + /// } + /// @Reducer + /// struct Path { + /// // ... + /// } + /// } + /// ``` + /// + /// Then in the view you can use this operator, with + /// `NavigationStack` ``SwiftUI/NavigationStack/init(path:root:destination:)``, to + /// derive a store for each element in the stack: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + /// // Root view + /// } destination: { + /// // Destinations + /// } + /// } + /// } + /// ``` + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> Binding, StackAction>> + where Value == Store { + #if DEBUG + let isInViewBody = _PerceptionLocals.isInPerceptionTracking + #endif + return Binding, StackAction>>( + get: { + #if DEBUG + _PerceptionLocals.$isInPerceptionTracking.withValue(isInViewBody) { + self.wrappedValue.scope(state: state, action: action) + } + #else + self.wrappedValue.scope(state: state, action: action) + #endif + }, + set: { _ in } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SwiftUI.Bindable { + /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. + /// + /// See ``SwiftUI/Binding/scope(state:action:)-4mj4d`` defined on `Binding` for more + /// information. + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> Binding, StackAction>> + where Value == Store { + Binding, StackAction>>( + get: { self.wrappedValue.scope(state: state, action: action) }, + set: { _ in } + ) + } + } + + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + extension Perception.Bindable { + /// Derives a binding to a store focused on ``StackState`` and ``StackAction``. + /// + /// See ``SwiftUI/Binding/scope(state:action:)-4mj4d`` defined on `Binding` for more + /// information. + public func scope( + state: KeyPath>, + action: CaseKeyPath> + ) -> Binding, StackAction>> + where Value == Store { + Binding, StackAction>>( + get: { self.wrappedValue.scope(state: state, action: action) }, + set: { _ in } + ) + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension NavigationStack { + /// Drives a navigation stack with a store. + /// + /// See the dedicated article on for more information on the library's + /// navigation tools, and in particular see for information on using + /// this view. + public init( + path: Binding, StackAction>>, + root: () -> R, + @ViewBuilder destination: @escaping (Store) -> Destination + ) + where + Data == StackState.PathView, + Root == ModifiedContent> + { + self.init( + path: Binding( + get: { path.wrappedValue.currentState.path }, + set: { pathView, transaction in + if pathView.count > path.wrappedValue.withState({ $0 }).count, + let component = pathView.last + { + path.wrappedValue.send( + .push(id: component.id, state: component.element), + transaction: transaction + ) + } else { + path.wrappedValue.send( + .popFrom(id: path.wrappedValue.withState { $0 }.ids[pathView.count]), + transaction: transaction + ) + } + } + ) + ) { + root() + .modifier( + _NavigationDestinationViewModifier(store: path.wrappedValue, destination: destination) + ) + } + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public struct _NavigationDestinationViewModifier< + State: ObservableState, Action, Destination: View + >: + ViewModifier + { + @SwiftUI.State var store: Store, StackAction> + fileprivate let destination: (Store) -> Destination + + public func body(content: Content) -> some View { + content + .environment(\.navigationDestinationType, State.self) + .navigationDestination(for: StackState.Component.self) { component in + var element = component.element + self + .destination( + self.store.scope( + id: self.store.id(state: \.[id:component.id], action: \.[id:component.id]), + state: ToState { + element = $0[id: component.id] ?? element + return element + }, + action: { .element(id: component.id, action: $0) }, + isInvalid: { !$0.ids.contains(component.id) } + ) + ) + .environment(\.navigationDestinationType, State.self) + } + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension NavigationLink where Destination == Never { + /// Creates a navigation link that presents the view corresponding to an element of + /// ``StackState``. + /// + /// When someone activates the navigation link that this initializer creates, SwiftUI looks for + /// a parent `NavigationStack` view with a store of ``StackState`` containing elements that + /// matches the type of this initializer's `state` input. + /// + /// See SwiftUI's documentation for `NavigationLink.init(value:label:)` for more. + /// + /// - Parameters: + /// - state: An optional value to present. When the user selects the link, SwiftUI stores a + /// copy of the value. Pass a `nil` value to disable the link. + /// - label: A label that describes the view that this link presents. + public init( + state: P?, + @ViewBuilder label: () -> L, + fileID: StaticString = #fileID, + line: UInt = #line + ) + where Label == _NavigationLinkStoreContent { + @Dependency(\.stackElementID) var stackElementID + self.init(value: state.map { StackState.Component(id: stackElementID(), element: $0) }) { + _NavigationLinkStoreContent( + state: state, label: { label() }, fileID: fileID, line: line + ) + } + } + + /// Creates a navigation link that presents the view corresponding to an element of + /// ``StackState``, with a text label that the link generates from a localized string key. + /// + /// When someone activates the navigation link that this initializer creates, SwiftUI looks for + /// a parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements + /// that matches the type of this initializer's `state` input. + /// + /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. + /// + /// - Parameters: + /// - titleKey: A localized string that describes the view that this link + /// presents. + /// - state: An optional value to present. When the user selects the link, SwiftUI stores a + /// copy of the value. Pass a `nil` value to disable the link. + public init

( + _ titleKey: LocalizedStringKey, state: P?, fileID: StaticString = #fileID, line: UInt = #line + ) + where Label == _NavigationLinkStoreContent { + self.init(state: state, label: { Text(titleKey) }, fileID: fileID, line: line) + } + + /// Creates a navigation link that presents the view corresponding to an element of + /// ``StackState``, with a text label that the link generates from a title string. + /// + /// When someone activates the navigation link that this initializer creates, SwiftUI looks for + /// a parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements + /// that matches the type of this initializer's `state` input. + /// + /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. + /// + /// - Parameters: + /// - title: A string that describes the view that this link presents. + /// - state: An optional value to present. When the user selects the link, SwiftUI stores a + /// copy of the value. Pass a `nil` value to disable the link. + @_disfavoredOverload + public init( + _ title: S, state: P?, fileID: StaticString = #fileID, line: UInt = #line + ) + where Label == _NavigationLinkStoreContent { + self.init(state: state, label: { Text(title) }, fileID: fileID, line: line) + } + } + + public struct _NavigationLinkStoreContent: View { + let state: State? + @ViewBuilder let label: Label + let fileID: StaticString + let line: UInt + @Environment(\.navigationDestinationType) var navigationDestinationType + + public var body: some View { + #if DEBUG + self.label.onAppear { + if self.navigationDestinationType != State.self { + let elementType = + self.navigationDestinationType.map(typeName) + ?? """ + (None found in view hierarchy. Is this link inside a store-powered \ + 'NavigationStack'?) + """ + runtimeWarn( + """ + A navigation link at "\(self.fileID):\(self.line)" is unpresentable. … + + NavigationStack state element type: + \(elementType) + NavigationLink state type: + \(typeName(State.self)) + NavigationLink state value: + \(String(customDumping: self.state).indent(by: 2)) + """ + ) + } + } + #else + self.label + #endif + } + } +#endif + +extension StackState { + var path: PathView { + _read { yield PathView(base: self) } + _modify { + var path = PathView(base: self) + yield &path + self = path.base + } + set { self = newValue.base } + } + + public struct Component: Hashable { + let id: StackElementID + var element: Element + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + } + + public struct PathView: MutableCollection, RandomAccessCollection, + RangeReplaceableCollection + { + var base: StackState + + public var startIndex: Int { self.base.startIndex } + public var endIndex: Int { self.base.endIndex } + public func index(after i: Int) -> Int { self.base.index(after: i) } + public func index(before i: Int) -> Int { self.base.index(before: i) } + + public subscript(position: Int) -> Component { + _read { + yield Component(id: self.base.ids[position], element: self.base[position]) + } + _modify { + let id = self.base.ids[position] + var component = Component(id: id, element: self.base[position]) + yield &component + self.base[id: id] = component.element + } + set { + self.base[id: newValue.id] = newValue.element + } + } + + init(base: StackState) { + self.base = base + } + + public init() { + self.init(base: StackState()) + } + + public mutating func replaceSubrange( + _ subrange: Range, with newElements: C + ) where C.Element == Component { + for id in self.base.ids[subrange] { + self.base[id: id] = nil + } + for component in newElements.reversed() { + self.base._dictionary + .updateValue(component.element, forKey: component.id, insertingAt: subrange.lowerBound) + } + } + } +} + +private struct NavigationDestinationTypeKey: EnvironmentKey { + static var defaultValue: Any.Type? { nil } +} + +extension EnvironmentValues { + var navigationDestinationType: Any.Type? { + get { self[NavigationDestinationTypeKey.self] } + set { self[NavigationDestinationTypeKey.self] = newValue } + } +} diff --git a/Sources/ComposableArchitecture/Observation/ObservableState.swift b/Sources/ComposableArchitecture/Observation/ObservableState.swift new file mode 100644 index 000000000000..d0fcd4009b47 --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/ObservableState.swift @@ -0,0 +1,180 @@ +#if canImport(Perception) + import Foundation + + /// A type that emits notifications to observers when underlying data changes. + /// + /// Conforming to this protocol signals to other APIs that the value type supports observation. + /// However, applying the ``ObservableState`` protocol by itself to a type doesn’t add observation + /// functionality to the type. Instead, always use the ``ObservableState()`` macro when adding + /// observation support to a type. + public protocol ObservableState: Perceptible { + var _$id: ObservableStateID { get } + mutating func _$willModify() + } + + /// A unique identifier for a observed value. + public struct ObservableStateID: Equatable, Hashable, Sendable { + @usableFromInline + var location: UUID { + get { self.storage.location } + set { + if isKnownUniquelyReferenced(&self.storage) { + self.storage.location = newValue + } else { + self.storage = Storage(location: newValue, tag: self.tag) + } + } + } + + @usableFromInline + var tag: Int? { + self.storage.tag + } + + private var storage: Storage + + @usableFromInline + init(location: UUID, tag: Int? = nil) { + self.storage = Storage(location: location, tag: tag) + } + + public init() { + self.init(location: UUID()) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.storage === rhs.storage + || lhs.storage.location == rhs.storage.location + && lhs.storage.tag == rhs.storage.tag + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.location) + hasher.combine(self.tag) + } + + @inlinable + public static func _$id(for value: T) -> Self { + (value as? any ObservableState)?._$id ?? ._$inert + } + + @inlinable + public static func _$id(for value: some ObservableState) -> Self { + value._$id + } + + public static let _$inert = Self() + + @inlinable + public func _$tag(_ tag: Int?) -> Self { + Self(location: self.location, tag: tag) + } + + @inlinable + public mutating func _$willModify() { + self.location = UUID() + } + + private final class Storage: @unchecked Sendable { + fileprivate var location: UUID + fileprivate let tag: Int? + + init(location: UUID = UUID(), tag: Int? = nil) { + self.location = location + self.tag = tag + } + } + } + + @inlinable + public func _$isIdentityEqual( + _ lhs: T, _ rhs: T + ) -> Bool { + lhs._$id == rhs._$id + } + + @inlinable + public func _$isIdentityEqual( + _ lhs: IdentifiedArray, + _ rhs: IdentifiedArray + ) -> Bool { + areOrderedSetsDuplicates(lhs.ids, rhs.ids) + } + + @inlinable + public func _$isIdentityEqual( + _ lhs: PresentationState, + _ rhs: PresentationState + ) -> Bool { + lhs.wrappedValue?._$id == rhs.wrappedValue?._$id + } + + @inlinable + public func _$isIdentityEqual( + _ lhs: StackState, + _ rhs: StackState + ) -> Bool { + areOrderedSetsDuplicates(lhs.ids, rhs.ids) + } + + @inlinable + public func _$isIdentityEqual( + _ lhs: C, + _ rhs: C + ) -> Bool + where C.Element: ObservableState { + lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { $0._$id == $1._$id } + } + + // NB: This is a fast path so that String is not checked as a collection. + @inlinable + public func _$isIdentityEqual(_ lhs: String, _ rhs: String) -> Bool { + false + } + + @inlinable + public func _$isIdentityEqual(_ lhs: T, _ rhs: T) -> Bool { + guard !_isPOD(T.self) else { return false } + + func openCollection(_ lhs: C, _ rhs: Any) -> Bool { + guard C.Element.self is ObservableState.Type else { + return false + } + + func openIdentifiable(_: Element.Type) -> Bool? { + guard + let lhs = lhs as? IdentifiedArrayOf, + let rhs = rhs as? IdentifiedArrayOf + else { + return nil + } + return areOrderedSetsDuplicates(lhs.ids, rhs.ids) + } + + if let identifiable = C.Element.self as? any Identifiable.Type, + let result = openIdentifiable(identifiable) + { + return result + } else if let rhs = rhs as? C { + return lhs.count == rhs.count && zip(lhs, rhs).allSatisfy(_$isIdentityEqual) + } else { + return false + } + } + + if let lhs = lhs as? any ObservableState, let rhs = rhs as? any ObservableState { + return lhs._$id == rhs._$id + } else if let lhs = lhs as? any Collection { + return openCollection(lhs, rhs) + } else { + return false + } + } + + @inlinable + public func _$willModify(_: inout T) {} + @inlinable + public func _$willModify(_ value: inout T) { + value._$willModify() + } +#endif diff --git a/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift b/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift new file mode 100644 index 000000000000..e3cd215073cb --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift @@ -0,0 +1,176 @@ +#if canImport(Perception) + /// Provides storage for tracking and access to data changes. + public struct ObservationStateRegistrar: Sendable { + public private(set) var id = ObservableStateID() + @usableFromInline + let registrar = PerceptionRegistrar() + public init() {} + public mutating func _$willModify() { self.id._$willModify() } + } + + extension ObservationStateRegistrar: Equatable, Hashable, Codable { + public static func == (_: Self, _: Self) -> Bool { true } + public func hash(into hasher: inout Hasher) {} + public init(from decoder: Decoder) throws { self.init() } + public func encode(to encoder: Encoder) throws {} + } + + #if canImport(Observation) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension ObservationStateRegistrar { + /// Registers access to a specific property for observation. + /// + /// - Parameters: + /// - subject: An instance of an observable type. + /// - keyPath: The key path of an observed property. + @inlinable + public func access( + _ subject: Subject, + keyPath: KeyPath + ) { + self.registrar.access(subject, keyPath: keyPath) + } + + /// Mutates a value to a new value, and decided to notify observers based on the identity of + /// the value. + /// + /// - Parameters: + /// - subject: An instance of an observable type. + /// - keyPath: The key path of an observed property. + /// - value: The value being mutated. + /// - newValue: The new value to mutate with. + /// - isIdentityEqual: A comparison function that determines whether two values have the + /// same identity or not. + @inlinable + public func mutate( + _ subject: Subject, + keyPath: KeyPath, + _ value: inout Value, + _ newValue: Value, + _ isIdentityEqual: (Value, Value) -> Bool + ) { + if isIdentityEqual(value, newValue) { + value = newValue + } else { + self.registrar.withMutation(of: subject, keyPath: keyPath) { + value = newValue + } + } + } + + /// A no-op for non-observable values. + /// + /// See ``willModify(_:keyPath:_:)-29op6`` info on what this method does when used with + /// observable values. + @inlinable + public func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + member + } + + /// A property observation called before setting the value of the subject. + /// + /// - Parameters: + /// - subject: An instance of an observable type.` + /// - keyPath: The key path of an observed property. + /// - member: The value in the subject that will be set. + @inlinable + public func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + member._$willModify() + return member + } + + /// A property observation called after setting the value of the subject. + /// + /// If the identity of the value changed between ``willModify(_:keyPath:_:)-29op6`` and + /// ``didModify(_:keyPath:_:_:_:)-34nhq``, observers are notified. + @inlinable + public func didModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member, + _ oldValue: Member, + _ isIdentityEqual: (Member, Member) -> Bool + ) { + if !isIdentityEqual(oldValue, member) { + let newValue = member + member = oldValue + self.mutate(subject, keyPath: keyPath, &member, newValue, isIdentityEqual) + } + } + } + #endif + + extension ObservationStateRegistrar { + @_disfavoredOverload + @inlinable + public func access( + _ subject: Subject, + keyPath: KeyPath + ) { + self.registrar.access(subject, keyPath: keyPath) + } + + @_disfavoredOverload + @inlinable + public func mutate( + _ subject: Subject, + keyPath: KeyPath, + _ value: inout Value, + _ newValue: Value, + _ isIdentityEqual: (Value, Value) -> Bool + ) { + if isIdentityEqual(value, newValue) { + value = newValue + } else { + self.registrar.withMutation(of: subject, keyPath: keyPath) { + value = newValue + } + } + } + + @_disfavoredOverload + @inlinable + public func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + return member + } + + @_disfavoredOverload + @inlinable + public func willModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member + ) -> Member { + member._$willModify() + return member + } + + @_disfavoredOverload + @inlinable + public func didModify( + _ subject: Subject, + keyPath: KeyPath, + _ member: inout Member, + _ oldValue: Member, + _ isIdentityEqual: (Member, Member) -> Bool + ) { + if !isIdentityEqual(oldValue, member) { + let newValue = member + member = oldValue + self.mutate(subject, keyPath: keyPath, &member, newValue, isIdentityEqual) + } + } + } +#endif diff --git a/Sources/ComposableArchitecture/Observation/Store+Observation.swift b/Sources/ComposableArchitecture/Observation/Store+Observation.swift new file mode 100644 index 000000000000..45088e21588f --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/Store+Observation.swift @@ -0,0 +1,312 @@ +#if canImport(Perception) + import SwiftUI + + #if canImport(Observation) + import Observation + #endif + + extension Store: Perceptible {} + + #if canImport(Observation) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension Store: Observable {} + #endif + + extension Store where State: ObservableState { + /// Direct access to state in the store when `State` conforms to ``ObservableState``. + public var state: State { + self._$observationRegistrar.access(self, keyPath: \.currentState) + return self.currentState + } + + public subscript(dynamicMember keyPath: KeyPath) -> Value { + self.state[keyPath: keyPath] + } + } + + extension Store: Equatable { + public static func == (lhs: Store, rhs: Store) -> Bool { + lhs === rhs + } + } + + extension Store: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + } + + extension Store: Identifiable {} + + extension Store where State: ObservableState { + /// Scopes the store to optional child state and actions. + /// + /// If your feature holds onto a child feature as an optional: + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(Child.Action) + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// …then you can use this `scope` operator in order to transform a store of your feature into + /// a non-optional store of the child domain: + /// + /// ```swift + /// if let childStore = store.scope(state: \.child, action: \.child) { + /// ChildView(store: childStore) + /// } + /// ``` + /// + /// > Important: This operation should only be used from within a SwiftUI view or within + /// > `withPerceptionTracking` in order for changes of the optional state to be properly + /// > observed. + /// + /// - Parameters: + /// - state: A key path to optional child state. + /// - action: A case key path to child actions. + /// - Returns: An optional store of non-optional child state and actions. + public func scope( + state: KeyPath, + action: CaseKeyPath + ) -> Store? { + #if DEBUG + if !self.canCacheChildren { + runtimeWarn( + """ + Scoping from uncached \(self) is not compatible with observation. Ensure that all \ + parent store scoping operations take key paths and case key paths instead of transform \ + functions, which have been deprecated. + """ + ) + } + #endif + guard var childState = self.state[keyPath: state] + else { return nil } + return self.scope( + id: self.id(state: state.appending(path: \.!), action: action), + state: ToState { + childState = $0[keyPath: state] ?? childState + return childState + }, + action: { action($0) }, + isInvalid: { $0[keyPath: state] == nil } + ) + } + } + + extension Binding { + /// Scopes the binding of a store to a binding of an optional presentation store. + /// + /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation + /// view modifiers, such as `sheet(item:)`, popover(item:)`, etc. + /// + /// + /// For example, suppose your feature can present a child feature in a sheet. Then your feature's + /// domain would hold onto the child's domain using the library's presentation tools (see + /// for more information on these tools): + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// @Presents var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(PresentationActionOf) + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` + /// view modifier: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// // ... + /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in + /// ChildView(store: store) + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: A key path to optional child state. + /// - action: A case key path to presentation child actions. + /// - Returns: A binding of an optional child store. + public func scope( + state: KeyPath, + action: CaseKeyPath> + ) -> Binding?> + where Value == Store { + #if DEBUG + let isInViewBody = _PerceptionLocals.isInPerceptionTracking + #endif + return Binding?>( + get: { + #if DEBUG + _PerceptionLocals.$isInPerceptionTracking.withValue(isInViewBody) { + self.wrappedValue.scope(state: state, action: action.appending(path: \.presented)) + } + #else + self.wrappedValue.scope(state: state, action: action.appending(path: \.presented)) + #endif + }, + set: { + if $0 == nil, self.wrappedValue.state[keyPath: state] != nil { + self.wrappedValue.send(action(.dismiss), transaction: $1) + } + } + ) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SwiftUI.Bindable { + /// Scopes the binding of a store to a binding of an optional presentation store. + /// + /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation + /// view modifiers, such as `sheet(item:)`, popover(item:)`, etc. + /// + /// + /// For example, suppose your feature can present a child feature in a sheet. Then your + /// feature's domain would hold onto the child's domain using the library's presentation tools + /// (see for more information on these tools): + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// @Presents var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(PresentationActionOf) + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` + /// view modifier: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// // ... + /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in + /// ChildView(store: store) + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: A key path to optional child state. + /// - action: A case key path to presentation child actions. + /// - Returns: A binding of an optional child store. + public func scope( + state: KeyPath, + action: CaseKeyPath> + ) -> Binding?> + where Value == Store { + Binding?>( + get: { self.wrappedValue.scope(state: state, action: action.appending(path: \.presented)) }, + set: { + if $0 == nil, self.wrappedValue.currentState[keyPath: state] != nil { + self.wrappedValue.send(action(.dismiss), transaction: $1) + } + } + ) + } + } + + @available(iOS, introduced: 13, obsoleted: 17) + @available(macOS, introduced: 10.15, obsoleted: 14) + @available(tvOS, introduced: 13, obsoleted: 17) + @available(watchOS, introduced: 6, obsoleted: 10) + extension Perception.Bindable { + /// Scopes the binding of a store to a binding of an optional presentation store. + /// + /// Use this operator to derive a binding that can be handed to SwiftUI's various navigation + /// view modifiers, such as `sheet(item:)`, popover(item:)`, etc. + /// + /// + /// For example, suppose your feature can present a child feature in a sheet. Then your + /// feature's domain would hold onto the child's domain using the library's presentation tools + /// (see for more information on these tools): + /// + /// ```swift + /// @Reducer + /// struct Feature { + /// @ObservableState + /// struct State { + /// @Presents var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(PresentationActionOf) + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// Then you can derive a binding to the child domain that can be handed to the `sheet(item:)` + /// view modifier: + /// + /// ```swift + /// struct FeatureView: View { + /// @Bindable var store: StoreOf + /// + /// var body: some View { + /// // ... + /// .sheet(item: $store.scope(state: \.child, action: \.child)) { store in + /// ChildView(store: store) + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - state: A key path to optional child state. + /// - action: A case key path to presentation child actions. + /// - Returns: A binding of an optional child store. + public func scope( + state: KeyPath, + action: CaseKeyPath> + ) -> Binding?> + where Value == Store { + Binding?>( + get: { self.wrappedValue.scope(state: state, action: action.appending(path: \.presented)) }, + set: { + if $0 == nil, self.wrappedValue.currentState[keyPath: state] != nil { + self.wrappedValue.send(action(.dismiss), transaction: $1) + } + } + ) + } + } +#endif diff --git a/Sources/ComposableArchitecture/Observation/ViewAction.swift b/Sources/ComposableArchitecture/Observation/ViewAction.swift new file mode 100644 index 000000000000..69404ef5dd64 --- /dev/null +++ b/Sources/ComposableArchitecture/Observation/ViewAction.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Defines the actions that can be sent from a view. +/// +/// See the ``ViewAction(for:)`` macro for more information on how to use this. +public protocol ViewAction { + associatedtype ViewAction + static func view(_ action: ViewAction) -> Self +} + +/// A type that represents a view with a ``Store`` that can send ``ViewAction``s. +public protocol ViewActionSending { + associatedtype StoreState + associatedtype StoreAction: ViewAction + @MainActor(unsafe) var store: Store { get } +} + +extension ViewActionSending { + /// Send a view action to the store. + @discardableResult + public func send(_ action: StoreAction.ViewAction) -> StoreTask { + self.store.send(.view(action)) + } + + /// Send a view action to the store with animation. + @discardableResult + public func send(_ action: StoreAction.ViewAction, animation: Animation?) -> StoreTask { + self.store.send(.view(action), animation: animation) + } + + /// Send a view action to the store with a transaction. + @discardableResult + public func send(_ action: StoreAction.ViewAction, transaction: Transaction) -> StoreTask { + self.store.send(.view(action), transaction: transaction) + } +} diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift index 30016c5700da..7700dcc93796 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/PresentationReducer.swift @@ -66,9 +66,10 @@ public struct PresentationState { get { self.storage.state } set { if !isKnownUniquelyReferenced(&self.storage) { - self.storage = Storage(state: self.storage.state) + self.storage = Storage(state: newValue) + } else { + self.storage.state = newValue } - self.storage.state = newValue } } diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift index 1ca434d0bdf4..b6a019456b9b 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift @@ -486,7 +486,7 @@ public struct _StackReducer: Reducer { • This action was sent to the store while its state contained no element at this ID. To \ fix this make sure that actions for this reducer can only be sent from a view store when \ its state contains an element at this id. In SwiftUI applications, use \ - "NavigationStackStore". + "NavigationStack.init(path:)" with a binding to a store. """ ) destinationEffects = .none diff --git a/Sources/ComposableArchitecture/RootStore.swift b/Sources/ComposableArchitecture/RootStore.swift index c16c1b72e80f..3b5a17d76b26 100644 --- a/Sources/ComposableArchitecture/RootStore.swift +++ b/Sources/ComposableArchitecture/RootStore.swift @@ -130,10 +130,7 @@ public final class RootStore { } #endif if let task = continuation.yield({ - self.send( - effectAction - //, originatingFrom: action - ) + self.send(effectAction, originatingFrom: action) }) { tasks.wrappedValue.append(task) } @@ -162,7 +159,13 @@ public final class RootStore { } } } - return open(reducer: self.reducer) + #if canImport(Perception) + return _withoutPerceptionChecking { + open(reducer: self.reducer) + } + #else + return open(reducer: self.reducer) + #endif } } diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 13b5cb77e52e..72312426b4e1 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -65,17 +65,17 @@ import SwiftUI /// var body: some View { /// TabView { /// ActivityView( -/// store: self.store.scope(state: \.activity, action: \.activity) +/// store: store.scope(state: \.activity, action: \.activity) /// ) /// .tabItem { Text("Activity") } /// /// SearchView( -/// store: self.store.scope(state: \.search, action: \.search) +/// store: store.scope(state: \.search, action: \.search) /// ) /// .tabItem { Text("Search") } /// /// ProfileView( -/// store: self.store.scope(state: \.profile, action: \.profile) +/// store: store.scope(state: \.profile, action: \.profile) /// ) /// .tabItem { Text("Profile") } /// } @@ -131,8 +131,9 @@ import SwiftUI /// to run only on the main thread, and so a check is executed immediately to make sure that is the /// case. Further, all actions sent to the store and all scopes (see ``scope(state:action:)-90255``) /// of the store are also checked to make sure that work is performed on the main thread. +@dynamicMemberLookup public final class Store { - private var canCacheChildren = true + var canCacheChildren = true private var children: [ScopeID: AnyObject] = [:] var _isInvalidated = { false } @@ -140,6 +141,19 @@ public final class Store { private let toState: PartialToState private let fromAction: (Action) -> Any + #if canImport(Perception) + let _$observationRegistrar = PerceptionRegistrar( + isPerceptionCheckingEnabled: _isStorePerceptionCheckingEnabled + ) + private var parentCancellable: AnyCancellable? + #else + // NB: This dynamic member lookup is needed to support pre-Observation (<5.9) versions of Swift. + @_disfavoredOverload + private subscript(dynamicMember keyPath: KeyPath) -> Never { + self.currentState[keyPath: keyPath] + } + #endif + /// Initializes a store from an initial state and a reducer. /// /// - Parameters: @@ -169,21 +183,27 @@ public final class Store { } } + init() { + self._isInvalidated = { true } + self.rootStore = RootStore(initialState: (), reducer: EmptyReducer()) + self.toState = .keyPath(\State.self) + self.fromAction = { $0 } + } + deinit { Logger.shared.log("\(storeTypeName(of: self)).deinit") } - /// Calls the given closure with the current state of the store. + /// Calls the given closure with a snapshot of the current state of the store. /// - /// A lightweight way of accessing store state when no view store is available and state does not - /// need to be observed, _e.g._ by a SwiftUI view. If a view store is available, prefer - /// ``ViewStore/state-swift.property``. + /// A lightweight way of accessing store state when state is not observable and ``state-1qxwl`` is + /// unavailable. /// /// - Parameter body: A closure that takes the current state of the store as its sole argument. If /// the closure has a return value, that value is also used as the return value of the /// `withState` method. The state argument reflects the current state of the store only for the - /// duration of the closure's execution, and is not observable over time, _e.g._ by SwiftUI. If - /// you want to observe store state in a view, use a ``ViewStore`` instead. + /// duration of the closure's execution, and is only observable over time, _e.g._ by SwiftUI, if + /// it conforms to ``ObservableState``. /// - Returns: The return value, if any, of the `body` closure. public func withState(_ body: (_ state: State) -> R) -> R { body(self.currentState) @@ -234,6 +254,7 @@ public final class Store { /// ```swift /// @Reducer /// struct AppFeature { + /// @ObservableState /// struct State { /// var login: Login.State /// // ... @@ -261,116 +282,6 @@ public final class Store { /// `LoginView` could be extracted to a module that has no access to `AppFeature.State` or /// `AppFeature.Action`. /// - /// Scoping also gives a view the opportunity to focus on just the state and actions it cares - /// about, even if its feature domain is larger. - /// - /// For example, the above login domain could model a two screen login flow: a login form followed - /// by a two-factor authentication screen. The second screen's domain might be nested in the - /// first: - /// - /// ```swift - /// @Reducer - /// struct Login { - /// struct State: Equatable { - /// var email = "" - /// var password = "" - /// var twoFactorAuth: TwoFactorAuthState? - /// } - /// enum Action { - /// case emailChanged(String) - /// case loginButtonTapped - /// case loginResponse(Result) - /// case passwordChanged(String) - /// case twoFactorAuth(TwoFactorAuthAction) - /// } - /// // ... - /// } - /// ``` - /// - /// The login view holds onto a store of this domain: - /// - /// ```swift - /// struct LoginView: View { - /// let store: StoreOf - /// - /// var body: some View { /* ... */ } - /// } - /// ``` - /// - /// If its body were to use a view store of the same domain, this would introduce a number of - /// problems: - /// - /// * The login view would be able to read from `twoFactorAuth` state. This state is only intended - /// to be read from the two-factor auth screen. - /// - /// * Even worse, changes to `twoFactorAuth` state would now cause SwiftUI to recompute - /// `LoginView`'s body unnecessarily. - /// - /// * The login view would be able to send `twoFactorAuth` actions. These actions are only - /// intended to be sent from the two-factor auth screen (and reducer). - /// - /// * The login view would be able to send non user-facing login actions, like `loginResponse`. - /// These actions are only intended to be used in the login reducer to feed the results of - /// effects back into the store. - /// - /// To avoid these issues, one can introduce a view-specific domain that slices off the subset of - /// state and actions that a view cares about: - /// - /// ```swift - /// extension LoginView { - /// struct ViewState: Equatable { - /// var email: String - /// var password: String - /// } - /// - /// enum ViewAction { - /// case emailChanged(String) - /// case loginButtonTapped - /// case passwordChanged(String) - /// } - /// } - /// ``` - /// - /// One can also introduce a couple helpers that transform feature state into view state and - /// transform view actions into feature actions. - /// - /// ```swift - /// extension Login.State { - /// var view: LoginView.ViewState { - /// .init(email: self.email, password: self.password) - /// } - /// } - /// - /// extension LoginView.ViewAction { - /// var feature: Login.Action { - /// switch self { - /// case let .emailChanged(email) - /// return .emailChanged(email) - /// case .loginButtonTapped: - /// return .loginButtonTapped - /// case let .passwordChanged(password) - /// return .passwordChanged(password) - /// } - /// } - /// } - /// ``` - /// - /// With these helpers defined, `LoginView` can now scope its store's feature domain into its view - /// domain: - /// - /// ```swift - /// var body: some View { - /// WithViewStore( - /// self.store, observe: \.view, send: \.feature - /// ) { viewStore in - /// // ... - /// } - /// } - /// ``` - /// - /// This view store is now incapable of reading any state but view state (and will not recompute - /// when non-view state changes), and is incapable of sending any actions but view actions. - /// /// - Parameters: /// - state: A key path from `State` to `ChildState`. /// - action: A case key path from `Action` to `ChildAction`. @@ -388,22 +299,7 @@ public final class Store { } @available( - iOS, deprecated: 9999, - message: - "Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths" - ) - @available( - macOS, deprecated: 9999, - message: - "Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths" - ) - @available( - tvOS, deprecated: 9999, - message: - "Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths" - ) - @available( - watchOS, deprecated: 9999, + *, deprecated, message: "Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths" ) @@ -485,6 +381,26 @@ public final class Store { self.rootStore = rootStore self.toState = toState self.fromAction = fromAction + + #if canImport(Perception) + func subscribeToDidSet(_ type: T.Type) -> AnyCancellable { + let toState = toState as! PartialToState + return rootStore.didSet + .compactMap { [weak rootStore] in + rootStore.map { toState($0.state) }?._$id + } + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + guard let self else { return } + self._$observationRegistrar.withMutation(of: self, keyPath: \.currentState) {} + } + } + + if let stateType = State.self as? ObservableState.Type { + self.parentCancellable = subscribeToDidSet(stateType) + } + #endif } convenience init( @@ -637,7 +553,6 @@ extension PresentationState: _OptionalProtocol {} func storeTypeName(of store: Store) -> String { let stateType = typeName(State.self, genericsAbbreviated: false) let actionType = typeName(Action.self, genericsAbbreviated: false) - // TODO: `PresentationStoreOf`, `StackStoreOf`, `IdentifiedStoreOf`? if stateType.hasSuffix(".State"), actionType.hasSuffix(".Action"), stateType.dropLast(6) == actionType.dropLast(7) @@ -757,3 +672,13 @@ private enum PartialToState { } } } + +#if canImport(Perception) + private let _isStorePerceptionCheckingEnabled: Bool = { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return false + } else { + return true + } + }() +#endif diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index ec774d9ff2c7..1e960e08140d 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -15,6 +15,30 @@ import SwiftUI /// > SwiftUI components. /// /// Read for more information. +@available( + iOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" +) +@available( + macOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" +) +@available( + tvOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" +) +@available( + watchOS, + deprecated: 9999, + message: + "Deriving bindings directly from stores using '@ObservableState'. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#BindingState" +) @propertyWrapper public struct BindingState { /// The underlying value wrapped by the binding state. @@ -154,6 +178,17 @@ public struct BindingAction: CasePathable, Equatable, @unchecked Sendable @dynamicMemberLookup public struct AllCasePaths { + #if canImport(Perception) + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> AnyCasePath where Root: ObservableState { + AnyCasePath( + embed: { .set(keyPath, $0) }, + extract: { $0.keyPath == keyPath ? $0.value as? Value : nil } + ) + } + #endif + public subscript( dynamicMember keyPath: WritableKeyPath> ) -> AnyCasePath { @@ -391,7 +426,12 @@ public struct BindingViewStore { fileID: StaticString = #fileID, line: UInt = #line ) where Action.State == State { - self.store = store.scope(state: { $0 }, action: Action.binding) + self.store = store.scope( + id: nil, + state: ToState(\.self), + action: Action.binding, + isInvalid: nil + ) #if DEBUG self.bindableActionType = type(of: Action.self) self.fileID = fileID @@ -470,7 +510,16 @@ extension ViewStore { self.init( store, observe: { (_: State) in - toViewState(BindingViewStore(store: store.scope(state: { $0 }, action: fromViewAction))) + toViewState( + BindingViewStore( + store: store.scope( + id: nil, + state: ToState(\.self), + action: fromViewAction, + isInvalid: nil + ) + ) + ) }, send: fromViewAction, removeDuplicates: isDuplicate @@ -578,7 +627,16 @@ extension WithViewStore where Content: View { self.init( store, observe: { (_: State) in - toViewState(BindingViewStore(store: store.scope(state: { $0 }, action: fromViewAction))) + toViewState( + BindingViewStore( + store: store.scope( + id: nil, + state: ToState(\.self), + action: fromViewAction, + isInvalid: nil + ) + ) + ) }, send: fromViewAction, removeDuplicates: isDuplicate, diff --git a/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift b/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift index 805a20dba9a3..b9dc8bf29a3f 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift @@ -80,8 +80,10 @@ public struct NavigationLinkStore< self.store = store self.viewStore = ViewStore( store.scope( - state: { $0.wrappedValue.flatMap(toDestinationState) != nil }, - action: { $0 } + id: nil, + state: ToState { $0.wrappedValue.flatMap(toDestinationState) != nil }, + action: { $0 }, + isInvalid: nil ), observe: { $0 } ) @@ -129,8 +131,10 @@ public struct NavigationLinkStore< self.store = store self.viewStore = ViewStore( store.scope( - state: { $0.wrappedValue.flatMap(toDestinationState)?.id == id }, - action: { $0 } + id: nil, + state: ToState { $0.wrappedValue.flatMap(toDestinationState)?.id == id }, + action: { $0 }, + isInvalid: nil ), observe: { $0 } ) @@ -156,8 +160,12 @@ public struct NavigationLinkStore< ) { IfLetStore( self.store.scope( - state: returningLastNonNilValue { $0.wrappedValue.flatMap(self.toDestinationState) }, - action: { .presented(self.fromDestinationAction($0)) } + id: nil, + state: ToState( + returningLastNonNilValue { $0.wrappedValue.flatMap(self.toDestinationState) } + ), + action: { .presented(self.fromDestinationAction($0)) }, + isInvalid: nil ), then: self.destination ) diff --git a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift index 88ef9a08c873..45b17e99873a 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift @@ -83,6 +83,26 @@ import SwiftUI /// } /// ``` /// +@available( + iOS, deprecated: 9999, + message: + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" +) +@available( + macOS, deprecated: 9999, + message: + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" +) +@available( + tvOS, deprecated: 9999, + message: + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" +) +@available( + watchOS, deprecated: 9999, + message: + "Pass 'ForEach' a store scoped to an identified array and identified action, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-ForEachStore-with-ForEach]" +) public struct ForEachStore< EachState, EachAction, Data: Collection, ID: Hashable, Content: View >: DynamicViewContent { diff --git a/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift b/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift index f62329b2282b..12fe2a5a5a11 100644 --- a/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift +++ b/Sources/ComposableArchitecture/SwiftUI/FullScreenCover.swift @@ -18,6 +18,26 @@ import SwiftUI /// system dismisses the currently displayed sheet. /// - onDismiss: The closure to execute when dismissing the modal view. /// - content: A closure returning the content of the modal view. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'fullScreenCover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) public func fullScreenCover( store: Store, PresentationAction>, onDismiss: (() -> Void)? = nil, diff --git a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift index 96627b8e5ad1..3b8d0908561a 100644 --- a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift @@ -17,6 +17,26 @@ import SwiftUI /// } /// ``` /// +@available( + iOS, deprecated: 9999, + message: + "Use 'if let' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" +) +@available( + macOS, deprecated: 9999, + message: + "Use 'if let' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" +) +@available( + tvOS, deprecated: 9999, + message: + "Use 'if let' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" +) +@available( + watchOS, deprecated: 9999, + message: + "Use 'if let' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-IfLetStore-with-if-let]" +) public struct IfLetStore: View { private let content: (ViewStore) -> Content private let store: Store diff --git a/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift b/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift index a19868c5250f..2b50414f760b 100644 --- a/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift +++ b/Sources/ComposableArchitecture/SwiftUI/NavigationDestination.swift @@ -16,6 +16,26 @@ extension View { /// in a view that the system pushes onto the navigation stack. If `store`'s state is /// `nil`-ed out, the system pops the view from the stack. /// - destination: A closure returning the content of the destination view. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'navigationDestination(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) public func navigationDestination( store: Store, PresentationAction>, @ViewBuilder destination: @escaping (_ store: Store) -> Destination diff --git a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift index 075440c5d483..f5dc7444287e 100644 --- a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift @@ -8,10 +8,30 @@ import SwiftUI /// /// See the dedicated article on for more information on the library's navigation /// tools, and in particular see for information on using this view. +@available( + iOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" +) +@available( + macOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" +) +@available( + tvOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" +) +@available( + watchOS, deprecated: 9999, + message: + "Use 'NavigationStack.init(path:)' with a store scoped from observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-NavigationStackStore-with-NavigationStack]" +) @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public struct NavigationStackStore: View { private let root: Root - private let destination: (Component) -> Destination + private let destination: (StackState.Component) -> Destination @ObservedObject private var viewStore: ViewStore, StackAction> /// Creates a navigation stack with a store of stack state and actions. @@ -110,205 +130,19 @@ public struct NavigationStackStore ) { self.root .environment(\.navigationDestinationType, State.self) - .navigationDestination(for: Component.self) { component in + .navigationDestination(for: StackState.Component.self) { component in NavigationDestinationView(component: component, destination: self.destination) } } } } -public struct _NavigationLinkStoreContent: View { - let state: State? - @ViewBuilder let label: Label - let fileID: StaticString - let line: UInt - @Environment(\.navigationDestinationType) var navigationDestinationType - - public var body: some View { - #if DEBUG - self.label.onAppear { - if self.navigationDestinationType != State.self { - runtimeWarn( - """ - A navigation link at "\(self.fileID):\(self.line)" is unpresentable. … - - NavigationStackStore element type: - \(self.navigationDestinationType.map(typeName) ?? "(None found in view hierarchy)") - NavigationLink state type: - \(typeName(State.self)) - NavigationLink state value: - \(String(customDumping: self.state).indent(by: 2)) - """ - ) - } - } - #else - self.label - #endif - } -} - -@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) -extension NavigationLink where Destination == Never { - /// Creates a navigation link that presents the view corresponding to an element of - /// ``StackState``. - /// - /// When someone activates the navigation link that this initializer creates, SwiftUI looks for a - /// parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements that - /// matches the type of this initializer's `state` input. - /// - /// See SwiftUI's documentation for `NavigationLink.init(value:label:)` for more. - /// - /// - Parameters: - /// - state: An optional value to present. When the user selects the link, SwiftUI stores a copy - /// of the value. Pass a `nil` value to disable the link. - /// - label: A label that describes the view that this link presents. - public init( - state: P?, - @ViewBuilder label: () -> L, - fileID: StaticString = #fileID, - line: UInt = #line - ) - where Label == _NavigationLinkStoreContent { - @Dependency(\.stackElementID) var stackElementID - self.init(value: state.map { Component(id: stackElementID(), element: $0) }) { - _NavigationLinkStoreContent( - state: state, label: { label() }, fileID: fileID, line: line - ) - } - } - - /// Creates a navigation link that presents the view corresponding to an element of - /// ``StackState``, with a text label that the link generates from a localized string key. - /// - /// When someone activates the navigation link that this initializer creates, SwiftUI looks for a - /// parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements that - /// matches the type of this initializer's `state` input. - /// - /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. - /// - /// - Parameters: - /// - titleKey: A localized string that describes the view that this link - /// presents. - /// - state: An optional value to present. When the user selects the link, SwiftUI stores a copy - /// of the value. Pass a `nil` value to disable the link. - public init

( - _ titleKey: LocalizedStringKey, state: P?, fileID: StaticString = #fileID, line: UInt = #line - ) - where Label == _NavigationLinkStoreContent { - self.init(state: state, label: { Text(titleKey) }, fileID: fileID, line: line) - } - - /// Creates a navigation link that presents the view corresponding to an element of - /// ``StackState``, with a text label that the link generates from a title string. - /// - /// When someone activates the navigation link that this initializer creates, SwiftUI looks for a - /// parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements that - /// matches the type of this initializer's `state` input. - /// - /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more. - /// - /// - Parameters: - /// - title: A string that describes the view that this link presents. - /// - state: An optional value to present. When the user selects the link, SwiftUI stores a copy - /// of the value. Pass a `nil` value to disable the link. - @_disfavoredOverload - public init( - _ title: S, state: P?, fileID: StaticString = #fileID, line: UInt = #line - ) - where Label == _NavigationLinkStoreContent { - self.init(state: state, label: { Text(title) }, fileID: fileID, line: line) - } -} - private struct NavigationDestinationView: View { - let component: Component - let destination: (Component) -> Destination + let component: StackState.Component + let destination: (StackState.Component) -> Destination var body: some View { self.destination(self.component) .environment(\.navigationDestinationType, State.self) .id(self.component.id) } } - -private struct Component: Hashable { - let id: StackElementID - var element: Element - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(self.id) - } -} - -extension StackState { - fileprivate var path: PathView { - _read { yield PathView(base: self) } - _modify { - var path = PathView(base: self) - yield &path - self = path.base - } - set { self = newValue.base } - } - - fileprivate struct PathView: MutableCollection, RandomAccessCollection, - RangeReplaceableCollection - { - var base: StackState - - var startIndex: Int { self.base.startIndex } - var endIndex: Int { self.base.endIndex } - func index(after i: Int) -> Int { self.base.index(after: i) } - func index(before i: Int) -> Int { self.base.index(before: i) } - - subscript(position: Int) -> Component { - _read { - yield Component(id: self.base.ids[position], element: self.base[position]) - } - _modify { - let id = self.base.ids[position] - var component = Component(id: id, element: self.base[position]) - yield &component - self.base[id: id] = component.element - } - set { - self.base[id: newValue.id] = newValue.element - } - } - - init(base: StackState) { - self.base = base - } - - init() { - self.init(base: StackState()) - } - - mutating func replaceSubrange( - _ subrange: Range, with newElements: C - ) where C.Element == Component { - for id in self.base.ids[subrange] { - self.base[id: id] = nil - } - for component in newElements.reversed() { - self.base._dictionary - .updateValue(component.element, forKey: component.id, insertingAt: subrange.lowerBound) - } - } - } -} - -private struct NavigationDestinationTypeKey: EnvironmentKey { - static var defaultValue: Any.Type? { nil } -} - -extension EnvironmentValues { - fileprivate var navigationDestinationType: Any.Type? { - get { self[NavigationDestinationTypeKey.self] } - set { self[NavigationDestinationTypeKey.self] = newValue } - } -} diff --git a/Sources/ComposableArchitecture/SwiftUI/Popover.swift b/Sources/ComposableArchitecture/SwiftUI/Popover.swift index a8f816972560..4051f5472f97 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Popover.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Popover.swift @@ -17,6 +17,26 @@ extension View { /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's /// arrow in macOS. iOS ignores this parameter. /// - content: A closure returning the content of the popover. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'popover(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) public func popover( store: Store, PresentationAction>, attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), diff --git a/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift b/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift index 7636fb495cf9..14799e08021a 100644 --- a/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift +++ b/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift @@ -256,21 +256,17 @@ public struct PresentationStore< action: { $0 }, isInvalid: { $0.wrappedValue.flatMap(toDestinationState) == nil } ) - let viewStore = ViewStore( - store, - observe: { $0 }, - removeDuplicates: { - toID($0) == toID($1) - } - ) + let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { toID($0) == toID($1) }) self.store = store self.toDestinationState = toDestinationState self.toID = toID self.fromDestinationAction = fromDestinationAction self.destinationStore = store.scope( - state: { $0.wrappedValue.flatMap(toDestinationState) }, - action: { .presented(fromDestinationAction($0)) } + id: nil, + state: ToState { $0.wrappedValue.flatMap(toDestinationState) }, + action: { .presented(fromDestinationAction($0)) }, + isInvalid: nil ) self.content = content self.viewStore = viewStore diff --git a/Sources/ComposableArchitecture/SwiftUI/Sheet.swift b/Sources/ComposableArchitecture/SwiftUI/Sheet.swift index ce27a64ae47c..4086c1184179 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Sheet.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Sheet.swift @@ -13,6 +13,26 @@ extension View { /// system dismisses the currently displayed sheet. /// - onDismiss: The closure to execute when dismissing the modal view. /// - content: A closure returning the content of the modal view. + @available( + iOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + macOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + tvOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) + @available( + watchOS, deprecated: 9999, + message: + "Pass a binding of a store to 'sheet(item:)' instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-navigation-view-modifiers-with-SwiftUI-modifiers]" + ) public func sheet( store: Store, PresentationAction>, onDismiss: (() -> Void)? = nil, diff --git a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift index 706f69422c81..96dc3e5df4c3 100644 --- a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift @@ -53,6 +53,26 @@ import SwiftUI /// See ``Reducer/ifCaseLet(_:action:then:fileID:line:)-3k4yb`` and /// ``Scope/init(state:action:child:fileID:line:)-7yj7l`` for embedding reducers that operate on /// each case of an enum in reducers that operate on the entire enum. +@available( + iOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) +@available( + macOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) +@available( + tvOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) +@available( + watchOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) public struct SwitchStore: View { public let store: Store public let content: (State) -> Content @@ -76,6 +96,26 @@ public struct SwitchStore: View { } /// A view that handles a specific case of enum state in a ``SwitchStore``. +@available( + iOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) +@available( + macOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) +@available( + tvOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) +@available( + watchOS, deprecated: 9999, + message: + "Use 'switch' with a store of observable state, instead. For more information, see the following article:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]" +) public struct CaseLet: View { public let toCaseState: (EnumState) -> CaseState? public let fromCaseAction: (CaseAction) -> EnumAction @@ -112,8 +152,10 @@ public struct CaseLet Important: It is important to properly leverage the `observe` argument in order to observe -/// only the state that your view needs to do its job. See the -/// article for more information. +/// only the state that your view needs to do its job. See the "Performance" section below for more +/// information. /// /// For example, the following view, which manually observes the store it is handed by constructing /// a view store in its initializer: @@ -113,6 +113,231 @@ import SwiftUI /// self.store.send(.buttonTapped) /// } /// ``` +/// +/// ## Performance +/// +/// A common performance pitfall when using the library comes from constructing ``ViewStore``s and +/// ``WithViewStore``s. When constructed naively, using either view store's initializer +/// ``ViewStore/init(_:observe:)-3ak1y`` or the SwiftUI helper ``WithViewStore``, it will observe +/// every change to state in the store: +/// +/// ```swift +/// WithViewStore(self.store, observe: { $0 }) { viewStore in +/// // This is executed for every action sent into the system +/// // that causes self.store.state to change. +/// } +/// ``` +/// +/// Most of the time this observes far too much state. A typical feature in the Composable +/// Architecture holds onto not only the state the view needs to present UI, but also state that the +/// feature only needs internally, as well as state of child features embedded in the feature. +/// Changes to the internal and child state should not cause the view's body to re-compute since +/// that state is not needed in the view. +/// +/// For example, if the root of our application was a tab view, then we could model that in state +/// as a struct that holds each tab's state as a property: +/// +/// ```swift +/// @Reducer +/// struct AppFeature { +/// struct State { +/// var activity: Activity.State +/// var search: Search.State +/// var profile: Profile.State +/// } +/// // ... +/// } +/// ``` +/// +/// If the view only needs to construct the views for each tab, then no view store is even needed +/// because we can pass scoped stores to each child feature view: +/// +/// ```swift +/// struct AppView: View { +/// let store: StoreOf +/// +/// var body: some View { +/// // No need to observe state changes because the view does +/// // not need access to the state. +/// TabView { +/// ActivityView( +/// store: self.store +/// .scope(state: \.activity, action: \.activity) +/// ) +/// SearchView( +/// store: self.store +/// .scope(state: \.search, action: \.search) +/// ) +/// ProfileView( +/// store: self.store +/// .scope(state: \.profile, action: \.profile) +/// ) +/// } +/// } +/// } +/// ``` +/// +/// This means `AppView` does not actually need to observe any state changes. This view will only be +/// created a single time, whereas if we observed the store then it would re-compute every time a single +/// thing changed in either the activity, search or profile child features. +/// +/// If sometime in the future we do actually need some state from the store, we can start to observe +/// only the bare essentials of state necessary for the view to do its job. For example, suppose that +/// we need access to the currently selected tab in state: +/// +/// ```swift +/// @Reducer +/// struct AppFeature { +/// enum Tab { case activity, search, profile } +/// struct State { +/// var activity: Activity.State +/// var search: Search.State +/// var profile: Profile.State +/// var selectedTab: Tab +/// } +/// // ... +/// } +/// ``` +/// +/// Then we can observe this state so that we can construct a binding to `selectedTab` for the tab view: +/// +/// ```swift +/// struct AppView: View { +/// let store: StoreOf +/// +/// var body: some View { +/// WithViewStore(self.store, observe: { $0 }) { viewStore in +/// TabView( +/// selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) +/// ) { +/// ActivityView( +/// store: self.store.scope(state: \.activity, action: \.activity) +/// ) +/// .tag(AppFeature.Tab.activity) +/// SearchView( +/// store: self.store.scope(state: \.search, action: \.search) +/// ) +/// .tag(AppFeature.Tab.search) +/// ProfileView( +/// store: self.store.scope(state: \.profile, action: \.profile) +/// ) +/// .tag(AppFeature.Tab.profile) +/// } +/// } +/// } +/// } +/// ``` +/// +/// However, this style of state observation is terribly inefficient since _every_ change to +/// `AppFeature.State` will cause the view to re-compute even though the only piece of state we +/// actually care about is the `selectedTab`. The reason we are observing too much state is because +/// we use `observe: { $0 }` in the construction of the ``WithViewStore``, which means the view +/// store will observe all of state. +/// +/// To chisel away at the observed state you can provide a closure for that argument that plucks out +/// the state the view needs. In this case the view only needs a single field: +/// +/// ```swift +/// WithViewStore(self.store, observe: \.selectedTab) { viewStore in +/// TabView(selection: viewStore.binding(send: { .tabSelected($0) }) { +/// // ... +/// } +/// } +/// ``` +/// +/// In the future, the view may need access to more state. For example, suppose `Activity.State` +/// holds onto an `unreadCount` integer to represent how many new activities you have. There's no +/// need to observe _all_ of `Activity.State` to get access to this one field. You can observe just +/// the one field. +/// +/// Technically you can do this by mapping your state into a tuple, but because tuples are not +/// `Equatable` you will need to provide an explicit `removeDuplicates` argument: +/// +/// ```swift +/// WithViewStore( +/// self.store, +/// observe: { (selectedTab: $0.selectedTab, unreadActivityCount: $0.activity.unreadCount) }, +/// removeDuplicates: == +/// ) { viewStore in +/// TabView(selection: viewStore.binding(get: \.selectedTab, send: { .tabSelected($0) }) { +/// ActivityView( +/// store: self.store.scope(state: \.activity, action: \.activity) +/// ) +/// .tag(AppFeature.Tab.activity) +/// .badge("\(viewStore.unreadActivityCount)") +/// +/// // ... +/// } +/// } +/// ``` +/// +/// Alternatively, and recommended, you can introduce a lightweight, equatable `ViewState` struct +/// nested inside your view whose purpose is to transform the `Store`'s full state into the bare +/// essentials of what the view needs: +/// +/// ```swift +/// struct AppView: View { +/// let store: StoreOf +/// +/// struct ViewState: Equatable { +/// let selectedTab: AppFeature.Tab +/// let unreadActivityCount: Int +/// init(state: AppFeature.State) { +/// self.selectedTab = state.selectedTab +/// self.unreadActivityCount = state.activity.unreadCount +/// } +/// } +/// +/// var body: some View { +/// WithViewStore(self.store, observe: ViewState.init) { viewStore in +/// TabView { +/// ActivityView( +/// store: self.store +/// .scope(state: \.activity, action: \.activity) +/// ) +/// .badge("\(viewStore.unreadActivityCount)") +/// +/// // ... +/// } +/// } +/// } +/// } +/// ``` +/// +/// This gives you maximum flexibility in the future for adding new fields to `ViewState` without +/// making your view convoluted. +/// +/// This technique for reducing view re-computations is most effective towards the root of your app +/// hierarchy and least effective towards the leaf nodes of your app. Root features tend to hold +/// lots of state that its view does not need, such as child features, and leaf features tend to +/// only hold what's necessary. If you are going to employ this technique you will get the most +/// benefit by applying it to views closer to the root. At leaf features and views that need access +/// to most of the state, it is fine to continue using `observe: { $0 }` to observe all of the state +/// in the store. +@available( + iOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) +@available( + macOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) +@available( + tvOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) +@available( + watchOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) public struct WithViewStore: View { private let content: (ViewStore) -> Content #if DEBUG @@ -276,7 +501,12 @@ public struct WithViewStore: View { line: UInt = #line ) { self.init( - store: store.scope(state: toViewState, action: fromViewAction), + store: store.scope( + id: nil, + state: ToState(toViewState), + action: fromViewAction, + isInvalid: nil + ), removeDuplicates: isDuplicate, content: content, file: file, @@ -365,7 +595,12 @@ public struct WithViewStore: View { line: UInt = #line ) { self.init( - store: store.scope(state: toViewState, action: { $0 }), + store: store.scope( + id: nil, + state: ToState(toViewState), + action: { $0 }, + isInvalid: nil + ), removeDuplicates: isDuplicate, content: content, file: file, @@ -455,7 +690,12 @@ extension WithViewStore where ViewState: Equatable, Content: View { line: UInt = #line ) { self.init( - store: store.scope(state: toViewState, action: fromViewAction), + store: store.scope( + id: nil, + state: ToState(toViewState), + action: fromViewAction, + isInvalid: nil + ), removeDuplicates: ==, content: content, file: file, @@ -541,7 +781,12 @@ extension WithViewStore where ViewState: Equatable, Content: View { line: UInt = #line ) { self.init( - store: store.scope(state: toViewState, action: { $0 }), + store: store.scope( + id: nil, + state: ToState(toViewState), + action: { $0 }, + isInvalid: nil + ), removeDuplicates: ==, content: content, file: file, diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 4e01da6a0742..c6af3bd39e13 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -2165,7 +2165,12 @@ extension TestStore { store: Store(initialState: self.state) { BindingReducer(action: toViewAction.extract(from:)) } - .scope(state: { $0 }, action: toViewAction.embed) + .scope( + id: nil, + state: ToState(\.self), + action: toViewAction.embed, + isInvalid: nil + ) ) } } diff --git a/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift b/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift index e28c8d9057a4..3f41a42ce177 100644 --- a/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift +++ b/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift @@ -55,6 +55,58 @@ } } + /// Creates a `UIAlertController` from a ``Store`` focused on alert state. + /// + /// You can use this initializer in tandem with ``ObjectiveC/NSObject/observe(_:)`` and + /// ``Store/scope(state:action:)-36e72`` to drive an alert from state: + /// + /// ```swift + /// class FeatureController: UIViewController { + /// let store: StoreOf + /// private weak var alertController: UIAlertController? + /// // ... + /// func viewDidLoad() { + /// // ... + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// if + /// let store = store.scope(state: \.alert, action: \.alert), + /// alertController == nil + /// { + /// alertController = UIAlertController(store: store) + /// self.present(alertController!, animated: true, completion: nil) + /// } else if store.alert == nil, alertController != nil { + /// alertController?.dismiss(animated: true) + /// alertController = nil + /// } + /// } + /// } + /// } + /// ``` + public convenience init( + store: Store, PresentationAction> + ) { + let state = store.currentState + self.init( + title: String(state: state.title), + message: state.message.map { String(state: $0) }, + preferredStyle: .alert + ) + for button in state.buttons { + self.addAction(.init(button, action: { store.send($0.map { .presented($0) } ?? .dismiss) })) + } + if state.buttons.isEmpty { + self.addAction( + .init( + title: "OK", + style: .cancel, + handler: { _ in store.send(.dismiss) }) + ) + } + } + /// Creates a `UIAlertController` from `ConfirmationDialogState`. /// /// - Parameters: @@ -72,6 +124,59 @@ self.addAction(.init(button, action: send)) } } + + /// Creates a `UIAlertController` from a ``Store`` focused on confirmation dialog state. + /// + /// You can use this initializer in tandem with ``ObjectiveC/NSObject/observe(_:)`` and + /// ``Store/scope(state:action:)-36e72`` to drive an alert from state: + /// + /// ```swift + /// class FeatureController: UIViewController { + /// let store: StoreOf + /// private weak var alertController: UIAlertController? + /// // ... + /// func viewDidLoad() { + /// // ... + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// if + /// let store = store.scope(state: \.actionSheet, action: \.actionSheet), + /// alertController == nil + /// { + /// alertController = UIAlertController(store: store) + /// self.present(alertController!, animated: true, completion: nil) + /// } else if store.alert == nil, alertController != nil { + /// alertController?.dismiss(animated: true) + /// alertController = nil + /// } + /// } + /// } + /// } + /// ``` + public convenience init( + store: Store, PresentationAction> + ) { + let state = store.currentState + self.init( + title: String(state: state.title), + message: state.message.map { String(state: $0) }, + preferredStyle: .actionSheet + ) + for button in state.buttons { + self.addAction(.init(button, action: { store.send($0.map { .presented($0) } ?? .dismiss) })) + } + if state.buttons.isEmpty { + self.addAction( + .init( + title: "OK", + style: .cancel, + handler: { _ in store.send(.dismiss) }) + ) + } + } + } @available(iOS 13, *) diff --git a/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift b/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift index bb33474ee8aa..b180ff7bf64c 100644 --- a/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift +++ b/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift @@ -49,8 +49,8 @@ extension Store { then unwrap: @escaping (_ store: Store) -> Void, else: @escaping () -> Void = {} ) -> Cancellable where State == Wrapped? { - self.rootStore.didSet - .map { self.currentState } + return self + .publisher .removeDuplicates(by: { ($0 != nil) == ($1 != nil) }) .sink { state in if var state = state { diff --git a/Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift b/Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift new file mode 100644 index 000000000000..bde5d565794c --- /dev/null +++ b/Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift @@ -0,0 +1,193 @@ +#if canImport(Perception) && canImport(ObjectiveC) + import Foundation + import ObjectiveC + + extension NSObject { + /// Observe access to properties of a `@Perceptible` or `@Observable` object. + /// + /// This tool allows you to set up an observation loop so that you can access fields from an + /// observable model in order to populate your view, and also automatically track changes to + /// any accessed fields so that the view is always up-to-date. + /// + /// It is most useful when dealing with non-SwiftUI views, such as UIKit views and controller. + /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all + /// the view elements: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let countLabel = UILabel() + /// let incrementButton = UIButton(primaryAction: .init { _ in + /// store.send(.incrementButtonTapped) + /// }) + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.text = "\(store.count)" + /// } + /// } + /// ``` + /// + /// This closure is immediately called, allowing you to set the initial state of your UI + /// components from the feature's state. And if the `count` property in the feature's state is + /// ever mutated, this trailing closure will be called again, allowing us to update the view + /// again. + /// + /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your + /// view, such as `viewDidLoad` for `UIViewController`. This works even if you have many UI + /// components to update: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.isHidden = store.isObservingCount + /// if !countLabel.isHidden { + /// countLabel.text = "\(store.count)" + /// } + /// factLabel.text = store.fact + /// } + /// } + /// ``` + /// + /// This does mean that you may execute the line `factLabel.text = store.fact` even when something + /// unrelated changes, such as `store.count`, but that is typically OK for simple properties of + /// UI components. It is not a performance problem to repeatedly set the `text` of a label or + /// the `isHidden` of a button. + /// + /// However, if there is heavy work you need to perform when state changes, then it is best to + /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or + /// collection view when a collection changes: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// self.dataSource = store.items + /// self.tableView.reloadData() + /// } + /// } + /// ``` + /// + /// ## Navigation + /// + /// The ``observe(_:)`` method makes it easy to drive navigation from state. To do so you need + /// a reference to the controller that you are presenting (held as an optional), and when state + /// becomes non-`nil` you assign and present the controller, and when state becomes `nil` you + /// dismiss the controller and `nil` out the reference. + /// + /// For example, if your feature's state holds onto alert state, then an alert can be presented + /// and dismissed with the following: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// var alertController: UIAlertController? + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// if + /// let store = store.scope(state: \.alert, action: \.alert), + /// alertController == nil + /// { + /// alertController = UIAlertController(store: store) + /// present(alertController!, animated: true, completion: nil) + /// } else if store.alert == nil, alertController != nil { + /// alertController?.dismiss(animated: true) + /// alertController = nil + /// } + /// } + /// } + /// ``` + /// + /// Here we are using the ``Store/scope(state:action:)-36e72`` operator for optional state in + /// order to detect when the `alert` state flips from `nil` to non-`nil` and vice-versa. + /// + /// ## Cancellation + /// + /// The method returns a ``ObservationToken`` that can be used to cancel observation. For example, + /// if you only want to observe while a view controller is visible, you can start observation in + /// the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: + /// + /// ```swift + /// var observation: ObservationToken? + /// + /// func viewWillAppear() { + /// super.viewWillAppear() + /// self.observation = observe { [weak self] in + /// // ... + /// } + /// } + /// func viewWillDisappear() { + /// super.viewWillDisappear() + /// self.observation?.cancel() + /// } + /// ``` + @discardableResult + public func observe(_ apply: @escaping () -> Void) -> ObservationToken { + let token = ObservationToken() + self.tokens.insert(token) + @Sendable func onChange() { + guard !token.isCancelled + else { return } + + withPerceptionTracking(apply) { + Task { @MainActor in + guard !token.isCancelled + else { return } + onChange() + } + } + } + onChange() + return token + } + + fileprivate var tokens: Set { + get { + (objc_getAssociatedObject(self, &NSObject.tokensHandle) as? Set) ?? [] + } + set { + objc_setAssociatedObject( + self, + &NSObject.tokensHandle, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static var tokensHandle: UInt8 = 0 + } + + /// A token for cancelling observation created with ``ObjectiveC/NSObject/observe(_:)``. + public final class ObservationToken: NSObject, Sendable { + private let _isCancelled = LockIsolated(false) + fileprivate var isCancelled: Bool { self._isCancelled.value } + + /// Cancels observation that was created with ``ObjectiveC/NSObject/observe(_:)``. + /// + /// > Note: This cancellation is lazy and cooperative. It does not cancel the observation + /// immediately, but rather next time a change is detected by ``ObjectiveC/NSObject/observe(_:)`` + /// it will cease any future observation. + public func cancel() { self._isCancelled.setValue(true) } + + deinit { + self.cancel() + } + } +#endif diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 9843c48da8e2..a2ba4ddba238 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -61,6 +61,30 @@ import SwiftUI /// > store it was derived from) must happen on the same thread. Further, for SwiftUI applications, /// > all interactions must happen on the _main_ thread. See the documentation of the ``Store`` /// > class for more information as to why this decision was made. +@available( + iOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) +@available( + macOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) +@available( + tvOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) +@available( + watchOS, + deprecated: 9999, + message: + "Use '@ObservableState', instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Using-ObservableState" +) @dynamicMemberLookup public final class ViewStore: ObservableObject { // N.B. `ViewStore` does not use a `@Published` property, so `objectWillChange` @@ -126,7 +150,12 @@ public final class ViewStore: ObservableObject { self.storeTypeName = ComposableArchitecture.storeTypeName(of: store) Logger.shared.log("View\(self.storeTypeName).init") #endif - self.store = store.scope(state: toViewState, action: fromViewAction) + self.store = store.scope( + id: nil, + state: ToState(toViewState), + action: fromViewAction, + isInvalid: nil + ) self._state = CurrentValueRelay(self.store.currentState) self.viewCancellable = self.store.rootStore.didSet .compactMap { [weak self] in self?.store.currentState } diff --git a/Sources/ComposableArchitectureMacros/Availability.swift b/Sources/ComposableArchitectureMacros/Availability.swift new file mode 100644 index 000000000000..96c01f694964 --- /dev/null +++ b/Sources/ComposableArchitectureMacros/Availability.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension AttributeSyntax { + var availability: AttributeSyntax? { + if attributeName.identifier == "available" { + return self + } else { + return nil + } + } +} + +extension IfConfigClauseSyntax.Elements { + var availability: IfConfigClauseSyntax.Elements? { + switch self { + case .attributes(let attributes): + if let availability = attributes.availability { + return .attributes(availability) + } else { + return nil + } + default: + return nil + } + } +} + +extension IfConfigClauseSyntax { + var availability: IfConfigClauseSyntax? { + if let availability = elements?.availability { + return with(\.elements, availability) + } else { + return nil + } + } + + var clonedAsIf: IfConfigClauseSyntax { + detached.with(\.poundKeyword, .poundIfToken()) + } +} + +extension IfConfigDeclSyntax { + var availability: IfConfigDeclSyntax? { + var elements = [IfConfigClauseListSyntax.Element]() + for clause in clauses { + if let availability = clause.availability { + if elements.isEmpty { + elements.append(availability.clonedAsIf) + } else { + elements.append(availability) + } + } + } + if elements.isEmpty { + return nil + } else { + return with(\.clauses, IfConfigClauseListSyntax(elements)) + } + + } +} + +extension AttributeListSyntax.Element { + var availability: AttributeListSyntax.Element? { + switch self { + case .attribute(let attribute): + if let availability = attribute.availability { + return .attribute(availability) + } + case .ifConfigDecl(let ifConfig): + if let availability = ifConfig.availability { + return .ifConfigDecl(availability) + } + } + return nil + } +} + +extension AttributeListSyntax { + var availability: AttributeListSyntax? { + var elements = [AttributeListSyntax.Element]() + for element in self { + if let availability = element.availability { + elements.append(availability) + } + } + if elements.isEmpty { + return nil + } + return AttributeListSyntax(elements) + } +} diff --git a/Sources/ComposableArchitectureMacros/Extensions.swift b/Sources/ComposableArchitectureMacros/Extensions.swift new file mode 100644 index 000000000000..980c0b1ad9a8 --- /dev/null +++ b/Sources/ComposableArchitectureMacros/Extensions.swift @@ -0,0 +1,299 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension VariableDeclSyntax { + var identifierPattern: IdentifierPatternSyntax? { + bindings.first?.pattern.as(IdentifierPatternSyntax.self) + } + + var isInstance: Bool { + for modifier in modifiers { + for token in modifier.tokens(viewMode: .all) { + if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { + return false + } + } + } + return true + } + + var identifier: TokenSyntax? { + identifierPattern?.identifier + } + + var type: TypeSyntax? { + bindings.first?.typeAnnotation?.type + } + + func accessorsMatching(_ predicate: (TokenKind) -> Bool) -> [AccessorDeclSyntax] { + let patternBindings = bindings.compactMap { binding in + binding.as(PatternBindingSyntax.self) + } + let accessors: [AccessorDeclListSyntax.Element] = patternBindings.compactMap { patternBinding in + switch patternBinding.accessorBlock?.accessors { + case .accessors(let accessors): + return accessors + default: + return nil + } + }.flatMap { $0 } + return accessors.compactMap { accessor in + guard let decl = accessor.as(AccessorDeclSyntax.self) else { + return nil + } + if predicate(decl.accessorSpecifier.tokenKind) { + return decl + } else { + return nil + } + } + } + + var willSetAccessors: [AccessorDeclSyntax] { + accessorsMatching { $0 == .keyword(.willSet) } + } + var didSetAccessors: [AccessorDeclSyntax] { + accessorsMatching { $0 == .keyword(.didSet) } + } + + var isComputed: Bool { + if accessorsMatching({ $0 == .keyword(.get) }).count > 0 { + return true + } else { + return bindings.contains { binding in + if case .getter = binding.accessorBlock?.accessors { + return true + } else { + return false + } + } + } + } + + var isImmutable: Bool { + return bindingSpecifier.tokenKind == .keyword(.let) + } + + func isEquivalent(to other: VariableDeclSyntax) -> Bool { + if isInstance != other.isInstance { + return false + } + return identifier?.text == other.identifier?.text + } + + var initializer: InitializerClauseSyntax? { + bindings.first?.initializer + } + + func hasMacroApplication(_ name: String) -> Bool { + for attribute in attributes { + switch attribute { + case .attribute(let attr): + if attr.attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) == [.identifier(name)] { + return true + } + default: + break + } + } + return false + } + + func firstAttribute(for name: String) -> AttributeSyntax? { + for attribute in attributes { + switch attribute { + case .attribute(let attr): + if attr.attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) == [.identifier(name)] { + return attr + } + default: + break + } + } + return nil + } +} + +extension TypeSyntax { + var identifier: String? { + for token in tokens(viewMode: .all) { + switch token.tokenKind { + case .identifier(let identifier): + return identifier + default: + break + } + } + return nil + } + + func genericSubstitution(_ parameters: GenericParameterListSyntax?) -> String? { + var genericParameters = [String: TypeSyntax?]() + if let parameters { + for parameter in parameters { + genericParameters[parameter.name.text] = parameter.inheritedType + } + } + var iterator = self.asProtocol(TypeSyntaxProtocol.self).tokens(viewMode: .sourceAccurate) + .makeIterator() + guard let base = iterator.next() else { + return nil + } + + if let genericBase = genericParameters[base.text] { + if let text = genericBase?.identifier { + return "some " + text + } else { + return nil + } + } + var substituted = base.text + + while let token = iterator.next() { + switch token.tokenKind { + case .leftAngle: + substituted += "<" + case .rightAngle: + substituted += ">" + case .comma: + substituted += "," + case .identifier(let identifier): + let type: TypeSyntax = "\(raw: identifier)" + guard let substituedType = type.genericSubstitution(parameters) else { + return nil + } + substituted += substituedType + break + default: + // ignore? + break + } + } + + return substituted + } +} + +extension FunctionDeclSyntax { + var isInstance: Bool { + for modifier in modifiers { + for token in modifier.tokens(viewMode: .all) { + if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { + return false + } + } + } + return true + } + + struct SignatureStandin: Equatable { + var isInstance: Bool + var identifier: String + var parameters: [String] + var returnType: String + } + + var signatureStandin: SignatureStandin { + var parameters = [String]() + for parameter in signature.parameterClause.parameters { + parameters.append( + parameter.firstName.text + ":" + + (parameter.type.genericSubstitution(genericParameterClause?.parameters) ?? "")) + } + let returnType = + signature.returnClause?.type.genericSubstitution(genericParameterClause?.parameters) ?? "Void" + return SignatureStandin( + isInstance: isInstance, identifier: name.text, parameters: parameters, returnType: returnType) + } + + func isEquivalent(to other: FunctionDeclSyntax) -> Bool { + return signatureStandin == other.signatureStandin + } +} + +extension DeclGroupSyntax { + var memberFunctionStandins: [FunctionDeclSyntax.SignatureStandin] { + var standins = [FunctionDeclSyntax.SignatureStandin]() + for member in memberBlock.members { + if let function = member.as(MemberBlockItemSyntax.self)?.decl.as(FunctionDeclSyntax.self) { + standins.append(function.signatureStandin) + } + } + return standins + } + + func hasMemberFunction(equvalentTo other: FunctionDeclSyntax) -> Bool { + for member in memberBlock.members { + if let function = member.as(MemberBlockItemSyntax.self)?.decl.as(FunctionDeclSyntax.self) { + if function.isEquivalent(to: other) { + return true + } + } + } + return false + } + + func hasMemberProperty(equivalentTo other: VariableDeclSyntax) -> Bool { + for member in memberBlock.members { + if let variable = member.as(MemberBlockItemSyntax.self)?.decl.as(VariableDeclSyntax.self) { + if variable.isEquivalent(to: other) { + return true + } + } + } + return false + } + + var definedVariables: [VariableDeclSyntax] { + memberBlock.members.compactMap { member in + if let variableDecl = member.as(MemberBlockItemSyntax.self)?.decl.as(VariableDeclSyntax.self) + { + return variableDecl + } + return nil + } + } + + func addIfNeeded(_ decl: DeclSyntax?, to declarations: inout [DeclSyntax]) { + guard let decl else { return } + if let fn = decl.as(FunctionDeclSyntax.self) { + if !hasMemberFunction(equvalentTo: fn) { + declarations.append(decl) + } + } else if let property = decl.as(VariableDeclSyntax.self) { + if !hasMemberProperty(equivalentTo: property) { + declarations.append(decl) + } + } + } + + var isClass: Bool { + return self.is(ClassDeclSyntax.self) + } + + var isActor: Bool { + return self.is(ActorDeclSyntax.self) + } + + var isEnum: Bool { + return self.is(EnumDeclSyntax.self) + } + + var isStruct: Bool { + return self.is(StructDeclSyntax.self) + } +} diff --git a/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift new file mode 100644 index 000000000000..81dd49f26f15 --- /dev/null +++ b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift @@ -0,0 +1,544 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros + +public struct ObservableStateMacro { + static let moduleName = "ComposableArchitecture" + + static let conformanceName = "ObservableState" + static var qualifiedConformanceName: String { + return "\(moduleName).\(conformanceName)" + } + static let originalConformanceName = "Observable" + static var qualifiedOriginalConformanceName: String { + "Observation.\(originalConformanceName)" + } + + static var observableConformanceType: TypeSyntax { + "\(raw: qualifiedConformanceName)" + } + + static let registrarTypeName = "ObservationStateRegistrar" + static var qualifiedRegistrarTypeName: String { + "\(moduleName).\(registrarTypeName)" + } + + static let idName = "ObservableStateID" + static var qualifiedIDName: String { + "\(moduleName).\(idName)" + } + + static let trackedMacroName = "ObservationStateTracked" + static let ignoredMacroName = "ObservationStateIgnored" + static let presentsMacroName = "Presents" + static let presentationStatePropertyWrapperName = "PresentationState" + + static let registrarVariableName = "_$observationRegistrar" + + static func registrarVariable(_ observableType: TokenSyntax) -> DeclSyntax { + return + """ + @\(raw: ignoredMacroName) var \(raw: registrarVariableName) = \(raw: qualifiedRegistrarTypeName)() + """ + } + + static func idVariable(_ access: DeclModifierListSyntax.Element?) -> DeclSyntax { + return + """ + \(access)var _$id: \(raw: qualifiedIDName) { + \(raw: registrarVariableName).id + } + """ + } + + static func willModifyFunction(_ access: DeclModifierListSyntax.Element?) -> DeclSyntax { + return + """ + \(access)mutating func _$willModify() { + \(raw: registrarVariableName)._$willModify() + } + """ + } + + static var ignoredAttribute: AttributeSyntax { + AttributeSyntax( + leadingTrivia: .space, + atSign: .atSignToken(), + attributeName: IdentifierTypeSyntax(name: .identifier(ignoredMacroName)), + trailingTrivia: .space + ) + } +} + +struct ObservationDiagnostic: DiagnosticMessage { + enum ID: String { + case invalidApplication = "invalid type" + case missingInitializer = "missing initializer" + } + + var message: String + var diagnosticID: MessageID + var severity: DiagnosticSeverity + + init( + message: String, diagnosticID: SwiftDiagnostics.MessageID, + severity: SwiftDiagnostics.DiagnosticSeverity = .error + ) { + self.message = message + self.diagnosticID = diagnosticID + self.severity = severity + } + + init( + message: String, domain: String, id: ID, severity: SwiftDiagnostics.DiagnosticSeverity = .error + ) { + self.message = message + self.diagnosticID = MessageID(domain: domain, id: id.rawValue) + self.severity = severity + } +} + +extension DiagnosticsError { + init( + syntax: S, message: String, domain: String = "Observation", id: ObservationDiagnostic.ID, + severity: SwiftDiagnostics.DiagnosticSeverity = .error + ) { + self.init(diagnostics: [ + Diagnostic( + node: Syntax(syntax), + message: ObservationDiagnostic(message: message, domain: domain, id: id, severity: severity) + ) + ]) + } +} + +extension DeclModifierListSyntax { + func privatePrefixed(_ prefix: String) -> DeclModifierListSyntax { + let modifier: DeclModifierSyntax = DeclModifierSyntax(name: "private", trailingTrivia: .space) + return [modifier] + + filter { + switch $0.name.tokenKind { + case .keyword(let keyword): + switch keyword { + case .fileprivate, .private, .internal, .public: + return false + default: + return true + } + default: + return true + } + } + } + + init(keyword: Keyword) { + self.init([DeclModifierSyntax(name: .keyword(keyword))]) + } +} + +extension TokenSyntax { + func privatePrefixed(_ prefix: String) -> TokenSyntax { + switch tokenKind { + case .identifier(let identifier): + return TokenSyntax( + .identifier(prefix + identifier), leadingTrivia: leadingTrivia, + trailingTrivia: trailingTrivia, presence: presence) + default: + return self + } + } +} + +extension PatternBindingListSyntax { + func privatePrefixed(_ prefix: String) -> PatternBindingListSyntax { + var bindings = self.map { $0 } + for index in 0.. VariableDeclSyntax + { + let newAttributes = attributes + [.attribute(attribute)] + return VariableDeclSyntax( + leadingTrivia: leadingTrivia, + attributes: newAttributes, + modifiers: modifiers.privatePrefixed(prefix), + bindingSpecifier: TokenSyntax( + bindingSpecifier.tokenKind, leadingTrivia: .space, trailingTrivia: .space, + presence: .present), + bindings: bindings.privatePrefixed(prefix), + trailingTrivia: trailingTrivia + ) + } + + var isValidForObservation: Bool { + !isComputed && isInstance && !isImmutable && identifier != nil + } +} + +extension ObservableStateMacro: MemberMacro { + public static func expansion< + Declaration: DeclGroupSyntax, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + providingMembersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + guard !declaration.isEnum + else { + return try enumExpansion(of: node, providingMembersOf: declaration, in: context) + } + + guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else { + return [] + } + + let observableType = identified.name.trimmed + + if declaration.isClass { + // classes are not supported + throw DiagnosticsError( + syntax: node, + message: "'@ObservableState' cannot be applied to class type '\(observableType.text)'", + id: .invalidApplication) + } + if declaration.isActor { + // actors cannot yet be supported for their isolation + throw DiagnosticsError( + syntax: node, + message: "'@ObservableState' cannot be applied to actor type '\(observableType.text)'", + id: .invalidApplication) + } + + var declarations = [DeclSyntax]() + + let access = declaration.modifiers.first { $0.name.tokenKind == .keyword(.public) } + declaration.addIfNeeded( + ObservableStateMacro.registrarVariable(observableType), to: &declarations) + declaration.addIfNeeded(ObservableStateMacro.idVariable(access), to: &declarations) + declaration.addIfNeeded(ObservableStateMacro.willModifyFunction(access), to: &declarations) + + return declarations + } +} + +extension ObservableStateMacro { + public static func enumExpansion< + Declaration: DeclGroupSyntax, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + providingMembersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + let access = declaration.modifiers.first { $0.name.tokenKind == .keyword(.public) } + + let enumCaseDecls = declaration.memberBlock.members + .flatMap { $0.decl.as(EnumCaseDeclSyntax.self)?.elements ?? [] } + var getCases: [String] = [] + var willModifyCases: [String] = [] + for (tag, enumCaseDecl) in enumCaseDecls.enumerated() { + // TODO: Support multiple parameters of observable state? + if let parameters = enumCaseDecl.parameterClause?.parameters, + parameters.count == 1, + let parameter = parameters.first + { + getCases.append( + """ + case let .\(enumCaseDecl.name.text)(state): + return ._$id(for: state)._$tag(\(tag)) + """ + ) + willModifyCases.append( + """ + case var .\(enumCaseDecl.name.text)(state): + \(moduleName)._$willModify(&state) + self = .\(enumCaseDecl.name.text)(\(parameter.firstName.map { "\($0): " } ?? "")state) + """ + ) + } else { + getCases.append( + """ + case .\(enumCaseDecl.name.text): + return ._$inert._$tag(\(tag)) + """ + ) + willModifyCases.append( + """ + case .\(enumCaseDecl.name.text): + break + """ + ) + } + } + + return [ + """ + \(access)var _$id: \(raw: qualifiedIDName) { + switch self { + \(raw: getCases.joined(separator: "\n")) + } + } + """, + """ + \(access)mutating func _$willModify() { + switch self { + \(raw: willModifyCases.joined(separator: "\n")) + } + } + """, + ] + } +} + +extension SyntaxStringInterpolation { + // It would be nice for SwiftSyntaxBuilder to provide this out-of-the-box. + mutating func appendInterpolation(_ node: Node?) { + if let node { + appendInterpolation(node) + } + } +} + +extension ObservableStateMacro: MemberAttributeMacro { + public static func expansion< + Declaration: DeclGroupSyntax, + MemberDeclaration: DeclSyntaxProtocol, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + attachedTo declaration: Declaration, + providingAttributesFor member: MemberDeclaration, + in context: Context + ) throws -> [AttributeSyntax] { + guard let property = member.as(VariableDeclSyntax.self), property.isValidForObservation, + property.identifier != nil + else { + return [] + } + + // dont apply to ignored properties or properties that are already flagged as tracked + if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) + || property.hasMacroApplication(ObservableStateMacro.trackedMacroName) + { + return [] + } + + property.diagnose( + attribute: "ObservationIgnored", + renamed: ObservableStateMacro.ignoredMacroName, + context: context + ) + property.diagnose( + attribute: "ObservationTracked", + renamed: ObservableStateMacro.trackedMacroName, + context: context + ) + property.diagnose( + attribute: "PresentationState", + renamed: ObservableStateMacro.presentsMacroName, + context: context + ) + + if property.hasMacroApplication(ObservableStateMacro.presentsMacroName) { + return [ + AttributeSyntax( + attributeName: IdentifierTypeSyntax( + name: .identifier(ObservableStateMacro.ignoredMacroName))) + ] + } + + return [ + AttributeSyntax( + attributeName: IdentifierTypeSyntax( + name: .identifier(ObservableStateMacro.trackedMacroName))) + ] + } +} + +extension VariableDeclSyntax { + func diagnose( + attribute name: String, + renamed rename: String, + context: C + ) { + if let attribute = self.firstAttribute(for: name) { + context.diagnose( + Diagnostic( + node: attribute, + message: MacroExpansionErrorMessage("'@\(name)' cannot be used in '@ObservableState'"), + fixIt: .replace( + message: MacroExpansionFixItMessage("Use '@\(rename)' instead"), + oldNode: attribute, + newNode: attribute.with( + \.attributeName, TypeSyntax(IdentifierTypeSyntax(name: .identifier(rename))) + ) + ) + ) + ) + } + } +} + +extension ObservableStateMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + // This method can be called twice - first with an empty `protocols` when + // no conformance is needed, and second with a `MissingTypeSyntax` instance. + if protocols.isEmpty { + return [] + } + + return [ + ( + """ + extension \(raw: type.trimmedDescription): \(raw: qualifiedConformanceName), \ + Observation.Observable {} + """ as DeclSyntax + ) + .cast(ExtensionDeclSyntax.self) + ] + } +} + +public struct ObservationStateTrackedMacro: AccessorMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingAccessorsOf declaration: Declaration, + in context: Context + ) throws -> [AccessorDeclSyntax] { + guard let property = declaration.as(VariableDeclSyntax.self), + property.isValidForObservation, + let identifier = property.identifier?.trimmed + else { + return [] + } + + if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) + || property.hasMacroApplication(ObservableStateMacro.presentationStatePropertyWrapperName) + || property.hasMacroApplication(ObservableStateMacro.presentsMacroName) + { + return [] + } + + let initAccessor: AccessorDeclSyntax = + """ + @storageRestrictions(initializes: _\(identifier)) + init(initialValue) { + _\(identifier) = initialValue + } + """ + + let getAccessor: AccessorDeclSyntax = + """ + get { + \(raw: ObservableStateMacro.registrarVariableName).access(self, keyPath: \\.\(identifier)) + return _\(identifier) + } + """ + + let setAccessor: AccessorDeclSyntax = + """ + set { + \(raw: ObservableStateMacro.registrarVariableName).mutate(self, keyPath: \\.\(identifier), &_\(identifier), newValue, _$isIdentityEqual) + } + """ + let modifyAccessor: AccessorDeclSyntax = """ + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \\.\(identifier), &_\(identifier)) + defer { + _$observationRegistrar.didModify(self, keyPath: \\.\(identifier), &_\(identifier), oldValue, _$isIdentityEqual) + } + yield &_\(identifier) + } + """ + + return [initAccessor, getAccessor, setAccessor, modifyAccessor] + } +} + +extension ObservationStateTrackedMacro: PeerMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: SwiftSyntax.AttributeSyntax, + providingPeersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + guard let property = declaration.as(VariableDeclSyntax.self), + property.isValidForObservation + else { + return [] + } + + if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) + || property.hasMacroApplication(ObservableStateMacro.presentationStatePropertyWrapperName) + || property.hasMacroApplication(ObservableStateMacro.presentsMacroName) + || property.hasMacroApplication(ObservableStateMacro.trackedMacroName) + { + return [] + } + + let storage = DeclSyntax( + property.privatePrefixed("_", addingAttribute: ObservableStateMacro.ignoredAttribute)) + return [storage] + } +} + +public struct ObservationStateIgnoredMacro: AccessorMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingAccessorsOf declaration: Declaration, + in context: Context + ) throws -> [AccessorDeclSyntax] { + return [] + } +} diff --git a/Sources/ComposableArchitectureMacros/Plugins.swift b/Sources/ComposableArchitectureMacros/Plugins.swift index 44236821a2fd..bf1815642b13 100644 --- a/Sources/ComposableArchitectureMacros/Plugins.swift +++ b/Sources/ComposableArchitectureMacros/Plugins.swift @@ -4,6 +4,11 @@ import SwiftSyntaxMacros @main struct MacrosPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ - ReducerMacro.self + ObservableStateMacro.self, + ObservationStateTrackedMacro.self, + ObservationStateIgnoredMacro.self, + PresentsMacro.self, + ReducerMacro.self, + ViewActionMacro.self, ] } diff --git a/Sources/ComposableArchitectureMacros/PresentsMacro.swift b/Sources/ComposableArchitectureMacros/PresentsMacro.swift new file mode 100644 index 000000000000..303ff8fb47ac --- /dev/null +++ b/Sources/ComposableArchitectureMacros/PresentsMacro.swift @@ -0,0 +1,228 @@ +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros + +public enum PresentsMacro { +} + +extension PresentsMacro: AccessorMacro { + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: D, + in context: C + ) throws -> [AccessorDeclSyntax] { + guard + let property = declaration.as(VariableDeclSyntax.self), + property.isValidForPresentation, + let identifier = property.identifier?.trimmed + else { + return [] + } + + let initAccessor: AccessorDeclSyntax = + """ + @storageRestrictions(initializes: _\(identifier)) + init(initialValue) { + _\(identifier) = PresentationState(wrappedValue: initialValue) + } + """ + + let getAccessor: AccessorDeclSyntax = + """ + get { + _$observationRegistrar.access(self, keyPath: \\.\(identifier)) + return _\(identifier).wrappedValue + } + """ + + let setAccessor: AccessorDeclSyntax = + """ + set { + _$observationRegistrar.mutate(self, keyPath: \\.\(identifier), &_\(identifier).wrappedValue, newValue, _$isIdentityEqual) + } + """ + + // TODO: _modify accessor? + + return [initAccessor, getAccessor, setAccessor] + } +} + +extension PresentsMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: D, + in context: C + ) throws -> [DeclSyntax] { + guard + let property = declaration.as(VariableDeclSyntax.self), + property.isValidForPresentation + else { + return [] + } + + let wrapped = DeclSyntax( + property.privateWrapped(addingAttribute: ObservableStateMacro.ignoredAttribute) + ) + let projected = DeclSyntax(property.projected) + return [ + projected, + wrapped, + ] + } +} + +extension VariableDeclSyntax { + fileprivate func privateWrapped( + addingAttribute attribute: AttributeSyntax + ) -> VariableDeclSyntax { + var attributes = self.attributes + for index in attributes.indices.reversed() { + let attribute = attributes[index] + switch attribute { + case let .attribute(attribute): + if attribute.attributeName.tokens(viewMode: .all).map(\.tokenKind) == [ + .identifier("Presents") + ] { + attributes.remove(at: index) + } + default: + break + } + } + let newAttributes = attributes + [.attribute(attribute)] + return VariableDeclSyntax( + leadingTrivia: leadingTrivia, + attributes: newAttributes, + modifiers: modifiers.privatePrefixed("_"), + bindingSpecifier: TokenSyntax( + bindingSpecifier.tokenKind, trailingTrivia: .space, + presence: .present + ), + bindings: bindings.privateWrapped, + trailingTrivia: trailingTrivia + ) + } + + fileprivate var projected: VariableDeclSyntax { + VariableDeclSyntax( + leadingTrivia: leadingTrivia, + modifiers: modifiers, + bindingSpecifier: TokenSyntax( + bindingSpecifier.tokenKind, trailingTrivia: .space, + presence: .present + ), + bindings: bindings.projected, + trailingTrivia: trailingTrivia + ) + } + + fileprivate var isValidForPresentation: Bool { + !isComputed && isInstance && !isImmutable && identifier != nil + } +} + +extension PatternBindingListSyntax { + var privateWrapped: PatternBindingListSyntax { + var bindings = self + for index in bindings.indices { + var binding = bindings[index] + if let optionalType = binding.typeAnnotation?.type.as(OptionalTypeSyntax.self) { + binding.typeAnnotation = nil + binding.initializer = InitializerClauseSyntax( + value: FunctionCallExprSyntax( + calledExpression: optionalType.wrappedType.presentationWrapped, + leftParen: .leftParenToken(), + arguments: [ + LabeledExprSyntax( + label: "wrappedValue", + expression: binding.initializer?.value ?? ExprSyntax(NilLiteralExprSyntax()) + ) + ], + rightParen: .rightParenToken() + ) + ) + } + if let identifier = binding.pattern.as(IdentifierPatternSyntax.self) { + bindings[index] = PatternBindingSyntax( + leadingTrivia: binding.leadingTrivia, + pattern: IdentifierPatternSyntax( + leadingTrivia: identifier.leadingTrivia, + identifier: identifier.identifier.privatePrefixed("_"), + trailingTrivia: identifier.trailingTrivia + ), + typeAnnotation: binding.typeAnnotation, + initializer: binding.initializer, + accessorBlock: binding.accessorBlock, + trailingComma: binding.trailingComma, + trailingTrivia: binding.trailingTrivia + ) + } + } + + return bindings + } + + var projected: PatternBindingListSyntax { + var bindings = self + for index in bindings.indices { + var binding = bindings[index] + if let optionalType = binding.typeAnnotation?.type.as(OptionalTypeSyntax.self) { + binding.typeAnnotation?.type = TypeSyntax( + IdentifierTypeSyntax( + name: .identifier(optionalType.wrappedType.presentationWrapped.trimmedDescription) + ) + ) + } + if let identifier = binding.pattern.as(IdentifierPatternSyntax.self) { + bindings[index] = PatternBindingSyntax( + leadingTrivia: binding.leadingTrivia, + pattern: IdentifierPatternSyntax( + leadingTrivia: identifier.leadingTrivia, + identifier: identifier.identifier.privatePrefixed("$"), + trailingTrivia: identifier.trailingTrivia + ), + typeAnnotation: binding.typeAnnotation, + accessorBlock: AccessorBlockSyntax( + accessors: .accessors([ + """ + get { + _$observationRegistrar.access(self, keyPath: \\.\(identifier)) + return _\(identifier.identifier).projectedValue + } + """, + """ + set { + _$observationRegistrar.mutate(self, keyPath: \\.\(identifier), &_\(identifier).projectedValue, newValue, _$isIdentityEqual) + } + """, + ]) + ) + ) + } + } + + return bindings + } +} + +extension TypeSyntax { + fileprivate var presentationWrapped: GenericSpecializationExprSyntax { + GenericSpecializationExprSyntax( + expression: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: "ComposableArchitecture"), + name: "PresentationState" + ), + genericArgumentClause: GenericArgumentClauseSyntax( + arguments: [ + GenericArgumentSyntax( + argument: self + ) + ] + ) + ) + } +} diff --git a/Sources/ComposableArchitectureMacros/ViewActionMacro.swift b/Sources/ComposableArchitectureMacros/ViewActionMacro.swift new file mode 100644 index 000000000000..0b89518daef1 --- /dev/null +++ b/Sources/ComposableArchitectureMacros/ViewActionMacro.swift @@ -0,0 +1,185 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros + +public struct ViewActionMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: D, + providingExtensionsOf type: T, + conformingTo protocols: [TypeSyntax], + in context: C + ) throws -> [ExtensionDeclSyntax] { + guard + case let .argumentList(arguments) = node.arguments, + arguments.count == 1, + let memberAccessExpr = arguments.first?.expression.as(MemberAccessExprSyntax.self) + else { return [] } + let inputType = String("\(memberAccessExpr)".dropLast(5)) + + guard declaration.hasStoreVariable + else { + var declarationWithStoreVariable = declaration + declarationWithStoreVariable.memberBlock.members.insert( + MemberBlockItemSyntax( + leadingTrivia: declarationWithStoreVariable.memberBlock.members.first?.leadingTrivia + ?? "\n ", + decl: VariableDeclSyntax( + bindingSpecifier: declaration.modifiers + .contains(where: { $0.name.tokenKind == .keyword(.public) }) + ? "public let" + : "let", + bindings: [ + PatternBindingSyntax( + pattern: " store" as PatternSyntax, + typeAnnotation: TypeAnnotationSyntax( + type: " StoreOf<\(raw: inputType)>" as TypeSyntax + ) + ) + ] + ), + trailingTrivia: .newline + ), + at: declarationWithStoreVariable.memberBlock.members.startIndex + ) + + context.diagnose( + Diagnostic( + node: declaration, + message: MacroExpansionErrorMessage( + """ + '@ViewAction' requires \ + \(declaration.identifierDescription.map { "'\($0)' " } ?? " ")to have a 'store' \ + property of type 'Store'. + """ + ), + fixIt: .replace( + message: MacroExpansionFixItMessage("Add 'store'"), + oldNode: declaration, + newNode: declarationWithStoreVariable + ) + ) + ) + return [] + } + + declaration.diagnoseDirectStoreDotSend( + declaration: declaration, + context: context + ) + + let ext: DeclSyntax = + """ + extension \(type.trimmed): ComposableArchitecture.ViewActionSending {} + """ + return [ext.cast(ExtensionDeclSyntax.self)] + } +} + +extension SyntaxProtocol { + func diagnoseDirectStoreDotSend( + declaration: D, + context: some MacroExpansionContext + ) { + for decl in declaration.children(viewMode: .fixedUp) { + if let functionCall = decl.as(FunctionCallExprSyntax.self) { + if let sendExpression = functionCall.sendExpression { + var fixIt: FixIt? + if let outer = functionCall.arguments.first, + let inner = + outer + .as(LabeledExprSyntax.self)?.expression + .as(FunctionCallExprSyntax.self), + inner.calledExpression + .as(MemberAccessExprSyntax.self)?.declName.baseName.text == "view", + inner.arguments.count == 1 + { + var newFunctionCall = functionCall + newFunctionCall.calledExpression = sendExpression + newFunctionCall.arguments = inner.arguments + fixIt = .replace( + message: MacroExpansionFixItMessage("Call 'send' directly with a view action"), + oldNode: functionCall, + newNode: newFunctionCall + ) + } + context.diagnose( + Diagnostic( + node: decl, + message: MacroExpansionWarningMessage( + """ + Do not use 'store.send' directly when using '@ViewAction' + """ + ), + highlights: [decl], + fixIts: fixIt.map { [$0] } ?? [] + ) + ) + } + } + decl.diagnoseDirectStoreDotSend(declaration: decl, context: context) + } + } +} + +extension DeclGroupSyntax { + fileprivate var hasStoreVariable: Bool { + self.memberBlock.members.contains(where: { member in + if let variableDecl = member.decl.as(VariableDeclSyntax.self), + let firstBinding = variableDecl.bindings.first, + let identifierPattern = firstBinding.pattern.as(IdentifierPatternSyntax.self), + identifierPattern.identifier.text == "store" + { + return true + } else { + return false + } + }) + } +} + +extension DeclGroupSyntax { + var identifierDescription: String? { + switch self { + case let syntax as ActorDeclSyntax: + return syntax.name.trimmedDescription + case let syntax as ClassDeclSyntax: + return syntax.name.trimmedDescription + case let syntax as ExtensionDeclSyntax: + return syntax.extendedType.trimmedDescription + case let syntax as ProtocolDeclSyntax: + return syntax.name.trimmedDescription + case let syntax as StructDeclSyntax: + return syntax.name.trimmedDescription + case let syntax as EnumDeclSyntax: + return syntax.name.trimmedDescription + default: + return nil + } + } +} + +extension FunctionCallExprSyntax { + fileprivate var sendExpression: ExprSyntax? { + guard + let memberAccess = self.calledExpression.as(MemberAccessExprSyntax.self), + memberAccess.declName.baseName.text == "send" + else { return nil } + + if memberAccess.base?.as(DeclReferenceExprSyntax.self)?.baseName.text == "store" { + return ExprSyntax(DeclReferenceExprSyntax(baseName: "send")) + } + + if let innerMemberAccess = memberAccess.base?.as(MemberAccessExprSyntax.self), + innerMemberAccess.base?.as(DeclReferenceExprSyntax.self)?.baseName.text == "self", + innerMemberAccess.declName.baseName.text == "store" + { + return ExprSyntax( + MemberAccessExprSyntax(base: DeclReferenceExprSyntax(baseName: "self"), name: "send") + ) + } + + return nil + } +} diff --git a/Sources/swift-composable-architecture-benchmark/Observation.swift b/Sources/swift-composable-architecture-benchmark/Observation.swift new file mode 100644 index 000000000000..f8df0d256646 --- /dev/null +++ b/Sources/swift-composable-architecture-benchmark/Observation.swift @@ -0,0 +1,176 @@ +import Benchmark +import ComposableArchitecture +import Foundation + +let observationSuite = BenchmarkSuite(name: "Observation") { suite in + #if swift(>=5.9) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + var stateWithObservation: StateWithObservation! + suite.benchmark("ObservableState: Mutate count") { + doNotOptimizeAway(stateWithObservation.count += 1) + } setUp: { + stateWithObservation = StateWithObservation() + } tearDown: { + stateWithObservation = nil + } + suite.benchmark("ObservableState: Mutate name") { + doNotOptimizeAway(stateWithObservation.name += "!!!") + } setUp: { + stateWithObservation = StateWithObservation() + } tearDown: { + stateWithObservation = nil + } + suite.benchmark("ObservableState: Append item") { + doNotOptimizeAway(stateWithObservation.items.append(Item())) + } setUp: { + stateWithObservation = StateWithObservation() + } tearDown: { + stateWithObservation = nil + } + suite.benchmark("ObservableState: Mutate item") { + doNotOptimizeAway(stateWithObservation.items[0].name += "!!!") + } setUp: { + stateWithObservation = StateWithObservation() + } tearDown: { + stateWithObservation = nil + } + + var stateWithoutObservation: StateWithoutObservation! + suite.benchmark("State: Mutate count") { + doNotOptimizeAway(stateWithoutObservation.count += 1) + } setUp: { + stateWithoutObservation = StateWithoutObservation() + } tearDown: { + stateWithoutObservation = nil + } + suite.benchmark("State: Mutate name") { + doNotOptimizeAway(stateWithoutObservation.name += "!!!") + } setUp: { + stateWithoutObservation = StateWithoutObservation() + } tearDown: { + stateWithoutObservation = nil + } + suite.benchmark("State: Append item") { + doNotOptimizeAway(stateWithoutObservation.items.append(Item())) + } setUp: { + stateWithoutObservation = StateWithoutObservation() + } tearDown: { + stateWithoutObservation = nil + } + suite.benchmark("State: Mutate item") { + doNotOptimizeAway(stateWithoutObservation.items[0].name += "!!!") + } setUp: { + stateWithoutObservation = StateWithoutObservation() + } tearDown: { + stateWithoutObservation = nil + } + + var objectWithObservation: ObjectWithObservation! + suite.benchmark("Observable: Mutate count") { + doNotOptimizeAway(objectWithObservation.count += 1) + } setUp: { + objectWithObservation = ObjectWithObservation() + } tearDown: { + objectWithObservation = nil + } + suite.benchmark("Observable: Mutate name") { + doNotOptimizeAway(objectWithObservation.name += "!!!") + } setUp: { + objectWithObservation = ObjectWithObservation() + } tearDown: { + objectWithObservation = nil + } + suite.benchmark("Observable: Append item") { + doNotOptimizeAway(objectWithObservation.items.append(Item())) + } setUp: { + objectWithObservation = ObjectWithObservation() + } tearDown: { + objectWithObservation = nil + } + suite.benchmark("Observable: Mutate item") { + doNotOptimizeAway(objectWithObservation.items[0].name += "!!!") + } setUp: { + objectWithObservation = ObjectWithObservation() + } tearDown: { + objectWithObservation = nil + } + + var objectWithoutObservation: ObjectWithoutObservation! + suite.benchmark("Class: Mutate count") { + doNotOptimizeAway(objectWithoutObservation.count += 1) + } setUp: { + objectWithoutObservation = ObjectWithoutObservation() + } tearDown: { + objectWithoutObservation = nil + } + suite.benchmark("Class: Mutate name") { + doNotOptimizeAway(objectWithoutObservation.name += "!!!") + } setUp: { + objectWithoutObservation = ObjectWithoutObservation() + } tearDown: { + objectWithoutObservation = nil + } + suite.benchmark("Class: Append item") { + doNotOptimizeAway(objectWithoutObservation.items.append(Item())) + } setUp: { + objectWithoutObservation = ObjectWithoutObservation() + } tearDown: { + objectWithoutObservation = nil + } + suite.benchmark("Class: Mutate item") { + doNotOptimizeAway(objectWithoutObservation.items[0].name += "!!!") + } setUp: { + objectWithoutObservation = ObjectWithoutObservation() + } tearDown: { + objectWithoutObservation = nil + } + } + #endif +} + +#if swift(>=5.9) + @ObservableState + private struct StateWithObservation { + var count = 0 + var name = "" + var items: IdentifiedArrayOf = .items + } + + private struct StateWithoutObservation { + var count = 0 + var name = "" + var items: IdentifiedArrayOf = .items + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Observable + private class ObjectWithObservation { + var count = 0 + var name = "" + var items: IdentifiedArrayOf = .items + } + + private class ObjectWithoutObservation { + var count = 0 + var name = "" + var items: IdentifiedArrayOf = .items + } + + @ObservableState + private struct Item: Identifiable { + let id = UUID() + var name = "" + var isInStock = false + } + + extension IdentifiedArrayOf { + fileprivate static var items: IdentifiedArrayOf { + [ + Item(name: "Computer", isInStock: true), + Item(name: "Monitor", isInStock: true), + Item(name: "Keyboard", isInStock: true), + Item(name: "Mouse", isInStock: true), + ] + } + } +#endif diff --git a/Sources/swift-composable-architecture-benchmark/main.swift b/Sources/swift-composable-architecture-benchmark/main.swift index 7a5fb7c2fd76..d5b65c707a2f 100644 --- a/Sources/swift-composable-architecture-benchmark/main.swift +++ b/Sources/swift-composable-architecture-benchmark/main.swift @@ -5,6 +5,7 @@ Benchmark.main([ defaultBenchmarkSuite, dependenciesSuite, effectSuite, + observationSuite, storeScopeSuite, storeSuite, viewStoreSuite, diff --git a/Tests/ComposableArchitectureMacrosTests/MacroBaseTestCase.swift b/Tests/ComposableArchitectureMacrosTests/MacroBaseTestCase.swift new file mode 100644 index 000000000000..0237efe096f2 --- /dev/null +++ b/Tests/ComposableArchitectureMacrosTests/MacroBaseTestCase.swift @@ -0,0 +1,24 @@ +#if canImport(ComposableArchitectureMacros) + import ComposableArchitectureMacros + import MacroTesting + import SwiftSyntaxMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + class MacroBaseTestCase: XCTestCase { + override func invokeTest() { + MacroTesting.withMacroTesting( + //isRecording: true, + macros: [ + ObservableStateMacro.self, + ObservationStateTrackedMacro.self, + ObservationStateIgnoredMacro.self, + PresentsMacro.self, + ViewActionMacro.self, + ] + ) { + super.invokeTest() + } + } + } +#endif diff --git a/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift b/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift new file mode 100644 index 000000000000..abc744a1b381 --- /dev/null +++ b/Tests/ComposableArchitectureMacrosTests/ObservableStateMacroTests.swift @@ -0,0 +1,346 @@ +#if canImport(ComposableArchitectureMacros) + import ComposableArchitectureMacros + import MacroTesting + import SwiftSyntaxMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + final class ObservableStateMacroTests: MacroBaseTestCase { + override func invokeTest() { + withMacroTesting { + super.invokeTest() + } + } + + func testAvailability() { + assertMacro { + """ + @ObservableState + @available(iOS 18, *) + struct State { + var count = 0 + } + """ + } expansion: { + #""" + @available(iOS 18, *) + struct State { + var count = 0 { + @storageRestrictions(initializes: _count) + init(initialValue) { + _count = initialValue + } + get { + _$observationRegistrar.access(self, keyPath: \.count) + return _count + } + set { + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + } + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) + defer { + _$observationRegistrar.didModify(self, keyPath: \.count, &_count, oldValue, _$isIdentityEqual) + } + yield &_count + } + } + + var _$observationRegistrar = ComposableArchitecture.ObservationStateRegistrar() + + var _$id: ComposableArchitecture.ObservableStateID { + _$observationRegistrar.id + } + + mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """# + } + } + + func testObservableState() throws { + assertMacro { + #""" + @ObservableState + struct State { + var count = 0 + } + """# + } expansion: { + #""" + struct State { + var count = 0 { + @storageRestrictions(initializes: _count) + init(initialValue) { + _count = initialValue + } + get { + _$observationRegistrar.access(self, keyPath: \.count) + return _count + } + set { + _$observationRegistrar.mutate(self, keyPath: \.count, &_count, newValue, _$isIdentityEqual) + } + _modify { + let oldValue = _$observationRegistrar.willModify(self, keyPath: \.count, &_count) + defer { + _$observationRegistrar.didModify(self, keyPath: \.count, &_count, oldValue, _$isIdentityEqual) + } + yield &_count + } + } + + var _$observationRegistrar = ComposableArchitecture.ObservationStateRegistrar() + + var _$id: ComposableArchitecture.ObservableStateID { + _$observationRegistrar.id + } + + mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """# + } + } + + func testObservableStateIgnored() throws { + assertMacro { + #""" + @ObservableState + struct State { + @ObservationStateIgnored + var count = 0 + } + """# + } expansion: { + """ + struct State { + var count = 0 + + var _$observationRegistrar = ComposableArchitecture.ObservationStateRegistrar() + + var _$id: ComposableArchitecture.ObservableStateID { + _$observationRegistrar.id + } + + mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """ + } + } + + func testObservableState_Enum() { + assertMacro { + """ + @ObservableState + enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + } + """ + } expansion: { + """ + enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + + var _$id: ComposableArchitecture.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + case let .feature2(state): + return ._$id(for: state)._$tag(1) + } + } + + mutating func _$willModify() { + switch self { + case var .feature1(state): + ComposableArchitecture._$willModify(&state) + self = .feature1(state) + case var .feature2(state): + ComposableArchitecture._$willModify(&state) + self = .feature2(state) + } + } + } + """ + } + } + + func testObservableState_Enum_Label() { + assertMacro { + """ + @ObservableState + enum Path { + case feature1(state: String) + } + """ + } expansion: { + """ + enum Path { + case feature1(state: String) + + var _$id: ComposableArchitecture.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + } + } + + mutating func _$willModify() { + switch self { + case var .feature1(state): + ComposableArchitecture._$willModify(&state) + self = .feature1(state: state) + } + } + } + """ + } + } + + func testObservableState_Enum_AccessControl() { + assertMacro { + """ + @ObservableState + public enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + } + """ + } expansion: { + """ + public enum Path { + case feature1(Feature1.State) + case feature2(Feature2.State) + + public var _$id: ComposableArchitecture.ObservableStateID { + switch self { + case let .feature1(state): + return ._$id(for: state)._$tag(0) + case let .feature2(state): + return ._$id(for: state)._$tag(1) + } + } + + public mutating func _$willModify() { + switch self { + case var .feature1(state): + ComposableArchitecture._$willModify(&state) + self = .feature1(state) + case var .feature2(state): + ComposableArchitecture._$willModify(&state) + self = .feature2(state) + } + } + } + """ + } + } + + func testObservableState_Enum_NonObservableCase() { + assertMacro { + """ + @ObservableState + public enum Path { + case foo(Int) + } + """ + } expansion: { + """ + public enum Path { + case foo(Int) + + public var _$id: ComposableArchitecture.ObservableStateID { + switch self { + case let .foo(state): + return ._$id(for: state)._$tag(0) + } + } + + public mutating func _$willModify() { + switch self { + case var .foo(state): + ComposableArchitecture._$willModify(&state) + self = .foo(state) + } + } + } + """ + } + } + + func testObservableState_Enum_MultipleAssociatedValues() { + assertMacro { + """ + @ObservableState + public enum Path { + case foo(Int, String) + } + """ + } expansion: { + """ + public enum Path { + case foo(Int, String) + + public var _$id: ComposableArchitecture.ObservableStateID { + switch self { + case .foo: + return ._$inert._$tag(0) + } + } + + public mutating func _$willModify() { + switch self { + case .foo: + break + } + } + } + """ + } + } + + func testObservableState_Class() { + assertMacro { + """ + @ObservableState + public class Model { + } + """ + } diagnostics: { + """ + @ObservableState + ┬─────────────── + ╰─ 🛑 '@ObservableState' cannot be applied to class type 'Model' + public class Model { + } + """ + } + } + + func testObservableState_Actor() { + assertMacro { + """ + @ObservableState + public actor Model { + } + """ + } diagnostics: { + """ + @ObservableState + ┬─────────────── + ╰─ 🛑 '@ObservableState' cannot be applied to actor type 'Model' + public actor Model { + } + """ + } + } + } +#endif diff --git a/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift b/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift new file mode 100644 index 000000000000..ec6fe49fd648 --- /dev/null +++ b/Tests/ComposableArchitectureMacrosTests/PresentsMacroTests.swift @@ -0,0 +1,146 @@ +#if canImport(ComposableArchitectureMacros) + import ComposableArchitectureMacros + import MacroTesting + import SwiftSyntaxMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + final class PresentsMacroTests: XCTestCase { + override func invokeTest() { + withMacroTesting( + // isRecording: true, + macros: [PresentsMacro.self] + ) { + super.invokeTest() + } + } + + func testBasics() { + assertMacro { + """ + struct State { + @Presents var destination: Destination.State? + } + """ + } expansion: { + #""" + struct State { + var destination: Destination.State? { + @storageRestrictions(initializes: _destination) + init(initialValue) { + _destination = PresentationState(wrappedValue: initialValue) + } + get { + _$observationRegistrar.access(self, keyPath: \.destination) + return _destination.wrappedValue + } + set { + _$observationRegistrar.mutate(self, keyPath: \.destination, &_destination.wrappedValue, newValue, _$isIdentityEqual) + } + } + + var $destination: ComposableArchitecture.PresentationState { + get { + _$observationRegistrar.access(self, keyPath: \.destination) + return _destination.projectedValue + } + set { + _$observationRegistrar.mutate(self, keyPath: \.destination, &_destination.projectedValue, newValue, _$isIdentityEqual) + } + } + + @ObservationStateIgnored private var _destination = ComposableArchitecture.PresentationState(wrappedValue: nil) + } + """# + } + } + + func testPublicAccess() { + assertMacro { + """ + public struct State { + @Presents public var destination: Destination.State? + } + """ + } expansion: { + #""" + public struct State { + public var destination: Destination.State? { + @storageRestrictions(initializes: _destination) + init(initialValue) { + _destination = PresentationState(wrappedValue: initialValue) + } + get { + _$observationRegistrar.access(self, keyPath: \.destination) + return _destination.wrappedValue + } + set { + _$observationRegistrar.mutate(self, keyPath: \.destination, &_destination.wrappedValue, newValue, _$isIdentityEqual) + } + } + + public var $destination: ComposableArchitecture.PresentationState { + get { + _$observationRegistrar.access(self, keyPath: \.destination) + return _destination.projectedValue + } + set { + _$observationRegistrar.mutate(self, keyPath: \.destination, &_destination.projectedValue, newValue, _$isIdentityEqual) + } + } + + @ObservationStateIgnored private var _destination = ComposableArchitecture.PresentationState(wrappedValue: nil) + } + """# + } + } + + func testObservableStateDiagnostic() { + assertMacro([ + ObservableStateMacro.self, + ObservationStateIgnoredMacro.self, + ObservationStateTrackedMacro.self, + PresentsMacro.self, + ]) { + """ + @ObservableState + struct State: Equatable { + @PresentationState var destination: Destination.State? + } + """ + } diagnostics: { + """ + @ObservableState + struct State: Equatable { + @PresentationState var destination: Destination.State? + ┬───────────────── + ╰─ 🛑 '@PresentationState' cannot be used in '@ObservableState' + ✏️ Use '@Presents' instead + } + """ + } fixes: { + """ + @ObservableState + struct State: Equatable { + @Presents + } + """ + } expansion: { + """ + struct State: Equatable { + + var _$observationRegistrar = ComposableArchitecture.ObservationStateRegistrar() + + var _$id: ComposableArchitecture.ObservableStateID { + _$observationRegistrar.id + } + + mutating func _$willModify() { + _$observationRegistrar._$willModify() + } + } + """ + } + } + } +#endif diff --git a/Tests/ComposableArchitectureMacrosTests/ViewActionMacroTests.swift b/Tests/ComposableArchitectureMacrosTests/ViewActionMacroTests.swift new file mode 100644 index 000000000000..bdbb52af627b --- /dev/null +++ b/Tests/ComposableArchitectureMacrosTests/ViewActionMacroTests.swift @@ -0,0 +1,394 @@ +#if canImport(ComposableArchitectureMacros) + import ComposableArchitectureMacros + import MacroTesting + import XCTest + + final class ViewActionMacroTests: XCTestCase { + override func invokeTest() { + withMacroTesting( + // isRecording: true, + macros: [ViewActionMacro.self] + ) { + super.invokeTest() + } + } + + func testLetStore() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + let store: StoreOf + var body: some View { + EmptyView() + } + } + """ + } expansion: { + """ + struct FeatureView: View { + let store: StoreOf + var body: some View { + EmptyView() + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testStateStore() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + @State var store: StoreOf + var body: some View { + EmptyView() + } + } + """ + } expansion: { + """ + struct FeatureView: View { + @State var store: StoreOf + var body: some View { + EmptyView() + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testStateStore_WithDefault() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + @State var store = Store(initialState: Feature.State()) { + Feature() + } + var body: some View { + EmptyView() + } + } + """ + } expansion: { + """ + struct FeatureView: View { + @State var store = Store(initialState: Feature.State()) { + Feature() + } + var body: some View { + EmptyView() + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testBindableStore() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + @Bindable var store: StoreOf + var body: some View { + EmptyView() + } + } + """ + } expansion: { + """ + struct FeatureView: View { + @Bindable var store: StoreOf + var body: some View { + EmptyView() + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testNoStore() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var body: some View { + EmptyView() + } + } + """ + } diagnostics: { + """ + @ViewAction(for: Feature.self) + ╰─ 🛑 '@ViewAction' requires 'FeatureView' to have a 'store' property of type 'Store'. + ✏️ Add 'store' + struct FeatureView: View { + var body: some View { + EmptyView() + } + } + """ + } fixes: { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + let store: StoreOf + + var body: some View { + EmptyView() + } + } + """ + } expansion: { + """ + struct FeatureView: View { + let store: StoreOf + + var body: some View { + EmptyView() + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testNoStore_Public() { + assertMacro { + """ + @ViewAction(for: Feature.self) + public struct FeatureView: View { + public var body: some View { + EmptyView() + } + } + """ + } diagnostics: { + """ + @ViewAction(for: Feature.self) + ╰─ 🛑 '@ViewAction' requires 'FeatureView' to have a 'store' property of type 'Store'. + ✏️ Add 'store' + public struct FeatureView: View { + public var body: some View { + EmptyView() + } + } + """ + } fixes: { + """ + @ViewAction(for: Feature.self) + public struct FeatureView: View { + public let store: StoreOf + + public var body: some View { + EmptyView() + } + } + """ + } expansion: { + """ + public struct FeatureView: View { + public let store: StoreOf + + public var body: some View { + EmptyView() + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testWarning_StoreSend() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { store.send(.tap) } + } + } + """ + } diagnostics: { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { store.send(.tap) } + ┬─────────────── + ╰─ ⚠️ Do not use 'store.send' directly when using '@ViewAction' + } + } + """ + } expansion: { + """ + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { store.send(.tap) } + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testWarning_SelfStoreSend() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { self.store.send(.tap) } + } + } + """ + } diagnostics: { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { self.store.send(.tap) } + ┬──────────────────── + ╰─ ⚠️ Do not use 'store.send' directly when using '@ViewAction' + } + } + """ + } expansion: { + """ + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { self.store.send(.tap) } + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testWarning_StoreSend_ViewAction() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { store.send(.view(.tap)) } + } + } + """ + } diagnostics: { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { store.send(.view(.tap)) } + ┬────────────────────── + ╰─ ⚠️ Do not use 'store.send' directly when using '@ViewAction' + ✏️ Call 'send' directly with a view action + } + } + """ + } fixes: { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { send(.tap) } + } + } + """ + } expansion: { + """ + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { send(.tap) } + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + + func testWarning_SelfStoreSend_ViewAction() { + assertMacro { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { self.store.send(.view(.tap)) } + } + } + """ + } diagnostics: { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { self.store.send(.view(.tap)) } + ┬─────────────────────────── + ╰─ ⚠️ Do not use 'store.send' directly when using '@ViewAction' + ✏️ Call 'send' directly with a view action + } + } + """ + } fixes: { + """ + @ViewAction(for: Feature.self) + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { self.send(.tap) } + } + } + """ + } expansion: { + """ + struct FeatureView: View { + var store: StoreOf + var body: some View { + Button("Tap") { self.send(.tap) } + } + } + + extension FeatureView: ComposableArchitecture.ViewActionSending { + } + """ + } + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/DependencyKeyWritingReducerTests.swift b/Tests/ComposableArchitectureTests/DependencyKeyWritingReducerTests.swift index 3156bea3fdf2..7fb50c82e2df 100644 --- a/Tests/ComposableArchitectureTests/DependencyKeyWritingReducerTests.swift +++ b/Tests/ComposableArchitectureTests/DependencyKeyWritingReducerTests.swift @@ -1,149 +1,151 @@ -import ComposableArchitecture -import XCTest - -@MainActor -final class DependencyKeyWritingReducerTests: BaseTCATestCase { - func testWritingFusion() async { - let reducer: _DependencyKeyWritingReducer = Feature() - .dependency(\.myValue, 42) - .dependency(\.myValue, 1729) - .dependency(\.myValue, 1) - .dependency(\.myValue, 2) - .dependency(\.myValue, 3) - - XCTAssertTrue((reducer as Any) is _DependencyKeyWritingReducer) - } - - func testTransformFusion() async { - let reducer: _DependencyKeyWritingReducer = Feature() - .transformDependency(\.myValue) { $0 = 42 } - .transformDependency(\.myValue) { $0 = 1729 } - .transformDependency(\.myValue) { $0 = 1 } - .transformDependency(\.myValue) { $0 = 2 } - .transformDependency(\.myValue) { $0 = 3 } - - XCTAssertTrue((reducer as Any) is _DependencyKeyWritingReducer) - } - - func testWritingFusionOrder() async { - let store = TestStore(initialState: Feature.State()) { - Feature() +#if swift(>=5.9) + import ComposableArchitecture + import XCTest + + @MainActor + final class DependencyKeyWritingReducerTests: BaseTCATestCase { + func testWritingFusion() async { + let reducer: _DependencyKeyWritingReducer = Feature() .dependency(\.myValue, 42) .dependency(\.myValue, 1729) - } + .dependency(\.myValue, 1) + .dependency(\.myValue, 2) + .dependency(\.myValue, 3) - await store.send(.tap) { - $0.value = 42 + XCTAssertTrue((reducer as Any) is _DependencyKeyWritingReducer) } - } - func testTransformFusionOrder() async { - let store = TestStore(initialState: Feature.State()) { - Feature() + func testTransformFusion() async { + let reducer: _DependencyKeyWritingReducer = Feature() .transformDependency(\.myValue) { $0 = 42 } .transformDependency(\.myValue) { $0 = 1729 } - } + .transformDependency(\.myValue) { $0 = 1 } + .transformDependency(\.myValue) { $0 = 2 } + .transformDependency(\.myValue) { $0 = 3 } - await store.send(.tap) { - $0.value = 42 + XCTAssertTrue((reducer as Any) is _DependencyKeyWritingReducer) } - } - func testWritingOrder() async { - let store = TestStore(initialState: Feature.State()) { - CombineReducers { + func testWritingFusionOrder() async { + let store = TestStore(initialState: Feature.State()) { Feature() .dependency(\.myValue, 42) + .dependency(\.myValue, 1729) } - .dependency(\.myValue, 1729) - } - await store.send(.tap) { - $0.value = 42 + await store.send(.tap) { + $0.value = 42 + } } - } - func testTransformOrder() async { - let store = TestStore(initialState: Feature.State()) { - CombineReducers { + func testTransformFusionOrder() async { + let store = TestStore(initialState: Feature.State()) { Feature() .transformDependency(\.myValue) { $0 = 42 } + .transformDependency(\.myValue) { $0 = 1729 } + } + + await store.send(.tap) { + $0.value = 42 } - .transformDependency(\.myValue) { $0 = 1729 } } - await store.send(.tap) { - $0.value = 42 + func testWritingOrder() async { + let store = TestStore(initialState: Feature.State()) { + CombineReducers { + Feature() + .dependency(\.myValue, 42) + } + .dependency(\.myValue, 1729) + } + + await store.send(.tap) { + $0.value = 42 + } + } + + func testTransformOrder() async { + let store = TestStore(initialState: Feature.State()) { + CombineReducers { + Feature() + .transformDependency(\.myValue) { $0 = 42 } + } + .transformDependency(\.myValue) { $0 = 1729 } + } + + await store.send(.tap) { + $0.value = 42 + } + } + + @Reducer + fileprivate struct Feature_testDependency_EffectOfEffect { + struct State: Equatable { var count = 0 } + enum Action: Equatable { + case tap + case response(Int) + case otherResponse(Int) + } + @Dependency(\.myValue) var myValue + + var body: some Reducer { + Reduce { state, action in + switch action { + case .tap: + state.count += 1 + return .run { send in await send(.response(self.myValue)) } + + case let .response(value): + state.count = value + return .run { send in await send(.otherResponse(self.myValue)) } + + case let .otherResponse(value): + state.count = value + return .none + } + } + } + } + func testDependency_EffectOfEffect() async { + let store = TestStore(initialState: Feature_testDependency_EffectOfEffect.State()) { + Feature_testDependency_EffectOfEffect() + .dependency(\.myValue, 42) + } + + await store.send(.tap) { + $0.count = 1 + } + await store.receive(.response(42)) { + $0.count = 42 + } + await store.receive(.otherResponse(42)) } } @Reducer - fileprivate struct Feature_testDependency_EffectOfEffect { - struct State: Equatable { var count = 0 } - enum Action: Equatable { - case tap - case response(Int) - case otherResponse(Int) - } + private struct Feature { @Dependency(\.myValue) var myValue - + struct State: Equatable { var value = 0 } + enum Action { case tap } var body: some Reducer { Reduce { state, action in switch action { case .tap: - state.count += 1 - return .run { send in await send(.response(self.myValue)) } - - case let .response(value): - state.count = value - return .run { send in await send(.otherResponse(self.myValue)) } - - case let .otherResponse(value): - state.count = value + state.value = self.myValue return .none } } } } - func testDependency_EffectOfEffect() async { - let store = TestStore(initialState: Feature_testDependency_EffectOfEffect.State()) { - Feature_testDependency_EffectOfEffect() - .dependency(\.myValue, 42) - } - await store.send(.tap) { - $0.count = 1 - } - await store.receive(.response(42)) { - $0.count = 42 - } - await store.receive(.otherResponse(42)) + private enum MyValue: DependencyKey { + static let liveValue = 0 + static let testValue = 0 } -} - -@Reducer -private struct Feature { - @Dependency(\.myValue) var myValue - struct State: Equatable { var value = 0 } - enum Action { case tap } - var body: some Reducer { - Reduce { state, action in - switch action { - case .tap: - state.value = self.myValue - return .none - } + extension DependencyValues { + var myValue: Int { + get { self[MyValue.self] } + set { self[MyValue.self] = newValue } } } -} - -private enum MyValue: DependencyKey { - static let liveValue = 0 - static let testValue = 0 -} -extension DependencyValues { - var myValue: Int { - get { self[MyValue.self] } - set { self[MyValue.self] = newValue } - } -} +#endif diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index 5ab2dd6ca002..97522266457e 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -1,20 +1,85 @@ -import Combine -@_spi(Canary) @_spi(Internals) import ComposableArchitecture -import XCTest - -@MainActor -final class EffectTests: BaseTCATestCase { - var cancellables: Set = [] - let mainQueue = DispatchQueue.test - - #if (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) - func testConcatenate() async { - if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - await withMainSerialExecutor { +#if swift(>=5.9) + import Combine + @_spi(Canary) @_spi(Internals) import ComposableArchitecture + import XCTest + + @MainActor + final class EffectTests: BaseTCATestCase { + var cancellables: Set = [] + let mainQueue = DispatchQueue.test + + #if (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) + func testConcatenate() async { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + await withMainSerialExecutor { + let clock = TestClock() + let values = LockIsolated<[Int]>([]) + + let effect = Effect.concatenate( + (1...3).map { count in + .run { send in + try await clock.sleep(for: .seconds(count)) + await send(count) + } + } + ) + + let task = Task { + for await n in effect.actions { + values.withValue { $0.append(n) } + } + } + + XCTAssertEqual(values.value, []) + + await clock.advance(by: .seconds(1)) + XCTAssertEqual(values.value, [1]) + + await clock.advance(by: .seconds(2)) + XCTAssertEqual(values.value, [1, 2]) + + await clock.advance(by: .seconds(3)) + XCTAssertEqual(values.value, [1, 2, 3]) + + await clock.run() + XCTAssertEqual(values.value, [1, 2, 3]) + + await task.value + } + } + } + #endif + + func testConcatenateOneEffect() async { + let values = LockIsolated<[Int]>([]) + + let effect = Effect.concatenate( + .publisher { Just(1).delay(for: 1, scheduler: self.mainQueue) } + ) + + let task = Task { + for await n in effect.actions { + values.withValue { $0.append(n) } + } + } + + XCTAssertEqual(values.value, []) + + await self.mainQueue.advance(by: 1) + XCTAssertEqual(values.value, [1]) + + await self.mainQueue.run() + XCTAssertEqual(values.value, [1]) + + await task.value + } + + #if (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) + func testMerge() async { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { let clock = TestClock() - let values = LockIsolated<[Int]>([]) - let effect = Effect.concatenate( + let effect = Effect.merge( (1...3).map { count in .run { send in try await clock.sleep(for: .seconds(count)) @@ -23,6 +88,8 @@ final class EffectTests: BaseTCATestCase { } ) + let values = LockIsolated<[Int]>([]) + let task = Task { for await n in effect.actions { values.withValue { $0.append(n) } @@ -34,203 +101,138 @@ final class EffectTests: BaseTCATestCase { await clock.advance(by: .seconds(1)) XCTAssertEqual(values.value, [1]) - await clock.advance(by: .seconds(2)) + await clock.advance(by: .seconds(1)) XCTAssertEqual(values.value, [1, 2]) - await clock.advance(by: .seconds(3)) - XCTAssertEqual(values.value, [1, 2, 3]) - - await clock.run() + await clock.advance(by: .seconds(1)) XCTAssertEqual(values.value, [1, 2, 3]) await task.value } } - } - #endif + #endif - func testConcatenateOneEffect() async { - let values = LockIsolated<[Int]>([]) + func testDoubleCancelInFlight() async { + var result: Int? - let effect = Effect.concatenate( - .publisher { Just(1).delay(for: 1, scheduler: self.mainQueue) } - ) + let effect = Effect.send(42) + .cancellable(id: "id", cancelInFlight: true) + .cancellable(id: "id", cancelInFlight: true) - let task = Task { for await n in effect.actions { - values.withValue { $0.append(n) } + XCTAssertNil(result) + result = n } - } - - XCTAssertEqual(values.value, []) - - await self.mainQueue.advance(by: 1) - XCTAssertEqual(values.value, [1]) - - await self.mainQueue.run() - XCTAssertEqual(values.value, [1]) - await task.value - } - - #if (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) - func testMerge() async { - if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - let clock = TestClock() + XCTAssertEqual(result, 42) + } - let effect = Effect.merge( - (1...3).map { count in - .run { send in - try await clock.sleep(for: .seconds(count)) - await send(count) + @Reducer + fileprivate struct Feature_testDependenciesTransferredToEffects_Task { + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date) var date + var body: some Reducer { + Reduce { state, action in + switch action { + case .tap: + return .run { send in + await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) } - } - ) - - let values = LockIsolated<[Int]>([]) - - let task = Task { - for await n in effect.actions { - values.withValue { $0.append(n) } + case let .response(value): + state = value + return .none } } - - XCTAssertEqual(values.value, []) - - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values.value, [1]) - - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values.value, [1, 2]) - - await clock.advance(by: .seconds(1)) - XCTAssertEqual(values.value, [1, 2, 3]) - - await task.value } } - #endif - - func testDoubleCancelInFlight() async { - var result: Int? - - let effect = Effect.send(42) - .cancellable(id: "id", cancelInFlight: true) - .cancellable(id: "id", cancelInFlight: true) - - for await n in effect.actions { - XCTAssertNil(result) - result = n - } - - XCTAssertEqual(result, 42) - } + func testDependenciesTransferredToEffects_Task() async { + let store = TestStore(initialState: 0) { + Feature_testDependenciesTransferredToEffects_Task() + .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) + } - @Reducer - fileprivate struct Feature_testDependenciesTransferredToEffects_Task { - enum Action: Equatable { - case tap - case response(Int) - } - @Dependency(\.date) var date - var body: some Reducer { - Reduce { state, action in - switch action { - case .tap: - return .run { send in - await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) - } - case let .response(value): - state = value - return .none - } + await store.send(.tap).finish(timeout: NSEC_PER_SEC) + await store.receive(.response(1_234_567_890)) { + $0 = 1_234_567_890 } } - } - func testDependenciesTransferredToEffects_Task() async { - let store = TestStore(initialState: 0) { - Feature_testDependenciesTransferredToEffects_Task() - .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) - } - await store.send(.tap).finish(timeout: NSEC_PER_SEC) - await store.receive(.response(1_234_567_890)) { - $0 = 1_234_567_890 - } - } - - @Reducer - fileprivate struct Feature_testDependenciesTransferredToEffects_Run { - enum Action: Equatable { - case tap - case response(Int) - } - @Dependency(\.date) var date - var body: some Reducer { - Reduce { state, action in - switch action { - case .tap: - return .run { send in - await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) + @Reducer + fileprivate struct Feature_testDependenciesTransferredToEffects_Run { + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date) var date + var body: some Reducer { + Reduce { state, action in + switch action { + case .tap: + return .run { send in + await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) + } + case let .response(value): + state = value + return .none } - case let .response(value): - state = value - return .none } } } - } - func testDependenciesTransferredToEffects_Run() async { - let store = TestStore(initialState: 0) { - Feature_testDependenciesTransferredToEffects_Run() - .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) - } - - await store.send(.tap).finish(timeout: NSEC_PER_SEC) - await store.receive(.response(1_234_567_890)) { - $0 = 1_234_567_890 - } - } + func testDependenciesTransferredToEffects_Run() async { + let store = TestStore(initialState: 0) { + Feature_testDependenciesTransferredToEffects_Run() + .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) + } - func testMap() async { - @Dependency(\.date) var date - let effect = withDependencies { - $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - } operation: { - Effect.send(()).map { date() } - } - var output: Date? - for await date in effect.actions { - XCTAssertNil(output) - output = date + await store.send(.tap).finish(timeout: NSEC_PER_SEC) + await store.receive(.response(1_234_567_890)) { + $0 = 1_234_567_890 + } } - XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) - if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + func testMap() async { + @Dependency(\.date) var date let effect = withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) } operation: { - Effect.run { send in await send(()) }.map { date() } + Effect.send(()).map { date() } } - output = nil + var output: Date? for await date in effect.actions { XCTAssertNil(output) output = date } XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) + + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + let effect = withDependencies { + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) + } operation: { + Effect.run { send in await send(()) }.map { date() } + } + output = nil + for await date in effect.actions { + XCTAssertNil(output) + output = date + } + XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) + } } - } - func testCanary1() async { - for _ in 1...100 { - let task = TestStoreTask(rawValue: Task {}, timeout: NSEC_PER_SEC) - await task.finish() + func testCanary1() async { + for _ in 1...100 { + let task = TestStoreTask(rawValue: Task {}, timeout: NSEC_PER_SEC) + await task.finish() + } } - } - func testCanary2() async { - for _ in 1...100 { - let task = TestStoreTask(rawValue: nil, timeout: NSEC_PER_SEC) - await task.finish() + func testCanary2() async { + for _ in 1...100 { + let task = TestStoreTask(rawValue: nil, timeout: NSEC_PER_SEC) + await task.finish() + } } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/MacroTests.swift b/Tests/ComposableArchitectureTests/MacroTests.swift new file mode 100644 index 000000000000..b21aba9b5087 --- /dev/null +++ b/Tests/ComposableArchitectureTests/MacroTests.swift @@ -0,0 +1,36 @@ +#if swift(>=5.9) + import ComposableArchitecture + import SwiftUI + + private enum TestViewAction { + @Reducer + fileprivate struct Feature { + struct State {} + enum Action: ViewAction { + case view(View) + enum View { case tap } + } + var body: some ReducerOf { EmptyReducer() } + } + @ViewAction(for: Feature.self) + fileprivate struct FeatureView: View { + let store: StoreOf + var body: some View { + Button("Tap") { send(.tap) } + Button("Tap") { send(.tap, animation: .default) } + Button("Tap") { send(.tap, transaction: Transaction(animation: .default)) } + } + } + } + + private enum TestObservableEnum_NonObservableCase { + @Reducer + fileprivate struct Feature { + enum State { + case inert(Int) + } + enum Action {} + var body: some ReducerOf { EmptyReducer() } + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/MemoryManagementTests.swift b/Tests/ComposableArchitectureTests/MemoryManagementTests.swift index c1846c3c091b..b1dfe2966fe2 100644 --- a/Tests/ComposableArchitectureTests/MemoryManagementTests.swift +++ b/Tests/ComposableArchitectureTests/MemoryManagementTests.swift @@ -5,6 +5,7 @@ import XCTest final class MemoryManagementTests: BaseTCATestCase { var cancellables: Set = [] + @available(*, deprecated) func testOwnership_ScopeHoldsOntoParent() { let counterReducer = Reduce { state, _ in state += 1 @@ -38,6 +39,7 @@ final class MemoryManagementTests: BaseTCATestCase { XCTAssertEqual(count, 1) } + @available(*, deprecated) func testEffectWithMultipleScopes() { let expectation = self.expectation(description: "") diff --git a/Tests/ComposableArchitectureTests/ObservableTests.swift b/Tests/ComposableArchitectureTests/ObservableTests.swift new file mode 100644 index 000000000000..2f46d1085a7c --- /dev/null +++ b/Tests/ComposableArchitectureTests/ObservableTests.swift @@ -0,0 +1,579 @@ +#if swift(>=5.9) + import Combine + import ComposableArchitecture + import XCTest + + @MainActor + final class ObservableTests: BaseTCATestCase { + func testBasics() async { + var state = ChildState() + let countDidChange = self.expectation(description: "count.didChange") + + withPerceptionTracking { + _ = state.count + } onChange: { + countDidChange.fulfill() + } + + state.count += 1 + await self.fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 1) + } + + func testReplace() async { + XCTTODO("Ideally this would pass but we cannot detect this kind of mutation currently.") + + var state = ChildState(count: 42) + let countDidChange = self.expectation(description: "count.didChange") + + withPerceptionTracking { + _ = state.count + } onChange: { + countDidChange.fulfill() + } + + state.replace(with: ChildState()) + await self.fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 0) + } + + func testReset() async { + XCTTODO("Ideally this would pass but we cannot detect this kind of mutation currently.") + + var state = ChildState(count: 42) + let countDidChange = self.expectation(description: "count.didChange") + + withPerceptionTracking { + _ = state.count + } onChange: { + countDidChange.fulfill() + } + + state.reset() + await self.fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(state.count, 0) + } + + func testChildCountMutation() async { + var state = ParentState() + let childCountDidChange = self.expectation(description: "child.count.didChange") + + withPerceptionTracking { + _ = state.child.count + } onChange: { + childCountDidChange.fulfill() + } + withPerceptionTracking { + _ = state.child + } onChange: { + XCTFail("state.child should not change.") + } + + state.child.count += 1 + await self.fulfillment(of: [childCountDidChange], timeout: 0) + XCTAssertEqual(state.child.count, 1) + } + + func testChildReset() async { + var state = ParentState() + let childDidChange = self.expectation(description: "child.didChange") + + let child = state.child + withPerceptionTracking { + _ = child.count + } onChange: { + XCTFail("child.count should not change.") + } + withPerceptionTracking { + _ = state.child + } onChange: { + childDidChange.fulfill() + } + + state.child = ChildState(count: 42) + await self.fulfillment(of: [childDidChange], timeout: 0) + XCTAssertEqual(state.child.count, 42) + } + + func testReplaceChild() async { + var state = ParentState() + let childDidChange = self.expectation(description: "child.didChange") + + withPerceptionTracking { + _ = state.child + } onChange: { + childDidChange.fulfill() + } + + state.child.replace(with: ChildState(count: 42)) + await self.fulfillment(of: [childDidChange], timeout: 0) + XCTAssertEqual(state.child.count, 42) + } + + func testResetChild() async { + var state = ParentState(child: ChildState(count: 42)) + let childDidChange = self.expectation(description: "child.didChange") + + withPerceptionTracking { + _ = state.child + } onChange: { + childDidChange.fulfill() + } + + state.child.reset() + await self.fulfillment(of: [childDidChange], timeout: 0) + XCTAssertEqual(state.child.count, 0) + } + + func testSwapSiblings() async { + var state = ParentState( + child: ChildState(count: 1), + sibling: ChildState(count: -1) + ) + let childDidChange = self.expectation(description: "child.didChange") + let siblingDidChange = self.expectation(description: "sibling.didChange") + + withPerceptionTracking { + _ = state.child + } onChange: { + childDidChange.fulfill() + } + withPerceptionTracking { + _ = state.sibling + } onChange: { + siblingDidChange.fulfill() + } + + state.swap() + await self.fulfillment(of: [childDidChange], timeout: 0) + await self.fulfillment(of: [siblingDidChange], timeout: 0) + XCTAssertEqual(state.child.count, -1) + XCTAssertEqual(state.sibling.count, 1) + } + + func testPresentOptional() async { + var state = ParentState() + let optionalDidChange = self.expectation(description: "optional.didChange") + + withPerceptionTracking { + _ = state.optional + } onChange: { + optionalDidChange.fulfill() + } + + state.optional = ChildState(count: 42) + await self.fulfillment(of: [optionalDidChange], timeout: 0) + XCTAssertEqual(state.optional?.count, 42) + } + + func testMutatePresentedOptional() async { + var state = ParentState(optional: ChildState()) + let optionalCountDidChange = self.expectation(description: "optional.count.didChange") + + withPerceptionTracking { + _ = state.optional + } onChange: { + XCTFail("Optional should not change") + } + let optional = state.optional + withPerceptionTracking { + _ = optional?.count + } onChange: { + optionalCountDidChange.fulfill() + } + + state.optional?.count += 1 + await self.fulfillment(of: [optionalCountDidChange], timeout: 0) + XCTAssertEqual(state.optional?.count, 1) + } + + func testPresentDestination() async { + var state = ParentState() + let destinationDidChange = self.expectation(description: "destination.didChange") + + withPerceptionTracking { + _ = state.destination + } onChange: { + destinationDidChange.fulfill() + } + + state.destination = .child1(ChildState(count: 42)) + await self.fulfillment(of: [destinationDidChange], timeout: 0) + XCTAssertEqual(state.destination?[case: \.child1]?.count, 42) + } + + func testDismissDestination() async { + var state = ParentState(destination: .child1(ChildState())) + let destinationDidChange = self.expectation(description: "destination.didChange") + + withPerceptionTracking { + _ = state.destination + } onChange: { + destinationDidChange.fulfill() + } + + state.destination = nil + await self.fulfillment(of: [destinationDidChange], timeout: 0) + XCTAssertEqual(state.destination, nil) + } + + func testChangeDestination() async { + var state = ParentState(destination: .child1(ChildState())) + let destinationDidChange = self.expectation(description: "destination.didChange") + + withPerceptionTracking { + _ = state.destination + } onChange: { + destinationDidChange.fulfill() + } + + state.destination = .child2(ChildState(count: 42)) + await self.fulfillment(of: [destinationDidChange], timeout: 0) + XCTAssertEqual(state.destination?[case: \.child2]?.count, 42) + } + + func testChangeDestination_KeepIdentity() async { + let childState = ChildState(count: 42) + var state = ParentState(destination: .child1(childState)) + let destinationDidChange = self.expectation(description: "destination.didChange") + + withPerceptionTracking { + _ = state.destination + } onChange: { + destinationDidChange.fulfill() + } + + state.destination = .child2(childState) + await self.fulfillment(of: [destinationDidChange], timeout: 0) + XCTAssertEqual(state.destination?[case: \.child2]?.count, 42) + } + + func testMutatingDestination_NonObservableCase() async { + var state = ParentState(destination: .inert(0)) + + withPerceptionTracking { + _ = state.destination + } onChange: { + XCTFail("destination should not change") + } + + state.destination = .inert(1) + XCTAssertEqual(state.destination, .inert(1)) + } + + func testReplaceWithCopy() async { + let childState = ChildState(count: 1) + var childStateCopy = childState + childStateCopy.count = 2 + var state = ParentState(child: childState, sibling: childStateCopy) + let childCountDidChange = self.expectation(description: "child.count.didChange") + + withPerceptionTracking { + _ = state.child.count + } onChange: { + childCountDidChange.fulfill() + } + + state.child.replace(with: state.sibling) + + await self.fulfillment(of: [childCountDidChange], timeout: 0) + XCTAssertEqual(state.child.count, 2) + XCTAssertEqual(state.sibling.count, 2) + } + + func testStore_ReplaceChild() async { + let store = Store(initialState: ParentState()) { + Reduce { state, _ in + state.child.replace(with: ChildState(count: 42)) + return .none + } + } + let childDidChange = self.expectation(description: "child.didChange") + + withPerceptionTracking { + _ = store.child + } onChange: { + childDidChange.fulfill() + } + + store.send(()) + await self.fulfillment(of: [childDidChange], timeout: 0) + XCTAssertEqual(store.child.count, 42) + } + + func testStore_Replace() async { + let store = Store(initialState: ChildState()) { + Reduce { state, _ in + state.replace(with: ChildState(count: 42)) + return .none + } + } + let countDidChange = self.expectation(description: "child.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + store.send(()) + await self.fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(store.count, 42) + } + + func testStore_ResetChild() async { + let store = Store(initialState: ParentState(child: ChildState(count: 42))) + { + Reduce { state, _ in + state.child.reset() + return .none + } + } + let childDidChange = self.expectation(description: "child.didChange") + + withPerceptionTracking { + _ = store.child + } onChange: { + childDidChange.fulfill() + } + + store.send(()) + await self.fulfillment(of: [childDidChange], timeout: 0) + XCTAssertEqual(store.child.count, 0) + } + + func testStore_Reset() async { + let store = Store(initialState: ChildState(count: 42)) { + Reduce { state, _ in + state.reset() + return .none + } + } + let countDidChange = self.expectation(description: "child.didChange") + + withPerceptionTracking { + _ = store.count + } onChange: { + countDidChange.fulfill() + } + + store.send(()) + await self.fulfillment(of: [countDidChange], timeout: 0) + XCTAssertEqual(store.count, 0) + } + + func testIdentifiedArray_AddElement() { + var state = ParentState() + let rowsDidChange = self.expectation(description: "rowsDidChange") + + withPerceptionTracking { + _ = state.rows + } onChange: { + rowsDidChange.fulfill() + } + + state.rows.append(ChildState()) + XCTAssertEqual(state.rows.count, 1) + self.wait(for: [rowsDidChange], timeout: 0) + } + + func testIdentifiedArray_MutateElement() { + var state = ParentState(rows: [ + ChildState(), + ChildState(), + ]) + let firstRowCountDidChange = self.expectation(description: "firstRowCountDidChange") + + withPerceptionTracking { + _ = state.rows + } onChange: { + XCTFail("rows should not change") + } + withPerceptionTracking { + _ = state.rows[0] + } onChange: { + XCTFail("rows[0] should not change") + } + withPerceptionTracking { + _ = state.rows[0].count + } onChange: { + firstRowCountDidChange.fulfill() + } + withPerceptionTracking { + _ = state.rows[1].count + } onChange: { + XCTFail("rows[1].count should not change") + } + + state.rows[0].count += 1 + XCTAssertEqual(state.rows[0].count, 1) + self.wait(for: [firstRowCountDidChange], timeout: 0) + } + + func testPresents_NilToNonNil() { + var state = ParentState() + let presentationDidChange = self.expectation(description: "presentationDidChange") + + withPerceptionTracking { + _ = state.presentation + } onChange: { + presentationDidChange.fulfill() + } + + state.presentation = ChildState() + XCTAssertEqual(state.presentation?.count, 0) + self.wait(for: [presentationDidChange], timeout: 0) + } + + func testPresents_Mutate() { + var state = ParentState(presentation: ChildState()) + let presentationCountDidChange = self.expectation(description: "presentationCountDidChange") + + withPerceptionTracking { + _ = state.presentation + } onChange: { + XCTFail("presentation should not change") + } + withPerceptionTracking { + _ = state.presentation?.count + } onChange: { + presentationCountDidChange.fulfill() + } + + state.presentation?.count += 1 + XCTAssertEqual(state.presentation?.count, 1) + self.wait(for: [presentationCountDidChange], timeout: 0) + } + + func testStackState_AddElement() { + var state = ParentState() + let pathDidChange = self.expectation(description: "pathDidChange") + + withPerceptionTracking { + _ = state.path + } onChange: { + pathDidChange.fulfill() + } + + state.path.append(ChildState()) + XCTAssertEqual(state.path.count, 1) + self.wait(for: [pathDidChange], timeout: 0) + } + + func testStackState_MutateElement() { + var state = ParentState( + path: StackState([ + ChildState(), + ChildState(), + ]) + ) + let firstElementCountDidChange = self.expectation(description: "firstElementCountDidChange") + + withPerceptionTracking { + _ = state.path + } onChange: { + XCTFail("path should not change") + } + withPerceptionTracking { + _ = state.path[0] + } onChange: { + XCTFail("path[0] should not change") + } + withPerceptionTracking { + _ = state.path[0].count + } onChange: { + firstElementCountDidChange.fulfill() + } + withPerceptionTracking { + _ = state.path[1].count + } onChange: { + XCTFail("path[1].count should not change") + } + + state.path[id: 0]?.count += 1 + XCTAssertEqual(state.path[0].count, 1) + self.wait(for: [firstElementCountDidChange], timeout: 0) + } + + func testCopy() { + var state = ParentState() + var childCopy = state.child.copy() + childCopy.count = 42 + let childCountDidChange = self.expectation(description: "childCountDidChange") + + withPerceptionTracking { + _ = state.child.count + } onChange: { + childCountDidChange.fulfill() + } + + state.child.replace(with: childCopy) + XCTAssertEqual(state.child.count, 42) + self.wait(for: [childCountDidChange], timeout: 0) + } + + func testArrayAppend() { + var state = ParentState() + let childrenDidChange = self.expectation(description: "childrenDidChange") + + withPerceptionTracking { + _ = state.children + } onChange: { + childrenDidChange.fulfill() + } + + state.children.append(ChildState()) + self.wait(for: [childrenDidChange]) + } + + func testArrayMutate() { + var state = ParentState(children: [ChildState()]) + + withPerceptionTracking { + _ = state.children + } onChange: { + XCTFail("children should not change") + } + + state.children[0].count += 1 + } + } + + @ObservableState + private struct ChildState: Equatable, Identifiable { + let id = UUID() + var count = 0 + mutating func replace(with other: Self) { + self = other + } + mutating func reset() { + self = Self() + } + mutating func copy() -> Self { + self + } + } + @ObservableState + private struct ParentState: Equatable { + var child = ChildState() + @Presents var destination: DestinationState? + var children: [ChildState] = [] + @Presents var optional: ChildState? + var path = StackState() + @Presents var presentation: ChildState? + var rows: IdentifiedArrayOf = [] + var sibling = ChildState() + mutating func swap() { + let childCopy = child + self.child = self.sibling + self.sibling = childCopy + } + } + @CasePathable + @ObservableState + private enum DestinationState: Equatable { + case child1(ChildState) + case child2(ChildState) + case inert(Int) + } +#endif diff --git a/Tests/ComposableArchitectureTests/ObserveTests.swift b/Tests/ComposableArchitectureTests/ObserveTests.swift new file mode 100644 index 000000000000..e13f1973567d --- /dev/null +++ b/Tests/ComposableArchitectureTests/ObserveTests.swift @@ -0,0 +1,42 @@ +#if swift(>=5.9) + import Combine + import ComposableArchitecture + import XCTest + + @MainActor + final class ObserveTests: BaseTCATestCase { + func testObserve() async throws { + let model = Model() + var counts: [Int] = [] + let observation = observe { + counts.append(model.count) + } + XCTAssertEqual(counts, [0]) + model.count += 1 + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertEqual(counts, [0, 1]) + _ = observation + } + + func testCancellation() async throws { + let model = Model() + var counts: [Int] = [] + let observation = observe { + counts.append(model.count) + } + XCTAssertEqual(counts, [0]) + observation.cancel() + model.count += 1 + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertEqual(counts, [0]) + _ = observation + } + } + +@Perceptible +class Model { + var count = 0 +} + +#endif + diff --git a/Tests/ComposableArchitectureTests/ReducerBuilderTests.swift b/Tests/ComposableArchitectureTests/ReducerBuilderTests.swift index 632ef7bf933e..5b2eea003c96 100644 --- a/Tests/ComposableArchitectureTests/ReducerBuilderTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerBuilderTests.swift @@ -1,226 +1,228 @@ // NB: This file contains compile-time tests to ensure reducer builder generic inference is working. -import ComposableArchitecture -import XCTest +#if swift(>=5.9) + import ComposableArchitecture + import XCTest -@Reducer -private struct Test { - struct State {} - enum Action { case tap } - - var body: some Reducer { - EmptyReducer() - } - - @available(iOS, introduced: 9999) - @available(macOS, introduced: 9999) - @available(tvOS, introduced: 9999) - @available(visionOS, introduced: 9999) - @available(watchOS, introduced: 9999) @Reducer - struct Unavailable { + private struct Test { + struct State {} + enum Action { case tap } + var body: some Reducer { EmptyReducer() } - } -} -func testExistentialReducers() { - _ = CombineReducers { - Test() - Test() as any ReducerOf - } -} - -func testLimitedAvailability() { - _ = CombineReducers { - Test() - if #available(iOS 9999, macOS 9999, tvOS 9999, visionOS 9999, watchOS 9999, *) { - Test.Unavailable() - } else if #available(iOS 8888, macOS 8888, tvOS 8888, visionOS 8888, watchOS 8888, *) { - EmptyReducer() + @available(iOS, introduced: 9999) + @available(macOS, introduced: 9999) + @available(tvOS, introduced: 9999) + @available(visionOS, introduced: 9999) + @available(watchOS, introduced: 9999) + @Reducer + struct Unavailable { + var body: some Reducer { + EmptyReducer() + } } } -} - -@Reducer -private struct Root { - struct State { - var feature: Feature.State - var optionalFeature: Feature.State? - var enumFeature: Features.State? - var features: IdentifiedArrayOf + + func testExistentialReducers() { + _ = CombineReducers { + Test() + Test() as any ReducerOf + } } - enum Action { - case feature(Feature.Action) - case optionalFeature(Feature.Action) - case enumFeature(Features.Action) - case features(IdentifiedActionOf) + func testLimitedAvailability() { + _ = CombineReducers { + Test() + if #available(iOS 9999, macOS 9999, tvOS 9999, visionOS 9999, watchOS 9999, *) { + Test.Unavailable() + } else if #available(iOS 8888, macOS 8888, tvOS 8888, visionOS 8888, watchOS 8888, *) { + EmptyReducer() + } + } } - @available(iOS, introduced: 9999) - @available(macOS, introduced: 9999) - @available(tvOS, introduced: 9999) - @available(visionOS, introduced: 9999) - @available(watchOS, introduced: 9999) @Reducer - struct Unavailable { - let body = EmptyReducer() - } + private struct Root { + struct State { + var feature: Feature.State + var optionalFeature: Feature.State? + var enumFeature: Features.State? + var features: IdentifiedArrayOf + } - var body: some ReducerOf { - CombineReducers { - Scope(state: \.feature, action: \.feature) { - Feature() - Feature() - } - Scope(state: \.feature, action: \.feature) { - Feature() - Feature() - } + enum Action { + case feature(Feature.Action) + case optionalFeature(Feature.Action) + case enumFeature(Features.Action) + case features(IdentifiedActionOf) } - .ifLet(\.optionalFeature, action: \.optionalFeature) { - Feature() - Feature() + + @available(iOS, introduced: 9999) + @available(macOS, introduced: 9999) + @available(tvOS, introduced: 9999) + @available(visionOS, introduced: 9999) + @available(watchOS, introduced: 9999) + @Reducer + struct Unavailable { + let body = EmptyReducer() } - .ifLet(\.enumFeature, action: \.enumFeature) { - EmptyReducer() - .ifCaseLet(\.featureA, action: \.featureA) { + + var body: some ReducerOf { + CombineReducers { + Scope(state: \.feature, action: \.feature) { Feature() Feature() } - .ifCaseLet(\.featureB, action: \.featureB) { + Scope(state: \.feature, action: \.feature) { Feature() Feature() } - - Features() - } - .forEach(\.features, action: \.features) { - Feature() - Feature() + } + .ifLet(\.optionalFeature, action: \.optionalFeature) { + Feature() + Feature() + } + .ifLet(\.enumFeature, action: \.enumFeature) { + EmptyReducer() + .ifCaseLet(\.featureA, action: \.featureA) { + Feature() + Feature() + } + .ifCaseLet(\.featureB, action: \.featureB) { + Feature() + Feature() + } + + Features() + } + .forEach(\.features, action: \.features) { + Feature() + Feature() + } } - } - @ReducerBuilder - var testFlowControl: some ReducerOf { - if true { - Self() - } + @ReducerBuilder + var testFlowControl: some ReducerOf { + if true { + Self() + } - if Bool.random() { - Self() - } else { - EmptyReducer() + if Bool.random() { + Self() + } else { + EmptyReducer() + } + + for _ in 1...10 { + Self() + } + + if #available(iOS 9999, macOS 9999, tvOS 9999, visionOS 9999, watchOS 9999, *) { + Unavailable() + } } - for _ in 1...10 { - Self() + @Reducer + struct Feature { + struct State: Identifiable { + let id: Int + } + enum Action { + case action + } + + var body: some Reducer { + EmptyReducer() + } } - if #available(iOS 9999, macOS 9999, tvOS 9999, visionOS 9999, watchOS 9999, *) { - Unavailable() + @Reducer + struct Features { + enum State { + case featureA(Feature.State) + case featureB(Feature.State) + } + + enum Action { + case featureA(Feature.Action) + case featureB(Feature.Action) + } + + var body: some ReducerOf { + Scope(state: \.featureA, action: \.featureA) { + Feature() + } + Scope(state: \.featureB, action: \.featureB) { + Feature() + } + } } } @Reducer - struct Feature { - struct State: Identifiable { - let id: Int - } - enum Action { - case action + private struct IfLetExample { + struct State { + var optional: Int? } - var body: some Reducer { - EmptyReducer() + enum Action {} + + var body: some ReducerOf { + EmptyReducer().ifLet(\.optional, action: \.self) { EmptyReducer() } } } @Reducer - struct Features { + private struct IfCaseLetExample { enum State { - case featureA(Feature.State) - case featureB(Feature.State) + case value(Int) } - enum Action { - case featureA(Feature.Action) - case featureB(Feature.Action) - } + enum Action {} var body: some ReducerOf { - Scope(state: \.featureA, action: \.featureA) { - Feature() - } - Scope(state: \.featureB, action: \.featureB) { - Feature() - } + EmptyReducer().ifCaseLet(\.value, action: \.self) { EmptyReducer() } } } -} - -@Reducer -private struct IfLetExample { - struct State { - var optional: Int? - } - enum Action {} - - var body: some ReducerOf { - EmptyReducer().ifLet(\.optional, action: \.self) { EmptyReducer() } - } -} - -@Reducer -private struct IfCaseLetExample { - enum State { - case value(Int) - } - - enum Action {} - - var body: some ReducerOf { - EmptyReducer().ifCaseLet(\.value, action: \.self) { EmptyReducer() } - } -} - -@Reducer -private struct ForEachExample { - struct Element: Identifiable { let id: Int } + @Reducer + private struct ForEachExample { + struct Element: Identifiable { let id: Int } - struct State { - var values: IdentifiedArrayOf - } + struct State { + var values: IdentifiedArrayOf + } - enum Action { - case value(IdentifiedAction) - } + enum Action { + case value(IdentifiedAction) + } - var body: some ReducerOf { - EmptyReducer().forEach(\.values, action: \.value) { EmptyReducer() } + var body: some ReducerOf { + EmptyReducer().forEach(\.values, action: \.value) { EmptyReducer() } + } } -} -@Reducer -private struct ScopeIfLetExample { - struct State { - var optionalSelf: Self? { - get { self } - set { newValue.map { self = $0 } } + @Reducer + private struct ScopeIfLetExample { + struct State { + var optionalSelf: Self? { + get { self } + set { newValue.map { self = $0 } } + } } - } - enum Action {} + enum Action {} - var body: some ReducerOf { - Scope(state: \.self, action: \.self) { - EmptyReducer() - .ifLet(\.optionalSelf, action: \.self) { - EmptyReducer() - } + var body: some ReducerOf { + Scope(state: \.self, action: \.self) { + EmptyReducer() + .ifLet(\.optionalSelf, action: \.self) { + EmptyReducer() + } + } } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ReducerTests.swift b/Tests/ComposableArchitectureTests/ReducerTests.swift index b08e1f56748d..90647c6539e6 100644 --- a/Tests/ComposableArchitectureTests/ReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerTests.swift @@ -1,25 +1,25 @@ -import Combine -@_spi(Internals) import ComposableArchitecture -import CustomDump -import XCTest -import os.signpost +#if swift(>=5.9) + import Combine + @_spi(Internals) import ComposableArchitecture + import CustomDump + import XCTest + import os.signpost -@MainActor -final class ReducerTests: BaseTCATestCase { - var cancellables: Set = [] + @MainActor + final class ReducerTests: BaseTCATestCase { + var cancellables: Set = [] - func testCallableAsFunction() { - let reducer = Reduce { state, _ in - state += 1 - return .none - } + func testCallableAsFunction() { + let reducer = Reduce { state, _ in + state += 1 + return .none + } - var state = 0 - _ = reducer.reduce(into: &state, action: ()) - XCTAssertEqual(state, 1) - } + var state = 0 + _ = reducer.reduce(into: &state, action: ()) + XCTAssertEqual(state, 1) + } - #if (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) @Reducer @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) fileprivate struct Feature_testCombine_EffectsAreMerged { @@ -69,48 +69,48 @@ final class ReducerTests: BaseTCATestCase { XCTAssertEqual(slowValue, 1729) } } - #endif - @Reducer - fileprivate struct Feature_testCombine { - typealias State = Int - enum Action { case increment } - let effect: @Sendable () async -> Void - var body: some Reducer { - Reduce { state, action in - state += 1 - return .run { _ in - await self.effect() + @Reducer + fileprivate struct Feature_testCombine { + typealias State = Int + enum Action { case increment } + let effect: @Sendable () async -> Void + var body: some Reducer { + Reduce { state, action in + state += 1 + return .run { _ in + await self.effect() + } } } } - } - func testCombine() async { - var first = false - var second = false + func testCombine() async { + var first = false + var second = false - let store = TestStore(initialState: 0) { - Feature_testCombine(effect: { @MainActor in first = true }) - Feature_testCombine(effect: { @MainActor in second = true }) - } + let store = TestStore(initialState: 0) { + Feature_testCombine(effect: { @MainActor in first = true }) + Feature_testCombine(effect: { @MainActor in second = true }) + } - await store - .send(.increment) { $0 = 2 } - .finish() + await store + .send(.increment) { $0 = 2 } + .finish() - XCTAssertTrue(first) - XCTAssertTrue(second) - } + XCTAssertTrue(first) + XCTAssertTrue(second) + } - func testDefaultSignpost() async { - let reducer = EmptyReducer().signpost(log: .default) - var n = 0 - for await _ in reducer.reduce(into: &n, action: ()).actions {} - } + func testDefaultSignpost() async { + let reducer = EmptyReducer().signpost(log: .default) + var n = 0 + for await _ in reducer.reduce(into: &n, action: ()).actions {} + } - func testDisabledSignpost() async { - let reducer = EmptyReducer().signpost(log: .disabled) - var n = 0 - for await _ in reducer.reduce(into: &n, action: ()).actions {} + func testDisabledSignpost() async { + let reducer = EmptyReducer().signpost(log: .disabled) + var n = 0 + for await _ in reducer.reduce(into: &n, action: ()).actions {} + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift index c2bf747ace5d..e3679a07dd9f 100644 --- a/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/BindingReducerTests.swift @@ -1,143 +1,145 @@ -import ComposableArchitecture -import XCTest - -@MainActor -final class BindingTests: BaseTCATestCase { - @Reducer - struct BindingTest { - struct State: Equatable { - @BindingState var nested = Nested() - - struct Nested: Equatable { - var field = "" - } - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - } +#if swift(>=5.9) + import ComposableArchitecture + import XCTest + + @MainActor + final class BindingTests: BaseTCATestCase { + @Reducer + struct BindingTest { + struct State: Equatable { + @BindingState var nested = Nested() - var body: some ReducerOf { - BindingReducer() - Reduce { state, action in - switch action { - case .binding(.set(\.$nested, State.Nested(field: "special"))): - state.nested.field += "*" - return .none - case .binding(\.$nested): - state.nested.field += "!" - return .none - case .binding: - return .none + struct Nested: Equatable { + var field = "" } } - } - } - func testEquality() { - struct State { - @BindingState var count = 0 - } - XCTAssertEqual( - BindingAction.set(\.$count, 1), - BindingAction.set(\.$count, 1) - ) - XCTAssertNotEqual( - BindingAction.set(\.$count, 1), - BindingAction.set(\.$count, 2) - ) - } - - func testViewEquality() { - struct Feature: Reducer { - struct State: Equatable { - @BindingState var count = 0 - } enum Action: BindableAction, Equatable { case binding(BindingAction) } + var body: some ReducerOf { BindingReducer() + Reduce { state, action in + switch action { + case .binding(.set(\.$nested, State.Nested(field: "special"))): + state.nested.field += "*" + return .none + case .binding(\.$nested): + state.nested.field += "!" + return .none + case .binding: + return .none + } + } } } - struct ViewState: Equatable { - @BindingViewState var count: Int - } - let store = Store(initialState: Feature.State()) { - Feature() + + func testEquality() { + struct State { + @BindingState var count = 0 + } + XCTAssertEqual( + BindingAction.set(\.$count, 1), + BindingAction.set(\.$count, 1) + ) + XCTAssertNotEqual( + BindingAction.set(\.$count, 1), + BindingAction.set(\.$count, 2) + ) } - let viewStore = ViewStore(store, observe: { ViewState(count: $0.$count) }) - let initialState = viewStore.state - let count = viewStore.$count - count.wrappedValue += 1 - XCTAssertNotEqual(initialState, viewStore.state) - XCTAssertEqual(count.wrappedValue, 1) - } + func testViewEquality() { + struct Feature: Reducer { + struct State: Equatable { + @BindingState var count = 0 + } + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + var body: some ReducerOf { + BindingReducer() + } + } + struct ViewState: Equatable { + @BindingViewState var count: Int + } + let store = Store(initialState: Feature.State()) { + Feature() + } + let viewStore = ViewStore(store, observe: { ViewState(count: $0.$count) }) + let initialState = viewStore.state + let count = viewStore.$count + count.wrappedValue += 1 + XCTAssertNotEqual(initialState, viewStore.state) - func testNestedBindingState() { - let store = Store(initialState: BindingTest.State()) { BindingTest() } + XCTAssertEqual(count.wrappedValue, 1) + } - let viewStore = ViewStore(store, observe: { $0 }) + func testNestedBindingState() { + let store = Store(initialState: BindingTest.State()) { BindingTest() } - viewStore.$nested.field.wrappedValue = "Hello" + let viewStore = ViewStore(store, observe: { $0 }) - XCTAssertEqual(viewStore.state, .init(nested: .init(field: "Hello!"))) - } + viewStore.$nested.field.wrappedValue = "Hello" - func testNestedBindingViewState() { - struct ViewState: Equatable { - @BindingViewState var field: String + XCTAssertEqual(viewStore.state, .init(nested: .init(field: "Hello!"))) } - let store = Store(initialState: BindingTest.State()) { BindingTest() } + func testNestedBindingViewState() { + struct ViewState: Equatable { + @BindingViewState var field: String + } - let viewStore = ViewStore(store, observe: { ViewState(field: $0.$nested.field) }) + let store = Store(initialState: BindingTest.State()) { BindingTest() } - viewStore.$field.wrappedValue = "Hello" + let viewStore = ViewStore(store, observe: { ViewState(field: $0.$nested.field) }) - XCTAssertEqual(store.withState { $0.nested.field }, "Hello!") - } + viewStore.$field.wrappedValue = "Hello" - func testBindingActionUpdatesRespectsPatternMatching() async { - let testStore = TestStore( - initialState: BindingTest.State(nested: BindingTest.State.Nested(field: "")) - ) { - BindingTest() + XCTAssertEqual(store.withState { $0.nested.field }, "Hello!") } - await testStore.send(.binding(.set(\.$nested, BindingTest.State.Nested(field: "special")))) { - $0.nested = BindingTest.State.Nested(field: "special*") - } - await testStore.send(.binding(.set(\.$nested, BindingTest.State.Nested(field: "Hello")))) { - $0.nested = BindingTest.State.Nested(field: "Hello!") - } - } + func testBindingActionUpdatesRespectsPatternMatching() async { + let testStore = TestStore( + initialState: BindingTest.State(nested: BindingTest.State.Nested(field: "")) + ) { + BindingTest() + } - // NB: This crashes in Swift(<5.8) RELEASE when `BindingAction` holds directly onto an unboxed - // `value: Any` existential - func testLayoutBug() { - enum Foo { - case bar(Baz) + await testStore.send(.binding(.set(\.$nested, BindingTest.State.Nested(field: "special")))) { + $0.nested = BindingTest.State.Nested(field: "special*") + } + await testStore.send(.binding(.set(\.$nested, BindingTest.State.Nested(field: "Hello")))) { + $0.nested = BindingTest.State.Nested(field: "Hello!") + } } - enum Baz { - case fizz(BindingAction) - case buzz(Bool) + + // NB: This crashes in Swift(<5.8) RELEASE when `BindingAction` holds directly onto an unboxed + // `value: Any` existential + func testLayoutBug() { + enum Foo { + case bar(Baz) + } + enum Baz { + case fizz(BindingAction) + case buzz(Bool) + } + _ = AnyCasePath(unsafe: Foo.bar).extract(from: .bar(.buzz(true))) } - _ = AnyCasePath(unsafe: Foo.bar).extract(from: .bar(.buzz(true))) - } - struct TestMatching { - struct CounterState { - @BindingState var count = 0 + struct TestMatching { + struct CounterState { + @BindingState var count = 0 + } + @CasePathable + enum CounterAction: CasePathable { + case binding(BindingAction) + } } - @CasePathable - enum CounterAction: CasePathable { - case binding(BindingAction) + func testMatching() { + let action = TestMatching.CounterAction.binding(.set(\.$count, 42)) + XCTAssertEqual(action[case: \.binding.$count], 42) } } - func testMatching() { - let action = TestMatching.CounterAction.binding(.set(\.$count, 42)) - XCTAssertEqual(action[case: \.binding.$count], 42) - } -} +#endif diff --git a/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift index ff7a1186ae79..52d688d68ec2 100644 --- a/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/ForEachReducerTests.swift @@ -1,250 +1,252 @@ -import ComposableArchitecture -import XCTest +#if swift(>=5.9) + import ComposableArchitecture + import XCTest -@MainActor -final class ForEachReducerTests: BaseTCATestCase { - func testElementAction() async { - let store = TestStore( - initialState: Elements.State( - rows: [ - .init(id: 1, value: "Blob"), - .init(id: 2, value: "Blob Jr."), - .init(id: 3, value: "Blob Sr."), - ] - ) - ) { - Elements() - } + @MainActor + final class ForEachReducerTests: BaseTCATestCase { + func testElementAction() async { + let store = TestStore( + initialState: Elements.State( + rows: [ + .init(id: 1, value: "Blob"), + .init(id: 2, value: "Blob Jr."), + .init(id: 3, value: "Blob Sr."), + ] + ) + ) { + Elements() + } - await store.send(.rows(.element(id: 1, action: "Blob Esq."))) { - $0.rows[id: 1]?.value = "Blob Esq." - } - await store.send(.rows(.element(id: 2, action: ""))) { - $0.rows[id: 2]?.value = "" - } - await store.receive(\.rows[id:2]) { - $0.rows[id: 2]?.value = "Empty" + await store.send(.rows(.element(id: 1, action: "Blob Esq."))) { + $0.rows[id: 1]?.value = "Blob Esq." + } + await store.send(.rows(.element(id: 2, action: ""))) { + $0.rows[id: 2]?.value = "" + } + await store.receive(\.rows[id:2]) { + $0.rows[id: 2]?.value = "Empty" + } } - } - func testNonElementAction() async { - let store = TestStore(initialState: Elements.State()) { - Elements() + func testNonElementAction() async { + let store = TestStore(initialState: Elements.State()) { + Elements() + } + + await store.send(.buttonTapped) } - await store.send(.buttonTapped) - } + #if DEBUG + func testMissingElement() async { + let store = TestStore(initialState: Elements.State()) { + EmptyReducer() + .forEach(\.rows, action: \.rows) {} + } - #if DEBUG - func testMissingElement() async { - let store = TestStore(initialState: Elements.State()) { - EmptyReducer() - .forEach(\.rows, action: \.rows) {} - } + XCTExpectFailure { + $0.compactDescription == """ + A "forEach" at "\(#fileID):\(#line - 5)" received an action for a missing element. … - XCTExpectFailure { - $0.compactDescription == """ - A "forEach" at "\(#fileID):\(#line - 5)" received an action for a missing element. … + Action: + Elements.Action.rows(.element(id:, action:)) - Action: - Elements.Action.rows(.element(id:, action:)) + This is generally considered an application logic error, and can happen for a few reasons: - This is generally considered an application logic error, and can happen for a few reasons: + • A parent reducer removed an element with this ID before this reducer ran. This reducer \ + must run before any other reducer removes an element, which ensures that element \ + reducers can handle their actions while their state is still available. - • A parent reducer removed an element with this ID before this reducer ran. This reducer \ - must run before any other reducer removes an element, which ensures that element \ - reducers can handle their actions while their state is still available. + • An in-flight effect emitted this action when state contained no element at this ID. \ + While it may be perfectly reasonable to ignore this action, consider canceling the \ + associated effect before an element is removed, especially if it is a long-living effect. - • An in-flight effect emitted this action when state contained no element at this ID. \ - While it may be perfectly reasonable to ignore this action, consider canceling the \ - associated effect before an element is removed, especially if it is a long-living effect. + • This action was sent to the store while its state contained no element at this ID. To \ + fix this make sure that actions for this reducer can only be sent from a view store when \ + its state contains an element at this id. In SwiftUI applications, use "ForEachStore". + """ + } - • This action was sent to the store while its state contained no element at this ID. To \ - fix this make sure that actions for this reducer can only be sent from a view store when \ - its state contains an element at this id. In SwiftUI applications, use "ForEachStore". - """ + await store.send(.rows(.element(id: 1, action: "Blob Esq."))) } + #endif - await store.send(.rows(.element(id: 1, action: "Blob Esq."))) - } - #endif - - func testAutomaticEffectCancellation() async { - if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - struct Timer: Reducer { - struct State: Equatable, Identifiable { - let id: UUID - var elapsed = 0 - } - enum Action: Equatable { - case startButtonTapped - case tick - } - @Dependency(\.continuousClock) var clock - var body: some Reducer { - Reduce { state, action in - switch action { - case .startButtonTapped: - return .run { send in - for await _ in self.clock.timer(interval: .seconds(1)) { - await send(.tick) + func testAutomaticEffectCancellation() async { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + struct Timer: Reducer { + struct State: Equatable, Identifiable { + let id: UUID + var elapsed = 0 + } + enum Action: Equatable { + case startButtonTapped + case tick + } + @Dependency(\.continuousClock) var clock + var body: some Reducer { + Reduce { state, action in + switch action { + case .startButtonTapped: + return .run { send in + for await _ in self.clock.timer(interval: .seconds(1)) { + await send(.tick) + } } + case .tick: + state.elapsed += 1 + return .none } - case .tick: - state.elapsed += 1 - return .none } } } - } - struct Timers: Reducer { - struct State: Equatable { - var timers: IdentifiedArrayOf = [] - } - enum Action: Equatable { - case addTimerButtonTapped - case removeLastTimerButtonTapped - case timers(id: Timer.State.ID, action: Timer.Action) - } - @Dependency(\.uuid) var uuid - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .addTimerButtonTapped: - state.timers.append(Timer.State(id: self.uuid())) - return .none - case .removeLastTimerButtonTapped: - state.timers.removeLast() - return .none - case .timers: - return .none - } + struct Timers: Reducer { + struct State: Equatable { + var timers: IdentifiedArrayOf = [] + } + enum Action: Equatable { + case addTimerButtonTapped + case removeLastTimerButtonTapped + case timers(id: Timer.State.ID, action: Timer.Action) } - .forEach(\.timers, action: /Action.timers) { - Timer() + @Dependency(\.uuid) var uuid + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .addTimerButtonTapped: + state.timers.append(Timer.State(id: self.uuid())) + return .none + case .removeLastTimerButtonTapped: + state.timers.removeLast() + return .none + case .timers: + return .none + } + } + .forEach(\.timers, action: /Action.timers) { + Timer() + } } } - } - let clock = TestClock() - let store = TestStore(initialState: Timers.State()) { - Timers() - } withDependencies: { - $0.uuid = .incrementing - $0.continuousClock = clock - } - await store.send(.addTimerButtonTapped) { - $0.timers = [ - Timer.State(id: UUID(0)) - ] - } - await store.send( - .timers( - id: UUID(0), - action: .startButtonTapped - ) - ) - await clock.advance(by: .seconds(2)) - await store.receive( - .timers( - id: UUID(0), - action: .tick - ) - ) { - $0.timers[0].elapsed = 1 - } - await store.receive( - .timers( - id: UUID(0), - action: .tick - ) - ) { - $0.timers[0].elapsed = 2 - } - await store.send(.addTimerButtonTapped) { - $0.timers = [ - Timer.State( - id: UUID(0), elapsed: 2), - Timer.State(id: UUID(1)), - ] - } - await clock.advance(by: .seconds(1)) - await store.receive( - .timers( - id: UUID(0), - action: .tick - ) - ) { - $0.timers[0].elapsed = 3 - } - await store.send( - .timers( - id: UUID(1), - action: .startButtonTapped - ) - ) - await clock.advance(by: .seconds(1)) - await store.receive( - .timers( - id: UUID(0), - action: .tick - ) - ) { - $0.timers[0].elapsed = 4 - } - await store.receive( - .timers( - id: UUID(1), - action: .tick + let clock = TestClock() + let store = TestStore(initialState: Timers.State()) { + Timers() + } withDependencies: { + $0.uuid = .incrementing + $0.continuousClock = clock + } + await store.send(.addTimerButtonTapped) { + $0.timers = [ + Timer.State(id: UUID(0)) + ] + } + await store.send( + .timers( + id: UUID(0), + action: .startButtonTapped + ) ) - ) { - $0.timers[1].elapsed = 1 - } - await store.send(.removeLastTimerButtonTapped) { - $0.timers = [ - Timer.State(id: UUID(0), elapsed: 4) - ] - } - await clock.advance(by: .seconds(1)) - await store.receive( - .timers( - id: UUID(0), - action: .tick + await clock.advance(by: .seconds(2)) + await store.receive( + .timers( + id: UUID(0), + action: .tick + ) + ) { + $0.timers[0].elapsed = 1 + } + await store.receive( + .timers( + id: UUID(0), + action: .tick + ) + ) { + $0.timers[0].elapsed = 2 + } + await store.send(.addTimerButtonTapped) { + $0.timers = [ + Timer.State( + id: UUID(0), elapsed: 2), + Timer.State(id: UUID(1)), + ] + } + await clock.advance(by: .seconds(1)) + await store.receive( + .timers( + id: UUID(0), + action: .tick + ) + ) { + $0.timers[0].elapsed = 3 + } + await store.send( + .timers( + id: UUID(1), + action: .startButtonTapped + ) ) - ) { - $0.timers[0].elapsed = 5 - } - await store.send(.removeLastTimerButtonTapped) { - $0.timers = [] + await clock.advance(by: .seconds(1)) + await store.receive( + .timers( + id: UUID(0), + action: .tick + ) + ) { + $0.timers[0].elapsed = 4 + } + await store.receive( + .timers( + id: UUID(1), + action: .tick + ) + ) { + $0.timers[1].elapsed = 1 + } + await store.send(.removeLastTimerButtonTapped) { + $0.timers = [ + Timer.State(id: UUID(0), elapsed: 4) + ] + } + await clock.advance(by: .seconds(1)) + await store.receive( + .timers( + id: UUID(0), + action: .tick + ) + ) { + $0.timers[0].elapsed = 5 + } + await store.send(.removeLastTimerButtonTapped) { + $0.timers = [] + } } } } -} -@Reducer -struct Elements { - struct State: Equatable { - struct Row: Equatable, Identifiable { - var id: Int - var value: String + @Reducer + struct Elements { + struct State: Equatable { + struct Row: Equatable, Identifiable { + var id: Int + var value: String + } + var rows: IdentifiedArrayOf = [] } - var rows: IdentifiedArrayOf = [] - } - enum Action: Equatable { - case buttonTapped - case rows(IdentifiedAction) - } - var body: some ReducerOf { - Reduce { state, action in - .none + enum Action: Equatable { + case buttonTapped + case rows(IdentifiedAction) } - .forEach(\.rows, action: \.rows) { + var body: some ReducerOf { Reduce { state, action in - state.value = action - return action.isEmpty - ? .run { await $0("Empty") } - : .none + .none + } + .forEach(\.rows, action: \.rows) { + Reduce { state, action in + state.value = action + return action.isEmpty + ? .run { await $0("Empty") } + : .none + } } } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift index 240bb083748d..4101a8413925 100644 --- a/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/PresentationReducerTests.swift @@ -2593,10 +2593,10 @@ final class PresentationReducerTests: BaseTCATestCase { } } - func testFastPastEquality() { + func testFastPathEquality() { struct State: Equatable { static func == (lhs: Self, rhs: Self) -> Bool { - Thread.sleep(forTimeInterval: 1) + Thread.sleep(forTimeInterval: 5) return true } } diff --git a/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift index 5440f111a897..078b4470aadb 100644 --- a/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/StackReducerTests.swift @@ -1,249 +1,200 @@ -@_spi(Internals) import ComposableArchitecture -import XCTest - -@MainActor -final class StackReducerTests: BaseTCATestCase { - func testStackStateSubscriptCase() { - enum Element: Equatable { - case int(Int) - case text(String) - } - - var stack = StackState([.int(42)]) - stack[id: 0, case: /Element.int]? += 1 - XCTAssertEqual(stack[id: 0], .int(43)) +#if swift(>=5.9) + @_spi(Internals) import ComposableArchitecture + import XCTest - stack[id: 0, case: /Element.int] = nil - XCTAssertTrue(stack.isEmpty) - } - - #if DEBUG - func testStackStateSubscriptCase_Unexpected() { + @MainActor + final class StackReducerTests: BaseTCATestCase { + func testStackStateSubscriptCase() { enum Element: Equatable { case int(Int) case text(String) } var stack = StackState([.int(42)]) + stack[id: 0, case: /Element.int]? += 1 + XCTAssertEqual(stack[id: 0], .int(43)) - XCTExpectFailure { - stack[id: 0, case: /Element.text]?.append("!") - } issueMatcher: { - $0.compactDescription == """ - Can't modify unrelated case "int" - """ - } - - XCTExpectFailure { - stack[id: 0, case: /Element.text] = nil - } issueMatcher: { - $0.compactDescription == """ - Can't modify unrelated case "int" - """ - } - - XCTAssertEqual(Array(stack), [.int(42)]) - } - #endif - - func testCustomDebugStringConvertible() { - @Dependency(\.stackElementID) var stackElementID - XCTAssertEqual(stackElementID.peek().generation, 0) - XCTAssertEqual(stackElementID.next().customDumpDescription, "#0") - XCTAssertEqual(stackElementID.peek().generation, 1) - XCTAssertEqual(stackElementID.next().customDumpDescription, "#1") - - withDependencies { - $0.context = .live - } operation: { - XCTAssertEqual(stackElementID.next().customDumpDescription, "#0") - XCTAssertEqual(stackElementID.next().customDumpDescription, "#1") + stack[id: 0, case: /Element.int] = nil + XCTAssertTrue(stack.isEmpty) } - } - func testPresent() async { - struct Child: Reducer { - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case decrementButtonTapped - case incrementButtonTapped - } - var body: some Reducer { - Reduce { state, action in - switch action { - case .decrementButtonTapped: - state.count -= 1 - return .none - case .incrementButtonTapped: - state.count += 1 - return .none - } + #if DEBUG + func testStackStateSubscriptCase_Unexpected() { + enum Element: Equatable { + case int(Int) + case text(String) } - } - } - struct Parent: Reducer { - struct State: Equatable { - var children = StackState() - } - enum Action: Equatable { - case children(StackAction) - case pushChild - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .children: - return .none - case .pushChild: - state.children.append(Child.State()) - return .none - } + + var stack = StackState([.int(42)]) + + XCTExpectFailure { + stack[id: 0, case: /Element.text]?.append("!") + } issueMatcher: { + $0.compactDescription == """ + Can't modify unrelated case "int" + """ } - .forEach(\.children, action: /Action.children) { - Child() + + XCTExpectFailure { + stack[id: 0, case: /Element.text] = nil + } issueMatcher: { + $0.compactDescription == """ + Can't modify unrelated case "int" + """ } + + XCTAssertEqual(Array(stack), [.int(42)]) } - } + #endif - let store = TestStore(initialState: Parent.State()) { - Parent() - } + func testCustomDebugStringConvertible() { + @Dependency(\.stackElementID) var stackElementID + XCTAssertEqual(stackElementID.peek().generation, 0) + XCTAssertEqual(stackElementID.next().customDumpDescription, "#0") + XCTAssertEqual(stackElementID.peek().generation, 1) + XCTAssertEqual(stackElementID.next().customDumpDescription, "#1") - await store.send(.pushChild) { - $0.children.append(Child.State()) + withDependencies { + $0.context = .live + } operation: { + XCTAssertEqual(stackElementID.next().customDumpDescription, "#0") + XCTAssertEqual(stackElementID.next().customDumpDescription, "#1") + } } - } - func testDismissFromParent() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action: Equatable { - case onAppear - } - var body: some Reducer { - Reduce { state, action in - switch action { - case .onAppear: - return .run { _ in - try await Task.never() + func testPresent() async { + struct Child: Reducer { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case decrementButtonTapped + case incrementButtonTapped + } + var body: some Reducer { + Reduce { state, action in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + case .incrementButtonTapped: + state.count += 1 + return .none } } } } - } - struct Parent: Reducer { - struct State: Equatable { - var children = StackState() - } - enum Action: Equatable { - case children(StackAction) - case popChild - case pushChild - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .children: - return .none - case .popChild: - state.children.removeLast() - return .none - case .pushChild: - state.children.append(Child.State()) - return .none - } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() } - .forEach(\.children, action: /Action.children) { - Child() + enum Action: Equatable { + case children(StackAction) + case pushChild + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .children: + return .none + case .pushChild: + state.children.append(Child.State()) + return .none + } + } + .forEach(\.children, action: /Action.children) { + Child() + } } } - } - let store = TestStore(initialState: Parent.State()) { - Parent() - } + let store = TestStore(initialState: Parent.State()) { + Parent() + } - await store.send(.pushChild) { - $0.children.append(Child.State()) - } - await store.send(.children(.element(id: 0, action: .onAppear))) - await store.send(.popChild) { - $0.children.removeLast() + await store.send(.pushChild) { + $0.children.append(Child.State()) + } } - } - func testDismissFromChild() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action: Equatable { - case closeButtonTapped - case onAppear - } - @Dependency(\.dismiss) var dismiss - var body: some Reducer { - Reduce { state, action in - switch action { - case .closeButtonTapped: - return .run { _ in - await self.dismiss() - } - case .onAppear: - return .run { _ in - try await Task.never() + func testDismissFromParent() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action: Equatable { + case onAppear + } + var body: some Reducer { + Reduce { state, action in + switch action { + case .onAppear: + return .run { _ in + try await Task.never() + } } } } } - } - struct Parent: Reducer { - struct State: Equatable { - var children = StackState() - } - enum Action: Equatable { - case children(StackAction) - case pushChild - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .children: - return .none - case .pushChild: - state.children.append(Child.State()) - return .none - } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() + } + enum Action: Equatable { + case children(StackAction) + case popChild + case pushChild } - .forEach(\.children, action: /Action.children) { - Child() + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .children: + return .none + case .popChild: + state.children.removeLast() + return .none + case .pushChild: + state.children.append(Child.State()) + return .none + } + } + .forEach(\.children, action: /Action.children) { + Child() + } } } - } - let store = TestStore(initialState: Parent.State()) { - Parent() - } + let store = TestStore(initialState: Parent.State()) { + Parent() + } - await store.send(.pushChild) { - $0.children.append(Child.State()) - } - await store.send(.children(.element(id: 0, action: .onAppear))) - await store.send(.children(.element(id: 0, action: .closeButtonTapped))) - await store.receive(.children(.popFrom(id: 0))) { - $0.children.removeLast() + await store.send(.pushChild) { + $0.children.append(Child.State()) + } + await store.send(.children(.element(id: 0, action: .onAppear))) + await store.send(.popChild) { + $0.children.removeLast() + } } - } - #if DEBUG - func testDismissReceiveWrongAction() async { + func testDismissFromChild() async { struct Child: Reducer { struct State: Equatable {} - enum Action: Equatable { case tap } + enum Action: Equatable { + case closeButtonTapped + case onAppear + } @Dependency(\.dismiss) var dismiss var body: some Reducer { Reduce { state, action in - .run { _ in await self.dismiss() } + switch action { + case .closeButtonTapped: + return .run { _ in + await self.dismiss() + } + case .onAppear: + return .run { _ in + try await Task.never() + } + } } } } @@ -253,826 +204,735 @@ final class StackReducerTests: BaseTCATestCase { } enum Action: Equatable { case children(StackAction) + case pushChild } var body: some ReducerOf { - Reduce { _, _ in .none }.forEach(\.children, action: /Action.children) { Child() } + Reduce { state, action in + switch action { + case .children: + return .none + case .pushChild: + state.children.append(Child.State()) + return .none + } + } + .forEach(\.children, action: /Action.children) { + Child() + } } } - let store = TestStore(initialState: Parent.State(children: StackState([Child.State()]))) { + let store = TestStore(initialState: Parent.State()) { Parent() } - XCTExpectFailure { - $0.compactDescription == """ - Received unexpected action: … - -   StackReducerTests.Parent.Action.children( - − .popFrom(id: #1) - + .popFrom(id: #0) -   ) - - (Expected: −, Received: +) - """ + await store.send(.pushChild) { + $0.children.append(Child.State()) } - - await store.send(.children(.element(id: 0, action: .tap))) - await store.receive(.children(.popFrom(id: 1))) { - $0.children = StackState() + await store.send(.children(.element(id: 0, action: .onAppear))) + await store.send(.children(.element(id: 0, action: .closeButtonTapped))) + await store.receive(.children(.popFrom(id: 0))) { + $0.children.removeLast() } } - #endif - - func testDismissFromIntermediateChild() async { - struct Child: Reducer { - struct State: Equatable { var count = 0 } - enum Action: Equatable { - case onAppear - } - @Dependency(\.dismiss) var dismiss - @Dependency(\.mainQueue) var mainQueue - var body: some Reducer { - Reduce { state, action in - switch action { - case .onAppear: - return .run { [count = state.count] _ in - try await self.mainQueue.sleep(for: .seconds(count)) - await self.dismiss() + + #if DEBUG + func testDismissReceiveWrongAction() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action: Equatable { case tap } + @Dependency(\.dismiss) var dismiss + var body: some Reducer { + Reduce { state, action in + .run { _ in await self.dismiss() } } } } - } - } - struct Parent: Reducer { - struct State: Equatable { - var children = StackState() - } - enum Action: Equatable { - case child(StackAction) - } - var body: some ReducerOf { - Reduce { _, _ in .none } - .forEach(\.children, action: /Action.child) { Child() } - } - } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() + } + enum Action: Equatable { + case children(StackAction) + } + var body: some ReducerOf { + Reduce { _, _ in .none }.forEach(\.children, action: /Action.children) { Child() } + } + } - let mainQueue = DispatchQueue.test - let store = TestStore(initialState: Parent.State()) { - Parent() - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } + let store = TestStore(initialState: Parent.State(children: StackState([Child.State()]))) { + Parent() + } - await store.send(.child(.push(id: 0, state: Child.State(count: 2)))) { - $0.children[id: 0] = Child.State(count: 2) - } - await store.send(.child(.element(id: 0, action: .onAppear))) + XCTExpectFailure { + $0.compactDescription == """ + Received unexpected action: … - await store.send(.child(.push(id: 1, state: Child.State(count: 1)))) { - $0.children[id: 1] = Child.State(count: 1) - } - await store.send(.child(.element(id: 1, action: .onAppear))) +   StackReducerTests.Parent.Action.children( + − .popFrom(id: #1) + + .popFrom(id: #0) +   ) - await store.send(.child(.push(id: 2, state: Child.State(count: 2)))) { - $0.children[id: 2] = Child.State(count: 2) - } - await store.send(.child(.element(id: 2, action: .onAppear))) + (Expected: −, Received: +) + """ + } - await mainQueue.advance(by: .seconds(1)) - await store.receive(.child(.popFrom(id: 1))) { - $0.children.removeLast(2) - } - await store.send(.child(.popFrom(id: 0))) { - $0.children = StackState() - } - } + await store.send(.children(.element(id: 0, action: .tap))) + await store.receive(.children(.popFrom(id: 1))) { + $0.children = StackState() + } + } + #endif - func testDismissFromDeepLinkedChild() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action: Equatable { - case closeButtonTapped - } - @Dependency(\.dismiss) var dismiss - var body: some Reducer { - Reduce { state, action in - switch action { - case .closeButtonTapped: - return .run { _ in - await self.dismiss() + func testDismissFromIntermediateChild() async { + struct Child: Reducer { + struct State: Equatable { var count = 0 } + enum Action: Equatable { + case onAppear + } + @Dependency(\.dismiss) var dismiss + @Dependency(\.mainQueue) var mainQueue + var body: some Reducer { + Reduce { state, action in + switch action { + case .onAppear: + return .run { [count = state.count] _ in + try await self.mainQueue.sleep(for: .seconds(count)) + await self.dismiss() + } } } } } - } - struct Parent: Reducer { - struct State: Equatable { - var children = StackState() - } - enum Action: Equatable { - case children(StackAction) - case pushChild - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .children: - return .none - case .pushChild: - state.children.append(Child.State()) - return .none - } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() + } + enum Action: Equatable { + case child(StackAction) } - .forEach(\.children, action: /Action.children) { - Child() + var body: some ReducerOf { + Reduce { _, _ in .none } + .forEach(\.children, action: /Action.child) { Child() } } } - } - var children = StackState() - children.append(Child.State()) - let store = TestStore(initialState: Parent.State(children: children)) { - Parent() - } + let mainQueue = DispatchQueue.test + let store = TestStore(initialState: Parent.State()) { + Parent() + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } - await store.send(.children(.element(id: 0, action: .closeButtonTapped))) - await store.receive(.children(.popFrom(id: 0))) { - $0.children.removeAll() + await store.send(.child(.push(id: 0, state: Child.State(count: 2)))) { + $0.children[id: 0] = Child.State(count: 2) + } + await store.send(.child(.element(id: 0, action: .onAppear))) + + await store.send(.child(.push(id: 1, state: Child.State(count: 1)))) { + $0.children[id: 1] = Child.State(count: 1) + } + await store.send(.child(.element(id: 1, action: .onAppear))) + + await store.send(.child(.push(id: 2, state: Child.State(count: 2)))) { + $0.children[id: 2] = Child.State(count: 2) + } + await store.send(.child(.element(id: 2, action: .onAppear))) + + await mainQueue.advance(by: .seconds(1)) + await store.receive(.child(.popFrom(id: 1))) { + $0.children.removeLast(2) + } + await store.send(.child(.popFrom(id: 0))) { + $0.children = StackState() + } } - } - func testEnumChild() async { - struct Child: Reducer { - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case closeButtonTapped - case incrementButtonTapped - case onAppear - } - @Dependency(\.dismiss) var dismiss - var body: some Reducer { - Reduce { state, action in - switch action { - case .closeButtonTapped: - return .run { _ in - await self.dismiss() - } - case .incrementButtonTapped: - state.count += 1 - return .none - case .onAppear: - return .run { _ in - try await Task.never() + func testDismissFromDeepLinkedChild() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action: Equatable { + case closeButtonTapped + } + @Dependency(\.dismiss) var dismiss + var body: some Reducer { + Reduce { state, action in + switch action { + case .closeButtonTapped: + return .run { _ in + await self.dismiss() + } } } } } - } - struct Path: Reducer { - enum State: Equatable { - case child1(Child.State) - case child2(Child.State) - } - enum Action: Equatable { - case child1(Child.Action) - case child2(Child.Action) - } - var body: some ReducerOf { - Scope(state: /State.child1, action: /Action.child1) { - Child() + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() } - Scope(state: /State.child2, action: /Action.child2) { - Child() + enum Action: Equatable { + case children(StackAction) + case pushChild } - } - } - struct Parent: Reducer { - struct State: Equatable { - var path = StackState() - } - enum Action: Equatable { - case path(StackAction) - case pushChild1 - case pushChild2 - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .path: - return .none - case .pushChild1: - state.path.append(.child1(Child.State())) - return .none - case .pushChild2: - state.path.append(.child2(Child.State())) - return .none + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .children: + return .none + case .pushChild: + state.children.append(Child.State()) + return .none + } + } + .forEach(\.children, action: /Action.children) { + Child() } - } - .forEach(\.path, action: /Action.path) { - Path() } } - } - let store = TestStore(initialState: Parent.State()) { - Parent() - } - await store.send(.pushChild1) { - $0.path.append(.child1(Child.State())) - } - await store.send(.path(.element(id: 0, action: .child1(.onAppear)))) - await store.send(.pushChild2) { - $0.path.append(.child2(Child.State())) - } - await store.send(.path(.element(id: 1, action: .child2(.onAppear)))) - await store.send(.path(.popFrom(id: 0))) { - $0.path.removeAll() - } - } + var children = StackState() + children.append(Child.State()) + let store = TestStore(initialState: Parent.State(children: children)) { + Parent() + } - func testParentDismiss() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action { case tap } - @Dependency(\.dismiss) var dismiss - var body: some Reducer { - Reduce { state, action in - .run { _ in try await Task.never() } - } + await store.send(.children(.element(id: 0, action: .closeButtonTapped))) + await store.receive(.children(.popFrom(id: 0))) { + $0.children.removeAll() } } - struct Parent: Reducer { - struct State: Equatable { - var path = StackState() - } - enum Action { - case path(StackAction) - case popToRoot - case pushChild - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .path: - return .none - case .popToRoot: - state.path.removeAll() - return .none - case .pushChild: - state.path.append(Child.State()) - return .none - } + + func testEnumChild() async { + struct Child: Reducer { + struct State: Equatable { + var count = 0 } - .forEach(\.path, action: /Action.path) { - Child() + enum Action: Equatable { + case closeButtonTapped + case incrementButtonTapped + case onAppear } - } - } - - let store = TestStore(initialState: Parent.State()) { - Parent() - } - await store.send(.pushChild) { - $0.path.append(Child.State()) - } - await store.send(.path(.element(id: 0, action: .tap))) - await store.send(.pushChild) { - $0.path.append(Child.State()) - } - await store.send(.path(.element(id: 1, action: .tap))) - await store.send(.popToRoot) { - $0.path.removeAll() - } - } - - enum TestSiblingCannotCancel { - @Reducer - struct Child { - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case cancel - case response(Int) - case tap - } - @Dependency(\.mainQueue) var mainQueue - enum CancelID: Hashable { case cancel } - var body: some Reducer { - Reduce { state, action in - switch action { - case .cancel: - return .cancel(id: CancelID.cancel) - case let .response(value): - state.count = value - return .none - case .tap: - return .run { send in - try await self.mainQueue.sleep(for: .seconds(1)) - await send(.response(42)) + @Dependency(\.dismiss) var dismiss + var body: some Reducer { + Reduce { state, action in + switch action { + case .closeButtonTapped: + return .run { _ in + await self.dismiss() + } + case .incrementButtonTapped: + state.count += 1 + return .none + case .onAppear: + return .run { _ in + try await Task.never() + } } - .cancellable(id: CancelID.cancel) } } } - } - @Reducer - struct Path { - enum State: Equatable { - case child1(Child.State) - case child2(Child.State) - } - enum Action: Equatable { - case child1(Child.Action) - case child2(Child.Action) - } - var body: some ReducerOf { - Scope(state: \.child1, action: \.child1) { Child() } - Scope(state: \.child2, action: \.child2) { Child() } - } - } - @Reducer - struct Parent { - struct State: Equatable { - var path = StackState() - } - enum Action: Equatable { - case path(StackAction) - case pushChild1 - case pushChild2 - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .path: - return .none - case .pushChild1: - state.path.append(.child1(Child.State())) - return .none - case .pushChild2: - state.path.append(.child2(Child.State())) - return .none - } + struct Path: Reducer { + enum State: Equatable { + case child1(Child.State) + case child2(Child.State) } - .forEach(\.path, action: \.path) { - Path() + enum Action: Equatable { + case child1(Child.Action) + case child2(Child.Action) + } + var body: some ReducerOf { + Scope(state: /State.child1, action: /Action.child1) { + Child() + } + Scope(state: /State.child2, action: /Action.child2) { + Child() + } } } - } - } - func testSiblingCannotCancel() async { - var path = StackState() - path.append(.child1(TestSiblingCannotCancel.Child.State())) - path.append(.child2(TestSiblingCannotCancel.Child.State())) - let mainQueue = DispatchQueue.test - let store = TestStore(initialState: TestSiblingCannotCancel.Parent.State(path: path)) { - TestSiblingCannotCancel.Parent() - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } - - await store.send(.path(.element(id: 0, action: .child1(.tap)))) - await store.send(.path(.element(id: 1, action: .child2(.tap)))) - await store.send(.path(.element(id: 0, action: .child1(.cancel)))) - await mainQueue.advance(by: .seconds(1)) - await store.receive(.path(.element(id: 1, action: .child2(.response(42))))) { - $0.path[id: 1, case: \.child2]?.count = 42 - } - - await store.send(.path(.element(id: 0, action: .child1(.tap)))) - await store.send(.path(.element(id: 1, action: .child2(.tap)))) - await store.send(.path(.element(id: 1, action: .child2(.cancel)))) - await mainQueue.advance(by: .seconds(1)) - await store.receive(.path(.element(id: 0, action: .child1(.response(42))))) { - $0.path[id: 0, case: \.child1]?.count = 42 - } - } - - enum TestFirstChildWhileEffectInFlight_DeliversToCorrectID { - @Reducer - struct Child { - let id: Int - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case response(Int) - case tap - } - @Dependency(\.mainQueue) var mainQueue - var body: some Reducer { - Reduce { state, action in - switch action { - case let .response(value): - state.count += value - return .none - case .tap: - return .run { send in - try await self.mainQueue.sleep(for: .seconds(self.id)) - await send(.response(self.id)) + struct Parent: Reducer { + struct State: Equatable { + var path = StackState() + } + enum Action: Equatable { + case path(StackAction) + case pushChild1 + case pushChild2 + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .path: + return .none + case .pushChild1: + state.path.append(.child1(Child.State())) + return .none + case .pushChild2: + state.path.append(.child2(Child.State())) + return .none } } + .forEach(\.path, action: /Action.path) { + Path() + } } } - } - @Reducer - struct Path { - enum State: Equatable { - case child1(Child.State) - case child2(Child.State) + + let store = TestStore(initialState: Parent.State()) { + Parent() + } + await store.send(.pushChild1) { + $0.path.append(.child1(Child.State())) } - enum Action: Equatable { - case child1(Child.Action) - case child2(Child.Action) + await store.send(.path(.element(id: 0, action: .child1(.onAppear)))) + await store.send(.pushChild2) { + $0.path.append(.child2(Child.State())) } - var body: some ReducerOf { - Scope(state: \.child1, action: \.child1) { Child(id: 1) } - Scope(state: \.child2, action: \.child2) { Child(id: 2) } + await store.send(.path(.element(id: 1, action: .child2(.onAppear)))) + await store.send(.path(.popFrom(id: 0))) { + $0.path.removeAll() } } - @Reducer - struct Parent { - struct State: Equatable { - var path = StackState() - } - enum Action: Equatable { - case path(StackAction) - case popAll - case popFirst - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .path: - return .none - case .popAll: - state.path = StackState() - return .none - case .popFirst: - state.path[id: state.path.ids[0]] = nil - return .none + + func testParentDismiss() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action { case tap } + @Dependency(\.dismiss) var dismiss + var body: some Reducer { + Reduce { state, action in + .run { _ in try await Task.never() } } } - .forEach(\.path, action: \.path) { - Path() - } } - } - } - func testFirstChildWhileEffectInFlight_DeliversToCorrectID() async { - let mainQueue = DispatchQueue.test - let store = TestStore( - initialState: TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Parent.State( - path: StackState([ - .child1(TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Child.State()), - .child2(TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Child.State()), - ]) - ) - ) { - TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Parent() - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } - - await store.send(.path(.element(id: 0, action: .child1(.tap)))) - await store.send(.path(.element(id: 1, action: .child2(.tap)))) - await mainQueue.advance(by: .seconds(1)) - await store.receive(.path(.element(id: 0, action: .child1(.response(1))))) { - $0.path[id: 0, case: \.child1]?.count = 1 - } - await mainQueue.advance(by: .seconds(1)) - await store.receive(.path(.element(id: 1, action: .child2(.response(2))))) { - $0.path[id: 1, case: \.child2]?.count = 2 - } - - await store.send(.path(.element(id: 0, action: .child1(.tap)))) - await store.send(.path(.element(id: 1, action: .child2(.tap)))) - await store.send(.popFirst) { - $0.path[id: 0] = nil - } - await mainQueue.advance(by: .seconds(2)) - await store.receive(.path(.element(id: 1, action: .child2(.response(2))))) { - $0.path[id: 1, case: \.child2]?.count = 4 - } - await store.send(.popFirst) { - $0.path[id: 1] = nil - } - } - - #if DEBUG - func testSendActionWithIDThatDoesNotExist() async { struct Parent: Reducer { struct State: Equatable { - var path = StackState() + var path = StackState() } enum Action { - case path(StackAction) + case path(StackAction) + case popToRoot + case pushChild } var body: some ReducerOf { - EmptyReducer() - .forEach(\.path, action: /Action.path) {} + Reduce { state, action in + switch action { + case .path: + return .none + case .popToRoot: + state.path.removeAll() + return .none + case .pushChild: + state.path.append(Child.State()) + return .none + } + } + .forEach(\.path, action: /Action.path) { + Child() + } } } - let line = #line - 3 - - XCTExpectFailure { - $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received an \ - action for a missing element. … - - Action: - () - This is generally considered an application logic error, and can happen for a few reasons: - - • A parent reducer removed an element with this ID before this reducer ran. This reducer \ - must run before any other reducer removes an element, which ensures that element \ - reducers can handle their actions while their state is still available. - - • An in-flight effect emitted this action when state contained no element at this ID. \ - While it may be perfectly reasonable to ignore this action, consider canceling the \ - associated effect before an element is removed, especially if it is a long-living effect. + let store = TestStore(initialState: Parent.State()) { + Parent() + } + await store.send(.pushChild) { + $0.path.append(Child.State()) + } + await store.send(.path(.element(id: 0, action: .tap))) + await store.send(.pushChild) { + $0.path.append(Child.State()) + } + await store.send(.path(.element(id: 1, action: .tap))) + await store.send(.popToRoot) { + $0.path.removeAll() + } + } - • This action was sent to the store while its state contained no element at this ID. To \ - fix this make sure that actions for this reducer can only be sent from a view store when \ - its state contains an element at this id. In SwiftUI applications, use \ - "NavigationStackStore". - """ + enum TestSiblingCannotCancel { + @Reducer + struct Child { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case cancel + case response(Int) + case tap + } + @Dependency(\.mainQueue) var mainQueue + enum CancelID: Hashable { case cancel } + var body: some Reducer { + Reduce { state, action in + switch action { + case .cancel: + return .cancel(id: CancelID.cancel) + case let .response(value): + state.count = value + return .none + case .tap: + return .run { send in + try await self.mainQueue.sleep(for: .seconds(1)) + await send(.response(42)) + } + .cancellable(id: CancelID.cancel) + } + } + } } - - var path = StackState() - path.append(1) - let store = TestStore(initialState: Parent.State(path: path)) { - Parent() + @Reducer + struct Path { + enum State: Equatable { + case child1(Child.State) + case child2(Child.State) + } + enum Action: Equatable { + case child1(Child.Action) + case child2(Child.Action) + } + var body: some ReducerOf { + Scope(state: \.child1, action: \.child1) { Child() } + Scope(state: \.child2, action: \.child2) { Child() } + } } - await store.send(.path(.element(id: 999, action: ()))) - } - #endif - - #if DEBUG - func testPopIDThatDoesNotExist() async { - struct Parent: Reducer { + @Reducer + struct Parent { struct State: Equatable { - var path = StackState() + var path = StackState() } - enum Action { - case path(StackAction) + enum Action: Equatable { + case path(StackAction) + case pushChild1 + case pushChild2 } var body: some ReducerOf { - EmptyReducer() - .forEach(\.path, action: /Action.path) {} + Reduce { state, action in + switch action { + case .path: + return .none + case .pushChild1: + state.path.append(.child1(Child.State())) + return .none + case .pushChild2: + state.path.append(.child2(Child.State())) + return .none + } + } + .forEach(\.path, action: \.path) { + Path() + } } } - let line = #line - 3 - - XCTExpectFailure { - $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ - "popFrom" action for a missing element. … + } + func testSiblingCannotCancel() async { + var path = StackState() + path.append(.child1(TestSiblingCannotCancel.Child.State())) + path.append(.child2(TestSiblingCannotCancel.Child.State())) + let mainQueue = DispatchQueue.test + let store = TestStore(initialState: TestSiblingCannotCancel.Parent.State(path: path)) { + TestSiblingCannotCancel.Parent() + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } - ID: - #999 - Path IDs: - [#0] - """ + await store.send(.path(.element(id: 0, action: .child1(.tap)))) + await store.send(.path(.element(id: 1, action: .child2(.tap)))) + await store.send(.path(.element(id: 0, action: .child1(.cancel)))) + await mainQueue.advance(by: .seconds(1)) + await store.receive(.path(.element(id: 1, action: .child2(.response(42))))) { + $0.path[id: 1, case: \.child2]?.count = 42 } - let store = TestStore(initialState: Parent.State(path: StackState([1]))) { - Parent() + await store.send(.path(.element(id: 0, action: .child1(.tap)))) + await store.send(.path(.element(id: 1, action: .child2(.tap)))) + await store.send(.path(.element(id: 1, action: .child2(.cancel)))) + await mainQueue.advance(by: .seconds(1)) + await store.receive(.path(.element(id: 0, action: .child1(.response(42))))) { + $0.path[id: 0, case: \.child1]?.count = 42 } - await store.send(.path(.popFrom(id: 999))) } - #endif - #if DEBUG - func testChildWithInFlightEffect() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action { case tap } + enum TestFirstChildWhileEffectInFlight_DeliversToCorrectID { + @Reducer + struct Child { + let id: Int + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case response(Int) + case tap + } + @Dependency(\.mainQueue) var mainQueue var body: some Reducer { Reduce { state, action in - .run { _ in try await Task.never() } + switch action { + case let .response(value): + state.count += value + return .none + case .tap: + return .run { send in + try await self.mainQueue.sleep(for: .seconds(self.id)) + await send(.response(self.id)) + } + } } } } - struct Parent: Reducer { - struct State: Equatable { - var path = StackState() + @Reducer + struct Path { + enum State: Equatable { + case child1(Child.State) + case child2(Child.State) } - enum Action { - case path(StackAction) + enum Action: Equatable { + case child1(Child.Action) + case child2(Child.Action) } var body: some ReducerOf { - EmptyReducer() - .forEach(\.path, action: /Action.path) { Child() } + Scope(state: \.child1, action: \.child1) { Child(id: 1) } + Scope(state: \.child2, action: \.child2) { Child(id: 2) } } } - - var path = StackState() - path.append(Child.State()) - let store = TestStore(initialState: Parent.State(path: path)) { - Parent() - } - let line = #line - await store.send(.path(.element(id: 0, action: .tap))) - - XCTExpectFailure { - $0.sourceCodeContext.location?.fileURL.absoluteString.contains("BaseTCATestCase") == true - || $0.sourceCodeContext.location?.lineNumber == line + 1 - && $0.compactDescription == """ - An effect returned for this action is still running. It must complete before the end \ - of the test. … - - To fix, inspect any effects the reducer returns for this action and ensure that all \ - of them complete by the end of the test. There are a few reasons why an effect may \ - not have completed: - - • If using async/await in your effect, it may need a little bit of time to properly \ - finish. To fix you can simply perform "await store.finish()" at the end of your test. - - • If an effect uses a clock/scheduler (via "receive(on:)", "delay", "debounce", \ - etc.), make sure that you wait enough time for it to perform the effect. If you are \ - using a test clock/scheduler, advance it so that the effects may complete, or \ - consider using an immediate clock/scheduler to immediately perform the effect instead. - - • If you are returning a long-living effect (timers, notifications, subjects, etc.), \ - then make sure those effects are torn down by marking the effect ".cancellable" and \ - returning a corresponding cancellation effect ("Effect.cancel") from another action, \ - or, if your effect is driven by a Combine subject, send it a completion. - """ - } - } - #endif - - func testMultipleChildEffects() async { - struct Child: Reducer { - struct State: Equatable { var count = 0 } - enum Action: Equatable { - case tap - case response(Int) - } - @Dependency(\.mainQueue) var mainQueue - var body: some Reducer { - Reduce { state, action in - switch action { - case .tap: - return .run { [count = state.count] send in - try await self.mainQueue.sleep(for: .seconds(count)) - await send(.response(42)) + @Reducer + struct Parent { + struct State: Equatable { + var path = StackState() + } + enum Action: Equatable { + case path(StackAction) + case popAll + case popFirst + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .path: + return .none + case .popAll: + state.path = StackState() + return .none + case .popFirst: + state.path[id: state.path.ids[0]] = nil + return .none } - case let .response(value): - state.count = value - return .none + } + .forEach(\.path, action: \.path) { + Path() } } } } - struct Parent: Reducer { - struct State: Equatable { - var children: StackState + func testFirstChildWhileEffectInFlight_DeliversToCorrectID() async { + let mainQueue = DispatchQueue.test + let store = TestStore( + initialState: TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Parent.State( + path: StackState([ + .child1(TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Child.State()), + .child2(TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Child.State()), + ]) + ) + ) { + TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Parent() + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() } - enum Action: Equatable { - case child(StackAction) + + await store.send(.path(.element(id: 0, action: .child1(.tap)))) + await store.send(.path(.element(id: 1, action: .child2(.tap)))) + await mainQueue.advance(by: .seconds(1)) + await store.receive(.path(.element(id: 0, action: .child1(.response(1))))) { + $0.path[id: 0, case: \.child1]?.count = 1 } - var body: some ReducerOf { - Reduce { _, _ in .none } - .forEach(\.children, action: /Action.child) { Child() } + await mainQueue.advance(by: .seconds(1)) + await store.receive(.path(.element(id: 1, action: .child2(.response(2))))) { + $0.path[id: 1, case: \.child2]?.count = 2 } - } - - let mainQueue = DispatchQueue.test - let store = TestStore( - initialState: Parent.State( - children: StackState([ - Child.State(count: 1), - Child.State(count: 2), - ]) - ) - ) { - Parent() - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } - - await store.send(.child(.element(id: 0, action: .tap))) - await store.send(.child(.element(id: 1, action: .tap))) - await mainQueue.advance(by: .seconds(1)) - await store.receive(.child(.element(id: 0, action: .response(42)))) { - $0.children[id: 0]?.count = 42 - } - await mainQueue.advance(by: .seconds(1)) - await store.receive(.child(.element(id: 1, action: .response(42)))) { - $0.children[id: 1]?.count = 42 - } - } - func testChildEffectCancellation() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action: Equatable { case tap } - var body: some Reducer { - Reduce { state, action in - .run { _ in try await Task.never() } - } + await store.send(.path(.element(id: 0, action: .child1(.tap)))) + await store.send(.path(.element(id: 1, action: .child2(.tap)))) + await store.send(.popFirst) { + $0.path[id: 0] = nil } - } - struct Parent: Reducer { - struct State: Equatable { - var children: StackState - } - enum Action: Equatable { - case child(StackAction) + await mainQueue.advance(by: .seconds(2)) + await store.receive(.path(.element(id: 1, action: .child2(.response(2))))) { + $0.path[id: 1, case: \.child2]?.count = 4 } - var body: some ReducerOf { - Reduce { _, _ in .none } - .forEach(\.children, action: /Action.child) { Child() } + await store.send(.popFirst) { + $0.path[id: 1] = nil } } - let store = TestStore( - initialState: Parent.State( - children: StackState([ - Child.State() - ]) - ) - ) { - Parent() - } + #if DEBUG + func testSendActionWithIDThatDoesNotExist() async { + struct Parent: Reducer { + struct State: Equatable { + var path = StackState() + } + enum Action { + case path(StackAction) + } + var body: some ReducerOf { + EmptyReducer() + .forEach(\.path, action: /Action.path) {} + } + } + let line = #line - 3 - await store.send(.child(.element(id: 0, action: .tap))) - await store.send(.child(.popFrom(id: 0))) { - $0.children[id: 0] = nil - } - } + XCTExpectFailure { + $0.compactDescription == """ + A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received an \ + action for a missing element. … + + Action: + () + + This is generally considered an application logic error, and can happen for a few reasons: - func testPush() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action: Equatable {} - var body: some Reducer { - EmptyReducer() + • A parent reducer removed an element with this ID before this reducer ran. This reducer \ + must run before any other reducer removes an element, which ensures that element \ + reducers can handle their actions while their state is still available. + + • An in-flight effect emitted this action when state contained no element at this ID. \ + While it may be perfectly reasonable to ignore this action, consider canceling the \ + associated effect before an element is removed, especially if it is a long-living effect. + + • This action was sent to the store while its state contained no element at this ID. To \ + fix this make sure that actions for this reducer can only be sent from a view store when \ + its state contains an element at this id. In SwiftUI applications, use \ + "NavigationStack.init(path:)" with a binding to a store. + """ + } + + var path = StackState() + path.append(1) + let store = TestStore(initialState: Parent.State(path: path)) { + Parent() + } + await store.send(.path(.element(id: 999, action: ()))) } - } - struct Parent: Reducer { - struct State: Equatable { - var children = StackState() - } - enum Action: Equatable { - case child(StackAction) - case push - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .child: - return .none - case .push: - state.children.append(Child.State()) - return .none + #endif + + #if DEBUG + func testPopIDThatDoesNotExist() async { + struct Parent: Reducer { + struct State: Equatable { + var path = StackState() + } + enum Action { + case path(StackAction) + } + var body: some ReducerOf { + EmptyReducer() + .forEach(\.path, action: /Action.path) {} } } - .forEach(\.children, action: /Action.child) { Child() } - } - } + let line = #line - 3 + + XCTExpectFailure { + $0.compactDescription == """ + A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ + "popFrom" action for a missing element. … + + ID: + #999 + Path IDs: + [#0] + """ + } - let store = TestStore(initialState: Parent.State()) { - Parent() - } + let store = TestStore(initialState: Parent.State(path: StackState([1]))) { + Parent() + } + await store.send(.path(.popFrom(id: 999))) + } + #endif + + #if DEBUG + func testChildWithInFlightEffect() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action { case tap } + var body: some Reducer { + Reduce { state, action in + .run { _ in try await Task.never() } + } + } + } + struct Parent: Reducer { + struct State: Equatable { + var path = StackState() + } + enum Action { + case path(StackAction) + } + var body: some ReducerOf { + EmptyReducer() + .forEach(\.path, action: /Action.path) { Child() } + } + } - await store.send(.child(.push(id: 0, state: Child.State()))) { - $0.children[id: 0] = Child.State() - } - await store.send(.push) { - $0.children[id: 1] = Child.State() - } - await store.send(.child(.push(id: 2, state: Child.State()))) { - $0.children[id: 2] = Child.State() - } - await store.send(.push) { - $0.children[id: 3] = Child.State() - } - await store.send(.child(.popFrom(id: 0))) { - $0.children = StackState() - } - await store.send(.child(.push(id: 0, state: Child.State()))) { - $0.children[id: 0] = Child.State() - } - } + var path = StackState() + path.append(Child.State()) + let store = TestStore(initialState: Parent.State(path: path)) { + Parent() + } + let line = #line + await store.send(.path(.element(id: 0, action: .tap))) + + XCTExpectFailure { + $0.sourceCodeContext.location?.fileURL.absoluteString.contains("BaseTCATestCase") == true + || $0.sourceCodeContext.location?.lineNumber == line + 1 + && $0.compactDescription == """ + An effect returned for this action is still running. It must complete before the end \ + of the test. … + + To fix, inspect any effects the reducer returns for this action and ensure that all \ + of them complete by the end of the test. There are a few reasons why an effect may \ + not have completed: + + • If using async/await in your effect, it may need a little bit of time to properly \ + finish. To fix you can simply perform "await store.finish()" at the end of your test. + + • If an effect uses a clock/scheduler (via "receive(on:)", "delay", "debounce", \ + etc.), make sure that you wait enough time for it to perform the effect. If you are \ + using a test clock/scheduler, advance it so that the effects may complete, or \ + consider using an immediate clock/scheduler to immediately perform the effect instead. + + • If you are returning a long-living effect (timers, notifications, subjects, etc.), \ + then make sure those effects are torn down by marking the effect ".cancellable" and \ + returning a corresponding cancellation effect ("Effect.cancel") from another action, \ + or, if your effect is driven by a Combine subject, send it a completion. + """ + } + } + #endif - #if DEBUG - func testPushReusedID() async { + func testMultipleChildEffects() async { struct Child: Reducer { - struct State: Equatable {} - enum Action: Equatable {} + struct State: Equatable { var count = 0 } + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.mainQueue) var mainQueue var body: some Reducer { - EmptyReducer() + Reduce { state, action in + switch action { + case .tap: + return .run { [count = state.count] send in + try await self.mainQueue.sleep(for: .seconds(count)) + await send(.response(42)) + } + case let .response(value): + state.count = value + return .none + } + } } } struct Parent: Reducer { struct State: Equatable { - var children = StackState() + var children: StackState } enum Action: Equatable { case child(StackAction) @@ -1082,43 +942,46 @@ final class StackReducerTests: BaseTCATestCase { .forEach(\.children, action: /Action.child) { Child() } } } - let line = #line - 3 - let store = TestStore(initialState: Parent.State()) { + let mainQueue = DispatchQueue.test + let store = TestStore( + initialState: Parent.State( + children: StackState([ + Child.State(count: 1), + Child.State(count: 2), + ]) + ) + ) { Parent() + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() } - XCTExpectFailure { - $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ - "push" action for an element it already contains. … - - ID: - #0 - Path IDs: - [#0] - """ + await store.send(.child(.element(id: 0, action: .tap))) + await store.send(.child(.element(id: 1, action: .tap))) + await mainQueue.advance(by: .seconds(1)) + await store.receive(.child(.element(id: 0, action: .response(42)))) { + $0.children[id: 0]?.count = 42 } - - await store.send(.child(.push(id: 0, state: Child.State()))) { - $0.children[id: 0] = Child.State() + await mainQueue.advance(by: .seconds(1)) + await store.receive(.child(.element(id: 1, action: .response(42)))) { + $0.children[id: 1]?.count = 42 } - await store.send(.child(.push(id: 0, state: Child.State()))) } - #endif - #if DEBUG - func testPushIDGreaterThanNextGeneration() async { + func testChildEffectCancellation() async { struct Child: Reducer { struct State: Equatable {} - enum Action: Equatable {} + enum Action: Equatable { case tap } var body: some Reducer { - EmptyReducer() + Reduce { state, action in + .run { _ in try await Task.never() } + } } } struct Parent: Reducer { struct State: Equatable { - var children = StackState() + var children: StackState } enum Action: Equatable { case child(StackAction) @@ -1128,30 +991,24 @@ final class StackReducerTests: BaseTCATestCase { .forEach(\.children, action: /Action.child) { Child() } } } - let line = #line - 3 - let store = TestStore(initialState: Parent.State()) { + let store = TestStore( + initialState: Parent.State( + children: StackState([ + Child.State() + ]) + ) + ) { Parent() } - XCTExpectFailure { - $0.compactDescription == """ - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ - "push" action with an unexpected generational ID. … - - Received ID: - #1 - Expected ID: - #0 - """ - } - - await store.send(.child(.push(id: 1, state: Child.State()))) { - $0.children[id: 1] = Child.State() + await store.send(.child(.element(id: 0, action: .tap))) + await store.send(.child(.popFrom(id: 0))) { + $0.children[id: 0] = nil } } - func testMismatchedIDFailure() async { + func testPush() async { struct Child: Reducer { struct State: Equatable {} enum Action: Equatable {} @@ -1165,9 +1022,19 @@ final class StackReducerTests: BaseTCATestCase { } enum Action: Equatable { case child(StackAction) + case push } var body: some ReducerOf { - Reduce { _, _ in .none }.forEach(\.children, action: /Action.child) { Child() } + Reduce { state, action in + switch action { + case .child: + return .none + case .push: + state.children.append(Child.State()) + return .none + } + } + .forEach(\.children, action: /Action.child) { Child() } } } @@ -1175,162 +1042,297 @@ final class StackReducerTests: BaseTCATestCase { Parent() } - XCTExpectFailure { - $0.compactDescription == """ - A state change does not match expectation: … - -   StackReducerTests.Parent.State( -   children: [ - − #1: StackReducerTests.Child.State() - + #0: StackReducerTests.Child.State() -   ] -   ) - - (Expected: −, Actual: +) - """ - } await store.send(.child(.push(id: 0, state: Child.State()))) { + $0.children[id: 0] = Child.State() + } + await store.send(.push) { $0.children[id: 1] = Child.State() } + await store.send(.child(.push(id: 2, state: Child.State()))) { + $0.children[id: 2] = Child.State() + } + await store.send(.push) { + $0.children[id: 3] = Child.State() + } + await store.send(.child(.popFrom(id: 0))) { + $0.children = StackState() + } + await store.send(.child(.push(id: 0, state: Child.State()))) { + $0.children[id: 0] = Child.State() + } } - #endif - func testSendCopiesStackElementIDGenerator() async { - struct Feature: Reducer { - struct State: Equatable { - var path = StackState() + #if DEBUG + func testPushReusedID() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action: Equatable {} + var body: some Reducer { + EmptyReducer() + } + } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() + } + enum Action: Equatable { + case child(StackAction) + } + var body: some ReducerOf { + Reduce { _, _ in .none } + .forEach(\.children, action: /Action.child) { Child() } + } + } + let line = #line - 3 + + let store = TestStore(initialState: Parent.State()) { + Parent() + } + + XCTExpectFailure { + $0.compactDescription == """ + A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ + "push" action for an element it already contains. … + + ID: + #0 + Path IDs: + [#0] + """ + } + + await store.send(.child(.push(id: 0, state: Child.State()))) { + $0.children[id: 0] = Child.State() + } + await store.send(.child(.push(id: 0, state: Child.State()))) } - enum Action: Equatable { - case buttonTapped - case path(StackAction) - case response - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .buttonTapped: - state.path.append(1) - return .send(.response) - case .path: - return .none - case .response: - state.path.append(2) - return .none + #endif + + #if DEBUG + func testPushIDGreaterThanNextGeneration() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action: Equatable {} + var body: some Reducer { + EmptyReducer() + } + } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() } + enum Action: Equatable { + case child(StackAction) + } + var body: some ReducerOf { + Reduce { _, _ in .none } + .forEach(\.children, action: /Action.child) { Child() } + } + } + let line = #line - 3 + + let store = TestStore(initialState: Parent.State()) { + Parent() + } + + XCTExpectFailure { + $0.compactDescription == """ + A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" received a \ + "push" action with an unexpected generational ID. … + + Received ID: + #1 + Expected ID: + #0 + """ + } + + await store.send(.child(.push(id: 1, state: Child.State()))) { + $0.children[id: 1] = Child.State() } - .forEach(\.path, action: /Action.path) {} } - } - let store = TestStore(initialState: Feature.State()) { - Feature() - } + func testMismatchedIDFailure() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action: Equatable {} + var body: some Reducer { + EmptyReducer() + } + } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() + } + enum Action: Equatable { + case child(StackAction) + } + var body: some ReducerOf { + Reduce { _, _ in .none }.forEach(\.children, action: /Action.child) { Child() } + } + } - await store.send(.buttonTapped) { - $0.path[id: 0] = 1 - @Dependency(\.stackElementID) var stackElementID - _ = stackElementID.next() - _ = stackElementID.next() - _ = stackElementID.next() - } - await store.receive(.response) { - $0.path[id: 1] = 2 - @Dependency(\.stackElementID) var stackElementID - _ = stackElementID.next() - _ = stackElementID.next() - _ = stackElementID.next() - } - await store.send(.buttonTapped) { - $0.path[id: 2] = 1 - } - await store.receive(.response) { - $0.path[id: 3] = 2 - } - } + let store = TestStore(initialState: Parent.State()) { + Parent() + } + + XCTExpectFailure { + $0.compactDescription == """ + A state change does not match expectation: … + +   StackReducerTests.Parent.State( +   children: [ + − #1: StackReducerTests.Child.State() + + #0: StackReducerTests.Child.State() +   ] +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.child(.push(id: 0, state: Child.State()))) { + $0.children[id: 1] = Child.State() + } + } + #endif - func testOuterCancellation() async { - struct Child: Reducer { - struct State: Equatable {} - enum Action: Equatable { case onAppear } - var body: some ReducerOf { - Reduce { state, action in - .run { _ in - try await Task.never() + func testSendCopiesStackElementIDGenerator() async { + struct Feature: Reducer { + struct State: Equatable { + var path = StackState() + } + enum Action: Equatable { + case buttonTapped + case path(StackAction) + case response + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .buttonTapped: + state.path.append(1) + return .send(.response) + case .path: + return .none + case .response: + state.path.append(2) + return .none + } } + .forEach(\.path, action: /Action.path) {} } } + + let store = TestStore(initialState: Feature.State()) { + Feature() + } + + await store.send(.buttonTapped) { + $0.path[id: 0] = 1 + @Dependency(\.stackElementID) var stackElementID + _ = stackElementID.next() + _ = stackElementID.next() + _ = stackElementID.next() + } + await store.receive(.response) { + $0.path[id: 1] = 2 + @Dependency(\.stackElementID) var stackElementID + _ = stackElementID.next() + _ = stackElementID.next() + _ = stackElementID.next() + } + await store.send(.buttonTapped) { + $0.path[id: 2] = 1 + } + await store.receive(.response) { + $0.path[id: 3] = 2 + } } - struct Parent: Reducer { - struct State: Equatable { - var children = StackState() - } - enum Action: Equatable { - case children(StackAction) - case tapAfter - case tapBefore - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .children: - return .none - case .tapAfter: - return .none - case .tapBefore: - state.children.removeAll() - return .none + func testOuterCancellation() async { + struct Child: Reducer { + struct State: Equatable {} + enum Action: Equatable { case onAppear } + var body: some ReducerOf { + Reduce { state, action in + .run { _ in + try await Task.never() + } } } + } - Reduce { state, action in - switch action { - case .children: - return .none - case .tapAfter: - return .none - case .tapBefore: - return .none - } + struct Parent: Reducer { + struct State: Equatable { + var children = StackState() } - .forEach(\.children, action: /Action.children) { - Child() + enum Action: Equatable { + case children(StackAction) + case tapAfter + case tapBefore } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .children: + return .none + case .tapAfter: + return .none + case .tapBefore: + state.children.removeAll() + return .none + } + } + + Reduce { state, action in + switch action { + case .children: + return .none + case .tapAfter: + return .none + case .tapBefore: + return .none + } + } + .forEach(\.children, action: /Action.children) { + Child() + } - Reduce { state, action in - switch action { - case .children: - return .none - case .tapAfter: - state.children.removeAll() - return .none - case .tapBefore: - return .none + Reduce { state, action in + switch action { + case .children: + return .none + case .tapAfter: + state.children.removeAll() + return .none + case .tapBefore: + return .none + } } } } - } - let store = TestStore(initialState: Parent.State()) { - Parent() - } + let store = TestStore(initialState: Parent.State()) { + Parent() + } - await store.send(.children(.push(id: 0, state: Child.State()))) { - $0.children[id: 0] = Child.State() - } - await store.send(.children(.element(id: 0, action: .onAppear))) - await store.send(.tapBefore) { - $0.children.removeAll() - } + await store.send(.children(.push(id: 0, state: Child.State()))) { + $0.children[id: 0] = Child.State() + } + await store.send(.children(.element(id: 0, action: .onAppear))) + await store.send(.tapBefore) { + $0.children.removeAll() + } - await store.send(.children(.push(id: 1, state: Child.State()))) { - $0.children[id: 1] = Child.State() - } - await store.send(.children(.element(id: 1, action: .onAppear))) - await store.send(.tapAfter) { - $0.children.removeAll() + await store.send(.children(.push(id: 1, state: Child.State()))) { + $0.children[id: 1] = Child.State() + } + await store.send(.children(.element(id: 1, action: .onAppear))) + await store.send(.tapAfter) { + $0.children.removeAll() + } + // NB: Another action needs to come into the `ifLet` to cancel the child action + await store.send(.tapAfter) } - // NB: Another action needs to come into the `ifLet` to cancel the child action - await store.send(.tapAfter) } -} +#endif diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 3e03aedb3009..2e3551d837c8 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -87,15 +87,13 @@ func testViewStoreSendMainThread() async { uncheckedUseMainSerialExecutor = false XCTExpectFailure { - [ - """ + $0.compactDescription == """ "ViewStore.send" was called on a non-main thread with: () … The "Store" class is not thread-safe, and so all interactions with an instance of \ "Store" (including all of its scopes and derived view stores) must be done on the main \ thread. """ - ].contains($0.compactDescription) } let store = Store(initialState: 0) {} diff --git a/Tests/ComposableArchitectureTests/ScopeCacheTests.swift b/Tests/ComposableArchitectureTests/ScopeCacheTests.swift index 25980dd587e8..28cd9ad1568a 100644 --- a/Tests/ComposableArchitectureTests/ScopeCacheTests.swift +++ b/Tests/ComposableArchitectureTests/ScopeCacheTests.swift @@ -1,53 +1,155 @@ -@_spi(Internals) import ComposableArchitecture -import XCTest - -class ScopeCacheTests: XCTestCase { - func testBasics() { - let store = Store(initialState: Parent.State(child: Child.State())) { - Parent() - } - let childStore = store.scope(state: \.child, action: \.child) - let unwrappedChildStore = childStore.scope( - id: childStore.id(state: \.!, action: \.self), - state: ToState { $0! }, - action: { $0 }, - isInvalid: { $0 == nil } - ) - unwrappedChildStore.send(.dismiss) - XCTAssertEqual(store.currentState.child, nil) - } -} +#if swift(>=5.9) + @_spi(Internals) import ComposableArchitecture + import XCTest -@Reducer -private struct Parent { - struct State { - @PresentationState var child: Child.State? - } - enum Action { - case child(PresentationAction) - case show + @MainActor + final class ScopeCacheTests: BaseTCATestCase { + @available(*, deprecated) + func testOptionalScope_UncachedStore() { + #if DEBUG + let store = StoreOf(initialState: Feature.State(child: Feature.State())) { + } + + XCTExpectFailure { + _ = + store + .scope(state: { $0 }, action: { $0 }) + .scope(state: \.child, action: \.child.presented)? + .send(.show) + } issueMatcher: { + $0.compactDescription == """ + Scoping from uncached StoreOf is not compatible with observation. Ensure that all \ + parent store scoping operations take key paths and case key paths instead of transform \ + functions, which have been deprecated. + """ + } + store.send(.child(.dismiss)) + #endif + } + + func testOptionalScope_CachedStore() { + #if DEBUG + let store = StoreOf(initialState: Feature.State(child: Feature.State())) { + } + store + .scope(state: \.self, action: \.self) + .scope(state: \.child, action: \.child.presented)? + .send(.show) + #endif + } + + func testOptionalScope_StoreIfLet() { + #if DEBUG + let store = StoreOf(initialState: Feature.State(child: Feature.State())) { + Feature() + } + let cancellable = + store + .scope(state: \.child, action: \.child.presented) + .ifLet { store in + store.scope(state: \.child, action: \.child.presented)?.send(.show) + } + _ = cancellable + #endif + } + + @available(*, deprecated) + func testOptionalScope_StoreIfLet_UncachedStore() { + #if DEBUG + let store = StoreOf(initialState: Feature.State(child: Feature.State())) { + } + XCTExpectFailure { + let cancellable = + store + .scope(state: { $0 }, action: { $0 }) + .ifLet { store in + store.scope(state: \.child, action: \.child.presented)?.send(.show) + } + _ = cancellable + } issueMatcher: { + $0.compactDescription == """ + Scoping from uncached StoreOf is not compatible with observation. Ensure that all \ + parent store scoping operations take key paths and case key paths instead of transform \ + functions, which have been deprecated. + """ + } + #endif + } + + func testIdentifiedArrayScope_CachedStore() { + #if DEBUG + let store = StoreOf(initialState: Feature.State(rows: [Feature.State()])) { + } + + let rowsStore = Array( + store + .scope(state: \.self, action: \.self) + .scope(state: \.rows, action: \.rows) + ) + rowsStore[0].send(.show) + #endif + } + + @available(*, deprecated) + func testIdentifiedArrayScope_UncachedStore() { + #if DEBUG + let store = StoreOf(initialState: Feature.State(rows: [Feature.State()])) { + Feature() + } + XCTExpectFailure { + _ = Array( + store + .scope(state: { $0 }, action: { $0 }) + .scope(state: \.rows, action: \.rows) + ) + } issueMatcher: { + $0.compactDescription == """ + Scoping from uncached StoreOf is not compatible with observation. Ensure that all \ + parent store scoping operations take key paths and case key paths instead of transform \ + functions, which have been deprecated. + """ + } + #endif + } } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .child(.presented(.dismiss)): - state.child = nil - return .none - case .child: - return .none - case .show: - state.child = Child.State() - return .none - } + + @Reducer + private struct Feature { + @ObservableState + struct State: Identifiable, Equatable { + let id = UUID() + @Presents var child: Feature.State? + var rows: IdentifiedArrayOf = [] + } + indirect enum Action { + case child(PresentationAction) + case dismiss + case rows(IdentifiedActionOf) + case show } - .ifLet(\.$child, action: \.child) { - Child() + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .child(.presented(.dismiss)): + state.child = nil + return .none + case .child: + return .none + case .dismiss: + return .none + case .rows: + return .none + case .show: + state.child = Feature.State() + return .none + } + } + .ifLet(\.$child, action: \.child) { + Feature() + } + .forEach(\.rows, action: \.rows) { + Feature() + } } } -} -@Reducer -private struct Child { - struct State: Equatable {} - enum Action { case dismiss } - var body: some ReducerOf { EmptyReducer() } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ScopeLoggerTests.swift b/Tests/ComposableArchitectureTests/ScopeLoggerTests.swift index 16da32103513..4baac97235fe 100644 --- a/Tests/ComposableArchitectureTests/ScopeLoggerTests.swift +++ b/Tests/ComposableArchitectureTests/ScopeLoggerTests.swift @@ -1,80 +1,82 @@ -@_spi(Logging) import ComposableArchitecture -import SwiftUI -import XCTest +#if swift(>=5.9) + @_spi(Logging) import ComposableArchitecture + import SwiftUI + import XCTest -class ScopeLoggerTests: XCTestCase { - func testScoping() { - #if DEBUG - Logger.shared.isEnabled = true - let store = Store( - initialState: NavigationTestCaseView.Feature.State( - path: StackState([ - BasicsView.Feature.State() - ]) + class ScopeLoggerTests: XCTestCase { + func testScoping() { + #if DEBUG + Logger.shared.isEnabled = true + let store = Store( + initialState: NavigationTestCaseView.Feature.State( + path: StackState([ + BasicsView.Feature.State() + ]) + ) + ) { + NavigationTestCaseView.Feature() + } + let viewStore = ViewStore(store, observe: { $0 }) + let pathStore = store.scope(state: \.path, action: \.path) + let elementStore = pathStore.scope(state: \.[id:0]!, action: \.[id:0]) + Logger.shared.clear() + elementStore.send(.incrementButtonTapped) + XCTAssertEqual( + [], + Logger.shared.logs ) - ) { - NavigationTestCaseView.Feature() - } - let viewStore = ViewStore(store, observe: { $0 }) - let pathStore = store.scope(state: \.path, action: \.path) - let elementStore = pathStore.scope(state: \.[id:0]!, action: \.[id:0]) - Logger.shared.clear() - elementStore.send(.incrementButtonTapped) - XCTAssertEqual( - [], - Logger.shared.logs - ) - let _ = viewStore - let _ = pathStore - let _ = elementStore - #endif + let _ = viewStore + let _ = pathStore + let _ = elementStore + #endif + } } -} -struct NavigationTestCaseView { - @Reducer - struct Feature { - struct State: Equatable { - var path = StackState() - } - enum Action { - case path(StackAction) - } - var body: some ReducerOf { - EmptyReducer() - .forEach(\.path, action: \.path) { - BasicsView.Feature() - } + struct NavigationTestCaseView { + @Reducer + struct Feature { + struct State: Equatable { + var path = StackState() + } + enum Action { + case path(StackAction) + } + var body: some ReducerOf { + EmptyReducer() + .forEach(\.path, action: \.path) { + BasicsView.Feature() + } + } } } -} -struct BasicsView { - @Reducer - struct Feature { - struct State: Equatable, Identifiable { - let id = UUID() - var count = 0 - } - enum Action { - case decrementButtonTapped - case dismissButtonTapped - case incrementButtonTapped - } - @Dependency(\.dismiss) var dismiss - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .decrementButtonTapped: - state.count -= 1 - return .none - case .dismissButtonTapped: - return .run { _ in await self.dismiss() } - case .incrementButtonTapped: - state.count += 1 - return .none + struct BasicsView { + @Reducer + struct Feature { + struct State: Equatable, Identifiable { + let id = UUID() + var count = 0 + } + enum Action { + case decrementButtonTapped + case dismissButtonTapped + case incrementButtonTapped + } + @Dependency(\.dismiss) var dismiss + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + case .dismissButtonTapped: + return .run { _ in await self.dismiss() } + case .incrementButtonTapped: + state.count += 1 + return .none + } } } } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ScopeTests.swift b/Tests/ComposableArchitectureTests/ScopeTests.swift index cc7c7e183fd0..a12c6ee82ddd 100644 --- a/Tests/ComposableArchitectureTests/ScopeTests.swift +++ b/Tests/ComposableArchitectureTests/ScopeTests.swift @@ -1,154 +1,156 @@ -import ComposableArchitecture -import XCTest +#if swift(>=5.9) + import ComposableArchitecture + import XCTest -@MainActor -@available(*, deprecated, message: "TODO: Update to use case pathable syntax with Swift 5.9") -final class ScopeTests: BaseTCATestCase { - func testStructChild() async { - let store = TestStore(initialState: Feature.State()) { - Feature() - } + @MainActor + @available(*, deprecated, message: "TODO: Update to use case pathable syntax with Swift 5.9") + final class ScopeTests: BaseTCATestCase { + func testStructChild() async { + let store = TestStore(initialState: Feature.State()) { + Feature() + } - await store.send(.child1(.incrementButtonTapped)) { - $0.child1.count = 1 - } - await store.send(.child1(.decrementButtonTapped)) { - $0.child1.count = 0 - } - await store.send(.child1(.decrementButtonTapped)) { - $0.child1.count = -1 - } - await store.receive(.child1(.incrementButtonTapped)) { - $0.child1.count = 0 + await store.send(.child1(.incrementButtonTapped)) { + $0.child1.count = 1 + } + await store.send(.child1(.decrementButtonTapped)) { + $0.child1.count = 0 + } + await store.send(.child1(.decrementButtonTapped)) { + $0.child1.count = -1 + } + await store.receive(.child1(.incrementButtonTapped)) { + $0.child1.count = 0 + } } - } - func testEnumChild() async { - let store = TestStore(initialState: Feature.State()) { - Feature() - } + func testEnumChild() async { + let store = TestStore(initialState: Feature.State()) { + Feature() + } - await store.send(.child2(.count(1))) { - $0.child2 = .count(1) - } - await store.send(.child2(.count(-1))) { - $0.child2 = .count(-1) - } - await store.receive(.child2(.count(0))) { - $0.child2 = .count(0) + await store.send(.child2(.count(1))) { + $0.child2 = .count(1) + } + await store.send(.child2(.count(-1))) { + $0.child2 = .count(-1) + } + await store.receive(.child2(.count(0))) { + $0.child2 = .count(0) + } } - } - #if DEBUG - func testNilChild() async { - let store = TestStoreOf(initialState: Child2.State.count(0)) { - Scope(state: \.name, action: \.name) {} - } + #if DEBUG + func testNilChild() async { + let store = TestStoreOf(initialState: Child2.State.count(0)) { + Scope(state: \.name, action: \.name) {} + } - XCTExpectFailure { - $0.compactDescription == """ - A "Scope" at "\(#fileID):\(#line - 5)" received a child action when child state was set to \ - a different case. … + XCTExpectFailure { + $0.compactDescription == """ + A "Scope" at "\(#fileID):\(#line - 5)" received a child action when child state was set to \ + a different case. … - Action: - Child2.Action.name - State: - Child2.State.count + Action: + Child2.Action.name + State: + Child2.State.count - This is generally considered an application logic error, and can happen for a few reasons: + This is generally considered an application logic error, and can happen for a few reasons: - • A parent reducer set "Child2.State" to a different case before the scoped reducer ran. \ - Child reducers must run before any parent reducer sets child state to a different case. \ - This ensures that child reducers can handle their actions while their state is still \ - available. Consider using "Reducer.ifCaseLet" to embed this child reducer in the \ - parent reducer that change its state to ensure the child reducer runs first. + • A parent reducer set "Child2.State" to a different case before the scoped reducer ran. \ + Child reducers must run before any parent reducer sets child state to a different case. \ + This ensures that child reducers can handle their actions while their state is still \ + available. Consider using "Reducer.ifCaseLet" to embed this child reducer in the \ + parent reducer that change its state to ensure the child reducer runs first. - • An in-flight effect emitted this action when child state was unavailable. While it may \ - be perfectly reasonable to ignore this action, consider canceling the associated effect \ - before child state changes to another case, especially if it is a long-living effect. + • An in-flight effect emitted this action when child state was unavailable. While it may \ + be perfectly reasonable to ignore this action, consider canceling the associated effect \ + before child state changes to another case, especially if it is a long-living effect. - • This action was sent to the store while state was another case. Make sure that actions \ - for this reducer can only be sent from a view store when state is set to the appropriate \ - case. In SwiftUI applications, use "SwitchStore". - """ + • This action was sent to the store while state was another case. Make sure that actions \ + for this reducer can only be sent from a view store when state is set to the appropriate \ + case. In SwiftUI applications, use "SwitchStore". + """ + } + + await store.send(.name("Blob")) } + #endif + } - await store.send(.name("Blob")) + @Reducer + private struct Feature { + struct State: Equatable { + var child1 = Child1.State() + var child2 = Child2.State.count(0) } - #endif -} - -@Reducer -private struct Feature { - struct State: Equatable { - var child1 = Child1.State() - var child2 = Child2.State.count(0) - } - enum Action: Equatable { - case child1(Child1.Action) - case child2(Child2.Action) - } - var body: some ReducerOf { - Scope(state: \.child1, action: \.child1) { - Child1() + enum Action: Equatable { + case child1(Child1.Action) + case child2(Child2.Action) } - Scope(state: \.child2, action: \.child2) { - Child2() + var body: some ReducerOf { + Scope(state: \.child1, action: \.child1) { + Child1() + } + Scope(state: \.child2, action: \.child2) { + Child2() + } } } -} -@Reducer -private struct Child1 { - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case decrementButtonTapped - case incrementButtonTapped - } - var body: some Reducer { - Reduce { state, action in - switch action { - case .decrementButtonTapped: - state.count -= 1 - return state.count < 0 - ? .run { await $0(.incrementButtonTapped) } - : .none - case .incrementButtonTapped: - state.count += 1 - return .none + @Reducer + private struct Child1 { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case decrementButtonTapped + case incrementButtonTapped + } + var body: some Reducer { + Reduce { state, action in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return state.count < 0 + ? .run { await $0(.incrementButtonTapped) } + : .none + case .incrementButtonTapped: + state.count += 1 + return .none + } } } } -} -@Reducer -private struct Child2 { - enum State: Equatable { - case count(Int) - case name(String) - } - enum Action: Equatable { - case count(Int) - case name(String) - } - var body: some ReducerOf { - Scope(state: \.count, action: \.count) { - Reduce { state, action in - state = action - return state < 0 - ? .run { await $0(0) } - : .none - } + @Reducer + private struct Child2 { + enum State: Equatable { + case count(Int) + case name(String) } - Scope(state: \.name, action: \.name) { - Reduce { state, action in - state = action - return state.isEmpty - ? .run { await $0("Empty") } - : .none + enum Action: Equatable { + case count(Int) + case name(String) + } + var body: some ReducerOf { + Scope(state: \.count, action: \.count) { + Reduce { state, action in + state = action + return state < 0 + ? .run { await $0(0) } + : .none + } + } + Scope(state: \.name, action: \.name) { + Reduce { state, action in + state = action + return state.isEmpty + ? .run { await $0("Empty") } + : .none + } } } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift b/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift index ff5291ce0fb2..85ddedc29de5 100644 --- a/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift +++ b/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift @@ -1,185 +1,189 @@ -import Combine -@_spi(Logging) import ComposableArchitecture -import XCTest +#if swift(>=5.9) + import Combine + @_spi(Logging) import ComposableArchitecture + import XCTest -@MainActor -final class StoreLifetimeTests: BaseTCATestCase { - func testStoreCaching() { - let grandparentStore = Store(initialState: Grandparent.State()) { - Grandparent() + @MainActor + final class StoreLifetimeTests: BaseTCATestCase { + @available(*, deprecated) + func testStoreCaching() { + let grandparentStore = Store(initialState: Grandparent.State()) { + Grandparent() + } + let parentStore = grandparentStore.scope(state: \.child, action: \.child) + XCTAssertTrue(parentStore === grandparentStore.scope(state: \.child, action: \.child)) + XCTAssertFalse( + parentStore === grandparentStore.scope(state: { $0.child }, action: { .child($0) }) + ) + let childStore = parentStore.scope(state: \.child, action: \.child) + XCTAssertTrue(childStore === parentStore.scope(state: \.child, action: \.child)) + XCTAssertFalse( + childStore === parentStore.scope(state: { $0.child }, action: { .child($0) }) + ) } - let parentStore = grandparentStore.scope(state: \.child, action: \.child) - XCTAssertTrue(parentStore === grandparentStore.scope(state: \.child, action: \.child)) - XCTAssertFalse( - parentStore === grandparentStore.scope(state: { $0.child }, action: { .child($0) }) - ) - let childStore = parentStore.scope(state: \.child, action: \.child) - XCTAssertTrue(childStore === parentStore.scope(state: \.child, action: \.child)) - XCTAssertFalse( - childStore === parentStore.scope(state: { $0.child }, action: { .child($0) }) - ) - } - func testStoreInvalidation() { - let grandparentStore = Store(initialState: Grandparent.State()) { - Grandparent() - } - var parentStore: Store! = grandparentStore.scope(state: { $0.child }, action: { .child($0) }) - let childStore = parentStore.scope(state: \.child, action: \.child) + @available(*, deprecated) + func testStoreInvalidation() { + let grandparentStore = Store(initialState: Grandparent.State()) { + Grandparent() + } + var parentStore: Store! = grandparentStore.scope(state: { $0.child }, action: { .child($0) }) + let childStore = parentStore.scope(state: \.child, action: \.child) - childStore.send(.tap) - XCTAssertEqual(1, grandparentStore.withState(\.child.child.count)) - XCTAssertEqual(1, parentStore.withState(\.child.count)) - XCTAssertEqual(1, childStore.withState(\.count)) - grandparentStore.send(.incrementGrandchild) - XCTAssertEqual(2, grandparentStore.withState(\.child.child.count)) - XCTAssertEqual(2, parentStore.withState(\.child.count)) - XCTAssertEqual(2, childStore.withState(\.count)) + childStore.send(.tap) + XCTAssertEqual(1, grandparentStore.withState(\.child.child.count)) + XCTAssertEqual(1, parentStore.withState(\.child.count)) + XCTAssertEqual(1, childStore.withState(\.count)) + grandparentStore.send(.incrementGrandchild) + XCTAssertEqual(2, grandparentStore.withState(\.child.child.count)) + XCTAssertEqual(2, parentStore.withState(\.child.count)) + XCTAssertEqual(2, childStore.withState(\.count)) - parentStore = nil + parentStore = nil - childStore.send(.tap) - XCTAssertEqual(3, grandparentStore.withState(\.child.child.count)) - XCTAssertEqual(3, childStore.withState(\.count)) - grandparentStore.send(.incrementGrandchild) - XCTAssertEqual(4, grandparentStore.withState(\.child.child.count)) - XCTAssertEqual(4, childStore.withState(\.count)) - } + childStore.send(.tap) + XCTAssertEqual(3, grandparentStore.withState(\.child.child.count)) + XCTAssertEqual(3, childStore.withState(\.count)) + grandparentStore.send(.incrementGrandchild) + XCTAssertEqual(4, grandparentStore.withState(\.child.child.count)) + XCTAssertEqual(4, childStore.withState(\.count)) + } - #if DEBUG - func testStoreDeinit() { - Logger.shared.isEnabled = true - do { - let store = Store(initialState: ()) {} - _ = store - } + #if DEBUG + func testStoreDeinit() { + Logger.shared.isEnabled = true + do { + let store = Store(initialState: ()) {} + _ = store + } - XCTAssertEqual( - Logger.shared.logs, - [ - "Store<(), ()>.init", - "Store<(), ()>.deinit", - ] - ) - } + XCTAssertEqual( + Logger.shared.logs, + [ + "Store<(), ()>.init", + "Store<(), ()>.deinit", + ] + ) + } - func testStoreDeinit_RunningEffect() async { - XCTTODO( - "We would like for this to pass, but it requires full deprecation of uncached child stores" - ) - Logger.shared.isEnabled = true - let effectFinished = self.expectation(description: "Effect finished") - do { - let store = Store(initialState: ()) { - Reduce { state, _ in - .run { _ in - try? await Task.never() - effectFinished.fulfill() + func testStoreDeinit_RunningEffect() async { + XCTTODO( + "We would like for this to pass, but it requires full deprecation of uncached child stores" + ) + Logger.shared.isEnabled = true + let effectFinished = self.expectation(description: "Effect finished") + do { + let store = Store(initialState: ()) { + Reduce { state, _ in + .run { _ in + try? await Task.never() + effectFinished.fulfill() + } } } + store.send(()) + _ = store } - store.send(()) - _ = store - } - XCTAssertEqual( - Logger.shared.logs, - [ - "Store<(), ()>.init", - "Store<(), ()>.deinit", - ] - ) - await self.fulfillment(of: [effectFinished], timeout: 0.5) - } + XCTAssertEqual( + Logger.shared.logs, + [ + "Store<(), ()>.init", + "Store<(), ()>.deinit", + ] + ) + await self.fulfillment(of: [effectFinished], timeout: 0.5) + } - func testStoreDeinit_RunningCombineEffect() async { - XCTTODO( - "We would like for this to pass, but it requires full deprecation of uncached child stores" - ) - Logger.shared.isEnabled = true - let effectFinished = self.expectation(description: "Effect finished") - do { - let store = Store(initialState: ()) { - Reduce { state, _ in - .publisher { - Empty(completeImmediately: false) - .handleEvents(receiveCancel: { - effectFinished.fulfill() - }) + func testStoreDeinit_RunningCombineEffect() async { + XCTTODO( + "We would like for this to pass, but it requires full deprecation of uncached child stores" + ) + Logger.shared.isEnabled = true + let effectFinished = self.expectation(description: "Effect finished") + do { + let store = Store(initialState: ()) { + Reduce { state, _ in + .publisher { + Empty(completeImmediately: false) + .handleEvents(receiveCancel: { + effectFinished.fulfill() + }) + } } } + store.send(()) + _ = store } - store.send(()) - _ = store + + XCTAssertEqual( + Logger.shared.logs, + [ + "Store<(), ()>.init", + "Store<(), ()>.deinit", + ] + ) + await self.fulfillment(of: [effectFinished], timeout: 0.5) } + #endif + } - XCTAssertEqual( - Logger.shared.logs, - [ - "Store<(), ()>.init", - "Store<(), ()>.deinit", - ] - ) - await self.fulfillment(of: [effectFinished], timeout: 0.5) + @Reducer + private struct Child { + struct State: Equatable { + var count = 0 } - #endif -} - -@Reducer -private struct Child { - struct State: Equatable { - var count = 0 - } - enum Action { - case tap - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .tap: - state.count += 1 - return .none + enum Action { + case tap + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .tap: + state.count += 1 + return .none + } } } } -} -@Reducer -private struct Parent { - struct State: Equatable { - var child = Child.State() - } - enum Action { - case child(Child.Action) - } - var body: some ReducerOf { - Scope(state: \.child, action: \.child) { - Child() + @Reducer + private struct Parent { + struct State: Equatable { + var child = Child.State() + } + enum Action { + case child(Child.Action) + } + var body: some ReducerOf { + Scope(state: \.child, action: \.child) { + Child() + } } } -} -@Reducer -private struct Grandparent { - struct State: Equatable { - var child = Parent.State() - } - enum Action { - case child(Parent.Action) - case incrementGrandchild - } - var body: some ReducerOf { - Scope(state: \.child, action: \.child) { - Parent() + @Reducer + private struct Grandparent { + struct State: Equatable { + var child = Parent.State() + } + enum Action { + case child(Parent.Action) + case incrementGrandchild } - Reduce { state, action in - switch action { - case .child: - return .none - case .incrementGrandchild: - state.child.child.count += 1 - return .none + var body: some ReducerOf { + Scope(state: \.child, action: \.child) { + Parent() + } + Reduce { state, action in + switch action { + case .child: + return .none + case .incrementGrandchild: + state.child.child.count += 1 + return .none + } } } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/StorePerceptionTests.swift b/Tests/ComposableArchitectureTests/StorePerceptionTests.swift new file mode 100644 index 000000000000..e31273d190ca --- /dev/null +++ b/Tests/ComposableArchitectureTests/StorePerceptionTests.swift @@ -0,0 +1,86 @@ +#if swift(>=5.9) + @_spi(Logging) import ComposableArchitecture + import SwiftUI + import XCTest + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor + final class StorePerceptionTests: BaseTCATestCase { + func testPerceptionCheck_SkipWhenOutsideView() { + let store = Store(initialState: Feature.State()) { + Feature() + } + store.send(.tap) + } + + func testPerceptionCheck_SkipWhenActionClosureOfView() { + struct FeatureView: View { + let store = Store(initialState: Feature.State()) { + Feature() + } + var body: some View { + Text("Hi") + .onAppear { store.send(.tap) } + } + } + render(FeatureView()) + } + + #if DEBUG + func testPerceptionCheck_AccessStateWithoutTracking() { + if #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) { + struct FeatureView: View { + let store = Store(initialState: Feature.State()) { + Feature() + } + var body: some View { + Text(store.count.description) + } + } + XCTExpectFailure { + render(FeatureView()) + } issueMatcher: { + $0.compactDescription == """ + Perceptible state was accessed but is not being tracked. Track changes to state by \ + wrapping your view in a 'WithPerceptionTracking' view. + """ + } + } + } + #endif + + func testPerceptionCheck_AccessStateWithTracking() { + struct FeatureView: View { + let store = Store(initialState: Feature.State()) { + Feature() + } + var body: some View { + WithPerceptionTracking { + Text(store.count.description) + } + } + } + render(FeatureView()) + } + + private func render(_ view: some View) { + let image = ImageRenderer(content: view).cgImage + _ = image + } + } + + @Reducer + private struct Feature { + @ObservableState + struct State { + var count = 0 + } + enum Action { case tap } + var body: some ReducerOf { + Reduce { state, action in + state.count += 1 + return .none + } + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index cd98bb28ff6b..9a538d7b8c54 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -1,984 +1,993 @@ -import Combine -@_spi(Internals) import ComposableArchitecture -import XCTest +#if swift(>=5.9) + import Combine + @_spi(Internals) import ComposableArchitecture + import XCTest -@MainActor -final class StoreTests: BaseTCATestCase { - var cancellables: Set = [] + @MainActor + final class StoreTests: BaseTCATestCase { + var cancellables: Set = [] - func testCancellableIsRemovedOnImmediatelyCompletingEffect() { - let store = Store(initialState: ()) {} + func testCancellableIsRemovedOnImmediatelyCompletingEffect() { + let store = Store(initialState: ()) {} - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + XCTAssertEqual(store.rootStore.effectCancellables.count, 0) - store.send(()) + store.send(()) - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) - } + XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + } - func testCancellableIsRemovedWhenEffectCompletes() { - let mainQueue = DispatchQueue.test + func testCancellableIsRemovedWhenEffectCompletes() { + let mainQueue = DispatchQueue.test - enum Action { case start, end } + enum Action { case start, end } - let reducer = Reduce({ _, action in - switch action { - case .start: - return .publisher { - Just(.end) - .delay(for: 1, scheduler: mainQueue) + let reducer = Reduce({ _, action in + switch action { + case .start: + return .publisher { + Just(.end) + .delay(for: 1, scheduler: mainQueue) + } + case .end: + return .none } - case .end: - return .none - } - }) - let store = Store(initialState: ()) { reducer } - - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) - - store.send(.start) - - XCTAssertEqual(store.rootStore.effectCancellables.count, 1) + }) + let store = Store(initialState: ()) { reducer } - mainQueue.advance(by: 2) + XCTAssertEqual(store.rootStore.effectCancellables.count, 0) - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) - } + store.send(.start) - func testScopedStoreReceivesUpdatesFromParent() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) + XCTAssertEqual(store.rootStore.effectCancellables.count, 1) - let parentStore = Store(initialState: 0) { counterReducer } - let parentViewStore = ViewStore(parentStore, observe: { $0 }) - let childStore = parentStore.scope(state: String.init, action: { $0 }) + mainQueue.advance(by: 2) - var values: [String] = [] - ViewStore(childStore, observe: { $0 }) - .publisher - .sink(receiveValue: { values.append($0) }) - .store(in: &self.cancellables) - - XCTAssertEqual(values, ["0"]) + XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + } - parentViewStore.send(()) + @available(*, deprecated) + func testScopedStoreReceivesUpdatesFromParent() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) - XCTAssertEqual(values, ["0", "1"]) - } + let parentStore = Store(initialState: 0) { counterReducer } + let parentViewStore = ViewStore(parentStore, observe: { $0 }) + let childStore = parentStore.scope(state: String.init, action: { $0 }) - func testParentStoreReceivesUpdatesFromChild() { - let counterReducer = Reduce({ state, _ in - state += 1 - return .none - }) + var values: [String] = [] + ViewStore(childStore, observe: { $0 }) + .publisher + .sink(receiveValue: { values.append($0) }) + .store(in: &self.cancellables) - let parentStore = Store(initialState: 0) { counterReducer } - let childStore = parentStore.scope(state: String.init, action: { $0 }) - let childViewStore = ViewStore(childStore, observe: { $0 }) + XCTAssertEqual(values, ["0"]) - var values: [Int] = [] - ViewStore(parentStore, observe: { $0 }) - .publisher - .sink(receiveValue: { values.append($0) }) - .store(in: &self.cancellables) + parentViewStore.send(()) - XCTAssertEqual(values, [0]) + XCTAssertEqual(values, ["0", "1"]) + } - childViewStore.send(()) + @available(*, deprecated) + func testParentStoreReceivesUpdatesFromChild() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) - XCTAssertEqual(values, [0, 1]) - } + let parentStore = Store(initialState: 0) { counterReducer } + let childStore = parentStore.scope(state: String.init, action: { $0 }) + let childViewStore = ViewStore(childStore, observe: { $0 }) - func testScopeCallCount_OneLevel_NoSubscription() { - var numCalls1 = 0 - let store = Store(initialState: 0) {} - .scope( - state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }, - action: { $0 } - ) + var values: [Int] = [] + ViewStore(parentStore, observe: { $0 }) + .publisher + .sink(receiveValue: { values.append($0) }) + .store(in: &self.cancellables) - XCTAssertEqual(numCalls1, 0) - store.send(()) - XCTAssertEqual(numCalls1, 0) - } + XCTAssertEqual(values, [0]) - func testScopeCallCount_OneLevel_Subscribing() { - var numCalls1 = 0 - let store = Store(initialState: 0) {} - .scope( - state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }, - action: { $0 } - ) - let _ = store.publisher.sink { _ in } + childViewStore.send(()) - XCTAssertEqual(numCalls1, 1) - store.send(()) - XCTAssertEqual(numCalls1, 1) - } + XCTAssertEqual(values, [0, 1]) + } - func testScopeCallCount_TwoLevels_Subscribing() { - var numCalls1 = 0 - var numCalls2 = 0 - let store = Store(initialState: 0) {} - .scope( - state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }, - action: { $0 } - ) - .scope( - state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }, - action: { $0 } - ) - let _ = store.publisher.sink { _ in } + func testScopeCallCount_OneLevel_NoSubscription() { + var numCalls1 = 0 + let store = Store(initialState: 0) {} + .scope( + state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }, + action: { $0 } + ) - XCTAssertEqual(numCalls1, 1) - XCTAssertEqual(numCalls2, 1) - store.send(()) - XCTAssertEqual(numCalls1, 1) - XCTAssertEqual(numCalls2, 1) - } + XCTAssertEqual(numCalls1, 0) + store.send(()) + XCTAssertEqual(numCalls1, 0) + } - func testScopeCallCount_ThreeLevels_ViewStoreSubscribing() { - var numCalls1 = 0 - var numCalls2 = 0 - var numCalls3 = 0 - - let store1 = Store(initialState: 0) {} - let store2 = - store1 - .scope( - state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }, - action: { $0 } - ) - let store3 = - store2 - .scope( - state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }, - action: { $0 } - ) - let store4 = - store3 - .scope( - state: { (count: Int) -> Int in - numCalls3 += 1 - return count - }, - action: { $0 } - ) + func testScopeCallCount_OneLevel_Subscribing() { + var numCalls1 = 0 + let store = Store(initialState: 0) {} + .scope( + state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }, + action: { $0 } + ) + let _ = store.publisher.sink { _ in } + + XCTAssertEqual(numCalls1, 1) + store.send(()) + XCTAssertEqual(numCalls1, 1) + } + + func testScopeCallCount_TwoLevels_Subscribing() { + var numCalls1 = 0 + var numCalls2 = 0 + let store = Store(initialState: 0) {} + .scope( + state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }, + action: { $0 } + ) + .scope( + state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }, + action: { $0 } + ) + let _ = store.publisher.sink { _ in } + + XCTAssertEqual(numCalls1, 1) + XCTAssertEqual(numCalls2, 1) + store.send(()) + XCTAssertEqual(numCalls1, 1) + XCTAssertEqual(numCalls2, 1) + } + + func testScopeCallCount_ThreeLevels_ViewStoreSubscribing() { + var numCalls1 = 0 + var numCalls2 = 0 + var numCalls3 = 0 + + let store1 = Store(initialState: 0) {} + let store2 = + store1 + .scope( + state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }, + action: { $0 } + ) + let store3 = + store2 + .scope( + state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }, + action: { $0 } + ) + let store4 = + store3 + .scope( + state: { (count: Int) -> Int in + numCalls3 += 1 + return count + }, + action: { $0 } + ) - let viewStore1 = ViewStore(store1, observe: { $0 }) - let viewStore2 = ViewStore(store2, observe: { $0 }) - let viewStore3 = ViewStore(store3, observe: { $0 }) - let viewStore4 = ViewStore(store4, observe: { $0 }) - defer { - _ = viewStore1 - _ = viewStore2 - _ = viewStore3 - _ = viewStore4 - } + let viewStore1 = ViewStore(store1, observe: { $0 }) + let viewStore2 = ViewStore(store2, observe: { $0 }) + let viewStore3 = ViewStore(store3, observe: { $0 }) + let viewStore4 = ViewStore(store4, observe: { $0 }) + defer { + _ = viewStore1 + _ = viewStore2 + _ = viewStore3 + _ = viewStore4 + } - XCTAssertEqual(numCalls1, 6) - XCTAssertEqual(numCalls2, 4) - XCTAssertEqual(numCalls3, 2) + XCTAssertEqual(numCalls1, 6) + XCTAssertEqual(numCalls2, 4) + XCTAssertEqual(numCalls3, 2) - viewStore4.send(()) + viewStore4.send(()) - XCTAssertEqual(numCalls1, 9) - XCTAssertEqual(numCalls2, 6) - XCTAssertEqual(numCalls3, 3) + XCTAssertEqual(numCalls1, 9) + XCTAssertEqual(numCalls2, 6) + XCTAssertEqual(numCalls3, 3) - viewStore4.send(()) + viewStore4.send(()) - XCTAssertEqual(numCalls1, 12) - XCTAssertEqual(numCalls2, 8) - XCTAssertEqual(numCalls3, 4) + XCTAssertEqual(numCalls1, 12) + XCTAssertEqual(numCalls2, 8) + XCTAssertEqual(numCalls3, 4) - viewStore4.send(()) + viewStore4.send(()) - XCTAssertEqual(numCalls1, 15) - XCTAssertEqual(numCalls2, 10) - XCTAssertEqual(numCalls3, 5) + XCTAssertEqual(numCalls1, 15) + XCTAssertEqual(numCalls2, 10) + XCTAssertEqual(numCalls3, 5) - viewStore4.send(()) + viewStore4.send(()) - XCTAssertEqual(numCalls1, 18) - XCTAssertEqual(numCalls2, 12) - XCTAssertEqual(numCalls3, 6) - } + XCTAssertEqual(numCalls1, 18) + XCTAssertEqual(numCalls2, 12) + XCTAssertEqual(numCalls3, 6) + } - func testSynchronousEffectsSentAfterSinking() { - enum Action { - case tap - case next1 - case next2 - case end - } - var values: [Int] = [] - let counterReducer = Reduce({ state, action in - switch action { - case .tap: - return .merge( - .send(.next1), - .send(.next2), - .publisher { - values.append(1) + func testSynchronousEffectsSentAfterSinking() { + enum Action { + case tap + case next1 + case next2 + case end + } + var values: [Int] = [] + let counterReducer = Reduce({ state, action in + switch action { + case .tap: + return .merge( + .send(.next1), + .send(.next2), + .publisher { + values.append(1) + return Empty(outputType: Action.self, failureType: Never.self) + } + ) + case .next1: + return .merge( + .send(.end), + .publisher { + values.append(2) + return Empty(outputType: Action.self, failureType: Never.self) + } + ) + case .next2: + return .publisher { + values.append(3) return Empty(outputType: Action.self, failureType: Never.self) } - ) - case .next1: - return .merge( - .send(.end), - .publisher { - values.append(2) + case .end: + return .publisher { + values.append(4) return Empty(outputType: Action.self, failureType: Never.self) } - ) - case .next2: - return .publisher { - values.append(3) - return Empty(outputType: Action.self, failureType: Never.self) - } - case .end: - return .publisher { - values.append(4) - return Empty(outputType: Action.self, failureType: Never.self) } - } - }) - - let store = Store(initialState: ()) { counterReducer } + }) - _ = ViewStore(store, observe: {}, removeDuplicates: ==).send(.tap) + let store = Store(initialState: ()) { counterReducer } - XCTAssertEqual(values, [1, 2, 3, 4]) - } + _ = ViewStore(store, observe: {}, removeDuplicates: ==).send(.tap) - func testLotsOfSynchronousActions() { - enum Action { case incr, noop } - let reducer = Reduce({ state, action in - switch action { - case .incr: - state += 1 - return .send(state >= 100_000 ? .noop : .incr) - case .noop: - return .none - } - }) + XCTAssertEqual(values, [1, 2, 3, 4]) + } - let store = Store(initialState: 0) { reducer } - _ = ViewStore(store, observe: { $0 }).send(.incr) - XCTAssertEqual(ViewStore(store, observe: { $0 }).state, 100_000) - } + func testLotsOfSynchronousActions() { + enum Action { case incr, noop } + let reducer = Reduce({ state, action in + switch action { + case .incr: + state += 1 + return .send(state >= 100_000 ? .noop : .incr) + case .noop: + return .none + } + }) - func testIfLetAfterScope() { - struct AppState: Equatable { - var count: Int? + let store = Store(initialState: 0) { reducer } + _ = ViewStore(store, observe: { $0 }).send(.incr) + XCTAssertEqual(ViewStore(store, observe: { $0 }).state, 100_000) } - let appReducer = Reduce({ state, action in - state.count = action - return .none - }) - - let parentStore = Store(initialState: AppState()) { appReducer } - let parentViewStore = ViewStore(parentStore, observe: { $0 }) + @available(*, deprecated) + func testIfLetAfterScope() { + struct AppState: Equatable { + var count: Int? + } - // NB: This test needs to hold a strong reference to the emitted stores - var outputs: [Int?] = [] - var stores: [Any] = [] + let appReducer = Reduce({ state, action in + state.count = action + return .none + }) - parentStore - .scope(state: { $0.count }, action: { $0 }) - .ifLet( - then: { store in - stores.append(store) - outputs.append(ViewStore(store, observe: { $0 }).state) - }, - else: { - outputs.append(nil) - } - ) - .store(in: &self.cancellables) + let parentStore = Store(initialState: AppState()) { appReducer } + let parentViewStore = ViewStore(parentStore, observe: { $0 }) + + // NB: This test needs to hold a strong reference to the emitted stores + var outputs: [Int?] = [] + var stores: [Any] = [] + + parentStore + .scope(state: { $0.count }, action: { $0 }) + .ifLet( + then: { store in + stores.append(store) + outputs.append(ViewStore(store, observe: { $0 }).state) + }, + else: { + outputs.append(nil) + } + ) + .store(in: &self.cancellables) - XCTAssertEqual(outputs, [nil]) + XCTAssertEqual(outputs, [nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil]) + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1, nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) - _ = parentViewStore.send(1) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) + _ = parentViewStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) - _ = parentViewStore.send(nil) - XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) - } + _ = parentViewStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) + } - func testIfLetTwo() { - let parentStore = Store(initialState: 0) { - Reduce { state, action in - if action { - state? += 1 - return .none - } else { - return .run { send in await send(true) } + func testIfLetTwo() { + let parentStore = Store(initialState: 0) { + Reduce { state, action in + if action { + state? += 1 + return .none + } else { + return .run { send in await send(true) } + } } } - } - parentStore - .ifLet(then: { childStore in - let vs = ViewStore(childStore, observe: { $0 }) - - vs - .publisher - .sink { _ in } - .store(in: &self.cancellables) - - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - XCTAssertEqual(vs.state, 3) - }) - .store(in: &self.cancellables) - } - - func testActionQueuing() async { - let subject = PassthroughSubject() - - enum Action: Equatable { - case incrementTapped - case `init` - case doIncrement - } + parentStore + .ifLet(then: { childStore in + let vs = ViewStore(childStore, observe: { $0 }) + + vs + .publisher + .sink { _ in } + .store(in: &self.cancellables) + + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + XCTAssertEqual(vs.state, 3) + }) + .store(in: &self.cancellables) + } + + func testActionQueuing() async { + let subject = PassthroughSubject() + + enum Action: Equatable { + case incrementTapped + case `init` + case doIncrement + } - let store = TestStore(initialState: 0) { - Reduce { state, action in - switch action { - case .incrementTapped: - subject.send() - return .none + let store = TestStore(initialState: 0) { + Reduce { state, action in + switch action { + case .incrementTapped: + subject.send() + return .none - case .`init`: - return .publisher { subject.map { .doIncrement } } + case .`init`: + return .publisher { subject.map { .doIncrement } } - case .doIncrement: - state += 1 - return .none + case .doIncrement: + state += 1 + return .none + } } } - } - await store.send(.`init`) - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 1 - } - await store.send(.incrementTapped) - await store.receive(.doIncrement) { - $0 = 2 - } - subject.send(completion: .finished) - } - - func testCoalesceSynchronousActions() { - let store = Store(initialState: 0) { - Reduce { state, action in - switch action { - case 0: - return .merge( - .send(1), - .send(2), - .send(3) - ) - default: - state = action - return .none + await store.send(.`init`) + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 1 + } + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 2 + } + subject.send(completion: .finished) + } + + func testCoalesceSynchronousActions() { + let store = Store(initialState: 0) { + Reduce { state, action in + switch action { + case 0: + return .merge( + .send(1), + .send(2), + .send(3) + ) + default: + state = action + return .none + } } } - } - - var emissions: [Int] = [] - let viewStore = ViewStore(store, observe: { $0 }) - viewStore.publisher - .sink { emissions.append($0) } - .store(in: &self.cancellables) - - XCTAssertEqual(emissions, [0]) - - viewStore.send(0) - XCTAssertEqual(emissions, [0, 3]) - } + var emissions: [Int] = [] + let viewStore = ViewStore(store, observe: { $0 }) + viewStore.publisher + .sink { emissions.append($0) } + .store(in: &self.cancellables) - func testBufferedActionProcessing() { - struct ChildState: Equatable { - var count: Int? - } + XCTAssertEqual(emissions, [0]) - struct ParentState: Equatable { - var count: Int? - var child: ChildState? - } + viewStore.send(0) - enum ParentAction: Equatable { - case button - case child(Int?) + XCTAssertEqual(emissions, [0, 3]) } - var handledActions: [ParentAction] = [] - let parentReducer = Reduce({ state, action in - handledActions.append(action) - - switch action { - case .button: - state.child = .init(count: nil) - return .none - - case let .child(childCount): - state.count = childCount - return .none + @available(*, deprecated) + func testBufferedActionProcessing() { + struct ChildState: Equatable { + var count: Int? } - }) - .ifLet(\.child, action: /ParentAction.child) { - Reduce({ state, action in - state.count = action - return .none - }) - } - - let parentStore = Store(initialState: ParentState()) { - parentReducer - } - parentStore - .scope( - state: \.child, - action: ParentAction.child - ) - .ifLet { childStore in - ViewStore(childStore, observe: { $0 }).send(2) + struct ParentState: Equatable { + var count: Int? + var child: ChildState? } - .store(in: &cancellables) - XCTAssertEqual(handledActions, []) + enum ParentAction: Equatable { + case button + case child(Int?) + } - _ = ViewStore(parentStore, observe: { $0 }).send(.button) - XCTAssertEqual( - handledActions, - [ - .button, - .child(2), - ]) - } + var handledActions: [ParentAction] = [] + let parentReducer = Reduce({ state, action in + handledActions.append(action) - func testCascadingTaskCancellation() async { - enum Action { case task, response, response1, response2 } - let store = TestStore(initialState: 0) { - Reduce { state, action in switch action { - case .task: - return .run { send in await send(.response) } - case .response: - return .merge( - .run { _ in try await Task.never() }, - .run { send in await send(.response1) } - ) - case .response1: - return .merge( - .run { _ in try await Task.never() }, - .run { send in await send(.response2) } - ) - case .response2: - return .run { _ in try await Task.never() } + case .button: + state.child = .init(count: nil) + return .none + + case let .child(childCount): + state.count = childCount + return .none } + }) + .ifLet(\.child, action: /ParentAction.child) { + Reduce({ state, action in + state.count = action + return .none + }) } - } - let task = await store.send(.task) - await store.receive(.response) - await store.receive(.response1) - await store.receive(.response2) - await task.cancel() - } - - func testTaskCancellationEmpty() async { - enum Action { case task } + let parentStore = Store(initialState: ParentState()) { + parentReducer + } - let store = TestStore(initialState: 0) { - Reduce { state, action in - switch action { - case .task: - return .run { _ in try await Task.never() } + parentStore + .scope( + state: \.child, + action: ParentAction.child + ) + .ifLet { childStore in + ViewStore(childStore, observe: { $0 }).send(2) + } + .store(in: &cancellables) + + XCTAssertEqual(handledActions, []) + + _ = ViewStore(parentStore, observe: { $0 }).send(.button) + XCTAssertEqual( + handledActions, + [ + .button, + .child(2), + ]) + } + + func testCascadingTaskCancellation() async { + enum Action { case task, response, response1, response2 } + let store = TestStore(initialState: 0) { + Reduce { state, action in + switch action { + case .task: + return .run { send in await send(.response) } + case .response: + return .merge( + .run { _ in try await Task.never() }, + .run { send in await send(.response1) } + ) + case .response1: + return .merge( + .run { _ in try await Task.never() }, + .run { send in await send(.response2) } + ) + case .response2: + return .run { _ in try await Task.never() } + } } } - } - await store.send(.task).cancel() - } + let task = await store.send(.task) + await store.receive(.response) + await store.receive(.response1) + await store.receive(.response2) + await task.cancel() + } - func testScopeCancellation() async throws { - let neverEndingTask = Task { try await Task.never() } + func testTaskCancellationEmpty() async { + enum Action { case task } - let store = Store(initialState: ()) { - Reduce { _, _ in - .run { _ in - try await neverEndingTask.value + let store = TestStore(initialState: 0) { + Reduce { state, action in + switch action { + case .task: + return .run { _ in try await Task.never() } + } } } + + await store.send(.task).cancel() } - let scopedStore = store.scope(state: { $0 }, action: { $0 }) - let sendTask = scopedStore.send((), originatingFrom: nil) - await Task.yield() - neverEndingTask.cancel() - try await XCTUnwrap(sendTask).value - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) - XCTAssertEqual(scopedStore.rootStore.effectCancellables.count, 0) - } + @available(*, deprecated) + func testScopeCancellation() async throws { + let neverEndingTask = Task { try await Task.never() } - @Reducer - fileprivate struct Feature_testOverrideDependenciesDirectlyOnReducer { - @Dependency(\.calendar) var calendar - @Dependency(\.locale) var locale - @Dependency(\.timeZone) var timeZone - @Dependency(\.urlSession) var urlSession - var body: some Reducer { - Reduce { state, action in - _ = self.calendar - _ = self.locale - _ = self.timeZone - _ = self.urlSession - state += action ? 1 : -1 - return .none + let store = Store(initialState: ()) { + Reduce { _, _ in + .run { _ in + try await neverEndingTask.value + } + } + } + let scopedStore = store.scope(state: { $0 }, action: { $0 }) + + let sendTask = scopedStore.send((), originatingFrom: nil) + await Task.yield() + neverEndingTask.cancel() + try await XCTUnwrap(sendTask).value + XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + XCTAssertEqual(scopedStore.rootStore.effectCancellables.count, 0) + } + + @Reducer + fileprivate struct Feature_testOverrideDependenciesDirectlyOnReducer { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + var body: some Reducer { + Reduce { state, action in + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } } } - } - func testOverrideDependenciesDirectlyOnReducer() { - let store = Store(initialState: 0) { - Feature_testOverrideDependenciesDirectlyOnReducer() - .dependency(\.calendar, Calendar(identifier: .gregorian)) - .dependency(\.locale, Locale(identifier: "en_US")) - .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) - .dependency(\.urlSession, URLSession(configuration: .ephemeral)) - } + func testOverrideDependenciesDirectlyOnReducer() { + let store = Store(initialState: 0) { + Feature_testOverrideDependenciesDirectlyOnReducer() + .dependency(\.calendar, Calendar(identifier: .gregorian)) + .dependency(\.locale, Locale(identifier: "en_US")) + .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) + .dependency(\.urlSession, URLSession(configuration: .ephemeral)) + } - ViewStore(store, observe: { $0 }).send(true) - } + ViewStore(store, observe: { $0 }).send(true) + } - @Reducer - fileprivate struct Feature_testOverrideDependenciesDirectlyOnStore { - @Dependency(\.uuid) var uuid - var body: some Reducer { - Reduce { state, action in - state = self.uuid() - return .none + @Reducer + fileprivate struct Feature_testOverrideDependenciesDirectlyOnStore { + @Dependency(\.uuid) var uuid + var body: some Reducer { + Reduce { state, action in + state = self.uuid() + return .none + } } } - } - func testOverrideDependenciesDirectlyOnStore() { - @Dependency(\.uuid) var uuid - let store = Store(initialState: uuid()) { - Feature_testOverrideDependenciesDirectlyOnStore() - } withDependencies: { - $0.uuid = .constant(UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) - } - let viewStore = ViewStore(store, observe: { $0 }) - - XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) - } + func testOverrideDependenciesDirectlyOnStore() { + @Dependency(\.uuid) var uuid + let store = Store(initialState: uuid()) { + Feature_testOverrideDependenciesDirectlyOnStore() + } withDependencies: { + $0.uuid = .constant(UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) + } + let viewStore = ViewStore(store, observe: { $0 }) - @Reducer - fileprivate struct Feature_testStoreVsTestStore { - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case tap - case response1(Int) - case response2(Int) - case response3(Int) + XCTAssertEqual(viewStore.state, UUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!) } - @Dependency(\.count) var count - var body: some Reducer { - Reduce { state, action in - switch action { - case .tap: - return withDependencies { - $0.count.value += 1 - } operation: { - .run { send in await send(.response1(self.count.value)) } - } - case let .response1(count): - state.count = count - return withDependencies { - $0.count.value += 1 - } operation: { - .run { send in await send(.response2(self.count.value)) } - } - case let .response2(count): - state.count = count - return withDependencies { - $0.count.value += 1 - } operation: { - .run { send in await send(.response3(self.count.value)) } + + @Reducer + fileprivate struct Feature_testStoreVsTestStore { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case tap + case response1(Int) + case response2(Int) + case response3(Int) + } + @Dependency(\.count) var count + var body: some Reducer { + Reduce { state, action in + switch action { + case .tap: + return withDependencies { + $0.count.value += 1 + } operation: { + .run { send in await send(.response1(self.count.value)) } + } + case let .response1(count): + state.count = count + return withDependencies { + $0.count.value += 1 + } operation: { + .run { send in await send(.response2(self.count.value)) } + } + case let .response2(count): + state.count = count + return withDependencies { + $0.count.value += 1 + } operation: { + .run { send in await send(.response3(self.count.value)) } + } + case let .response3(count): + state.count = count + return .none } - case let .response3(count): - state.count = count - return .none } } } - } - func testStoreVsTestStore() async { - let testStore = TestStore(initialState: Feature_testStoreVsTestStore.State()) { - Feature_testStoreVsTestStore() - } - await testStore.send(.tap) - await testStore.receive(.response1(1)) { - $0.count = 1 - } - await testStore.receive(.response2(1)) - await testStore.receive(.response3(1)) + func testStoreVsTestStore() async { + let testStore = TestStore(initialState: Feature_testStoreVsTestStore.State()) { + Feature_testStoreVsTestStore() + } + await testStore.send(.tap) + await testStore.receive(.response1(1)) { + $0.count = 1 + } + await testStore.receive(.response2(1)) + await testStore.receive(.response3(1)) - let store = Store(initialState: Feature_testStoreVsTestStore.State()) { - Feature_testStoreVsTestStore() + let store = Store(initialState: Feature_testStoreVsTestStore.State()) { + Feature_testStoreVsTestStore() + } + await store.send(.tap, originatingFrom: nil)?.value + XCTAssertEqual(store.withState(\.count), testStore.state.count) } - await store.send(.tap, originatingFrom: nil)?.value - XCTAssertEqual(store.withState(\.count), testStore.state.count) - } - @Reducer - fileprivate struct Feature_testStoreVsTestStore_Publisher { - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case tap - case response1(Int) - case response2(Int) - case response3(Int) - } - @Dependency(\.count) var count - var body: some Reducer { - Reduce { state, action in - switch action { - case .tap: - return withDependencies { - $0.count.value += 1 - } operation: { - .run { send in await send(.response1(self.count.value)) } - } - case let .response1(count): - state.count = count - return withDependencies { - $0.count.value += 1 - } operation: { - .run { send in await send(.response2(self.count.value)) } - } - case let .response2(count): - state.count = count - return withDependencies { - $0.count.value += 1 - } operation: { - .run { send in await send(.response3(self.count.value)) } + @Reducer + fileprivate struct Feature_testStoreVsTestStore_Publisher { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case tap + case response1(Int) + case response2(Int) + case response3(Int) + } + @Dependency(\.count) var count + var body: some Reducer { + Reduce { state, action in + switch action { + case .tap: + return withDependencies { + $0.count.value += 1 + } operation: { + .run { send in await send(.response1(self.count.value)) } + } + case let .response1(count): + state.count = count + return withDependencies { + $0.count.value += 1 + } operation: { + .run { send in await send(.response2(self.count.value)) } + } + case let .response2(count): + state.count = count + return withDependencies { + $0.count.value += 1 + } operation: { + .run { send in await send(.response3(self.count.value)) } + } + case let .response3(count): + state.count = count + return .none } - case let .response3(count): - state.count = count - return .none } } } - } - func testStoreVsTestStore_Publisher() async { - let testStore = TestStore(initialState: Feature_testStoreVsTestStore_Publisher.State()) { - Feature_testStoreVsTestStore_Publisher() - } - await testStore.send(.tap) - await testStore.receive(.response1(1)) { - $0.count = 1 - } - await testStore.receive(.response2(1)) - await testStore.receive(.response3(1)) + func testStoreVsTestStore_Publisher() async { + let testStore = TestStore(initialState: Feature_testStoreVsTestStore_Publisher.State()) { + Feature_testStoreVsTestStore_Publisher() + } + await testStore.send(.tap) + await testStore.receive(.response1(1)) { + $0.count = 1 + } + await testStore.receive(.response2(1)) + await testStore.receive(.response3(1)) - let store = Store(initialState: Feature_testStoreVsTestStore_Publisher.State()) { - Feature_testStoreVsTestStore_Publisher() + let store = Store(initialState: Feature_testStoreVsTestStore_Publisher.State()) { + Feature_testStoreVsTestStore_Publisher() + } + await store.send(.tap, originatingFrom: nil)?.value + XCTAssertEqual(store.withState(\.count), testStore.state.count) } - await store.send(.tap, originatingFrom: nil)?.value - XCTAssertEqual(store.withState(\.count), testStore.state.count) - } - @Reducer - struct Child_testChildParentEffectCancellation { - struct State: Equatable {} - enum Action: Equatable { - case task - case didFinish - } + @Reducer + struct Child_testChildParentEffectCancellation { + struct State: Equatable {} + enum Action: Equatable { + case task + case didFinish + } - var body: some Reducer { - Reduce { state, action in - switch action { - case .task: - return .run { send in await send(.didFinish) } - case .didFinish: - return .none + var body: some Reducer { + Reduce { state, action in + switch action { + case .task: + return .run { send in await send(.didFinish) } + case .didFinish: + return .none + } } } } - } - @Reducer - struct Parent_testChildParentEffectCancellation { - struct State: Equatable { - var count = 0 - var child: Child_testChildParentEffectCancellation.State? - } - enum Action: Equatable { - case child(Child_testChildParentEffectCancellation.Action) - case delay - } - @Dependency(\.mainQueue) var mainQueue - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .child(.didFinish): - state.child = nil - return .run { send in - try await self.mainQueue.sleep(for: .seconds(1)) - await send(.delay) + @Reducer + struct Parent_testChildParentEffectCancellation { + struct State: Equatable { + var count = 0 + var child: Child_testChildParentEffectCancellation.State? + } + enum Action: Equatable { + case child(Child_testChildParentEffectCancellation.Action) + case delay + } + @Dependency(\.mainQueue) var mainQueue + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .child(.didFinish): + state.child = nil + return .run { send in + try await self.mainQueue.sleep(for: .seconds(1)) + await send(.delay) + } + case .child: + return .none + case .delay: + state.count += 1 + return .none } - case .child: - return .none - case .delay: - state.count += 1 - return .none } - } - .ifLet(\.child, action: \.child) { - Child_testChildParentEffectCancellation() + .ifLet(\.child, action: \.child) { + Child_testChildParentEffectCancellation() + } } } - } - func testChildParentEffectCancellation() async throws { - let mainQueue = DispatchQueue.test - let store = Store( - initialState: Parent_testChildParentEffectCancellation.State( - child: .init() + func testChildParentEffectCancellation() async throws { + let mainQueue = DispatchQueue.test + let store = Store( + initialState: Parent_testChildParentEffectCancellation.State( + child: .init() + ) + ) { + Parent_testChildParentEffectCancellation() + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } + let viewStore = ViewStore(store, observe: { $0 }) + + let childTask = viewStore.send(.child(.task)) + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(viewStore.child, nil) + + childTask.cancel() + await mainQueue.advance(by: 1) + try await Task.sleep(nanoseconds: 100_000_000) + XCTTODO( + """ + This fails because cancelling a child task will cancel all parent effects too. + """ ) - ) { - Parent_testChildParentEffectCancellation() - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } - let viewStore = ViewStore(store, observe: { $0 }) - - let childTask = viewStore.send(.child(.task)) - try await Task.sleep(nanoseconds: 100_000_000) - XCTAssertEqual(viewStore.child, nil) - - childTask.cancel() - await mainQueue.advance(by: 1) - try await Task.sleep(nanoseconds: 100_000_000) - XCTTODO( - """ - This fails because cancelling a child task will cancel all parent effects too. - """ - ) - XCTAssertEqual(viewStore.count, 1) - } + XCTAssertEqual(viewStore.count, 1) + } - func testInit_InitialState_WithDependencies() async { - struct Feature: Reducer { - struct State: Equatable { - var date: Date - init() { - @Dependency(\.date) var date - self.date = date() + func testInit_InitialState_WithDependencies() async { + struct Feature: Reducer { + struct State: Equatable { + var date: Date + init() { + @Dependency(\.date) var date + self.date = date() + } + } + enum Action: Equatable {} + var body: some Reducer { + EmptyReducer() } } - enum Action: Equatable {} - var body: some Reducer { - EmptyReducer() + + let store = Store(initialState: Feature.State()) { + Feature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1_234_567_890)) } - } - let store = Store(initialState: Feature.State()) { - Feature() - } withDependencies: { - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1_234_567_890)) + XCTAssertEqual(store.withState(\.date), Date(timeIntervalSinceReferenceDate: 1_234_567_890)) } - XCTAssertEqual(store.withState(\.date), Date(timeIntervalSinceReferenceDate: 1_234_567_890)) - } - - func testInit_ReducerBuilder_WithDependencies() async { - struct Feature: Reducer { - let date: Date - struct State: Equatable { var date: Date? } - enum Action: Equatable { case tap } - var body: some Reducer { - Reduce { state, _ in - state.date = self.date - return .none + func testInit_ReducerBuilder_WithDependencies() async { + struct Feature: Reducer { + let date: Date + struct State: Equatable { var date: Date? } + enum Action: Equatable { case tap } + var body: some Reducer { + Reduce { state, _ in + state.date = self.date + return .none + } } } - } - @Dependency(\.date) var date - let store = Store(initialState: Feature.State()) { - Feature(date: date()) - } withDependencies: { - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1_234_567_890)) - } - - store.send(.tap) - XCTAssertEqual(store.withState(\.date), Date(timeIntervalSinceReferenceDate: 1_234_567_890)) - } + @Dependency(\.date) var date + let store = Store(initialState: Feature.State()) { + Feature(date: date()) + } withDependencies: { + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1_234_567_890)) + } - @Reducer - struct Feature_testPresentationScope { - struct State: Equatable { - var count = 0 - @PresentationState var child: State? + store.send(.tap) + XCTAssertEqual(store.withState(\.date), Date(timeIntervalSinceReferenceDate: 1_234_567_890)) } - enum Action { - case child(PresentationAction) - case tap - } - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .child: - return .none - case .tap: - state.count += 1 - return .none + + @Reducer + struct Feature_testPresentationScope { + struct State: Equatable { + var count = 0 + @PresentationState var child: State? + } + enum Action { + case child(PresentationAction) + case tap + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .child: + return .none + case .tap: + state.count += 1 + return .none + } + } + .ifLet(\.$child, action: \.child) { + Feature_testPresentationScope() } } - .ifLet(\.$child, action: \.child) { + } + + @available(*, deprecated) + func testPresentationScope() async { + let store = Store( + initialState: Feature_testPresentationScope.State( + child: .init(child: .init())) + ) { Feature_testPresentationScope() } - } - } - func testPresentationScope() async { - let store = Store( - initialState: Feature_testPresentationScope.State( - child: .init(child: .init())) - ) { - Feature_testPresentationScope() - } - var removeDuplicatesCount1 = 0 - var stateScopeCount1 = 0 - var viewStoreCount1 = 0 - var removeDuplicatesCount2 = 0 - var storeStateCount1 = 0 - var stateScopeCount2 = 0 - var viewStoreCount2 = 0 - var storeStateCount2 = 0 - let childStore1 = store.scope( - state: { - stateScopeCount1 += 1 - return $0.$child - }, - action: { .child($0) } - ) - let childViewStore1 = ViewStore( - childStore1, - observe: { $0 }, - removeDuplicates: { lhs, rhs in - removeDuplicatesCount1 += 1 - return lhs == rhs - } - ) - childViewStore1.objectWillChange - .sink { _ in viewStoreCount1 += 1 } - .store(in: &self.cancellables) - childStore1.publisher - .sink { _ in storeStateCount1 += 1 } - .store(in: &self.cancellables) - let childStore2 = store.scope( - state: { - stateScopeCount2 += 1 - return $0.$child - }, - action: { .child($0) } - ) - let childViewStore2 = ViewStore( - childStore2, - observe: { $0 }, - removeDuplicates: { lhs, rhs in - removeDuplicatesCount2 += 1 - return lhs == rhs - } - ) - childViewStore2.objectWillChange - .sink { _ in viewStoreCount2 += 1 } - .store(in: &self.cancellables) - childStore2.publisher - .sink { _ in storeStateCount2 += 1 } - .store(in: &self.cancellables) - - store.send(.tap) - XCTAssertEqual(removeDuplicatesCount1, 1) - XCTAssertEqual(stateScopeCount1, 5) - XCTAssertEqual(viewStoreCount1, 0) - XCTAssertEqual(storeStateCount1, 2) - XCTAssertEqual(removeDuplicatesCount2, 1) - XCTAssertEqual(stateScopeCount2, 5) - XCTAssertEqual(viewStoreCount2, 0) - XCTAssertEqual(storeStateCount2, 2) - store.send(.tap) - XCTAssertEqual(removeDuplicatesCount1, 2) - XCTAssertEqual(stateScopeCount1, 7) - XCTAssertEqual(viewStoreCount1, 0) - XCTAssertEqual(storeStateCount1, 3) - XCTAssertEqual(removeDuplicatesCount2, 2) - XCTAssertEqual(stateScopeCount2, 7) - XCTAssertEqual(viewStoreCount2, 0) - XCTAssertEqual(storeStateCount2, 3) - - store.send(.child(.dismiss)) - _ = (childViewStore1, childViewStore2, childStore1, childStore2) - } -} - -private struct Count: TestDependencyKey { - var value: Int - static let liveValue = Count(value: 0) - static let testValue = Count(value: 0) -} -extension DependencyValues { - fileprivate var count: Count { - get { self[Count.self] } - set { self[Count.self] = newValue } - } -} + var removeDuplicatesCount1 = 0 + var stateScopeCount1 = 0 + var viewStoreCount1 = 0 + var removeDuplicatesCount2 = 0 + var storeStateCount1 = 0 + var stateScopeCount2 = 0 + var viewStoreCount2 = 0 + var storeStateCount2 = 0 + let childStore1 = store.scope( + state: { + stateScopeCount1 += 1 + return $0.$child + }, + action: { .child($0) } + ) + let childViewStore1 = ViewStore( + childStore1, + observe: { $0 }, + removeDuplicates: { lhs, rhs in + removeDuplicatesCount1 += 1 + return lhs == rhs + } + ) + childViewStore1.objectWillChange + .sink { _ in viewStoreCount1 += 1 } + .store(in: &self.cancellables) + childStore1.publisher + .sink { _ in storeStateCount1 += 1 } + .store(in: &self.cancellables) + let childStore2 = store.scope( + state: { + stateScopeCount2 += 1 + return $0.$child + }, + action: { .child($0) } + ) + let childViewStore2 = ViewStore( + childStore2, + observe: { $0 }, + removeDuplicates: { lhs, rhs in + removeDuplicatesCount2 += 1 + return lhs == rhs + } + ) + childViewStore2.objectWillChange + .sink { _ in viewStoreCount2 += 1 } + .store(in: &self.cancellables) + childStore2.publisher + .sink { _ in storeStateCount2 += 1 } + .store(in: &self.cancellables) + + store.send(.tap) + XCTAssertEqual(removeDuplicatesCount1, 1) + XCTAssertEqual(stateScopeCount1, 5) + XCTAssertEqual(viewStoreCount1, 0) + XCTAssertEqual(storeStateCount1, 2) + XCTAssertEqual(removeDuplicatesCount2, 1) + XCTAssertEqual(stateScopeCount2, 5) + XCTAssertEqual(viewStoreCount2, 0) + XCTAssertEqual(storeStateCount2, 2) + store.send(.tap) + XCTAssertEqual(removeDuplicatesCount1, 2) + XCTAssertEqual(stateScopeCount1, 7) + XCTAssertEqual(viewStoreCount1, 0) + XCTAssertEqual(storeStateCount1, 3) + XCTAssertEqual(removeDuplicatesCount2, 2) + XCTAssertEqual(stateScopeCount2, 7) + XCTAssertEqual(viewStoreCount2, 0) + XCTAssertEqual(storeStateCount2, 3) + + store.send(.child(.dismiss)) + _ = (childViewStore1, childViewStore2, childStore1, childStore2) + } + } + + private struct Count: TestDependencyKey { + var value: Int + static let liveValue = Count(value: 0) + static let testValue = Count(value: 0) + } + extension DependencyValues { + fileprivate var count: Count { + get { self[Count.self] } + set { self[Count.self] = newValue } + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index e8c77f70b0fd..c9da27736e46 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if swift(>=5.9) && DEBUG import ComposableArchitecture import XCTest diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index 5103ce8520ae..3943f65a13eb 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -1,587 +1,589 @@ -import Combine -import ComposableArchitecture -import XCTest +#if swift(>=5.9) + import Combine + import ComposableArchitecture + import XCTest -@MainActor -final class TestStoreTests: BaseTCATestCase { - func testEffectConcatenation() async { - struct State: Equatable {} + @MainActor + final class TestStoreTests: BaseTCATestCase { + func testEffectConcatenation() async { + struct State: Equatable {} - enum Action: Equatable { - case a, b1, b2, b3, c1, c2, c3, d - } + enum Action: Equatable { + case a, b1, b2, b3, c1, c2, c3, d + } - let mainQueue = DispatchQueue.test - let store = TestStore(initialState: State()) { - Reduce { _, action in - switch action { - case .a: - return .merge( - .run { send in - try await mainQueue.sleep(for: .seconds(1)) - await send(.b1) - await send(.c1) - }, - .run { _ in try await Task.never() } - .cancellable(id: 1) - ) - case .b1: - return .concatenate(.send(.b2), .send(.b3)) - case .c1: - return .concatenate(.send(.c2), .send(.c3)) - case .b2, .b3, .c2, .c3: - return .none - case .d: - return .cancel(id: 1) + let mainQueue = DispatchQueue.test + let store = TestStore(initialState: State()) { + Reduce { _, action in + switch action { + case .a: + return .merge( + .run { send in + try await mainQueue.sleep(for: .seconds(1)) + await send(.b1) + await send(.c1) + }, + .run { _ in try await Task.never() } + .cancellable(id: 1) + ) + case .b1: + return .concatenate(.send(.b2), .send(.b3)) + case .c1: + return .concatenate(.send(.c2), .send(.c3)) + case .b2, .b3, .c2, .c3: + return .none + case .d: + return .cancel(id: 1) + } } } - } - await store.send(.a) + await store.send(.a) - await mainQueue.advance(by: 1) + await mainQueue.advance(by: 1) - await store.receive(.b1) - await store.receive(.b2) - await store.receive(.b3) + await store.receive(.b1) + await store.receive(.b2) + await store.receive(.b3) - await store.receive(.c1) - await store.receive(.c2) - await store.receive(.c3) + await store.receive(.c1) + await store.receive(.c2) + await store.receive(.c3) - await store.send(.d) - } - - func testAsync() async { - enum Action: Equatable { - case tap - case response(Int) + await store.send(.d) } - let store = TestStore(initialState: 0) { - Reduce { state, action in - switch action { - case .tap: - return .run { send in await send(.response(42)) } - case let .response(number): - state = number - return .none + + func testAsync() async { + enum Action: Equatable { + case tap + case response(Int) + } + let store = TestStore(initialState: 0) { + Reduce { state, action in + switch action { + case .tap: + return .run { send in await send(.response(42)) } + case let .response(number): + state = number + return .none + } } } - } - await store.send(.tap) - await store.receive(.response(42)) { - $0 = 42 + await store.send(.tap) + await store.receive(.response(42)) { + $0 = 42 + } } - } - #if DEBUG - func testExpectedStateEquality() async { - struct State: Equatable { - var count: Int = 0 - var isChanging: Bool = false - } + #if DEBUG + func testExpectedStateEquality() async { + struct State: Equatable { + var count: Int = 0 + var isChanging: Bool = false + } - enum Action: Equatable { - case increment - case changed(from: Int, to: Int) - } + enum Action: Equatable { + case increment + case changed(from: Int, to: Int) + } - let store = TestStore(initialState: State()) { - Reduce { state, action in - switch action { - case .increment: - state.isChanging = true - return .send(.changed(from: state.count, to: state.count + 1)) - case let .changed(from, to): - state.isChanging = false - if state.count == from { - state.count = to + let store = TestStore(initialState: State()) { + Reduce { state, action in + switch action { + case .increment: + state.isChanging = true + return .send(.changed(from: state.count, to: state.count + 1)) + case let .changed(from, to): + state.isChanging = false + if state.count == from { + state.count = to + } + return .none } - return .none } } - } - await store.send(.increment) { - $0.isChanging = true - } - await store.receive(.changed(from: 0, to: 1)) { - $0.isChanging = false - $0.count = 1 - } + await store.send(.increment) { + $0.isChanging = true + } + await store.receive(.changed(from: 0, to: 1)) { + $0.isChanging = false + $0.count = 1 + } - XCTExpectFailure() - await store.send(.increment) { - $0.isChanging = false - } + XCTExpectFailure() + await store.send(.increment) { + $0.isChanging = false + } - XCTExpectFailure() - await store.receive(.changed(from: 1, to: 2)) { - $0.isChanging = true - $0.count = 1100 + XCTExpectFailure() + await store.receive(.changed(from: 1, to: 2)) { + $0.isChanging = true + $0.count = 1100 + } } - } - func testExpectedStateEqualityMustModify() async { - struct State: Equatable { - var count: Int = 0 - } + func testExpectedStateEqualityMustModify() async { + struct State: Equatable { + var count: Int = 0 + } - enum Action: Equatable { - case noop, finished - } + enum Action: Equatable { + case noop, finished + } - let store = TestStore(initialState: State()) { - Reduce { state, action in - switch action { - case .noop: - return .send(.finished) - case .finished: - return .none + let store = TestStore(initialState: State()) { + Reduce { state, action in + switch action { + case .noop: + return .send(.finished) + case .finished: + return .none + } } } - } - await store.send(.noop) - await store.receive(.finished) + await store.send(.noop) + await store.receive(.finished) - XCTExpectFailure() - await store.send(.noop) { - $0.count = 0 - } + XCTExpectFailure() + await store.send(.noop) { + $0.count = 0 + } - XCTExpectFailure() - await store.receive(.finished) { - $0.count = 0 + XCTExpectFailure() + await store.receive(.finished) { + $0.count = 0 + } } - } - func testReceiveActionMatchingPredicate() async { - enum Action: Equatable { - case noop, finished + func testReceiveActionMatchingPredicate() async { + enum Action: Equatable { + case noop, finished + } + + let store = TestStore(initialState: 0) { + Reduce { state, action in + switch action { + case .noop: + return .send(.finished) + case .finished: + return .none + } + } + } + + let predicateShouldBeCalledExpectation = expectation( + description: "predicate should be called") + await store.send(.noop) + await store.receive { action in + predicateShouldBeCalledExpectation.fulfill() + return action == .finished + } + _ = { wait(for: [predicateShouldBeCalledExpectation], timeout: 0) }() + + await store.send(.noop) + XCTExpectFailure() + await store.receive(.noop) + + await store.send(.noop) + XCTExpectFailure() + await store.receive { $0 == .noop } } + #endif + func testStateAccess() async { + enum Action { case a, b, c, d } let store = TestStore(initialState: 0) { - Reduce { state, action in + Reduce { count, action in switch action { - case .noop: - return .send(.finished) - case .finished: + case .a: + count += 1 + return .merge(.send(.b), .send(.c), .send(.d)) + case .b, .c, .d: + count += 1 return .none } } } - let predicateShouldBeCalledExpectation = expectation( - description: "predicate should be called") - await store.send(.noop) - await store.receive { action in - predicateShouldBeCalledExpectation.fulfill() - return action == .finished + await store.send(.a) { + $0 = 1 + XCTAssertEqual(store.state, 0) } - _ = { wait(for: [predicateShouldBeCalledExpectation], timeout: 0) }() - - await store.send(.noop) - XCTExpectFailure() - await store.receive(.noop) - - await store.send(.noop) - XCTExpectFailure() - await store.receive { $0 == .noop } - } - #endif - - func testStateAccess() async { - enum Action { case a, b, c, d } - let store = TestStore(initialState: 0) { - Reduce { count, action in - switch action { - case .a: - count += 1 - return .merge(.send(.b), .send(.c), .send(.d)) - case .b, .c, .d: - count += 1 - return .none - } - } - } - - await store.send(.a) { - $0 = 1 - XCTAssertEqual(store.state, 0) - } - XCTAssertEqual(store.state, 1) - await store.receive(.b) { - $0 = 2 XCTAssertEqual(store.state, 1) - } - XCTAssertEqual(store.state, 2) - await store.receive(.c) { - $0 = 3 + await store.receive(.b) { + $0 = 2 + XCTAssertEqual(store.state, 1) + } XCTAssertEqual(store.state, 2) - } - XCTAssertEqual(store.state, 3) - await store.receive(.d) { - $0 = 4 + await store.receive(.c) { + $0 = 3 + XCTAssertEqual(store.state, 2) + } XCTAssertEqual(store.state, 3) + await store.receive(.d) { + $0 = 4 + XCTAssertEqual(store.state, 3) + } + XCTAssertEqual(store.state, 4) } - XCTAssertEqual(store.state, 4) - } - @Reducer - struct Feature_testOverrideDependenciesDirectlyOnReducer { - @Dependency(\.calendar) var calendar - @Dependency(\.locale) var locale - @Dependency(\.timeZone) var timeZone - @Dependency(\.urlSession) var urlSession - - var body: some Reducer { - Reduce { state, action in - _ = self.calendar - _ = self.locale - _ = self.timeZone - _ = self.urlSession - state += action ? 1 : -1 - return .none + @Reducer + struct Feature_testOverrideDependenciesDirectlyOnReducer { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + + var body: some Reducer { + Reduce { state, action in + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } } } - } - func testOverrideDependenciesDirectlyOnReducer() async { - let store = TestStore(initialState: 0) { - Feature_testOverrideDependenciesDirectlyOnReducer() - .dependency(\.calendar, Calendar(identifier: .gregorian)) - .dependency(\.locale, Locale(identifier: "en_US")) - .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) - .dependency(\.urlSession, URLSession(configuration: .ephemeral)) + func testOverrideDependenciesDirectlyOnReducer() async { + let store = TestStore(initialState: 0) { + Feature_testOverrideDependenciesDirectlyOnReducer() + .dependency(\.calendar, Calendar(identifier: .gregorian)) + .dependency(\.locale, Locale(identifier: "en_US")) + .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) + .dependency(\.urlSession, URLSession(configuration: .ephemeral)) + } + + await store.send(true) { $0 = 1 } } - await store.send(true) { $0 = 1 } - } + @Reducer + struct Feature_testOverrideDependenciesOnTestStore { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession - @Reducer - struct Feature_testOverrideDependenciesOnTestStore { - @Dependency(\.calendar) var calendar - @Dependency(\.locale) var locale - @Dependency(\.timeZone) var timeZone - @Dependency(\.urlSession) var urlSession - - var body: some Reducer { - Reduce { state, action in - _ = self.calendar - _ = self.locale - _ = self.timeZone - _ = self.urlSession - state += action ? 1 : -1 - return .none + var body: some Reducer { + Reduce { state, action in + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } } } - } - func testOverrideDependenciesOnTestStore() async { - let store = TestStore(initialState: 0) { - Feature_testOverrideDependenciesOnTestStore() - } - store.dependencies.calendar = Calendar(identifier: .gregorian) - store.dependencies.locale = Locale(identifier: "en_US") - store.dependencies.timeZone = TimeZone(secondsFromGMT: 0)! - store.dependencies.urlSession = URLSession(configuration: .ephemeral) + func testOverrideDependenciesOnTestStore() async { + let store = TestStore(initialState: 0) { + Feature_testOverrideDependenciesOnTestStore() + } + store.dependencies.calendar = Calendar(identifier: .gregorian) + store.dependencies.locale = Locale(identifier: "en_US") + store.dependencies.timeZone = TimeZone(secondsFromGMT: 0)! + store.dependencies.urlSession = URLSession(configuration: .ephemeral) - await store.send(true) { $0 = 1 } - } + await store.send(true) { $0 = 1 } + } - @Reducer - struct Feature_testOverrideDependenciesOnTestStore_MidwayChange { - @Dependency(\.date.now) var now + @Reducer + struct Feature_testOverrideDependenciesOnTestStore_MidwayChange { + @Dependency(\.date.now) var now - var body: some Reducer { - Reduce { state, _ in - state = Int(self.now.timeIntervalSince1970) - return .none + var body: some Reducer { + Reduce { state, _ in + state = Int(self.now.timeIntervalSince1970) + return .none + } } } - } - func testOverrideDependenciesOnTestStore_MidwayChange() async { - let store = TestStore(initialState: 0) { - Feature_testOverrideDependenciesOnTestStore_MidwayChange() - } withDependencies: { - $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - } - - await store.send(()) { $0 = 1_234_567_890 } + func testOverrideDependenciesOnTestStore_MidwayChange() async { + let store = TestStore(initialState: 0) { + Feature_testOverrideDependenciesOnTestStore_MidwayChange() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) + } - store.dependencies.date.now = Date(timeIntervalSince1970: 987_654_321) + await store.send(()) { $0 = 1_234_567_890 } - await store.send(()) { $0 = 987_654_321 } - } + store.dependencies.date.now = Date(timeIntervalSince1970: 987_654_321) - @Reducer - struct Feature_testOverrideDependenciesOnTestStore_Init { - @Dependency(\.calendar) var calendar - @Dependency(\.client.fetch) var fetch - @Dependency(\.locale) var locale - @Dependency(\.timeZone) var timeZone - @Dependency(\.urlSession) var urlSession - - var body: some Reducer { - Reduce { state, action in - _ = self.calendar - _ = self.fetch() - _ = self.locale - _ = self.timeZone - _ = self.urlSession - state += action ? 1 : -1 - return .none - } - } - } - func testOverrideDependenciesOnTestStore_Init() async { - let store = TestStore(initialState: 0) { - Feature_testOverrideDependenciesOnTestStore_Init() - } withDependencies: { - $0.calendar = Calendar(identifier: .gregorian) - $0.client.fetch = { 1 } - $0.locale = Locale(identifier: "en_US") - $0.timeZone = TimeZone(secondsFromGMT: 0)! - $0.urlSession = URLSession(configuration: .ephemeral) + await store.send(()) { $0 = 987_654_321 } } - await store.send(true) { $0 = 1 } - } + @Reducer + struct Feature_testOverrideDependenciesOnTestStore_Init { + @Dependency(\.calendar) var calendar + @Dependency(\.client.fetch) var fetch + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession - @Reducer - struct Feature_testDependenciesEarlyBinding { - struct State: Equatable { - var count = 0 - var date: Date - init() { - @Dependency(\.date.now) var now: Date - self.date = now - } - } - enum Action: Equatable { - case tap - case response(Int) - } - @Dependency(\.date.now) var now: Date - var body: some Reducer { - Reduce { state, action in - switch action { - case .tap: - state.count += 1 - return .run { send in await send(.response(42)) } - case let .response(number): - state.count = number - state.date = now + var body: some Reducer { + Reduce { state, action in + _ = self.calendar + _ = self.fetch() + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 return .none } } } - } - func testDependenciesEarlyBinding() async { - let store = TestStore(initialState: Feature_testDependenciesEarlyBinding.State()) { - Feature_testDependenciesEarlyBinding() - } withDependencies: { - $0.date = .constant(Date(timeIntervalSince1970: 1_234_567_890)) - } - - await store.send(.tap) { - @Dependency(\.date.now) var now: Date - $0.count = 1 - $0.date = now - } - await store.receive(.response(42)) { - @Dependency(\.date.now) var now: Date - $0.count = 42 - $0.date = now - } - } + func testOverrideDependenciesOnTestStore_Init() async { + let store = TestStore(initialState: 0) { + Feature_testOverrideDependenciesOnTestStore_Init() + } withDependencies: { + $0.calendar = Calendar(identifier: .gregorian) + $0.client.fetch = { 1 } + $0.locale = Locale(identifier: "en_US") + $0.timeZone = TimeZone(secondsFromGMT: 0)! + $0.urlSession = URLSession(configuration: .ephemeral) + } - func testPrepareDependenciesCalledOnce() { - var count = 0 - let store = TestStore(initialState: 0) { - EmptyReducer() - } withDependencies: { _ in - count += 1 + await store.send(true) { $0 = 1 } } - XCTAssertEqual(count, 1) - _ = store - } - - func testEffectEmitAfterSkipInFlightEffects() async { - let mainQueue = DispatchQueue.test - enum Action: Equatable { case tap, response } - let store = TestStore(initialState: 0) { - Reduce { state, action in - switch action { - case .tap: - return .run { send in - try await mainQueue.sleep(for: .seconds(1)) - await send(.response) + @Reducer + struct Feature_testDependenciesEarlyBinding { + struct State: Equatable { + var count = 0 + var date: Date + init() { + @Dependency(\.date.now) var now: Date + self.date = now + } + } + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date.now) var now: Date + var body: some Reducer { + Reduce { state, action in + switch action { + case .tap: + state.count += 1 + return .run { send in await send(.response(42)) } + case let .response(number): + state.count = number + state.date = now + return .none } - case .response: - state = 42 - return .none } } } + func testDependenciesEarlyBinding() async { + let store = TestStore(initialState: Feature_testDependenciesEarlyBinding.State()) { + Feature_testDependenciesEarlyBinding() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 1_234_567_890)) + } - await store.send(.tap) - await store.skipInFlightEffects() - await mainQueue.advance(by: .seconds(1)) - await store.receive(.response) { - $0 = 42 + await store.send(.tap) { + @Dependency(\.date.now) var now: Date + $0.count = 1 + $0.date = now + } + await store.receive(.response(42)) { + @Dependency(\.date.now) var now: Date + $0.count = 42 + $0.date = now + } } - } - func testAssert_NonExhaustiveTestStore() async { - let store = TestStore(initialState: 0) { - EmptyReducer() + func testPrepareDependenciesCalledOnce() { + var count = 0 + let store = TestStore(initialState: 0) { + EmptyReducer() + } withDependencies: { _ in + count += 1 + } + + XCTAssertEqual(count, 1) + _ = store } - store.exhaustivity = .off - store.assert { - $0 = 0 + func testEffectEmitAfterSkipInFlightEffects() async { + let mainQueue = DispatchQueue.test + enum Action: Equatable { case tap, response } + let store = TestStore(initialState: 0) { + Reduce { state, action in + switch action { + case .tap: + return .run { send in + try await mainQueue.sleep(for: .seconds(1)) + await send(.response) + } + case .response: + state = 42 + return .none + } + } + } + + await store.send(.tap) + await store.skipInFlightEffects() + await mainQueue.advance(by: .seconds(1)) + await store.receive(.response) { + $0 = 42 + } } - } - #if DEBUG - func testAssert_NonExhaustiveTestStore_Failure() async { + func testAssert_NonExhaustiveTestStore() async { let store = TestStore(initialState: 0) { EmptyReducer() } store.exhaustivity = .off - XCTExpectFailure { - store.assert { - $0 = 1 + store.assert { + $0 = 0 + } + } + + #if DEBUG + func testAssert_NonExhaustiveTestStore_Failure() async { + let store = TestStore(initialState: 0) { + EmptyReducer() } - } issueMatcher: { - $0.compactDescription == """ - A state change does not match expectation: … + store.exhaustivity = .off - − 1 - + 0 + XCTExpectFailure { + store.assert { + $0 = 1 + } + } issueMatcher: { + $0.compactDescription == """ + A state change does not match expectation: … + + − 1 + + 0 - (Expected: −, Actual: +) - """ + (Expected: −, Actual: +) + """ + } } - } - #endif + #endif - func testSubscribeReceiveCombineScheduler() async { - let subject = PassthroughSubject() - let scheduler = DispatchQueue.test + func testSubscribeReceiveCombineScheduler() async { + let subject = PassthroughSubject() + let scheduler = DispatchQueue.test - struct State: Equatable { - var count: Int = 0 - } + struct State: Equatable { + var count: Int = 0 + } - enum Action: Equatable { - case increment - case start - } + enum Action: Equatable { + case increment + case start + } - let store = TestStore(initialState: State()) { - Reduce { state, action in - switch action { - case .start: - return .publisher { - subject - .subscribe(on: scheduler) - .receive(on: scheduler) - .map { .increment } + let store = TestStore(initialState: State()) { + Reduce { state, action in + switch action { + case .start: + return .publisher { + subject + .subscribe(on: scheduler) + .receive(on: scheduler) + .map { .increment } + } + case .increment: + state.count += 1 + return .none } - case .increment: - state.count += 1 - return .none } } - } - - let task = await store.send(.start) - await scheduler.advance() - subject.send() - await scheduler.advance() - await store.receive(.increment) { $0.count = 1 } - await task.cancel() - } - func testMainSerialExecutor_AutoAssignsAndResets_False() async { - uncheckedUseMainSerialExecutor = false - XCTAssertFalse(uncheckedUseMainSerialExecutor) - var store: TestStore? = TestStore(initialState: 0) { - EmptyReducer() + let task = await store.send(.start) + await scheduler.advance() + subject.send() + await scheduler.advance() + await store.receive(.increment) { $0.count = 1 } + await task.cancel() } - XCTAssertTrue(uncheckedUseMainSerialExecutor) - store = nil - XCTAssertFalse(uncheckedUseMainSerialExecutor) - _ = store - } - func testMainSerialExecutor_AutoAssignsAndResets_True() async { - uncheckedUseMainSerialExecutor = true - XCTAssertTrue(uncheckedUseMainSerialExecutor) - var store: TestStore? = TestStore(initialState: 0) { - EmptyReducer() + func testMainSerialExecutor_AutoAssignsAndResets_False() async { + uncheckedUseMainSerialExecutor = false + XCTAssertFalse(uncheckedUseMainSerialExecutor) + var store: TestStore? = TestStore(initialState: 0) { + EmptyReducer() + } + XCTAssertTrue(uncheckedUseMainSerialExecutor) + store = nil + XCTAssertFalse(uncheckedUseMainSerialExecutor) + _ = store } - XCTAssertTrue(uncheckedUseMainSerialExecutor) - store = nil - XCTAssertTrue(uncheckedUseMainSerialExecutor) - _ = store - } - #if DEBUG - func testReceiveCaseKeyPathWithValue() async { - let store = TestStore(initialState: 0) { - Reduce { state, action in - switch action { - case .tap: - return .send(.delegate(.success(42))) - case .delegate: - return .none + func testMainSerialExecutor_AutoAssignsAndResets_True() async { + uncheckedUseMainSerialExecutor = true + XCTAssertTrue(uncheckedUseMainSerialExecutor) + var store: TestStore? = TestStore(initialState: 0) { + EmptyReducer() + } + XCTAssertTrue(uncheckedUseMainSerialExecutor) + store = nil + XCTAssertTrue(uncheckedUseMainSerialExecutor) + _ = store + } + + #if DEBUG + func testReceiveCaseKeyPathWithValue() async { + let store = TestStore(initialState: 0) { + Reduce { state, action in + switch action { + case .tap: + return .send(.delegate(.success(42))) + case .delegate: + return .none + } } } - } - await store.send(.tap) - await store.receive(\.delegate.success, 42) + await store.send(.tap) + await store.receive(\.delegate.success, 42) - XCTExpectFailure { - $0.compactDescription == """ - Received unexpected action: … + XCTExpectFailure { + $0.compactDescription == """ + Received unexpected action: … -   Action.delegate( - − .success(43) - + .success(42) -   ) +   Action.delegate( + − .success(43) + + .success(42) +   ) - (Expected: −, Actual: +) - """ + (Expected: −, Actual: +) + """ + } + await store.send(.tap) + await store.receive(\.delegate.success, 43) } - await store.send(.tap) - await store.receive(\.delegate.success, 43) + #endif + } + + private struct Client: DependencyKey { + var fetch: () -> Int + static let liveValue = Client(fetch: { 42 }) + } + extension DependencyValues { + fileprivate var client: Client { + get { self[Client.self] } + set { self[Client.self] = newValue } } - #endif -} - -private struct Client: DependencyKey { - var fetch: () -> Int - static let liveValue = Client(fetch: { 42 }) -} -extension DependencyValues { - fileprivate var client: Client { - get { self[Client.self] } - set { self[Client.self] = newValue } } -} -@CasePathable -private enum Action { - case tap - case delegate(Delegate) @CasePathable - enum Delegate { - case success(Int) + private enum Action { + case tap + case delegate(Delegate) + @CasePathable + enum Delegate { + case success(Int) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ThrottleTests.swift b/Tests/ComposableArchitectureTests/ThrottleTests.swift index 61e6835591ed..ac59f67ef7e4 100644 --- a/Tests/ComposableArchitectureTests/ThrottleTests.swift +++ b/Tests/ComposableArchitectureTests/ThrottleTests.swift @@ -1,193 +1,195 @@ -import Combine -import ComposableArchitecture -import XCTest - -@MainActor -final class EffectThrottleTests: BaseTCATestCase { - let mainQueue = DispatchQueue.test - - func testThrottleLatest_Publisher() async { - let store = TestStore(initialState: ThrottleFeature.State()) { - ThrottleFeature(id: #function, latest: true) - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } +#if swift(>=5.9) + import Combine + import ComposableArchitecture + import XCTest + + @MainActor + final class EffectThrottleTests: BaseTCATestCase { + let mainQueue = DispatchQueue.test + + func testThrottleLatest_Publisher() async { + let store = TestStore(initialState: ThrottleFeature.State()) { + ThrottleFeature(id: #function, latest: true) + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } - await store.send(.tap(1)) - await self.mainQueue.advance() - await store.receive(.throttledResponse(1)) { - $0.count = 1 - } + await store.send(.tap(1)) + await self.mainQueue.advance() + await store.receive(.throttledResponse(1)) { + $0.count = 1 + } - await store.send(.tap(2)) - await self.mainQueue.advance() - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(3)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(4)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(5)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.receive(.throttledResponse(5)) { - $0.count = 5 + await store.send(.tap(2)) + await self.mainQueue.advance() + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(3)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(4)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(5)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.receive(.throttledResponse(5)) { + $0.count = 5 + } } - } - func testThrottleLatest_Async() async { - let store = TestStore(initialState: ThrottleFeature.State()) { - ThrottleFeature(id: #function, latest: true) - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } + func testThrottleLatest_Async() async { + let store = TestStore(initialState: ThrottleFeature.State()) { + ThrottleFeature(id: #function, latest: true) + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } - await store.send(.tap(1)) - await self.mainQueue.advance() - await store.receive(.throttledResponse(1)) { - $0.count = 1 - } + await store.send(.tap(1)) + await self.mainQueue.advance() + await store.receive(.throttledResponse(1)) { + $0.count = 1 + } - await store.send(.tap(2)) - await self.mainQueue.advance() - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(3)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(4)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(5)) - await self.mainQueue.advance(by: .seconds(1)) - await store.receive(.throttledResponse(5)) { - $0.count = 5 + await store.send(.tap(2)) + await self.mainQueue.advance() + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(3)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(4)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(5)) + await self.mainQueue.advance(by: .seconds(1)) + await store.receive(.throttledResponse(5)) { + $0.count = 5 + } } - } - func testThrottleFirst_Publisher() async { - let store = TestStore(initialState: ThrottleFeature.State()) { - ThrottleFeature(id: #function, latest: false) - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } + func testThrottleFirst_Publisher() async { + let store = TestStore(initialState: ThrottleFeature.State()) { + ThrottleFeature(id: #function, latest: false) + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } - await store.send(.tap(1)) - await self.mainQueue.advance() - await store.receive(.throttledResponse(1)) { - $0.count = 1 - } + await store.send(.tap(1)) + await self.mainQueue.advance() + await store.receive(.throttledResponse(1)) { + $0.count = 1 + } - await store.send(.tap(2)) - await self.mainQueue.advance() - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(3)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(4)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.count, 1) - - await store.send(.tap(5)) - await self.mainQueue.advance(by: .seconds(0.25)) - await store.receive(.throttledResponse(2)) { - $0.count = 2 + await store.send(.tap(2)) + await self.mainQueue.advance() + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(3)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(4)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state.count, 1) + + await store.send(.tap(5)) + await self.mainQueue.advance(by: .seconds(0.25)) + await store.receive(.throttledResponse(2)) { + $0.count = 2 + } } - } - func testThrottleAfterInterval_Publisher() async { - let store = TestStore(initialState: ThrottleFeature.State()) { - ThrottleFeature(id: #function, latest: true) - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } + func testThrottleAfterInterval_Publisher() async { + let store = TestStore(initialState: ThrottleFeature.State()) { + ThrottleFeature(id: #function, latest: true) + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } - await store.send(.tap(1)) - await self.mainQueue.advance() - await store.receive(.throttledResponse(1)) { - $0.count = 1 - } + await store.send(.tap(1)) + await self.mainQueue.advance() + await store.receive(.throttledResponse(1)) { + $0.count = 1 + } - await self.mainQueue.advance(by: .seconds(1)) - await store.send(.tap(2)) - await self.mainQueue.advance() - await store.receive(.throttledResponse(2)) { - $0.count = 2 + await self.mainQueue.advance(by: .seconds(1)) + await store.send(.tap(2)) + await self.mainQueue.advance() + await store.receive(.throttledResponse(2)) { + $0.count = 2 + } } - } - func testThrottleEmitsFirstValueOnce_Publisher() async { - let store = TestStore(initialState: ThrottleFeature.State()) { - ThrottleFeature(id: #function, latest: true) - } withDependencies: { - $0.mainQueue = mainQueue.eraseToAnyScheduler() - } + func testThrottleEmitsFirstValueOnce_Publisher() async { + let store = TestStore(initialState: ThrottleFeature.State()) { + ThrottleFeature(id: #function, latest: true) + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } - await store.send(.tap(1)) - await self.mainQueue.advance() - await store.receive(.throttledResponse(1)) { - $0.count = 1 - } + await store.send(.tap(1)) + await self.mainQueue.advance() + await store.receive(.throttledResponse(1)) { + $0.count = 1 + } - await self.mainQueue.advance(by: .seconds(1)) - await store.send(.tap(2)) - await self.mainQueue.advance() - await store.receive(.throttledResponse(2)) { - $0.count = 2 + await self.mainQueue.advance(by: .seconds(1)) + await store.send(.tap(2)) + await self.mainQueue.advance() + await store.receive(.throttledResponse(2)) { + $0.count = 2 + } } } -} -@Reducer -struct ThrottleFeature { - struct State: Equatable { - var count = 0 - } - enum Action: Equatable { - case tap(Int) - case throttledResponse(Int) - } - let id: String - let latest: Bool - @Dependency(\.mainQueue) var mainQueue - var body: some Reducer { - Reduce { state, action in - switch action { - case let .tap(value): - return .send(.throttledResponse(value)) - .throttle(id: self.id, for: .seconds(1), scheduler: self.mainQueue, latest: self.latest) - case let .throttledResponse(value): - state.count = value - return .none + @Reducer + struct ThrottleFeature { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case tap(Int) + case throttledResponse(Int) + } + let id: String + let latest: Bool + @Dependency(\.mainQueue) var mainQueue + var body: some Reducer { + Reduce { state, action in + switch action { + case let .tap(value): + return .send(.throttledResponse(value)) + .throttle(id: self.id, for: .seconds(1), scheduler: self.mainQueue, latest: self.latest) + case let .throttledResponse(value): + state.count = value + return .none + } } } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ViewStoreTests.swift b/Tests/ComposableArchitectureTests/ViewStoreTests.swift index 6076d28153c5..39315804c2fc 100644 --- a/Tests/ComposableArchitectureTests/ViewStoreTests.swift +++ b/Tests/ComposableArchitectureTests/ViewStoreTests.swift @@ -30,6 +30,7 @@ final class ViewStoreTests: BaseTCATestCase { XCTAssertEqual(emissionCount, 1) } + @available(*, deprecated) func testEqualityChecks() { let store = Store(initialState: State()) {}