Skip to content

Commit e05bd71

Browse files
committed
[breaking]: update action apply API
1 parent 095f334 commit e05bd71

15 files changed

+467
-37
lines changed

Workflow/Sources/AnyWorkflowConvertible.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,27 @@ extension AnyWorkflowConvertible {
115115
/// Process an `Output`
116116
///
117117
/// - Parameter apply: On `Output`, mutate `State` as necessary and return new `Output` (or `nil`).
118-
public func onOutput<Parent>(_ apply: @escaping ((inout Parent.State, Output) -> Parent.Output?)) -> AnyWorkflow<Rendering, AnyWorkflowAction<Parent>> {
118+
public func onOutput<Parent>(
119+
_ apply: @escaping (inout Parent.State, ApplyContext<Parent>, Output) -> Parent.Output?
120+
) -> AnyWorkflow<Rendering, AnyWorkflowAction<Parent>> {
119121
asAnyWorkflow()
120122
.mapOutput { output in
121-
AnyWorkflowAction { state -> Parent.Output? in
123+
AnyWorkflowAction { state, context -> Parent.Output? in
124+
apply(&state, context, output)
125+
}
126+
}
127+
}
128+
129+
/// Process an `Output`
130+
///
131+
/// - Parameter apply: On `Output`, mutate `State` as necessary and return new `Output` (or `nil`).
132+
@_disfavoredOverload
133+
public func onOutput<Parent>(
134+
_ apply: @escaping (inout Parent.State, Output) -> Parent.Output?
135+
) -> AnyWorkflow<Rendering, AnyWorkflowAction<Parent>> {
136+
asAnyWorkflow()
137+
.mapOutput { output in
138+
AnyWorkflowAction { state, _ -> Parent.Output? in
122139
apply(&state, output)
123140
}
124141
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/// Internal utility protocol so that `WorkflowTesting` can provide an alternate implementation
18+
protocol ApplyContextType<WorkflowType> {
19+
associatedtype WorkflowType: Workflow
20+
21+
subscript<Value>(
22+
workflowValue keyPath: KeyPath<WorkflowType, Value>
23+
) -> Value { get }
24+
}
25+
26+
/// Runtime context passed as a parameter to `WorkflowAction`'s `apply()` method
27+
/// that provides an integration point with the runtime that can be used to read property values
28+
/// off of the current `Workflow` instance.
29+
public struct ApplyContext<WorkflowType: Workflow> {
30+
let wrappedContext: any ApplyContextType<WorkflowType>
31+
32+
init<Impl: ApplyContextType>(_ context: Impl)
33+
where Impl.WorkflowType == WorkflowType
34+
{
35+
self.wrappedContext = context
36+
}
37+
38+
public subscript<Value>(
39+
workflowValue keyPath: KeyPath<WorkflowType, Value>
40+
) -> Value {
41+
wrappedContext[workflowValue: keyPath]
42+
}
43+
}
44+
45+
extension ApplyContext {
46+
static func make<Wrapped: ApplyContextType>(
47+
implementation: Wrapped
48+
) -> ApplyContext<Wrapped.WorkflowType>
49+
where Wrapped.WorkflowType == WorkflowType
50+
{
51+
ApplyContext(implementation)
52+
}
53+
}
54+
55+
// FIXME: this is currently a class so that we can zero out the storage
56+
// when it's invalidated. it'd be nice to eventually make the `ApplyContext`
57+
// type itself `~Escapable` since that's really the behavior that we want
58+
// to enforce.
59+
60+
/// The `ApplyContext` used by the Workflow runtime when applying actions.
61+
final class ConcreteApplyContext<WorkflowType: Workflow>: ApplyContextType {
62+
private(set) var storage: WorkflowType?
63+
64+
private var validatedStorage: WorkflowType {
65+
guard let storage else {
66+
fatalError("Attempt to use an ApplyContext for Workflow of type '\(WorkflowType.self)' after it was invalidated. The context is only valid during a call to an apply(...) method.")
67+
}
68+
69+
return storage
70+
}
71+
72+
init(storage: WorkflowType) {
73+
self.storage = storage
74+
}
75+
76+
subscript<Value>(
77+
workflowValue keyPath: KeyPath<WorkflowType, Value>
78+
) -> Value {
79+
validatedStorage[keyPath: keyPath]
80+
}
81+
82+
// MARK: -
83+
84+
func invalidate() {
85+
storage = nil
86+
}
87+
}

Workflow/Sources/RenderContext.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,12 +180,17 @@ protocol RenderContextType: AnyObject {
180180
extension RenderContext {
181181
public func makeSink<Event>(
182182
of eventType: Event.Type,
183-
onEvent: @escaping (Event, inout WorkflowType.State) -> WorkflowType.Output?
183+
onEvent: @escaping (
184+
Event,
185+
inout WorkflowType.State,
186+
ApplyContext<WorkflowType>
187+
) -> WorkflowType.Output?
188+
// (Event, inout WorkflowType.State) -> WorkflowType.Output?
184189
) -> Sink<Event> {
185190
makeSink(of: AnyWorkflowAction.self)
186191
.contraMap { event in
187-
AnyWorkflowAction<WorkflowType> { state in
188-
onEvent(event, &state)
192+
AnyWorkflowAction<WorkflowType> { state, context in
193+
onEvent(event, &state, context)
189194
}
190195
}
191196
}

Workflow/Sources/StateMutationSink.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public struct StateMutationSink<WorkflowType: Workflow> {
4444
/// - update: The `State` mutation to perform.
4545
public func send(_ update: @escaping (inout WorkflowType.State) -> Void) {
4646
sink.send(
47-
AnyWorkflowAction<WorkflowType> { state in
47+
AnyWorkflowAction<WorkflowType> { state, _ in
4848
update(&state)
4949
return nil
5050
}

Workflow/Sources/WorkflowAction.swift

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,22 @@ public protocol WorkflowAction<WorkflowType> {
2727
///
2828
/// - Returns: An optional output event for the workflow. If an output event is returned, it will be passed up
2929
/// the workflow hierarchy to this workflow's parent.
30-
func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output?
30+
func apply(
31+
toState state: inout WorkflowType.State,
32+
context: ApplyContext<WorkflowType>
33+
) -> WorkflowType.Output?
34+
}
35+
36+
extension WorkflowAction {
37+
/// Closure type signature matching `WorkflowAction`'s `apply()` method.
38+
public typealias ActionApplyClosure = (inout WorkflowType.State, ApplyContext<WorkflowType>) -> WorkflowType.Output?
3139
}
3240

3341
/// A type-erased workflow action.
3442
///
3543
/// The `AnyWorkflowAction` type forwards `apply` to an underlying workflow action, hiding its specific underlying type.
3644
public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
37-
private let _apply: (inout WorkflowType.State) -> WorkflowType.Output?
45+
private let _apply: ActionApplyClosure
3846

3947
/// The underlying type-erased `WorkflowAction`
4048
public let base: Any
@@ -50,7 +58,9 @@ public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
5058
self = anyEvent
5159
return
5260
}
53-
self._apply = { base.apply(toState: &$0) }
61+
self._apply = {
62+
base.apply(toState: &$0, context: $1)
63+
}
5464
self.base = base
5565
self.isClosureBased = false
5666
}
@@ -59,7 +69,7 @@ public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
5969
///
6070
/// - Parameter apply: the apply function for the resulting action.
6171
public init(
62-
_ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?,
72+
_ apply: @escaping ActionApplyClosure,
6373
fileID: StaticString = #fileID,
6474
line: UInt = #line
6575
) {
@@ -71,16 +81,35 @@ public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction {
7181
self.init(closureAction: closureAction)
7282
}
7383

84+
/// Creates a type-erased workflow action with the given `apply` implementation.
85+
///
86+
/// - Parameter apply: the apply function for the resulting action.
87+
@_disfavoredOverload
88+
public init(
89+
_ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?,
90+
fileID: StaticString = #fileID,
91+
line: UInt = #line
92+
) {
93+
self.init(
94+
{ state, _ in apply(&state) },
95+
fileID: fileID,
96+
line: line
97+
)
98+
}
99+
74100
/// Private initializer forwarded to via `init(_ apply:...)`
75101
/// - Parameter closureAction: The `ClosureAction` wrapping the underlying `apply` closure.
76102
fileprivate init(closureAction: ClosureAction<WorkflowType>) {
77-
self._apply = closureAction.apply(toState:)
103+
self._apply = closureAction.apply(toState:context:)
78104
self.base = closureAction
79105
self.isClosureBased = true
80106
}
81107

82-
public func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? {
83-
_apply(&state)
108+
public func apply(
109+
toState state: inout WorkflowType.State,
110+
context: ApplyContext<WorkflowType>
111+
) -> WorkflowType.Output? {
112+
_apply(&state, context)
84113
}
85114
}
86115

@@ -89,15 +118,15 @@ extension AnyWorkflowAction {
89118
///
90119
/// - Parameter output: The output event to send when this action is applied.
91120
public init(sendingOutput output: WorkflowType.Output) {
92-
self = AnyWorkflowAction { state in
121+
self = AnyWorkflowAction { _, _ in
93122
output
94123
}
95124
}
96125

97126
/// Creates a type-erased workflow action that does nothing (it leaves state unchanged and does not emit an output
98127
/// event).
99128
public static var noAction: AnyWorkflowAction<WorkflowType> {
100-
AnyWorkflowAction { state in
129+
AnyWorkflowAction { _, _ in
101130
nil
102131
}
103132
}
@@ -109,12 +138,12 @@ extension AnyWorkflowAction {
109138
/// Mainly used to provide more useful debugging/telemetry information for `AnyWorkflow` instances
110139
/// defined via a closure.
111140
struct ClosureAction<WorkflowType: Workflow>: WorkflowAction {
112-
private let _apply: (inout WorkflowType.State) -> WorkflowType.Output?
141+
private let _apply: ActionApplyClosure
113142
let fileID: StaticString
114143
let line: UInt
115144

116145
init(
117-
_apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?,
146+
_apply: @escaping ActionApplyClosure,
118147
fileID: StaticString,
119148
line: UInt
120149
) {
@@ -123,8 +152,11 @@ struct ClosureAction<WorkflowType: Workflow>: WorkflowAction {
123152
self.line = line
124153
}
125154

126-
func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? {
127-
_apply(&state)
155+
func apply(
156+
toState state: inout WorkflowType.State,
157+
context: ApplyContext<WorkflowType>
158+
) -> WorkflowType.Output? {
159+
_apply(&state, context)
128160
}
129161
}
130162

Workflow/Sources/WorkflowNode.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,14 @@ extension WorkflowNode {
218218
defer { observerCompletion?(state, output) }
219219

220220
/// Apply the action to the current state
221-
output = action.apply(toState: &state)
221+
do {
222+
// TODO: can we avoid instantiating a class here somehow?
223+
let context = ConcreteApplyContext(storage: workflow)
224+
defer { context.invalidate() }
225+
226+
let wrappedContext = ApplyContext.make(implementation: context)
227+
output = action.apply(toState: &state, context: wrappedContext)
228+
}
222229

223230
return output
224231
}

Workflow/Tests/AnyWorkflowActionTests.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ final class AnyWorkflowActionTests: XCTestCase {
8383
XCTAssertEqual(log, [])
8484

8585
var state: Void = ()
86-
_ = erased.apply(toState: &state)
86+
let ctx = ApplyContext(ConcreteApplyContext(storage: ExampleWorkflow()))
87+
_ = erased.apply(toState: &state, context: ctx)
8788

8889
XCTAssertEqual(log, ["action invoked"])
8990
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Testing
18+
19+
@testable import Workflow
20+
21+
@MainActor
22+
struct ApplyContextTests {
23+
@Test
24+
func concreteApplyContextInvalidatedAfterUse() async throws {
25+
var escapedContext: ApplyContext<EscapingContextWorkflow>?
26+
let onApply = { (context: ApplyContext<EscapingContextWorkflow>) in
27+
#expect(context[workflowValue: \.property] == 42)
28+
#expect(context.concreteStorage != nil)
29+
escapedContext = context
30+
}
31+
32+
let workflow = EscapingContextWorkflow(
33+
property: 42,
34+
onApply: onApply
35+
)
36+
let node = WorkflowNode(workflow: workflow)
37+
38+
let emitEvent = node.render()
39+
node.enableEvents()
40+
41+
emitEvent()
42+
43+
#expect(escapedContext != nil)
44+
#expect(escapedContext?.concreteStorage == nil)
45+
}
46+
}
47+
48+
// MARK: -
49+
50+
private struct EscapingContextWorkflow: Workflow {
51+
typealias Rendering = () -> Void
52+
typealias State = Void
53+
54+
var property: Int
55+
var onApply: ((ApplyContext<Self>) -> Void)?
56+
57+
func render(
58+
state: State,
59+
context: RenderContext<EscapingContextWorkflow>
60+
) -> Rendering {
61+
let sink = context.makeSink(of: EscapingAction.self)
62+
let action = EscapingAction(onApply: onApply)
63+
return { sink.send(action) }
64+
}
65+
66+
struct EscapingAction: WorkflowAction {
67+
typealias WorkflowType = EscapingContextWorkflow
68+
69+
var onApply: ((ApplyContext<WorkflowType>) -> Void)?
70+
71+
func apply(
72+
toState state: inout WorkflowType.State,
73+
context: ApplyContext<WorkflowType>
74+
) -> WorkflowType.Output? {
75+
onApply?(context)
76+
return nil
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)