-
Notifications
You must be signed in to change notification settings - Fork 47
[feat]: add runtime observation API #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
/// Marks the end of a `WorkflowSession`, indicating that the corresponding `WorkflowNode` has been removed from the tree of Workflows. | ||
/// - Parameter session: The `WorkflowSession` that ended. | ||
func sessionDidEnd( | ||
_ session: WorkflowSession |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it looks like a few of these don't have access to state
, and none of them have access to the underlying workflow instance. is there an underlying limitation preventing that? I somewhat assumed WorkflowSession
would contain some of those things at first glance but realized I was wrong after further reading
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the session stuff in particular was largely modeled on the analogous concepts/interfaces in the kotlin repo.
i think i need a bit of clarification on this thought:
none of them have access to the underlying workflow instance
by 'underlying workflow instance', what do you mean exactly? in many of them the type conforming to Workflow
is passed as a parameter for the observer to use.
is there an underlying limitation preventing that?
for the session lifecycle in particular, the state and corresponding workflow (conforming type instance) could be passed. though there is a question about how to order the sessionDidBegin
and workflowDidMakeInitialState
.
/// - action: The action that was received. | ||
/// - session: The `WorkflowSession` corresponding to the backing `WorkflowNode`. | ||
func workflowDidReceiveAction<Action: WorkflowAction>( | ||
_ action: Action, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can the workflow itself be passed in here, and its state?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it probably can – i think i had experimented with some variants of that, and seem to recall some issues that weren't straightforward to resolve, but i will take a look at it again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i added the workflow back in, but getting the state available in this context seems a bit more difficult. i did add another hook for when the action is applied to the state, so perhaps that could be used instead. i'm inclined to leave it as-is for now, unless there's a clear reason we'd want to expose the state here in particular.
let rawIdentifier: UInt64 = Self._makeNextSessionID() | ||
} | ||
|
||
private indirect enum IndirectParent { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
out of curiosity, what drove the need for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
structs and enums cannot contain stored properties that reference their type by default, but if you mark an enumeration case as 'indirect', it allows this form of recursion. originally i had this as a class, but swapped to the 'indirect enum case' (i.e. a custom optional implementation that allows recursion), mostly just to see if implementing this with the value types was possible.
IMO it is a bit unusual, but seems to work. though it might also make sense to remove the parent reference entirely if it doesn't seem valuable. it does let you walk from a given node up to the root though, which might be useful in some circumstances.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To make sure I understand, if you deleted the indirect
keyword from the declaration of IndirectParent
, it wouldn't compile?
I see that is true if some case
of IndirectParent
has an associated value of type IndirectParent
, but I didn't realize that's true also if the associated value type is only mutually recursive with IndirectParent
, like WorkflowSession
is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this is reasonable. Another typical workaround is to use a "box":
final class Box<T> {
let value: T
init(_ value: T) { self.value = value }
}
// ...
let parent: Box<WorkflowSession>?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To make sure I understand, if you deleted the indirect keyword from the declaration of IndirectParent, it wouldn't compile?
@square-tomb that's right. if i remove it, i get the following compiler error at the declaration of the _indirectParent
property:
Value type 'WorkflowSession' cannot have a stored property that recursively contains it
i assume this is because value types are to be stack-allocated, and so their entire layout must have a fixed size (or at least a bounded size?) so the compiler knows how to set up the function prologues appropriately. if a value type contains a recursive reference (anywhere in a descendant property), then you get this error, presumably because the type's layout size cannot be determined.
Another typical workaround is to use a "box"
@JaviSoto yep, and i believe that is essentially what's done internally when you mark an enum as indirect
. i found this thread had some useful information and links on the topic.
renderKey: String, | ||
parent: WorkflowSession? | ||
) { | ||
self.workflowType = WorkflowType.self |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reason for not storing the workflow itself?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i was thinking of the session as like mostly fixed metadata about the 'node' that persists over time. the actual Workflow
instance (that clients provide) that is associated with a given node can change over time, so if we stored that here we'd have to deal with that fact. also, this is largely modeled from the Kotlin implementation, which doesn't store the Workflow directly on this type (or, the 'props' as they're called in that context).
36e94e6
to
cbb4981
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great to me
let rawIdentifier: UInt64 = Self._makeNextSessionID() | ||
} | ||
|
||
private indirect enum IndirectParent { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To make sure I understand, if you deleted the indirect
keyword from the declaration of IndirectParent
, it wouldn't compile?
I see that is true if some case
of IndirectParent
has an associated value of type IndirectParent
, but I didn't realize that's true also if the associated value type is only mutually recursive with IndirectParent
, like WorkflowSession
is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was super easy to follow, the approach makes a ton of sense, and the tests are fantastic. This is really great!
@@ -37,6 +37,10 @@ public protocol WorkflowAction { | |||
public struct AnyWorkflowAction<WorkflowType: Workflow>: WorkflowAction { | |||
private let _apply: (inout WorkflowType.State) -> WorkflowType.Output? | |||
|
|||
/// Underlying type-erased `WorkflowAction` value, if it exists. Will be nil if the | |||
/// action is defined by a closure. Primarily used for testing purposes. | |||
let _wrappedValue: Any? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be
let _wrappedValue: (any WorkflowAction)?
But that requires Xcode 14, which we may not want to require in case users of the library haven't updated?
Of course given Xcode 14 we can also completely remove this type-erasure since you can now do any WorkflowAction<WorkflowType>
(we'd have to add a default generic type to the protocol too)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Square is on Xcode 14.2 now. Can we make this change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
happy to do that, but that will be a breaking change which will require a major version bump, so would prefer to do it independently of this PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Workflow is an open source repo and currently supports iOS 11+. Making it swift 5.7+ required is definitely a breaking change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I kept forgetting that 👍
Alternatively, you could do it with #if compiler()
, but I wouldn't recommend it unless we have CI set up to make sure everything builds under both Xcode 14.0 and 14.2
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Not to backseat code here; but shall we do the deployment target bump some time soon? It feels fine for folks still needing < 14 to just use an old version)
let rawIdentifier: UInt64 = Self._makeNextSessionID() | ||
} | ||
|
||
private indirect enum IndirectParent { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this is reasonable. Another typical workaround is to use a "box":
final class Box<T> {
let value: T
init(_ value: T) { self.value = value }
}
// ...
let parent: Box<WorkflowSession>?
XCTAssert(observer?.observers.last is NoOpObserver) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are super thorough!! 👍👍👍
c4aff5b
to
b745169
Compare
Workflow/Sources/WorkflowHost.swift
Outdated
@@ -36,7 +36,7 @@ public final class WorkflowHost<WorkflowType: Workflow> { | |||
|
|||
private let (outputEvent, outputEventObserver) = Signal<WorkflowType.Output, Never>.pipe() | |||
|
|||
private let rootNode: WorkflowNode<WorkflowType> | |||
let rootNode: WorkflowNode<WorkflowType> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mentioned concerns about potentially leaking some Workflow internals that could lead to Workflows hanging around longer than expected. Is this one of those places?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not this spot – this change was just to enable some unit tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sometimes I use this little trick to make it clearer why something that one may think should be private isn't, and also to emphasize that while it isn't private, it's also no public:
// @testable
internal let rootNode: WorkflowNode<WorkflowType>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could also leverage @_spi
here to force someone to opt into the SPI interface to access this.
* main: [fix]: use weak reference to internal sinks when vending to clients (#189) [feat]: add primary associated types to more protocols (#188) [chore]: pre major version bump cleanup (#187) [chore]: bump minimum deployment & swift versions (#186) Abstract ViewEnvironment to shared framework (#185) Bump activesupport from 6.1.4.4 to 6.1.7.1 (#183) [feat]: add primary associated types to `Workflow` protocol (#181) [chore]: update swiftformat ifdef indent rule to no-indent (#182)
…rvers * origin/main: [chore]: refactor some internal actions to use existential any (#190)
…ow-workflow-conformance * origin/main: Expose AnyScreen.wrappedScreen for inspection (#193) Improve introspection for SignalProducerWorkflow actions (#192) [release]: bump version to 2.2.0 & remove separate concurrency version (#191) [feat]: add runtime observation API (#168) [chore]: refactor some internal actions to use existential any (#190) [fix]: use weak reference to internal sinks when vending to clients (#189) [feat]: add primary associated types to more protocols (#188) [chore]: pre major version bump cleanup (#187) [chore]: bump minimum deployment & swift versions (#186) Abstract ViewEnvironment to shared framework (#185)
Issue
the Swift implementation of the Workflow library currently does not offer much support for consumers to tie into runtime operations in which they may be interested. by contrast, the Kotlin implementation has supported various facilities for this over time, and currently allows most runtime methods to be observed or even replaced by clients.
Description
WorkflowSession
that represents metadata about the node backing a particularWorkflow
'instance' in the tree. this was largely modeled after the analogous concept in the Kotlin library. this type includes:WorkflowObserver
protocol, which provides (optional) runtime hooks for the following:WorkflowObserver
implementation to multiplex observers provided by clientsChecklist
UI TestsN/ASnapshot Tests (iOS only)N/A