diff --git a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj index c218938..76a8573 100644 --- a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj +++ b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.pbxproj @@ -8,13 +8,12 @@ /* Begin PBXBuildFile section */ 0908A6462C91C0B10035A749 /* GoodCoordinator in Frameworks */ = {isa = PBXBuildFile; productRef = 0908A6452C91C0B10035A749 /* GoodCoordinator */; }; - 092188C22C8CD57900C6085A /* NewReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 092188C12C8CD57900C6085A /* NewReactor */; }; - 099D61632C8211B500B86922 /* NewReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 099D61622C8211B500B86922 /* NewReactor */; }; + 09545AF02CB6704900DC5A61 /* LegacyReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 09545AEF2CB6704900DC5A61 /* LegacyReactor */; }; + 09545AF22CB6705100DC5A61 /* GoodReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 09545AF12CB6705100DC5A61 /* GoodReactor */; }; 099D61702C83447900B86922 /* GoodCoordinator in Frameworks */ = {isa = PBXBuildFile; productRef = 099D616F2C83447900B86922 /* GoodCoordinator */; }; 099D61732C8472D300B86922 /* GoodNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 099D61722C8472D300B86922 /* GoodNetworking */; }; 099D617F2C84A3CC00B86922 /* RandomNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099D617C2C84A3CC00B86922 /* RandomNumber.swift */; }; 099D61802C84A3CC00B86922 /* RNGEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099D617D2C84A3CC00B86922 /* RNGEndpoint.swift */; }; - 5D4A9750299CCA4800DFAEAE /* GoodReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 5D4A974F299CCA4800DFAEAE /* GoodReactor */; }; EA51F944299424A900B14A7C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA51F943299424A900B14A7C /* AppDelegate.swift */; }; EA51F94F299424AA00B14A7C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA51F94E299424AA00B14A7C /* Assets.xcassets */; }; EA51F952299424AA00B14A7C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EA51F950299424AA00B14A7C /* LaunchScreen.storyboard */; }; @@ -73,8 +72,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 09545AF22CB6705100DC5A61 /* GoodReactor in Frameworks */, 0908A6462C91C0B10035A749 /* GoodCoordinator in Frameworks */, - 092188C22C8CD57900C6085A /* NewReactor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -83,9 +82,8 @@ buildActionMask = 2147483647; files = ( 099D61702C83447900B86922 /* GoodCoordinator in Frameworks */, - 099D61632C8211B500B86922 /* NewReactor in Frameworks */, - 5D4A9750299CCA4800DFAEAE /* GoodReactor in Frameworks */, 099D61732C8472D300B86922 /* GoodNetworking in Frameworks */, + 09545AF02CB6704900DC5A61 /* LegacyReactor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -280,8 +278,8 @@ ); name = "goodreactor-swiftui-sample"; packageProductDependencies = ( - 092188C12C8CD57900C6085A /* NewReactor */, 0908A6452C91C0B10035A749 /* GoodCoordinator */, + 09545AF12CB6705100DC5A61 /* GoodReactor */, ); productName = "goodreactor-swiftui-sample"; productReference = 092188B02C8CD2A100C6085A /* goodreactor-swiftui-sample.app */; @@ -301,10 +299,9 @@ ); name = "GoodReactor-Sample"; packageProductDependencies = ( - 5D4A974F299CCA4800DFAEAE /* GoodReactor */, - 099D61622C8211B500B86922 /* NewReactor */, 099D616F2C83447900B86922 /* GoodCoordinator */, 099D61722C8472D300B86922 /* GoodNetworking */, + 09545AEF2CB6704900DC5A61 /* LegacyReactor */, ); productName = "GoodReactor-Sample"; productReference = EA51F940299424A900B14A7C /* GoodReactor-Sample.app */; @@ -707,13 +704,13 @@ isa = XCSwiftPackageProductDependency; productName = GoodCoordinator; }; - 092188C12C8CD57900C6085A /* NewReactor */ = { + 09545AEF2CB6704900DC5A61 /* LegacyReactor */ = { isa = XCSwiftPackageProductDependency; - productName = NewReactor; + productName = LegacyReactor; }; - 099D61622C8211B500B86922 /* NewReactor */ = { + 09545AF12CB6705100DC5A61 /* GoodReactor */ = { isa = XCSwiftPackageProductDependency; - productName = NewReactor; + productName = GoodReactor; }; 099D616F2C83447900B86922 /* GoodCoordinator */ = { isa = XCSwiftPackageProductDependency; @@ -723,10 +720,6 @@ isa = XCSwiftPackageProductDependency; productName = GoodNetworking; }; - 5D4A974F299CCA4800DFAEAE /* GoodReactor */ = { - isa = XCSwiftPackageProductDependency; - productName = GoodReactor; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = EA51F938299424A900B14A7C /* Project object */; diff --git a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f6509d9..41343b6 100644 --- a/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GoodReactor-Sample/GoodReactor-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "29f6f72823407f70983528a7c3a0a061ad6f1aa0c17e358f4623a31f0b714680", + "originHash" : "e66c8117d9b42237263b4de46edc4aa97b6cdad073c429d74b862601ff05bbc5", "pins" : [ { "identity" : "alamofire", "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", - "version" : "5.9.1" + "revision" : "e16d3481f5ed35f0472cb93350085853d754913f", + "version" : "5.10.1" } }, { @@ -19,6 +19,15 @@ "version" : "4.3.0" } }, + { + "identity" : "chronometer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/KittyMac/Chronometer.git", + "state" : { + "revision" : "d21b89e5cb5929b5175bdb3ad710a52cbf497eaa", + "version" : "0.1.12" + } + }, { "identity" : "combineext", "kind" : "remoteSourceControl", @@ -33,8 +42,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GoodRequest/GoodLogger.git", "state" : { - "revision" : "4c5761a062fd2a98c9b81078a029a14b67ccab2a", - "version" : "1.1.0" + "revision" : "8203802fcff9163a309bd5edd44cc6f649404050", + "version" : "1.2.4" + } + }, + { + "identity" : "hitch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/KittyMac/Hitch.git", + "state" : { + "revision" : "d6c147a1d70992db39a141cb5bf9cf8fbb776250", + "version" : "0.4.148" + } + }, + { + "identity" : "sextant", + "kind" : "remoteSourceControl", + "location" : "https://github.com/KittyMac/Sextant.git", + "state" : { + "revision" : "52a77d0bce0210cf9557faef7fd0adb9a6da02fb", + "version" : "0.4.31" + } + }, + { + "identity" : "spanker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/KittyMac/Spanker.git", + "state" : { + "revision" : "d4b439bf76a40fb45d86a24d13b9c26e7d630eee", + "version" : "0.2.49" } }, { diff --git a/GoodReactor-Sample/GoodReactor-Sample/Screens/About/AboutViewModel.swift b/GoodReactor-Sample/GoodReactor-Sample/Screens/About/AboutViewModel.swift index 362baf5..967ac8f 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Screens/About/AboutViewModel.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Screens/About/AboutViewModel.swift @@ -7,7 +7,7 @@ import Combine import Foundation -import GoodReactor +import LegacyReactor final class AboutViewModel: GoodReactor { diff --git a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift index 01d1262..b15cfe3 100644 --- a/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift +++ b/GoodReactor-Sample/GoodReactor-Sample/Screens/Home/HomeViewController.swift @@ -7,7 +7,7 @@ import Combine import UIKit -import NewReactor +import GoodReactor final class HomeViewController: BaseViewController { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift index f48fb8f..d6c151d 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppReactor.swift @@ -6,14 +6,14 @@ // import GoodCoordinator -import NewReactor +import GoodReactor import Observation import SwiftUI // TODO: Coordinator reactor macro for empty reactors @Observable final class AppReactor: Reactor { - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event enum Action { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift index d5daa84..88397da 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/App/AppSwiftUI.swift @@ -6,7 +6,7 @@ // import GoodCoordinator -import NewReactor +import GoodReactor import SwiftUI @main struct goodreactor_swiftui_sampleApp: App { @@ -16,6 +16,7 @@ import SwiftUI MainWindow() } } + } @NavigationRoot struct MainWindow: View { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift index 6b0606b..6eb4855 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentView.swift @@ -6,7 +6,7 @@ // import GoodCoordinator -import NewReactor +import GoodReactor import SwiftUI struct ContentView: View { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift index 4222fff..9916d2b 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Content/ContentViewModel.swift @@ -6,13 +6,13 @@ // import GoodCoordinator -import NewReactor +import GoodReactor import Observation import SwiftUI @Observable final class ContentViewModel: Reactor { - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event enum Action { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift index 40e6354..c4f205d 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailView.swift @@ -7,7 +7,7 @@ import GoodCoordinator import SwiftUI -import NewReactor +import GoodReactor struct DetailView: View { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift index 1527637..c6c82b4 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Detail/DetailViewModel.swift @@ -6,12 +6,12 @@ // import GoodCoordinator -import NewReactor +import GoodReactor import Observation @Observable final class DetailViewModel: Reactor { - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event enum Action { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift index 1f50f76..824c9fa 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import NewReactor +import GoodReactor struct HomeView: View { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift index 140499b..783911c 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/HomeView/HomeViewModel.swift @@ -7,12 +7,12 @@ import Foundation import GoodCoordinator -import NewReactor +import GoodReactor import Observation @Observable final class HomeViewModel: Reactor { - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event enum Action { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift index 91ee475..d8fa430 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import NewReactor +import GoodReactor struct LoginView: View { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift index f41501c..7e19b81 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/LoginView/LoginViewModel.swift @@ -7,12 +7,12 @@ import Foundation import GoodCoordinator -import NewReactor +import GoodReactor import Observation @Observable final class LoginViewModel: Reactor { - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event enum Action { diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift index e8c67ae..50982d2 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileView.swift @@ -5,7 +5,7 @@ // Created by Filip Šašala on 24/09/2024. // -import NewReactor +import GoodReactor import GoodCoordinator import SwiftUI diff --git a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift index 15b73c4..105f89a 100644 --- a/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift +++ b/GoodReactor-Sample/goodreactor-swiftui-sample/Screens/Profile/ProfileViewModel.swift @@ -7,12 +7,12 @@ import Foundation import GoodCoordinator -import NewReactor +import GoodReactor import Observation @Observable final class ProfileViewModel: Reactor { - typealias Event = NewReactor.Event + typealias Event = GoodReactor.Event enum Action { diff --git a/README.md b/README.md index ba54171..28adcb6 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ func makeInitialState() -> State { Finally in the `reduce` function you define how `state` changes, according to certain `event`s: ```swift -typealias Event = NewReactor.Event +typealias Event = GoodReactor.Event func reduce(state: inout State, event: Event) { switch event.kind { diff --git a/Sources/GoodReactor/Identifier.swift b/Sources/GoodReactor/Identifier.swift index c8e1c0d..516ab11 100644 --- a/Sources/GoodReactor/Identifier.swift +++ b/Sources/GoodReactor/Identifier.swift @@ -20,3 +20,15 @@ public extension Identifier { } } + +// MARK: - Code location identifier + +public final class CodeLocationIdentifier: Identifier { + + public let id: String + + public init(_ file: StaticString = #file, _ line: UInt = #line, _ column: UInt = #column) { + self.id = "\(file):\(line):\(column)" + } + +} diff --git a/Sources/GoodReactor/MapTables.swift b/Sources/GoodReactor/MapTables.swift index ad79b24..213d974 100644 --- a/Sources/GoodReactor/MapTables.swift +++ b/Sources/GoodReactor/MapTables.swift @@ -11,15 +11,31 @@ internal enum MapTables { typealias AnyReactor = AnyObject + // State of a reactor static let state = WeakMapTable() + + // Initial state of a reactor static let initialState = WeakMapTable() - static let destinations = WeakMapTable() + + // Number of currently running asynchronous tasks for an event of a reactor static let runningEvents = WeakMapTable>() + + // Subscriptions of a reactor (new way) static let subscriptions = WeakMapTable>() + + // Debouncers of a reactor + static let debouncers = WeakMapTable>() + + // State stream cancellable (Combine) static let stateStreams = WeakMapTable() + + // Event stream cancellable (Combine) static let eventStreams = WeakMapTable() + + // Logger of a reactor static let loggers = WeakMapTable() + // Semaphore lock of an event (does not matter which reactor it's running on) static let eventLocks = WeakMapTable() } diff --git a/Sources/GoodReactor/Reactor.swift b/Sources/GoodReactor/Reactor.swift index c078273..a49a960 100644 --- a/Sources/GoodReactor/Reactor.swift +++ b/Sources/GoodReactor/Reactor.swift @@ -254,7 +254,9 @@ public extension Reactor { // MARK: - Public public extension Reactor { - + + typealias DebouncerResultHandler = (@Sendable () async -> Mutation?) + /// Send an action to this Reactor. The Reactor will decide how to modify /// the ``State`` according to the implementation of ``reduce(state:event:)`` /// function. @@ -305,7 +307,7 @@ public extension Reactor { } /// Asynchronously runs a handler associated with an event. If handler returns a mutation, - /// handler waits until the mutation is reduced before returning. + /// the entire event waits until the mutation is reduced before continuing. /// /// - Parameters: /// - event: Event responsible for starting the asynchronous task. @@ -316,7 +318,12 @@ public extension Reactor { /// This function doesn't block. /// /// - important: Start async events only from ``reduce(state:event:)`` to ensure correct behaviour. - func run(_ event: Event, _ eventHandler: @autoclosure @escaping () -> @Sendable () async -> Mutation?) { + /// - warning: This function is unavailable from asynchronous contexts. If you need to run multiple tasks + /// concurrently, create a `TaskGroup` with ``_Concurrency/Task``s, use `async let` or consider using + /// an external helper struct. + /// - note: If you need to return multiple mutations from an asynchronous event, create a helper struct + /// and supply mutations using a ``Publisher`` and ``subscribe(to:map:)`` + @available(*, noasync) func run(_ event: Event, _ eventHandler: @autoclosure @escaping () -> @Sendable () async -> Mutation?) { let semaphore = MapTables.eventLocks[key: event.id, default: AsyncSemaphore(value: 0)] MapTables.runningEvents[key: self, default: []].insert(event.id) @@ -341,6 +348,67 @@ public extension Reactor { } } } + + /// Debounces calls to a function by ignoring repeated successive calls. If handler returns a mutation, + /// the mutation will be executed once when debouncing is + /// + /// ## Usage + /// ```swift + /// debounce(duration: .seconds(1)) { + /// await sendDebouncedNetworkRequest() + /// } + /// ``` + /// + /// - Parameters: + /// - duration: Time interval the debouncer will wait for any repeated calls. + /// - resultHandler: Function that will be called when there are no more + /// events to debounce and enough time has passed. + /// - file: Internal debouncer identifier macro, do not pass any value. + /// - line: Internal debouncer identifier macro, do not pass any value. + /// - column: Internal debouncer identifier macro, do not pass any value. + /// + /// This function doesn't block. + /// + /// - note: Each debouncer is identified by its location in source code. If you need to + /// supply events to a debouncer from multiple locations, use an instance of a ``Debouncer`` + /// directly. + func debounce( + duration: DispatchTimeInterval, + resultHandler: @escaping DebouncerResultHandler, + _ file: StaticString = #file, + _ line: UInt = #line, + _ column: UInt = #column + ) { + let debouncerIdentifier = DebouncerIdentifier(file, line, column) + + let localDebouncer: Debouncer + if let debouncer = MapTables.debouncers[key: self, default: [:]][debouncerIdentifier] as? Debouncer { + localDebouncer = debouncer + } else { + // create new debouncer + localDebouncer = Debouncer(delay: duration, outputHandler: { @MainActor [weak self] resultHandler in + guard let self else { return } + + let mutation = await Task.detached { await resultHandler() }.value + + guard !Task.isCancelled else { + _debugLog(message: "Debounce cancelled") + return + } + + if let mutation { + let mutationEvent = Event(kind: .mutation(mutation)) + _send(event: mutationEvent) + } + }) + + MapTables.debouncers[key: self, default: [:]][debouncerIdentifier] = localDebouncer + } + + Task { + await localDebouncer.push(resultHandler) + } + } /// Create a new subscription to external event publisher for current reactor. /// diff --git a/Sources/GoodReactor/ViewModel.swift b/Sources/GoodReactor/SwiftUI/ViewModel.swift similarity index 100% rename from Sources/GoodReactor/ViewModel.swift rename to Sources/GoodReactor/SwiftUI/ViewModel.swift diff --git a/Sources/GoodReactor/AnyTask.swift b/Sources/GoodReactor/Utilities/AnyTask.swift similarity index 100% rename from Sources/GoodReactor/AnyTask.swift rename to Sources/GoodReactor/Utilities/AnyTask.swift diff --git a/Sources/GoodReactor/AsyncSemaphore.swift b/Sources/GoodReactor/Utilities/AsyncSemaphore.swift similarity index 100% rename from Sources/GoodReactor/AsyncSemaphore.swift rename to Sources/GoodReactor/Utilities/AsyncSemaphore.swift diff --git a/Sources/GoodReactor/Utilities/Debouncer.swift b/Sources/GoodReactor/Utilities/Debouncer.swift new file mode 100644 index 0000000..473355d --- /dev/null +++ b/Sources/GoodReactor/Utilities/Debouncer.swift @@ -0,0 +1,166 @@ +// +// Debouncer.swift +// GoodReactor +// +// Created by Filip Šašala on 25/10/2024. +// + +import Foundation + +/// Executes an output only after a specified time interval elapses between events +/// +/// ```swift +/// let debouncer = Debouncer(dueTime: .seconds(2), output: { print($0) }) +/// +/// for index in (0...99) { +/// DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(100 * index))) { +/// // pushes a value every 100 ms +/// debouncer.push(index) +/// } +/// } +/// +/// // will only print "99" 2 seconds after the last call to `push(_:)` +/// ``` +public actor Debouncer { + + // MARK: - Enums + + typealias DueValue = (value: Value, dueTime: DispatchTime) + + enum State { + + case idle + case debouncing(value: DueValue, nextValue: DueValue?) + + } + + enum DebouncingResult { + + case continueDebouncing(DueValue) + case finishDebouncing + + } + + // MARK: - Properties + + public var outputHandler: (@isolated(any) (Value) async -> Void)? + public var timeInterval: DispatchTimeInterval + + private var state: State + private var task: Task? + + // MARK: - Initialization + + /// A debouncer that executes output only after a specified time has elapsed between events. + /// - Parameters: + /// - delay: How long the debouncer should wait before executing the output + /// - outputHandler: Handler to execute once the debouncing is done + public init( + delay timeInterval: DispatchTimeInterval = .never, + @_implicitSelfCapture outputHandler: (@isolated(any) @Sendable (Value) async -> Void)? = nil + ) { + self.state = .idle + self.timeInterval = timeInterval + self.outputHandler = outputHandler + } + + deinit { + task?.cancel() + } + +} + +// MARK: - Public + +public extension Debouncer { + + /// Send an updated value to debouncer. + func push(_ value: Value) { + let newValue = DueValue(value: value, dueTime: DispatchTime.now().advanced(by: timeInterval)) + + switch self.state { + case .idle: + self.state = .debouncing(value: newValue, nextValue: nil) + self.task = makeNewDebouncingTask(value) + + case .debouncing(let current, _): + self.state = .debouncing(value: current, nextValue: newValue) + } + } + +} + +// MARK: - Private + +private extension Debouncer { + + private func makeNewDebouncingTask(_ value: Value) -> Task { + return Task { + var timeToSleep = timeInterval.nanoseconds + var currentValue = value + + loop: while true { + try? await Task.sleep(nanoseconds: timeToSleep) + + let result: DebouncingResult + switch self.state { + case .idle: + assertionFailure("inconsistent state, a value was being debounced") + result = .finishDebouncing + + case .debouncing(_, nextValue: .some(let nextValue)): + state = .debouncing(value: nextValue, nextValue: nil) + result = .continueDebouncing(nextValue) + + case .debouncing(_, nextValue: .none): + state = .idle + result = .finishDebouncing + } + + switch result { + case .finishDebouncing: + break loop + + case .continueDebouncing(let value): + timeToSleep = DispatchTime.now().distance(to: value.dueTime).nanoseconds + currentValue = value.value + } + } + + await outputHandler?(currentValue) + } + } + +} + +// MARK: - Extensions + +fileprivate extension DispatchTimeInterval { + + var nanoseconds: UInt64 { + switch self { + case .nanoseconds(let value) where value >= 0: + return UInt64(value) + + case .microseconds(let value) where value >= 0: + return UInt64(value) * 1000 + + case .milliseconds(let value) where value >= 0: + return UInt64(value) * 1_000_000 + + case .seconds(let value) where value >= 0: + return UInt64(value) * 1_000_000_000 + + case .never: + return .zero + + default: + return .zero + } + } + +} + +// MARK: - Identifier + +internal typealias DebouncerIdentifier = CodeLocationIdentifier diff --git a/Sources/GoodReactor/NSPointerArrayExtension.swift b/Sources/GoodReactor/Utilities/NSPointerArrayExtension.swift similarity index 100% rename from Sources/GoodReactor/NSPointerArrayExtension.swift rename to Sources/GoodReactor/Utilities/NSPointerArrayExtension.swift diff --git a/Sources/GoodReactor/Publisher.swift b/Sources/GoodReactor/Utilities/Publisher.swift similarity index 59% rename from Sources/GoodReactor/Publisher.swift rename to Sources/GoodReactor/Utilities/Publisher.swift index 89001ad..9deb605 100644 --- a/Sources/GoodReactor/Publisher.swift +++ b/Sources/GoodReactor/Utilities/Publisher.swift @@ -31,6 +31,14 @@ public extension Publisher { eachSubscriber { await $0.finish() } } + nonisolated func sendAsync(_ value: Value) { + Task { await send(value) } + } + + nonisolated func finishAsync() { + Task { await finish() } + } + } // MARK: - Internal @@ -60,3 +68,32 @@ private extension Publisher { } } + +// MARK: - Property wrapper + +public typealias Broadcast = GoodReactor.Published + +@propertyWrapper public final class Published { + + public var wrappedValue: Value { + didSet { + projectedValue.sendAsync(wrappedValue) + } + } + + public var projectedValue: Publisher + + public init(wrappedValue: Value, sendInitialValue: Bool = false) { + self.wrappedValue = wrappedValue + self.projectedValue = Publisher() + + if sendInitialValue { + projectedValue.sendAsync(wrappedValue) + } + } + + deinit { + projectedValue.finishAsync() + } + +} diff --git a/Sources/GoodReactor/Subscriber.swift b/Sources/GoodReactor/Utilities/Subscriber.swift similarity index 100% rename from Sources/GoodReactor/Subscriber.swift rename to Sources/GoodReactor/Utilities/Subscriber.swift diff --git a/Sources/GoodReactor/WeakMapTable.swift b/Sources/GoodReactor/Utilities/WeakMapTable.swift similarity index 100% rename from Sources/GoodReactor/WeakMapTable.swift rename to Sources/GoodReactor/Utilities/WeakMapTable.swift diff --git a/Tests/GoodReactorTests/GoodReactorTests.swift b/Tests/GoodReactorTests/GoodReactorTests.swift index c90a9bd..eb2f864 100644 --- a/Tests/GoodReactorTests/GoodReactorTests.swift +++ b/Tests/GoodReactorTests/GoodReactorTests.swift @@ -66,4 +66,21 @@ final class GoodReactorTests: XCTestCase { withExtendedLifetime(cancellable, {}) } + @MainActor func testDebounce() async { + let model = ObservableModel() + + XCTAssertEqual(model.state.counter, 9) + + for _ in 0..<10 { + await model.send(action: .debounceTest) // send event 10x in a second + try? await Task.sleep(for: .milliseconds(100)) + } + + XCTAssertEqual(model.state.counter, 9) // event should be waiting in debouncer + + try? await Task.sleep(for: .seconds(1)) + + XCTAssertEqual(model.state.counter, 10) // event should be debounced by now + } + } diff --git a/Tests/GoodReactorTests/PublisherTests.swift b/Tests/GoodReactorTests/PublisherTests.swift index ed9c3ea..6757c3f 100644 --- a/Tests/GoodReactorTests/PublisherTests.swift +++ b/Tests/GoodReactorTests/PublisherTests.swift @@ -102,6 +102,25 @@ final class PublisherTests: XCTestCase { await fulfillment(of: [expectation], timeout: 3) } + @Broadcast var publishedWrapperCounter = 9 + + func testPublishedWrapper() async { + let subscriber = Subscriber() + await subscriber.subscribe(to: $publishedWrapperCounter) + + let expectation = XCTestExpectation(description: "Received new value when a Published/Broadcast variable is set") + + Task { + let result = await subscriber.next() + XCTAssertEqual(result, 100) + expectation.fulfill() + } + + self.publishedWrapperCounter = 100 + + await fulfillment(of: [expectation]) + } + } // Only for testing purposes diff --git a/Tests/GoodReactorTests/Samples/ObservableModel.swift b/Tests/GoodReactorTests/Samples/ObservableModel.swift index 91ee738..afa2135 100644 --- a/Tests/GoodReactorTests/Samples/ObservableModel.swift +++ b/Tests/GoodReactorTests/Samples/ObservableModel.swift @@ -32,12 +32,15 @@ final class EmptyObject {} case subtractOne case resetToZero case cascade + case multipleRun + case debounceTest } enum Mutation { case didChangeTime(seconds: Int) case didAddOne case didReceiveValue(newValue: Int) + case didAddOneWithDelay } // MARK: Destination @@ -79,6 +82,29 @@ final class EmptyObject {} let oldValue = state.counter run(event) { await self.asyncAddOne(oldValue: oldValue) } + case .action(.multipleRun): + run(event) { + try? await Task.sleep(for: .seconds(1)) + return .didAddOneWithDelay + } + run(event) { + try? await Task.sleep(for: .seconds(1)) + return .didAddOneWithDelay + } + run(event) { + try? await Task.sleep(for: .seconds(1)) + return .didAddOneWithDelay + } + + case .action(.debounceTest): + let counterValue = state.counter + + print("debounce action started") + debounce(duration: .milliseconds(500)) { + print("running debounced function") + return await self.asyncAddOne(oldValue: counterValue) + } + case .mutation(.didAddOne): state.counter += 1 @@ -87,6 +113,9 @@ final class EmptyObject {} run(event) { await self.asyncAddOne(oldValue: counterValue) } } + case .mutation(.didAddOneWithDelay): + state.counter += 1 + case .mutation(.didReceiveValue(let newValue)): state.counter = newValue @@ -97,8 +126,13 @@ final class EmptyObject {} // MARK: Async/side effects + func asyncAddOne() async -> Mutation? { + try? await Task.sleep(nanoseconds: UInt64(5e8)) // 500 ms + return .didAddOneWithDelay + } + func asyncAddOne(oldValue: Int) async -> Mutation? { - try? await Task.sleep(nanoseconds: UInt64(33e7)) + try? await Task.sleep(nanoseconds: UInt64(33e7)) // 330 ms if oldValue < 10 { return .didAddOne