Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8a03e6a
wip
stephencelis Sep 13, 2024
3fe6fb0
wip
stephencelis Sep 13, 2024
9122a3a
wip
stephencelis Sep 13, 2024
f80cb3b
wip
stephencelis Sep 13, 2024
f67562a
wip
stephencelis Sep 13, 2024
2bd4f44
wip
stephencelis Sep 13, 2024
00ad75e
wip
stephencelis Sep 13, 2024
65c42e1
wip
stephencelis Sep 13, 2024
e79668e
wip
stephencelis Sep 13, 2024
0665585
wip
stephencelis Sep 13, 2024
a5e7734
wip
stephencelis Sep 13, 2024
0d630e2
wip
stephencelis Sep 13, 2024
edbf4e6
wip
stephencelis Sep 13, 2024
0ae2e54
Merge remote-tracking branch 'origin/main' into core
stephencelis Sep 13, 2024
26e41f9
wip
stephencelis Sep 13, 2024
f4da4c6
wip
stephencelis Sep 13, 2024
33cc53e
wip
stephencelis Sep 13, 2024
052841e
wip
stephencelis Sep 13, 2024
e34e628
wip
stephencelis Sep 13, 2024
712fa8d
wip
stephencelis Sep 14, 2024
d9eb1f6
wip
stephencelis Sep 14, 2024
603d725
Merge remote-tracking branch 'origin/main' into core
stephencelis Sep 19, 2024
62a61c3
get rid of originating action
mbrandonw Sep 27, 2024
c70426f
wip
stephencelis Oct 2, 2024
bef3861
Merge remote-tracking branch 'origin/main' into core
stephencelis Oct 8, 2024
e58cba5
Merge remote-tracking branch 'origin/main' into core
stephencelis Oct 16, 2024
c3338b5
Merge remote-tracking branch 'origin/main' into core
stephencelis Oct 21, 2024
413c24c
Merge branch 'main' into core
stephencelis Nov 12, 2024
bbe33b7
Merge remote-tracking branch 'origin/main' into core
stephencelis Feb 6, 2025
47d606b
Merge remote-tracking branch 'origin/main' into core
stephencelis Feb 27, 2025
58fddea
Merge remote-tracking branch 'origin/main' into core
stephencelis Feb 27, 2025
1b7f72c
wip
stephencelis Feb 28, 2025
5714403
Merge remote-tracking branch 'origin/main' into core
stephencelis Mar 13, 2025
95570eb
Deprecate non-writable scopes
stephencelis Mar 13, 2025
31b0755
Revert "Deprecate non-writable scopes"
stephencelis Mar 13, 2025
f05ac83
Merge branch 'main' into core
stephencelis Mar 26, 2025
725189d
wip
stephencelis Mar 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
wip
  • Loading branch information
stephencelis committed Sep 13, 2024
commit 8a03e6aa109af4366087bda8fa9fc9ed110485b0
251 changes: 251 additions & 0 deletions Sources/ComposableArchitecture/Core.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import Combine
import Foundation

@MainActor
protocol Core<State, Action>: AnyObject, Sendable {
associatedtype State
associatedtype Action
var state: State { get }
func send(_ action: Action) -> Task<Void, Never>?

var didSet: CurrentValueRelay<Void> { get }
}

final class InvalidCore<State, Action>: Core {
var state: State {
get { fatalError() }
set { fatalError() }
}
func send(_ action: Action) -> Task<Void, Never>? { nil }

let didSet = CurrentValueRelay<Void>(())
}

final class RootCore<Root: Reducer>: Core {
var state: Root.State {
didSet {
didSet.send(())
}
}
let reducer: Root
var bufferedActions: [Root.Action] = []
let didSet = CurrentValueRelay(())
var effectCancellables: [UUID: AnyCancellable] = [:]
private var isSending = false
init(
initialState: Root.State,
reducer: Root
) {
self.state = initialState
self.reducer = reducer
}
func send(_ action: Root.Action) -> Task<Void, Never>? {
_withoutPerceptionChecking {
send(action, originatingFrom: nil)
}
}
func send(_ action: Root.Action, originatingFrom originatingAction: Any?) -> Task<Void, Never>? {
self.bufferedActions.append(action)
guard !self.isSending else { return nil }

self.isSending = true
var currentState = self.state
let tasks = LockIsolated<[Task<Void, Never>]>([])
defer {
withExtendedLifetime(self.bufferedActions) {
self.bufferedActions.removeAll()
}
self.state = currentState
self.isSending = false
if !self.bufferedActions.isEmpty {
if let task = self.send(
self.bufferedActions.removeLast(),
originatingFrom: originatingAction
) {
tasks.withValue { $0.append(task) }
}
}
}

var index = self.bufferedActions.startIndex
while index < self.bufferedActions.endIndex {
defer { index += 1 }
let action = self.bufferedActions[index]
let effect = reducer.reduce(into: &currentState, action: action)

switch effect.operation {
case .none:
break
case let .publisher(publisher):
var didComplete = false
let boxedTask = Box<Task<Void, Never>?>(wrappedValue: nil)
let uuid = UUID()
let effectCancellable = withEscapedDependencies { continuation in
publisher
.receive(on: UIScheduler.shared)
.handleEvents(receiveCancel: { [weak self] in self?.effectCancellables[uuid] = nil })
.sink(
receiveCompletion: { [weak self] _ in
boxedTask.wrappedValue?.cancel()
didComplete = true
self?.effectCancellables[uuid] = nil
},
receiveValue: { [weak self] effectAction in
guard let self else { return }
if let task = continuation.yield({
self.send(effectAction, originatingFrom: action)
}) {
tasks.withValue { $0.append(task) }
}
}
)
}

if !didComplete {
let task = Task<Void, Never> { @MainActor in
for await _ in AsyncStream<Void>.never {}
effectCancellable.cancel()
}
boxedTask.wrappedValue = task
tasks.withValue { $0.append(task) }
self.effectCancellables[uuid] = effectCancellable
}
case let .run(priority, operation):
withEscapedDependencies { continuation in
let task = Task(priority: priority) { @MainActor in
let isCompleted = LockIsolated(false)
defer { isCompleted.setValue(true) }
await operation(
Send { effectAction in
if isCompleted.value {
reportIssue(
"""
An action was sent from a completed effect:

Action:
\(debugCaseOutput(effectAction))

Effect returned from:
\(debugCaseOutput(action))

Avoid sending actions using the 'send' argument from 'Effect.run' after \
the effect has completed. This can happen if you escape the 'send' \
argument in an unstructured context.

To fix this, make sure that your 'run' closure does not return until \
you're done calling 'send'.
"""
)
}
if let task = continuation.yield({
self.send(effectAction, originatingFrom: action)
}) {
tasks.withValue { $0.append(task) }
}
}
)
}
tasks.withValue { $0.append(task) }
}
}
}

guard !tasks.isEmpty else { return nil }
return Task { @MainActor in
await withTaskCancellationHandler {
var index = tasks.startIndex
while index < tasks.endIndex {
defer { index += 1 }
await tasks[index].value
}
} onCancel: {
var index = tasks.startIndex
while index < tasks.endIndex {
defer { index += 1 }
tasks[index].cancel()
}
}
}
}
private actor DefaultIsolation {}
}

class ScopedCore<Base: Core, State, Action>: Core {
var base: Base
let stateKeyPath: KeyPath<Base.State, State>
let actionKeyPath: CaseKeyPath<Base.Action, Action>
init(
base: Base,
stateKeyPath: KeyPath<Base.State, State>,
actionKeyPath: CaseKeyPath<Base.Action, Action>
) {
self.base = base
self.stateKeyPath = stateKeyPath
self.actionKeyPath = actionKeyPath
}
var state: State {
base.state[keyPath: stateKeyPath]
}
func send(_ action: Action) -> Task<Void, Never>? {
base.send(actionKeyPath(action))
}
var didSet: CurrentValueRelay<Void> {
base.didSet
}
}

class IfLetCore<Base: Core, State, Action>: Core {
var base: Base
var cachedState: State
let stateKeyPath: KeyPath<Base.State, State?>
let actionKeyPath: CaseKeyPath<Base.Action, Action>
init(
base: Base,
cachedState: State,
stateKeyPath: KeyPath<Base.State, State?>,
actionKeyPath: CaseKeyPath<Base.Action, Action>
) {
self.base = base
self.cachedState = cachedState
self.stateKeyPath = stateKeyPath
self.actionKeyPath = actionKeyPath
}
var state: State {
base.state[keyPath: stateKeyPath] ?? cachedState
}
func send(_ action: Action) -> Task<Void, Never>? {
#if DEBUG
if BindingLocal.isActive && base.state[keyPath: stateKeyPath] == nil {
return nil
}
#endif
return base.send(actionKeyPath(action))
}
var didSet: CurrentValueRelay<Void> {
base.didSet
}
}

class ClosureScopedCore<Base: Core, State, Action>: Core {
var base: Base
let toState: (Base.State) -> State
let fromAction: (Action) -> Base.Action
init(
base: Base,
toState: @escaping (Base.State) -> State,
fromAction: @escaping (Action) -> Base.Action
) {
self.base = base
self.toState = toState
self.fromAction = fromAction
}
var state: State {
toState(base.state)
}
func send(_ action: Action) -> Task<Void, Never>? {
base.send(fromAction(action))
}
var didSet: CurrentValueRelay<Void> {
base.didSet
}
}
6 changes: 3 additions & 3 deletions Sources/ComposableArchitecture/Effect.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Combine
@preconcurrency import Combine
import Foundation
import SwiftUI

public struct Effect<Action> {
public struct Effect<Action>: Sendable {
@usableFromInline
enum Operation {
enum Operation: Sendable {
case none
case publisher(AnyPublisher<Action, Never>)
case run(TaskPriority? = nil, @Sendable (_ send: Send<Action>) async -> Void)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Combine
import Foundation

final class CurrentValueRelay<Output>: Publisher {
final class CurrentValueRelay<Output>: Publisher, @unchecked Sendable {
typealias Failure = Never

private var currentValue: Output
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,30 @@ public struct _StoreCollection<ID: Hashable & Sendable, State, Action>: RandomAc
else {
return Store()
}
let id = self.data.ids[position]
var element = self.data[position]
return self.store.scope(
id: self.store.id(state: \.[id:id]!, action: \.[id:id]),
state: ToState {
element = $0[id: id] ?? element
return element
},
action: { .element(id: id, action: $0) },
isInvalid: { !$0.ids.contains(id) }
let elementID = self.data.ids[position]
let scopeID = ScopeID<IdentifiedArray<ID, State>, IdentifiedAction<ID, Action>>(
state: \.[id:elementID], action: \.[id:elementID]
)
guard let child = self.store.children[scopeID] as? Store<State, Action>
else {
@MainActor
func open(
_ core: some Core<IdentifiedArray<ID, State>, IdentifiedAction<ID, Action>>
) -> Store<State, Action> {
let child = Store<State, Action>(
core: IfLetCore(
base: core,
cachedState: self.data[position],
stateKeyPath: \.[id:elementID],
actionKeyPath: \.[id:elementID]
)
)
self.store.children[scopeID] = child
return child
}
return open(self.store.core)
}
return child
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,30 +187,50 @@ public struct _NavigationDestinationViewModifier<
content
.environment(\.navigationDestinationType, State.self)
.navigationDestination(for: StackState<State>.Component.self) { component in
var element = component.element
self
.destination(
self.store.scope(
id: self.store.id(
state:
\.[
id:component.id,fileID:_HashableStaticString(
rawValue: fileID),filePath:_HashableStaticString(
rawValue: filePath),line:line,column:column
],
action: \.[id:component.id]
),
state: ToState {
element = $0[id: component.id] ?? element
return element
},
action: { .element(id: component.id, action: $0) },
isInvalid: { !$0.ids.contains(component.id) }
)
)
navigationDestination(component: component)
.environment(\.navigationDestinationType, State.self)
}
}

private func navigationDestination(component: StackState<State>.Component) -> Destination {
let id = store.id(
state:
\.[
id:component.id,
fileID:_HashableStaticString(rawValue: fileID),
filePath:_HashableStaticString(rawValue: filePath),
line:line,
column:column
],
action: \.[id:component.id]
)
if let child = store.children[id] as? Store<State, Action> {
return destination(child)
} else {
@MainActor
func open(
_ core: some Core<StackState<State>, StackAction<State, Action>>
) -> Destination {
let child = Store<State, Action>(
core: IfLetCore(
base: core,
cachedState: component.element,
stateKeyPath: \.[
id:component.id,
fileID:_HashableStaticString(rawValue: fileID),
filePath:_HashableStaticString(rawValue: filePath),
line:line,
column:column
],
actionKeyPath: \.[id:component.id]
)
)
store.children[id] = child
return destination(child)
}
return open(store.core)
}
}
}

@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
Expand Down
Loading