Skip to content

Commit d4d1df8

Browse files
Store internals: Refactor RootStore/ToState to "Core" protocol composition (#3460)
* wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * get rid of originating action * wip * wip * Deprecate non-writable scopes * Revert "Deprecate non-writable scopes" This reverts commit 95570eb. * wip --------- Co-authored-by: Brandon Williams <mbrandonw@hey.com>
1 parent 7b2e742 commit d4d1df8

24 files changed

+816
-647
lines changed

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
- uses: actions/checkout@v4
2727
- name: Select Xcode ${{ matrix.xcode }}
2828
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
29+
- name: Update xcbeautify
30+
run: brew upgrade xcbeautify
2931
- name: List available devices
3032
run: xcrun simctl list devices available
3133
- name: Cache derived data
@@ -65,6 +67,8 @@ jobs:
6567
- uses: actions/checkout@v4
6668
- name: Select Xcode ${{ matrix.xcode }}
6769
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
70+
- name: Update xcbeautify
71+
run: brew upgrade xcbeautify
6872
- name: Install visionOS runtime
6973
if: matrix.platform == 'visionOS'
7074
run: |
@@ -100,6 +104,8 @@ jobs:
100104
- uses: actions/checkout@v4
101105
- name: Select Xcode 15.4
102106
run: sudo xcode-select -s /Applications/Xcode_15.4.app
107+
- name: Update xcbeautify
108+
run: brew upgrade xcbeautify
103109
- name: Build for library evolution
104110
run: make build-for-library-evolution
105111

@@ -118,6 +124,8 @@ jobs:
118124
deriveddata-examples-
119125
- name: Select Xcode 16
120126
run: sudo xcode-select -s /Applications/Xcode_16.2.app
127+
- name: Update xcbeautify
128+
run: brew upgrade xcbeautify
121129
- name: Set IgnoreFileSystemDeviceInodeChanges flag
122130
run: defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES
123131
- name: Update mtime for incremental builds
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import Combine
2+
import Foundation
3+
4+
@MainActor
5+
protocol Core<State, Action>: AnyObject, Sendable {
6+
associatedtype State
7+
associatedtype Action
8+
var state: State { get }
9+
func send(_ action: Action) -> Task<Void, Never>?
10+
11+
var canStoreCacheChildren: Bool { get }
12+
var didSet: CurrentValueRelay<Void> { get }
13+
var isInvalid: Bool { get }
14+
15+
var effectCancellables: [UUID: AnyCancellable] { get }
16+
}
17+
18+
final class InvalidCore<State, Action>: Core {
19+
var state: State {
20+
get { fatalError() }
21+
set { fatalError() }
22+
}
23+
func send(_ action: Action) -> Task<Void, Never>? { nil }
24+
25+
@inlinable
26+
@inline(__always)
27+
var canStoreCacheChildren: Bool { false }
28+
let didSet = CurrentValueRelay<Void>(())
29+
@inlinable
30+
@inline(__always)
31+
var isInvalid: Bool { true }
32+
@inlinable
33+
@inline(__always)
34+
var effectCancellables: [UUID: AnyCancellable] { [:] }
35+
}
36+
37+
final class RootCore<Root: Reducer>: Core {
38+
var state: Root.State {
39+
didSet {
40+
didSet.send(())
41+
}
42+
}
43+
let reducer: Root
44+
45+
@inlinable
46+
@inline(__always)
47+
var canStoreCacheChildren: Bool { true }
48+
let didSet = CurrentValueRelay(())
49+
@inlinable
50+
@inline(__always)
51+
var isInvalid: Bool { false }
52+
53+
private var bufferedActions: [Root.Action] = []
54+
var effectCancellables: [UUID: AnyCancellable] = [:]
55+
private var isSending = false
56+
init(
57+
initialState: Root.State,
58+
reducer: Root
59+
) {
60+
self.state = initialState
61+
self.reducer = reducer
62+
}
63+
func send(_ action: Root.Action) -> Task<Void, Never>? {
64+
_withoutPerceptionChecking {
65+
_send(action)
66+
}
67+
}
68+
func _send(_ action: Root.Action) -> Task<Void, Never>? {
69+
self.bufferedActions.append(action)
70+
guard !self.isSending else { return nil }
71+
72+
self.isSending = true
73+
var currentState = self.state
74+
let tasks = LockIsolated<[Task<Void, Never>]>([])
75+
defer {
76+
withExtendedLifetime(self.bufferedActions) {
77+
self.bufferedActions.removeAll()
78+
}
79+
self.state = currentState
80+
self.isSending = false
81+
if !self.bufferedActions.isEmpty {
82+
if let task = self.send(
83+
self.bufferedActions.removeLast()
84+
) {
85+
tasks.withValue { $0.append(task) }
86+
}
87+
}
88+
}
89+
90+
var index = self.bufferedActions.startIndex
91+
while index < self.bufferedActions.endIndex {
92+
defer { index += 1 }
93+
let action = self.bufferedActions[index]
94+
let effect = reducer.reduce(into: &currentState, action: action)
95+
let uuid = UUID()
96+
97+
switch effect.operation {
98+
case .none:
99+
break
100+
case let .publisher(publisher):
101+
var didComplete = false
102+
let boxedTask = Box<Task<Void, Never>?>(wrappedValue: nil)
103+
let effectCancellable = withEscapedDependencies { continuation in
104+
publisher
105+
.receive(on: UIScheduler.shared)
106+
.handleEvents(receiveCancel: { [weak self] in self?.effectCancellables[uuid] = nil })
107+
.sink(
108+
receiveCompletion: { [weak self] _ in
109+
boxedTask.wrappedValue?.cancel()
110+
didComplete = true
111+
self?.effectCancellables[uuid] = nil
112+
},
113+
receiveValue: { [weak self] effectAction in
114+
guard let self else { return }
115+
if let task = continuation.yield({
116+
self.send(effectAction)
117+
}) {
118+
tasks.withValue { $0.append(task) }
119+
}
120+
}
121+
)
122+
}
123+
124+
if !didComplete {
125+
let task = Task<Void, Never> { @MainActor in
126+
for await _ in AsyncStream<Void>.never {}
127+
effectCancellable.cancel()
128+
}
129+
boxedTask.wrappedValue = task
130+
tasks.withValue { $0.append(task) }
131+
self.effectCancellables[uuid] = AnyCancellable {
132+
task.cancel()
133+
}
134+
}
135+
case let .run(priority, operation):
136+
withEscapedDependencies { continuation in
137+
let task = Task(priority: priority) { @MainActor [weak self] in
138+
let isCompleted = LockIsolated(false)
139+
defer { isCompleted.setValue(true) }
140+
await operation(
141+
Send { effectAction in
142+
if isCompleted.value {
143+
reportIssue(
144+
"""
145+
An action was sent from a completed effect:
146+
147+
Action:
148+
\(debugCaseOutput(effectAction))
149+
150+
Effect returned from:
151+
\(debugCaseOutput(action))
152+
153+
Avoid sending actions using the 'send' argument from 'Effect.run' after \
154+
the effect has completed. This can happen if you escape the 'send' \
155+
argument in an unstructured context.
156+
157+
To fix this, make sure that your 'run' closure does not return until \
158+
you're done calling 'send'.
159+
"""
160+
)
161+
}
162+
if let task = continuation.yield({
163+
self?.send(effectAction)
164+
}) {
165+
tasks.withValue { $0.append(task) }
166+
}
167+
}
168+
)
169+
self?.effectCancellables[uuid] = nil
170+
}
171+
tasks.withValue { $0.append(task) }
172+
self.effectCancellables[uuid] = AnyCancellable {
173+
task.cancel()
174+
}
175+
}
176+
}
177+
}
178+
179+
guard !tasks.isEmpty else { return nil }
180+
return Task { @MainActor in
181+
await withTaskCancellationHandler {
182+
var index = tasks.startIndex
183+
while index < tasks.endIndex {
184+
defer { index += 1 }
185+
await tasks[index].value
186+
}
187+
} onCancel: {
188+
var index = tasks.startIndex
189+
while index < tasks.endIndex {
190+
defer { index += 1 }
191+
tasks[index].cancel()
192+
}
193+
}
194+
}
195+
}
196+
private actor DefaultIsolation {}
197+
}
198+
199+
final class ScopedCore<Base: Core, State, Action>: Core {
200+
let base: Base
201+
let stateKeyPath: KeyPath<Base.State, State>
202+
let actionKeyPath: CaseKeyPath<Base.Action, Action>
203+
init(
204+
base: Base,
205+
stateKeyPath: KeyPath<Base.State, State>,
206+
actionKeyPath: CaseKeyPath<Base.Action, Action>
207+
) {
208+
self.base = base
209+
self.stateKeyPath = stateKeyPath
210+
self.actionKeyPath = actionKeyPath
211+
}
212+
@inlinable
213+
@inline(__always)
214+
var state: State {
215+
base.state[keyPath: stateKeyPath]
216+
}
217+
@inlinable
218+
@inline(__always)
219+
func send(_ action: Action) -> Task<Void, Never>? {
220+
base.send(actionKeyPath(action))
221+
}
222+
@inlinable
223+
@inline(__always)
224+
var canStoreCacheChildren: Bool {
225+
base.canStoreCacheChildren
226+
}
227+
@inlinable
228+
@inline(__always)
229+
var didSet: CurrentValueRelay<Void> {
230+
base.didSet
231+
}
232+
@inlinable
233+
@inline(__always)
234+
var isInvalid: Bool {
235+
base.isInvalid
236+
}
237+
@inlinable
238+
@inline(__always)
239+
var effectCancellables: [UUID: AnyCancellable] {
240+
base.effectCancellables
241+
}
242+
}
243+
244+
final class IfLetCore<Base: Core, State, Action>: Core {
245+
let base: Base
246+
var cachedState: State
247+
let stateKeyPath: KeyPath<Base.State, State?>
248+
let actionKeyPath: CaseKeyPath<Base.Action, Action>
249+
var parentCancellable: AnyCancellable?
250+
init(
251+
base: Base,
252+
cachedState: State,
253+
stateKeyPath: KeyPath<Base.State, State?>,
254+
actionKeyPath: CaseKeyPath<Base.Action, Action>
255+
) {
256+
self.base = base
257+
self.cachedState = cachedState
258+
self.stateKeyPath = stateKeyPath
259+
self.actionKeyPath = actionKeyPath
260+
}
261+
@inlinable
262+
@inline(__always)
263+
var state: State {
264+
let state = base.state[keyPath: stateKeyPath] ?? cachedState
265+
cachedState = state
266+
return state
267+
}
268+
@inlinable
269+
@inline(__always)
270+
func send(_ action: Action) -> Task<Void, Never>? {
271+
#if DEBUG
272+
if BindingLocal.isActive && isInvalid {
273+
return nil
274+
}
275+
#endif
276+
return base.send(actionKeyPath(action))
277+
}
278+
@inlinable
279+
@inline(__always)
280+
var canStoreCacheChildren: Bool {
281+
base.canStoreCacheChildren
282+
}
283+
@inlinable
284+
@inline(__always)
285+
var didSet: CurrentValueRelay<Void> {
286+
base.didSet
287+
}
288+
@inlinable
289+
@inline(__always)
290+
var isInvalid: Bool {
291+
base.state[keyPath: stateKeyPath] == nil || base.isInvalid
292+
}
293+
@inlinable
294+
@inline(__always)
295+
var effectCancellables: [UUID: AnyCancellable] {
296+
base.effectCancellables
297+
}
298+
}
299+
300+
final class ClosureScopedCore<Base: Core, State, Action>: Core {
301+
let base: Base
302+
let toState: (Base.State) -> State
303+
let fromAction: (Action) -> Base.Action
304+
init(
305+
base: Base,
306+
toState: @escaping (Base.State) -> State,
307+
fromAction: @escaping (Action) -> Base.Action
308+
) {
309+
self.base = base
310+
self.toState = toState
311+
self.fromAction = fromAction
312+
}
313+
@inlinable
314+
@inline(__always)
315+
var state: State {
316+
toState(base.state)
317+
}
318+
@inlinable
319+
@inline(__always)
320+
func send(_ action: Action) -> Task<Void, Never>? {
321+
base.send(fromAction(action))
322+
}
323+
@inlinable
324+
@inline(__always)
325+
var canStoreCacheChildren: Bool {
326+
false
327+
}
328+
@inlinable
329+
@inline(__always)
330+
var didSet: CurrentValueRelay<Void> {
331+
base.didSet
332+
}
333+
@inlinable
334+
@inline(__always)
335+
var isInvalid: Bool {
336+
base.isInvalid
337+
}
338+
@inlinable
339+
@inline(__always)
340+
var effectCancellables: [UUID: AnyCancellable] {
341+
base.effectCancellables
342+
}
343+
}

Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Combine
22
import Foundation
33

4-
final class CurrentValueRelay<Output>: Publisher {
4+
final class CurrentValueRelay<Output>: Publisher, @unchecked Sendable {
55
typealias Failure = Never
66

77
private var currentValue: Output

0 commit comments

Comments
 (0)