Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
- uses: actions/checkout@v4
- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
- name: Update xcbeautify
run: brew upgrade xcbeautify
- name: List available devices
run: xcrun simctl list devices available
- name: Cache derived data
Expand Down Expand Up @@ -65,6 +67,8 @@ jobs:
- uses: actions/checkout@v4
- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
- name: Update xcbeautify
run: brew upgrade xcbeautify
- name: Install visionOS runtime
if: matrix.platform == 'visionOS'
run: |
Expand Down Expand Up @@ -100,6 +104,8 @@ jobs:
- uses: actions/checkout@v4
- name: Select Xcode 15.4
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Update xcbeautify
run: brew upgrade xcbeautify
- name: Build for library evolution
run: make build-for-library-evolution

Expand All @@ -118,6 +124,8 @@ jobs:
deriveddata-examples-
- name: Select Xcode 16
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Update xcbeautify
run: brew upgrade xcbeautify
- name: Set IgnoreFileSystemDeviceInodeChanges flag
run: defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES
- name: Update mtime for incremental builds
Expand Down
343 changes: 343 additions & 0 deletions Sources/ComposableArchitecture/Core.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
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 canStoreCacheChildren: Bool { get }
var didSet: CurrentValueRelay<Void> { get }
var isInvalid: Bool { get }

var effectCancellables: [UUID: AnyCancellable] { get }
}

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

@inlinable
@inline(__always)
var canStoreCacheChildren: Bool { false }
let didSet = CurrentValueRelay<Void>(())
@inlinable
@inline(__always)
var isInvalid: Bool { true }
@inlinable
@inline(__always)
var effectCancellables: [UUID: AnyCancellable] { [:] }
}

final class RootCore<Root: Reducer>: Core {
var state: Root.State {
didSet {
didSet.send(())
}
}
let reducer: Root

@inlinable
@inline(__always)
var canStoreCacheChildren: Bool { true }
let didSet = CurrentValueRelay(())
@inlinable
@inline(__always)
var isInvalid: Bool { false }

private var bufferedActions: [Root.Action] = []
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)
}
}
func _send(_ action: Root.Action) -> 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()
) {
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)
let uuid = UUID()

switch effect.operation {
case .none:
break
case let .publisher(publisher):
var didComplete = false
let boxedTask = Box<Task<Void, Never>?>(wrappedValue: nil)
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)
}) {
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] = AnyCancellable {
task.cancel()
}
}
case let .run(priority, operation):
withEscapedDependencies { continuation in
let task = Task(priority: priority) { @MainActor [weak self] 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)
}) {
tasks.withValue { $0.append(task) }
}
}
)
self?.effectCancellables[uuid] = nil
}
tasks.withValue { $0.append(task) }
self.effectCancellables[uuid] = AnyCancellable {
task.cancel()
}
}
}
}

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 {}
}

final class ScopedCore<Base: Core, State, Action>: Core {
let 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
}
@inlinable
@inline(__always)
var state: State {
base.state[keyPath: stateKeyPath]
}
@inlinable
@inline(__always)
func send(_ action: Action) -> Task<Void, Never>? {
base.send(actionKeyPath(action))
}
@inlinable
@inline(__always)
var canStoreCacheChildren: Bool {
base.canStoreCacheChildren
}
@inlinable
@inline(__always)
var didSet: CurrentValueRelay<Void> {
base.didSet
}
@inlinable
@inline(__always)
var isInvalid: Bool {
base.isInvalid
}
@inlinable
@inline(__always)
var effectCancellables: [UUID: AnyCancellable] {
base.effectCancellables
}
}

final class IfLetCore<Base: Core, State, Action>: Core {
let base: Base
var cachedState: State
let stateKeyPath: KeyPath<Base.State, State?>
let actionKeyPath: CaseKeyPath<Base.Action, Action>
var parentCancellable: AnyCancellable?
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
}
@inlinable
@inline(__always)
var state: State {
let state = base.state[keyPath: stateKeyPath] ?? cachedState
cachedState = state
return state
}
@inlinable
@inline(__always)
func send(_ action: Action) -> Task<Void, Never>? {
#if DEBUG
if BindingLocal.isActive && isInvalid {
return nil
}
#endif
return base.send(actionKeyPath(action))
}
@inlinable
@inline(__always)
var canStoreCacheChildren: Bool {
base.canStoreCacheChildren
}
@inlinable
@inline(__always)
var didSet: CurrentValueRelay<Void> {
base.didSet
}
@inlinable
@inline(__always)
var isInvalid: Bool {
base.state[keyPath: stateKeyPath] == nil || base.isInvalid
}
@inlinable
@inline(__always)
var effectCancellables: [UUID: AnyCancellable] {
base.effectCancellables
}
}

final class ClosureScopedCore<Base: Core, State, Action>: Core {
let 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
}
@inlinable
@inline(__always)
var state: State {
toState(base.state)
}
@inlinable
@inline(__always)
func send(_ action: Action) -> Task<Void, Never>? {
base.send(fromAction(action))
}
@inlinable
@inline(__always)
var canStoreCacheChildren: Bool {
false
}
@inlinable
@inline(__always)
var didSet: CurrentValueRelay<Void> {
base.didSet
}
@inlinable
@inline(__always)
var isInvalid: Bool {
base.isInvalid
}
@inlinable
@inline(__always)
var effectCancellables: [UUID: AnyCancellable] {
base.effectCancellables
}
}
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
Loading
Loading