Skip to content

Commit 70da19a

Browse files
authored
Merge pull request #19 from GoodRequest/feature/typeerasure
Type erasure + state mocks + concurrency improvements
2 parents 71bd529 + 7e6ca06 commit 70da19a

File tree

17 files changed

+343
-236
lines changed

17 files changed

+343
-236
lines changed

Package.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ let package = Package(
4646
name: "GoodReactorTests",
4747
dependencies: ["GoodReactor"],
4848
swiftSettings: [.swiftLanguageMode(.v6)]
49-
),
50-
.testTarget(
51-
name: "GoodCoordinatorTests",
52-
dependencies: ["LegacyReactor"],
53-
swiftSettings: [.swiftLanguageMode(.v6)]
5449
)
5550
]
5651
)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// AnyReactor.swift
3+
// GoodReactor
4+
//
5+
// Created by Filip Šašala on 24/09/2025.
6+
//
7+
8+
import Observation
9+
10+
@available(iOS 17.0, macOS 14.0, *)
11+
@MainActor @Observable @dynamicMemberLookup public final class AnyReactor<WrappedAction: Sendable, WrappedMutation: Sendable, WrappedDestination: Sendable, WrappedState>: Reactor {
12+
13+
// MARK: - Type aliases
14+
15+
public typealias Action = WrappedAction
16+
public typealias Mutation = WrappedMutation
17+
public typealias Destination = WrappedDestination
18+
public typealias State = WrappedState
19+
20+
// MARK: - Forwarders
21+
22+
private let _getState: () -> State
23+
private let _setState: (State) -> ()
24+
private let _initialStateBuilder: () -> State
25+
private let _sendAction: (Action) -> ()
26+
private let _sendActionAsync: (Action) async -> ()
27+
private let _getDestination: () -> Destination?
28+
private let _sendDestination: (Destination?) -> ()
29+
private let _reduce: (inout State, Event<WrappedAction, WrappedMutation, WrappedDestination>) -> ()
30+
private let _transform: () -> ()
31+
32+
// MARK: - Initialization
33+
34+
public init<R: Reactor>(_ base: R) where
35+
R.Action == Action,
36+
R.Mutation == Mutation,
37+
R.Destination == Destination,
38+
R.State == State
39+
{
40+
self._getState = { base.state }
41+
self._setState = { base.state = $0 }
42+
self._initialStateBuilder = { base.makeInitialState() }
43+
self._sendAction = { base.send(action: $0) }
44+
self._sendActionAsync = { await base.send(action: $0) }
45+
self._getDestination = { base.destination }
46+
self._sendDestination = { base.send(destination: $0) }
47+
self._reduce = { base.reduce(state: &$0, event: $1) }
48+
self._transform = { base.transform() }
49+
}
50+
51+
}
52+
53+
// MARK: - Dynamic member lookup
54+
55+
@available(iOS 17.0, macOS 14.0, *)
56+
public extension AnyReactor {
57+
58+
subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
59+
_getState()[keyPath: keyPath]
60+
}
61+
62+
subscript<T>(dynamicMember keyPath: ReferenceWritableKeyPath<State, T>) -> T {
63+
_getState()[keyPath: keyPath]
64+
}
65+
66+
}
67+
68+
// MARK: - Reactor
69+
70+
@available(iOS 17.0, macOS 14.0, *)
71+
public extension AnyReactor {
72+
73+
func makeInitialState() -> State {
74+
_initialStateBuilder()
75+
}
76+
77+
func transform() {
78+
_transform()
79+
}
80+
81+
func reduce(state: inout State, event: Event<WrappedAction, WrappedMutation, WrappedDestination>) {
82+
_reduce(&state, event)
83+
}
84+
85+
var destination: Destination? {
86+
get {
87+
_getDestination()
88+
}
89+
set {
90+
_sendDestination(newValue)
91+
}
92+
}
93+
94+
var initialState: State {
95+
_initialStateBuilder()
96+
}
97+
98+
}
99+
100+
// MARK: - Mirror
101+
102+
@available(iOS 17.0, macOS 14.0, *)
103+
public extension AnyReactor {
104+
105+
func send(action: Action) {
106+
_sendAction(action)
107+
}
108+
109+
func send(action: Action) async {
110+
await _sendActionAsync(action)
111+
}
112+
113+
func send(destination: Destination?) {
114+
_sendDestination(destination)
115+
}
116+
117+
}
118+
119+
// MARK: - Eraser
120+
121+
@available(iOS 17.0, macOS 14.0, *)
122+
public extension Reactor {
123+
124+
func eraseToAnyReactor() -> AnyReactor<Action, Mutation, Destination, State> {
125+
AnyReactor(self)
126+
}
127+
128+
}

Sources/GoodReactor/Event.swift

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
// Created by Filip Šašala on 27/08/2024.
66
//
77

8+
import Foundation
9+
10+
// MARK: - Event
11+
812
public final class Event<A, M, D>: Sendable where A: Sendable, M: Sendable, D: Sendable {
913

1014
public enum Kind: Sendable {
@@ -27,4 +31,39 @@ public final class Event<A, M, D>: Sendable where A: Sendable, M: Sendable, D: S
2731

2832
}
2933

30-
internal final class EventIdentifier: Identifier, Sendable {}
34+
// MARK: - Event task counter
35+
36+
struct EventTaskCounter: Sendable {
37+
38+
var events: [EventIdentifier: Int] = [:]
39+
40+
/// Checks if any tasks are running for provided event
41+
/// - Parameter identifier: Event to check
42+
/// - Returns: `true` if any tasks are running, `false` otherwise
43+
func tasksActive(forEvent identifier: EventIdentifier) -> Bool {
44+
return events.keys.contains(identifier)
45+
}
46+
47+
/// Increments task counter for provided event
48+
/// - Parameter identifier: Event starting a new task
49+
mutating func newTask(eventId identifier: EventIdentifier) {
50+
events[identifier] = events[identifier, default: 0] + 1
51+
}
52+
53+
/// Decrements task counter for provided event
54+
/// - Parameter identifier: Event stopping the task
55+
/// - Returns: Number of remaining tasks
56+
mutating func stopTask(eventId identifier: EventIdentifier) -> Int {
57+
let taskCount = events[identifier, default: 0]
58+
let newTaskCount = taskCount - 1
59+
60+
events[identifier] = newTaskCount
61+
62+
if newTaskCount < 1 {
63+
events.removeValue(forKey: identifier)
64+
}
65+
66+
return newTaskCount
67+
}
68+
69+
}

Sources/GoodReactor/Identifier.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ public final class CodeLocationIdentifier: Identifier {
3232
}
3333

3434
}
35+
36+
// MARK: - Event identifier
37+
38+
internal final class EventIdentifier: Identifier, Sendable {}

Sources/GoodReactor/MapTables.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,31 @@
77

88
internal enum MapTables {
99

10-
typealias AnyReactor = AnyObject
10+
internal typealias ReactorType = AnyObject
1111

1212
// State of a reactor
13-
static let state = WeakMapTable<AnyReactor, Any>()
13+
static let state = WeakMapTable<ReactorType, Any>()
1414

1515
// Initial state of a reactor
16-
static let initialState = WeakMapTable<AnyReactor, Any>()
16+
static let initialState = WeakMapTable<ReactorType, Any>()
1717

1818
// Number of currently running asynchronous tasks for an event of a reactor
19-
static let runningEvents = WeakMapTable<AnyReactor, Set<EventIdentifier>>()
19+
static let runningEvents = WeakMapTable<ReactorType, EventTaskCounter>()
2020

2121
// Subscriptions of a reactor (new way)
22-
static let subscriptions = WeakMapTable<AnyReactor, Set<AnyTask>>()
22+
static let subscriptions = WeakMapTable<ReactorType, Set<AnyTask>>()
2323

2424
// Debouncers of a reactor
25-
static let debouncers = WeakMapTable<AnyReactor, Dictionary<DebouncerIdentifier, Any>>()
25+
static let debouncers = WeakMapTable<ReactorType, Dictionary<DebouncerIdentifier, Any>>()
2626

2727
// State stream cancellable (Combine)
28-
static let stateStreams = WeakMapTable<AnyReactor, Any>()
28+
static let stateStreams = WeakMapTable<ReactorType, Any>()
2929

3030
// Event stream cancellable (Combine)
31-
static let eventStreams = WeakMapTable<AnyReactor, Any>()
31+
static let eventStreams = WeakMapTable<ReactorType, Any>()
3232

3333
// Logger of a reactor
34-
static let loggers = WeakMapTable<AnyReactor, ReactorLogger>()
34+
static let loggers = WeakMapTable<ReactorType, ReactorLogger>()
3535

3636
// Semaphore lock of an event (does not matter which reactor it's running on)
3737
static let eventLocks = WeakMapTable<EventIdentifier, AsyncSemaphore>()

Sources/GoodReactor/Reactor.swift

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import SwiftUI
1919

2020
// MARK: - Reactor protocol
2121

22-
@MainActor @dynamicMemberLookup public protocol Reactor: AnyObject, Identifiable {
22+
@MainActor @dynamicMemberLookup public protocol Reactor: AnyObject, Identifiable, Hashable {
2323

2424
/// Internal events
2525
///
@@ -229,30 +229,6 @@ public extension Reactor {
229229

230230
}
231231

232-
// MARK: - Directly writable types
233-
234-
public extension Reactor {
235-
236-
/// Opt-in writable accessor for value type paths (structs)
237-
subscript<T: __ReactorDirectWriteable>(dynamicMember keyPath: WritableKeyPath<State, T>) -> T {
238-
get {
239-
state[keyPath: keyPath]
240-
}
241-
set {
242-
var s = state
243-
s[keyPath: keyPath] = newValue
244-
state = s
245-
}
246-
}
247-
248-
/// Opt-in writable accessor for reference type paths (class members)
249-
subscript<T: __ReactorDirectWriteable>(dynamicMember keyPath: ReferenceWritableKeyPath<State, T>) -> T {
250-
get { state[keyPath: keyPath] }
251-
set { state[keyPath: keyPath] = newValue }
252-
}
253-
254-
}
255-
256232
// MARK: - Default implementation
257233

258234
public extension Reactor {
@@ -379,14 +355,18 @@ public extension Reactor {
379355
/// and supply mutations using a ``Publisher`` and ``subscribe(to:map:)``
380356
@available(*, noasync) func run(_ event: Event, @_implicitSelfCapture eventHandler: @autoclosure @escaping () -> @Sendable () async -> Mutation?) {
381357
let semaphore = MapTables.eventLocks[key: event.id, default: AsyncSemaphore(value: 0)]
382-
MapTables.runningEvents[key: self, default: []].insert(event.id)
358+
MapTables.runningEvents[key: self, default: EventTaskCounter()].newTask(eventId: event.id)
383359

384360
Task { @MainActor [weak self] in
385361
guard let self else { return }
386362

387363
defer {
388-
MapTables.runningEvents[key: self, default: []].remove(event.id)
389-
semaphore.signal()
364+
let remainingTasks = MapTables.runningEvents[key: self, default: EventTaskCounter()]
365+
.stopTask(eventId: event.id)
366+
367+
if remainingTasks < 1 {
368+
semaphore.signal()
369+
}
390370
}
391371

392372
let mutation = await Task.detached(operation: eventHandler()).value
@@ -531,7 +511,7 @@ private extension Reactor {
531511

532512
_reduce(state: &state, event: event)
533513

534-
if MapTables.runningEvents[key: self, default: []].contains(eventId) {
514+
if MapTables.runningEvents[key: self, default: EventTaskCounter()].tasksActive(forEvent: eventId) {
535515
try? await semaphore.waitUnlessCancelled()
536516
}
537517
}

Sources/GoodReactor/ReactorDirectWritable.swift

Lines changed: 0 additions & 10 deletions
This file was deleted.

Sources/GoodReactor/Stub.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// Stub.swift
3+
// GoodReactor
4+
//
5+
// Created by Filip Šašala on 24/09/2025.
6+
//
7+
8+
import Observation
9+
10+
/// Stub is a ``Reactor`` implementation adjusted for use in Xcode previews
11+
/// to mock different states of the UI. Stub resolves the state at initialization and
12+
/// supports small changes to state in its `reducer`.
13+
@available(iOS 17.0, macOS 14.0, *)
14+
@Observable public final class Stub<R: Reactor>: Reactor {
15+
16+
public typealias Event = R.Event
17+
public typealias Action = R.Action
18+
public typealias Mutation = R.Mutation
19+
public typealias Destination = R.Destination
20+
public typealias State = R.State
21+
22+
public var destination: R.Destination?
23+
public var destinations: R.Destination.Type?
24+
25+
private let supplier: () -> (R.State)
26+
private let reducer: ((inout R.State, Event) -> ())?
27+
28+
public func makeInitialState() -> R.State {
29+
supplier()
30+
}
31+
32+
public func reduce(state: inout R.State, event: Event) {
33+
reducer?(&state, event)
34+
}
35+
36+
/// Initializes a Stub ``Reactor`` with state and a simple reducer.
37+
/// - Parameters:
38+
/// - supplier: Closure supplying initial mocked state
39+
/// - reducer: Closure reducing small changes to mocked state
40+
public init(
41+
supplier: @escaping (() -> (R.State)),
42+
reducer: ((inout R.State, Event) -> ())? = nil
43+
) {
44+
self.supplier = supplier
45+
self.reducer = reducer
46+
}
47+
48+
}
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)