diff --git a/.github/workflows/swift.yaml b/.github/workflows/swift.yaml index 416c83858..c37c763bc 100644 --- a/.github/workflows/swift.yaml +++ b/.github/workflows/swift.yaml @@ -6,14 +6,16 @@ on: - main pull_request: +# TODO: revert to macos-latest when Xcode 16.3 is available by end of August 2025 + env: - XCODE_VERSION: 16.1.0 + XCODE_VERSION: 16.3 TUIST_TEST_DEVICE: iPad (10th generation) TUIST_TEST_PLATFORM: iOS jobs: development-tests: - runs-on: macos-latest + runs-on: macos-15 name: "development-tests [iOS ${{ matrix.sdk }}]" @@ -30,10 +32,10 @@ jobs: - sdk: "17.5" simctl_runtime: "com.apple.CoreSimulator.SimRuntime.iOS-17-5" - installation_required: false + installation_required: true - - sdk: "18.1" - simctl_runtime: "com.apple.CoreSimulator.SimRuntime.iOS-18-1" + - sdk: "18.4" + simctl_runtime: "com.apple.CoreSimulator.SimRuntime.iOS-18-4" installation_required: false steps: @@ -62,7 +64,7 @@ jobs: # FIXME: these should probably be run with a matrix too snapshot-tests: - runs-on: macos-latest + runs-on: macos-15 env: TUIST_TEST_OS: 18.1 @@ -85,7 +87,7 @@ jobs: run: tuist test --path Samples package-tests: - runs-on: macos-latest + runs-on: macos-15 steps: - uses: actions/checkout@v4 @@ -99,7 +101,7 @@ jobs: run: swift test tutorial: - runs-on: macos-latest + runs-on: macos-15 steps: - uses: actions/checkout@v4 diff --git a/Workflow/Sources/SubtreeManager.swift b/Workflow/Sources/SubtreeManager.swift index 5c43a3c7e..cf262e8c3 100644 --- a/Workflow/Sources/SubtreeManager.swift +++ b/Workflow/Sources/SubtreeManager.swift @@ -155,9 +155,29 @@ enum EventSource: Equatable { } extension WorkflowNode.SubtreeManager { + /// The possible output types that a SubtreeManager can produce. enum Output { - case update(any WorkflowAction, source: EventSource) - case childDidUpdate(WorkflowUpdateDebugInfo?) + /// Indicates that an event produced a `WorkflowAction` to apply to the node. + /// + /// - Parameters: + /// - action: The `WorkflowAction` to be applied to the node. + /// - source: The event source that triggered this update. This is primarily used to differentiate between 'external' events and events that originate from the subtree itself. + /// - subtreeInvalidated: A boolean indicating whether at least one descendant workflow has been invalidated during this update. + case update( + any WorkflowAction, + source: EventSource, + subtreeInvalidated: Bool + ) + + /// Indicates that a child workflow within the subtree handled an event and was updated. This informs the parent node about the change and propagates the update 'up' the tree. + /// + /// - Parameters: + /// - debugInfo: Optional debug information about the workflow update. + /// - subtreeInvalidated: A boolean indicating whether at least one descendant workflow has been invalidated during this update. + case childDidUpdate( + WorkflowUpdateDebugInfo?, + subtreeInvalidated: Bool + ) } } @@ -334,7 +354,11 @@ extension WorkflowNode.SubtreeManager { fileprivate final class ReusableSink: AnyReusableSink where Action.WorkflowType == WorkflowType { func handle(action: Action) { - let output = Output.update(action, source: .external) + let output = Output.update( + action, + source: .external, + subtreeInvalidated: false // initial state + ) if case .pending = eventPipe.validationState { // Workflow is currently processing an `event`. @@ -515,10 +539,14 @@ extension WorkflowNode.SubtreeManager { let output = if let outputEvent = workflowOutput.outputEvent { Output.update( outputMap(outputEvent), - source: .subtree(workflowOutput.debugInfo) + source: .subtree(workflowOutput.debugInfo), + subtreeInvalidated: workflowOutput.subtreeInvalidated ) } else { - Output.childDidUpdate(workflowOutput.debugInfo) + Output.childDidUpdate( + workflowOutput.debugInfo, + subtreeInvalidated: workflowOutput.subtreeInvalidated + ) } eventPipe.handle(event: output) diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 53ff810f4..3dcc43689 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -101,14 +101,19 @@ public final class WorkflowHost { workflowType: "\(WorkflowType.self)", kind: .didUpdate(source: .external) ) - } + }, + subtreeInvalidated: true // treat as an invalidation ) handle(output: output) } private func handle(output: WorkflowNode.Output) { - mutableRendering.value = rootNode.render() + let shouldRender = !shouldSkipRenderForOutput(output) + if shouldRender { + mutableRendering.value = rootNode.render() + } + // Always emit an output, regardless of whether a render occurs if let outputEvent = output.outputEvent { outputEventObserver.send(value: outputEvent) } @@ -118,7 +123,10 @@ public final class WorkflowHost { updateInfo: output.debugInfo.unwrappedOrErrorDefault ) - rootNode.enableEvents() + // If we rendered, the event pipes must be re-enabled + if shouldRender { + rootNode.enableEvents() + } } /// A signal containing output events emitted by the root workflow in the hierarchy. @@ -127,6 +135,20 @@ public final class WorkflowHost { } } +// MARK: - Conditional Rendering Utilities + +extension WorkflowHost { + private func shouldSkipRenderForOutput( + _ output: WorkflowNode.Output + ) -> Bool { + // We can skip the render pass if: + // 1. The runtime config supports this behavior. + // 2. No subtree invalidation occurred during action processing. + context.runtimeConfig.renderOnlyIfStateChanged + && !output.subtreeInvalidated + } +} + // MARK: - HostContext /// A context object to expose certain root-level information to each node diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index 46811e2f4..386ca4be0 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -39,6 +39,8 @@ final class WorkflowNode { hostContext.observer } + lazy var hasVoidState: Bool = WorkflowType.State.self == Void.self + init( workflow: WorkflowType, key: String = "", @@ -84,27 +86,32 @@ final class WorkflowNode { private func handle(subtreeOutput: SubtreeManager.Output) { let output: Output + // In all cases, propagate subtree invalidation. We should go from + // `false` -> `true` if the action application result indicates + // that a child node's state changed. switch subtreeOutput { - case .update(let action, let source): + case .update(let action, let source, let subtreeInvalidated): /// 'Opens' the existential `any WorkflowAction` value /// allowing the underlying conformance to be applied to the Workflow's State - let outputEvent = openAndApply( + let result = applyAction( action, - isExternal: source == .external + isExternal: source == .external, + subtreeInvalidated: subtreeInvalidated ) /// Finally, we tell the outside world that our state has changed (including an output event if it exists). output = Output( - outputEvent: outputEvent, + outputEvent: result.output, debugInfo: hostContext.ifDebuggerEnabled { WorkflowUpdateDebugInfo( workflowType: "\(WorkflowType.self)", kind: .didUpdate(source: source.toDebugInfoSource()) ) - } + }, + subtreeInvalidated: subtreeInvalidated || result.stateChanged ) - case .childDidUpdate(let debugInfo): + case .childDidUpdate(let debugInfo, let subtreeInvalidated): output = Output( outputEvent: nil, debugInfo: hostContext.ifDebuggerEnabled { @@ -112,7 +119,8 @@ final class WorkflowNode { workflowType: "\(WorkflowType.self)", kind: .childDidUpdate(debugInfo.unwrappedOrErrorDefault) ) - } + }, + subtreeInvalidated: subtreeInvalidated ) } @@ -184,20 +192,43 @@ extension WorkflowNode { struct Output { var outputEvent: WorkflowType.Output? var debugInfo: WorkflowUpdateDebugInfo? + /// Indicates whether a node in the subtree of the current node (self-inclusive) + /// should be considered by the runtime to have changed, and thus be invalid + /// from the perspective of needing to be re-rendered. + var subtreeInvalidated: Bool } } +// MARK: - Action Application + extension WorkflowNode { + /// Represents the result of applying a `WorkflowAction` to a workflow's state. + struct ActionApplicationResult { + /// An optional output event produced by the action application. + /// This will be propagated up the workflow hierarchy if present. + var output: WorkflowType.Output? + + /// Indicates whether the node's state was modified during action application. + /// This is used to determine if the node needs to be re-rendered and to + /// track invalidation through the workflow hierarchy. Note that currently this + /// value does not definitively indicate if the state actually changed, but should + /// be treated as a 'dirty bit' flag – if it's set, the node should be re-rendered. + var stateChanged: Bool + } + /// Applies an appropriate `WorkflowAction` to advance the underlying Workflow `State` /// - Parameters: /// - action: The `WorkflowAction` to apply /// - isExternal: Whether the handled action came from the 'outside world' vs being bubbled up from a child node /// - Returns: An optional `Output` produced by the action application - private func openAndApply( + private func applyAction( _ action: A, - isExternal: Bool - ) -> WorkflowType.Output? where A.WorkflowType == WorkflowType { - let output: WorkflowType.Output? + isExternal: Bool, + subtreeInvalidated: Bool + ) -> ActionApplicationResult + where A.WorkflowType == WorkflowType + { + let result: ActionApplicationResult // handle specific observation call if this is the first node // processing this 'action cascade' @@ -215,19 +246,67 @@ extension WorkflowNode { state: state, session: session ) - defer { observerCompletion?(state, output) } + defer { observerCompletion?(state, result.output) } - /// Apply the action to the current state do { // FIXME: can we avoid instantiating a class here somehow? let context = ConcreteApplyContext(storage: workflow) defer { context.invalidate() } - let wrappedContext = ApplyContext.make(implementation: context) - output = action.apply(toState: &state, context: wrappedContext) + + let renderOnlyIfStateChanged = hostContext.runtimeConfig.renderOnlyIfStateChanged + + // Local helper that applies the action without any extra logic, and + // allows the caller to decide whether the state should be marked as + // having changed. + func performSimpleActionApplication( + markStateAsChanged: Bool + ) -> ActionApplicationResult { + ActionApplicationResult( + output: action.apply(toState: &state, context: wrappedContext), + stateChanged: markStateAsChanged + ) + } + + // Take this path only if no known state has yet been invalidated + // while handling this chain of action applications. We'll handle + // some cases in which we can reasonably infer if state actually + // changed during the action application. + if renderOnlyIfStateChanged { + // Some child state already changed, so just apply the action + // and say our state changed as well. + if subtreeInvalidated { + result = performSimpleActionApplication(markStateAsChanged: true) + } else { + if let equatableState = state as? (any Equatable) { + // If we can recover an Equatable conformance, then + // compare before & after to see if something changed. + func applyEquatableState( + _ initialState: EquatableState + ) -> ActionApplicationResult { + // TODO: is there a CoW tax (that matters) here? + let output = action.apply(toState: &state, context: wrappedContext) + let stateChanged = (state as! EquatableState) != initialState + return ActionApplicationResult( + output: output, + stateChanged: stateChanged + ) + } + result = applyEquatableState(equatableState) + } else if hasVoidState { + // State is Void, so treat as no change + result = performSimpleActionApplication(markStateAsChanged: false) + } else { + // Otherwise, assume something changed + result = performSimpleActionApplication(markStateAsChanged: true) + } + } + } else { + result = performSimpleActionApplication(markStateAsChanged: true) + } } - return output + return result } } diff --git a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift new file mode 100644 index 000000000..6e2f64f55 --- /dev/null +++ b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift @@ -0,0 +1,508 @@ +/* + * Copyright 2025 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ReactiveSwift +import Testing + +@_spi(WorkflowRuntimeConfig) +@testable +import Workflow + +@Suite(renderOnlyIfStateChanged) +@MainActor +struct RenderOnlyIfStateChangedEnabledTests { + // MARK: - Render Skipping Tests + + @Test + func skipsRenderWhenNoStateChangeAndRenderOnlyIfStateChangedEnabled() { + let host = WorkflowHost(workflow: CounterWorkflow()) + + var renderCount = 0 + let disposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { disposable?.dispose() } + + // Initial render + #expect(renderCount == 0) + #expect(host.rendering.value.count == 0) + + // Trigger action that doesn't change state + host.rendering.value.noOpAction() + + // Should not render since state didn't change + #expect(renderCount == 0) + #expect(host.rendering.value.count == 0) + } + + @Test(renderOnlyIfStateChangedDisabled) + func reRendersWhenNoStateChangeAndRenderOnlyIfStateChangedDisabled() { + let host = WorkflowHost(workflow: CounterWorkflow()) + + var renderCount = 0 + let disposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { disposable?.dispose() } + + // Initial render + #expect(renderCount == 0) + #expect(host.rendering.value.count == 0) + + // Trigger action that doesn't change state + host.rendering.value.noOpAction() + + // Should render again since the skipping config is off + #expect(renderCount == 1) + #expect(host.rendering.value.count == 0) + } + + @Test + func rendersWhenStateChangesAndRenderOnlyIfStateChangedEnabled() { + let host = WorkflowHost(workflow: CounterWorkflow()) + + var renderCount = 0 + let disposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { disposable?.dispose() } + + // Initial render + #expect(renderCount == 0) + #expect(host.rendering.value.count == 0) + + // Trigger action that changes state + host.rendering.value.incrementAction() + + // Should render since state changed + #expect(renderCount == 1) + #expect(host.rendering.value.count == 1) + } + + @Test + func skipsRenderWithVoidStateAndPropertyAccess() { + let host = WorkflowHost(workflow: VoidStateWorkflow(prop: 42)) + + var renderCount = 0 + var outputs: [Int] = [] + + let renderDisposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { renderDisposable?.dispose() } + + let outputDisposable = host.output.observeValues { + outputs.append($0) + } + defer { outputDisposable?.dispose() } + + // Initial render + #expect(renderCount == 0) + #expect(outputs == []) + + // Trigger action that reads from props (state is Void) + host.rendering.value.action() + + // Should not render since the State is Void + #expect(renderCount == 0) + #expect(outputs == [42]) + } + + // MARK: - Subtree Invalidation Tests + + @Test + func rendersWhenSubtreeInvalidatedRegardlessOfStateChange() { + let host = WorkflowHost(workflow: ParentWorkflow()) + + var renderCount = 0 + let disposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { disposable?.dispose() } + + // Initial render + #expect(renderCount == 0) + + // Trigger child action that invalidates subtree + host.rendering.value.childAction() + + // Should render due to subtree invalidation even if parent state doesn't change + #expect(renderCount == 1) + } + + // MARK: - External Update Tests + + @Test + func externalUpdateAlwaysRendersDueToSubtreeInvalidation() { + let host = WorkflowHost(workflow: CounterWorkflow()) + + var renderCount = 0 + let disposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { disposable?.dispose() } + + // Initial render + #expect(renderCount == 0) + + // External update (always marks subtree as invalidated) + host.update(workflow: CounterWorkflow()) + + // Should render due to external update marking subtree as invalidated + #expect(renderCount == 1) + } + + // MARK: - Output Event Tests + + @Test + func outputEventsEmittedEvenWhenRenderSkipped() { + let host = WorkflowHost(workflow: OutputWorkflow()) + + var renderCount = 0 + var outputCount = 0 + + let renderDisposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { renderDisposable?.dispose() } + + let outputDisposable = host.output.observeValues { _ in + outputCount += 1 + } + defer { outputDisposable?.dispose() } + + // Initial render + #expect(renderCount == 0) + #expect(outputCount == 0) + + // Trigger action that emits output but doesn't change state + host.rendering.value.emitOutputAction() + + // Should not render but should emit output + #expect(renderCount == 0) + #expect(outputCount == 1) + } + + // MARK: - Event Pipes + + @Test + func eventPipesWorkWhenRenderSkipped() { + let host = WorkflowHost(workflow: CounterWorkflow()) + + // Track the initial rendering reference + let initialRendering = host.rendering.value + + var outputCount = 0 + let outputDisposable = host.output.observeValues { _ in + outputCount += 1 + } + var renderCount = 0 + let renderDisposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { + outputDisposable?.dispose() + renderDisposable?.dispose() + } + + #expect(outputCount == 0) + #expect(renderCount == 0) + + // Trigger action that doesn't change state + initialRendering.noOpAction() + + #expect(outputCount == 1) + #expect(renderCount == 0) + + // The event pipes should have been left alone (not invalidated), so + // emitting another event still works. + initialRendering.incrementAction() + + #expect(outputCount == 2) + #expect(renderCount == 1) + } + + @Test + func eventPipesWorkWhenRenderNotSkipped() { + let host = WorkflowHost(workflow: CounterWorkflow()) + + // Track the initial rendering reference + let initialRendering = host.rendering.value + + var outputCount = 0 + let outputDisposable = host.output.observeValues { _ in + outputCount += 1 + } + var renderCount = 0 + let renderDisposable = host.rendering.signal.observeValues { _ in + renderCount += 1 + } + defer { + outputDisposable?.dispose() + renderDisposable?.dispose() + } + + #expect(outputCount == 0) + #expect(renderCount == 0) + + // Trigger action that causes a re-render + initialRendering.incrementAction() + + #expect(outputCount == 1) + #expect(renderCount == 1) + + // The event pipes should have been re-enabled, so + // emitting another event still works. If we forgot + // to do so, the runtime should trap. + initialRendering.noOpAction() + + #expect(outputCount == 2) + #expect(renderCount == 1) + } +} + +// MARK: - Test Workflows + +extension RenderOnlyIfStateChangedEnabledTests { + fileprivate struct CounterWorkflow: Workflow { + struct State: Equatable { + var count = 0 + } + + struct Rendering { + let count: Int + let incrementAction: () -> Void + let noOpAction: () -> Void + } + + typealias Output = Void + + func makeInitialState() -> State { + State() + } + + func render(state: State, context: RenderContext) -> Rendering { + let incrementSink = context.makeSink(of: IncrementAction.self) + let noOpSink = context.makeSink(of: NoOpAction.self) + + return Rendering( + count: state.count, + incrementAction: { incrementSink.send(.increment) }, + noOpAction: { noOpSink.send(.noOp) } + ) + } + + enum IncrementAction: WorkflowAction { + case increment + + func apply( + toState state: inout CounterWorkflow.State, + context: ApplyContext + ) -> Output? { + state.count += 1 + return () + } + } + + enum NoOpAction: WorkflowAction { + case noOp + + func apply( + toState state: inout CounterWorkflow.State, + context: ApplyContext + ) -> Output? { + // Don't change state + () + } + } + } + + fileprivate struct OutputWorkflow: Workflow { + struct State: Equatable {} + + func makeInitialState() -> State { + State() + } + + enum Output { + case emitted + } + + struct Rendering { + let emitOutputAction: () -> Void + } + + func render(state: State, context: RenderContext) -> Rendering { + let sink = context.makeSink(of: EmitOutputAction.self) + + return Rendering( + emitOutputAction: { sink.send(.emit) } + ) + } + + enum EmitOutputAction: WorkflowAction { + case emit + + func apply( + toState state: inout OutputWorkflow.State, + context: ApplyContext + ) -> OutputWorkflow.Output? { + // Don't change state but emit output + .emitted + } + } + } + + fileprivate struct ParentWorkflow: Workflow { + struct State: Equatable {} + + func makeInitialState() -> State { + State() + } + + struct Rendering { + let childAction: () -> Void + } + + func render(state: State, context: RenderContext) -> Rendering { + let childRendering = ChildWorkflow() + .mapOutput { _ in ParentAction.childUpdated } + .rendered(in: context) + + return Rendering( + childAction: childRendering.action + ) + } + + enum ParentAction: WorkflowAction { + case childUpdated + + func apply( + toState state: inout ParentWorkflow.State, + context: ApplyContext + ) -> Never? { + // Don't change state + nil + } + } + } + + fileprivate struct ChildWorkflow: Workflow { + struct State { + var value = 0 + } + + func makeInitialState() -> State { + State() + } + + enum Output { + case updated + } + + struct Rendering { + let action: () -> Void + } + + func render(state: State, context: RenderContext) -> Rendering { + let sink = context.makeSink(of: UpdateAction.self) + + return Rendering( + action: { sink.send(.update) } + ) + } + + enum UpdateAction: WorkflowAction { + case update + + func apply( + toState state: inout ChildWorkflow.State, + context: ApplyContext + ) -> ChildWorkflow.Output? { + state.value += 1 + return .updated + } + } + } + + fileprivate struct VoidStateWorkflow: Workflow { + typealias State = Void + + struct Rendering { + let action: () -> Void + } + + typealias Output = Int + + var prop: Int + + func makeInitialState() -> State { + State() + } + + func render(state: State, context: RenderContext) -> Rendering { + let readPropSink = context.makeSink(of: ReadPropAction.self) + + return Rendering( + action: { readPropSink.send(.readProp) } + ) + } + + enum ReadPropAction: WorkflowAction { + case readProp + + func apply( + toState state: inout State, + context: ApplyContext + ) -> Output? { + context[workflowValue: \.prop] + } + } + } +} + +// MARK: - Traits + +private struct RenderOnlyIfStateChangedTrait: SuiteTrait, TestTrait { + var enabled = true + + struct TestScopeProvider: TestScoping { + var enabled: Bool + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + var config = Runtime.configuration + config.renderOnlyIfStateChanged = enabled + + try await Runtime.$_currentConfiguration.withValue(config, operation: function) + } + } + + func scopeProvider(for test: Test, testCase: Test.Case?) -> TestScopeProvider? { + TestScopeProvider(enabled: enabled) + } +} + +private var renderOnlyIfStateChanged: some SuiteTrait & TestTrait { + RenderOnlyIfStateChangedTrait() +} + +private var renderOnlyIfStateChangedDisabled: some SuiteTrait & TestTrait { + RenderOnlyIfStateChangedTrait(enabled: false) +} diff --git a/Workflow/Tests/SubtreeManagerTests.swift b/Workflow/Tests/SubtreeManagerTests.swift index c4a3278a3..4c97d00c7 100644 --- a/Workflow/Tests/SubtreeManagerTests.swift +++ b/Workflow/Tests/SubtreeManagerTests.swift @@ -71,7 +71,7 @@ final class SubtreeManagerTests: XCTestCase { manager.onUpdate = { switch $0 { - case .update(let event, _): + case .update(let event, _, _): events.append(AnyWorkflowAction(event)) default: break