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)
}
}