Skip to content

Commit

Permalink
Main actor isolation added to the Store type
Browse files Browse the repository at this point in the history
  • Loading branch information
mecid committed Mar 15, 2024
1 parent 07bc349 commit 13ec799
Show file tree
Hide file tree
Showing 3 changed files with 45 additions and 43 deletions.
4 changes: 2 additions & 2 deletions Sources/Example/GithubApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ actor SearchMiddleware: Middleware {

typealias SearchStore = Store<SearchState, SearchAction>

struct SearchContainerView: View {
@MainActor struct SearchView: View {
@State private var store = SearchStore(
initialState: .init(),
reducer: SearchReducer(),
Expand Down Expand Up @@ -117,7 +117,7 @@ struct SearchContainerView: View {
var body: some Scene {
WindowGroup {
NavigationStack {
SearchContainerView()
SearchView()
}
}
}
Expand Down
74 changes: 38 additions & 36 deletions Sources/UnidirectionalFlow/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@
//
import Observation

/// Type that stores the state of the app or feature.
@Observable @dynamicMemberLookup public final class Store<State, Action> {
/// Type that stores the state of the app or module allowing feeding actions.
@Observable @dynamicMemberLookup @MainActor public final class Store<State, Action> {
private var state: State

private let reducer: any Reducer<State, Action>
private let middlewares: any Collection<any Middleware<State, Action>>
private let lock = NSRecursiveLock()

/// Creates an instance of `Store` with the folowing parameters.
public init(
Expand All @@ -27,28 +25,24 @@ import Observation

/// A subscript providing access to the state of the store.
public subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
lock.withLock { state[keyPath: keyPath] }
state[keyPath: keyPath]
}

/// Use this method to mutate the state of the store by feeding actions.
public func send(_ action: Action) async {
await apply(action)
apply(action)
await intercept(action)
}

@MainActor private func apply(_ action: Action) {
lock.withLock {
state = reducer.reduce(oldState: state, with: action)
}
private func apply(_ action: Action) {
state = reducer.reduce(oldState: state, with: action)
}

private func intercept(_ action: Action) async {
let capturedState = lock.withLock { state }

await withTaskGroup(of: Optional<Action>.self) { group in
middlewares.forEach { middleware in
for middleware in middlewares {
group.addTask {
await middleware.process(state: capturedState, with: action)
await middleware.process(state: self.state, with: action)
}
}

Expand All @@ -66,8 +60,8 @@ extension Store {
deriveState: @escaping (State) -> DerivedState,
deriveAction: @escaping (DerivedAction) -> Action
) -> Store<DerivedState, DerivedAction> {
let derived = Store<DerivedState, DerivedAction>(
initialState: lock.withLock { deriveState(state) },
let store = Store<DerivedState, DerivedAction>(
initialState: deriveState(state),
reducer: IdentityReducer(),
middlewares: [
ClosureMiddleware { _, action in
Expand All @@ -77,24 +71,34 @@ extension Store {
]
)

@Sendable func enableStateObservationTracking() {
withObservationTracking {
let newState = lock.withLock { deriveState(state) }
derived.lock.withLock {
if derived.state != newState {
derived.state = newState
}
}
} onChange: {
Task {
enableStateObservationTracking()
}
enableStateObservation(
for: store,
deriveState: deriveState,
deriveAction: deriveAction
)

return store
}

private func enableStateObservation<DerivedState: Equatable, DerivedAction: Equatable>(
for store: Store<DerivedState, DerivedAction>,
deriveState: @escaping (State) -> DerivedState,
deriveAction: @escaping (DerivedAction) -> Action
) {
withObservationTracking {
let newState = deriveState(state)
if store.state != newState {
store.state = newState
}
} onChange: {
Task {
await self.enableStateObservation(
for: store,
deriveState: deriveState,
deriveAction: deriveAction
)
}
}

enableStateObservationTracking()

return derived
}
}

Expand All @@ -107,13 +111,11 @@ extension Store {
embed: @escaping (Value) -> Action
) -> Binding<Value> {
.init(
get: { self.lock.withLock { extract(self.state) } },
get: { extract(self.state) },
set: { newValue in
let action = embed(newValue)

MainActor.assumeIsolated {
self.apply(action)
}
self.apply(action)

Task {
await self.intercept(action)
Expand Down
10 changes: 5 additions & 5 deletions Tests/UnidirectionalFlowTests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ final class StoreTests: XCTestCase {
}
}

func testSend() async {
@MainActor func testSend() async {
let store = Store<State, Action>(
initialState: .init(),
reducer: TestReducer(),
Expand All @@ -61,7 +61,7 @@ final class StoreTests: XCTestCase {
XCTAssertEqual(store.counter, 0)
}

func testMiddleware() async {
@MainActor func testMiddleware() async {
let store = Store<State, Action>(
initialState: .init(),
reducer: TestReducer(),
Expand All @@ -75,7 +75,7 @@ final class StoreTests: XCTestCase {
XCTAssertEqual(store.counter, 1)
}

func testMiddlewareCancellation() async {
@MainActor func testMiddlewareCancellation() async {
let store = Store<State, Action>(
initialState: .init(),
reducer: TestReducer(),
Expand All @@ -91,7 +91,7 @@ final class StoreTests: XCTestCase {
XCTAssertEqual(store.counter, 0)
}

func testDerivedStore() async throws {
@MainActor func testDerivedStore() async throws {
let store = Store<State, Action>(
initialState: .init(),
reducer: TestReducer(),
Expand Down Expand Up @@ -160,7 +160,7 @@ final class StoreTests: XCTestCase {
XCTAssertEqual(store.counter, 10)
}

func testThreadSafety() {
@MainActor func testThreadSafety() {
measure {
let store = Store<State, Action>(
initialState: .init(),
Expand Down

0 comments on commit 13ec799

Please sign in to comment.