diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj index 6e13a3982a45..e6e4d5f01436 100644 --- a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -7,30 +7,36 @@ objects = { /* Begin PBXBuildFile section */ - 433B8B762A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */; }; - 4F5AC11F24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */; }; - CA0C51FB245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */; }; + 433B8B762A49C9AF0035DEE4 /* 04-Navigation-Multiple-Destinations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433B8B752A49C9AF0035DEE4 /* 04-Navigation-Multiple-Destinations.swift */; }; + 4F5AC11F24ECC7E4009DC50B /* 02-GettingStarted-SharedStateInMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5AC11E24ECC7E4009DC50B /* 02-GettingStarted-SharedStateInMemoryTests.swift */; }; + CA0C51FB245389CC00A04EAB /* 05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0C51FA245389CC00A04EAB /* 05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */; }; CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */; }; CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */; }; CA3E421F26B8337500581ABC /* 01-GettingStarted-FocusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3E421E26B8337500581ABC /* 01-GettingStarted-FocusState.swift */; }; - CA410EE0247A15FE00E41798 /* 02-Effects-WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */; }; - CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */; }; + CA410EE0247A15FE00E41798 /* 03-Effects-WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EDF247A15FE00E41798 /* 03-Effects-WebSocket.swift */; }; + CA410EE2247C73B400E41798 /* 03-Effects-WebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EE1247C73B400E41798 /* 03-Effects-WebSocketTests.swift */; }; CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */; }; CA5ECF92267A79F0002067FF /* FactClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5ECF91267A79F0002067FF /* FactClient.swift */; }; CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */; }; CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */; }; CA6AC2662451135C00C71CB3 /* DownloadComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */; }; CA6AC2672451135C00C71CB3 /* DownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2632451135C00C71CB3 /* DownloadClient.swift */; }; - CA78F0CD28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA78F0CC28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.swift */; }; - CA7BC8EE245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BC8ED245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift */; }; - CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */; }; - CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */; }; - CAA9ADC624465C810003A984 /* 02-Effects-Cancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */; }; - CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */; }; - CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */; }; - CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */; }; - CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */; }; - CABC4F3B26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */; }; + CA78F0CD28DA47D70026C4AD /* 05-HigherOrderReducers-RecursionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA78F0CC28DA47D70026C4AD /* 05-HigherOrderReducers-RecursionTests.swift */; }; + CA7BC8EE245CCFE4001FB69F /* 02-SharedState-UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BC8ED245CCFE4001FB69F /* 02-SharedState-UserDefaults.swift */; }; + CAA9ADC22446587C0003A984 /* 03-Effects-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC12446587C0003A984 /* 03-Effects-Basics.swift */; }; + CAA9ADC424465AB00003A984 /* 03-Effects-BasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC324465AB00003A984 /* 03-Effects-BasicsTests.swift */; }; + CAA9ADC624465C810003A984 /* 03-Effects-Cancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC524465C810003A984 /* 03-Effects-Cancellation.swift */; }; + CAA9ADC824465D950003A984 /* 03-Effects-CancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC724465D950003A984 /* 03-Effects-CancellationTests.swift */; }; + CAA9ADCA2446605B0003A984 /* 03-Effects-LongLiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC92446605B0003A984 /* 03-Effects-LongLiving.swift */; }; + CAA9ADCC2446615B0003A984 /* 03-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 03-Effects-LongLivingTests.swift */; }; + CABC4F3926AEE00C00D5FA2C /* 03-Effects-Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3826AEE00C00D5FA2C /* 03-Effects-Refreshable.swift */; }; + CABC4F3B26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3A26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift */; }; + CACA7FBC2BC707F2002DF110 /* 02-SharedState-Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */; }; + CADECDB62B5CA228009DC881 /* 02-SharedState-InMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */; }; + CADECDB82B5CA425009DC881 /* 02-SharedState-FileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */; }; + CADECDBA2B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB92B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift */; }; + CADECDBC2B5CA730009DC881 /* 02-GettingStarted-SharedStateFileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDBB2B5CA730009DC881 /* 02-GettingStarted-SharedStateFileStorageTests.swift */; }; + CADECDC02B5DE7C1009DC881 /* 02-SharedState-Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDBF2B5DE7C1009DC881 /* 02-SharedState-Onboarding.swift */; }; CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */; }; CAF88E7324B8E26D00539345 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E7224B8E26D00539345 /* AppDelegate.swift */; }; CAF88E7524B8E26D00539345 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E7424B8E26D00539345 /* RootView.swift */; }; @@ -39,15 +45,15 @@ CAF88E9124B8E3AF00539345 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = CAF88E9024B8E3AF00539345 /* ComposableArchitecture */; }; CAF88E9324B8E3D000539345 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E9224B8E3D000539345 /* Core.swift */; }; CAF88E9524B8E4D500539345 /* FocusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E9424B8E4D500539345 /* FocusView.swift */; }; - DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */; }; - DC072322244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */; }; + DC07231724465D1E003A8B65 /* 03-Effects-TimersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC07231624465D1E003A8B65 /* 03-Effects-TimersTests.swift */; }; + DC072322244663B1003A8B65 /* 04-Navigation-Sheet-LoadThenPresent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC072321244663B1003A8B65 /* 04-Navigation-Sheet-LoadThenPresent.swift */; }; DC13940E2469E25C00EE1157 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC13940D2469E25C00EE1157 /* ComposableArchitecture */; }; DC1394102469E27300EE1157 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC13940F2469E27300EE1157 /* ComposableArchitecture */; }; DC25DC5F2450F13200082E81 /* IfLetStoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */; }; DC25DC612450F2B000082E81 /* LoadThenNavigate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */; }; DC25DC642450F2DF00082E81 /* ActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */; }; DC27215625BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC27215525BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift */; }; - DC3C87B029A48C4D004D9104 /* 03-NavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3C87AF29A48C4D004D9104 /* 03-NavigationStack.swift */; }; + DC3C87B029A48C4D004D9104 /* 04-NavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3C87AF29A48C4D004D9104 /* 04-NavigationStack.swift */; }; DC4C6EAC2450DD380066A05D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */; }; DC4C6EAE2450DD380066A05D /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EAD2450DD380066A05D /* RootViewController.swift */; }; DC4C6EB02450DD380066A05D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EAF2450DD380066A05D /* Assets.xcassets */; }; @@ -58,23 +64,23 @@ 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 */; }; - DC634B252448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */; }; + DC634B252448D15B00DAA016 /* 05-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC634B242448D15B00DAA016 /* 05-HigherOrderReducers-ReusableFavoritingTests.swift */; }; DC85EBC3285A731E00431CF3 /* ResignFirstResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC85EBC2285A731E00431CF3 /* ResignFirstResponder.swift */; }; DC88D8A6245341EC0077F427 /* 01-GettingStarted-Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */; }; DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C41A24460F95006900B9 /* 00-RootView.swift */; }; DC89C41D24460F96006900B9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC89C41C24460F96006900B9 /* Assets.xcassets */; }; DC89C4442446111B006900B9 /* 01-GettingStarted-Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */; }; - DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */; }; - DC89C45324465452006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */; }; - DC89C45524465C44006900B9 /* 02-Effects-Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */; }; + DC89C44D244621A5006900B9 /* 04-Navigation-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C44C244621A5006900B9 /* 04-Navigation-NavigateAndLoad.swift */; }; + DC89C45324465452006900B9 /* 04-Navigation-Lists-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45224465451006900B9 /* 04-Navigation-Lists-NavigateAndLoad.swift */; }; + DC89C45524465C44006900B9 /* 03-Effects-Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45424465C44006900B9 /* 03-Effects-Timers.swift */; }; DC9EB4172450CBD2005F413B /* UIViewRepresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */; }; - DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */; }; + DCC68EAB244666AF0037F998 /* 04-Navigation-Sheet-PresentAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EAA244666AF0037F998 /* 04-Navigation-Sheet-PresentAndLoad.swift */; }; DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */; }; DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EDE2447BC810037F998 /* TemplateText.swift */; }; DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */; }; - DCC68EE32447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */; }; + DCC68EE32447C8540037F998 /* 05-HigherOrderReducers-ReusableFavoriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EE22447C8540037F998 /* 05-HigherOrderReducers-ReusableFavoriting.swift */; }; DCD442C6286CA91F008B4EA7 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD442C5286CA91F008B4EA7 /* AboutView.swift */; }; - DCE63B71245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */; }; + DCE63B71245CC0B90080A23D /* 05-HigherOrderReducers-Recursion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE63B70245CC0B90080A23D /* 05-HigherOrderReducers-Recursion.swift */; }; DCFE1960278DBF0600C14CCF /* CaseStudiesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFE195F278DBF0600C14CCF /* CaseStudiesApp.swift */; }; /* End PBXBuildFile section */ @@ -146,30 +152,36 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Multiple-Destinations.swift"; sourceTree = ""; }; - 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-SharedStateTests.swift"; sourceTree = ""; }; - CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift"; sourceTree = ""; }; + 433B8B752A49C9AF0035DEE4 /* 04-Navigation-Multiple-Destinations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-Navigation-Multiple-Destinations.swift"; sourceTree = ""; }; + 4F5AC11E24ECC7E4009DC50B /* 02-GettingStarted-SharedStateInMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-GettingStarted-SharedStateInMemoryTests.swift"; sourceTree = ""; }; + CA0C51FA245389CC00A04EAB /* 05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift"; sourceTree = ""; }; CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Bindings-Basics.swift"; sourceTree = ""; }; CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AnimationsTests.swift"; sourceTree = ""; }; CA3E421E26B8337500581ABC /* 01-GettingStarted-FocusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-FocusState.swift"; sourceTree = ""; }; - CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocket.swift"; sourceTree = ""; }; - CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocketTests.swift"; sourceTree = ""; }; + CA410EDF247A15FE00E41798 /* 03-Effects-WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-WebSocket.swift"; sourceTree = ""; }; + CA410EE1247C73B400E41798 /* 03-Effects-WebSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-WebSocketTests.swift"; sourceTree = ""; }; CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndConfirmationDialogsTests.swift"; sourceTree = ""; }; CA5ECF91267A79F0002067FF /* FactClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactClient.swift; sourceTree = ""; }; CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReusableComponents-Download.swift"; sourceTree = ""; }; CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadComponent.swift; sourceTree = ""; }; CA6AC2632451135C00C71CB3 /* DownloadClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadClient.swift; sourceTree = ""; }; - CA78F0CC28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-RecursionTests.swift"; sourceTree = ""; }; - CA7BC8ED245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-SharedState.swift"; sourceTree = ""; }; - CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Basics.swift"; sourceTree = ""; }; - CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-BasicsTests.swift"; sourceTree = ""; }; - CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Cancellation.swift"; sourceTree = ""; }; - CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-CancellationTests.swift"; sourceTree = ""; }; - CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLiving.swift"; sourceTree = ""; }; - CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLivingTests.swift"; sourceTree = ""; }; - CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Refreshable.swift"; sourceTree = ""; }; - CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-RefreshableTests.swift"; sourceTree = ""; }; + CA78F0CC28DA47D70026C4AD /* 05-HigherOrderReducers-RecursionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "05-HigherOrderReducers-RecursionTests.swift"; sourceTree = ""; }; + CA7BC8ED245CCFE4001FB69F /* 02-SharedState-UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-UserDefaults.swift"; sourceTree = ""; }; + CAA9ADC12446587C0003A984 /* 03-Effects-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-Basics.swift"; sourceTree = ""; }; + CAA9ADC324465AB00003A984 /* 03-Effects-BasicsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-BasicsTests.swift"; sourceTree = ""; }; + CAA9ADC524465C810003A984 /* 03-Effects-Cancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-Cancellation.swift"; sourceTree = ""; }; + CAA9ADC724465D950003A984 /* 03-Effects-CancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-CancellationTests.swift"; sourceTree = ""; }; + CAA9ADC92446605B0003A984 /* 03-Effects-LongLiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-LongLiving.swift"; sourceTree = ""; }; + CAA9ADCB2446615B0003A984 /* 03-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-LongLivingTests.swift"; sourceTree = ""; }; + CABC4F3826AEE00C00D5FA2C /* 03-Effects-Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-Refreshable.swift"; sourceTree = ""; }; + CABC4F3A26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-RefreshableTests.swift"; sourceTree = ""; }; + CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-Notifications.swift"; sourceTree = ""; }; + CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-InMemory.swift"; sourceTree = ""; }; + CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-FileStorage.swift"; sourceTree = ""; }; + CADECDB92B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-GettingStarted-SharedStateUserDefaultsTests.swift"; sourceTree = ""; }; + CADECDBB2B5CA730009DC881 /* 02-GettingStarted-SharedStateFileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-GettingStarted-SharedStateFileStorageTests.swift"; sourceTree = ""; }; + CADECDBF2B5DE7C1009DC881 /* 02-SharedState-Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-SharedState-Onboarding.swift"; sourceTree = ""; }; CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndConfirmationDialogs.swift"; sourceTree = ""; }; CAF88E7024B8E26D00539345 /* tvOSCaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = tvOSCaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; CAF88E7224B8E26D00539345 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -180,13 +192,13 @@ CAF88E8724B8E26E00539345 /* FocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusTests.swift; sourceTree = ""; }; CAF88E9224B8E3D000539345 /* Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Core.swift; sourceTree = ""; }; CAF88E9424B8E4D500539345 /* FocusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusView.swift; sourceTree = ""; }; - DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-TimersTests.swift"; sourceTree = ""; }; - DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Sheet-LoadThenPresent.swift"; sourceTree = ""; }; + DC07231624465D1E003A8B65 /* 03-Effects-TimersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-TimersTests.swift"; sourceTree = ""; }; + DC072321244663B1003A8B65 /* 04-Navigation-Sheet-LoadThenPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-Navigation-Sheet-LoadThenPresent.swift"; sourceTree = ""; }; DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfLetStoreController.swift; sourceTree = ""; }; DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadThenNavigate.swift; sourceTree = ""; }; DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorViewController.swift; sourceTree = ""; }; DC27215525BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-BindingBasicsTests.swift"; sourceTree = ""; }; - DC3C87AF29A48C4D004D9104 /* 03-NavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-NavigationStack.swift"; sourceTree = ""; }; + DC3C87AF29A48C4D004D9104 /* 04-NavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-NavigationStack.swift"; sourceTree = ""; }; DC4C6EA72450DD380066A05D /* UIKitCaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UIKitCaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; DC4C6EAD2450DD380066A05D /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; @@ -201,7 +213,7 @@ 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 = ""; }; - DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableFavoritingTests.swift"; sourceTree = ""; }; + DC634B242448D15B00DAA016 /* 05-HigherOrderReducers-ReusableFavoritingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "05-HigherOrderReducers-ReusableFavoritingTests.swift"; sourceTree = ""; }; DC85EBC2285A731E00431CF3 /* ResignFirstResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignFirstResponder.swift; sourceTree = ""; }; DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Animations.swift"; sourceTree = ""; }; DC89C41324460F95006900B9 /* SwiftUICaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUICaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -212,17 +224,17 @@ DC89C43824460FC7006900B9 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; DC89C43924460FFF006900B9 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Counter.swift"; sourceTree = ""; }; - DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-NavigateAndLoad.swift"; sourceTree = ""; }; - DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Lists-NavigateAndLoad.swift"; sourceTree = ""; }; - DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Timers.swift"; sourceTree = ""; }; + DC89C44C244621A5006900B9 /* 04-Navigation-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-Navigation-NavigateAndLoad.swift"; sourceTree = ""; }; + DC89C45224465451006900B9 /* 04-Navigation-Lists-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-Navigation-Lists-NavigateAndLoad.swift"; sourceTree = ""; }; + DC89C45424465C44006900B9 /* 03-Effects-Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-Timers.swift"; sourceTree = ""; }; DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewRepresented.swift; sourceTree = ""; }; - DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Sheet-PresentAndLoad.swift"; sourceTree = ""; }; + DCC68EAA244666AF0037F998 /* 04-Navigation-Sheet-PresentAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-Navigation-Sheet-PresentAndLoad.swift"; sourceTree = ""; }; DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-OptionalState.swift"; sourceTree = ""; }; DCC68EDE2447BC810037F998 /* TemplateText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateText.swift; sourceTree = ""; }; DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Composition-TwoCounters.swift"; sourceTree = ""; }; - DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableFavoriting.swift"; sourceTree = ""; }; + DCC68EE22447C8540037F998 /* 05-HigherOrderReducers-ReusableFavoriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "05-HigherOrderReducers-ReusableFavoriting.swift"; sourceTree = ""; }; DCD442C5286CA91F008B4EA7 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; - DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-Recursion.swift"; sourceTree = ""; }; + DCE63B70245CC0B90080A23D /* 05-HigherOrderReducers-Recursion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "05-HigherOrderReducers-Recursion.swift"; sourceTree = ""; }; DCFE195F278DBF0600C14CCF /* CaseStudiesApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseStudiesApp.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -275,14 +287,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - CA6AC25F2451131C00C71CB3 /* 04-HigherOrderReducers-ResuableOfflineDownloads */ = { + CA6AC25F2451131C00C71CB3 /* 05-HigherOrderReducers-ResuableOfflineDownloads */ = { isa = PBXGroup; children = ( CA6AC2632451135C00C71CB3 /* DownloadClient.swift */, CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */, CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */, ); - path = "04-HigherOrderReducers-ResuableOfflineDownloads"; + path = "05-HigherOrderReducers-ResuableOfflineDownloads"; sourceTree = ""; }; CAF88E7124B8E26D00539345 /* tvOSCaseStudies */ = { @@ -391,25 +403,29 @@ DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */, CA3E421E26B8337500581ABC /* 01-GettingStarted-FocusState.swift */, DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */, - CA7BC8ED245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift */, - CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */, - CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */, - CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */, - CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */, - DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */, - CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */, - DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */, - DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */, - DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */, - DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */, - 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */, - DC3C87AF29A48C4D004D9104 /* 03-NavigationStack.swift */, - DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */, - DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */, + CADECDB72B5CA425009DC881 /* 02-SharedState-FileStorage.swift */, + CADECDB52B5CA228009DC881 /* 02-SharedState-InMemory.swift */, + CACA7FBB2BC707F2002DF110 /* 02-SharedState-Notifications.swift */, + CADECDBF2B5DE7C1009DC881 /* 02-SharedState-Onboarding.swift */, + CA7BC8ED245CCFE4001FB69F /* 02-SharedState-UserDefaults.swift */, + CAA9ADC12446587C0003A984 /* 03-Effects-Basics.swift */, + CAA9ADC524465C810003A984 /* 03-Effects-Cancellation.swift */, + CAA9ADC92446605B0003A984 /* 03-Effects-LongLiving.swift */, + CABC4F3826AEE00C00D5FA2C /* 03-Effects-Refreshable.swift */, + DC89C45424465C44006900B9 /* 03-Effects-Timers.swift */, + CA410EDF247A15FE00E41798 /* 03-Effects-WebSocket.swift */, + DC89C45224465451006900B9 /* 04-Navigation-Lists-NavigateAndLoad.swift */, + 433B8B752A49C9AF0035DEE4 /* 04-Navigation-Multiple-Destinations.swift */, + DC89C44C244621A5006900B9 /* 04-Navigation-NavigateAndLoad.swift */, + DC072321244663B1003A8B65 /* 04-Navigation-Sheet-LoadThenPresent.swift */, + DCC68EAA244666AF0037F998 /* 04-Navigation-Sheet-PresentAndLoad.swift */, + DC3C87AF29A48C4D004D9104 /* 04-NavigationStack.swift */, + DCE63B70245CC0B90080A23D /* 05-HigherOrderReducers-Recursion.swift */, + DCC68EE22447C8540037F998 /* 05-HigherOrderReducers-ReusableFavoriting.swift */, DCFE195F278DBF0600C14CCF /* CaseStudiesApp.swift */, CA5ECF91267A79F0002067FF /* FactClient.swift */, DC89C41C24460F96006900B9 /* Assets.xcassets */, - CA6AC25F2451131C00C71CB3 /* 04-HigherOrderReducers-ResuableOfflineDownloads */, + CA6AC25F2451131C00C71CB3 /* 05-HigherOrderReducers-ResuableOfflineDownloads */, DC89C44524461416006900B9 /* Internal */, ); path = SwiftUICaseStudies; @@ -421,16 +437,18 @@ CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */, CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */, DC27215525BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift */, - 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */, - CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */, - CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */, - CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */, - CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */, - DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */, - CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */, - CA78F0CC28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.swift */, - DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */, - CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */, + CADECDBB2B5CA730009DC881 /* 02-GettingStarted-SharedStateFileStorageTests.swift */, + 4F5AC11E24ECC7E4009DC50B /* 02-GettingStarted-SharedStateInMemoryTests.swift */, + CADECDB92B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift */, + CAA9ADC324465AB00003A984 /* 03-Effects-BasicsTests.swift */, + CAA9ADC724465D950003A984 /* 03-Effects-CancellationTests.swift */, + CAA9ADCB2446615B0003A984 /* 03-Effects-LongLivingTests.swift */, + CABC4F3A26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift */, + DC07231624465D1E003A8B65 /* 03-Effects-TimersTests.swift */, + CA410EE1247C73B400E41798 /* 03-Effects-WebSocketTests.swift */, + CA78F0CC28DA47D70026C4AD /* 05-HigherOrderReducers-RecursionTests.swift */, + DC634B242448D15B00DAA016 /* 05-HigherOrderReducers-ReusableFavoritingTests.swift */, + CA0C51FA245389CC00A04EAB /* 05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */, ); path = SwiftUICaseStudiesTests; sourceTree = ""; @@ -726,40 +744,44 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DC3C87B029A48C4D004D9104 /* 03-NavigationStack.swift in Sources */, + DC3C87B029A48C4D004D9104 /* 04-NavigationStack.swift in Sources */, DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */, - DC072322244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift in Sources */, - DC89C45324465452006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift in Sources */, - DCC68EE32447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift in Sources */, + DC072322244663B1003A8B65 /* 04-Navigation-Sheet-LoadThenPresent.swift in Sources */, + DC89C45324465452006900B9 /* 04-Navigation-Lists-NavigateAndLoad.swift in Sources */, + CACA7FBC2BC707F2002DF110 /* 02-SharedState-Notifications.swift in Sources */, + DCC68EE32447C8540037F998 /* 05-HigherOrderReducers-ReusableFavoriting.swift in Sources */, CA3E421F26B8337500581ABC /* 01-GettingStarted-FocusState.swift in Sources */, DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */, CA6AC2672451135C00C71CB3 /* DownloadClient.swift in Sources */, - CAA9ADC624465C810003A984 /* 02-Effects-Cancellation.swift in Sources */, + CADECDB62B5CA228009DC881 /* 02-SharedState-InMemory.swift in Sources */, + CAA9ADC624465C810003A984 /* 03-Effects-Cancellation.swift in Sources */, CA5ECF92267A79F0002067FF /* FactClient.swift in Sources */, DC9EB4172450CBD2005F413B /* UIViewRepresented.swift in Sources */, CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */, CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */, + CADECDC02B5DE7C1009DC881 /* 02-SharedState-Onboarding.swift in Sources */, DCFE1960278DBF0600C14CCF /* CaseStudiesApp.swift in Sources */, DCD442C6286CA91F008B4EA7 /* AboutView.swift in Sources */, CA6AC2662451135C00C71CB3 /* DownloadComponent.swift in Sources */, DC5B505125C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift in Sources */, - CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */, + CADECDB82B5CA425009DC881 /* 02-SharedState-FileStorage.swift in Sources */, + CAA9ADC22446587C0003A984 /* 03-Effects-Basics.swift in Sources */, DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */, DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */, - CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */, + CABC4F3926AEE00C00D5FA2C /* 03-Effects-Refreshable.swift in Sources */, DC85EBC3285A731E00431CF3 /* ResignFirstResponder.swift in Sources */, - DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */, - 433B8B762A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift in Sources */, + DCC68EAB244666AF0037F998 /* 04-Navigation-Sheet-PresentAndLoad.swift in Sources */, + 433B8B762A49C9AF0035DEE4 /* 04-Navigation-Multiple-Destinations.swift in Sources */, CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift in Sources */, CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */, DC88D8A6245341EC0077F427 /* 01-GettingStarted-Animations.swift in Sources */, - DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */, + DC89C44D244621A5006900B9 /* 04-Navigation-NavigateAndLoad.swift in Sources */, DC89C4442446111B006900B9 /* 01-GettingStarted-Counter.swift in Sources */, - DCE63B71245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift in Sources */, - CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.swift in Sources */, - DC89C45524465C44006900B9 /* 02-Effects-Timers.swift in Sources */, - CA410EE0247A15FE00E41798 /* 02-Effects-WebSocket.swift in Sources */, - CA7BC8EE245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift in Sources */, + DCE63B71245CC0B90080A23D /* 05-HigherOrderReducers-Recursion.swift in Sources */, + CAA9ADCA2446605B0003A984 /* 03-Effects-LongLiving.swift in Sources */, + DC89C45524465C44006900B9 /* 03-Effects-Timers.swift in Sources */, + CA410EE0247A15FE00E41798 /* 03-Effects-WebSocket.swift in Sources */, + CA7BC8EE245CCFE4001FB69F /* 02-SharedState-UserDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -768,18 +790,20 @@ buildActionMask = 2147483647; files = ( DC27215625BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift in Sources */, - CA0C51FB245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift in Sources */, - DC634B252448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */, - CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */, - CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */, - CABC4F3B26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift in Sources */, + CADECDBA2B5CA613009DC881 /* 02-GettingStarted-SharedStateUserDefaultsTests.swift in Sources */, + CA0C51FB245389CC00A04EAB /* 05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift in Sources */, + DC634B252448D15B00DAA016 /* 05-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */, + CAA9ADC824465D950003A984 /* 03-Effects-CancellationTests.swift in Sources */, + CA410EE2247C73B400E41798 /* 03-Effects-WebSocketTests.swift in Sources */, + CABC4F3B26AEE20200D5FA2C /* 03-Effects-RefreshableTests.swift in Sources */, CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */, - DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */, + DC07231724465D1E003A8B65 /* 03-Effects-TimersTests.swift in Sources */, CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift in Sources */, - CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */, - CA78F0CD28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.swift in Sources */, - 4F5AC11F24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift in Sources */, - CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */, + CAA9ADC424465AB00003A984 /* 03-Effects-BasicsTests.swift in Sources */, + CADECDBC2B5CA730009DC881 /* 02-GettingStarted-SharedStateFileStorageTests.swift in Sources */, + CA78F0CD28DA47D70026C4AD /* 05-HigherOrderReducers-RecursionTests.swift in Sources */, + 4F5AC11F24ECC7E4009DC50B /* 02-GettingStarted-SharedStateInMemoryTests.swift in Sources */, + CAA9ADCC2446615B0003A984 /* 03-Effects-LongLivingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift index def28e79a207..eef92539acf8 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift @@ -3,6 +3,7 @@ import SwiftUI struct RootView: View { @State var isNavigationStackCaseStudyPresented = false + @State var isSignUpCaseStudyPresented = false var body: some View { NavigationStack { @@ -33,11 +34,6 @@ struct RootView: View { OptionalBasicsView(store: store) } } - NavigationLink("Shared state") { - Demo(store: Store(initialState: SharedState.State()) { SharedState() }) { store in - SharedStateView(store: store) - } - } NavigationLink("Alerts and Confirmation Dialogs") { Demo( store: Store(initialState: AlertAndConfirmationDialog.State()) { @@ -61,6 +57,51 @@ struct RootView: View { Text("Getting started") } + Section { + NavigationLink("In memory") { + Demo( + store: Store(initialState: SharedStateInMemory.State()) { SharedStateInMemory() } + ) { store in + SharedStateInMemoryView(store: store) + } + } + NavigationLink("User defaults") { + Demo( + store: Store(initialState: SharedStateUserDefaults.State()) { + SharedStateUserDefaults() + } + ) { store in + SharedStateUserDefaultsView(store: store) + } + } + NavigationLink("File storage") { + Demo( + store: Store(initialState: SharedStateFileStorage.State()) { + SharedStateFileStorage() + } + ) { store in + SharedStateFileStorageView(store: store) + } + } + NavigationLink("Notifications") { + Demo( + store: Store(initialState: SharedStateNotifications.State()) { + SharedStateNotifications() + } + ) { store in + SharedStateNotificationsView(store: store) + } + } + Button("Sign up flow") { + isSignUpCaseStudyPresented = true + } + .sheet(isPresented: $isSignUpCaseStudyPresented) { + SignUpFlow() + } + } header: { + Text("Shared state") + } + Section { NavigationLink("Basics") { Demo(store: Store(initialState: EffectsBasics.State()) { EffectsBasics() }) { store in @@ -102,7 +143,7 @@ struct RootView: View { Section { Button("Stack") { - self.isNavigationStackCaseStudyPresented = true + isNavigationStackCaseStudyPresented = true } .buttonStyle(.plain) @@ -171,7 +212,7 @@ struct RootView: View { } } .navigationTitle("Case Studies") - .sheet(isPresented: self.$isNavigationStackCaseStudyPresented) { + .sheet(isPresented: $isNavigationStackCaseStudyPresented) { Demo(store: Store(initialState: NavigationDemo.State()) { NavigationDemo() }) { store in NavigationDemoView(store: store) } @@ -194,7 +235,7 @@ struct Demo: View { } var body: some View { - self.content(self.store) + content(store) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift index e7bc6ed90428..abc42f43b687 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -65,7 +65,7 @@ struct Animations { return .run { send in for color in [Color.red, .blue, .green, .orange, .pink, .purple, .yellow, .black] { await send(.setColor(color), animation: .linear) - try await self.clock.sleep(for: .seconds(1)) + try await clock.sleep(for: .seconds(1)) } } .cancellable(id: CancelID.rainbow) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-FileStorage.swift similarity index 58% rename from Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift rename to Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-FileStorage.swift index 9b98190bcaf7..0c8da379d597 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-FileStorage.swift @@ -3,8 +3,9 @@ import SwiftUI private let readMe = """ This screen demonstrates how multiple independent screens can share state in the Composable \ - Architecture. Each tab manages its own state, and could be in separate modules, but changes in \ - one tab are immediately reflected in the other. + Architecture through file storage. Each tab manages its own state, and \ + could be in separate modules, but changes in one tab are immediately reflected in the other, and \ + all changes are persisted to disk. This tab has its own state, consisting of a count value that can be incremented and decremented, \ as well as an alert value that is set when asking if the current count is prime. @@ -15,53 +16,136 @@ private let readMe = """ """ @Reducer -struct CounterTab { +struct SharedStateFileStorage { + enum Tab { case counter, profile } + @ObservableState struct State: Equatable { - @Presents var alert: AlertState? - var stats = Stats() + 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 { + Scope(state: \.counter, action: \.counter) { + CounterTab() + } + + Scope(state: \.profile, action: \.profile) { + ProfileTab() + } + Reduce { state, action in switch action { - case .alert: + case .counter, .profile: return .none - - case .decrementButtonTapped: - state.stats.decrement() + case let .selectTab(tab): + state.currentTab = tab return .none + } + } + } +} - case .incrementButtonTapped: - state.stats.increment() - return .none +struct SharedStateFileStorageView: View { + @Bindable var store: StoreOf + + var body: some View { + TabView(selection: $store.currentTab.sending(\.selectTab)) { + CounterTabView( + store: store.scope(state: \.counter, action: \.counter) + ) + .tag(SharedStateFileStorage.Tab.counter) + .tabItem { Text("Counter") } - 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 :(" - ) + ProfileTabView( + store: store.scope(state: \.profile, action: \.profile) + ) + .tag(SharedStateFileStorage.Tab.profile) + .tabItem { Text("Profile") } + } + .navigationTitle("Shared State Demo") + } +} + +extension SharedStateFileStorage { + @Reducer + struct CounterTab { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Shared(.stats) var stats = Stats() + } + + enum Action { + case alert(PresentationAction) + case decrementButtonTapped + case incrementButtonTapped + case isPrimeButtonTapped + + enum Alert: Equatable {} + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert: + return .none + + case .decrementButtonTapped: + state.stats.decrement() + return .none + + case .incrementButtonTapped: + state.stats.increment() + return .none + + 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 :(" + ) + } + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } + } + + @Reducer + struct ProfileTab { + @ObservableState + struct State: Equatable { + @Shared(.stats) var stats = Stats() + } + + enum Action { + case resetStatsButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .resetStatsButtonTapped: + state.stats = Stats() + return .none } - return .none } } - .ifLet(\.$alert, action: \.alert) } } -struct CounterTabView: View { - @Bindable var store: StoreOf +private struct CounterTabView: View { + @Bindable var store: StoreOf var body: some View { Form { @@ -89,35 +173,12 @@ struct CounterTabView: View { } } .buttonStyle(.borderless) - .navigationTitle("Shared State Demo") .alert($store.scope(state: \.alert, action: \.alert)) } } -@Reducer -struct ProfileTab { - @ObservableState - struct State: Equatable { - var stats = Stats() - } - - enum Action { - case resetStatsButtonTapped - } - - var body: some Reducer { - Reduce { state, action in - switch action { - case .resetStatsButtonTapped: - state.stats.reset() - return .none - } - } - } -} - -struct ProfileTabView: View { - let store: StoreOf +private struct ProfileTabView: View { + let store: StoreOf var body: some View { Form { @@ -142,85 +203,10 @@ struct ProfileTabView: View { } } .buttonStyle(.borderless) - .navigationTitle("Profile") - } -} - -@Reducer -struct SharedState { - enum Tab { case counter, profile } - - @ObservableState - struct State: Equatable { - var currentTab = Tab.counter - var counter = CounterTab.State() - var profile = ProfileTab.State() - } - - enum Action { - case counter(CounterTab.Action) - case profile(ProfileTab.Action) - case selectTab(Tab) - } - - var body: some Reducer { - Scope(state: \.counter, action: \.counter) { - CounterTab() - } - .onChange(of: \.counter.stats) { _, stats in - Reduce { state, _ in - state.profile.stats = stats - return .none - } - } - - Scope(state: \.profile, action: \.profile) { - ProfileTab() - } - .onChange(of: \.profile.stats) { _, stats in - Reduce { state, _ in - state.counter.stats = stats - return .none - } - } - - Reduce { state, action in - switch action { - case .counter, .profile: - return .none - case let .selectTab(tab): - state.currentTab = tab - return .none - } - } - } -} - -struct SharedStateView: View { - @Bindable var store: StoreOf - - 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") } - - NavigationStack { - ProfileTabView( - store: self.store.scope(state: \.profile, action: \.profile) - ) - } - .tag(SharedState.Tab.profile) - .tabItem { Text("Profile") } - } } } -struct Stats: Equatable { +struct Stats: Codable, Equatable { private(set) var count = 0 private(set) var maxCount = 0 private(set) var minCount = 0 @@ -235,8 +221,11 @@ struct Stats: Equatable { numberOfCounts += 1 minCount = min(minCount, count) } - mutating func reset() { - self = Self() +} + +extension PersistenceReaderKey where Self == FileStorageKey { + fileprivate static var stats: Self { + fileStorage(.documentsDirectory.appending(component: "stats.json")) } } @@ -251,9 +240,9 @@ private func isPrime(_ p: Int) -> Bool { } #Preview { - SharedStateView( - store: Store(initialState: SharedState.State()) { - SharedState() + SharedStateFileStorageView( + store: Store(initialState: SharedStateFileStorage.State()) { + SharedStateFileStorage() } ) } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-InMemory.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-InMemory.swift new file mode 100644 index 000000000000..2f531146e532 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-InMemory.swift @@ -0,0 +1,228 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how multiple independent screens can share state in the Composable \ + Architecture through an in-memory reference. Each tab manages its own state, and \ + could be in separate modules, but changes in one tab are immediately reflected in the other. + + This tab has its own state, consisting of a count value that can be incremented and decremented, \ + as well as an alert value that is set when asking if the current count is prime. + + Internally, it is also keeping track of various stats, such as min and max counts and total \ + number of count events that occurred. Those states are viewable in the other tab, and the stats \ + can be reset from the other tab. + """ + +@Reducer +struct SharedStateInMemory { + enum Tab { case counter, profile } + + @ObservableState + struct State: Equatable { + var currentTab = Tab.counter + var counter = CounterTab.State() + var profile = ProfileTab.State() + } + + enum Action { + case counter(CounterTab.Action) + case profile(ProfileTab.Action) + case selectTab(Tab) + } + + var body: some Reducer { + Scope(state: \.counter, action: \.counter) { + CounterTab() + } + + Scope(state: \.profile, action: \.profile) { + ProfileTab() + } + + Reduce { state, action in + switch action { + case .counter, .profile: + return .none + case let .selectTab(tab): + state.currentTab = tab + return .none + } + } + } +} + +struct SharedStateInMemoryView: View { + @Bindable var store: StoreOf + + var body: some View { + TabView(selection: $store.currentTab.sending(\.selectTab)) { + CounterTabView( + store: store.scope(state: \.counter, action: \.counter) + ) + .tag(SharedStateInMemory.Tab.counter) + .tabItem { Text("Counter") } + + ProfileTabView( + store: store.scope(state: \.profile, action: \.profile) + ) + .tag(SharedStateInMemory.Tab.profile) + .tabItem { Text("Profile") } + } + .navigationTitle("Shared State Demo") + } +} + +extension SharedStateInMemory { + @Reducer + struct CounterTab { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Shared(.stats) var stats = Stats() + } + + enum Action { + case alert(PresentationAction) + case decrementButtonTapped + case incrementButtonTapped + case isPrimeButtonTapped + + enum Alert: Equatable {} + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert: + return .none + + case .decrementButtonTapped: + state.stats.decrement() + return .none + + case .incrementButtonTapped: + state.stats.increment() + return .none + + 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 :(" + ) + } + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } + } + + @Reducer + struct ProfileTab { + @ObservableState + struct State: Equatable { + @Shared(.stats) var stats = Stats() + } + + enum Action { + case resetStatsButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .resetStatsButtonTapped: + state.stats = Stats() + return .none + } + } + } + } +} + +private struct CounterTabView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Text(template: readMe, .caption) + + VStack(spacing: 16) { + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(store.stats.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + + Button("Is this prime?") { store.send(.isPrimeButtonTapped) } + } + } + .buttonStyle(.borderless) + .alert($store.scope(state: \.alert, action: \.alert)) + } +} + +private struct ProfileTabView: View { + let store: StoreOf + + var body: some View { + Form { + Text( + template: """ + This tab shows state from the previous tab, and it is capable of resetting 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) } + } + } + .buttonStyle(.borderless) + } +} + +#Preview { + SharedStateInMemoryView( + store: Store(initialState: SharedStateInMemory.State()) { SharedStateInMemory() } + ) +} + +extension PersistenceReaderKey where Self == InMemoryKey { + fileprivate static var stats: Self { + inMemory("stats") + } +} + +/// 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-SharedState-Notifications.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Notifications.swift new file mode 100644 index 000000000000..da0f3793bfa5 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Notifications.swift @@ -0,0 +1,130 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to use the `@SharedReader` tool to introduce a piece of \ + read-only state to your feature whose true value lives in an external system. In this case, \ + the state is the number of times a screenshot is taken, which is counted from the \ + `userDidTakeScreenshotNotification` notification. + + Run this application in the simulator, and take a few screenshots by going to \ + *Device â€ē Trigger Screenshot* in the menu, and observe that the UI counts the number of times \ + that happens. + + The `@SharedReader` state will update automatically when the screenshot notification is posted \ + by the system, and further you can use the `.publisher` property on `@SharedReader` to listen \ + for any changes to the data. + """ + +@Reducer +struct SharedStateNotifications { + @ObservableState + struct State: Equatable { + var fact: String? + @SharedReader(.screenshotCount) var screenshotCount = 0 + } + enum Action { + case factResponse(Result) + case onAppear + } + @Dependency(\.factClient) var factClient + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .factResponse(.success(fact)): + state.fact = fact + return .none + + case .factResponse(.failure): + return .none + + case .onAppear: + return .run { [screenshotCount = state.$screenshotCount] send in + for await count in screenshotCount.publisher.values { + await send(.factResponse(Result { try await factClient.fetch(count) })) + } + } + } + } + } +} + +struct SharedStateNotificationsView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Text("A screenshot of this screen has been taken \(store.screenshotCount) times.") + .font(.headline) + + if let fact = store.fact { + Text("\(fact)") + } + } + .navigationTitle("Long-living effects") + .task { await store.send(.onAppear).finish() } + } +} + +extension PersistenceReaderKey where Self == NotificationReaderKey { + static var screenshotCount: Self { + NotificationReaderKey( + initialValue: 0, + name: MainActor.assumeIsolated { + UIApplication.userDidTakeScreenshotNotification + } + ) { value, _ in + value += 1 + } + } +} + +struct NotificationReaderKey: PersistenceReaderKey { + let name: Notification.Name + private let transform: @Sendable (Notification) -> Value + + init( + initialValue: Value, + name: Notification.Name, + transform: @Sendable @escaping (inout Value, Notification) -> Void + ) { + self.name = name + let value = LockIsolated(initialValue) + self.transform = { notification in + value.withValue { [notification = UncheckedSendable(notification)] in + transform(&$0, notification.wrappedValue) + } + return value.value + } + } + + func load(initialValue: Value?) -> Value? { nil } + + func subscribe( + initialValue: Value?, + didSet: @Sendable @escaping (Value?) -> Void + ) -> Shared.Subscription { + let token = NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: nil, + using: { notification in + didSet(transform(notification)) + } + ) + return Shared.Subscription { + NotificationCenter.default.removeObserver(token) + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name + } + func hash(into hasher: inout Hasher) { + hasher.combine(name) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Onboarding.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Onboarding.swift new file mode 100644 index 000000000000..06f046d5aa66 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-Onboarding.swift @@ -0,0 +1,510 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This case study demonstrates how to use shared data in order to implement a complex sign up flow. + + The sign up flow consists of 3 steps, each of which can mutate a bit of shared data, and a final \ + summary screen. The summary screen also allows the user to make any last minute edits to any of \ + the data in the previous steps. + """ + +struct SignUpData: Equatable { + var email = "" + var firstName = "" + var lastName = "" + var password = "" + var passwordConfirmation = "" + var phoneNumber = "" + var topics: Set = [] + + enum Topic: String, Identifiable, CaseIterable { + case advancedSwift = "Advanced Swift" + case composableArchitecture = "Composable Architecture" + case concurrency = "Concurrency" + case modernSwiftUI = "Modern SwiftUI" + case swiftUI = "SwiftUI" + case testing = "Testing" + var id: Self { self } + } +} + +@Reducer +private struct SignUpFeature { + @Reducer + enum Path { + case basics(BasicsFeature) + case personalInfo(PersonalInfoFeature) + case summary(SummaryFeature) + case topics(TopicsFeature) + } + @ObservableState + struct State { + var path = StackState() + @Shared var signUpData: SignUpData + } + enum Action { + case path(StackActionOf) + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .path(.element(id: _, action: .topics(.delegate(.stepFinished)))): + state.path.append(.summary(SummaryFeature.State(signUpData: state.$signUpData))) + return .none + + case .path: + return .none + } + } + .forEach(\.path, action: \.path) + } +} + +struct SignUpFlow: View { + @Bindable private var store = Store( + initialState: SignUpFeature.State(signUpData: Shared(SignUpData())) + ) { + SignUpFeature() + } + + var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + Form { + Section { + Text(readMe) + } + Section { + NavigationLink( + "Sign up", + state: SignUpFeature.Path.State.basics( + BasicsFeature.State(signUpData: store.$signUpData) + ) + ) + } + } + .navigationTitle("Sign up") + } destination: { store in + switch store.case { + case let .basics(store): + BasicsStep(store: store) + case let .personalInfo(store): + PersonalInfoStep(store: store) + case let .summary(store): + SummaryStep(store: store) + case let .topics(store): + TopicsStep(store: store) + } + } + } +} + +@Reducer +private struct BasicsFeature { + @ObservableState + struct State { + var isEditingFromSummary = false + @Shared var signUpData: SignUpData + } + enum Action: BindableAction { + case binding(BindingAction) + } + var body: some ReducerOf { + BindingReducer() + } +} + +private struct BasicsStep: View { + @Environment(\.dismiss) private var dismiss + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + TextField("Email", text: $store.signUpData.email) + } + Section { + SecureField("Password", text: $store.signUpData.password) + SecureField("Password confirmation", text: $store.signUpData.passwordConfirmation) + } + } + .navigationTitle("Basics") + .toolbar { + ToolbarItem { + if store.isEditingFromSummary { + Button("Done") { + dismiss() + } + } else { + NavigationLink( + state: SignUpFeature.Path.State.personalInfo( + PersonalInfoFeature.State(signUpData: store.$signUpData) + ) + ) { + Text("Next") + } + } + } + } + } +} + +@Reducer +private struct PersonalInfoFeature { + @ObservableState + struct State { + var isEditingFromSummary = false + @Shared var signUpData: SignUpData + } + enum Action: BindableAction { + case binding(BindingAction) + } + @Dependency(\.dismiss) var dismiss + var body: some ReducerOf { + BindingReducer() + } +} + +private struct PersonalInfoStep: View { + @Environment(\.dismiss) private var dismiss + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + TextField("First name", text: $store.signUpData.firstName) + TextField("Last name", text: $store.signUpData.lastName) + TextField("Phone number", text: $store.signUpData.phoneNumber) + } + } + .navigationTitle("Personal info") + .toolbar { + ToolbarItem { + if store.isEditingFromSummary { + Button("Done") { + dismiss() + } + } else { + NavigationLink( + "Next", + state: SignUpFeature.Path.State.topics( + TopicsFeature.State(topics: store.$signUpData.topics) + ) + ) + } + } + } + } +} + +@Reducer +private struct TopicsFeature { + @ObservableState + struct State { + @Presents var alert: AlertState? + var isEditingFromSummary = false + @Shared var topics: Set + } + enum Action: BindableAction { + case alert(PresentationAction) + case binding(BindingAction) + case delegate(Delegate) + case doneButtonTapped + case nextButtonTapped + enum Delegate { + case stepFinished + } + } + @Dependency(\.dismiss) var dismiss + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .alert: + return .none + case .binding: + return .none + case .delegate: + return .none + case .doneButtonTapped: + if state.topics.isEmpty { + state.alert = AlertState { + TextState("Please choose at least one topic.") + } + return .none + } else { + return .run { _ in await dismiss() } + } + case .nextButtonTapped: + if state.topics.isEmpty { + state.alert = AlertState { + TextState("Please choose at least one topic.") + } + return .none + } else { + return .send(.delegate(.stepFinished)) + } + } + } + .ifLet(\.alert, action: \.alert) + } +} + +private struct TopicsStep: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + Text("Please choose all the topics you are interested in.") + } + Section { + ForEach(SignUpData.Topic.allCases) { topic in + Toggle(isOn: $store.topics[contains: topic]) { + Text(topic.rawValue) + } + } + } + } + .navigationTitle("Topics") + .alert($store.scope(state: \.alert, action: \.alert)) + .toolbar { + ToolbarItem { + if store.isEditingFromSummary { + Button("Done") { + store.send(.doneButtonTapped) + } + } else { + Button("Next") { + store.send(.nextButtonTapped) + } + } + } + } + .interactiveDismissDisabled() + } +} + +@Reducer +private struct SummaryFeature { + @Reducer + enum Destination { + case alert(AlertState) + case basics(BasicsFeature) + case personalInfo(PersonalInfoFeature) + case topics(TopicsFeature) + } + @ObservableState + struct State { + @Presents var destination: Destination.State? + @Shared var signUpData: SignUpData + } + enum Action { + case destination(PresentationAction) + case editFavoriteTopicsButtonTapped + case editPersonalInfoButtonTapped + case editRequiredInfoButtonTapped + case submitButtonTapped + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .destination: + return .none + case .editFavoriteTopicsButtonTapped: + state.destination = .topics( + TopicsFeature.State( + isEditingFromSummary: true, + topics: state.$signUpData.topics + ) + ) + return .none + case .editPersonalInfoButtonTapped: + state.destination = .personalInfo( + PersonalInfoFeature.State( + isEditingFromSummary: true, + signUpData: state.$signUpData + ) + ) + return .none + case .editRequiredInfoButtonTapped: + state.destination = .basics( + BasicsFeature.State( + isEditingFromSummary: true, + signUpData: state.$signUpData + ) + ) + return .none + case .submitButtonTapped: + state.destination = .alert( + AlertState { + TextState("Thank you for signing up!") + } + ) + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } +} + +private struct SummaryStep: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + Text(store.signUpData.email) + Text(String(repeating: "â€ĸ", count: store.signUpData.password.count)) + } header: { + HStack { + Text("Required info") + Spacer() + Button("Edit") { + store.send(.editRequiredInfoButtonTapped) + } + .font(.caption) + } + } + + Section { + Text(store.signUpData.firstName) + Text(store.signUpData.lastName) + Text(store.signUpData.phoneNumber) + } header: { + HStack { + Text("Personal info") + Spacer() + Button("Edit") { + store.send(.editPersonalInfoButtonTapped) + } + .font(.caption) + } + } + + Section { + ForEach(store.signUpData.topics.sorted(by: { $0.rawValue < $1.rawValue })) { topic in + Text(topic.rawValue) + } + } header: { + HStack { + Text("Favorite topics") + Spacer() + Button("Edit") { + store.send(.editFavoriteTopicsButtonTapped) + } + .font(.caption) + } + } + + Section { + Button { + store.send(.submitButtonTapped) + } label: { + Text("Submit") + } + } + } + .navigationTitle("Summary") + .sheet( + item: $store.scope(state: \.destination?.basics, action: \.destination.basics) + ) { basicsStore in + NavigationStack { + BasicsStep(store: basicsStore) + } + .presentationDetents([.medium]) + } + .sheet( + item: $store.scope(state: \.destination?.personalInfo, action: \.destination.personalInfo) + ) { personalStore in + NavigationStack { + PersonalInfoStep(store: personalStore) + } + .presentationDetents([.medium]) + } + .sheet( + item: $store.scope(state: \.destination?.topics, action: \.destination.topics) + ) { topicsStore in + NavigationStack { + TopicsStep(store: topicsStore) + } + .presentationDetents([.medium]) + } + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + } +} + +#Preview("Sign up") { + SignUpFlow() +} + +#Preview("Basics") { + NavigationStack { + BasicsStep( + store: Store(initialState: BasicsFeature.State(signUpData: Shared(SignUpData()))) { + BasicsFeature() + } + ) + } +} + +#Preview("Personal info") { + NavigationStack { + PersonalInfoStep( + store: Store(initialState: PersonalInfoFeature.State(signUpData: Shared(SignUpData()))) { + PersonalInfoFeature() + } + ) + } +} + +#Preview("Topics") { + NavigationStack { + TopicsStep( + store: Store(initialState: TopicsFeature.State(topics: Shared([]))) { + TopicsFeature() + } + ) + } +} + +#Preview("Summary") { + NavigationStack { + SummaryStep( + store: Store( + initialState: SummaryFeature.State( + signUpData: Shared( + SignUpData( + email: "blob@pointfree.co", + firstName: "Blob", + lastName: "McBlob", + password: "blob is awesome", + passwordConfirmation: "blob is awesome", + phoneNumber: "212-555-1234", + topics: [ + .composableArchitecture, + .concurrency, + .modernSwiftUI, + ] + ) + ) + ) + ) { + SummaryFeature() + } + ) + } +} + +fileprivate extension Set { + subscript(contains element: Element) -> Bool { + get { contains(element) } + set { + if newValue { + insert(element) + } else { + remove(element) + } + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-UserDefaults.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-UserDefaults.swift new file mode 100644 index 000000000000..a6370aeaa911 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-SharedState-UserDefaults.swift @@ -0,0 +1,222 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how multiple independent screens can share state in the Composable \ + Architecture through user defaults (i.e. "app storage"). Each tab manages its own state, and \ + could be in separate modules, but changes in one tab are immediately reflected in the other, and \ + all changes are persisted to use defaults. + + This tab has its own state, consisting of a count value that can be incremented and decremented, \ + as well as an alert value that is set when asking if the current count is prime. + """ + +@Reducer +struct SharedStateUserDefaults { + enum Tab { case counter, profile } + + @ObservableState + struct State: Equatable { + var currentTab = Tab.counter + var counter = CounterTab.State() + var profile = ProfileTab.State() + } + + enum Action { + case counter(CounterTab.Action) + case profile(ProfileTab.Action) + case selectTab(Tab) + } + + var body: some Reducer { + Scope(state: \.counter, action: \.counter) { + CounterTab() + } + + Scope(state: \.profile, action: \.profile) { + ProfileTab() + } + + Reduce { state, action in + switch action { + case .counter, .profile: + return .none + case let .selectTab(tab): + state.currentTab = tab + return .none + } + } + } +} + +struct SharedStateUserDefaultsView: View { + @Bindable var store: StoreOf + + var body: some View { + TabView(selection: $store.currentTab.sending(\.selectTab)) { + CounterTabView( + store: store.scope(state: \.counter, action: \.counter) + ) + .tag(SharedStateUserDefaults.Tab.counter) + .tabItem { Text("Counter") } + + ProfileTabView( + store: store.scope(state: \.profile, action: \.profile) + ) + .tag(SharedStateUserDefaults.Tab.profile) + .tabItem { Text("Profile") } + } + .navigationTitle("Shared State Demo") + } +} + +extension SharedStateUserDefaults { + @Reducer + struct CounterTab { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Shared(.count) var count = 0 + } + + enum Action { + case alert(PresentationAction) + case decrementButtonTapped + case incrementButtonTapped + case isPrimeButtonTapped + + enum Alert: Equatable {} + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert: + return .none + + case .decrementButtonTapped: + state.count -= 1 + return .none + + case .incrementButtonTapped: + state.count += 1 + return .none + + case .isPrimeButtonTapped: + state.alert = AlertState { + TextState( + isPrime(state.count) + ? "👍 The number \(state.count) is prime!" + : "👎 The number \(state.count) is not prime :(" + ) + } + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } + } + + @Reducer + struct ProfileTab { + @ObservableState + struct State: Equatable { + @Shared(.count) var count = 0 + } + + enum Action { + case resetStatsButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .resetStatsButtonTapped: + state.count = 0 + return .none + } + } + } + } +} + +private struct CounterTabView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Text(template: readMe, .caption) + + VStack(spacing: 16) { + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(store.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + + Button("Is this prime?") { store.send(.isPrimeButtonTapped) } + } + } + .buttonStyle(.borderless) + .alert($store.scope(state: \.alert, action: \.alert)) + } +} + +private struct ProfileTabView: View { + let store: StoreOf + + var body: some View { + Form { + Text( + template: """ + This tab shows the count from the previous tab, and it is capable of resetting the count \ + 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.count)") + Button("Reset") { store.send(.resetStatsButtonTapped) } + } + } + .buttonStyle(.borderless) + } +} + +extension PersistenceReaderKey where Self == AppStorageKey { + fileprivate static var count: Self { + appStorage("sharedStateDemoCount") + } +} + +#Preview { + SharedStateUserDefaultsView( + store: Store(initialState: SharedStateUserDefaults.State()) { SharedStateUserDefaults() } + ) +} + +/// 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/03-Effects-Basics.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift rename to Examples/CaseStudies/SwiftUICaseStudies/03-Effects-Basics.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-Cancellation.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift rename to Examples/CaseStudies/SwiftUICaseStudies/03-Effects-Cancellation.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-LongLiving.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift rename to Examples/CaseStudies/SwiftUICaseStudies/03-Effects-LongLiving.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-Refreshable.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift rename to Examples/CaseStudies/SwiftUICaseStudies/03-Effects-Refreshable.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-Timers.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift rename to Examples/CaseStudies/SwiftUICaseStudies/03-Effects-Timers.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-WebSocket.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift rename to Examples/CaseStudies/SwiftUICaseStudies/03-Effects-WebSocket.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Lists-NavigateAndLoad.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift rename to Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Lists-NavigateAndLoad.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Multiple-Destinations.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift rename to Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Multiple-Destinations.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-NavigateAndLoad.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift rename to Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-NavigateAndLoad.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Sheet-LoadThenPresent.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift rename to Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Sheet-LoadThenPresent.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Sheet-PresentAndLoad.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift rename to Examples/CaseStudies/SwiftUICaseStudies/04-Navigation-Sheet-PresentAndLoad.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-NavigationStack.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift rename to Examples/CaseStudies/SwiftUICaseStudies/04-NavigationStack.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift b/Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-Recursion.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift rename to Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-Recursion.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift b/Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift rename to Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift rename to Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift rename to Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ReusableFavoriting.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift rename to Examples/CaseStudies/SwiftUICaseStudies/05-HigherOrderReducers-ReusableFavoriting.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateFileStorageTests.swift similarity index 68% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateFileStorageTests.swift index 17aa0b50376f..83914225f0b5 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateFileStorageTests.swift @@ -3,11 +3,11 @@ import XCTest @testable import SwiftUICaseStudies -final class SharedStateTests: XCTestCase { +final class SharedStateFileStorageTests: XCTestCase { @MainActor func testTabSelection() async { - let store = TestStore(initialState: SharedState.State()) { - SharedState() + let store = TestStore(initialState: SharedStateFileStorage.State()) { + SharedStateFileStorage() } await store.send(.selectTab(.profile)) { @@ -20,28 +20,27 @@ final class SharedStateTests: XCTestCase { @MainActor func testSharedCounts() async { - let store = TestStore(initialState: SharedState.State()) { - SharedState() + let store = TestStore(initialState: SharedStateFileStorage.State()) { + SharedStateFileStorage() } await store.send(\.counter.incrementButtonTapped) { $0.counter.stats.increment() - $0.profile.stats.increment() } + await store.send(\.counter.decrementButtonTapped) { $0.counter.stats.decrement() - $0.profile.stats.decrement() } + await store.send(\.profile.resetStatsButtonTapped) { - $0.counter.stats = Stats() $0.profile.stats = Stats() } } @MainActor func testAlert() async { - let store = TestStore(initialState: SharedState.State()) { - SharedState() + let store = TestStore(initialState: SharedStateFileStorage.State()) { + SharedStateFileStorage() } await store.send(\.counter.isPrimeButtonTapped) { diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateInMemoryTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateInMemoryTests.swift new file mode 100644 index 000000000000..d745fdb432c6 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateInMemoryTests.swift @@ -0,0 +1,52 @@ +import ComposableArchitecture +import XCTest + +@testable import SwiftUICaseStudies + +final class SharedStateInMemoryTests: XCTestCase { + @MainActor + func testTabSelection() async { + let store = TestStore(initialState: SharedStateInMemory.State()) { + SharedStateInMemory() + } + + await store.send(.selectTab(.profile)) { + $0.currentTab = .profile + } + await store.send(.selectTab(.counter)) { + $0.currentTab = .counter + } + } + + @MainActor + func testSharedCounts() async { + let store = TestStore(initialState: SharedStateInMemory.State()) { + SharedStateInMemory() + } + + await store.send(.counter(.incrementButtonTapped)) { + $0.counter.stats.increment() + } + + await store.send(.counter(.decrementButtonTapped)) { + $0.counter.stats.decrement() + } + + await store.send(.profile(.resetStatsButtonTapped)) { + $0.profile.stats = Stats() + } + } + + @MainActor + func testAlert() async { + let store = TestStore(initialState: SharedStateInMemory.State()) { + SharedStateInMemory() + } + + await store.send(.counter(.isPrimeButtonTapped)) { + $0.counter.alert = AlertState { + TextState("👎 The number 0 is not prime :(") + } + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateUserDefaultsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateUserDefaultsTests.swift new file mode 100644 index 000000000000..76d9643c6deb --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-GettingStarted-SharedStateUserDefaultsTests.swift @@ -0,0 +1,52 @@ +import ComposableArchitecture +import XCTest + +@testable import SwiftUICaseStudies + +final class SharedStateUserDefaultsTests: XCTestCase { + @MainActor + func testTabSelection() async { + let store = TestStore(initialState: SharedStateUserDefaults.State()) { + SharedStateUserDefaults() + } + + await store.send(.selectTab(.profile)) { + $0.currentTab = .profile + } + await store.send(.selectTab(.counter)) { + $0.currentTab = .counter + } + } + + @MainActor + func testSharedCounts() async { + let store = TestStore(initialState: SharedStateUserDefaults.State()) { + SharedStateUserDefaults() + } + + await store.send(.counter(.incrementButtonTapped)) { + $0.counter.count = 1 + } + + await store.send(.counter(.decrementButtonTapped)) { + $0.counter.count = 0 + } + + await store.send(.profile(.resetStatsButtonTapped)) { + $0.profile.count = 0 + } + } + + @MainActor + func testAlert() async { + let store = TestStore(initialState: SharedStateUserDefaults.State()) { + SharedStateUserDefaults() + } + + await store.send(.counter(.isPrimeButtonTapped)) { + $0.counter.alert = AlertState { + TextState("👎 The number 0 is not prime :(") + } + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-BasicsTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-BasicsTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-CancellationTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-CancellationTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-LongLivingTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-LongLivingTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-RefreshableTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-RefreshableTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-TimersTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-TimersTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-WebSocketTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/03-Effects-WebSocketTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-RecursionTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/05-HigherOrderReducers-RecursionTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-RecursionTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/05-HigherOrderReducers-RecursionTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/05-HigherOrderReducers-ReusableFavoritingTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/05-HigherOrderReducers-ReusableFavoritingTests.swift diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift similarity index 100% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/05-HigherOrderReducers-ReusableOfflineDownloadsTests.swift diff --git a/Examples/Integration/Integration.xcodeproj/project.pbxproj b/Examples/Integration/Integration.xcodeproj/project.pbxproj index 6bfd9cfceb98..49af1b5e904f 100644 --- a/Examples/Integration/Integration.xcodeproj/project.pbxproj +++ b/Examples/Integration/Integration.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ CA8B2EB02AC5A8CC008272E0 /* PresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8B2EAF2AC5A8CC008272E0 /* PresentationTests.swift */; }; CA8B2EB22AC5AD63008272E0 /* NavigationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8B2EB12AC5AD63008272E0 /* NavigationTestCase.swift */; }; CA8B2EB42AC5AF70008272E0 /* NavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8B2EB32AC5AF70008272E0 /* NavigationTests.swift */; }; + CA9632012B5C64F400C44458 /* ObservableSharedStateTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9632002B5C64F400C44458 /* ObservableSharedStateTestCase.swift */; }; CAA1CAF5296DEE78000665B1 /* IntegrationApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */; }; CAA1CAF9296DEE79000665B1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAA1CAF8296DEE79000665B1 /* Assets.xcassets */; }; CAA1CAFC296DEE79000665B1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAA1CAFB296DEE79000665B1 /* Preview Assets.xcassets */; }; @@ -41,6 +42,7 @@ 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 */; }; + CADECDB42B5C939E009DC881 /* ObservableSharedStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADECDB32B5C939E009DC881 /* ObservableSharedStateTests.swift */; }; CAE2E9232B23417000EE370B /* IfLetStoreTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE2E9222B23417000EE370B /* IfLetStoreTestCase.swift */; }; CAE2E9252B2341AB00EE370B /* IfLetStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE2E9242B2341AB00EE370B /* IfLetStoreTests.swift */; }; CAE961792B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE961782B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift */; }; @@ -142,6 +144,7 @@ CA8B2EAF2AC5A8CC008272E0 /* PresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationTests.swift; sourceTree = ""; }; CA8B2EB12AC5AD63008272E0 /* NavigationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTestCase.swift; sourceTree = ""; }; CA8B2EB32AC5AF70008272E0 /* NavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTests.swift; sourceTree = ""; }; + CA9632002B5C64F400C44458 /* ObservableSharedStateTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableSharedStateTestCase.swift; sourceTree = ""; }; CAA1CAF1296DEE78000665B1 /* Integration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Integration.app; sourceTree = BUILT_PRODUCTS_DIR; }; CAA1CAF4296DEE78000665B1 /* IntegrationApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationApp.swift; sourceTree = ""; }; CAA1CAF8296DEE79000665B1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -155,6 +158,7 @@ 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 = ""; }; + CADECDB32B5C939E009DC881 /* ObservableSharedStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableSharedStateTests.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 = ""; }; CAE961782B0EE0A8007A66F5 /* ObservableBindingLocalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableBindingLocalTest.swift; sourceTree = ""; }; @@ -378,6 +382,7 @@ DC6E2D8F2AD5C56F005ACC26 /* ObservableNavigationTestCase.swift */, DC6E2D922AD5C56F005ACC26 /* ObservableOptionalTestCase.swift */, DC6E2D912AD5C56F005ACC26 /* ObservablePresentationTestCase.swift */, + CA9632002B5C64F400C44458 /* ObservableSharedStateTestCase.swift */, DC6E2D902AD5C56F005ACC26 /* ObservableSiblingTestCase.swift */, ); path = "iOS 17"; @@ -407,6 +412,7 @@ DC6E2DA02AD5C677005ACC26 /* ObservableNavigationTests.swift */, DC6E2DA22AD5C677005ACC26 /* ObservableOptionalTests.swift */, DC6E2DA42AD5C677005ACC26 /* ObservablePresentationTests.swift */, + CADECDB32B5C939E009DC881 /* ObservableSharedStateTests.swift */, DC6E2DA12AD5C677005ACC26 /* ObservableSiblingTests.swift */, ); path = "iOS 17"; @@ -583,6 +589,7 @@ DCFFB8E72A156488006AF839 /* BindingLocalTestCase.swift in Sources */, DC6E2D992AD5C56F005ACC26 /* ObservableOptionalTestCase.swift in Sources */, CA7BDDA12ADB543400277984 /* NewContainsOldTestCase.swift in Sources */, + CA9632012B5C64F400C44458 /* ObservableSharedStateTestCase.swift in Sources */, CAF5802529A5651D0042FB62 /* LegacyPresentationTestCase.swift in Sources */, DC6E2D9A2AD5C56F005ACC26 /* ObservableEnumTestCase.swift in Sources */, CA7BDDA32ADB575F00277984 /* OldContainsNewTestCase.swift in Sources */, @@ -609,6 +616,7 @@ files = ( CA8B2EAC2AC58AD9008272E0 /* IdentifiedListTests.swift in Sources */, CA8B2EB02AC5A8CC008272E0 /* PresentationTests.swift in Sources */, + CADECDB42B5C939E009DC881 /* ObservableSharedStateTests.swift in Sources */, DC6E2DA92AD5C677005ACC26 /* ObservableOptionalTests.swift in Sources */, CA4BA5EB29E76F110004FF9D /* LegacyNavigationTests.swift in Sources */, CA8B2EB42AC5AF70008272E0 /* NavigationTests.swift in Sources */, diff --git a/Examples/Integration/Integration/IntegrationApp.swift b/Examples/Integration/Integration/IntegrationApp.swift index 6d37adc7f91c..302ab68ce3a1 100644 --- a/Examples/Integration/Integration/IntegrationApp.swift +++ b/Examples/Integration/Integration/IntegrationApp.swift @@ -108,6 +108,9 @@ struct ContentView: View { .sheet(isPresented: self.$isObservableNavigationTestCasePresented) { ObservableNavigationTestCaseView() } + NavigationLink("Shared state") { + ObservableSharedStateView() + } NavigationLink("Siblings") { ObservableSiblingFeaturesView() } diff --git a/Examples/Integration/Integration/Legacy/NavigationStackTestCase.swift b/Examples/Integration/Integration/Legacy/NavigationStackTestCase.swift index 45c12e7d92cc..f92ba6f36225 100644 --- a/Examples/Integration/Integration/Legacy/NavigationStackTestCase.swift +++ b/Examples/Integration/Integration/Legacy/NavigationStackTestCase.swift @@ -10,7 +10,7 @@ private struct DestinationView: View { @Reducer private struct ChildFeature { - struct State: Hashable { + struct State: Equatable { @PresentationState var alert: AlertState? @PresentationState var navigationDestination: Int? var count = 0 diff --git a/Examples/Integration/Integration/iOS 17/ObservableSharedStateTestCase.swift b/Examples/Integration/Integration/iOS 17/ObservableSharedStateTestCase.swift new file mode 100644 index 000000000000..e4f3a1f27b44 --- /dev/null +++ b/Examples/Integration/Integration/iOS 17/ObservableSharedStateTestCase.swift @@ -0,0 +1,139 @@ +@_spi(Internals) @_spi(Logging) import ComposableArchitecture +import SwiftUI + +struct ObservableSharedStateView: View { + @Perception.Bindable private var store = Store(initialState: Feature.State()) { + Feature() + } + + var body: some View { + WithPerceptionTracking { + let _ = Logger.shared.log("\(Self.self).body") + Form { + Section { + HStack { + Button("Toggle") { store.isAppStorageOn1.toggle() } + .accessibilityIdentifier("isAppStorageOn1") + Text("App Storage #1 " + (store.isAppStorageOn1 ? "✅" : "❌")) + } + HStack { + Button("Toggle") { store.isAppStorageOn2.toggle() } + .accessibilityIdentifier("isAppStorageOn2") + Text("App Storage #2 " + (store.isAppStorageOn2 ? "✅" : "❌")) + } + Button("Write directly to user defaults") { + store.send(.writeToUserDefaultsButtonTapped) + } + Button("Delete user default") { + store.send(.deleteUserDefaultButtonTapped) + } + } header: { + Text("App storage") + } + + Section { + HStack { + Button("Toggle") { store.fileStorage1.isOn.toggle() } + .accessibilityIdentifier("isFileStorageOn1") + Text("File Storage #1 " + (store.fileStorage1.isOn ? "✅" : "❌")) + } + HStack { + Button("Toggle") { store.fileStorage2.isOn.toggle() } + .accessibilityIdentifier("isFileStorageOn2") + Text("File Storage #2 " + (store.fileStorage2.isOn ? "✅" : "❌")) + } + Button("Write directly to file system") { + store.send(.writeToFileStorageButtonTapped) + } + Button("Delete file") { + store.send(.deleteFileButtonTapped) + } + } header: { + Text("File storage") + } + + Section { + HStack { + Button("Toggle") { store.isInMemoryOn1.toggle() } + .accessibilityIdentifier("isInMemoryOn1") + Text("In-memory Storage #1 " + (store.isInMemoryOn1 ? "✅" : "❌")) + } + HStack { + Button("Toggle") { store.isInMemoryOn2.toggle() } + .accessibilityIdentifier("isInMemoryOn2") + Text("In-memory Storage #2 " + (store.isInMemoryOn2 ? "✅" : "❌")) + } + } header: { + Text("In-memory") + } + + Section { + Button("Reset") { + store.send(.resetButtonTapped) + } + } + } + } + } +} + +@Reducer +private struct Feature { + @ObservableState + struct State { + @Shared(.appStorage("isOn")) var isAppStorageOn1 = false + @Shared(.appStorage("isOn")) var isAppStorageOn2 = false + @Shared(.inMemory("isOn")) var isInMemoryOn1 = false + @Shared(.inMemory("isOn")) var isInMemoryOn2 = false + @Shared(.fileStorage(storageURL)) var fileStorage1 = Settings() + @Shared(.fileStorage(storageURL)) var fileStorage2 = Settings() + } + enum Action: BindableAction { + case binding(BindingAction) + case deleteFileButtonTapped + case deleteUserDefaultButtonTapped + case resetButtonTapped + case writeToFileStorageButtonTapped + case writeToUserDefaultsButtonTapped + } + @Dependency(\.defaultAppStorage) var defaults + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + case .deleteFileButtonTapped: + return .run { _ in + try FileManager.default.removeItem(at: storageURL) + } + case .deleteUserDefaultButtonTapped: + return .run { _ in + defaults.removeObject(forKey: "isOn") + } + case .resetButtonTapped: + state.isAppStorageOn1 = false + state.isAppStorageOn2 = false + state.fileStorage1.isOn = false + state.fileStorage2.isOn = false + state.isInMemoryOn1 = false + state.isInMemoryOn2 = false + return .none + case .writeToFileStorageButtonTapped: + return .run { [isOn = state.fileStorage1.isOn] _ in + try JSONEncoder().encode(Settings(isOn: !isOn)).write(to: storageURL) + } + case .writeToUserDefaultsButtonTapped: + return .run { [isOn = state.isAppStorageOn1] _ in + defaults.setValue(!isOn, forKey: "isOn") + } + } + } + } +} + +private let storageURL = URL.documentsDirectory.appending(component: "file.json") + +private struct Settings: Codable, Equatable { + var isOn = false +} diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/NewContainsOldTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/NewContainsOldTests.swift index 508ca2904f25..1730daa8dd85 100644 --- a/Examples/Integration/IntegrationUITests/iOS 16+17/NewContainsOldTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/NewContainsOldTests.swift @@ -18,7 +18,7 @@ final class iOS16_17_NewContainsOldTests: BaseIntegrationTests { XCTAssertEqual(self.app.staticTexts["1"].exists, true) self.assertLogs { """ - + NewContainsOldTestCase.body """ } @@ -27,7 +27,13 @@ final class iOS16_17_NewContainsOldTests: BaseIntegrationTests { self.assertLogs { """ BasicsView.body + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init ViewStoreOf.init WithViewStoreOf.body """ @@ -40,7 +46,7 @@ final class iOS16_17_NewContainsOldTests: BaseIntegrationTests { XCTAssertEqual(self.app.staticTexts["Child count: 0"].exists, true) self.assertLogs { """ - + NewContainsOldTestCase.body """ } } @@ -55,7 +61,14 @@ final class iOS16_17_NewContainsOldTests: BaseIntegrationTests { self.assertLogs { """ BasicsView.body + NewContainsOldTestCase.body + StoreOf.deinit + StoreOf.deinit + StoreOf.init + StoreOf.init ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init ViewStoreOf.init WithViewStoreOf.body """ diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/NewPresentsOldTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/NewPresentsOldTests.swift index 86cdeac886a8..6494efd144c5 100644 --- a/Examples/Integration/IntegrationUITests/iOS 16+17/NewPresentsOldTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/NewPresentsOldTests.swift @@ -20,11 +20,7 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { """ NewPresentsOldTestCase.body PresentationStoreOf.deinit - PresentationStoreOf.deinit - PresentationStoreOf.init PresentationStoreOf.init - StoreOf.deinit - StoreOf.init ViewPresentationStoreOf.deinit ViewPresentationStoreOf.init """ @@ -39,14 +35,10 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { BasicsView.body NewPresentsOldTestCase.body PresentationStoreOf.deinit - PresentationStoreOf.deinit - PresentationStoreOf.init PresentationStoreOf.init StoreOf.init StoreOf.init StoreOf.init - StoreOf.deinit - StoreOf.init StoreOf.init StoreOf.init StoreOf.init @@ -73,37 +65,67 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { self.assertLogs { """ BasicsView.body + BasicsView.body NewPresentsOldTestCase.body NewPresentsOldTestCase.body - PresentationStoreOf.init - PresentationStoreOf.init + PresentationStoreOf.deinit + PresentationStoreOf.deinit 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.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 + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.deinit ViewPresentationStoreOf.init ViewPresentationStoreOf.init ViewStoreOf.deinit ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init + ViewStoreOf.init ViewStoreOf.init ViewStoreOf.init ViewStoreOf.deinit 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 """ } @@ -117,11 +139,7 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { """ NewPresentsOldTestCase.body PresentationStoreOf.deinit - PresentationStoreOf.deinit - PresentationStoreOf.init PresentationStoreOf.init - StoreOf.deinit - StoreOf.init ViewPresentationStoreOf.deinit ViewPresentationStoreOf.init """ @@ -140,14 +158,10 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { BasicsView.body NewPresentsOldTestCase.body PresentationStoreOf.deinit - PresentationStoreOf.deinit - PresentationStoreOf.init PresentationStoreOf.init StoreOf.init StoreOf.init StoreOf.init - StoreOf.deinit - StoreOf.init StoreOf.init StoreOf.init StoreOf.init @@ -177,19 +191,16 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { self.assertLogs { """ BasicsView.body - BasicsView.body NewPresentsOldTestCase.body PresentationStoreOf.init - PresentationStoreOf.init - StoreOf.deinit StoreOf.deinit StoreOf.deinit StoreOf.init StoreOf.init - StoreOf.init StoreOf.deinit StoreOf.deinit - StoreOf.init + StoreOf.deinit + StoreOf.deinit StoreOf.init StoreOf.init StoreOf.init @@ -197,17 +208,19 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { ViewPresentationStoreOf.init ViewStoreOf.deinit ViewStoreOf.deinit - ViewStoreOf.deinit - 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 """ } } @@ -221,18 +234,29 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { self.assertLogs { """ BasicsView.body + BasicsView.body NewPresentsOldTestCase.body NewPresentsOldTestCase.body - PresentationStoreOf.init - PresentationStoreOf.init + PresentationStoreOf.deinit + PresentationStoreOf.deinit 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.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit + StoreOf.deinit StoreOf.deinit StoreOf.deinit StoreOf.init @@ -241,18 +265,37 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { StoreOf.init StoreOf.init StoreOf.init + ViewPresentationStoreOf.deinit + ViewPresentationStoreOf.deinit ViewPresentationStoreOf.init ViewPresentationStoreOf.init ViewStoreOf.deinit ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.deinit + ViewStoreOf.init ViewStoreOf.init ViewStoreOf.init + ViewStoreOf.init + ViewStoreOf.deinit + 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 """ } } @@ -268,8 +311,6 @@ final class iOS16_17_NewPresentsOldTests: BaseIntegrationTests { self.assertLogs { """ PresentationStoreOf.deinit - PresentationStoreOf.deinit - StoreOf.deinit ViewPresentationStoreOf.deinit """ } diff --git a/Examples/Integration/IntegrationUITests/iOS 16+17/OldContainsNewTests.swift b/Examples/Integration/IntegrationUITests/iOS 16+17/OldContainsNewTests.swift index bee709289ff5..cda7545637a8 100644 --- a/Examples/Integration/IntegrationUITests/iOS 16+17/OldContainsNewTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16+17/OldContainsNewTests.swift @@ -31,7 +31,13 @@ final class iOS16_17_OldContainsNewTests: BaseIntegrationTests { """ ObservableBasicsView.body OldContainsNewTestCase.body + Store.deinit + Store.deinit + Store.init + Store.init ViewStore.deinit + ViewStore.deinit + ViewStore.init ViewStore.init WithViewStore.body """ @@ -63,8 +69,14 @@ final class iOS16_17_OldContainsNewTests: BaseIntegrationTests { """ ObservableBasicsView.body OldContainsNewTestCase.body + Store.deinit + Store.deinit + Store.init + Store.init + ViewStore.deinit ViewStore.deinit ViewStore.init + ViewStore.init WithViewStore.body """ } @@ -78,7 +90,9 @@ final class iOS16_17_OldContainsNewTests: BaseIntegrationTests { self.app.buttons["iOS 16 + 17"].tap() self.assertLogs { """ - + Store.deinit + Store.deinit + ViewStore.deinit """ } } diff --git a/Examples/Integration/IntegrationUITests/iOS 16/PresentationTests.swift b/Examples/Integration/IntegrationUITests/iOS 16/PresentationTests.swift index 016843c82516..3f7f9ef9b718 100644 --- a/Examples/Integration/IntegrationUITests/iOS 16/PresentationTests.swift +++ b/Examples/Integration/IntegrationUITests/iOS 16/PresentationTests.swift @@ -51,9 +51,6 @@ final class iOS16_PresentationTests: BaseIntegrationTests { """ StoreOf.deinit StoreOf.deinit - StoreOf.deinit - StoreOf.deinit - StoreOf.deinit StoreOf.deinit StoreOf.deinit ViewStoreOf.deinit @@ -114,9 +111,6 @@ final class iOS16_PresentationTests: BaseIntegrationTests { PresentationView.body StoreOf.deinit StoreOf.deinit - StoreOf.deinit - StoreOf.deinit - StoreOf.deinit StoreOf.deinit StoreOf.deinit ViewStore.deinit diff --git a/Examples/Integration/IntegrationUITests/iOS 17/ObservableSharedStateTests.swift b/Examples/Integration/IntegrationUITests/iOS 17/ObservableSharedStateTests.swift new file mode 100644 index 000000000000..81a4ee82d4aa --- /dev/null +++ b/Examples/Integration/IntegrationUITests/iOS 17/ObservableSharedStateTests.swift @@ -0,0 +1,126 @@ +import InlineSnapshotTesting +import TestCases +import XCTest + +@MainActor +final class iOS17_ObservableSharedStateTests: BaseIntegrationTests { + override func setUp() { + super.setUp() + self.app.buttons["iOS 17"].tap() + self.app.buttons["Shared state"].tap() + self.app.buttons["Reset"].tap() + self.clearLogs() + // SnapshotTesting.isRecording = true + } + + func testUserDefaults() { + self.app.buttons["isAppStorageOn1"].tap() + XCTAssertEqual(self.app.staticTexts["App Storage #1 ✅"].exists, true) + XCTAssertEqual(self.app.staticTexts["App Storage #2 ✅"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + + self.app.buttons["isAppStorageOn2"].tap() + XCTAssertEqual(self.app.staticTexts["App Storage #1 ❌"].exists, true) + XCTAssertEqual(self.app.staticTexts["App Storage #2 ❌"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + + self.app.buttons["Write directly to user defaults"].tap() + XCTAssertEqual(self.app.staticTexts["App Storage #1 ✅"].exists, true) + XCTAssertEqual(self.app.staticTexts["App Storage #2 ✅"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + + self.app.buttons["Delete user default"].tap() + XCTAssertEqual(self.app.staticTexts["App Storage #1 ❌"].exists, true) + XCTAssertEqual(self.app.staticTexts["App Storage #2 ❌"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + ObservableSharedStateView.body + """ + } + } + + func testFileStorage() { + self.app.buttons["isFileStorageOn1"].tap() + XCTAssertEqual(self.app.staticTexts["File Storage #1 ✅"].exists, true) + XCTAssertEqual(self.app.staticTexts["File Storage #2 ✅"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + + self.app.buttons["isFileStorageOn2"].tap() + XCTAssertEqual(self.app.staticTexts["File Storage #1 ❌"].exists, true) + XCTAssertEqual(self.app.staticTexts["File Storage #2 ❌"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + + self.app.buttons["Write directly to file system"].tap() + XCTAssertEqual(self.app.staticTexts["File Storage #1 ✅"].exists, true) + XCTAssertEqual(self.app.staticTexts["File Storage #2 ✅"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + + self.app.buttons["Delete file"].tap() + XCTAssertEqual(self.app.staticTexts["File Storage #1 ❌"].exists, true) + XCTAssertEqual(self.app.staticTexts["File Storage #2 ❌"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + } + + func testInMemory() { + self.app.buttons["isInMemoryOn1"].tap() + XCTAssertEqual(self.app.staticTexts["In-memory Storage #1 ✅"].exists, true) + XCTAssertEqual(self.app.staticTexts["In-memory Storage #2 ✅"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + + self.app.buttons["isInMemoryOn2"].tap() + XCTAssertEqual(self.app.staticTexts["In-memory Storage #1 ❌"].exists, true) + XCTAssertEqual(self.app.staticTexts["In-memory Storage #2 ❌"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + } + + func testFileStorage_DeleteFileThenMutate() { + self.app.buttons["Delete file"].tap() + self.clearLogs() + + self.app.buttons["Write directly to file system"].tap() + XCTAssertEqual(self.app.staticTexts["File Storage #1 ✅"].exists, true) + XCTAssertEqual(self.app.staticTexts["File Storage #2 ✅"].exists, true) + self.assertLogs { + """ + ObservableSharedStateView.body + """ + } + } +} diff --git a/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj b/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj index c98b747e58e9..d0f63ad3b344 100644 --- a/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj +++ b/Examples/SyncUps/SyncUps.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ DC808D7729E9C3AD0072B4A9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC808D7629E9C3AD0072B4A9 /* Assets.xcassets */; }; DC808D8429E9C3AD0072B4A9 /* SyncUpFormTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D8329E9C3AD0072B4A9 /* SyncUpFormTests.swift */; }; DC808D8E29E9C3AD0072B4A9 /* SyncUpsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D8D29E9C3AD0072B4A9 /* SyncUpsUITests.swift */; }; - DC808DA029E9C4340072B4A9 /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D9F29E9C4340072B4A9 /* DataManager.swift */; }; DC808DA329E9C4490072B4A9 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC808DA229E9C4490072B4A9 /* ComposableArchitecture */; }; DC808DA529E9C4C70072B4A9 /* OpenSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DA429E9C4C70072B4A9 /* OpenSettings.swift */; }; DC808DA729E9C4D60072B4A9 /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DA629E9C4D60072B4A9 /* SpeechRecognizer.swift */; }; @@ -59,7 +58,6 @@ DC808D8929E9C3AD0072B4A9 /* SyncUpsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DC808D8D29E9C3AD0072B4A9 /* SyncUpsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpsUITests.swift; sourceTree = ""; }; DC808D9C29E9C3C10072B4A9 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; - DC808D9F29E9C4340072B4A9 /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; DC808DA429E9C4C70072B4A9 /* OpenSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettings.swift; sourceTree = ""; }; DC808DA629E9C4D60072B4A9 /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; DC808DA829E9C5090072B4A9 /* SyncUpForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpForm.swift; sourceTree = ""; }; @@ -175,7 +173,6 @@ DC808D9E29E9C4040072B4A9 /* Dependencies */ = { isa = PBXGroup; children = ( - DC808D9F29E9C4340072B4A9 /* DataManager.swift */, DC808DA429E9C4C70072B4A9 /* OpenSettings.swift */, DC808DA629E9C4D60072B4A9 /* SpeechRecognizer.swift */, ); @@ -334,7 +331,6 @@ DC808DA529E9C4C70072B4A9 /* OpenSettings.swift in Sources */, DC808DAD29E9C52A0072B4A9 /* Models.swift in Sources */, DC808DB129E9C54A0072B4A9 /* SyncUpDetail.swift in Sources */, - DC808DA029E9C4340072B4A9 /* DataManager.swift in Sources */, DC808D7329E9C3AC0072B4A9 /* App.swift in Sources */, DC808DB329E9C5540072B4A9 /* SyncUpsList.swift in Sources */, DC808DAF29E9C53E0072B4A9 /* RecordMeeting.swift in Sources */, diff --git a/Examples/SyncUps/SyncUps/App.swift b/Examples/SyncUps/SyncUps/App.swift index b71f1d941932..ada0777ad800 100644 --- a/Examples/SyncUps/SyncUps/App.swift +++ b/Examples/SyncUps/SyncUps/App.swift @@ -3,26 +3,25 @@ import SwiftUI @main struct SyncUpsApp: App { - let store = Store(initialState: AppFeature.State()) { + // NB: This is static to avoid interference with Xcode previews, which create this entry + // point each time they are run. + @MainActor + static let store = Store(initialState: AppFeature.State()) { AppFeature() ._printChanges() } withDependencies: { if ProcessInfo.processInfo.environment["UITesting"] == "true" { - $0.dataManager = .mock() + $0.defaultFileStorage = .inMemory } } 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 _XCTIsTesting { - // NB: Don't run application when testing so that it doesn't interfere with tests. + // NB: Don't run application in tests to avoid interference between the app and the test. EmptyView() } else { - AppView(store: store) + AppView(store: Self.store) } } } diff --git a/Examples/SyncUps/SyncUps/AppFeature.swift b/Examples/SyncUps/SyncUps/AppFeature.swift index ce8ac1042fe6..d9d70dc37775 100644 --- a/Examples/SyncUps/SyncUps/AppFeature.swift +++ b/Examples/SyncUps/SyncUps/AppFeature.swift @@ -21,15 +21,9 @@ struct AppFeature { case syncUpsList(SyncUpsList.Action) } - @Dependency(\.continuousClock) var clock @Dependency(\.date.now) var now - @Dependency(\.dataManager.save) var saveData @Dependency(\.uuid) var uuid - private enum CancelID { - case saveDebounce - } - var body: some ReducerOf { Scope(state: \.syncUpsList, action: \.syncUpsList) { SyncUpsList() @@ -37,47 +31,9 @@ struct AppFeature { Reduce { state, action in switch action { case let .path(.element(id, .detail(.delegate(delegateAction)))): - guard case let .some(.detail(detailState)) = state.path[id: id] - else { return .none } - - switch delegateAction { - case .deleteSyncUp: - state.syncUpsList.syncUps.remove(id: detailState.syncUp.id) - return .none - - case let .syncUpUpdated(syncUp): - state.syncUpsList.syncUps[id: syncUp.id] = syncUp - return .none - - case .startMeeting: - state.path.append(.record(RecordMeeting.State(syncUp: detailState.syncUp))) - return .none - } - - case let .path(.element(_, .record(.delegate(delegateAction)))): switch delegateAction { - case let .save(transcript: transcript): - guard let id = state.path.ids.dropLast().last - else { - XCTFail( - """ - Record meeting is the only element in the stack. A detail feature should precede it. - """ - ) - return .none - } - - state.path[id: id]?.detail?.syncUp.meetings.insert( - Meeting( - id: Meeting.ID(self.uuid()), - date: self.now, - transcript: transcript - ), - at: 0 - ) - guard let syncUp = state.path[id: id]?.detail?.syncUp - else { return .none } - state.syncUpsList.syncUps[id: syncUp.id] = syncUp + case let .startMeeting(sharedSyncUp): + state.path.append(.record(RecordMeeting.State(syncUp: sharedSyncUp))) return .none } @@ -89,16 +45,6 @@ struct AppFeature { } } .forEach(\.path, action: \.path) - - Reduce { state, action in - return .run { [syncUps = state.syncUpsList.syncUps] _ in - try await withTaskCancellation(id: CancelID.saveDebounce, cancelInFlight: true) { - try await self.clock.sleep(for: .seconds(1)) - try await self.saveData(JSONEncoder().encode(syncUps), .syncUps) - } - } catch: { _, _ in - } - } } } @@ -123,6 +69,15 @@ struct AppView: View { } } -extension URL { - static let syncUps = Self.documentsDirectory.appending(component: "sync-ups.json") +#Preview { + @Shared(.syncUps) var syncUps = [ + .mock, + .productMock, + .engineeringMock + ] + return AppView( + store: Store(initialState: AppFeature.State()) { + AppFeature() + } + ) } diff --git a/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift b/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift deleted file mode 100644 index 68ea0b0cc45d..000000000000 --- a/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift +++ /dev/null @@ -1,57 +0,0 @@ -import ComposableArchitecture -import Foundation - -@DependencyClient -struct DataManager: Sendable { - var load: @Sendable (_ from: URL) throws -> Data - var save: @Sendable (Data, _ to: URL) async throws -> Void -} - -extension DataManager: DependencyKey { - static let liveValue = Self( - load: { url in try Data(contentsOf: url) }, - save: { data, url in try data.write(to: url) } - ) - - static let testValue = Self() -} - -extension DependencyValues { - var dataManager: DataManager { - get { self[DataManager.self] } - set { self[DataManager.self] = newValue } - } -} - -extension DataManager { - static func mock(initialData: Data? = nil) -> Self { - let data = LockIsolated(initialData) - return Self( - load: { _ in - guard let data = data.value - else { - struct FileNotFound: Error {} - throw FileNotFound() - } - return data - }, - save: { newData, _ in data.setValue(newData) } - ) - } - - static let failToWrite = Self( - load: { _ in Data() }, - save: { _, _ in - struct SaveError: Error {} - throw SaveError() - } - ) - - static let failToLoad = Self( - load: { _ in - struct LoadError: Error {} - throw LoadError() - }, - save: { _, _ in } - ) -} diff --git a/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift b/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift index f7fe525b9040..a13bb5252d72 100644 --- a/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift +++ b/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift @@ -172,17 +172,17 @@ private actor Speech { recognitionTask?.finish() } - self.audioEngine?.inputNode.installTap( + audioEngine?.inputNode.installTap( onBus: 0, bufferSize: 1024, - format: self.audioEngine?.inputNode.outputFormat(forBus: 0) + format: audioEngine?.inputNode.outputFormat(forBus: 0) ) { buffer, when in request.append(buffer) } - self.audioEngine?.prepare() + audioEngine?.prepare() do { - try self.audioEngine?.start() + try audioEngine?.start() } catch { continuation.finish(throwing: error) return diff --git a/Examples/SyncUps/SyncUps/Meeting.swift b/Examples/SyncUps/SyncUps/Meeting.swift index caacc60976c3..d6ac16babb62 100644 --- a/Examples/SyncUps/SyncUps/Meeting.swift +++ b/Examples/SyncUps/SyncUps/Meeting.swift @@ -1,4 +1,3 @@ -import ComposableArchitecture import SwiftUI struct MeetingView: View { @@ -6,22 +5,24 @@ struct MeetingView: View { let syncUp: SyncUp var body: some View { - ScrollView { - VStack(alignment: .leading) { - Divider() - .padding(.bottom) - Text("Attendees") - .font(.headline) - ForEach(self.syncUp.attendees) { attendee in + Form { + Section { + ForEach(syncUp.attendees) { attendee in Text(attendee.name) } + } header: { + Text("Attendees") + } + Section { + Text(meeting.transcript) + } header: { Text("Transcript") - .font(.headline) - .padding(.top) - Text(self.meeting.transcript) } } - .navigationTitle(Text(self.meeting.date, style: .date)) - .padding() + .navigationTitle(Text(meeting.date, style: .date)) } } + +#Preview { + MeetingView(meeting: SyncUp.mock.meetings[0], syncUp: .mock) +} diff --git a/Examples/SyncUps/SyncUps/Models.swift b/Examples/SyncUps/SyncUps/Models.swift index ee3e724d7ae1..dc8bfa60ab13 100644 --- a/Examples/SyncUps/SyncUps/Models.swift +++ b/Examples/SyncUps/SyncUps/Models.swift @@ -11,7 +11,7 @@ struct SyncUp: Equatable, Identifiable, Codable { var title = "" var durationPerAttendee: Duration { - self.duration / self.attendees.count + duration / attendees.count } } @@ -56,9 +56,9 @@ enum Theme: String, CaseIterable, Equatable, Identifiable, Codable { } } - var mainColor: Color { Color(self.rawValue) } + var mainColor: Color { Color(rawValue) } - var name: String { self.rawValue.capitalized } + var name: String { rawValue.capitalized } } extension SyncUp { @@ -102,7 +102,7 @@ extension SyncUp { title: "Engineering" ) - static let designMock = Self( + static let productMock = Self( id: SyncUp.ID(), attendees: [ Attendee(id: Attendee.ID(), name: "Blob Sr"), diff --git a/Examples/SyncUps/SyncUps/RecordMeeting.swift b/Examples/SyncUps/SyncUps/RecordMeeting.swift index eb9f131459bd..c2765744ef4d 100644 --- a/Examples/SyncUps/SyncUps/RecordMeeting.swift +++ b/Examples/SyncUps/SyncUps/RecordMeeting.swift @@ -9,17 +9,16 @@ struct RecordMeeting { @Presents var alert: AlertState? var secondsElapsed = 0 var speakerIndex = 0 - var syncUp: SyncUp + @Shared var syncUp: SyncUp var transcript = "" var durationRemaining: Duration { - self.syncUp.duration - .seconds(self.secondsElapsed) + syncUp.duration - .seconds(secondsElapsed) } } enum Action { case alert(PresentationAction) - case delegate(Delegate) case endMeetingButtonTapped case nextButtonTapped case onTask @@ -32,10 +31,6 @@ struct RecordMeeting { case confirmDiscard case confirmSave } - @CasePathable - enum Delegate { - case save(transcript: String) - } } @Dependency(\.continuousClock) var clock @@ -46,22 +41,15 @@ struct RecordMeeting { Reduce { state, action in switch action { case .alert(.presented(.confirmDiscard)): - return .run { _ in - await self.dismiss() - } + return .run { _ in await dismiss() } case .alert(.presented(.confirmSave)): - return .run { [transcript = state.transcript] send in - await send(.delegate(.save(transcript: transcript))) - await self.dismiss() - } + state.syncUp.insert(transcript: state.transcript) + return .run { _ in await dismiss() } case .alert: return .none - case .delegate: - return .none - case .endMeetingButtonTapped: state.alert = .endMeeting(isDiscardable: true) return .none @@ -80,18 +68,18 @@ struct RecordMeeting { case .onTask: return .run { send in let authorization = - await self.speechClient.authorizationStatus() == .notDetermined - ? self.speechClient.requestAuthorization() - : self.speechClient.authorizationStatus() + await speechClient.authorizationStatus() == .notDetermined + ? speechClient.requestAuthorization() + : speechClient.authorizationStatus() await withTaskGroup(of: Void.self) { group in if authorization == .authorized { group.addTask { - await self.startSpeechRecognition(send: send) + await startSpeechRecognition(send: send) } } group.addTask { - await self.startTimer(send: send) + await startTimer(send: send) } } } @@ -105,10 +93,8 @@ struct RecordMeeting { let secondsPerAttendee = Int(state.syncUp.durationPerAttendee.components.seconds) if state.secondsElapsed.isMultiple(of: secondsPerAttendee) { if state.secondsElapsed == state.syncUp.duration.components.seconds { - return .run { [transcript = state.transcript] send in - await send(.delegate(.save(transcript: transcript))) - await self.dismiss() - } + state.syncUp.insert(transcript: state.transcript) + return .run { _ in await dismiss() } } state.speakerIndex += 1 } @@ -132,7 +118,7 @@ struct RecordMeeting { private func startSpeechRecognition(send: Send) async { do { - let speechTask = await self.speechClient.startTask(SFSpeechAudioBufferRecognitionRequest()) + let speechTask = await speechClient.startTask(SFSpeechAudioBufferRecognitionRequest()) for try await result in speechTask { await send(.speechResult(result)) } @@ -142,12 +128,27 @@ struct RecordMeeting { } private func startTimer(send: Send) async { - for await _ in self.clock.timer(interval: .seconds(1)) { + for await _ in clock.timer(interval: .seconds(1)) { await send(.timerTick) } } } +extension SyncUp { + fileprivate mutating func insert(transcript: String) { + @Dependency(\.date.now) var now + @Dependency(\.uuid) var uuid + meetings.insert( + Meeting( + id: Meeting.ID(uuid()), + date: now, + transcript: transcript + ), + at: 0 + ) + } +} + struct RecordMeetingView: View { @Bindable var store: StoreOf @@ -238,14 +239,14 @@ struct MeetingHeaderView: View { var body: some View { VStack { - ProgressView(value: self.progress) - .progressViewStyle(MeetingProgressViewStyle(theme: self.theme)) + ProgressView(value: progress) + .progressViewStyle(MeetingProgressViewStyle(theme: theme)) HStack { VStack(alignment: .leading) { Text("Time Elapsed") .font(.caption) Label( - Duration.seconds(self.secondsElapsed).formatted(.units()), + Duration.seconds(secondsElapsed).formatted(.units()), systemImage: "hourglass.bottomhalf.fill" ) } @@ -253,7 +254,7 @@ struct MeetingHeaderView: View { VStack(alignment: .trailing) { Text("Time Remaining") .font(.caption) - Label(self.durationRemaining.formatted(.units()), systemImage: "hourglass.tophalf.fill") + Label(durationRemaining.formatted(.units()), systemImage: "hourglass.tophalf.fill") .font(.body.monospacedDigit()) .labelStyle(.trailingIcon) } @@ -263,12 +264,12 @@ struct MeetingHeaderView: View { } private var totalDuration: Duration { - .seconds(self.secondsElapsed) + self.durationRemaining + .seconds(secondsElapsed) + durationRemaining } private var progress: Double { - guard self.totalDuration > .seconds(0) else { return 0 } - return Double(self.secondsElapsed) / Double(self.totalDuration.components.seconds) + guard totalDuration > .seconds(0) else { return 0 } + return Double(secondsElapsed) / Double(totalDuration.components.seconds) } } @@ -278,11 +279,11 @@ struct MeetingProgressViewStyle: ProgressViewStyle { func makeBody(configuration: Configuration) -> some View { ZStack { RoundedRectangle(cornerRadius: 10) - .fill(self.theme.accentColor) + .fill(theme.accentColor) .frame(height: 20) ProgressView(configuration) - .tint(self.theme.mainColor) + .tint(theme.mainColor) .frame(height: 12) .padding(.horizontal) } @@ -299,8 +300,8 @@ struct MeetingTimerView: View { .overlay { VStack { Group { - if self.speakerIndex < self.syncUp.attendees.count { - Text(self.syncUp.attendees[self.speakerIndex].name) + if speakerIndex < syncUp.attendees.count { + Text(syncUp.attendees[speakerIndex].name) } else { Text("Someone") } @@ -311,14 +312,14 @@ struct MeetingTimerView: View { .font(.largeTitle) .padding(.top) } - .foregroundStyle(self.syncUp.theme.accentColor) + .foregroundStyle(syncUp.theme.accentColor) } .overlay { - ForEach(Array(self.syncUp.attendees.enumerated()), id: \.element.id) { index, attendee in - if index < self.speakerIndex + 1 { - SpeakerArc(totalSpeakers: self.syncUp.attendees.count, speakerIndex: index) + ForEach(Array(syncUp.attendees.enumerated()), id: \.element.id) { index, attendee in + if index < speakerIndex + 1 { + SpeakerArc(totalSpeakers: syncUp.attendees.count, speakerIndex: index) .rotation(Angle(degrees: -90)) - .stroke(self.syncUp.theme.mainColor, lineWidth: 12) + .stroke(syncUp.theme.mainColor, lineWidth: 12) } } } @@ -338,21 +339,21 @@ struct SpeakerArc: Shape { path.addArc( center: center, radius: radius, - startAngle: self.startAngle, - endAngle: self.endAngle, + startAngle: startAngle, + endAngle: endAngle, clockwise: false ) } } private var degreesPerSpeaker: Double { - 360 / Double(self.totalSpeakers) + 360 / Double(totalSpeakers) } private var startAngle: Angle { - Angle(degrees: self.degreesPerSpeaker * Double(self.speakerIndex) + 1) + Angle(degrees: degreesPerSpeaker * Double(speakerIndex) + 1) } private var endAngle: Angle { - Angle(degrees: self.startAngle.degrees + self.degreesPerSpeaker - 1) + Angle(degrees: startAngle.degrees + degreesPerSpeaker - 1) } } @@ -364,13 +365,13 @@ struct MeetingFooterView: View { var body: some View { VStack { HStack { - if self.speakerIndex < self.syncUp.attendees.count - 1 { - Text("Speaker \(self.speakerIndex + 1) of \(self.syncUp.attendees.count)") + if speakerIndex < syncUp.attendees.count - 1 { + Text("Speaker \(speakerIndex + 1) of \(syncUp.attendees.count)") } else { Text("No more speakers.") } Spacer() - Button(action: self.nextButtonTapped) { + Button(action: nextButtonTapped) { Image(systemName: "forward.fill") } } @@ -382,7 +383,7 @@ struct MeetingFooterView: View { #Preview { NavigationStack { RecordMeetingView( - store: Store(initialState: RecordMeeting.State(syncUp: .mock)) { + store: Store(initialState: RecordMeeting.State(syncUp: Shared(.mock))) { RecordMeeting() } ) diff --git a/Examples/SyncUps/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUps/SyncUpDetail.swift index 2307386148b0..b321ee54ac85 100644 --- a/Examples/SyncUps/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUps/SyncUpDetail.swift @@ -19,7 +19,7 @@ struct SyncUpDetail { @ObservableState struct State: Equatable { @Presents var destination: Destination.State? - var syncUp: SyncUp + @Shared var syncUp: SyncUp } enum Action: Sendable { @@ -34,9 +34,7 @@ struct SyncUpDetail { @CasePathable enum Delegate { - case deleteSyncUp - case syncUpUpdated(SyncUp) - case startMeeting + case startMeeting(Shared) } } @@ -65,16 +63,15 @@ struct SyncUpDetail { case let .destination(.presented(.alert(alertAction))): switch alertAction { case .confirmDeletion: - return .run { send in - await send(.delegate(.deleteSyncUp), animation: .default) - await self.dismiss() - } + @Shared(.syncUps) var syncUps + syncUps.remove(id: state.syncUp.id) + return .run { _ in await dismiss() } + case .continueWithoutRecording: - return .send(.delegate(.startMeeting)) + return .send(.delegate(.startMeeting(state.$syncUp))) + case .openSettings: - return .run { _ in - await self.openSettings() - } + return .run { _ in await openSettings() } } case .destination: @@ -92,9 +89,9 @@ struct SyncUpDetail { return .none case .startMeetingButtonTapped: - switch self.authorizationStatus() { + switch authorizationStatus() { case .notDetermined, .authorized: - return .send(.delegate(.startMeeting)) + return .send(.delegate(.startMeeting(state.$syncUp))) case .denied: state.destination = .alert(.speechRecognitionDenied) @@ -110,11 +107,6 @@ struct SyncUpDetail { } } .ifLet(\.$destination, action: \.destination) - .onChange(of: \.syncUp) { oldValue, newValue in - Reduce { state, action in - .send(.delegate(.syncUpUpdated(newValue))) - } - } } } @@ -194,19 +186,21 @@ struct SyncUpDetailView: View { } .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 + .sheet( + item: $store.scope(state: \.destination?.edit, action: \.destination.edit) + ) { editSyncUpStore in NavigationStack { - SyncUpFormView(store: store) - .navigationTitle(self.store.syncUp.title) + SyncUpFormView(store: editSyncUpStore) + .navigationTitle(store.syncUp.title) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - self.store.send(.cancelEditButtonTapped) + store.send(.cancelEditButtonTapped) } } ToolbarItem(placement: .confirmationAction) { Button("Done") { - self.store.send(.doneEditingButtonTapped) + store.send(.doneEditingButtonTapped) } } } @@ -271,7 +265,7 @@ extension AlertState where Action == SyncUpDetail.Destination.Alert { #Preview { NavigationStack { SyncUpDetailView( - store: Store(initialState: SyncUpDetail.State(syncUp: .mock)) { + store: Store(initialState: SyncUpDetail.State(syncUp: Shared(.mock))) { SyncUpDetail() } ) diff --git a/Examples/SyncUps/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUps/SyncUpForm.swift index 8a09b0a10e68..4e5e25e8d2ab 100644 --- a/Examples/SyncUps/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUps/SyncUpForm.swift @@ -37,7 +37,7 @@ struct SyncUpForm { Reduce { state, action in switch action { case .addAttendeeButtonTapped: - let attendee = Attendee(id: Attendee.ID(self.uuid())) + let attendee = Attendee(id: Attendee.ID(uuid())) state.syncUp.attendees.append(attendee) state.focus = .attendee(attendee.id) return .none @@ -48,7 +48,7 @@ struct SyncUpForm { case let .deleteAttendees(atOffsets: indices): state.syncUp.attendees.remove(atOffsets: indices) if state.syncUp.attendees.isEmpty { - state.syncUp.attendees.append(Attendee(id: Attendee.ID(self.uuid()))) + state.syncUp.attendees.append(Attendee(id: Attendee.ID(uuid()))) } guard let firstIndex = indices.first else { return .none } @@ -104,7 +104,7 @@ struct ThemePicker: View { @Binding var selection: Theme var body: some View { - Picker("Theme", selection: self.$selection) { + Picker("Theme", selection: $selection) { ForEach(Theme.allCases) { theme in ZStack { RoundedRectangle(cornerRadius: 4) @@ -122,7 +122,7 @@ struct ThemePicker: View { extension Duration { fileprivate var minutes: Double { - get { Double(self.components.seconds / 60) } + get { Double(components.seconds / 60) } set { self = .seconds(newValue * 60) } } } diff --git a/Examples/SyncUps/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUps/SyncUpsList.swift index af755dc6b11b..df9da6dd8e44 100644 --- a/Examples/SyncUps/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUps/SyncUpsList.swift @@ -17,19 +17,7 @@ struct SyncUpsList { @ObservableState struct State: Equatable { @Presents var destination: Destination.State? - var syncUps: IdentifiedArrayOf = [] - - init(destination: Destination.State? = nil) { - self.destination = destination - - do { - @Dependency(\.dataManager.load) var load - self.syncUps = try JSONDecoder().decode(IdentifiedArray.self, from: load(.syncUps)) - } catch is DecodingError { - self.destination = .alert(.dataFailedToLoad) - } catch { - } - } + @Shared(.syncUps) var syncUps } enum Action { @@ -40,14 +28,17 @@ struct SyncUpsList { case onDelete(IndexSet) } - @Dependency(\.continuousClock) var clock @Dependency(\.uuid) var uuid var body: some ReducerOf { Reduce { state, action in switch action { case .addSyncUpButtonTapped: - state.destination = .add(SyncUpForm.State(syncUp: SyncUp(id: SyncUp.ID(self.uuid())))) + state.destination = .add( + SyncUpForm.State( + syncUp: SyncUp(id: SyncUp.ID(uuid())) + ) + ) return .none case .confirmAddSyncUpButtonTapped: @@ -60,21 +51,13 @@ struct SyncUpsList { if syncUp.attendees.isEmpty { syncUp.attendees.append( editState.syncUp.attendees.first - ?? Attendee(id: Attendee.ID(self.uuid())) + ?? Attendee(id: Attendee.ID(uuid())) ) } state.syncUps.append(syncUp) state.destination = nil return .none - case .destination(.presented(.alert(.confirmLoadMockData))): - state.syncUps = [ - .mock, - .designMock, - .engineeringMock, - ] - return .none - case .destination: return .none @@ -96,10 +79,8 @@ struct SyncUpsListView: View { var body: some View { List { - ForEach(store.syncUps) { syncUp in - NavigationLink( - state: AppFeature.Path.State.detail(SyncUpDetail.State(syncUp: syncUp)) - ) { + ForEach(store.$syncUps.elements) { $syncUp in + NavigationLink(state: AppFeature.Path.State.detail(SyncUpDetail.State(syncUp: $syncUp))) { CardView(syncUp: syncUp) } .listRowBackground(syncUp.theme.mainColor) @@ -116,20 +97,21 @@ struct SyncUpsListView: View { } } .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 + .sheet( + item: $store.scope(state: \.destination?.add, action: \.destination.add) + ) { addSyncUpStore in NavigationStack { - SyncUpFormView(store: store) + SyncUpFormView(store: addSyncUpStore) .navigationTitle("New sync-up") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Dismiss") { - self.store.send(.dismissAddSyncUpButtonTapped) + store.send(.dismissAddSyncUpButtonTapped) } } ToolbarItem(placement: .confirmationAction) { Button("Add") { - self.store.send(.confirmAddSyncUpButtonTapped) + store.send(.confirmAddSyncUpButtonTapped) } } } @@ -138,44 +120,24 @@ struct SyncUpsListView: View { } } -extension AlertState where Action == SyncUpsList.Destination.Alert { - static let dataFailedToLoad = Self { - TextState("Data failed to load") - } actions: { - ButtonState(action: .send(.confirmLoadMockData, animation: .default)) { - TextState("Yes") - } - ButtonState(role: .cancel) { - TextState("No") - } - } message: { - TextState( - """ - Unfortunately your past data failed to load. Would you like to load some mock data to play \ - around with? - """ - ) - } -} - struct CardView: View { let syncUp: SyncUp var body: some View { VStack(alignment: .leading) { - Text(self.syncUp.title) + Text(syncUp.title) .font(.headline) Spacer() HStack { - Label("\(self.syncUp.attendees.count)", systemImage: "person.3") + Label("\(syncUp.attendees.count)", systemImage: "person.3") Spacer() - Label(self.syncUp.duration.formatted(.units()), systemImage: "clock") + Label(syncUp.duration.formatted(.units()), systemImage: "clock") .labelStyle(.trailingIcon) } .font(.caption) } .padding() - .foregroundColor(self.syncUp.theme.accentColor) + .foregroundColor(syncUp.theme.accentColor) } } @@ -192,42 +154,37 @@ extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } } -#Preview { - SyncUpsListView( - store: Store(initialState: SyncUpsList.State()) { - SyncUpsList() - } withDependencies: { - $0.dataManager.load = { @Sendable _ in - try JSONEncoder().encode([ - SyncUp.mock, - .designMock, - .engineeringMock, - ]) +#Preview("List") { + @Shared(.syncUps) var syncUps = [ + .mock, + .productMock, + .engineeringMock + ] + return NavigationStack { + SyncUpsListView( + store: Store(initialState: SyncUpsList.State()) { + SyncUpsList() } - } - ) -} - -#Preview("Load data failure") { - SyncUpsListView( - store: Store(initialState: SyncUpsList.State()) { - SyncUpsList() - } withDependencies: { - $0.dataManager = .mock(initialData: Data("!@#$% bad data ^&*()".utf8)) - } - ) - .previewDisplayName("Load data failure") + ) + } } #Preview("Card") { CardView( syncUp: SyncUp( id: SyncUp.ID(), - attendees: [], duration: .seconds(60), - meetings: [], - theme: .bubblegum, title: "Point-Free Morning Sync" ) ) } + +extension PersistenceReaderKey +where Self == PersistenceKeyDefault>> { + static var syncUps: Self { + PersistenceKeyDefault( + .fileStorage(.documentsDirectory.appending(component: "sync-ups.json")), + [] + ) + } +} diff --git a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift index cf07cfbd0f35..054c5a9a57d3 100644 --- a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift +++ b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift @@ -4,59 +4,18 @@ import XCTest @testable import SyncUps final class AppFeatureTests: XCTestCase { - @MainActor - func testDelete() async throws { - let syncUp = SyncUp.mock - - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([syncUp]) - ) - } - - await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: syncUp)))) { - $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: syncUp)) - } - - await store.send(\.path[id:0].detail.deleteButtonTapped) { - $0.path[id: 0]?.detail?.destination = .alert(.deleteSyncUp) - } - - await store.send(\.path[id:0].detail.destination.alert.confirmDeletion) { - $0.path[id: 0]?.detail?.destination = nil - } - - await store.receive(\.path[id:0].detail.delegate.deleteSyncUp) { - $0.syncUpsList.syncUps = [] - } - await store.receive(\.path.popFrom) { - $0.path = StackState() - } - } - @MainActor func testDetailEdit() async throws { var syncUp = SyncUp.mock - let savedData = LockIsolated(Data?.none) - + @Shared(.syncUps) var syncUps = [syncUp] let store = TestStore(initialState: AppFeature.State()) { AppFeature() - } withDependencies: { dependencies in - dependencies.continuousClock = ImmediateClock() - dependencies.dataManager = .mock( - initialData: try! JSONEncoder().encode([syncUp]) - ) - dependencies.dataManager.save = { @Sendable [dependencies] data, url in - savedData.setValue(data) - try await dependencies.dataManager.save(data, to: url) - } } - await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: syncUp)))) { - $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: syncUp)) + let sharedSyncUp = try XCTUnwrap($syncUps[id: syncUp.id]) + + await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: sharedSyncUp)))) { + $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: sharedSyncUp)) } await store.send(\.path[id:0].detail.editButtonTapped) { @@ -74,17 +33,35 @@ final class AppFeatureTests: XCTestCase { $0.path[id: 0]?.detail?.destination = nil $0.path[id: 0]?.detail?.syncUp.title = "Blob" } + .finish() + } - await store.receive(\.path[id:0].detail.delegate.syncUpUpdated) { - $0.syncUpsList.syncUps[0].title = "Blob" + @MainActor + func testDelete() async throws { + let syncUp = SyncUp.mock + @Shared(.syncUps) var syncUps = [syncUp] + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() } - var savedSyncUp = syncUp - savedSyncUp.title = "Blob" - XCTAssertNoDifference( - try JSONDecoder().decode([SyncUp].self, from: savedData.value!), - [savedSyncUp] - ) + let sharedSyncUp = try XCTUnwrap($syncUps[id: syncUp.id]) + + await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: sharedSyncUp)))) { + $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: sharedSyncUp)) + } + + await store.send(\.path[id:0].detail.deleteButtonTapped) { + $0.path[id: 0]?.detail?.destination = .alert(.deleteSyncUp) + } + + await store.send(\.path[id:0].detail.destination.alert.confirmDeletion) { + $0.path[id: 0, case: \.detail]?.destination = nil + $0.syncUpsList.syncUps = [] + } + + await store.receive(\.path.popFrom) { + $0.path = StackState() + } } @MainActor @@ -103,17 +80,17 @@ final class AppFeatureTests: XCTestCase { duration: .seconds(6) ) + let sharedSyncUp = Shared(syncUp) let store = TestStore( initialState: AppFeature.State( path: StackState([ - .detail(SyncUpDetail.State(syncUp: syncUp)), - .record(RecordMeeting.State(syncUp: syncUp)), + .detail(SyncUpDetail.State(syncUp: sharedSyncUp)), + .record(RecordMeeting.State(syncUp: sharedSyncUp)), ]) ) ) { AppFeature() } withDependencies: { - $0.dataManager = .mock(initialData: try! JSONEncoder().encode([syncUp])) $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) $0.continuousClock = ImmediateClock() $0.speechClient.authorizationStatus = { .authorized } @@ -128,7 +105,10 @@ final class AppFeatureTests: XCTestCase { store.exhaustivity = .off await store.send(\.path[id:1].record.onTask) - await store.receive(\.path[id:1].record.delegate.save) { + await store.receive(\.path.popFrom) { + XCTAssertEqual($0.path.count, 1) + } + store.assert { $0.path[id: 0]?.detail?.syncUp.meetings = [ Meeting( id: Meeting.ID(UUID(0)), @@ -137,8 +117,5 @@ final class AppFeatureTests: XCTestCase { ) ] } - await store.receive(\.path.popFrom) { - XCTAssertEqual($0.path.count, 1) - } } } diff --git a/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift b/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift index 2b63f42266c4..e82478dd1057 100644 --- a/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift +++ b/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift @@ -11,22 +11,28 @@ final class RecordMeetingTests: XCTestCase { let store = TestStore( initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) ) ) ) { RecordMeeting() } withDependencies: { $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.date.now = Date(timeIntervalSince1970: 1234567890) + $0.dismiss = DismissEffect { + dismissed.fulfill() + } $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } let onTask = await store.send(.onTask) @@ -70,12 +76,17 @@ final class RecordMeetingTests: XCTestCase { await store.receive(\.timerTick) { $0.speakerIndex = 2 $0.secondsElapsed = 6 + $0.syncUp.meetings.insert( + Meeting( + id: Meeting.ID(UUID(0)), + date: Date(timeIntervalSince1970: 1234567890), + transcript: "" + ), + at: 0 + ) XCTAssertEqual($0.durationRemaining, .seconds(0)) } - // NB: this improves on the onMeetingFinished pattern from vanilla SwiftUI - await store.receive(\.delegate.save) - #if swift(>=5.10) nonisolated(unsafe) let `self` = self #endif @@ -90,20 +101,23 @@ final class RecordMeetingTests: XCTestCase { let store = TestStore( initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) ) ) ) { RecordMeeting() } withDependencies: { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.dismiss = DismissEffect { dismissed.fulfill() } $0.speechClient.authorizationStatus = { .authorized } $0.speechClient.startTask = { @Sendable _ in @@ -117,6 +131,7 @@ final class RecordMeetingTests: XCTestCase { continuation.finish() } } + $0.uuid = .incrementing } let onTask = await store.send(.onTask) @@ -135,7 +150,7 @@ final class RecordMeetingTests: XCTestCase { await store.receive(\.timerTick) } - await store.receive(\.delegate.save) + XCTAssertEqual(store.state.syncUp.meetings[0].transcript, "I completed the project") #if swift(>=5.10) nonisolated(unsafe) let `self` = self @@ -149,12 +164,14 @@ final class RecordMeetingTests: XCTestCase { let clock = TestClock() let dismissed = self.expectation(description: "dismissed") - let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { + let store = TestStore(initialState: RecordMeeting.State(syncUp: Shared(.mock))) { RecordMeeting() } withDependencies: { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.dismiss = DismissEffect { dismissed.fulfill() } $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } let onTask = await store.send(.onTask) @@ -170,10 +187,16 @@ final class RecordMeetingTests: XCTestCase { await store.send(\.alert.confirmSave) { $0.alert = nil + $0.syncUp.meetings.insert( + Meeting( + id: Meeting.ID(UUID(0)), + date: Date(timeIntervalSince1970: 1234567890), + transcript: "" + ), + at: 0 + ) } - await store.receive(\.delegate.save) - #if swift(>=5.10) nonisolated(unsafe) let `self` = self #endif @@ -186,7 +209,7 @@ final class RecordMeetingTests: XCTestCase { let clock = TestClock() let dismissed = self.expectation(description: "dismissed") - let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { + let store = TestStore(initialState: RecordMeeting.State(syncUp: Shared(.mock))) { RecordMeeting() } withDependencies: { $0.continuousClock = clock @@ -218,22 +241,26 @@ final class RecordMeetingTests: XCTestCase { let store = TestStore( initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) ) ) ) { RecordMeeting() } withDependencies: { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.dismiss = DismissEffect { dismissed.fulfill() } $0.speechClient.authorizationStatus = { .denied } + $0.uuid = .incrementing } let onTask = await store.send(.onTask) @@ -254,9 +281,16 @@ final class RecordMeetingTests: XCTestCase { await store.send(\.alert.confirmSave) { $0.alert = nil + $0.syncUp.meetings.insert( + Meeting( + id: Meeting.ID(UUID(0)), + date: Date(timeIntervalSince1970: 1234567890), + transcript: "" + ), + at: 0 + ) } - await store.receive(\.delegate.save) #if swift(>=5.10) nonisolated(unsafe) let `self` = self #endif @@ -271,20 +305,23 @@ final class RecordMeetingTests: XCTestCase { let store = TestStore( initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) + syncUp: Shared( + SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) ) ) ) { RecordMeeting() } withDependencies: { $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.dismiss = DismissEffect { dismissed.fulfill() } $0.speechClient.authorizationStatus = { .authorized } $0.speechClient.startTask = { @Sendable _ in @@ -299,6 +336,7 @@ final class RecordMeetingTests: XCTestCase { $0.finish(throwing: SpeechRecognitionFailure()) } } + $0.uuid = .incrementing } let onTask = await store.send(.onTask) @@ -327,7 +365,6 @@ final class RecordMeetingTests: XCTestCase { await store.receive(\.timerTick) store.exhaustivity = .on - await store.receive(\.delegate.save) #if swift(>=5.10) nonisolated(unsafe) let `self` = self #endif @@ -340,7 +377,7 @@ final class RecordMeetingTests: XCTestCase { let clock = TestClock() let dismissed = self.expectation(description: "dismissed") - let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { + let store = TestStore(initialState: RecordMeeting.State(syncUp: Shared(.mock))) { RecordMeeting() } withDependencies: { $0.continuousClock = clock diff --git a/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift b/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift index a237d0269d50..dec28dbe2a8e 100644 --- a/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift +++ b/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift @@ -6,7 +6,7 @@ import XCTest final class SyncUpDetailTests: XCTestCase { @MainActor func testSpeechRestricted() async { - let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { + let store = TestStore(initialState: SyncUpDetail.State(syncUp: Shared(.mock))) { SyncUpDetail() } withDependencies: { $0.speechClient.authorizationStatus = { .restricted } @@ -19,7 +19,7 @@ final class SyncUpDetailTests: XCTestCase { @MainActor func testSpeechDenied() async throws { - let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { + let store = TestStore(initialState: SyncUpDetail.State(syncUp: Shared(.mock))) { SyncUpDetail() } withDependencies: { $0.speechClient.authorizationStatus = { @@ -39,7 +39,7 @@ final class SyncUpDetailTests: XCTestCase { let store = TestStore( initialState: SyncUpDetail.State( destination: .alert(.speechRecognitionDenied), - syncUp: .mock + syncUp: Shared(.mock) ) ) { SyncUpDetail() @@ -59,7 +59,7 @@ final class SyncUpDetailTests: XCTestCase { let store = TestStore( initialState: SyncUpDetail.State( destination: .alert(.speechRecognitionDenied), - syncUp: .mock + syncUp: Shared(.mock) ) ) { SyncUpDetail() @@ -76,7 +76,7 @@ final class SyncUpDetailTests: XCTestCase { @MainActor func testSpeechAuthorized() async throws { - let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { + let store = TestStore(initialState: SyncUpDetail.State(syncUp: Shared(.mock))) { SyncUpDetail() } withDependencies: { $0.speechClient.authorizationStatus = { .authorized } @@ -90,7 +90,7 @@ final class SyncUpDetailTests: XCTestCase { @MainActor func testEdit() async { var syncUp = SyncUp.mock - let store = TestStore(initialState: SyncUpDetail.State(syncUp: syncUp)) { + let store = TestStore(initialState: SyncUpDetail.State(syncUp: Shared(syncUp))) { SyncUpDetail() } withDependencies: { $0.uuid = .incrementing @@ -109,17 +109,20 @@ final class SyncUpDetailTests: XCTestCase { $0.destination = nil $0.syncUp.title = "Blob's Meeting" } - - await store.receive(\.delegate.syncUpUpdated) } @MainActor - func testDelete() async { + func testDelete() async throws { let didDismiss = LockIsolated(false) defer { XCTAssertEqual(didDismiss.value, true) } let syncUp = SyncUp.mock - let store = TestStore(initialState: SyncUpDetail.State(syncUp: syncUp)) { + @Shared(.syncUps) var syncUps = [syncUp] + // TODO: Can this exhaustively be caught? + defer { XCTAssertEqual([], syncUps) } + + let sharedSyncUp = try XCTUnwrap($syncUps[id: syncUp.id]) + let store = TestStore(initialState: SyncUpDetail.State(syncUp: sharedSyncUp)) { SyncUpDetail() } withDependencies: { $0.dismiss = DismissEffect { @@ -133,6 +136,5 @@ final class SyncUpDetailTests: XCTestCase { await store.send(\.destination.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 1cdf676a5985..86209389ab70 100644 --- a/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift +++ b/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift @@ -9,8 +9,6 @@ final class SyncUpsListTests: XCTestCase { let store = TestStore(initialState: SyncUpsList.State()) { SyncUpsList() } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() $0.uuid = .incrementing } @@ -57,8 +55,6 @@ final class SyncUpsListTests: XCTestCase { ) { SyncUpsList() } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() $0.uuid = .incrementing } @@ -75,42 +71,4 @@ final class SyncUpsListTests: XCTestCase { ] } } - - @MainActor - func testLoadingDataDecodingFailed() async throws { - let store = TestStore(initialState: SyncUpsList.State()) { - SyncUpsList() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: Data("!@#$ BAD DATA %^&*()".utf8) - ) - } - - XCTAssertEqual(store.state.destination, .alert(.dataFailedToLoad)) - - await store.send(\.destination.alert.confirmLoadMockData) { - $0.destination = nil - $0.syncUps = [ - .mock, - .designMock, - .engineeringMock, - ] - } - } - - @MainActor - func testLoadingDataFileNotFound() async throws { - let store = TestStore(initialState: SyncUpsList.State()) { - SyncUpsList() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager.load = { @Sendable _ in - struct FileNotFound: Error {} - throw FileNotFound() - } - } - - XCTAssertEqual(store.state.destination, nil) - } } diff --git a/Package.resolved b/Package.resolved index 13fcb55d8c7b..beb3805c581f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "c31b1445c4fae49e6fdb75496b895a3653f6aefc", - "version" : "1.1.5" + "revision" : "09e49dd46932adfe80ce672b4b3772d79ee6c21a", + "version" : "1.2.1" } }, { diff --git a/Package.swift b/Package.swift index e771001f6ef3..4b8193573aed 100644 --- a/Package.swift +++ b/Package.swift @@ -42,6 +42,9 @@ let package = Package( .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "SwiftUINavigationCore", package: "swiftui-navigation"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ], + resources: [ + .process("Resources/PrivacyInfo.xcprivacy") ] ), .testTarget( diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index f35572b5da18..0721e8d8a8a9 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -49,6 +49,9 @@ let package = Package( .product(name: "Perception", package: "swift-perception"), .product(name: "SwiftUINavigationCore", package: "swiftui-navigation"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ], + resources: [ + .process("Resources/PrivacyInfo.xcprivacy") ] ), .testTarget( diff --git a/README.md b/README.md index 2040b4eeefda..752ad1930886 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,7 @@ comfortable with the library: * [Dependencies][dependencies-article] * [Testing][testing-article] * [Navigation][navigation-article] +* [Sharing state][sharing-state-article] * [Performance][performance-article] * [Concurrency][concurrency-article] * [Bindings][bindings-article] @@ -715,4 +716,5 @@ This library is released under the MIT license. See [LICENSE](LICENSE) for detai [performance-article]: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/performance [concurrency-article]: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/swiftconcurrency [bindings-article]: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/bindings +[sharing-state-article]: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/sharingstate [meet-tca]: https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/meetcomposablearchitecture diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md index a788d9a4d00e..8747656f8439 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md @@ -34,7 +34,7 @@ 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: @@ -58,7 +58,7 @@ and decrement the number. To make things interesting, suppose there is also a bu 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: +feature, and it will be annotated with the [`@Reducer`]() macro: ```swift import ComposableArchitecture diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides.md index 5a4393b4cbb7..b8859fac3a4d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides.md @@ -14,6 +14,7 @@ APIs, and these guides contain tips to do so. ## Topics +- - - - diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.10.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.10.md new file mode 100644 index 000000000000..d33c9f037832 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.10.md @@ -0,0 +1,68 @@ +# Migrating to 1.10 + +Update your code to make use of the new state sharing tools in the library, such as the ``Shared`` +property wrapper, and the ``AppStorageKey`` and ``FileStorageKey`` persistence strategies. + +## Overview + +The Composable Architecture is under constant development, and we are always looking for ways to +simplify the library, and make it more powerful. This version of the library only introduced new +APIs and did not deprecate any existing APIs. + +> Important: Before following this migration guide be sure you have fully migrated to the newest +> tools of version 1.9. See for more information. + +## Sharing state + +The new tools added are concerned with allowing one to seamlessly share state with many parts of an +application that is easy to understand, and most importantly, testable. See the dedicated + article for more information on how to use these new tools. + +To share state in one feature with another feature, simply use the ``Shared`` property wrapper: + +```swift +@ObservableState +struct State { + @Shared var signUpData: SignUpData + // ... +} +``` + +This will require that `SignUpData` be passed in from the parent, and any changes made to this state +will be instantly observed by all features holding onto it. + +Further, there are persistence strategies one can employ in `@Shared`. For example, if you want any +changes of `signUpData` to be automatically persisted to the file system you can use the +``PersistenceReaderKey/fileStorage(_:)`` and specify a URL: + +```swift +@ObservableState +struct State { + @Shared(.fileStorage(URL(/* ... */) var signUpData = SignUpData() + // ... +} +``` + +Upon app launch the `signUpData` will be populated from disk, and any changes made to `signUpData` +will automatically be persisted to disk. Further, if the disk version changes, all instances of +`signUpData` in the application will automatically update. + +There is another persistence strategy for storing simple data types in user defaults, called +``PersistenceReaderKey/appStorage(_:)-4l5b``. It can refer to a value in user defaults by a string +key: + +```swift +@ObservableState +struct State { + @Shared(.appStorage("isOn")) var isOn = false + // ... +} +``` + +Similar to ``PersistenceReaderKey/fileStorage(_:)``, upon launch of the application the initial +value of `isOn` will be populated from user defaults, and any change to `isOn` will be automatically +persisted to user defaults. Further, if the user defaults value changes, all instances of `isOn` +in the application will automatically update. + +That is the basics of sharing data. Be sure to see the dedicated article +for more detailed information. diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md new file mode 100644 index 000000000000..f4483a2d4dbc --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md @@ -0,0 +1,1118 @@ +# Sharing state + +Learn techniques for sharing state throughout many parts of your application, and how to persist +data to user defaults, the file system, and other external mediums. + +## Overview + +Sharing state is the process of letting many features have access to the same data so that when any +feature makes a change to this data it is instantly visible to every other feature. Such sharing can +be really handy, but also does not play nicely with value types, which are copied rather than +shared. Because the Composable Architecture highly prefers modeling domains with value types rather +than reference types, sharing state can be tricky. + +This is why the library comes with a few tools for sharing state with many parts of your +application. There are two main kinds of shared state in the library: explicitly passed state and +persisted state. And there are 3 persistence strategies shipped with the library: +[in-memory](), +[user defaults](), and +[file storage](). You can also implement your own +persistence strategy if you want to use something other than user defaults or the file system, such +as SQLite. + +* ["Source of truth"](#Source-of-truth) +* [Explicit shared state](#Explicit-shared-state) +* [Persisted shared state](#Persisted-shared-state) + * [In-memory](#In-memory) + * [User defaults](#User-defaults) + * [File storage](#File-storage) + * [Custom persistence](#Custom-persistence) +* [Observing changes to shared state](#Observing-changes-to-shared-state) +* [Initialization rules](#Initialization-rules) +* [Deriving shared state](#Deriving-shared-state) +* [Testing](#Testing) + * [Testing when using persistence](#Testing-when-using-persistence) + * [Testing when using custom persistence strategies](#Testing-when-using-custom-persistence-strategies) + * [Overriding shared state in tests](#Overriding-shared-state-in-tests) + * [UI Testing](#UI-Testing) + * [Testing tips](#Testing-tips) +* [Read-only shared state](#Read-only-shared-state) +* [Type-safe keys](#Type-safe-keys) +* [Shared state in pre-observation apps](#Shared-state-in-pre-observation-apps) +* [Gotchas of @Shared](#Gotchas-of-Shared) + +## "Source of truth" + +First a quick discussion on defining exactly what "shared state" is. A common concept thrown around +in architectural discussions is "single source of truth." This is the idea that the complete state +of an application, even its navigation, can be driven off a single piece of data. It's a great idea, +in theory, but in practice it can be quite difficult to completely embrace. + +First of all, a _single_ piece of data to drive _all_ of application state is just not feasible. +There is a lot of state in an application that is fine to be local to a view and does not need +global representation. For example, the state of whether a button is being pressed is probably fine +to reside privately inside the button. + +And second, applications typically do not have a _single_ source of truth. That is far too +simplistic. If your application loads data from an API, or from disk, or from user defaults, then +the "truth" for that data does not lie in your application. It lies externally. + +In reality, there are _two_ sources of "truth" in any application. There is the state the +application needs to execute its logic and behavior. This is the kind of state that determines if a +button is enabled or disabled, drives navigation such as sheets and drill-downs, and handles +validation of forms. Such state only makes sense for the application. + +Then there is a second source of "truth" in an application, which is the data that lies in some +external system and needs to be loaded into the application. Such state is best modeled as a +dependency or using the shared state tools discussed in this article. + +## Explicit shared state + +This is the simplest kind of shared state to get start with. It allows you to share state amongst +many features without any persistence. The data is only held in memory, and will be cleared out the +next time the application is run. + +To share data in this style, use the [`@Shared`]() property wrapper with no arguments. +For example, suppose you have a feature that holds a count and you want to be able to hand a shared +reference to that count to other features. You can do so by holding onto a `@Shared` property in +the feature's state: + +```swift +@Reducer +struct ParentFeature { + @ObservableState + struct State { + @Shared var count: Int + // Other properties + } + // ... +} +``` + +> Important: It is not possible to provide a default to a `@Shared` value. It must be passed to the +> feature's state from the outside. See for more +> information about how to initialize types that use `@Shared`. + +Then suppose that this feature can present a child feature that wants access to this shared `count` +value. It too would hold onto an `@Shared` property to a count: + +```swift +@Reducer +struct ChildFeature { + @ObservableState + struct State { + @Shared var count: Int + // Other properties + } + // ... +} +``` + +When the parent features creates the child feature's state, it can pass a _reference_ to the shared +count rather than the actual count value by using the `$count` ``Shared/projectedValue``: + +```swift +case .presentButtonTapped: + state.child = ChildFeature.State(count: state.$count) + // ... +``` + +Now any mutation the `ChildFeature` makes to its `count` will be instantly made to the +`ParentFeature`'s count too. + +## Persisted shared state + +Explicitly shared state discussed above is a nice, lightweight way to share a piece of data with +many parts of your application. However, sometimes you want to share state with the entire +application without having to pass it around explicitly. One can do this by passing a +``PersistenceKey`` to the `@Shared` property wrapper, and the library comes with three persistence +strategies, as well as the ability to create custom persistence strategies. + +#### In-memory + +This is the simplest persistence strategy in that it doesn't actually persist at all. It keeps +the data in memory and makes it available to every part of the application, but when the app is +relaunched the data will be reset back to its default. + +It can be used by passing ``PersistenceReaderKey/inMemory(_:)`` to the `@Shared` property wrapper. +For example, suppose you want to share an integer count value with the entire application so that +any feature can read from and write to the integer. This can be done like so: + +```swift +@Reducer +struct ChildFeature { + @ObservableState + struct State { + @Shared(.inMemory("count")) var count = 0 + // Other properties + } + // ... +} +``` + +> Note: When using a persistence strategy with `@Shared` you must provide a default value, which is +> used for the first access of the shared state. + +Now any part of the application can read from and write to this state, and features will never +get out of sync. + +#### User defaults + +If you would like to persist your shared value across application launches, then you can use the +``PersistenceReaderKey/appStorage(_:)-4l5b`` strategy with `@Shared` in order to automatically +persist any changes to the value to user defaults. It works similarly to in-memory sharing discussed +above. It requires a key to store the value in user defaults, as well as a default value that will +be used when there is no value in the user defaults: + +```swift +@Shared(.appStorage("count")) var count = 0 +``` + +That small change will guarantee that all changes to `count` are persisted and will be +automatically loaded the next time the application launches. + +This form of persistence only works for simple data types because that is what works best with +`UserDefaults`. This includes strings, booleans, integers, doubles, URLs, data, and more. If you +need to store more complex data, such as custom data types serialized to JSON, then you will want +to use the [`.fileStorage`]() strategy or a +[custom persistence]() strategy. + +#### File storage + +If you would like to persist your shared value across application launches, and your value is +complex (such as a custom data type), then you can use the ``PersistenceReaderKey/fileStorage(_:)`` +strategy with `@Shared`. It automatically persists any changes to the file system. + +It works similarly to the in-memory sharing discussed above, but it requires a URL to store the data +on disk, as well as a default value that will be used when there is no data in the file system: + +```swift +@Shared(.fileStorage(URL(/* ... */)) var users: [User] = [] +``` + +This strategy works by serializing your value to JSON to save to disk, and then deserializing JSON +when loading from disk. For this reason the value held in `@Shared(.fileStorage(â€Ļ))` must conform to +`Codable`. + +#### Custom persistence + +It is possible to define all new persistence strategies for the times that user defaults or JSON +files are not sufficient. To do so, define a type that conforms to the ``PersistenceKey`` protocol: + +```swift +public final class CustomPersistenceKey: PersistenceKey { + // ... +} +``` + +And then define a static function on the ``PersistenceKey`` protocol for creating your new +persistence strategy: + +```swift +extension PersistenceReaderKey { + public static func custom(/*...*/) -> Self + where Self == CustomPersistence { + CustomPersistence(/* ... */) + } +} +``` + +With those steps done you can make use of the strategy in the same way one does for +``PersistenceReaderKey/appStorage(_:)-4l5b`` and ``PersistenceReaderKey/fileStorage(_:)``: + +```swift +@Shared(.custom(/* ... */)) var myValue: Value +``` + +The ``PersistenceKey`` protocol represents loading from _and_ saving to some external storage, +such as the file system or user defaults. Sometimes saving is not a valid operation for the external +system, such as if your server holds onto a remote configuration file that your app uses to +customize its appearance or behavior. In those situations you can conform to the +``PersistenceReaderKey`` protocol. See for more +information. + +## Observing changes to shared state + +The ``Shared`` property wrapper exposes a ``Shared/publisher`` property so that you can observe +changes to the reference from any part of your application. For example, if some feature in your +app wants to listen for changes to some shared `count` value, then it can introduce an `onAppear` +action that kicks off a long-living effect that subscribes to changes of `count`: + +```swift +case .onAppear: + return .publisher { + state.$count.publisher + .map(Action.countUpdated) + } + +case .countUpdated(let count): + // Do something with count + return .none +``` + +Note that you will have to be careful for features that both hold onto shared state and subscribe +to changes to that state. It is possible to introduce an infinite loop if you do something like +this: + +```swift +case .onAppear: + return .publisher { + state.$count.publisher + .map(Action.countUpdated) + } + +case .countUpdated(let count): + state.count = count + 1 + return .none +``` + +If `count` changes, then `$count.publisher` emits, causing the `countUpdated` action to be sent, +causing the shared `count` to be mutated, causing `$count.publisher` to emit, and so on. + + + +## Initialization rules + +Because the state sharing tools use property wrappers there are special rules that must be followed +when writing custom initializers for your types. These rules apply to _any_ kind of property +wrapper, including those that ship with vanilla SwiftUI (e.g. `@State`, `@StateObject`, etc.), +but the rules can be quite confusing and so below we describe the various ways to initialize +shared state. + +It is common to need to provide a custom initializer to your feature's +``Reducer/State`` type, especially when modularizing. When using +[`@Shared`]() in your `State` that can become complicated. +Depending on your exact situation you can do one of the following: + +* You are using non-persisted shared state (i.e. no argument is passed to `@Shared`), and the +"source of truth" of the state lives with the parent feature. Then the initializer should take a +`Shared` value and you can assign through the underscored property: + + ```swift + public struct State { + @Shared public var count: Int + // other fields + + public init(count: Shared, /* other fields */) { + self._count = count + // other assignments + } + } + ``` + +* You are using non-persisted shared state (_i.e._ no argument is passed to `@Shared`), and the +"source of truth" of the state lives within the feature you are initializing. Then the initializer +should take a plain, non-`Shared` value and you construct the `Shared` value in the initializer: + + ```swift + public struct State { + @Shared public var count: Int + // other fields + + public init(count: Int, /* other fields */) { + self._count = Shared(count) + // other assignments + } + } + ``` + +* You are using a persistence strategy with shared state (_e.g._ +``PersistenceReaderKey/appStorage(_:)-4l5b``, ``PersistenceReaderKey/fileStorage(_:)``, _etc._), +then the initializer should take a plain, non-`Shared` value and you construct the `Shared` value in +the initializer using ``Shared/init(wrappedValue:_:fileID:line:)-80rtq`` which takes a +``PersistenceKey`` as the second argument: + + ```swift + public struct State { + @Shared public var count: Int + // other fields + + public init(count: Int, /* other fields */) { + self._count = Shared(wrappedValue: count, .appStorage("count")) + // other assignments + } + } + ``` + + The declaration of `count` can use `@Shared` without an argument because the persistence + strategy is specified in the initializer. + + > Important: The value passed to this initializer is only used if the external storage does not + > already have a value. If a value exists in the storage then it is not used. In fact, the + > `wrappedValue` argument of ``Shared/init(wrappedValue:_:fileID:line:)-80rtq`` is an + > `@autoclosure` so that it is not even evaluated if not used. For that reason you + > may prefer to make the argument to the initializer an `@autoclosure` so that it too is evaluated + > only if actually used: + > + > ```swift + > public struct State { + > @Shared public var count: Int + > // other fields + > + > public init(count: @autoclosure () -> Int, /* other fields */) { + > self._count = Shared(wrappedValue: count(), .appStorage("count")) + > // other assignments + > } + > } + > ``` + +## Deriving shared state + +It is possible to derive shared state for sub-parts of an existing piece of shared state. For +example, suppose you have a multi-step signup flow that uses `Shared` in order to share +data between each screen. However, some screens may not need all of `SignUpData`, but instead just a +small part. The phone number confirmation screen may only need access to `signUpData.phoneNumber`, +and so that feature can hold onto just `Shared` to express this fact: + +```swift +@Reducer +struct PhoneNumberFeature { + struct State { + @Shared var phoneNumber: String + } + // ... +} +``` + +Then, when the parent feature constructs the `PhoneNumberFeature` it can derive a small piece of +shared state from `Shared` to pass along: + +```swift +case .nextButtonTapped: + state.path.append( + PhoneNumberFeature.State(phoneNumber: state.$signUpData.phoneNumber) + ) +``` + +Here we are using the ``Shared/projectedValue`` value using `$` syntax, `$signUpData`, and then +further dot-chaining onto that projection to derive a `Shared`. This can be a powerful way +for features to hold onto only the bare minimum of shared state it needs to do its job. + +It can be instructive to think of `@Shared` as the Composable Architecture analogue of `@Bindable` +in vanilla SwiftUI. You use it to express that the actual "source of truth" of the value lies +elsewhere, but you want to be able to read its most current value and write to it. + +This also works for persistence strategies. If a parent feature holds onto a `@Shared` piece of +state with a persistence strategy: + +```swift +@Reducer +struct ParentFeature { + struct State { + @Shared(.fileStorage(.currentUser)) var currentUser + } + // ... +} +``` + +â€Ļand a child feature wants access to just a shared _piece_ of `currentUser`, such as their name, +then they can do so by holding onto a simple, unadorned `@Shared`: + +```swift +@Reducer +struct ChildFeature { + struct State { + @Shared var currentUserName: String + } + // ... +} +``` + +And then the parent can pass along `$currentUser.name` to the child feature when constructing its +state: + +```swift +case .editNameButtonTapped: + state.destination = .editName( + EditNameFeature(name: state.$currentUser.name) + ) +``` + +Any changes the child feature makes to its shared `name` will be automatically made to the parent's +shared `currentUser`, and further those changes will be automatically persisted thanks to the +`.fileStorage` persistence strategy used. This means the child feature gets to describe that it +needs access to shared state without describing the persistence strategy, and the parent can be +responsible for persisting and deriving shared state to pass to the child. + +If your shared state is a collection, and in particular an `IdentifiedArray`, then we have another +tool for deriving shared state to a particular element of the array. You can subscript into a +``Shared`` collection with the `[id:]` subscript, and that will give a piece of optional shared +state (thanks to a dynamic member overload ``Shared/subscript(dynamicMember:)-7ibhr``), which you +can then unwrap to turn into honest shared state: + +```swift +@Shared(.fileStorage(.todos)) var todos: IdentifiedArrayOf = [] + +guard let todo = $todos[id: todoID] +else { return } +todo // Shared +``` + +There is another tool for deriving shared state, and it is the computed property ``Shared/elements`` +that is defined on shared collections. It derives a collection of shared elements so that you can +get access to a shared reference of just one particular element in a collection. + +However, it is only appropriate to use this in conjunction with `ForEach` in order to derive a +shared reference for each element of a collection: + +```swift +struct State { + @Shared(.fileStorage(.todos)) var todos: IdentifiedArrayOf = [] + // ... +} + +// ... + +ForEach(store.$todos.elements) { $todo in + NavigationLink( + // $todo: Shared + // todo: Todo + state: Path.State.todo(TodoFeature.State(todo: $todo)) + ) { + Text(todo.title) + } +} +``` + +> Important: We do not recommend using ``Shared/elements`` outside of using it with `ForEach`, +> `List`, and other SwiftUI views that take collections. + +## Testing + +Shared state behaves quite a bit different from the regular state held in Composable Architecture +features. It is capable of being changed by any part of the application, not just when an action is +sent to the store, and it has reference semantics rather than value semantics. Typically references +cause serious problems with testing, especially exhaustive testing that the library prefers (see +), because references cannot be copied and so one cannot inspect the changes before and +after an action is sent. + +For this reason, the ``Shared`` property wrapper does extra work during testing to preserve a +previous snapshot of the state so that one can still exhaustively assert on shared state, even +though it is a reference. + +For the most part, shared state can be tested just like any regular state held in your features. For +example, consider the following simple counter feature that uses in-memory shared state for the +count: + +```swift +@Reducer +struct Feature { + struct State: Equatable { + @Shared var count: Int + } + enum Action { + case incrementButtonTapped + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .incrementButtonTapped: + state.count += 1 + return .none + } + } + } +} +``` + +This feature can be tested in exactly the same way as when you are using non-shared state: + +```swift +func testIncrement() async { + let store = TestStore(initialState: Feature.State(count: Shared(0))) { + Feature() + } + + await store.send(.incrementButtonTapped) { + $0.count = 1 + } +} +``` + +This test passes because we have described how the state changes. But even better, if we mutate the +`count` incorrectly: + + +```swift +func testIncrement() async { + let store = TestStore(initialState: Feature.State(count: Shared(0))) { + Feature() + } + + await store.send(.incrementButtonTapped) { + $0.count = 2 + } +} +``` + +â€Ļwe immediately get a test failure letting us know exactly what went wrong: + +``` +❌ State was not expected to change, but a change occurred: â€Ļ + + − Feature.State(_count: 2) + + Feature.State(_count: 1) + +(Expected: −, Actual: +) +``` + +This works even though the `@Shared` count is a reference type. The ``TestStore`` and ``Shared`` +type work in unison to snapshot the state before and after the action is sent, allowing us to still +assert in an exhaustive manner. + +However, exhaustively testing shared state is more complicated than testing non-shared state in +features. Shared state can be captured in effects and mutated directly, without ever sending an +action into system. This is in stark contrast to regular state, which can only ever be mutated when +sending an action. + +For example, it is possible to alter the `incrementButtonTapped` action so that it captures the +shared state in an effect, and then increments from the effect: + +```swift +case .incrementButtonTapped: + return .run { [count = state.$count] _ in + count.wrappedValue += 1 + } +``` + +The only reason this is possible is because `@Shared` state is reference-like, and hence can +technically be mutated from anywhere. + +However, how does this affect testing? Since the `count` is no longer incremented directly in +the reducer we can drop the trailing closure from the test store assertion: + +```swift +func testIncrement() async { + let store = TestStore(initialState: SimpleFeature.State(count: Shared(0))) { + SimpleFeature() + } + await store.send(.incrementButtonTapped) +} +``` + +This is technically correct, but we aren't testing the behavior of the effect at all. + +Luckily the ``TestStore`` has our back. If you run this test you will immediately get a failure +letting you know that the shared count was mutated but we did not assert on the changes: + +``` +❌ Tracked changes to 'Shared@MyAppTests/FeatureTests.swift:10' but failed to assert: â€Ļ + + − 0 + + 1 + +(Before: −, After: +) + +Call 'Shared.assert' to exhaustively test these changes, or call 'skipChanges' to ignore them. +``` + +In order to get this test passing we have to explicitly assert on the shared counter state at +the end of the test, which we can do using the ``Shared/assert(_:file:line:)`` method: + +```swift +func testIncrement() async { + let store = TestStore(initialState: SimpleFeature.State(count: Shared(0))) { + SimpleFeature() + } + await store.send(.incrementButtonTapped) + store.state.$count.assert { + $0 = 1 + } +} +``` + +Now the test passes. + +So, even though the `@Shared` type opens our application up to a little bit more uncertainty due +to its reference semantics, it is still possible to get exhaustive test coverage on its changes. + +#### Testing when using persistence + +It is also possible to test when using one of the persistence strategies provided by the library, +which are ``PersistenceReaderKey/appStorage(_:)-4l5b`` and +``PersistenceReaderKey/fileStorage(_:)``. Typically persistence is difficult to test because the +persisted data bleeds over from test to test, making it difficult to exhaustively prove how each +test behaves in isolation. + +But the `.appStorage` and `.fileStorage` strategies do extra work to make sure that happens. By +default the `.appStorage` strategy uses a non-persisting user defaults so that changes are not +actually persisted across test runs. And the `.fileStorage` strategy uses a mock file system so that +changes to state are not actually persisted to the file system. + +This means that if we altered the `SimpleFeature` of the section above to +use app storage: + +```swift +struct State: Equatable { + @Shared(.appStorage("count")) var count: Int +} +```` + +â€Ļthen the test for this feature can be written in the same way as before and will still pass. + +#### Testing when using custom persistence strategies + +When creating your own custom persistence strategies you must careful to do so in a style that +is amenable to testing. For example, the ``PersistenceReaderKey/appStorage(_:)-4l5b`` persistence +strategy that comes with the library injects a ``Dependencies/DependencyValues/defaultAppStorage`` +dependency so that one can inject a custom `UserDefaults` in order to execute in a controlled +environment. By default ``Dependencies/DependencyValues/defaultAppStorage`` uses a non-persisting +user defaults, but you can also customize it to use any kind of defaults. + +Similarly the ``PersistenceReaderKey/fileStorage(_:)`` persistence strategy uses an internal +dependency for changing how files are written to the disk and loaded from disk. In tests the +dependency will forgo any interaction with the file system and instead write data to a `[URL: Data]` +dictionary, and load data from that dictionary. That emulates how the file system works, but without +persisting any data to the global file system, which can bleed over into other tests. + +#### Overriding shared state in tests + +When testing features that use `@Shared` with a persistence strategy you may want to set the initial +value of that state for the test. Typically this can be done by declaring the shared state at +the beginning of the test so that its default value can be specified: + +```swift +func testFeature() { + @Shared(.appStorage("count")) var count = 42 + + // Shared state will be 42 for all features using it. + let store = TestStore(â€Ļ) +} +``` + +However, if your test suite is apart of an app target, then the entry point of the app will execute +and potentially cause an early access of `@Shared`, thus capturing a different default value than +what is specified above. This quirk of tests in app targets is documented in + of the article, and a similar quirk exists for Xcode +previews and is discussed below in . + +The most robust workaround to this issue is to simply not execute your app's entry point when tests +are running, which we detail in . This makes it so that you +are not accidentally execute network requests, tracking analytics, etc. while running tests. + +You can also work around this issue by simply setting the shared state again after initializing +it: + +```swift +func testFeature() { + @Shared(.appStorage("count")) var count = 42 + count = 42 // NB: Set again to override any value set by the app target. + + // Shared state will be 42 for all features using it. + let store = TestStore(â€Ļ) +} +``` + +#### UI Testing + +When UI testing your app you must take extra care so that shared state is not persisted across +app runs because that can cause one test to bleed over into another test, making it difficult to +write deterministic tests that always pass. To fix this, you can set an environment value from +your UI test target, and then if that value is present in the app target you can override the +``Dependencies/DependencyValues/defaultAppStorage`` and +``Dependencies/DependencyValues/defaultFileStorage`` dependencies so that they use in-memory +storage, i.e. they do not persist ever: + +```swift +@main +struct EntryPoint: App { + let store = Store(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + if ProcessInfo.processInfo.environment["UITesting"] == "true" { + $0.defaultAppStorage = UserDefaults( + suiteName:"\(NSTemporaryDirectory())\(UUID().uuidString)" + )! + $0.defaultFileStorage = .inMemory + } + } +} +``` + +#### Testing tips + +There is something you can do to make testing features with shared state more robust and catch +more potential future problems when you refactor your code. Right now suppose you have two features +using `@Shared(.appStorage("count"))`: + +```swift +@Reducer +struct Feature1 { + struct State { + @Shared(.appStorage("count")) var count = 0 + } + // ... +} + +@Reducer +struct Feature2 { + struct State { + @Shared(.appStorage("count")) var count = 0 + } + // ... +} +``` + +And suppose you wrote a test that proves one of these counts is incremented when a button is tapped: + +```swift +await store.send(.feature1(.buttonTapped)) { + $0.feature1.count = 1 +} +``` + +Because both features are using `@Shared` you can be sure that both counts are kept in sync, and +so you do not need to assert on `feature2.count`. + +However, if someday during a long, complex refactor you accidentally removed `@Shared` from +the second feature: + +```swift +@Reducer +struct Feature2 { + struct State { + var count = 0 + } + // ... +} +``` + +â€Ļthen all of your code would continue compiling, and the test would still pass, but you may have +introduced a bug by not having these two pieces of state in sync anymore. + +You could also fix this by forcing yourself to assert on all shared state in your features, even +though technically it's not necessary: + +```swift +await store.send(.feature1(.buttonTapped)) { + $0.feature1.count = 1 + $0.feature2.count = 1 +} +``` + +If you are worried about these kinds of bugs you can make your tests more robust by not asserting +on the shared state in the argument handed to the trailing closure of ``TestStore``'s `send`, and +instead capture a reference to the shared state in the test and mutate it in the trailing +closure: + + +```swift +func testIncrement() async { + @Shared(.appStorage("count")) var count = 0 + let store = TestStore(initialState: ParentFeature.State()) { + ParentFeature() + } + + await store.send(.feature1(.buttonTapped)) { + // Mutate $0 to expected value. + count = 1 + } +} +``` + +This will fail if you accidetally remove a `@Shared` from one of your features. + +Further, you can enforce this pattern in your codebase by making all `@Shared` properties +`fileprivate` so that they can never be mutated outside their file scope: + +```swift +struct State { + @Shared(.appStorage("count")) fileprivate var count = 0 +} +``` + +## Read-only shared state + +The [`@Shared`]() property wrapper described above gives you access to a piece of shared +state that is both readable and writable. That is by far the most common use case when it comes to +shared state, but there are times when one wants to express access to shared state for which you +are not allowed to write to it, or possibly it doesn't even make sense to write to it. + +For those times there is the [`@SharedReader`]() property wrapper. It represents +a reference to some piece of state shared with multiple parts of the application, but you are not +allowed to write to it. Every persistence strategy discussed above works with ``SharedReader``, +however if you try to mutate the state you will get a compiler error: + +```swift +@SharedReader(.appStorage("isOn")) var isOn = false +isOn = true // 🛑 +``` + +It is also possible to make custom persistence strategies that only have the notion of loading and +subscribing, but cannot write. To do this you will conform only to the ``PersistenceReaderKey`` +protocol instead of the full ``PersistenceKey`` protocol. + +For example, you could create a `.remoteConfig` strategy that loads (and subscribes to) a remote +configuration file held on your server so that it is kept automatically in sync: + +```swift +@SharedReader(.remoteConfig) var remoteConfig +``` + +## Type-safe keys + +Due to the nature of persisting data to external systems, you lose some type safety when shuffling +data from your app to the persistence storage and back. For example, if you are using the +``PersistenceReaderKey/fileStorage(_:)`` strategy to save an array of users to disk you might do so +like this: + +```swift +extension URL { + static let users = URL(/* ... */)) +} + +@Shared(.fileStorage(.users)) var users: [User] = [] +``` + +And say you have used this file storage users in multiple places throughout your application. + +But then, someday in the future you may decide to refactor this data to be an identified array +instead of a plain array: + +```swift +// Somewhere else in the application +@Shared(.fileStorage(.users)) var users: IdentifiedArrayOf = [] +``` + +But if you forget to convert _all_ shared user arrays to the new identified array your application +will still compile, but it will be broken. The two types of storage will not share state. + +To add some type-safety and reusability to this process you can extend the ``PersistenceReaderKey`` +protocol to add a static variable for describing the details of your persistence: + +```swift +extension PersistenceReaderKey where Self == FileStorageKey> { + static var users: Self { + fileStorage(.users) + } +} +``` + +Then when using [`@Shared`]() you can specify this key directly without `.fileStorage`: + +```swift +@Shared(.users) var users: IdentifiedArrayOf = [] +``` + +And now that the type is baked into the key you cannot accidentally use the wrong type because you +will get an immediate compiler error: + +```swift +@Shared(.users) var users = [User]() +``` + +> 🛑 Error: Cannot convert value of type '[User]' to expected argument type 'IdentifiedArrayOf' + +This technique works for all types of persistence strategies. For example, a type-safe `.inMemory` +key can be constructed like so: + +```swift +extension PersistenceReaderKey where Self == InMemoryKey> { + static var users: Self { + inMemory("users") + } +} +``` + +And a type-safe `.appStorage` key can be constructed like so: + +```swift +extension PersistenceReaderKey where Self == AppStorageKey { + static var count: Self { + appStorage("count") + } +} +``` + +And this technique also works on [custom persistence]() +strategies. + +Further, you can use the ``PersistenceKeyDefault`` type to also provide a default that is used +with the persistence strategy. For example, to use a default value of `[]` with the `.users` +persistence strategy described above, we can do the following: + +```swift +extension PersistenceReaderKey +where Self == PersistenceKeyDefault>> +{ + static var users: Self { + PersistenceKeyDefault(.fileStorage(.users), []) + } +} +``` + +And now anytime you reference the shared users state you can leave off the default value, and +you can even leave off the type annotation: + +```swift +@Shared(.users) var users +``` + +## Shared state in pre-observation apps + +It is possible to use [`@Shared`]() in features that have not yet been updated with +the observation tools released in 1.7, such as the ``ObservableState()`` macro. In the reducer +you can use `@Shared` regardless of your use of the observation tools. + +However, if you are deploying to iOS 16 or earlier, then you must use `WithPerceptionTracking` +in your views if you are accessing shared state. For example, the following view: + +```swift +struct FeatureView: View { + let store: StoreOf + + var body: some View { + Form { + Text(store.sharedCount.description) + } + } +} +``` + +â€Ļwill not update properly when `sharedCount` changes. This view will even generate a runtime warning +letting you know something is wrong: + +> đŸŸŖ Runtime Warning: Perceptible state was accessed but is not being tracked. Track changes to +> state by wrapping your view in a 'WithPerceptionTracking' view. + +The fix is to wrap the body of the view in `WithPerceptionTracking`: + +```swift +struct FeatureView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + Form { + Text(store.sharedCount.description) + } + } + } +} +``` + +## Gotchas of @Shared + +There are a few gotchas to be aware of when using shared state in the Composable Architecture. + +#### Hashability + +Because the `@Shared` type is equatable based on its wrapped value, and because the value is held in a reference and can change over time, it cannot be hashable. This also means that types containing `@Shared` properties should not compute their hashes from shared values. + +#### Codability + +The `@Shared` type is not conditionally encodable or decodable because the source of truth of the wrapped value is rarely local: it might be derived from some other shared value, or it might rely on loading the value from a backing persistence strategy. + +When introducing shared state to a data type that is encodable or decodable, you must provide your own implementations of `encode(to:)` and `init(from:)` that do the appropriate thing. + +For example, if the data type is sharing state with a persistence strategy, you can decode by delegating to the memberwise initializer that implicitly loads the shared value from the property wrapper's persistence strategy, or you can explicitly initialize a shared value via ``Shared/init(wrappedValue:_:fileID:line:)``. And for encoding you can often skip encoding the shared value: + +```swift +struct AppState { + @Shared(.appStorage("launchCount")) var launchCount = 0 + var todos: [String] = [] +} + +extension AppState: Codable { + enum CodingKeys: String, CodingKey { case todos } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Use the property wrapper default via the memberwise initializer: + try self.init( + todos: container.decode([String].self, forKey: .todos) + ) + + // Or initialize the shared storage manually: + self._launchCount = Shared(wrappedValue: 0, .appStorage("launchCount")) + self.todos = try container.decode([String].self, forKey: .todos) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.todos, forKey: .todos) + // Skip encoding the launch count. + } +} +``` + +#### Previews + +When a preview is run in an app target, the entry point is also created. This means if your entry +point looks something like this: + +```swift +@main +struct MainApp: App { + let store = Store(â€Ļ) + + var body: some Scene { + â€Ļ + } +} +``` + +â€Ļthen a store will be created each time you run your preview. This can be problematic with `@Shared` +and persistence strategies because the first access of a `@Shared` property will use the default +value provided, and that will cause `@Shared`'s created later to ignore the default. That will mean +you cannot override shared state in previews. + +The fix is to delay creation of the store until the entry point's `body` is executed. Further, it +can be a good idea to also not run the `body` when in tests because that can also interfere with +tests (as documented in ). Here is one way this can be accomplished: + +```swift +import ComposableArchitecture +import SwiftUI + +@main +struct MainApp: App { + @MainActor + static let store = Store(â€Ļ) + + var body: some Scene { + WindowGroup { + if _XCTIsTesting { + // NB: Don't run application in tests to avoid interference + // between the app and the test. + EmptyView() + } else { + AppView(store: Self.store) + } + } + } +} +``` + +Alternatively you can take an extra step to override shared state in your previews: + +```swift +#Preview { + @Shared(.appStorage("isOn")) var isOn = true + isOn = true +} +``` + +The second assignment of `isOn` will guarantee that it holds a value of `true`. + +## Topics + +### Essentials + +- ``Shared`` + +### Persistence strategies + +- ``AppStorageKey`` +- ``FileStorageKey`` +- ``InMemoryKey`` + +### Custom persistence + +- ``PersistenceKey`` + +### Read-only persistence + +- ``SharedReader`` +- ``PersistenceReaderKey`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md index d69c145fbb08..1ebc780476ac 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md @@ -81,8 +81,8 @@ class CounterTests: XCTestCase { > Tip: Tests that use ``TestStore`` should be annotated as `@MainActor` and marked as `async` since > most assertion helpers on ``TestStore`` can suspend. -Test stores have a ``TestStore/send(_:assert:file:line:)-2co21`` method, but it behaves differently -from stores and view stores. You provide an action to send into the system, but then you must also +Test stores have a ``TestStore/send(_:assert:file:line:)-2co21`` method, but it behaves differently from +stores and view stores. You provide an action to send into the system, but then you must also provide a trailing closure to describe how the state of the feature changed after sending the action: @@ -571,7 +571,7 @@ It can be important to understand how non-exhaustive testing works under the hoo limit the ways in which you can assert on state changes. When you construct an _exhaustive_ test store, which is the default, the `$0` used inside the -trailing closure of ``TestStore/send(_:assert:file:line:)-2co21`` represents the state _before_ the +trailing closure of ``TestStore/send(_:assert:file:line:)-2co21`` represents the state _before_ the action is sent: ```swift diff --git a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md index c1e6df9f5b04..bbcbc1690a0d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md +++ b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md @@ -51,6 +51,7 @@ day-to-day when building applications, such as: - - - +- - ### Tutorials @@ -62,10 +63,12 @@ day-to-day when building applications, such as: - - ``Effect`` - ``Store`` +- ### Testing - ``TestStore`` +- ### Integrations diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/AppStorageKey.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/AppStorageKey.md new file mode 100644 index 000000000000..75f5f39268c8 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/AppStorageKey.md @@ -0,0 +1,30 @@ +# ``ComposableArchitecture/AppStorageKey`` + +## Topics + +### Storing a value + +- ``PersistenceReaderKey/appStorage(_:)-4l5b`` +- ``PersistenceReaderKey/appStorage(_:)-6d47p`` +- ``PersistenceReaderKey/appStorage(_:)-6tsph`` +- ``PersistenceReaderKey/appStorage(_:)-69h4r`` +- ``PersistenceReaderKey/appStorage(_:)-xphy`` +- ``PersistenceReaderKey/appStorage(_:)-617ld`` +- ``PersistenceReaderKey/appStorage(_:)-6lnxu`` +- ``PersistenceReaderKey/appStorage(_:)-ibg0`` + +### Storing an optional value + +- ``PersistenceReaderKey/appStorage(_:)-4s3s5`` +- ``PersistenceReaderKey/appStorage(_:)-2dfnh`` +- ``PersistenceReaderKey/appStorage(_:)-5wv8g`` +- ``PersistenceReaderKey/appStorage(_:)-40e42`` +- ``PersistenceReaderKey/appStorage(_:)-4veqp`` +- ``PersistenceReaderKey/appStorage(_:)-7rox5`` +- ``PersistenceReaderKey/appStorage(_:)-2keyn`` +- ``PersistenceReaderKey/appStorage(_:)-7u49u`` + +### Key-path access + +- ``PersistenceReaderKey/appStorage(_:)-5jsie`` +- ``AppStorageKeyPathKey`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md index 25cfbe316e8e..980e4d819b2f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md @@ -9,10 +9,6 @@ instead. ## Topics -### Creating a test store - -- ``TestStore/init(initialState:reducer:withDependencies:file:line:)-8f79s`` - ### Nanosecond timeouts - ``TestStore/finish(timeout:file:line:)-43l4y`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/FileStorageKey.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/FileStorageKey.md new file mode 100644 index 000000000000..05812ae657ba --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/FileStorageKey.md @@ -0,0 +1,11 @@ +# ``ComposableArchitecture/FileStorageKey`` + +## Topics + +### Storing a value + +- ``PersistenceReaderKey/fileStorage(_:)`` + +### Overriding storage + +- ``FileStorage`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/InMemoryKey.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/InMemoryKey.md new file mode 100644 index 000000000000..833c218d35e3 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/InMemoryKey.md @@ -0,0 +1,7 @@ +# ``ComposableArchitecture/InMemoryKey`` + +## Topics + +### Storing a value + +- ``PersistenceReaderKey/inMemory(_:)`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md index f251a48eebc8..b5fe21eafcfb 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md @@ -23,6 +23,12 @@ - ``forEach(_:action:element:fileID:line:)-247po`` - +### Sharing state + +- +- ``Shared`` +- ``PersistenceKey`` + ### Supporting reducers - ``EmptyReducer`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md index 6fdad6b2fbc5..ffafd84d5984 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/UIKit.md @@ -4,7 +4,8 @@ Integrating the Composable Architecture into a UIKit application. ## Overview -While the Composable Architecture was designed with SwiftUI in mind, it comes with tools to integrate into application code written in UIKit. +While the Composable Architecture was designed with SwiftUI in mind, it comes with tools to +integrate into application code written in UIKit. ## Topics diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter1.png b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter1.png new file mode 100644 index 000000000000..92ac2bee4dd5 Binary files /dev/null and b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter1.png differ diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter2.png b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter2.png new file mode 100644 index 000000000000..92ac2bee4dd5 Binary files /dev/null and b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter2.png differ diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter3.png b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter3.png new file mode 100644 index 000000000000..a62fcd261e82 Binary files /dev/null and b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter3.png differ diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter6.png b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter6.png new file mode 100644 index 000000000000..5ca05c1acb4b Binary files /dev/null and b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter6.png differ diff --git a/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter8.png b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter8.png new file mode 100644 index 000000000000..6f4f33b52ef8 Binary files /dev/null and b/Sources/ComposableArchitecture/Documentation.docc/Tutorials/MeetTheComposableArchitecture/chapter8.png differ diff --git a/Sources/ComposableArchitecture/Internal/DefaultSubscript.swift b/Sources/ComposableArchitecture/Internal/DefaultSubscript.swift new file mode 100644 index 000000000000..07eeda74b068 --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/DefaultSubscript.swift @@ -0,0 +1,34 @@ +final class DefaultSubscript: Hashable { + var value: Value + init(_ value: Value) { + self.value = value + } + static func == (lhs: DefaultSubscript, rhs: DefaultSubscript) -> Bool { + lhs === rhs + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +extension Optional { + subscript(default defaultSubscript: DefaultSubscript) -> Wrapped { + get { self ?? defaultSubscript.value } + set { + defaultSubscript.value = newValue + if self != nil { self = newValue } + } + } +} + +extension RandomAccessCollection where Self: MutableCollection { + subscript( + position: Index, default defaultSubscript: DefaultSubscript + ) -> Element { + get { self.indices.contains(position) ? self[position] : defaultSubscript.value } + set { + defaultSubscript.value = newValue + if self.indices.contains(position) { self[position] = newValue } + } + } +} diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index 0c252da7a482..18b6e99599b9 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -1,3 +1,13 @@ +// NB: Deprecated with 1.10.0: + +@available(*, deprecated, message: "Use '.fileSystem' ('FileStorage.fileSystem') instead") +public func LiveFileStorage() -> FileStorage { .fileSystem } + +@available(*, deprecated, message: "Use '.inMemory' ('FileStorage.inMemory') instead") +public func InMemoryFileStorage() -> FileStorage { .inMemory } + +// NB: Deprecated with 1.0.0: + @available(*, unavailable, renamed: "Effect") public typealias EffectTask = Effect diff --git a/Sources/ComposableArchitecture/Internal/NotificationName.swift b/Sources/ComposableArchitecture/Internal/NotificationName.swift new file mode 100644 index 000000000000..4b7f2d977518 --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/NotificationName.swift @@ -0,0 +1,56 @@ +import Foundation + +#if canImport(AppKit) + import AppKit +#endif +#if canImport(UIKit) + import UIKit +#endif +#if canImport(WatchKit) + import WatchKit +#endif + +@_spi(Internals) +public var willResignNotificationName: Notification.Name? { + #if os(iOS) || os(tvOS) || os(visionOS) + return UIApplication.willResignActiveNotification + #elseif os(macOS) + return NSApplication.willResignActiveNotification + #else + if #available(watchOS 7, *) { + return WKExtension.applicationWillResignActiveNotification + } else { + return nil + } + #endif +} + +@_spi(Internals) +public let willEnterForegroundNotificationName: Notification.Name? = { + #if os(iOS) || os(tvOS) || os(visionOS) + return UIApplication.willEnterForegroundNotification + #elseif os(macOS) + return NSApplication.willBecomeActiveNotification + #else + if #available(watchOS 7, *) { + return WKExtension.applicationWillEnterForegroundNotification + } else { + return nil + } + #endif +}() + +@_spi(Internals) +public let willTerminateNotificationName: Notification.Name? = { + #if os(iOS) || os(tvOS) || os(visionOS) + return UIApplication.willTerminateNotification + #elseif os(macOS) + return NSApplication.willTerminateNotification + #else + return nil + #endif +}() + +var canListenForResignActive: Bool { + willResignNotificationName != nil +} diff --git a/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift b/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift index bb0ef83db503..a8c8c66f6d09 100644 --- a/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift +++ b/Sources/ComposableArchitecture/Reducer/Reducers/DebugReducer.swift @@ -81,26 +81,42 @@ public struct _PrintChangesReducer: Reducer { self.printer = printer } - @inlinable +#if DEBUG public func reduce( into state: inout Base.State, action: Base.Action ) -> Effect { - #if DEBUG - if let printer = self.printer { + if let printer = self.printer { + return withSharedChangeTracking { changeTracker in let oldState = state let effects = self.base.reduce(into: &state, action: action) - return effects.merge( - with: .publisher { [newState = state, queue = printer.queue] in - Deferred> { - queue.async { - printer.printChange(receivedAction: action, oldState: oldState, newState: newState) + return withEscapedDependencies { continuation in + effects.merge( + with: .publisher { [newState = state, queue = printer.queue] in + Deferred> { + queue.async { + continuation.yield { + changeTracker.assert { + printer.printChange( + receivedAction: action, oldState: oldState, newState: newState + ) + } + } + } + return Empty() } - return Empty() } - } - ) + ) + } } - #endif + } + return self.base.reduce(into: &state, action: action) + } + #else + @inlinable + public func reduce( + into state: inout Base.State, action: Base.Action + ) -> Effect { return self.base.reduce(into: &state, action: action) } + #endif } diff --git a/Sources/ComposableArchitecture/Resources/PrivacyInfo.xcprivacy b/Sources/ComposableArchitecture/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000000..3d9ffdf6f5d3 --- /dev/null +++ b/Sources/ComposableArchitecture/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,21 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + C56D.1 + + + + NSPrivacyTrackingDomains + + NSPrivacyTracking + + + diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift new file mode 100644 index 000000000000..29d81ba34a26 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey.swift @@ -0,0 +1,82 @@ +/// A type that can load and subscribe to state in an external system. +/// +/// Conform to this protocol to express loading state from an external system, and subscribing to +/// state changes in the external system. It is only necessary to conform to this protocol if the +/// ``AppStorageKey``, ``FileStorageKey``, or ``InMemoryKey`` strategies are not sufficient for your +/// use case. +/// +/// See the article for more information, in particular the +/// section. +public protocol PersistenceReaderKey: Hashable { + /// A type that can be loaded or subscribed to in an external system. + associatedtype Value + + /// Loads the freshest value from storage. Returns `nil` if there is no value in storage. + /// + /// - Parameter initialValue: An initial value assigned to the `@Shared` property. + /// - Returns: An initial value provided by an external system, or `nil`. + func load(initialValue: Value?) -> Value? + + /// Subscribes to external updates. + /// + /// - Parameters: + /// - initialValue: An initial value assigned to the `@Shared` property. + /// - didSet: A closure that is invoked with new values from an external system, or `nil` if the + /// external system no longer holds a value. + /// - Returns: A subscription to updates from an external system. If it is cancelled or + /// deinitialized, the `didSet` closure will no longer be invoked. + func subscribe( + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void + ) -> Shared.Subscription +} + +extension PersistenceReaderKey { + public func subscribe( + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void + ) -> Shared.Subscription { + Shared.Subscription {} + } +} + +/// A type that can persist shared state to an external storage. +/// +/// Conform to this protocol to express persistence to some external storage by describing how to +/// save to and load from the external storage, and providing a stream of values that represents +/// when the external storage is changed from the outside. It is only necessary to conform to this +/// protocol if the ``AppStorageKey``, ``FileStorageKey``, or ``InMemoryKey`` strategies are not +/// sufficient for your use case. +/// +/// See the article for more information, in particular the +/// section. +public protocol PersistenceKey: PersistenceReaderKey { + /// Saves a value to storage. + func save(_ value: Value) +} + +extension Shared { + /// A subscription to a ``PersistenceReaderKey``'s updates. + /// + /// This object is returned from ``PersistenceReaderKey/subscribe(initialValue:didSet:)``, which + /// will feed updates from an external system for its lifetime, or till ``cancel()`` is called. + public class Subscription { + let onCancel: () -> Void + + /// Initializes the subscription with the given cancel closure. + /// + /// - Parameter cancel: A closure that the `cancel()` method executes. + public init(_ cancel: @escaping () -> Void) { + self.onCancel = cancel + } + + deinit { + self.cancel() + } + + /// Cancels the subscription. + public func cancel() { + self.onCancel() + } + } +} diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift new file mode 100644 index 000000000000..6430bc5defc9 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift @@ -0,0 +1,415 @@ +import Dependencies +import Foundation + +extension PersistenceReaderKey { + /// Creates a persistence key that can read and write to a boolean user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an integer user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to a double user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to a string user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to a URL user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to a user default as data. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an integer user default, transforming + /// that to a `RawRepresentable` data type. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Value.RawValue == Int, Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to a string user default, transforming + /// that to a `RawRepresentable` data type. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Value.RawValue == String, Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an optional boolean user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an optional integer user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an optional double user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an optional string user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an optional URL user default. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to a user default as optional data. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an optional integer user default, + /// transforming that to a `RawRepresentable` data type. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Value.RawValue == Int, Self == AppStorageKey { + AppStorageKey(key) + } + + /// Creates a persistence key that can read and write to an optional string user default, + /// transforming that to a `RawRepresentable` data type. + /// + /// - Parameter key: The key to read and write the value to in the user defaults store. + /// - Returns: A user defaults persistence key. + public static func appStorage(_ key: String) -> Self + where Value.RawValue == String, Self == AppStorageKey { + AppStorageKey(key) + } +} + +/// A type defining a user defaults persistence strategy. +/// +/// See ``PersistenceReaderKey/appStorage(_:)-4l5b`` to create values of this type. +public struct AppStorageKey { + private let lookup: any Lookup + private let key: String + private let store: UserDefaults + + public init(_ key: String) where Value == Bool { + @Dependency(\.defaultAppStorage) var store + self.lookup = CastableLookup() + self.key = key + self.store = store + } + + public init(_ key: String) where Value == Int { + @Dependency(\.defaultAppStorage) var store + self.lookup = CastableLookup() + self.key = key + self.store = store + } + + public init(_ key: String) where Value == Double { + @Dependency(\.defaultAppStorage) var store + self.lookup = CastableLookup() + self.key = key + self.store = store + } + + public init(_ key: String) where Value == String { + @Dependency(\.defaultAppStorage) var store + self.lookup = CastableLookup() + self.key = key + self.store = store + } + + public init(_ key: String) where Value == URL { + @Dependency(\.defaultAppStorage) var store + self.lookup = CastableLookup() + self.key = key + self.store = store + } + + public init(_ key: String) where Value == Data { + @Dependency(\.defaultAppStorage) var store + self.lookup = CastableLookup() + self.key = key + self.store = store + } + + public init(_ key: String) + where Value: RawRepresentable, Value.RawValue == Int { + @Dependency(\.defaultAppStorage) var store + self.lookup = RawRepresentableLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) + where Value: RawRepresentable, Value.RawValue == String { + @Dependency(\.defaultAppStorage) var store + self.lookup = RawRepresentableLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) where Value == Bool? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) where Value == Int? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) where Value == Double? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) where Value == String? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) where Value == URL? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) where Value == Data? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: CastableLookup()) + self.key = key + self.store = store + } + + public init(_ key: String) + where R.RawValue == Int, Value == R? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())) + self.key = key + self.store = store + } + + public init(_ key: String) + where R.RawValue == String, Value == R? { + @Dependency(\.defaultAppStorage) var store + self.lookup = OptionalLookup(base: RawRepresentableLookup(base: CastableLookup())) + self.key = key + self.store = store + } +} + +extension AppStorageKey: PersistenceKey { + public func load(initialValue: Value?) -> Value? { + self.lookup.loadValue(from: self.store, at: self.key, default: initialValue) + } + + public func save(_ value: Value) { + SharedAppStorageLocals.$isSetting.withValue(true) { + self.lookup.saveValue(value, to: self.store, at: self.key) + } + } + + public func subscribe( + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void + ) -> Shared.Subscription { + let userDefaultsDidChange = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: self.store, + queue: nil + ) { _ in + guard !SharedAppStorageLocals.isSetting + else { return } + didSet(load(initialValue: initialValue)) + } + let willEnterForeground: (any NSObjectProtocol)? + if let willEnterForegroundNotificationName { + willEnterForeground = NotificationCenter.default.addObserver( + forName: willEnterForegroundNotificationName, + object: nil, + queue: nil + ) { _ in + didSet(load(initialValue: initialValue)) + } + } else { + willEnterForeground = nil + } + return Shared.Subscription { + NotificationCenter.default.removeObserver(userDefaultsDidChange) + if let willEnterForeground { + NotificationCenter.default.removeObserver(willEnterForeground) + } + } + } +} + +extension AppStorageKey: Hashable { + public static func == (lhs: AppStorageKey, rhs: AppStorageKey) -> Bool { + lhs.key == rhs.key && lhs.store == rhs.store + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.key) + hasher.combine(self.store) + } +} + +extension DependencyValues { + public var defaultAppStorage: UserDefaults { + get { self[DefaultAppStorageKey.self].value } + set { self[DefaultAppStorageKey.self].value = newValue } + } +} + +private enum DefaultAppStorageKey: DependencyKey { + static var testValue: UncheckedSendable { + UncheckedSendable( + UserDefaults( + suiteName: + "\(NSTemporaryDirectory())co.pointfree.ComposableArchitecture.\(UUID().uuidString)" + )! + ) + } + static var previewValue: UncheckedSendable { + Self.testValue + } + static var liveValue: UncheckedSendable { + UncheckedSendable(UserDefaults.standard) + } +} + +// NB: This is mainly used for tests, where observer notifications can bleed across cases. +private enum SharedAppStorageLocals { + @TaskLocal static var isSetting = false +} + +private protocol Lookup { + associatedtype Value + func loadValue(from store: UserDefaults, at key: String, default defaultValue: Value?) -> Value? + func saveValue(_ newValue: Value, to store: UserDefaults, at key: String) +} + +private struct CastableLookup: Lookup { + func loadValue( + from store: UserDefaults, at key: String, default defaultValue: Value? + ) -> Value? { + guard let value = store.object(forKey: key) as? Value + else { + store.setValue(defaultValue, forKey: key) + return defaultValue + } + return value + } + func saveValue(_ newValue: Value, to store: UserDefaults, at key: String) { + store.setValue(newValue, forKey: key) + } +} + +private struct RawRepresentableLookup: Lookup +where Value.RawValue == Base.Value { + let base: Base + func loadValue( + from store: UserDefaults, at key: String, default defaultValue: Value? + ) -> Value? { + base.loadValue(from: store, at: key, default: defaultValue?.rawValue) + .flatMap(Value.init(rawValue:)) + ?? defaultValue + } + func saveValue(_ newValue: Value, to store: UserDefaults, at key: String) { + base.saveValue(newValue.rawValue, to: store, at: key) + } +} + +private struct OptionalLookup: Lookup { + let base: Base + func loadValue( + from store: UserDefaults, at key: String, default defaultValue: Base.Value?? + ) -> Base.Value?? { + base.loadValue(from: store, at: key, default: defaultValue ?? nil) + } + func saveValue(_ newValue: Base.Value?, to store: UserDefaults, at key: String) { + if let newValue { + base.saveValue(newValue, to: store, at: key) + } else { + store.removeObject(forKey: key) + } + } +} diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift new file mode 100644 index 000000000000..06b7a8a59510 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift @@ -0,0 +1,84 @@ +import Dependencies +import Foundation + +extension PersistenceReaderKey { + /// Creates a persistence key for sharing data in user defaults given a key path. + /// + /// For example, one could initialize a key with the date and time at which the application was + /// most recently launched, and access this date from anywhere using the ``Shared`` property + /// wrapper: + /// + /// ```swift + /// @Shared(.appStorage(\.appLaunchedAt)) var appLaunchedAt = Date() + /// ``` + /// + /// - Parameter key: A string key identifying a value to share in memory. + /// - Returns: A persistence key. + public static func appStorage( + _ keyPath: ReferenceWritableKeyPath + ) -> Self where Self == AppStorageKeyPathKey { + AppStorageKeyPathKey(keyPath) + } +} + +/// A type defining a user defaults persistence strategy via key path. +/// +/// See ``PersistenceReaderKey/appStorage(_:)-5jsie`` to create values of this type. +public struct AppStorageKeyPathKey { + private let keyPath: ReferenceWritableKeyPath + private let store: UserDefaults + + public init(_ keyPath: ReferenceWritableKeyPath) { + @Dependency(\.defaultAppStorage) var store + self.keyPath = keyPath + self.store = store + } +} + +extension AppStorageKeyPathKey: PersistenceKey { + public func load(initialValue _: Value?) -> Value? { + self.store[keyPath: self.keyPath] + } + + public func save(_ newValue: Value) { + SharedAppStorageLocals.$isSetting.withValue(true) { + self.store[keyPath: self.keyPath] = newValue + } + } + + public func subscribe( + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void + ) -> Shared.Subscription { + let observer = self.store.observe(self.keyPath, options: .new) { _, change in + guard + !SharedAppStorageLocals.isSetting + else { return } + didSet(change.newValue ?? initialValue) + } + return Shared.Subscription { + observer.invalidate() + } + } + + private class Observer: NSObject { + let didChange: (Value?) -> Void + init(didChange: @escaping (Value?) -> Void) { + self.didChange = didChange + super.init() + } + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + self.didChange(change?[.newKey] as? Value) + } + } +} + +// NB: This is mainly used for tests, where observer notifications can bleed across cases. +private enum SharedAppStorageLocals { + @TaskLocal static var isSetting = false +} diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift new file mode 100644 index 000000000000..722265f316ff --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift @@ -0,0 +1,303 @@ +import Combine +import Dependencies +import Foundation + +extension PersistenceReaderKey { + /// Creates a persistence key that can read and write to a `Codable` value to the file system. + /// + /// - Parameter url: The file URL from which to read and write the value. + /// - Returns: A file persistence key. + public static func fileStorage(_ url: URL) -> Self + where Self == FileStorageKey { + FileStorageKey(url: url) + } +} + +/// A type defining a file persistence strategy +/// +/// Use ``PersistenceReaderKey/fileStorage(_:)`` to create values of this type. +public final class FileStorageKey: PersistenceKey, Sendable { + fileprivate let storage: FileStorage + let isSetting = LockIsolated(false) + let url: URL + let value = LockIsolated(nil) + let workItem = LockIsolated(nil) + + public init(url: URL) { + @Dependency(\.defaultFileStorage) var storage + self.storage = storage + self.url = url + } + + public func load(initialValue: Value?) -> Value? { + do { + return try JSONDecoder().decode(Value.self, from: self.storage.load(self.url)) + } catch { + return initialValue + } + } + + public func save(_ value: Value) { + if self.workItem.value == nil { + self.isSetting.setValue(true) + try? self.storage.save(JSONEncoder().encode(value), self.url) + let workItem = DispatchWorkItem { [weak self] in + guard let self, let value = self.value.value else { return } + self.isSetting.setValue(true) + try? self.storage.save(JSONEncoder().encode(value), self.url) + self.value.setValue(nil) + self.workItem.setValue(nil) + } + self.workItem.setValue(workItem) + if canListenForResignActive { + self.storage.asyncAfter(.seconds(1), workItem) + } else { + self.storage.async(workItem) + } + } else { + self.value.setValue(value) + } + } + + public func subscribe( + initialValue: Value?, + didSet: @Sendable @escaping (_ newValue: Value?) -> Void + ) -> Shared.Subscription { + let cancellable = LockIsolated(nil) + @Sendable func setUpSources() { + cancellable.withValue { [weak self] in + $0?.cancel() + guard let self else { return } + // NB: Make sure there is a file to create a source for. + if !self.storage.fileExists(self.url) { + try? self.storage.createDirectory(self.url.deletingLastPathComponent(), true) + try? self.storage.save(Data(), self.url) + } + let writeCancellable = self.storage.fileSystemSource(self.url, [.write]) { + if self.isSetting.value == true { + self.isSetting.setValue(false) + } else { + self.workItem.withValue { + $0?.cancel() + $0 = nil + } + didSet(self.load(initialValue: initialValue)) + } + } + let deleteCancellable = self.storage.fileSystemSource(self.url, [.delete, .rename]) { + `didSet`(self.load(initialValue: initialValue)) + setUpSources() + } + $0 = AnyCancellable { + writeCancellable.cancel() + deleteCancellable.cancel() + } + } + } + setUpSources() + let willResign: (any NSObjectProtocol)? + if let willResignNotificationName { + willResign = NotificationCenter.default.addObserver( + forName: willResignNotificationName, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self + else { return } + self.performImmediately() + } + } else { + willResign = nil + } + let willTerminate: (any NSObjectProtocol)? + if let willTerminateNotificationName { + willTerminate = NotificationCenter.default.addObserver( + forName: willTerminateNotificationName, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self + else { return } + self.performImmediately() + } + } else { + willTerminate = nil + } + return Shared.Subscription { + cancellable.withValue { $0?.cancel() } + if let willResign { + NotificationCenter.default.removeObserver(willResign) + } + if let willTerminate { + NotificationCenter.default.removeObserver(willTerminate) + } + } + } + + private func performImmediately() { + guard let workItem = self.workItem.value + else { return } + self.storage.async(workItem) + self.storage.async( + DispatchWorkItem { + self.workItem.withValue { + $0?.cancel() + $0 = nil + } + } + ) + } +} + +extension FileStorageKey: Hashable { + public static func == (lhs: FileStorageKey, rhs: FileStorageKey) -> Bool { + lhs.url == rhs.url && lhs.storage.id == rhs.storage.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.url) + hasher.combine(self.storage.id) + } +} + +private enum FileStorageDependencyKey: DependencyKey { + static var liveValue: FileStorage { + .fileSystem + } + static var previewValue: FileStorage { + .inMemory + } + static var testValue: FileStorage { + .inMemory + } +} + +extension DependencyValues { + /// Default file storage used by ``PersistenceReaderKey/fileStorage(_:)``. + /// + /// Use this dependency to override the manner in which ``PersistenceReaderKey/fileStorage(_:)`` + /// interacts with file storage. For example, while your app is running for UI tests you + /// probably do not want your features writing changes to disk, which would cause that data to + /// bleed over from test to test. + /// + /// So, for that situation you can use the ``FileStorage/inMemory`` file storage so that each + /// run of the app starts with a fresh "file system" that will never interfer with other tests: + /// + /// ```swift + /// @main + /// struct EntryPoint: App { + /// let store = Store(initialState: AppFeature.State()) { + /// AppFeature() + /// } withDependencies: { + /// if ProcessInfo.processInfo.environment["UITesting"] == "true" { + /// $0.defaultFileStorage = .inMemory + /// } + /// } + /// } + /// ``` + public var defaultFileStorage: FileStorage { + get { self[FileStorageDependencyKey.self] } + set { self[FileStorageDependencyKey.self] = newValue } + } +} + +/// A type that encapsulates saving and loading data from disk. +public struct FileStorage: Sendable { + let id: AnyHashableSendable + let async: @Sendable (DispatchWorkItem) -> Void + let asyncAfter: @Sendable (DispatchTimeInterval, DispatchWorkItem) -> Void + let createDirectory: @Sendable (URL, Bool) throws -> Void + let fileExists: @Sendable (URL) -> Bool + let fileSystemSource: + @Sendable (URL, DispatchSource.FileSystemEvent, @escaping () -> Void) -> AnyCancellable + let load: @Sendable (URL) throws -> Data + @_spi(Internals) public let save: @Sendable (Data, URL) throws -> Void + + /// File storage that interacts directly with the file system for saving, loading and listening + /// for file changes. + /// + /// This is the version of the ``Dependencies/DependencyValues/defaultFileStorage`` dependency + /// that is used by default when running your app in the simulator or on device. + public static var fileSystem = fileSystem( + queue: DispatchQueue(label: "co.pointfree.ComposableArchitecture.FileStorage") + ) + + /// File storage that emulates a file system without actually writing anything to disk. + /// + /// This is the version of the ``Dependencies/DependencyValues/defaultFileStorage`` dependency + /// that is used by default when running your app in tests and previews. + public static var inMemory: Self { + inMemory(fileSystem: LockIsolated([:])) + } + + @_spi(Internals) public static func fileSystem(queue: DispatchQueue) -> Self { + Self( + id: AnyHashableSendable(queue), + async: { queue.async(execute: $0) }, + asyncAfter: { queue.asyncAfter(deadline: .now() + $0, execute: $1) }, + createDirectory: { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: $1) + }, + fileExists: { FileManager.default.fileExists(atPath: $0.path) }, + fileSystemSource: { + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: open($0.path, O_EVTONLY), + eventMask: $1, + queue: queue + ) + source.setEventHandler(handler: $2) + source.resume() + return AnyCancellable { + source.cancel() + close(source.handle) + } + }, + load: { try Data(contentsOf: $0) }, + save: { try $0.write(to: $1) } + ) + } + + @_spi(Internals) public static func inMemory( + fileSystem: LockIsolated<[URL: Data]>, + scheduler: AnySchedulerOf = .immediate + ) -> Self { + let sourceHandlers = LockIsolated<[URL: Set]>([:]) + return Self( + id: AnyHashableSendable(ObjectIdentifier(fileSystem)), + async: { scheduler.schedule($0.perform) }, + asyncAfter: { scheduler.schedule(after: scheduler.now.advanced(by: .init($0)), $1.perform) }, + createDirectory: { _, _ in }, + fileExists: { fileSystem.keys.contains($0) }, + fileSystemSource: { url, _, handler in + let handler = Handler(operation: handler) + sourceHandlers.withValue { _ = $0[url, default: []].insert(handler) } + return AnyCancellable { + sourceHandlers.withValue { _ = $0[url]?.remove(handler) } + } + }, + load: { + guard let data = fileSystem[$0] + else { + struct LoadError: Error {} + throw LoadError() + } + return data + }, + save: { data, url in + fileSystem.withValue { $0[url] = data } + sourceHandlers.withValue { $0[url]?.forEach { $0.operation() } } + } + ) + } + + fileprivate struct Handler: Hashable { + let id = UUID() + let operation: () -> Void + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + } +} diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift new file mode 100644 index 000000000000..e1cd0fda8e83 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/InMemoryKey.swift @@ -0,0 +1,53 @@ +import Dependencies +import Foundation + +extension PersistenceReaderKey { + /// Creates a persistence key for sharing data in-memory for the lifetime of an application. + /// + /// For example, one could initialize a key with the date and time at which the application was + /// most recently launched, and access this date from anywhere using the ``Shared`` property + /// wrapper: + /// + /// ```swift + /// @Shared(.inMemory("appLaunchedAt")) var appLaunchedAt = Date() + /// ``` + /// + /// - Parameter key: A string key identifying a value to share in memory. + /// - Returns: An in-memory persistence key. + public static func inMemory(_ key: String) -> Self + where Self == InMemoryKey { + InMemoryKey(key) + } +} + +/// A type defining an in-memory persistence strategy +/// +/// See ``PersistenceReaderKey/inMemory(_:)`` to create values of this type. +public struct InMemoryKey: Hashable, PersistenceKey, Sendable { + let key: String + let store: InMemoryStorage + public init(_ key: String) { + @Dependency(\.defaultInMemoryStorage) var defaultInMemoryStorage + self.key = key + self.store = defaultInMemoryStorage + } + public func load(initialValue: Value?) -> Value? { initialValue } + public func save(_ value: Value) {} +} + +public struct InMemoryStorage: Hashable, Sendable { + private let id = UUID() + public init() {} +} + +private enum DefaultInMemoryStorageKey: DependencyKey { + static var liveValue: InMemoryStorage { InMemoryStorage() } + static var testValue: InMemoryStorage { InMemoryStorage() } +} + +extension DependencyValues { + public var defaultInMemoryStorage: InMemoryStorage { + get { self[DefaultInMemoryStorageKey.self] } + set { self[DefaultInMemoryStorageKey.self] = newValue } + } +} diff --git a/Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift b/Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift new file mode 100644 index 000000000000..bd1699073e9a --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift @@ -0,0 +1,57 @@ +/// A persistence key that provides a default value to an existing persistence key. +/// +/// Use this persistence key when constructing type-safe keys (see +/// for more info) to provide a deafult that is used instead of +/// providing one at the call site of using [`@Shared`](). +/// +/// For example, if an `isOn` value is backed by user defaults and it should default to `false` when +/// there is no value in user defaults, then you can define a persistence key like so: +/// +/// ```swift +/// extension PersistenceReaderKey where Self == PersistenceKeyDefault> { +/// static var isOn: Self { +/// PersistenceKeyDefault(.appStorage("isOn"), false) +/// } +/// } +/// ``` +/// +/// And then use it like so: +/// +/// ```swift +/// struct State { +/// @Shared(.isOn) var isOn +/// } +/// ``` +public struct PersistenceKeyDefault: PersistenceReaderKey { + let base: Base + let defaultValue: Base.Value + + public init(_ key: Base, _ value: Base.Value) { + self.base = key + self.defaultValue = value + } + + public func load(initialValue: Base.Value?) -> Base.Value? { + self.base.load(initialValue: initialValue ?? self.defaultValue) + } + + public func subscribe( + initialValue: Base.Value?, + didSet: @Sendable @escaping (Base.Value?) -> Void + ) -> Shared.Subscription { + self.base.subscribe(initialValue: initialValue, didSet: didSet) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.base) + } + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.base == rhs.base + } +} + +extension PersistenceKeyDefault: PersistenceKey where Base: PersistenceKey { + public func save(_ value: Value) { + self.base.save(value) + } +} diff --git a/Sources/ComposableArchitecture/SharedState/Reference.swift b/Sources/ComposableArchitecture/SharedState/Reference.swift new file mode 100644 index 000000000000..bf115fedaea4 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/Reference.swift @@ -0,0 +1,14 @@ +#if canImport(Combine) + import Combine +#endif + +protocol Reference: AnyObject, CustomStringConvertible { + associatedtype Value + var value: Value { get set } + + func access() + func withMutation(_ mutation: () throws -> T) rethrows -> T + #if canImport(Combine) + var publisher: AnyPublisher { get } + #endif +} diff --git a/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift new file mode 100644 index 000000000000..a0167e5433c5 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift @@ -0,0 +1,286 @@ +import Dependencies +import Foundation + +#if canImport(Combine) + import Combine +#endif +#if canImport(Perception) + import Perception +#endif + +extension Shared { + public init( + wrappedValue value: Value, + _ persistenceKey: some PersistenceKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + reference: { + @Dependency(\.persistentReferences) var references + return references.withValue { + if let reference = $0[persistenceKey] { + return reference + } else { + let reference = ValueReference( + initialValue: value, + persistenceKey: persistenceKey, + fileID: fileID, + line: line + ) + $0[persistenceKey] = reference + return reference + } + } + }(), + keyPath: \Value.self + ) + } + + @_disfavoredOverload + public init( + _ persistenceKey: some PersistenceKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Value == Wrapped? { + self.init(wrappedValue: nil, persistenceKey, fileID: fileID, line: line) + } + + @_disfavoredOverload + public init( + _ persistenceKey: some PersistenceKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) throws { + guard let initialValue = persistenceKey.load(initialValue: nil) + else { + throw LoadError() + } + self.init(wrappedValue: initialValue, persistenceKey, fileID: fileID, line: line) + } + + public init( + _ persistenceKey: PersistenceKeyDefault, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Key.Value == Value { + self.init( + wrappedValue: persistenceKey.load(initialValue: nil) ?? persistenceKey.defaultValue, + persistenceKey.base, + fileID: fileID, + line: line + ) + } + + public init( + wrappedValue: Value, + _ persistenceKey: PersistenceKeyDefault, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Key.Value == Value { + self.init( + wrappedValue: wrappedValue, + persistenceKey.base, + fileID: fileID, + line: line + ) + } +} + +extension SharedReader { + public init( + wrappedValue value: Value, + _ persistenceKey: some PersistenceReaderKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + reference: { + @Dependency(\.persistentReferences) var references + return references.withValue { + if let reference = $0[persistenceKey] { + return reference + } else { + let reference = ValueReference( + initialValue: value, + persistenceKey: persistenceKey, + fileID: fileID, + line: line + ) + $0[persistenceKey] = reference + return reference + } + } + }(), + keyPath: \Value.self + ) + } + + @_disfavoredOverload + public init( + _ persistenceKey: some PersistenceReaderKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Value == Wrapped? { + self.init(wrappedValue: nil, persistenceKey, fileID: fileID, line: line) + } + + @_disfavoredOverload + public init( + _ persistenceKey: some PersistenceReaderKey, + fileID: StaticString = #fileID, + line: UInt = #line + ) throws { + guard let initialValue = persistenceKey.load(initialValue: nil) + else { + throw LoadError() + } + self.init(wrappedValue: initialValue, persistenceKey, fileID: fileID, line: line) + } + + public init( + _ persistenceKey: PersistenceKeyDefault, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Key.Value == Value { + self.init( + wrappedValue: persistenceKey.load(initialValue: nil) ?? persistenceKey.defaultValue, + persistenceKey.base, + fileID: fileID, + line: line + ) + } + + public init( + wrappedValue: Value, + _ persistenceKey: PersistenceKeyDefault, + fileID: StaticString = #fileID, + line: UInt = #line + ) where Key.Value == Value { + self.init( + wrappedValue: wrappedValue, + persistenceKey.base, + fileID: fileID, + line: line + ) + } +} + +private struct LoadError: Error {} + +final class ValueReference>: Reference, @unchecked + Sendable +{ + private let lock = NSRecursiveLock() + private let persistenceKey: Persistence? + #if canImport(Combine) + private let subject: CurrentValueRelay + #endif + private var subscription: Shared.Subscription? + private var _value: Value { + willSet { + self.subject.send(newValue) + } + } + #if canImport(Perception) + private let _$perceptionRegistrar = PerceptionRegistrar( + isPerceptionCheckingEnabled: _isStorePerceptionCheckingEnabled + ) + #endif + private let fileID: StaticString + private let line: UInt + var value: Value { + get { + #if canImport(Perception) + self._$perceptionRegistrar.access(self, keyPath: \.value) + #endif + return self.lock.withLock { self._value } + } + set { + #if canImport(Perception) + self._$perceptionRegistrar.willSet(self, keyPath: \.value) + defer { self._$perceptionRegistrar.didSet(self, keyPath: \.value) } + #endif + self.lock.withLock { + self._value = newValue + func open(_ key: some PersistenceKey) { + key.save(self._value as! A) + } + guard let key = self.persistenceKey as? any PersistenceKey + else { return } + open(key) + } + } + } + #if canImport(Combine) + var publisher: AnyPublisher { + self.subject.dropFirst().eraseToAnyPublisher() + } + #endif + init( + initialValue: Value, + persistenceKey: Persistence? = nil, + fileID: StaticString, + line: UInt + ) { + self._value = persistenceKey?.load(initialValue: initialValue) ?? initialValue + self.persistenceKey = persistenceKey + #if canImport(Combine) + self.subject = CurrentValueRelay(initialValue) + #endif + self.fileID = fileID + self.line = line + if let persistenceKey { + self.subscription = persistenceKey.subscribe( + initialValue: initialValue + ) { [weak self] value in + guard let self else { return } + #if canImport(Perception) + self._$perceptionRegistrar.willSet(self, keyPath: \.value) + defer { self._$perceptionRegistrar.didSet(self, keyPath: \.value) } + #endif + self.lock.withLock { + self._value = value ?? initialValue + } + } + } + } + func access() { + #if canImport(Perception) + _$perceptionRegistrar.access(self, keyPath: \.value) + #endif + } + func withMutation(_ mutation: () throws -> T) rethrows -> T { + #if canImport(Perception) + self._$perceptionRegistrar.willSet(self, keyPath: \.value) + defer { self._$perceptionRegistrar.didSet(self, keyPath: \.value) } + #endif + return try mutation() + } + var description: String { + "Shared<\(Value.self)>@\(self.fileID):\(self.line)" + } +} + +#if canImport(Observation) + extension ValueReference: Observable {} +#endif +#if canImport(Perception) + extension ValueReference: Perceptible {} +#endif + +private enum PersistentReferencesKey: DependencyKey { + static var liveValue: LockIsolated<[AnyHashable: any Reference]> { + LockIsolated([:]) + } + static var testValue: LockIsolated<[AnyHashable: any Reference]> { + LockIsolated([:]) + } +} + +extension DependencyValues { + var persistentReferences: LockIsolated<[AnyHashable: any Reference]> { + get { self[PersistentReferencesKey.self] } + set { self[PersistentReferencesKey.self] = newValue } + } +} diff --git a/Sources/ComposableArchitecture/SharedState/Shared.swift b/Sources/ComposableArchitecture/SharedState/Shared.swift new file mode 100644 index 000000000000..94624ac2c5ed --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/Shared.swift @@ -0,0 +1,373 @@ +import CustomDump +import Dependencies +import XCTestDynamicOverlay + +#if canImport(Combine) + import Combine +#endif + +/// A property wrapper type that shares a value with multiple parts of an application. +/// +/// See the article for more detailed information on how to use this property +/// wrapper. +@dynamicMemberLookup +@propertyWrapper +public struct Shared { + private let reference: any Reference + private let keyPath: AnyKeyPath + + init(reference: any Reference, keyPath: AnyKeyPath) { + self.reference = reference + self.keyPath = keyPath + } + + public init(_ value: Value, fileID: StaticString = #fileID, line: UInt = #line) { + self.init( + reference: ValueReference>( + initialValue: value, + fileID: fileID, + line: line + ), + keyPath: \Value.self + ) + } + + public init(projectedValue: Shared) { + self = projectedValue + } + + public init?(_ base: Shared) { + guard let shared = base[dynamicMember: \.self] else { return nil } + self = shared + } + + public var wrappedValue: Value { + get { + @Dependency(\.sharedChangeTracker) var changeTracker + if changeTracker != nil { + return self.snapshot ?? self.currentValue + } else { + return self.currentValue + } + } + nonmutating set { + @Dependency(\.sharedChangeTracker) var changeTracker + if changeTracker != nil { + self.snapshot = newValue + } else { + @Dependency(\.sharedChangeTrackers) var changeTrackers: Set + for changeTracker in changeTrackers { + changeTracker.track(self.reference) + } + self.currentValue = newValue + } + } + } + + /// A projection of the shared value that returns a shared reference. + /// + /// Use the projected value to pass a shared value down to another feature. This is most + /// commonly done to share a value from one feature to another: + /// + /// ```swift + /// case .nextButtonTapped: + /// state.path.append( + /// PersonalInfoFeature(signUpData: state.$signUpData) + /// ) + /// ``` + /// + /// Further you can use dot-chaining syntax to derive a smaller piece of shared state to hand + /// to another feature: + /// + /// ```swift + /// case .nextButtonTapped: + /// state.path.append( + /// PhoneNumberFeature(phoneNumber: state.$signUpData.phoneNumber) + /// ) + /// ``` + /// + /// See for more details. + public var projectedValue: Self { + get { + reference.access() + return self + } + set { + reference.withMutation { + self = newValue + } + } + } + + #if canImport(Combine) + // TODO: Should this be wrapped in a type we own instead of `AnyPublisher`? + public var publisher: AnyPublisher { + func open(_ reference: some Reference) -> AnyPublisher { + reference.publisher + .map { $0[keyPath: unsafeDowncast(self.keyPath, to: KeyPath.self)] } + .eraseToAnyPublisher() + } + return open(self.reference) + } + #endif + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Shared { + Shared(reference: self.reference, keyPath: self.keyPath.appending(path: keyPath)!) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Shared? { + guard let initialValue = self.wrappedValue[keyPath: keyPath] + else { return nil } + return Shared( + reference: self.reference, + keyPath: self.keyPath.appending( + path: keyPath.appending(path: \.[default: DefaultSubscript(initialValue)]) + )! + ) + } + + public func assert( + _ updateValueToExpectedResult: (inout Value) throws -> Void, + file: StaticString = #file, + line: UInt = #line + ) rethrows where Value: Equatable { + @Dependency(\.sharedChangeTrackers) var changeTrackers + guard + let changeTracker = + changeTrackers + .first(where: { $0.changes[ObjectIdentifier(self.reference)] != nil }) + else { + XCTFail("Expected changes, but none occurred.", file: file, line: line) + return + } + try changeTracker.assert { + guard var snapshot = self.snapshot, snapshot != self.currentValue else { + XCTFail("Expected changes, but none occurred.", file: file, line: line) + return + } + try updateValueToExpectedResult(&snapshot) + self.snapshot = snapshot + // TODO: Finesse error more than `XCTAssertNoDifference` + XCTAssertNoDifference(self.currentValue, self.snapshot, file: file, line: line) + self.snapshot = nil + } + } + + private var currentValue: Value { + get { + func open(_ reference: some Reference) -> Value { + reference.value[ + keyPath: unsafeDowncast(self.keyPath, to: KeyPath.self) + ] + } + return open(self.reference) + } + nonmutating set { + func open(_ reference: some Reference) { + reference.value[ + keyPath: unsafeDowncast(self.keyPath, to: WritableKeyPath.self) + ] = newValue + } + return open(self.reference) + } + } + + private var snapshot: Value? { + get { + func open(_ reference: some Reference) -> Value? { + @Dependency(\.sharedChangeTracker) var changeTracker + return changeTracker?[reference]?.snapshot[ + keyPath: unsafeDowncast(self.keyPath, to: WritableKeyPath.self) + ] + } + return open(self.reference) + } + nonmutating set { + func open(_ reference: some Reference) { + @Dependency(\.sharedChangeTracker) var changeTracker + guard let newValue else { + changeTracker?[reference] = nil + return + } + if changeTracker?[reference] == nil { + changeTracker?[reference] = AnyChange(reference) + } + changeTracker?[reference]?.snapshot[ + keyPath: unsafeDowncast(self.keyPath, to: WritableKeyPath.self) + ] = newValue + } + return open(self.reference) + } + } +} + +extension Shared: @unchecked Sendable where Value: Sendable {} + +extension Shared: Equatable where Value: Equatable { + public static func == (lhs: Shared, rhs: Shared) -> Bool { + @Dependency(\.sharedChangeTracker) var changeTracker + if changeTracker != nil, lhs.reference === rhs.reference, lhs.keyPath == rhs.keyPath { + if let lhsReference = lhs.reference as? any Equatable { + func open(_ lhsReference: T) -> Bool { + lhsReference == rhs.reference as? T + } + return open(lhsReference) + } + return lhs.snapshot ?? lhs.currentValue == rhs.currentValue + } else { + return lhs.wrappedValue == rhs.wrappedValue + } + } +} + +extension Shared: Identifiable where Value: Identifiable { + public var id: Value.ID { + self.wrappedValue.id + } +} + +extension Shared: CustomDumpRepresentable { + public var customDumpValue: Any { + self.currentValue + } +} + +extension Shared: _CustomDiffObject { + public var _customDiffValues: (Any, Any) { + (self.snapshot ?? self.currentValue, self.currentValue) + } + + public var _objectIdentifier: ObjectIdentifier { + ObjectIdentifier(self.reference) + } +} + +extension Shared +where Value: RandomAccessCollection & MutableCollection, Value.Index: Hashable & Sendable { + /// Allows a `ForEach` view to transform a shared collection into shared elements. + /// + /// ```swift + /// struct State { + /// @Shared(.fileStorage(.todos)) var todos: IdentifiedArrayOf = [] + /// // ... + /// } + /// + /// // ... + /// + /// ForEach(store.$todos.elements) { $todo in + /// NavigationLink( + /// // $todo: Shared + /// // todo: Todo + /// state: Path.State.todo(TodoFeature.State(todo: $todo)) + /// ) { + /// Text(todo.title) + /// } + /// } + /// ``` + /// + /// > Warning: It is not appropriate to use this property outside of SwiftUI's `ForEach` view. If + /// > you need to derive a shared element from a shared collection, use a stable lookup, instead, + /// > like the `$array[id:]` subscript on `IdentifiedArray`. + public var elements: some RandomAccessCollection> { + zip(self.wrappedValue.indices, self.wrappedValue).lazy.map { index, element in + self[index, default: DefaultSubscript(element)] + } + } +} + +@available( + *, + unavailable, + message: + "Derive shared elements from a stable subscript, like '$array[id:]' on 'IdentifiedArray', or pass '$array.elements' to a 'ForEach' view." +) +extension Shared: Collection, Sequence +where Value: MutableCollection & RandomAccessCollection, Value.Index: Hashable { + public var startIndex: Value.Index { + assertionFailure("Conformance of 'Shared' to 'Collection' is unavailable.") + return self.wrappedValue.startIndex + } + public var endIndex: Value.Index { + assertionFailure("Conformance of 'Shared' to 'Collection' is unavailable.") + return self.wrappedValue.endIndex + } + public func index(after i: Value.Index) -> Value.Index { + assertionFailure("Conformance of 'Shared' to 'Collection' is unavailable.") + return self.wrappedValue.index(after: i) + } +} + +@available( + *, + unavailable, + message: + "Derive shared elements from a stable subscript, like '$array[id:]' on 'IdentifiedArray', or pass '$array.elements' to a 'ForEach' view." +) +extension Shared: MutableCollection +where Value: MutableCollection & RandomAccessCollection, Value.Index: Hashable { + public subscript(position: Value.Index) -> Shared { + get { + assertionFailure("Conformance of 'Shared' to 'MutableCollection' is unavailable.") + return self[position, default: DefaultSubscript(self.wrappedValue[position])] + } + set { + self.wrappedValue[position] = newValue.wrappedValue + } + } +} + +@available( + *, + unavailable, + message: + "Derive shared elements from a stable subscript, like '$array[id:]' on 'IdentifiedArray', or pass '$array.elements' to a 'ForEach' view." +) +extension Shared: BidirectionalCollection +where Value: MutableCollection & RandomAccessCollection, Value.Index: Hashable { + public func index(before i: Value.Index) -> Value.Index { + assertionFailure("Conformance of 'Shared' to 'BidirectionalCollection' is unavailable.") + return self.wrappedValue.index(before: i) + } +} + +@available( + *, + unavailable, + message: + "Derive shared elements from a stable subscript, like '$array[id:]' on 'IdentifiedArray', or pass '$array.elements' to a 'ForEach' view." +) +extension Shared: RandomAccessCollection +where Value: MutableCollection & RandomAccessCollection, Value.Index: Hashable { +} + +extension Shared { + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader { + SharedReader( + reference: self.reference, + keyPath: self.keyPath.appending(path: keyPath)! + ) + } + + public var reader: SharedReader { + SharedReader(reference: self.reference, keyPath: self.keyPath) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader? { + guard let initialValue = self.wrappedValue[keyPath: keyPath] + else { return nil } + return SharedReader( + reference: self.reference, + keyPath: self.keyPath.appending( + path: keyPath.appending(path: \.[default: DefaultSubscript(initialValue)]) + )! + ) + } +} diff --git a/Sources/ComposableArchitecture/SharedState/SharedChangeTracking.swift b/Sources/ComposableArchitecture/SharedState/SharedChangeTracking.swift new file mode 100644 index 000000000000..5102be06c586 --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/SharedChangeTracking.swift @@ -0,0 +1,142 @@ +import CustomDump +import Dependencies + +@_spi(Internals) +public func withSharedChangeTracking( + _ apply: (SharedChangeTracker) throws -> T +) rethrows -> T { + let changeTracker = SharedChangeTracker() + return try changeTracker.track { + try apply(changeTracker) + } +} + +@_spi(Internals) +public func withSharedChangeTracking( + _ apply: (SharedChangeTracker) async throws -> T +) async rethrows -> T { + let changeTracker = SharedChangeTracker() + return try await changeTracker.track { + try await apply(changeTracker) + } +} + +protocol Change { + associatedtype Value + var reference: any Reference { get } + var snapshot: Value { get set } +} + +extension Change { + func assertUnchanged() { + if let difference = diff(snapshot, self.reference.value, format: .proportional) { + XCTFail( + """ + Tracked changes to '\(self.reference.description)' but failed to assert: â€Ļ + + \(difference.indent(by: 2)) + + (Before: −, After: +) + + Call 'Shared<\(Value.self)>.assert' to exhaustively test these changes, or call \ + 'skipChanges' to ignore them. + """ + ) + } + } +} + +struct AnyChange: Change { + let reference: any Reference + var snapshot: Value + + init(_ reference: some Reference) { + self.reference = reference + self.snapshot = reference.value + } +} + +@_spi(Internals) +public final class SharedChangeTracker: Sendable { + let changes: LockIsolated<[ObjectIdentifier: Any]> = LockIsolated([:]) + var hasChanges: Bool { !self.changes.isEmpty } + @_spi(Internals) public init() {} + func resetChanges() { self.changes.withValue { $0.removeAll() } } + func assertUnchanged() { + for change in self.changes.values { + if let change = change as? any Change { + change.assertUnchanged() + } + } + self.resetChanges() + } + func track(_ reference: some Reference) { + if !self.changes.keys.contains(ObjectIdentifier(reference)) { + self.changes.withValue { $0[ObjectIdentifier(reference)] = AnyChange(reference) } + } + } + subscript(_ reference: some Reference) -> AnyChange? { + _read { yield self.changes[ObjectIdentifier(reference)] as? AnyChange } + _modify { + var change = self.changes[ObjectIdentifier(reference)] as? AnyChange + yield &change + self.changes.withValue { [change] in $0[ObjectIdentifier(reference)] = change } + } + } + func track(_ operation: () throws -> R) rethrows -> R { + try withDependencies { + $0.sharedChangeTrackers.insert(self) + } operation: { + try operation() + } + } + func track(_ operation: () async throws -> R) async rethrows -> R { + try await withDependencies { + $0.sharedChangeTrackers.insert(self) + } operation: { + try await operation() + } + } + @_spi(Internals) + public func assert(_ operation: () throws -> R) rethrows -> R { + try withDependencies { + $0.sharedChangeTracker = self + } operation: { + try operation() + } + } +} + +extension SharedChangeTracker: Hashable { + @_spi(Internals) + public static func == (lhs: SharedChangeTracker, rhs: SharedChangeTracker) -> Bool { + lhs === rhs + } + @_spi(Internals) + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +private enum SharedChangeTrackersKey: DependencyKey { + static var liveValue: Set { [] } + static var testValue: Set { [SharedChangeTracker()] } +} + +private enum SharedChangeTrackerKey: DependencyKey { + static var liveValue: SharedChangeTracker? { nil } + static var testValue: SharedChangeTracker? { nil } +} + +extension DependencyValues { + @_spi(Internals) + public var sharedChangeTrackers: Set { + get { self[SharedChangeTrackersKey.self] } + set { self[SharedChangeTrackersKey.self] = newValue } + } + @_spi(Internals) + public var sharedChangeTracker: SharedChangeTracker? { + get { self[SharedChangeTrackerKey.self] } + set { self[SharedChangeTrackerKey.self] = newValue } + } +} diff --git a/Sources/ComposableArchitecture/SharedState/SharedReader.swift b/Sources/ComposableArchitecture/SharedState/SharedReader.swift new file mode 100644 index 000000000000..54698248ab4d --- /dev/null +++ b/Sources/ComposableArchitecture/SharedState/SharedReader.swift @@ -0,0 +1,139 @@ +#if canImport(Combine) +import Combine +#endif + +/// A property wrapper type that shares a value with multiple parts of an application. +/// +/// See the article for more detailed information on how to use this property +/// wrapper, in particular . +@dynamicMemberLookup +@propertyWrapper +public struct SharedReader { + fileprivate let reference: any Reference + fileprivate let keyPath: AnyKeyPath + + init(reference: any Reference, keyPath: AnyKeyPath) { + self.reference = reference + self.keyPath = keyPath + } + + init(reference: some Reference) { + self.init(reference: reference, keyPath: \Value.self) + } + + public init(projectedValue: SharedReader) { + self = projectedValue + } + + public init?(_ base: SharedReader) { + guard let shared = base[dynamicMember: \.self] else { return nil } + self = shared + } + + public init(_ base: Shared) { + self = base.reader + } + + public var wrappedValue: Value { + func open(_ reference: some Reference) -> Value { + reference.value[ + keyPath: unsafeDowncast(self.keyPath, to: KeyPath.self) + ] + } + return open(self.reference) + } + + public var projectedValue: Self { + get { + reference.access() + return self + } + set { + reference.withMutation { + self = newValue + } + } + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader { + SharedReader(reference: self.reference, keyPath: self.keyPath.appending(path: keyPath)!) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> SharedReader? { + guard let initialValue = self.wrappedValue[keyPath: keyPath] + else { return nil } + return SharedReader( + reference: self.reference, + keyPath: self.keyPath.appending( + path: keyPath.appending(path: \.[default:DefaultSubscript(initialValue)]) + )! + ) + } + +#if canImport(Combine) + // TODO: Should this be wrapped in a type we own instead of `AnyPublisher`? + public var publisher: AnyPublisher { + func open(_ reference: R) -> AnyPublisher { + return reference.publisher + .compactMap { $0[keyPath: self.keyPath] as? Value } + .eraseToAnyPublisher() + } + return open(self.reference) + } +#endif +} + +extension SharedReader: @unchecked Sendable where Value: Sendable {} + +extension SharedReader: Equatable where Value: Equatable { + public static func == (lhs: SharedReader, rhs: SharedReader) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension SharedReader: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.wrappedValue) + } +} + +extension SharedReader: Identifiable where Value: Identifiable { + public var id: Value.ID { + self.wrappedValue.id + } +} + +extension SharedReader: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } catch { + try self.wrappedValue.encode(to: encoder) + } + } +} + +extension SharedReader: CustomDumpRepresentable { + public var customDumpValue: Any { + self.wrappedValue + } +} + +extension SharedReader +where Value: RandomAccessCollection & MutableCollection, Value.Index: Hashable & Sendable { + /// Derives a collection of read-only shared elements from a read-only shared collection of + /// elements. + /// + /// See the documentation for [`@Shared`]()'s ``Shared/elements`` for more + /// information. + public var elements: some RandomAccessCollection> { + zip(self.wrappedValue.indices, self.wrappedValue).lazy.map { index, element in + self[index, default: DefaultSubscript(element)] + } + } +} diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 835d60bb5365..c92a5b8b2abc 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -695,7 +695,7 @@ private enum PartialToState { } #if canImport(Perception) - private let _isStorePerceptionCheckingEnabled: Bool = { + let _isStorePerceptionCheckingEnabled: Bool = { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { return false } else { diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 87b77fc9c9db..0ea026c25b2e 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -488,8 +488,9 @@ public final class TestStore { public var timeout: UInt64 private let file: StaticString - private var line: UInt + private let line: UInt let reducer: TestReducer + private let sharedChangeTracker: SharedChangeTracker private let store: Store.TestAction> /// Creates a test store with an initial state and a reducer powering its runtime. @@ -515,13 +516,13 @@ public final class TestStore { file: StaticString = #file, line: UInt = #line ) - where - R.State == State, - R.Action == Action, - State: Equatable - { + where State: Equatable, R.State == State, R.Action == Action { + let sharedChangeTracker = SharedChangeTracker() let reducer = XCTFailContext.$current.withValue(XCTFailContext(file: file, line: line)) { - Dependencies.withDependencies(prepareDependencies) { + Dependencies.withDependencies { + prepareDependencies(&$0) + $0.sharedChangeTrackers.insert(sharedChangeTracker) + } operation: { TestReducer(Reduce(reducer()), initialState: initialState()) } } @@ -530,6 +531,7 @@ public final class TestStore { self.reducer = reducer self.store = Store(initialState: reducer.state) { reducer } self.timeout = 1 * NSEC_PER_SEC + self.sharedChangeTracker = sharedChangeTracker self.useMainSerialExecutor = true } @@ -619,7 +621,7 @@ public final class TestStore { XCTFailHelper( """ The store received \(self.reducer.receivedActions.count) unexpected \ - action\(self.reducer.receivedActions.count == 1 ? "" : "s") after this one: â€Ļ + action\(self.reducer.receivedActions.count == 1 ? "" : "s") by the end of this test: â€Ļ Unhandled actions: \(actions) @@ -656,6 +658,27 @@ public final class TestStore { line: effect.action.line ) } + // NB: This existential opening can go away if we can constrain 'State: Equatable' at the + // 'TestStore' level, but for some reason this breaks DocC. + if self.sharedChangeTracker.hasChanges, let stateType = State.self as? any Equatable.Type { + func open(_: EquatableState.Type) { + let store = self as! TestStore + try? store.expectedStateShouldMatch( + preamble: "Test store completed before asserting against changes to shared state", + postamble: """ + Invoke "TestStore.assert" at the end of this test to assert against changes to shared \ + state. + """, + expected: store.state, + actual: store.state, + updateStateToExpectedResult: nil, + skipUnnecessaryModifyFailure: true, + file: store.file, + line: store.line + ) + } + open(stateType) + } } /// Overrides the store's dependencies for a given operation. @@ -850,10 +873,12 @@ extension TestStore where State: Equatable { let expectedState = self.state let previousState = self.reducer.state let previousStackElementID = self.reducer.dependencies.stackElementID.incrementingCopy() - let task = self.store.send( - .init(origin: .send(action), file: file, line: line), - originatingFrom: nil - ) + let task = self.sharedChangeTracker.track { + self.store.send( + .init(origin: .send(action), file: file, line: line), + originatingFrom: nil + ) + } if uncheckedUseMainSerialExecutor { await Task.yield() } else { @@ -881,9 +906,6 @@ extension TestStore where State: Equatable { } catch { XCTFail("Threw error: \(error)", file: file, line: line) } - if "\(self.file)" == "\(file)" { - self.line = line - } // NB: Give concurrency runtime more time to kick off effects so users don't need to manually // instrument their effects. await Task.megaYield(count: 20) @@ -947,6 +969,8 @@ extension TestStore where State: Equatable { } private func expectedStateShouldMatch( + preamble: String = "", + postamble: String = "", expected: State, actual: State, updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, @@ -954,136 +978,152 @@ extension TestStore where State: Equatable { file: StaticString, line: UInt ) throws { - let current = expected - var expected = expected - - let currentStackElementID = self.reducer.dependencies.stackElementID - let copiedStackElementID = currentStackElementID.incrementingCopy() - self.reducer.dependencies.stackElementID = copiedStackElementID - defer { - self.reducer.dependencies.stackElementID = currentStackElementID - } - - let updateStateToExpectedResult = updateStateToExpectedResult.map { original in - { (state: inout State) in - try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { - try original(&state) - } + try self.sharedChangeTracker.assert { + let skipUnnecessaryModifyFailure = + skipUnnecessaryModifyFailure + || self.sharedChangeTracker.hasChanges == true + if self.exhaustivity != .on { + self.sharedChangeTracker.resetChanges() } - } - switch self.exhaustivity { - case .on: - var expectedWhenGivenPreviousState = expected - if let updateStateToExpectedResult { - try Dependencies.withDependencies { - $0 = self.reducer.dependencies - } operation: { - try updateStateToExpectedResult(&expectedWhenGivenPreviousState) - } - } - expected = expectedWhenGivenPreviousState + let current = expected + var expected = expected - if expectedWhenGivenPreviousState != actual { - expectationFailure(expected: expectedWhenGivenPreviousState) - } else { - tryUnnecessaryModifyFailure() + let currentStackElementID = self.reducer.dependencies.stackElementID + let copiedStackElementID = currentStackElementID.incrementingCopy() + self.reducer.dependencies.stackElementID = copiedStackElementID + defer { + self.reducer.dependencies.stackElementID = currentStackElementID } - case .off: - var expectedWhenGivenActualState = actual - if let updateStateToExpectedResult { - try Dependencies.withDependencies { - $0 = self.reducer.dependencies - } operation: { - try updateStateToExpectedResult(&expectedWhenGivenActualState) + let updateStateToExpectedResult = updateStateToExpectedResult.map { original in + { (state: inout State) in + try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { + try original(&state) + } } } - expected = expectedWhenGivenActualState - if expectedWhenGivenActualState != actual { - self.withExhaustivity(.on) { - expectationFailure(expected: expectedWhenGivenActualState) - } - } else if self.exhaustivity == .off(showSkippedAssertions: true) - && expectedWhenGivenActualState == actual - { - var expectedWhenGivenPreviousState = current + switch self.exhaustivity { + case .on: + var expectedWhenGivenPreviousState = expected if let updateStateToExpectedResult { - XCTExpectFailure(strict: false) { - do { - try Dependencies.withDependencies { - $0 = self.reducer.dependencies - } operation: { - try updateStateToExpectedResult(&expectedWhenGivenPreviousState) - } - } catch { - XCTFail( - """ - Skipped assertions: â€Ļ - - Threw error: \(error) - """, - file: file, - line: line - ) - } + try Dependencies.withDependencies { + $0 = self.reducer.dependencies + $0.sharedChangeTracker = self.sharedChangeTracker + } operation: { + try updateStateToExpectedResult(&expectedWhenGivenPreviousState) } } expected = expectedWhenGivenPreviousState + if expectedWhenGivenPreviousState != actual { expectationFailure(expected: expectedWhenGivenPreviousState) } else { tryUnnecessaryModifyFailure() } - } else { - tryUnnecessaryModifyFailure() + + case .off: + var expectedWhenGivenActualState = actual + if let updateStateToExpectedResult { + try Dependencies.withDependencies { + $0 = self.reducer.dependencies + $0.sharedChangeTracker = self.sharedChangeTracker + } operation: { + try updateStateToExpectedResult(&expectedWhenGivenActualState) + } + } + expected = expectedWhenGivenActualState + + if expectedWhenGivenActualState != actual { + self.withExhaustivity(.on) { + expectationFailure(expected: expectedWhenGivenActualState) + } + } else if self.exhaustivity == .off(showSkippedAssertions: true) + && expectedWhenGivenActualState == actual + { + var expectedWhenGivenPreviousState = current + if let updateStateToExpectedResult { + XCTExpectFailure(strict: false) { + do { + try Dependencies.withDependencies { + $0 = self.reducer.dependencies + $0.sharedChangeTracker = self.sharedChangeTracker + } operation: { + try updateStateToExpectedResult(&expectedWhenGivenPreviousState) + } + } catch { + XCTFail( + """ + Skipped assertions: â€Ļ + + Threw error: \(error) + """, + file: file, + line: line + ) + } + } + } + expected = expectedWhenGivenPreviousState + if self.withExhaustivity(.on, operation: { expectedWhenGivenPreviousState != actual }) { + expectationFailure(expected: expectedWhenGivenPreviousState) + } else { + tryUnnecessaryModifyFailure() + } + } else { + tryUnnecessaryModifyFailure() + } } - } - func expectationFailure(expected: State) { - let difference = - diff(expected, actual, format: .proportional) - .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } - ?? """ - Expected: - \(String(describing: expected).indent(by: 2)) + func expectationFailure(expected: State) { + let difference = self.withExhaustivity(.on) { + diff(expected, actual, format: .proportional) + .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } + ?? """ + Expected: + \(String(describing: expected).indent(by: 2)) - Actual: - \(String(describing: actual).indent(by: 2)) + Actual: + \(String(describing: actual).indent(by: 2)) + """ + } + let messageHeading = + !preamble.isEmpty + ? preamble + : updateStateToExpectedResult != nil + ? "A state change does not match expectation" + : "State was not expected to change, but a change occurred" + XCTFailHelper( """ - let messageHeading = - updateStateToExpectedResult != nil - ? "A state change does not match expectation" - : "State was not expected to change, but a change occurred" - XCTFailHelper( - """ - \(messageHeading): â€Ļ + \(messageHeading): â€Ļ - \(difference) - """, - file: file, - line: line - ) - } + \(difference)\(postamble.isEmpty ? "" : "\n\n\(postamble)") + """, + file: file, + line: line + ) + } - func tryUnnecessaryModifyFailure() { - guard - !skipUnnecessaryModifyFailure, - expected == current, - updateStateToExpectedResult != nil - else { return } + func tryUnnecessaryModifyFailure() { + guard + !skipUnnecessaryModifyFailure, + expected == current, + updateStateToExpectedResult != nil + else { return } - XCTFailHelper( - """ - Expected state to change, but no change occurred. + XCTFailHelper( + """ + Expected state to change, but no change occurred. - The trailing closure made no observable modifications to state. If no change to state is \ - expected, omit the trailing closure. - """, - file: file, - line: line - ) + The trailing closure made no observable modifications to state. If no change to state is \ + expected, omit the trailing closure. + """, + file: file, + line: line + ) + } + self.sharedChangeTracker.resetChanges() } } } @@ -1108,13 +1148,13 @@ extension TestStore where State: Equatable, Action: Equatable { TaskResultDebugging.$emitRuntimeWarnings.withValue(false) { diff(expectedAction, receivedAction, format: .proportional) .map { "\($0.indent(by: 4))\n\n(Expected: −, Received: +)" } - ?? """ - Expected: - \(String(describing: expectedAction).indent(by: 2)) + ?? """ + Expected: + \(String(describing: expectedAction).indent(by: 2)) - Received: - \(String(describing: receivedAction).indent(by: 2)) - """ + Received: + \(String(describing: receivedAction).indent(by: 2)) + """ } }, updateStateToExpectedResult, @@ -1216,7 +1256,8 @@ extension TestStore where State: Equatable, Action: Equatable { guard !self.reducer.inFlightEffects.isEmpty else { _ = { - self._receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + expectedAction, assert: updateStateToExpectedResult, file: file, line: line) }() return } @@ -1833,9 +1874,6 @@ extension TestStore where State: Equatable { } } self.reducer.state = state - if "\(self.file)" == "\(file)" { - self.line = line - } } @MainActor @@ -2399,7 +2437,8 @@ class TestReducer: Reducer { } func reduce(into state: inout State, action: TestAction) -> Effect { - let reducer = self.base.dependency(\.self, self.dependencies) + let reducer = self.base + .dependency(\.self, self.dependencies) let effects: Effect switch action.origin { @@ -2529,7 +2568,7 @@ extension TestStore { @available( *, unavailable, - message: "'State' and 'Action' must conform to 'Equatable' to assert against received actions." + message: "Provide a key path to the case you expect to receive (like 'store.receive(\\.tap)'), or conform 'Action' to 'Equatable' to assert against it directly." ) public func receive( _ expectedAction: Action, @@ -2538,18 +2577,4 @@ extension TestStore { line: UInt = #line ) async { } - - @MainActor - @discardableResult - @available( - *, unavailable, message: "'State' must conform to 'Equatable' to assert against sent actions." - ) - public func send( - _ action: Action, - assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async -> TestStoreTask { - TestStoreTask(rawValue: nil, timeout: 0) - } } diff --git a/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift index 55cd0af7cfbb..90a6c4b0d5d1 100644 --- a/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift +++ b/Sources/ComposableArchitectureMacros/ObservableStateMacro.swift @@ -46,6 +46,8 @@ public struct ObservableStateMacro { static let ignoredMacroName = "ObservationStateIgnored" static let presentsMacroName = "Presents" static let presentationStatePropertyWrapperName = "PresentationState" + static let sharedPropertyWrapperName = "Shared" + static let sharedReaderPropertyWrapperName = "SharedReader" static let registrarVariableName = "_$observationRegistrar" @@ -444,7 +446,10 @@ extension ObservableStateMacro: MemberAttributeMacro { context: context ) - if property.hasMacroApplication(ObservableStateMacro.presentsMacroName) { + if property.hasMacroApplication(ObservableStateMacro.presentsMacroName) + || property.hasMacroApplication(ObservableStateMacro.sharedPropertyWrapperName) + || property.hasMacroApplication(ObservableStateMacro.sharedReaderPropertyWrapperName) + { return [ AttributeSyntax( attributeName: IdentifierTypeSyntax( @@ -535,6 +540,7 @@ public struct ObservationStateTrackedMacro: AccessorMacro { if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) || property.hasMacroApplication(ObservableStateMacro.presentationStatePropertyWrapperName) || property.hasMacroApplication(ObservableStateMacro.presentsMacroName) + || property.hasMacroApplication(ObservableStateMacro.sharedPropertyWrapperName) { return [] } @@ -593,6 +599,7 @@ extension ObservationStateTrackedMacro: PeerMacro { if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) || property.hasMacroApplication(ObservableStateMacro.presentationStatePropertyWrapperName) || property.hasMacroApplication(ObservableStateMacro.presentsMacroName) + || property.hasMacroApplication(ObservableStateMacro.sharedPropertyWrapperName) || property.hasMacroApplication(ObservableStateMacro.trackedMacroName) { return [] diff --git a/Sources/ComposableArchitectureMacros/PresentsMacro.swift b/Sources/ComposableArchitectureMacros/PresentsMacro.swift index 303ff8fb47ac..648304c5b805 100644 --- a/Sources/ComposableArchitectureMacros/PresentsMacro.swift +++ b/Sources/ComposableArchitectureMacros/PresentsMacro.swift @@ -126,7 +126,7 @@ extension VariableDeclSyntax { } extension PatternBindingListSyntax { - var privateWrapped: PatternBindingListSyntax { + fileprivate var privateWrapped: PatternBindingListSyntax { var bindings = self for index in bindings.indices { var binding = bindings[index] @@ -166,7 +166,7 @@ extension PatternBindingListSyntax { return bindings } - var projected: PatternBindingListSyntax { + fileprivate var projected: PatternBindingListSyntax { var bindings = self for index in bindings.indices { var binding = bindings[index] diff --git a/Tests/ComposableArchitectureTests/AppStorageTests.swift b/Tests/ComposableArchitectureTests/AppStorageTests.swift new file mode 100644 index 000000000000..039bcdb19735 --- /dev/null +++ b/Tests/ComposableArchitectureTests/AppStorageTests.swift @@ -0,0 +1,152 @@ +@_spi(Internals) import ComposableArchitecture +import Perception +import XCTest + +final class AppStorageTests: XCTestCase { + func testBasics() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("count")) var count = 0 + XCTAssertEqual(count, 0) + XCTAssertEqual(defaults.integer(forKey: "count"), 0) + + count += 1 + XCTAssertEqual(count, 1) + XCTAssertEqual(defaults.integer(forKey: "count"), 1) + } + + func testDefaultsRegistered() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("count")) var count = 42 + XCTAssertEqual(defaults.integer(forKey: "count"), 42) + + count += 1 + XCTAssertEqual(count, 43) + XCTAssertEqual(defaults.integer(forKey: "count"), 43) + } + + func testDefaultsRegistered_Optional() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("data")) var data: Data? + XCTAssertEqual(defaults.data(forKey: "data"), nil) + + data = Data() + XCTAssertEqual(data, Data()) + XCTAssertEqual(defaults.data(forKey: "data"), Data()) + } + + func testDefaultsRegistered_RawRepresentable() { + enum Direction: String, CaseIterable { + case north, south, east, west + } + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("direction")) var direction: Direction = .north + XCTAssertEqual(defaults.string(forKey: "direction"), "north") + + direction = .south + XCTAssertEqual(defaults.string(forKey: "direction"), "south") + } + + func testDefaultsRegistered_Optional_RawRepresentable() { + enum Direction: String, CaseIterable { + case north, south, east, west + } + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("direction")) var direction: Direction? + XCTAssertEqual(defaults.string(forKey: "direction"), nil) + + direction = .south + XCTAssertEqual(defaults.string(forKey: "direction"), "south") + } + + func testDefaultAppStorageOverride() { + let defaults = UserDefaults(suiteName: "tests")! + defaults.removePersistentDomain(forName: "tests") + + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.appStorage("count")) var count = 0 + count += 1 + XCTAssertEqual(defaults.integer(forKey: "count"), 1) + } + + @Dependency(\.defaultAppStorage) var defaultAppStorage + XCTAssertNotEqual(defaultAppStorage, defaults) + XCTAssertEqual(defaultAppStorage.integer(forKey: "count"), 0) + } + + func testObservation_DirectMutation() { + @Shared(.appStorage("count")) var count = 0 + let countDidChange = self.expectation(description: "countDidChange") + withPerceptionTracking { + _ = count + } onChange: { + countDidChange.fulfill() + } + count += 1 + self.wait(for: [countDidChange], timeout: 0) + } + + func testObservation_ExternalMutation() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("count")) var count = 0 + let didChange = self.expectation(description: "didChange") + + withPerceptionTracking { + _ = count + } onChange: { [count = $count] in + XCTAssertEqual(count.wrappedValue, 0) + didChange.fulfill() + } + + defaults.setValue(42, forKey: "count") + self.wait(for: [didChange], timeout: 0) + XCTAssertEqual(count, 42) + } + + func testChangeUserDefaultsDirectly() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("count")) var count = 0 + defaults.setValue(count + 42, forKey: "count") + XCTAssertEqual(count, 42) + } + + func testChangeUserDefaultsDirectly_RawRepresentable() { + enum Direction: String, CaseIterable { + case north, south, east, west + } + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("direction")) var direction: Direction = .south + defaults.set("east", forKey: "direction") + XCTAssertEqual(direction, .east) + } + + func testChangeUserDefaultsDirectly_KeyWithPeriod() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("pointfreeco.count")) var count = 0 + defaults.setValue(count + 42, forKey: "pointfreeco.count") + XCTAssertEqual(count, 42) + } + + func testDeleteUserDefault() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("count")) var count = 0 + count = 42 + defaults.removeObject(forKey: "count") + XCTAssertEqual(count, 0) + } + + func testKeyPath() { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage(\.count)) var count = 0 + defaults.count += 1 + XCTAssertEqual(count, 1) + } +} + +extension UserDefaults { + @objc fileprivate dynamic var count: Int { + get { integer(forKey: "count") } + set { set(newValue, forKey: "count") } + } +} diff --git a/Tests/ComposableArchitectureTests/DebugTests.swift b/Tests/ComposableArchitectureTests/DebugTests.swift index 18c67067c0d8..6f7d44ce4f29 100644 --- a/Tests/ComposableArchitectureTests/DebugTests.swift +++ b/Tests/ComposableArchitectureTests/DebugTests.swift @@ -170,5 +170,41 @@ """ ) } + + @MainActor + func testDebugReducer_SharedState() async throws { + let logs = LockIsolated("") + let printer = _ReducerPrinter( + printChange: { action, oldState, newState in + logs.withValue { + $0.append(diff(oldState, newState).map { "\($0)\n" } ?? " (No state changes)\n") + } + } + ) + + struct State { + @Shared var count: Int + } + + let store = Store(initialState: State(count: Shared(0))) { + Reduce(internal: { state, action in + state.count += action ? 1 : -1 + return .none + }) + ._printChanges(printer) + } + store.send(true) + try await Task.sleep(nanoseconds: 300_000_000) + XCTAssertNoDifference( + logs.value, + #""" + DebugTests.State( + - _count: #1 0 + + _count: #1 1 + ) + + """# + ) + } } #endif diff --git a/Tests/ComposableArchitectureTests/FileStorageTests.swift b/Tests/ComposableArchitectureTests/FileStorageTests.swift new file mode 100644 index 000000000000..46aa2d2f76bf --- /dev/null +++ b/Tests/ComposableArchitectureTests/FileStorageTests.swift @@ -0,0 +1,370 @@ +@_spi(Internals) import ComposableArchitecture +import Perception +import XCTest + +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +final class FileStorageTests: XCTestCase { + func testBasics() throws { + let fileSystem = LockIsolated<[URL: Data]>([:]) + try withDependencies { + $0.defaultFileStorage = .inMemory(fileSystem: fileSystem, scheduler: .immediate) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + XCTAssertNoDifference(fileSystem.value, [.fileURL: Data()]) + users.append(.blob) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + } + } + + func testThrottle() throws { + let fileSystem = LockIsolated<[URL: Data]>([:]) + let testScheduler = DispatchQueue.test + try withDependencies { + $0.defaultFileStorage = .inMemory( + fileSystem: fileSystem, + scheduler: testScheduler.eraseToAnyScheduler() + ) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), nil) + + users.append(.blob) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + + users.append(.blobJr) + testScheduler.advance(by: .seconds(1) - .milliseconds(1)) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + + users.append(.blobSr) + testScheduler.advance(by: .milliseconds(1)) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob, .blobJr, .blobSr]) + + testScheduler.advance(by: .seconds(1)) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob, .blobJr, .blobSr]) + + testScheduler.advance(by: .seconds(0.5)) + users.append(.blobEsq) + try XCTAssertNoDifference( + fileSystem.value.users(for: .fileURL), + [ + .blob, + .blobJr, + .blobSr, + .blobEsq + ] + ) + } + } + + func testWillResign() throws { + guard let willResignNotificationName else { return } + + let fileSystem = LockIsolated<[URL: Data]>([:]) + let testScheduler = DispatchQueue.test + try withDependencies { + $0.defaultFileStorage = .inMemory( + fileSystem: fileSystem, + scheduler: testScheduler.eraseToAnyScheduler() + ) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), nil) + + users.append(.blob) + users.append(.blobJr) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + + NotificationCenter.default.post(name: willResignNotificationName, object: nil) + testScheduler.advance() + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob, .blobJr]) + } + } + + func testWillTerminate() throws { + guard let willTerminateNotificationName else { return } + + let fileSystem = LockIsolated<[URL: Data]>([:]) + let testScheduler = DispatchQueue.test + try withDependencies { + $0.defaultFileStorage = .inMemory( + fileSystem: fileSystem, + scheduler: testScheduler.eraseToAnyScheduler() + ) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), nil) + + users.append(.blob) + users.append(.blobJr) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + + NotificationCenter.default.post(name: willTerminateNotificationName, object: nil) + testScheduler.advance() + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob, .blobJr]) + } + } + + func testMultipleFiles() throws { + let fileSystem = LockIsolated<[URL: Data]>([:]) + try withDependencies { + $0.defaultFileStorage = .inMemory(fileSystem: fileSystem) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + @Shared(.fileStorage(.anotherFileURL)) var otherUsers = [User]() + + users.append(.blob) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + try XCTAssertNoDifference(fileSystem.value.users(for: .anotherFileURL), nil) + + otherUsers.append(.blobJr) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + try XCTAssertNoDifference(fileSystem.value.users(for: .anotherFileURL), [.blobJr]) + } + } + + @MainActor + func testLivePersistence() async throws { + guard let willResignNotificationName else { return } + try? FileManager.default.removeItem(at: .fileURL) + + try await withDependencies { + $0.defaultFileStorage = .fileSystem(queue: .main) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + + users.append(.blob) + NotificationCenter.default + .post(name: willResignNotificationName, object: nil) + await Task.yield() + + try XCTAssertNoDifference( + JSONDecoder().decode([User].self, from: Data(contentsOf: .fileURL)), + [.blob] + ) + } + } + + func testInitialValue() async throws { + try await withMainSerialExecutor { + let fileSystem = try LockIsolated<[URL: Data]>( + [.fileURL: try JSONEncoder().encode([User.blob])] + ) + try await withDependencies { + $0.defaultFileStorage = .inMemory(fileSystem: fileSystem) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + _ = users + await Task.yield() + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob]) + } + } + } + + func testInitialValue_LivePersistence() async throws { + try await withMainSerialExecutor { + try? FileManager.default.removeItem(at: .fileURL) + try JSONEncoder().encode([User.blob]).write(to: .fileURL) + + try await withDependencies { + $0.defaultFileStorage = .fileSystem(queue: .main) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + _ = users + await Task.yield() + try XCTAssertNoDifference( + JSONDecoder().decode([User].self, from: Data(contentsOf: .fileURL)), + [.blob] + ) + } + } + } + + @MainActor + func testWriteFile() async throws { + try await withMainSerialExecutor { + try? FileManager.default.removeItem(at: .fileURL) + try JSONEncoder().encode([User.blob]).write(to: .fileURL) + + try await withDependencies { + $0.defaultFileStorage = .fileSystem(queue: .main) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + await Task.yield() + XCTAssertNoDifference(users, [.blob]) + + try JSONEncoder().encode([User.blobJr]).write(to: .fileURL) + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertNoDifference(users, [.blobJr]) + } + } + } + + @MainActor + func testWriteFileWhileDebouncing() throws { + let fileSystem = LockIsolated<[URL: Data]>([:]) + let scheduler = DispatchQueue.test + let fileStorage = FileStorage.inMemory( + fileSystem: fileSystem, + scheduler: scheduler.eraseToAnyScheduler() + ) + + try withDependencies { + $0.defaultFileStorage = fileStorage + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + + users.append(.blob) + try fileStorage.save(Data(), .fileURL) + scheduler.run() + XCTAssertNoDifference(users, []) + try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), nil) + } + } + + @MainActor + func testDeleteFile() async throws { + try await withMainSerialExecutor { + try? FileManager.default.removeItem(at: .fileURL) + try JSONEncoder().encode([User.blob]).write(to: .fileURL) + + try await withDependencies { + $0.defaultFileStorage = .fileSystem(queue: .main) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + await Task.yield() + XCTAssertNoDifference(users, [.blob]) + + try FileManager.default.removeItem(at: .fileURL) + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertNoDifference(users, []) + } + } + } + + @MainActor + func testMoveFile() async throws { + try await withMainSerialExecutor { + try? FileManager.default.removeItem(at: .fileURL) + try? FileManager.default.removeItem(at: .anotherFileURL) + try JSONEncoder().encode([User.blob]).write(to: .fileURL) + + try await withDependencies { + $0.defaultFileStorage = .fileSystem(queue: .main) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + await Task.yield() + XCTAssertNoDifference(users, [.blob]) + + try FileManager.default.moveItem(at: .fileURL, to: .anotherFileURL) + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertNoDifference(users, []) + + try FileManager.default.removeItem(at: .fileURL) + try FileManager.default.moveItem(at: .anotherFileURL, to: .fileURL) + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertNoDifference(users, [.blob]) + } + } + } + + @MainActor + func testDeleteFile_ThenWriteToFile() async throws { + try await withMainSerialExecutor { + try? FileManager.default.removeItem(at: .fileURL) + try JSONEncoder().encode([User.blob]).write(to: .fileURL) + + try await withDependencies { + $0.defaultFileStorage = .fileSystem(queue: .main) + } operation: { + @Shared(.fileStorage(.fileURL)) var users = [User]() + await Task.yield() + XCTAssertNoDifference(users, [.blob]) + + try FileManager.default.removeItem(at: .fileURL) + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertNoDifference(users, []) + + try JSONEncoder().encode([User.blobJr]).write(to: .fileURL) + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertNoDifference(users, [.blobJr]) + } + } + } + + func testMismatchTypesSameCodability() { + @Shared(.fileStorage(.fileURL)) var users: [User] = [] + @Shared(.fileStorage(.fileURL)) var users1: [User] = [] + @Shared(.fileStorage(.fileURL)) var users2: IdentifiedArrayOf = [] + + users.append(User(id: 1, name: "Blob")) + XCTAssertEqual(users, [User(id: 1, name: "Blob")]) + XCTAssertEqual(users1, [User(id: 1, name: "Blob")]) + XCTAssertEqual(users2, [User(id: 1, name: "Blob")]) + } + + func testMismatchTypesDifferentCodability() { + @Shared(.fileStorage(.fileURL)) var users: [User] = [] + @Shared(.fileStorage(.fileURL)) var users1: [User] = [] + @Shared(.fileStorage(.fileURL)) var users2 = false + + users.append(User(id: 1, name: "Blob")) + XCTAssertEqual(users, [User(id: 1, name: "Blob")]) + XCTAssertEqual(users1, [User(id: 1, name: "Blob")]) + XCTAssertEqual(users2, false) + + users2 = true + XCTAssertEqual(users, []) + XCTAssertEqual(users1, []) + XCTAssertEqual(users2, true) + } + + func testTwoInMemoryFileStorages() { + let shared1 = withDependencies { + $0.defaultFileStorage = .inMemory + } operation: { + @Shared(.fileStorage(.userURL)) var user = User(id: 1, name: "Blob") + return $user + } + let shared2 = withDependencies { + $0.defaultFileStorage = .inMemory + } operation: { + @Shared(.fileStorage(.userURL)) var user = User(id: 1, name: "Blob") + return $user + } + + shared1.wrappedValue.name = "Blob Jr" + XCTAssertEqual(shared1.wrappedValue.name, "Blob Jr") + XCTAssertEqual(shared2.wrappedValue.name, "Blob") + shared2.wrappedValue.name = "Blob Sr" + XCTAssertEqual(shared1.wrappedValue.name, "Blob Jr") + XCTAssertEqual(shared2.wrappedValue.name, "Blob Sr") + } +} + +extension URL { + fileprivate static let fileURL = Self(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("file.json") + fileprivate static let userURL = Self(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("user.json") + fileprivate static let anotherFileURL = Self(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("another-file.json") +} + +private struct User: Codable, Equatable, Identifiable { + let id: Int + var name: String + static let blob = User(id: 1, name: "Blob") + static let blobJr = User(id: 2, name: "Blob Jr.") + static let blobSr = User(id: 3, name: "Blob Sr.") + static let blobEsq = User(id: 4, name: "Blob Esq.") +} + +extension [URL: Data] { + fileprivate func users(for url: URL) throws -> [User]? { + guard + let data = self[url], + !data.isEmpty + else { return nil } + return try JSONDecoder().decode([User].self, from: data) + } +} diff --git a/Tests/ComposableArchitectureTests/Internal/TestHelpers.swift b/Tests/ComposableArchitectureTests/Internal/TestHelpers.swift index 3d84645a4bf1..5f1bbb453dc5 100644 --- a/Tests/ComposableArchitectureTests/Internal/TestHelpers.swift +++ b/Tests/ComposableArchitectureTests/Internal/TestHelpers.swift @@ -1,5 +1,6 @@ import XCTest +@_transparent @available( *, deprecated, diff --git a/Tests/ComposableArchitectureTests/ObserveTests.swift b/Tests/ComposableArchitectureTests/ObserveTests.swift index d2ade11aa7f0..703bb523b40f 100644 --- a/Tests/ComposableArchitectureTests/ObserveTests.swift +++ b/Tests/ComposableArchitectureTests/ObserveTests.swift @@ -35,9 +35,9 @@ _ = observation } - #if DEBUG - @MainActor - func testNestedObservation() async throws { + @MainActor + func testNestedObservation() async throws { + #if DEBUG XCTExpectFailure { $0.compactDescription == """ An "observe" was called from another "observe" closure, which can lead to \ @@ -46,33 +46,33 @@ Avoid nested closures by moving child observation into their own lifecycle methods. """ } + #endif - let model = Model() - var counts: [Int] = [] - var innerObservation: Any! - let observation = observe { [weak self] in - guard let self else { return } - counts.append(model.count) - innerObservation = observe { - _ = model.otherCount - } - } - defer { - _ = observation - _ = innerObservation + let model = Model() + var counts: [Int] = [] + var innerObservation: Any! + let observation = observe { [weak self] in + guard let self else { return } + counts.append(model.count) + innerObservation = observe { + _ = model.otherCount } + } + defer { + _ = observation + _ = innerObservation + } - XCTAssertEqual(counts, [0]) + XCTAssertEqual(counts, [0]) - model.count += 1 - try await Task.sleep(nanoseconds: 1_000_000) - XCTAssertEqual(counts, [0, 1]) + model.count += 1 + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertEqual(counts, [0, 1]) - model.otherCount += 1 - try await Task.sleep(nanoseconds: 1_000_000) - XCTAssertEqual(counts, [0, 1, 1]) - } - #endif + model.otherCount += 1 + try await Task.sleep(nanoseconds: 1_000_000) + XCTAssertEqual(counts, [0, 1, 1]) + } } @Perceptible diff --git a/Tests/ComposableArchitectureTests/Reducers/OnChangeReducerTests.swift b/Tests/ComposableArchitectureTests/Reducers/OnChangeReducerTests.swift index e22c59333731..8ea6c403d781 100644 --- a/Tests/ComposableArchitectureTests/Reducers/OnChangeReducerTests.swift +++ b/Tests/ComposableArchitectureTests/Reducers/OnChangeReducerTests.swift @@ -193,4 +193,45 @@ final class OnChangeReducerTests: BaseTCATestCase { await store.send(.noop) } + + @MainActor + func testSharedState() async { + struct Count: Codable, Equatable { + var value = 0 + } + + struct Feature: Reducer { + struct State: Equatable { + @Shared(.fileStorage(URL(fileURLWithPath: "/file.json"))) var count = Count() + var description = "" + } + enum Action: Equatable { + case incrementButtonTapped + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .incrementButtonTapped: + state.count.value += 1 + return .none + } + } + .onChange(of: \.count) { oldValue, newValue in + Reduce { state, _ in + state.description = "old: \(oldValue.value), new: \(newValue.value)" + return .none + } + } + } + } + let store = TestStore(initialState: Feature.State()) { Feature() } + await store.send(.incrementButtonTapped) { + $0.count.value = 1 + $0.description = "old: 0, new: 1" + } + await store.send(.incrementButtonTapped) { + $0.count.value = 2 + $0.description = "old: 1, new: 2" + } + } } diff --git a/Tests/ComposableArchitectureTests/SharedAppStorageTests.swift b/Tests/ComposableArchitectureTests/SharedAppStorageTests.swift new file mode 100644 index 000000000000..985336d997e5 --- /dev/null +++ b/Tests/ComposableArchitectureTests/SharedAppStorageTests.swift @@ -0,0 +1,127 @@ +import ComposableArchitecture +import XCTest + +@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) +final class SharedAppStorageTests: XCTestCase { + @MainActor + func testBasics() async { + let store = TestStore(initialState: Feature.State()) { + Feature() + } + + await store.send(.incrementButtonTapped) { + $0.count = 1 + } + } + + @MainActor + func testSubscription() async throws { + let store = TestStore(initialState: Feature.State()) { + Feature() + } + + await store.send(.incrementButtonTapped) { + $0.count = 1 + } + @Dependency(\.defaultAppStorage) var userDefaults + userDefaults.setValue(42, forKey: "count") + await Task.yield() + await store.send(.incrementButtonTapped) { + $0.count = 43 + } + } + + @MainActor + func testSiblings() async { + let store = TestStore(initialState: ParentFeature.State()) { + ParentFeature() + } + + await store.send(.child1(.incrementButtonTapped)) { + $0.child1.count = 1 + XCTAssertEqual($0.child2.count, 1) + } + await store.send(.child2(.incrementButtonTapped)) { + $0.child2.count = 2 + XCTAssertEqual($0.child1.count, 2) + } + await store.send(.child1(.incrementButtonTapped)) { + $0.child2.count = 3 + XCTAssertEqual($0.child1.count, 3) + } + await store.send(.child2(.incrementButtonTapped)) { + $0.child1.count = 4 + XCTAssertEqual($0.child2.count, 4) + } + } + + @MainActor + func testSiblings_Failure() async { + let store = TestStore(initialState: ParentFeature.State()) { + ParentFeature() + } + + XCTExpectFailure { + $0.compactDescription == """ + State was not expected to change, but a change occurred: â€Ļ + +   ParentFeature.State( +   _child1: Feature.State( + − _count: #1 0 + + _count: #1 1 +   ), +   _child2: Feature.State( + − _count: #1 Int(↩ī¸Ž) + + _count: #1 Int(↩ī¸Ž) +   ) +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.child1(.incrementButtonTapped)) + } +} + +@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) +@Reducer +private struct ParentFeature { + @ObservableState + struct State: Equatable { + var child1 = Feature.State() + var child2 = Feature.State() + } + enum Action { + case child1(Feature.Action) + case child2(Feature.Action) + } + var body: some ReducerOf { + Scope(state: \.child1, action: \.child1) { + Feature() + } + Scope(state: \.child2, action: \.child2) { + Feature() + } + } +} + +@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) +@Reducer +private struct Feature { + @ObservableState + struct State: Equatable { + @Shared(.appStorage("count")) var count = 0 + } + enum Action { + case incrementButtonTapped + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .incrementButtonTapped: + state.count += 1 + return .none + } + } + } +} diff --git a/Tests/ComposableArchitectureTests/SharedInMemoryTests.swift b/Tests/ComposableArchitectureTests/SharedInMemoryTests.swift new file mode 100644 index 000000000000..62bb7bf9bde9 --- /dev/null +++ b/Tests/ComposableArchitectureTests/SharedInMemoryTests.swift @@ -0,0 +1,129 @@ +import ComposableArchitecture +import XCTest + +@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) +final class SharedInMemoryTests: XCTestCase { + @MainActor + func testBasics() async { + let store = TestStore(initialState: Feature.State()) { + Feature() + } + + await store.send(.incrementButtonTapped) { + $0.count = 1 + } + } + + @MainActor + func testSiblings() async { + let store = TestStore(initialState: ParentFeature.State()) { + ParentFeature() + } + + await store.send(.child1(.incrementButtonTapped)) { + $0.child1.count = 1 + XCTAssertEqual($0.child2.count, 1) + XCTAssertEqual($0.child3.count, 0) + } + await store.send(.child2(.incrementButtonTapped)) { + $0.child2.count = 2 + XCTAssertEqual($0.child1.count, 2) + XCTAssertEqual($0.child3.count, 0) + } + await store.send(.child1(.incrementButtonTapped)) { + $0.child2.count = 3 + XCTAssertEqual($0.child1.count, 3) + XCTAssertEqual($0.child3.count, 0) + } + await store.send(.child2(.incrementButtonTapped)) { + $0.child1.count = 4 + XCTAssertEqual($0.child2.count, 4) + XCTAssertEqual($0.child3.count, 0) + } + await store.send(.child3(.incrementButtonTapped)) { + $0.child3.count = 1 + XCTAssertEqual($0.child1.count, 4) + XCTAssertEqual($0.child2.count, 4) + } + } + + @MainActor + func testSiblings_Failure() async { + let store = TestStore(initialState: ParentFeature.State()) { + ParentFeature() + } + + XCTExpectFailure { + $0.compactDescription == """ + State was not expected to change, but a change occurred: â€Ļ + +   ParentFeature.State( +   _child1: Feature.State( + − _count: #1 0 + + _count: #1 1 +   ), +   _child2: Feature.State( + − _count: #1 Int(↩ī¸Ž) + + _count: #1 Int(↩ī¸Ž) +   ), +   _child3: Feature.State(â€Ļ) +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.child1(.incrementButtonTapped)) + } +} + +@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) +@Reducer +private struct ParentFeature { + @ObservableState + struct State: Equatable { + var child1 = Feature.State() + var child2 = Feature.State() + var child3 = withDependencies { + $0.defaultInMemoryStorage = .init() + } operation: { + Feature.State() + } + } + enum Action { + case child1(Feature.Action) + case child2(Feature.Action) + case child3(Feature.Action) + } + var body: some ReducerOf { + Scope(state: \.child1, action: \.child1) { + Feature() + } + Scope(state: \.child2, action: \.child2) { + Feature() + } + Scope(state: \.child3, action: \.child3) { + Feature() + } + } +} + +@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) +@Reducer +private struct Feature { + @ObservableState + struct State: Equatable { + @Shared(.inMemory("count")) var count = 0 + } + enum Action { + case incrementButtonTapped + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .incrementButtonTapped: + state.count += 1 + return .none + } + } + } +} diff --git a/Tests/ComposableArchitectureTests/SharedReaderTests.swift b/Tests/ComposableArchitectureTests/SharedReaderTests.swift new file mode 100644 index 000000000000..4e604921ab9c --- /dev/null +++ b/Tests/ComposableArchitectureTests/SharedReaderTests.swift @@ -0,0 +1,16 @@ +import Combine +import ComposableArchitecture +import XCTest + +final class SharedReaderTests: XCTestCase { + @MainActor + func testSharedReader() { + @Shared var count: Int + _count = Shared(0) + let countReader = $count.reader + + count += 1 + XCTAssertEqual(count, 1) + XCTAssertEqual(countReader.wrappedValue, 1) + } +} diff --git a/Tests/ComposableArchitectureTests/SharedTests.swift b/Tests/ComposableArchitectureTests/SharedTests.swift new file mode 100644 index 000000000000..ddb4fad04d9d --- /dev/null +++ b/Tests/ComposableArchitectureTests/SharedTests.swift @@ -0,0 +1,1048 @@ +import Combine +@_spi(Internals) import ComposableArchitecture +import XCTest + +final class SharedTests: XCTestCase { + @MainActor + func testSharing() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } + await store.send(.sharedIncrement) { + $0.sharedCount = 1 + } + await store.send(.incrementStats) { + $0.profile.stats.count = 1 + $0.stats.count = 1 + } + XCTAssertEqual(store.state.profile.stats.count, 1) + } + + @MainActor + func testSharing_Failure() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } + XCTExpectFailure { + $0.compactDescription == """ + A state change does not match expectation: â€Ļ + +   SharedFeature.State( +   _count: 0, +   _profile: #1 Profile(â€Ļ), + − _sharedCount: #1 2, + + _sharedCount: #1 1, +   _stats: #1 Stats(count: 0), +   _isOn: #1 false +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.sharedIncrement) { + $0.sharedCount = 2 + } + XCTAssertEqual(store.state.sharedCount, 1) + } + + @MainActor + func testSharing_NonExhaustive() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.sharedIncrement) + XCTAssertEqual(store.state.sharedCount, 1) + + XCTExpectFailure { + $0.compactDescription == """ + A state change does not match expectation: â€Ļ + +   SharedFeature.State( +   _count: 0, +   _profile: #1 Profile(â€Ļ), + − _sharedCount: #1 3, + + _sharedCount: #1 2, +   _stats: #1 Stats(count: 0), +   _isOn: #1 false +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.sharedIncrement) { + $0.sharedCount = 3 + } + XCTAssertEqual(store.state.sharedCount, 2) + } + + @MainActor + func testMultiSharing() async { + @Shared(Stats()) var stats + + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: $stats)), + sharedCount: Shared(0), + stats: $stats + ) + ) { + SharedFeature() + } + await store.send(.incrementStats) { + $0.profile.stats.count = 2 + $0.stats.count = 2 + } + XCTAssertEqual(stats.count, 2) + } + + @MainActor + func testIncrementalMutation() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } + await store.send(.sharedIncrement) { + $0.sharedCount += 1 + } + } + + @MainActor + func testIncrementalMutation_Failure() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } + XCTExpectFailure { + $0.compactDescription == """ + A state change does not match expectation: â€Ļ + +   SharedFeature.State( +   _count: 0, +   _profile: #1 Profile(â€Ļ), + − _sharedCount: #1 2, + + _sharedCount: #1 1, +   _stats: #1 Stats(count: 0), +   _isOn: #1 false +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.sharedIncrement) { + $0.sharedCount += 2 + } + } + + @MainActor + func testEffect() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } + await store.send(.request) + await store.receive(\.sharedIncrement) { + $0.sharedCount = 1 + } + } + + @MainActor + func testEffect_Failure() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } + XCTExpectFailure { + $0.compactDescription == """ + State was not expected to change, but a change occurred: â€Ļ + +   SharedFeature.State( +   _count: 0, +   _profile: #1 Profile(â€Ļ), + − _sharedCount: #1 0, + + _sharedCount: #1 1, +   _stats: #1 Stats(count: 0), +   _isOn: #1 false +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.request) + await store.receive(\.sharedIncrement) + } + + @MainActor + func testMutationOfSharedStateInLongLivingEffect() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } withDependencies: { + $0.mainQueue = .immediate + } + await store.send(.longLivingEffect).finish() + store.assert { + $0.sharedCount = 1 + } + } + + @MainActor + func testMutationOfSharedStateInLongLivingEffect_NoAssertion() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } withDependencies: { + $0.mainQueue = .immediate + } + XCTExpectFailure { + $0.compactDescription == """ + Test store completed before asserting against changes to shared state: â€Ļ + +   SharedFeature.State( +   _count: 0, +   _profile: #1 Profile(â€Ļ), + − _sharedCount: #1 0, + + _sharedCount: #1 1, +   _stats: #1 Stats(count: 0), +   _isOn: #1 false +   ) + + (Expected: −, Actual: +) + + Invoke "TestStore.assert" at the end of this test to assert against changes to shared state. + """ + } + await store.send(.longLivingEffect) + } + + @MainActor + func testMutationOfSharedStateInLongLivingEffect_IncorrectAssertion() async { + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()) + ) + ) { + SharedFeature() + } withDependencies: { + $0.mainQueue = .immediate + } + XCTExpectFailure { + $0.compactDescription == """ + A state change does not match expectation: â€Ļ + +   SharedFeature.State( +   _count: 0, +   _profile: #1 Profile(â€Ļ), + − _sharedCount: #1 2, + + _sharedCount: #1 1, +   _stats: #1 Stats(count: 0), +   _isOn: #1 false +   ) + + (Expected: −, Actual: +) + """ + } + await store.send(.longLivingEffect) + store.assert { + $0.sharedCount = 2 + } + } + + @MainActor + func testComplexSharedEffect_ReducerMutation() async { + struct Feature: Reducer { + struct State: Equatable { + @Shared var count: Int + } + enum Action { + case startTimer + case stopTimer + case timerTick + } + @Dependency(\.mainQueue) var queue + enum CancelID { case timer } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .startTimer: + return .run { send in + for await _ in self.queue.timer(interval: .seconds(1)) { + await send(.timerTick) + } + } + .cancellable(id: CancelID.timer) + case .stopTimer: + return .cancel(id: CancelID.timer) + case .timerTick: + state.count += 1 + return .none + } + } + } + } + let mainQueue = DispatchQueue.test + let store = TestStore(initialState: Feature.State(count: Shared(0))) { + Feature() + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } + await store.send(.startTimer) + await mainQueue.advance(by: .seconds(1)) + await store.receive(.timerTick) { + $0.count = 1 + } + await store.send(.stopTimer) + await mainQueue.advance(by: .seconds(1)) + } + + @MainActor + func testComplexSharedEffect_EffectMutation() async { + struct Feature: Reducer { + struct State: Equatable { + @Shared var count: Int + } + enum Action { + case startTimer + case stopTimer + case timerTick + } + @Dependency(\.mainQueue) var queue + enum CancelID { case timer } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .startTimer: + return .run { [count = state.$count] send in + for await _ in self.queue.timer(interval: .seconds(1)) { + count.wrappedValue += 1 + await send(.timerTick) + } + } + .cancellable(id: CancelID.timer) + case .stopTimer: + return .merge( + .cancel(id: CancelID.timer), + .run { [count = state.$count] _ in + Task { + try await self.queue.sleep(for: .seconds(1)) + count.wrappedValue = 42 + } + } + ) + case .timerTick: + return .none + } + } + } + } + let mainQueue = DispatchQueue.test + let store = TestStore(initialState: Feature.State(count: Shared(0))) { + Feature() + } withDependencies: { + $0.mainQueue = mainQueue.eraseToAnyScheduler() + } + await store.send(.startTimer) + await mainQueue.advance(by: .seconds(1)) + await store.receive(.timerTick) { + $0.count = 1 + } + await store.send(.stopTimer) + await mainQueue.advance(by: .seconds(1)) + store.assert { + $0.count = 42 + } + } + + @MainActor + func testDump() { + @Shared(Profile(stats: Shared(Stats()))) var profile: Profile + XCTAssertEqual( + String(customDumping: profile), + """ + Profile( + _stats: #1 Stats(count: 0) + ) + """ + ) + + let count = $profile.stats.count + XCTAssertEqual( + String(customDumping: count), + """ + #1 0 + """ + ) + } + + @MainActor + func testSimpleFeatureFailure() async { + let store = TestStore(initialState: SimpleFeature.State(count: Shared(0))) { + SimpleFeature() + } + + XCTExpectFailure { + $0.compactDescription == """ + State was not expected to change, but a change occurred: â€Ļ + +   SimpleFeature.State( + − _count: #1 0 + + _count: #1 1 +   ) + + (Expected: −, Actual: +) + """ + } + + await store.send(.incrementInReducer) + } + + func testObservation() { + @Shared var count: Int + _count = Shared(0) + let countDidChange = self.expectation(description: "countDidChange") + withPerceptionTracking { + _ = count + } onChange: { + countDidChange.fulfill() + } + count += 1 + self.wait(for: [countDidChange], timeout: 0) + } + + func testObservation_projected() { + @Shared var count: Int + _count = Shared(0) + let countDidChange = self.expectation(description: "countDidChange") + withPerceptionTracking { + _ = $count + } onChange: { + countDidChange.fulfill() + } + $count = Shared(1) + self.wait(for: [countDidChange], timeout: 0) + } + + @available(*, deprecated) + @MainActor + func testObservation_Object() { + @Shared var object: SharedObject + _object = Shared(SharedObject()) + let countDidChange = self.expectation(description: "countDidChange") + withPerceptionTracking { + _ = object.count + } onChange: { + countDidChange.fulfill() + } + object.count += 1 + self.wait(for: [countDidChange], timeout: 0) + } + + @MainActor + func testAssertSharedStateWithNoChanges() { + let store = TestStore(initialState: SimpleFeature.State(count: Shared(0))) { + SimpleFeature() + } + XCTExpectFailure { + $0.compactDescription == """ + Expected changes, but none occurred. + """ + } + store.state.$count.assert { + $0 = 0 + } + } + + @MainActor + func testPublisher() { + var cancellables: Set = [] + defer { _ = cancellables } + + let sharedCount = Shared(0) + var counts = [Int]() + sharedCount.publisher.sink { _ in + } receiveValue: { count in + XCTAssertEqual(sharedCount.wrappedValue, count - 1) + counts.append(count) + } + .store(in: &cancellables) + + sharedCount.wrappedValue += 1 + XCTAssertEqual(counts, [1]) + sharedCount.wrappedValue += 1 + XCTAssertEqual(counts, [1, 2]) + } + + @MainActor + func testPublisher_MultipleSubscribers() { + var cancellables: Set = [] + defer { _ = cancellables } + + let sharedCount = Shared(0) + var counts = [Int]() + sharedCount.publisher.sink { _ in + } receiveValue: { count in + counts.append(count) + } + .store(in: &cancellables) + sharedCount.publisher.sink { _ in + } receiveValue: { count in + counts.append(count) + } + .store(in: &cancellables) + + sharedCount.wrappedValue += 1 + XCTAssertEqual(counts, [1, 1]) + sharedCount.wrappedValue += 1 + XCTAssertEqual(counts, [1, 1, 2, 2]) + } + + @MainActor + func testPublisher_MutateInSink() { + var cancellables: Set = [] + defer { _ = cancellables } + + let sharedCount = Shared(0) + var counts = [Int]() + sharedCount.publisher.sink { _ in + } receiveValue: { count in + counts.append(count) + if count == 1 { + sharedCount.wrappedValue = 2 + } + } + .store(in: &cancellables) + + sharedCount.wrappedValue += 1 + XCTAssertEqual(counts, [1, 2]) + } + + @MainActor + func testPublisher_Persistence_MutateInSink() { + var cancellables: Set = [] + defer { _ = cancellables } + + @Shared(.appStorage("count")) var count = 0 + var counts = [Int]() + $count.publisher.sink { _ in + } receiveValue: { newCount in + counts.append(newCount) + if newCount == 1 { + count = 2 + } + } + .store(in: &cancellables) + + count += 1 + XCTAssertEqual(counts, [1, 2]) + @Dependency(\.defaultAppStorage) var userDefaults + // TODO: Should we runtime warn on re-entrant mutations? + XCTAssertEqual(count, 1) + XCTAssertEqual(userDefaults.integer(forKey: "count"), 1) + } + + @MainActor + func testPublisher_Persistence_ExternalChange() async throws { + @Dependency(\.defaultAppStorage) var defaults + @Shared(.appStorage("count")) var count = 0 + XCTAssertEqual(count, 0) + + var cancellables: Set = [] + defer { _ = cancellables } + + var counts = [Int]() + $count.publisher.sink { _ in + } receiveValue: { newCount in + counts.append(newCount) + if newCount == 1 { count = 2 } + } + .store(in: &cancellables) + + try await Task.sleep(nanoseconds: 1_000_000) + defaults.set(1, forKey: "count") + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertEqual(counts, [1, 2]) + XCTAssertEqual(defaults.integer(forKey: "count"), 2) + } + + @MainActor + func testMultiplePublisherSubscriptions() async { + let runCount = 10 + for _ in 1...runCount { + let store = TestStore(initialState: ListFeature.State()) { + ListFeature() + } withDependencies: { + $0.uuid = .incrementing + } + await store.send(.children(.element(id: 0, action: .onAppear))) + await store.send(.children(.element(id: 1, action: .onAppear))) + await store.send(.children(.element(id: 2, action: .onAppear))) + await store.send(.children(.element(id: 3, action: .onAppear))) + await store.send(.incrementValue) { + $0.value = 1 + } + await store.receive(\.children[id:0].response) { + $0.children[id: 0]?.text = "1" + } + await store.receive(\.children[id:1].response) { + $0.children[id: 1]?.text = "1" + } + await store.receive(\.children[id:2].response) { + $0.children[id: 2]?.text = "1" + } + await store.receive(\.children[id:3].response) { + $0.children[id: 3]?.text = "1" + } + } + } + + @MainActor + func testEarlySharedStateMutation() async { + let store = TestStore(initialState: EarlySharedStateMutation.State(count: Shared(0))) { + EarlySharedStateMutation() + } + + XCTTODO( + """ + This currently fails because the effect returned from '.action' synchronously sends the + '.response' action, which then mutates the shared state. Because the TestStore processes + actions immediately the shared state mutation must be asserted in `store.send` rather than + store.receive. + + We should update the TestStore so that effects suspend until one does 'store.receive'. That + would fix this test. + """ + ) + await store.send(.action) + await store.receive(.response) { + $0.count = 42 + } + } + + @MainActor + func testObserveWithPrintChanges() async { + let store = TestStore(initialState: SimpleFeature.State(count: Shared(0))) { + SimpleFeature()._printChanges() + } + + var observations: [Int] = [] + observe { + observations.append(store.state.count) + } + + XCTAssertEqual(observations, [0]) + await store.send(.incrementInReducer) { + dump($0.$count) + $0.count += 1 + } + XCTAssertEqual(observations, [0, 1]) + } + + func testSharedDefaults_UseDefault() { + @Shared(.isOn) var isOn + XCTAssertEqual(isOn, false) + } + + func testSharedDefaults_OverrideDefault() { + @Shared(.isOn) var isOn = true + XCTAssertEqual(isOn, true) + } + + func testSharedDefaults_MultipleWithDifferentDefaults() async throws { + @Shared(.isOn) var isOn1 + @Shared(.isOn) var isOn2 = true + @Shared(.appStorage("isOn")) var isOn3 = true + + XCTAssertEqual(isOn1, false) + XCTAssertEqual(isOn2, false) + XCTAssertEqual(isOn3, false) + + isOn2 = true + XCTAssertEqual(isOn1, true) + XCTAssertEqual(isOn2, true) + XCTAssertEqual(isOn3, true) + + isOn1 = false + XCTAssertEqual(isOn1, false) + XCTAssertEqual(isOn2, false) + XCTAssertEqual(isOn3, false) + + isOn3 = true + XCTAssertEqual(isOn1, true) + XCTAssertEqual(isOn2, true) + XCTAssertEqual(isOn3, true) + } + + func testSharedReaderDefaults_MultipleWithDifferentDefaults() async throws { + @Shared(.appStorage("isOn")) var isOn = false + @SharedReader(.isOn) var isOn1 + @SharedReader(.isOn) var isOn2 = true + @SharedReader(.appStorage("isOn")) var isOn3 = true + + XCTAssertEqual(isOn1, false) + XCTAssertEqual(isOn2, false) + XCTAssertEqual(isOn3, false) + + isOn = true + XCTAssertEqual(isOn1, true) + XCTAssertEqual(isOn2, true) + XCTAssertEqual(isOn3, true) + } + + @MainActor + func testPrivateSharedState() async { + let isOn = Shared(false) + let store = TestStore( + initialState: SharedFeature.State( + profile: Shared(Profile(stats: Shared(Stats()))), + sharedCount: Shared(0), + stats: Shared(Stats()), + isOn: isOn + ) + ) { + SharedFeature() + } + + await store.send(.toggleIsOn) { + _ = $0 + isOn.wrappedValue = true + } + await store.send(.toggleIsOn) { + _ = $0 + isOn.wrappedValue = false + } + } + + func testEquatability_DifferentReference() { + let count = Shared(0) + @Shared(.appStorage("count")) var appStorageCount = 0 + @Shared( + .fileStorage( + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("count.json") + ) + ) + var fileStorageCount = 0 + @Shared(.inMemory("count")) var inMemoryCount = 0 + + XCTAssertEqual(count, $appStorageCount) + XCTAssertEqual($appStorageCount, $fileStorageCount) + XCTAssertEqual($fileStorageCount, $inMemoryCount) + XCTAssertEqual($inMemoryCount, count) + } + + func testEquatable_DifferentKeyPath() { + struct Settings { + var isOn = false + var hasSeen = false + } + @Shared(.inMemory("settings")) var settings = Settings() + XCTAssertEqual($settings.isOn, $settings.hasSeen) + withSharedChangeTracking { tracker in + settings.isOn.toggle() + XCTAssertNotEqual(settings.isOn, settings.hasSeen) + XCTAssertNotEqual($settings.isOn, $settings.hasSeen) + XCTAssertNotEqual($settings.hasSeen, $settings.isOn) + tracker.assert { + XCTAssertEqual(settings.isOn, settings.hasSeen) + XCTAssertEqual($settings.isOn, $settings.hasSeen) + XCTAssertEqual($settings.hasSeen, $settings.isOn) + settings.hasSeen.toggle() + XCTAssertNotEqual(settings.isOn, settings.hasSeen) + XCTAssertNotEqual($settings.isOn, $settings.hasSeen) + XCTAssertNotEqual($settings.hasSeen, $settings.isOn) + } + } + } + + func testSelfEqualityInAnAssertion() { + let count = Shared(0) + withSharedChangeTracking { tracker in + count.wrappedValue += 1 + tracker.assert { + XCTAssertNotEqual(count, count) + XCTAssertEqual(count.wrappedValue, count.wrappedValue) + } + XCTAssertEqual(count, count) + XCTAssertEqual(count.wrappedValue, count.wrappedValue) + } + XCTAssertEqual(count, count) + XCTAssertEqual(count.wrappedValue, count.wrappedValue) + } + + func testBasicAssertion() { + let count = Shared(0) + withSharedChangeTracking { tracker in + count.wrappedValue += 1 + tracker.assert { + count.wrappedValue += 1 + XCTAssertEqual(count, count) + XCTAssertEqual(count.wrappedValue, count.wrappedValue) + } + XCTAssertEqual(count, count) + XCTAssertEqual(count.wrappedValue, count.wrappedValue) + } + XCTAssertEqual(count, count) + XCTAssertEqual(count.wrappedValue, count.wrappedValue) + } +} + +@Reducer +private struct SharedFeature { + @ObservableState + struct State: Equatable { + var count = 0 + @Shared var profile: Profile + @Shared var sharedCount: Int + @Shared var stats: Stats + @Shared fileprivate var isOn: Bool + init( + count: Int = 0, + profile: Shared, + sharedCount: Shared, + stats: Shared, + isOn: Shared = Shared(false) + ) { + self.count = count + self._profile = profile + self._sharedCount = sharedCount + self._stats = stats + self._isOn = isOn + } + } + enum Action { + case increment + case incrementStats + case longLivingEffect + case noop + case request + case sharedIncrement + case toggleIsOn + } + @Dependency(\.mainQueue) var mainQueue + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .increment: + state.count += 1 + return .none + case .incrementStats: + state.profile.stats.count += 1 + state.stats.count += 1 + return .none + case .longLivingEffect: + return .run { [sharedCount = state.$sharedCount] _ in + try await self.mainQueue.sleep(for: .seconds(1)) + sharedCount.wrappedValue += 1 + } + case .noop: + return .none + case .request: + return .run { send in + await send(.sharedIncrement) + } + case .sharedIncrement: + state.sharedCount += 1 + return .none + case .toggleIsOn: + state.isOn.toggle() + return .none + } + } + } +} + +private struct Stats: Codable, Equatable { + var count = 0 +} +private struct Profile: Equatable { + @Shared var stats: Stats +} +@Reducer +private struct SimpleFeature { + struct State: Equatable { + @Shared var count: Int + } + enum Action { + case incrementInEffect + case incrementInReducer + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .incrementInEffect: + return .run { [count = state.$count] _ in + count.wrappedValue += 1 + } + case .incrementInReducer: + state.count += 1 + return .none + } + } + } +} + +@Perceptible +class SharedObject { + var count = 0 +} + +@Reducer +private struct RowFeature { + @ObservableState + struct State: Equatable, Identifiable { + let id: Int + var text: String + @Shared var value: Int + } + + enum Action: Equatable { + case onAppear + case response(Int) + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .response(newValue): + state.text = "\(newValue)" + return .none + + case .onAppear: + return .publisher { [publisher = state.$value.publisher] in + publisher + .map(Action.response) + .prefix(1) + } + } + } + } +} + +@Reducer +private struct ListFeature { + @ObservableState + struct State: Equatable { + @Shared var value: Int + var children: IdentifiedArrayOf + + init(value: Int = 0) { + @Dependency(\.uuid) var uuid + self._value = Shared(value) + self.children = [ + .init(id: 0, text: "0", value: _value), + .init(id: 1, text: "0", value: _value), + .init(id: 2, text: "0", value: _value), + .init(id: 3, text: "0", value: _value), + ] + } + } + + enum Action: Equatable { + case children(IdentifiedActionOf) + case incrementValue + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .children: + return .none + + case .incrementValue: + state.value += 1 + return .none + } + } + .forEach(\.children, action: \.children) { RowFeature() } + } +} + +@Reducer +private struct EarlySharedStateMutation { + @ObservableState + struct State: Equatable { + @Shared var count: Int + } + enum Action { + case action + case response + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .action: + return .send(.response) + case .response: + state.count = 42 + return .none + } + } + } +} + +extension PersistenceReaderKey where Self == PersistenceKeyDefault> { + static var isOn: Self { + PersistenceKeyDefault(.appStorage("isOn"), false) + } +} + +// NB: This is a compile-time test to verify that optional shared state with defaults compiles. +struct StateWithOptionalSharedAndDefault { + @Shared(.optionalValueWithDefault) var optionalValueWithDefault +} +extension PersistenceKey where Self == PersistenceKeyDefault> { + fileprivate static var optionalValueWithDefault: Self { + return PersistenceKeyDefault(.appStorage("optionalValueWithDefault"), nil) + } +} diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 45cd3ba75b1f..8b7de56c2b2a 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -1005,6 +1005,56 @@ store.send(.child(.dismiss)) _ = (childViewStore1, childViewStore2, childStore1, childStore2) } + + @MainActor + func testReEntrantAction() async { + struct Feature: Reducer { + let subject = PassthroughSubject() + + struct State: Equatable { + var count = 0 + var isOn = false + var subjectCount = 0 + } + enum Action: Equatable { + case onAppear + case subjectEmitted + case tap + } + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + return .publisher { + subject.map { .subjectEmitted } + } + case .subjectEmitted: + if state.isOn { + state.count += 1 + } + state.subjectCount += 1 + return .none + case .tap: + state.isOn = true + subject.send() + state.isOn = false + return .none + } + } + } + } + + let store = Store(initialState: Feature.State()) { + Feature() + } + store.send(.onAppear) + store.send(.tap) + try? await Task.sleep(nanoseconds: 1_000_000) + XCTAssertEqual( + store.withState { $0 }, + Feature.State(count: 0, isOn: false, subjectCount: 1) + ) + } } private struct Count: TestDependencyKey { diff --git a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift index adc5c7099036..6de5bbf74ae0 100644 --- a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift @@ -124,7 +124,7 @@ final class TestStoreFailureTests: BaseTCATestCase { XCTExpectFailure { $0.compactDescription == """ - The store received 1 unexpected action after this one: â€Ļ + The store received 1 unexpected action by the end of this test: â€Ļ Unhandled actions: â€ĸ .second