Skip to content

Commit 7ba542a

Browse files
committed
First commit
0 parents  commit 7ba542a

File tree

6 files changed

+487
-0
lines changed

6 files changed

+487
-0
lines changed

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>IDEDidComputeMac32BitWarning</key>
6+
<true/>
7+
</dict>
8+
</plist>

Package.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// swift-tools-version: 5.9
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "Arboreal",
8+
platforms: [
9+
.iOS(.v17),
10+
.macOS(.v14),
11+
.tvOS(.v17),
12+
.watchOS(.v10)
13+
],
14+
products: [
15+
// Products define the executables and libraries a package produces, making them visible to other packages.
16+
.library(
17+
name: "Arboreal",
18+
targets: ["Arboreal"]),
19+
],
20+
targets: [
21+
// Targets are the basic building blocks of a package, defining a module or a test suite.
22+
// Targets can depend on other targets in this package and products from dependencies.
23+
.target(
24+
name: "Arboreal"),
25+
.testTarget(
26+
name: "ArborealTests",
27+
dependencies: ["Arboreal"]),
28+
]
29+
)

Sources/Arboreal/Arboreal.swift

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
//
2+
// Arboreal.swift
3+
// ObservablePlayground
4+
//
5+
// Created by Gordon Brander on 11/7/23.
6+
//
7+
8+
import SwiftUI
9+
import Observation
10+
import os
11+
12+
/// State is described with models.
13+
/// A model is any type that knows how to update itself in response to actions.
14+
/// Models can be value or reference types. It's typical to create
15+
/// `@Observable` models.
16+
public protocol ModelProtocol {
17+
associatedtype Action
18+
associatedtype Environment
19+
20+
/// Update model in response to action, returning any side-effects (Fx).
21+
/// Update also receives an environment, which contains services it can
22+
/// use to produce side-effects.
23+
mutating func update(
24+
action: Action,
25+
environment: Environment
26+
) -> Fx<Action>
27+
}
28+
29+
/// A mailbox is any type that implements a send method which can receive
30+
/// actions. Stores are mailboxes.
31+
public protocol MailboxProtocol {
32+
associatedtype Action
33+
34+
func send(_ action: Action)
35+
}
36+
37+
/// Stores hold state and can receive actions via `send`.
38+
public protocol StoreProtocol: MailboxProtocol {
39+
associatedtype Model: ModelProtocol where Model.Action == Action
40+
41+
/// State should be get-only for stores.
42+
var state: Model { get }
43+
}
44+
45+
/// Fx represents a collection of side-effects... things like http requests
46+
/// or database calls that reference or mutate some outside resource.
47+
///
48+
/// Effects are modeled as async closures, which return an action representing
49+
/// the result of the effect, for example, an HTTP response. Actions are
50+
/// expected to model both success and failure cases.
51+
///
52+
/// Usage:
53+
///
54+
/// Fx {
55+
/// let rows = environment.database.getPosts()
56+
/// return rows.first
57+
/// }
58+
public struct Fx<Action> {
59+
/// No effects. Return this when your update function produces
60+
/// no side-effects.
61+
///
62+
/// Usage:
63+
///
64+
/// func update(
65+
/// action: Action,
66+
/// environment: Environment
67+
/// ) -> Fx<Action> {
68+
/// return Fx.none
69+
/// }
70+
public static var none: Self {
71+
Self()
72+
}
73+
74+
/// An effect is an async thunk (zero-arg closure) that returns an Action.
75+
public typealias Effect = () async -> Action
76+
77+
/// The batch of side-effects represented by this fx instance.
78+
public var effects: [Effect] = []
79+
80+
/// Create an `Fx` with a single effect.
81+
public init(_ effect: @escaping Effect) {
82+
self.effects = [effect]
83+
}
84+
85+
/// Create an `Fx` with an array of effects.
86+
public init(_ effects: [Effect] = []) {
87+
self.effects = effects
88+
}
89+
90+
/// Merge two fx instances together.
91+
/// - Returns a new Fx containing the combined effects.
92+
public func merge(_ otherFx: Self) -> Self {
93+
var merged = self.effects
94+
merged.append(contentsOf: otherFx.effects)
95+
return Fx(merged)
96+
}
97+
98+
/// Map effects, transforming their actions with `tag`.
99+
/// Used to map child component updates to parent context.
100+
///
101+
/// Usage:
102+
///
103+
/// child
104+
/// .update(action: action, environment: environment)
105+
/// .map(tagChild)
106+
///
107+
/// - Returns a new Fx containing the tagged effects.
108+
public func map<TaggedAction>(
109+
_ tag: @escaping (Action) -> TaggedAction
110+
) -> Fx<TaggedAction> {
111+
Fx<TaggedAction>(
112+
effects.map({ effect in
113+
{ await tag(effect()) }
114+
})
115+
)
116+
}
117+
}
118+
119+
/// EffectRunner is an actor that runs all effects for a given store.
120+
/// Effects are isolated to this actor, keeping them off the main thread, and
121+
/// local to this effect runner.
122+
actor EffectRunner<Mailbox: MailboxProtocol & AnyObject> {
123+
/// Mailbox to notify when effect completes.
124+
/// We keep a weak reference to the mailbox, since it is expected
125+
/// to hold a reference to this EffectRunner.
126+
private weak var mailbox: Mailbox?
127+
128+
/// Create a new effect runner.
129+
/// - Parameters:
130+
/// - mailbox: the mailbox to send actions tos
131+
public init(_ mailbox: Mailbox) {
132+
self.mailbox = mailbox
133+
}
134+
135+
/// Run a batch of effects in parallel.
136+
/// Actions are sent to mailbox in whatever order the tasks complete.
137+
public nonisolated func run(
138+
_ fx: Fx<Mailbox.Action>
139+
) {
140+
for effect in fx.effects {
141+
Task {
142+
let action = await effect()
143+
await self.mailbox?.send(action)
144+
}
145+
}
146+
}
147+
}
148+
149+
/// Store is a source of truth for application state.
150+
///
151+
/// Store hold a get-only `state` which conforms to `ModelProtocol` and knows
152+
/// how to update itself and generate side-effects. All updates and effects
153+
/// to this state happen through actions sent to `store.send`.
154+
///
155+
/// Store is `@Observable`, and can hold a state that is either a value-type
156+
/// model, or a reference-type models that are also `@Observable`.
157+
///
158+
/// When using a hierarchy of observable `ModelProtocols` with Store,
159+
/// it is strongly recommended that you mark all properties of your
160+
/// models with `private(set)`, so that all updates are forced to go through
161+
/// `Model.update(action:environment:)`. This ensures there is only one code
162+
/// path that can modify state, making code more easily testable and reliable.
163+
@Observable public final class Store<Model: ModelProtocol>: StoreProtocol {
164+
/// Logger for store. You can customize this in the initializer.
165+
var logger: Logger
166+
/// Runs all effects returned by model update functions.
167+
@ObservationIgnored private lazy var runner = EffectRunner(self)
168+
/// An environment for the model update function
169+
@ObservationIgnored var environment: Model.Environment
170+
171+
/// A read-only view of the current state.
172+
/// Nested models and other reference types should also mark their
173+
/// properties read-only. All state updates should go through
174+
/// `Model.update()`.
175+
public private(set) var state: Model
176+
177+
/// Create a Store
178+
/// - Parameters:
179+
/// - state: the initial state for the store. Must conform to
180+
/// `ModelProtocol`.
181+
/// - `environment`: an environment with services that can be used by the
182+
/// model to create side-effects.
183+
public init(
184+
state: Model,
185+
environment: Model.Environment,
186+
logger: Logger = Logger(
187+
subsystem: "ObservableStore",
188+
category: "Store"
189+
)
190+
) {
191+
self.state = state
192+
self.environment = environment
193+
self.logger = logger
194+
}
195+
196+
/// Send an action to store, updating state, and running effects.
197+
/// Calls the update method of the underlying model to update state and
198+
/// generate effects. Effects are run and the resulting actions are sent
199+
/// back into store, in whatever order the effects complete.
200+
public func send(_ action: Model.Action) -> Void {
201+
let actionDescription = String(describing: action)
202+
logger.debug("Action: \(actionDescription)")
203+
let fx = state.update(
204+
action: action,
205+
environment: environment
206+
)
207+
runner.run(fx)
208+
}
209+
}
210+
211+
/// Create a ViewStore, a scoped view over a store.
212+
/// ViewStore is conceptually like a SwiftUI Binding. However, instead of
213+
/// offering get/set for some source-of-truth, it offers a StoreProtocol.
214+
///
215+
/// Using ViewStore, you can create self-contained views that work with their
216+
/// own domain
217+
public struct ViewStore<Model: ModelProtocol>: StoreProtocol {
218+
/// `_get` reads some source of truth dynamically, using a closure.
219+
///
220+
/// NOTE: We've found this to be important for some corner cases in
221+
/// SwiftUI components, where capturing the state by value may produce
222+
/// unexpected issues. Examples are input fields and NavigationStack,
223+
/// which both expect a Binding to a state (which dynamically reads
224+
/// the value using a closure). Using the same approach as Binding
225+
/// offers the most reliable results.
226+
private var _get: () -> Model
227+
private var _send: (Model.Action) -> Void
228+
229+
/// Initialize a ViewStore from a `get` closure and a `send` closure.
230+
/// These closures read from a parent store to provide a type-erased
231+
/// view over the store that only exposes domain-specific
232+
/// model and actions.
233+
public init(
234+
get: @escaping () -> Model,
235+
send: @escaping (Model.Action) -> Void
236+
) {
237+
self._get = get
238+
self._send = send
239+
}
240+
241+
/// Get the current state from the underlying model.
242+
public var state: Model {
243+
self._get()
244+
}
245+
246+
/// Send an action to the underlying store through ViewStore.
247+
public func send(_ action: Model.Action) {
248+
self._send(action)
249+
}
250+
}
251+
252+
extension StoreProtocol {
253+
/// Create a viewStore from a StoreProtocol
254+
public func viewStore<ChildModel: ModelProtocol>(
255+
get: @escaping (Model) -> ChildModel,
256+
tag: @escaping (ChildModel.Action) -> Action
257+
) -> ViewStore<ChildModel> {
258+
ViewStore(
259+
get: { get(self.state) },
260+
send: { action in self.send(tag(action)) }
261+
)
262+
}
263+
}
264+
265+
extension Binding {
266+
/// Create a `Binding` from a `StoreProtocol`.
267+
/// - Parameters:
268+
/// - `store` is a reference to the store
269+
/// - `get` reads the value from the state.
270+
/// - `tag` tags the value, turning it into an action for `send`
271+
/// - Returns a binding suitable for use in a vanilla SwiftUI view.
272+
public init<Store: StoreProtocol>(
273+
store: Store,
274+
get: @escaping (Store.Model) -> Value,
275+
tag: @escaping (Value) -> Store.Model.Action
276+
) {
277+
self.init(
278+
get: { get(store.state) },
279+
set: { value in
280+
store.send(tag(value))
281+
}
282+
)
283+
}
284+
}
285+
286+
extension StoreProtocol {
287+
/// Initialize a `Binding` from a `StoreProtocol`.
288+
/// - Parameters:
289+
/// - `get` reads the value from the state.
290+
/// - `tag` tags the value, turning it into an action for `send`
291+
/// - Returns a binding suitable for use in a vanilla SwiftUI view.
292+
public func binding<Value>(
293+
get: @escaping (Model) -> Value,
294+
tag: @escaping (Value) -> Action
295+
) -> Binding<Value> {
296+
Binding(store: self, get: get, tag: tag)
297+
}
298+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import XCTest
2+
import Observation
3+
@testable import Arboreal
4+
5+
final class ArborealTests: XCTestCase {
6+
enum Action: Hashable {
7+
case setText(String)
8+
}
9+
10+
@Observable
11+
class Model: ModelProtocol {
12+
private(set) var text = ""
13+
private(set) var edits: Int = 0
14+
15+
func update(
16+
action: Action,
17+
environment: Void
18+
) -> Fx<Action> {
19+
switch action {
20+
case .setText(let text):
21+
self.text = text
22+
self.edits = self.edits + 1
23+
return Fx.none
24+
}
25+
}
26+
}
27+
28+
/// Test creating binding for an address
29+
func testSend() async throws {
30+
let store = Store(
31+
state: Model(),
32+
environment: ()
33+
)
34+
35+
await store.send(.setText("Foo"))
36+
37+
XCTAssertEqual(
38+
store.state.text,
39+
"Foo"
40+
)
41+
XCTAssertEqual(
42+
store.state.edits,
43+
1
44+
)
45+
}
46+
}

0 commit comments

Comments
 (0)