diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Dripper.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Dripper.xcscheme new file mode 100644 index 0000000..d3d7796 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Dripper.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/DripperTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/DripperTests.xcscheme new file mode 100644 index 0000000..76e3540 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/DripperTests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/Counter/Counter/Counter.swift b/Examples/Counter/Counter/Counter.swift index af99124..655ff4d 100644 --- a/Examples/Counter/Counter/Counter.swift +++ b/Examples/Counter/Counter/Counter.swift @@ -18,14 +18,40 @@ struct Counter: Dripper { @Observable @MainActor - final class State: Sendable { + final class State: @preconcurrency CustomStringConvertible { + + // MARK: Properties + var counter: Int = .zero var text = "" + + @ObservationIgnored private let id: UUID + + // MARK: Computed Properties + + var description: String { + "Count: \(counter)" + } + + // MARK: Lifecycle + + init(counter: Int = .zero, text: String = "") { + let id = UUID() + self.id = id + self.counter = counter + self.text = text + os_log("State initialized: \(id)") + } + + deinit { + os_log("State deinitialized: \(self.id)") + } } enum Action { case increaseCounter case decreaseCounter + case setCounter(value: Int) case resetCounter case randomNumber } @@ -39,14 +65,14 @@ struct Counter: Dripper { state.counter += 1 case .decreaseCounter: state.counter -= 1 + case .setCounter(let value): + state.counter = value case .resetCounter: state.counter = .zero case .randomNumber: return .run { pour in let randomNumber = try await randomNumber() - - pour(.decreaseCounter) - state.counter = randomNumber + pour(.setCounter(value: randomNumber)) } } @@ -129,12 +155,3 @@ struct CounterView: View { } ) } - -#Preview { - CounterView( - station: Station( - initialState: Counter.State(), - dripper: Counter() - ) - ) -} diff --git a/Examples/Counter/Counter/CounterApp.swift b/Examples/Counter/Counter/CounterApp.swift index c59e0b1..6ee2914 100644 --- a/Examples/Counter/Counter/CounterApp.swift +++ b/Examples/Counter/Counter/CounterApp.swift @@ -11,13 +11,30 @@ import Dripper @main struct CounterApp: App { - private let counterStation = Station(initialState: Counter.State()) { - Counter() - } + + // MARK: Properties + + @State private var path: [Int] = [] + + // MARK: Computed Properties var body: some Scene { WindowGroup { - CounterView(station: counterStation) + NavigationStack(path: $path) { + List { + NavigationLink("1", value: 1) + NavigationLink("2", value: 2) + NavigationLink("3", value: 3) + } + .navigationTitle("Starting Counter") + .navigationDestination(for: Int.self) { number in + CounterView( + station: Station(initialState: Counter.State(counter: number)) { + Counter() + } + ) + } + } } } } diff --git a/Sources/Dripper/Drip.swift b/Sources/Dripper/Drip.swift index 6aa565a..3b968ae 100644 --- a/Sources/Dripper/Drip.swift +++ b/Sources/Dripper/Drip.swift @@ -7,29 +7,28 @@ import Foundation -public struct Drip: Dripper { - - // MARK: Nested Types - - public typealias Dripping = @MainActor (_ state: State, _ action: Action) -> Effect? +public struct Drip: Dripper { // MARK: Properties - @usableFromInline let drip: Dripping + @usableFromInline let drip: @MainActor (State, Action) -> Effect? // MARK: Lifecycle @inlinable - public init(_ drip: @escaping Dripping) { + public init( + _ drip: @MainActor @escaping (_ state: State, _ action: Action) -> Effect + ) { self.init(internal: drip) } + @inlinable public init(_ dripper: some Dripper) { self.init(internal: dripper.drip) } @usableFromInline - init(internal drip: @escaping Dripping) { + init(internal drip: @MainActor @escaping (State, Action) -> Effect?) { self.drip = drip } diff --git a/Sources/Dripper/Dripper.swift b/Sources/Dripper/Dripper.swift index 1145695..85ca504 100644 --- a/Sources/Dripper/Dripper.swift +++ b/Sources/Dripper/Dripper.swift @@ -13,7 +13,7 @@ public typealias DripperOf = Dripper @MainActor public protocol Dripper: Sendable { - associatedtype State: ObservableState + associatedtype State: StationState associatedtype Action associatedtype Body diff --git a/Sources/Dripper/DripperBuilder.swift b/Sources/Dripper/DripperBuilder.swift index 2406b39..d93b666 100644 --- a/Sources/Dripper/DripperBuilder.swift +++ b/Sources/Dripper/DripperBuilder.swift @@ -8,7 +8,7 @@ import Foundation @resultBuilder -public enum DripperBuilder { +public enum DripperBuilder { @inlinable public static func buildBlock>(_ dripper: D) -> D { dripper @@ -19,8 +19,8 @@ public enum DripperBuilder { expression } - @inlinable @MainActor + @inlinable public static func buildExpression( _ expression: any Dripper ) -> Drip { diff --git a/Sources/Dripper/Effect.swift b/Sources/Dripper/Effect.swift index e239202..a8523fd 100644 --- a/Sources/Dripper/Effect.swift +++ b/Sources/Dripper/Effect.swift @@ -10,25 +10,21 @@ import OSLog // MARK: - Effect -public struct Effect { - public typealias Blend = (_ blend: Pour) async -> Void - - @usableFromInline let blend: Blend +public struct Effect: Sendable { + @usableFromInline let blend: @Sendable (_ pour: Pour) async -> Void @usableFromInline - init(blend: @escaping Blend) { + init(blend: @Sendable @escaping (_ pour: Pour) async -> Void) { self.blend = blend } } // MARK: - Pour -@MainActor public struct Pour: Sendable { - let pour: @MainActor @Sendable (Action) -> Void + let pour: @Sendable (Action) -> Void - @usableFromInline - init(pour: @escaping @MainActor @Sendable (Action) -> Void) { + public init(pour: @Sendable @escaping (Action) -> Void) { self.pour = pour } @@ -50,7 +46,7 @@ extension Effect { // MARK: Static Functions public static func run( - blend: @escaping @MainActor (_ pour: Pour) async throws -> Void, + blend: @MainActor @escaping (_ pour: Pour) async throws -> Void, catch errorHandler: (@MainActor (_ error: any Error, _ pour: Pour) async -> Void)? = nil, fileID: StaticString = #fileID, line: UInt = #line diff --git a/Sources/Dripper/StateHandler.swift b/Sources/Dripper/StateHandler.swift new file mode 100644 index 0000000..b687ad0 --- /dev/null +++ b/Sources/Dripper/StateHandler.swift @@ -0,0 +1,104 @@ +// +// StateHandler.swift +// Dripper +// +// Created by 이창준 on 10/10/24. +// + +import Foundation +import OSLog + +// MARK: - StateHandler + +/// `StateActor` is an actor class responsible for managing `State` in a thread-safe manner. +/// +/// `StateActor` maintains the state, processes actions, and manages side-effects by coordinating with a `Dripper` to yield +/// updated state values asynchronously. +/// +/// `StateActor` encapsulates state modifications, ensuring data race safety by using isolated actor environment. \ +/// It also provides an asynchronous stream (`AsyncStream`) for any listener outside actor that is interested in state changes, +/// and handles asynchronous side-effects using `Task` objects. +/// +/// ## Generic Parameters +/// - `State`: The type that represents the state, must conform to `Sendable` to allow safe concurrent access. +/// - `Action`: The type that represents actions that can be dispatched, must conform to `Sendable` to allow safe concurrent access. +@dynamicMemberLookup +public actor StateHandler: StateYieldPolicy { + + // MARK: Properties + + let continuation: AsyncStream.Continuation + + /// Asynchronous stream which yields `State`. + private(set) var stream: AsyncStream + + private let dripper: any Dripper + + private var tasks: [UUID: Task] = [:] + + /// Source of `State` managed by this actor. + /// It also yields new + private var state: State + + // MARK: Lifecycle + + init(initialState state: State, dripper: some Dripper) { + self.state = state + self.dripper = dripper + + let (stream, continuation) = AsyncStream.makeStream() + self.stream = stream + self.continuation = continuation + } + + deinit { + for task in tasks { task.value.cancel() } + continuation.finish() + } + + // MARK: Functions + + func pour(_ action: Action) async { + let taskID = UUID() +// let oldState = state + let effect = await dripper.drip(state, action) + // FIXME: Only yield on continuation when state has changed. + // Since `State` is a class, we can't know if it's changed or not by simply + // copying and comparing them. +// if shouldYield(oldValue: oldState, newValue: state) { + continuation.yield(state) +// } + + if let effect { // Side-Effect occurred + let task = Task { [weak self, taskID] in + guard let self else { return } + let pour = Pour { action in + Task { + await self.pour(action) + } + } + await effect.blend(pour) + await self.removeTask(taskID) + } + + tasks.updateValue(task, forKey: taskID) + } + } + + subscript( + dynamicMember dynamicMember: ReferenceWritableKeyPath & Sendable + ) -> Member { + get { state[keyPath: dynamicMember] } + set { + state[keyPath: dynamicMember] = newValue + continuation.yield(state) + } + } + + private func removeTask(_ key: UUID) { + if let task = tasks.removeValue(forKey: key) { + task.cancel() + } + } + +} diff --git a/Sources/Dripper/StateYieldPolicy.swift b/Sources/Dripper/StateYieldPolicy.swift new file mode 100644 index 0000000..78fa96c --- /dev/null +++ b/Sources/Dripper/StateYieldPolicy.swift @@ -0,0 +1,25 @@ +// +// StateYieldPolicy.swift +// Dripper +// +// Created by 이창준 on 10/11/24. +// + +// MARK: - StateYieldPolicy + +protocol StateYieldPolicy { + associatedtype State + func shouldYield(oldValue: State, newValue: State) -> Bool +} + +extension StateYieldPolicy { + func shouldYield(oldValue _: State, newValue _: State) -> Bool { + true + } +} + +extension StateYieldPolicy where State: Equatable { + func shouldYield(oldValue: State, newValue: State) -> Bool { + oldValue != newValue + } +} diff --git a/Sources/Dripper/Station.swift b/Sources/Dripper/Station.swift index 3be5ae7..03b6e42 100644 --- a/Sources/Dripper/Station.swift +++ b/Sources/Dripper/Station.swift @@ -6,36 +6,52 @@ // import Foundation -import SwiftUI -public typealias StationOf = Station +public typealias StationState = Observable & Sendable -public typealias ObservableState = Observable & Sendable +public typealias StationOf = Station // MARK: - Station -@dynamicMemberLookup +/// ``Station`` is responsible for coordinating state updates, while the actual state is managed by ``StateHandler``, +/// which is an actor that ensures thread-safe state management. +/// +/// `Station` provides support for observing state changes using an `AsyncStream`. It manages a background task to +/// continuously update the state. +/// +/// > Important: +/// > ``state-swift.property`` should be a class since `@Observable` is currently only available on class objects. +/// > Therefore, `State`'s shape should be as below. +/// ```swift +/// @MainActor // UI binding + conforming Sendable +/// @Observable // Observation +/// final class State { +/// var count: Int = .zero +/// } +/// ``` @MainActor -public final class Station { +@dynamicMemberLookup +public final class Station: StateYieldPolicy { // MARK: Properties - private var state: State - private let dripper: any Dripper + /// The current state of the station. + /// + /// Since ``Dripper/Dripper/State`` is a class type, it's referenced by both ``Station`` and ``StateHandler``. + public private(set) var state: State - // MARK: Computed Properties + private let stateHandler: StateHandler - public var currentState: State { - state - } + private let continuation: AsyncStream.Continuation + private var task: Task? // MARK: Lifecycle public convenience init( initialState: State, - dripper: some Dripper + dripper: @Sendable @autoclosure () -> some Dripper ) { - self.init(state: initialState, dripper: dripper) + self.init(state: initialState, dripper: dripper()) } public convenience init( @@ -45,51 +61,61 @@ public final class Station { self.init(state: initialState, dripper: dripperBuilder()) } - init( - state: D.State, - dripper: D - ) where D.State == State, D.Action == Action { + init(state: D.State, dripper: D) where D.State == State, D.Action == Action { self.state = state - self.dripper = dripper + + let stateHandler = StateHandler(initialState: state, dripper: dripper) + self.stateHandler = stateHandler + self.continuation = stateHandler.continuation + + // Update `state` as `StateHandler`'s `state` is updated. + self.task = Task { @MainActor [weak self] in + guard let stateStream = await self?.stateHandler.stream else { return } + for await state in stateStream { + guard let self else { break } + self.state = state + } + } + } + + deinit { + task?.cancel() + // Continuation is finished on `StateHandler` } // MARK: Functions public func pour(_ action: Action) { - let effect = dripper.drip(state, action) - - if let effect { - Task { - await effect.blend( - Pour { self.pour($0) } - ) - } + Task { + await stateHandler.pour(action) } } public subscript( dynamicMember dynamicMember: ReferenceWritableKeyPath ) -> Member { - get { - state[keyPath: dynamicMember] - } + get { state[keyPath: dynamicMember] } set { state[keyPath: dynamicMember] = newValue + continuation.yield(state) } } } -extension Station where State: AnyObject { +#if canImport(SwiftUI) +import SwiftUI + +extension Station { public func bind( _ dynamicMember: ReferenceWritableKeyPath ) -> Binding { Binding( - get: { - self.state[keyPath: dynamicMember] - }, + get: { self.state[keyPath: dynamicMember] }, set: { newValue in self.state[keyPath: dynamicMember] = newValue + self.continuation.yield(self.state) } ) } } +#endif diff --git a/Tests/DripperTests/DripperTests.swift b/Tests/DripperTests/DripperTests.swift index cfe1d72..921754c 100644 --- a/Tests/DripperTests/DripperTests.swift +++ b/Tests/DripperTests/DripperTests.swift @@ -1,19 +1,150 @@ // -// Test.swift +// DripperTests.swift // Dripper // // Created by 이창준 on 9/27/24. // +import Foundation +import OSLog import Testing @testable import Dripper -struct Test { +// MARK: - Heartbeat + +struct Heartbeat: AsyncSequence, Sendable { + + // MARK: Nested Types + + public struct HeartbeatIterator: AsyncIteratorProtocol { + + // MARK: Properties + + var clock: C + let duration: C.Duration + let deadline: C.Instant? + let maxBeats: Int? + var beatCount: Int = .zero + + // MARK: Lifecycle + + init(clock: C, duration: C.Duration, deadline: C.Duration?, maxBeats: Int?) { + self.clock = clock + self.duration = duration + self.deadline = deadline.map { clock.now.advanced(by: $0) } ?? nil + self.maxBeats = maxBeats + } + + // MARK: Functions + + public mutating func next() async -> C.Instant? { + defer { beatCount += 1 } + if let maxBeats, beatCount >= maxBeats { + return nil + } + + if let deadline, clock.now >= deadline { + return nil + } + + do { + try await clock.sleep(for: duration) + } catch { + return nil + } + + return clock.now + } + } + + // MARK: Properties + + let clock: C + let duration: C.Duration + let deadline: C.Duration? + let maxBeats: Int? + + // MARK: Lifecycle + + init(clock: C, duration: C.Duration, deadline: C.Duration? = nil, maxBeats: Int? = nil) { + self.clock = clock + self.duration = duration + self.deadline = deadline + self.maxBeats = maxBeats + } + + // MARK: Functions + + public func makeAsyncIterator() -> HeartbeatIterator { + HeartbeatIterator(clock: clock, duration: duration, deadline: deadline, maxBeats: maxBeats) + } +} + +// MARK: - TestDripper + +struct TestDripper: Dripper { + + // MARK: Nested Types + + enum Action { + case increase(by: Int = 1) + } + + @MainActor + @Observable + final class State: Sendable { + + // MARK: Properties + + var counter: Int = .zero + + // MARK: Lifecycle + + init(counter: Int = .zero) { + self.counter = counter + } + } + + // MARK: Computed Properties + + var body: some Dripper { + Drip { state, action in + switch action { + case .increase(let amount): + state.counter += amount + os_log(.info, "\(state.counter)") + return .none + } + } + } +} + +// MARK: - DripperTests + +struct DripperTests { @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + let station = await StateHandler(initialState: TestDripper.State(), dripper: TestDripper()) + + let heartbeat = Heartbeat(clock: ContinuousClock(), duration: .seconds(2)) + Task { + for await _ in heartbeat { + await station.pour(.increase(by: 1)) + } + } + + let heartbeat2 = Heartbeat(clock: ContinuousClock(), duration: .seconds(2)) + Task { + for await _ in heartbeat2 { + await station.pour(.increase(by: 2)) + } + } + + try await Task.sleep(for: .seconds(20)) + + #expect(await station.counter == 1) } }