diff --git a/Package.swift b/Package.swift index 4d881b9f4..380181002 100644 --- a/Package.swift +++ b/Package.swift @@ -71,10 +71,10 @@ let package = Package( name: "Workflow", dependencies: [ .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - .product(name: "ReactiveSwift", package: "ReactiveSwift"), ], path: "Workflow/Sources" ), + .target( name: "WorkflowTesting", dependencies: [ diff --git a/Samples/Project.swift b/Samples/Project.swift index 6ece253e6..2d9a791da 100644 --- a/Samples/Project.swift +++ b/Samples/Project.swift @@ -158,7 +158,10 @@ let project = Project( .unitTest( for: "Workflow", sources: "../Workflow/Tests/**", - dependencies: [.external(name: "Workflow")] + dependencies: [ + .external(name: "ReactiveSwift"), + .external(name: "Workflow"), + ] ), .unitTest( for: "WorkflowTesting", diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift index 8c3b5a85c..4fd2af96a 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift @@ -87,7 +87,7 @@ class RootWorkflowTests: XCTestCase { // First rendering is just the welcome screen. Update the name. do { - let backStack = workflowHost.rendering.value + let backStack = workflowHost.rendering XCTAssertEqual(1, backStack.items.count) guard let welcomeScreen = backStack.items[0].screen.wrappedScreen as? WelcomeScreen else { @@ -100,7 +100,7 @@ class RootWorkflowTests: XCTestCase { // Log in and go to the todo list. do { - let backStack = workflowHost.rendering.value + let backStack = workflowHost.rendering XCTAssertEqual(1, backStack.items.count) guard let welcomeScreen = backStack.items[0].screen.wrappedScreen as? WelcomeScreen else { @@ -113,7 +113,7 @@ class RootWorkflowTests: XCTestCase { // Expect the todo list to be rendered. Edit the first todo. do { - let backStack = workflowHost.rendering.value + let backStack = workflowHost.rendering XCTAssertEqual(2, backStack.items.count) guard let _ = backStack.items[0].screen.wrappedScreen as? WelcomeScreen else { @@ -134,7 +134,7 @@ class RootWorkflowTests: XCTestCase { // Selected a todo to edit. Expect the todo edit screen. do { - let backStack = workflowHost.rendering.value + let backStack = workflowHost.rendering XCTAssertEqual(3, backStack.items.count) guard let _ = backStack.items[0].screen.wrappedScreen as? WelcomeScreen else { @@ -158,7 +158,7 @@ class RootWorkflowTests: XCTestCase { // Save the selected todo. do { - let backStack = workflowHost.rendering.value + let backStack = workflowHost.rendering XCTAssertEqual(3, backStack.items.count) guard let _ = backStack.items[0].screen.wrappedScreen as? WelcomeScreen else { @@ -204,7 +204,7 @@ class RootWorkflowTests: XCTestCase { // Expect the todo list. Validate the title was updated. do { - let backStack = workflowHost.rendering.value + let backStack = workflowHost.rendering XCTAssertEqual(2, backStack.items.count) guard let _ = backStack.items[0].screen.wrappedScreen as? WelcomeScreen else { diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 329cd1573..6c1224116 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -14,8 +14,8 @@ * limitations under the License. */ +import Combine import Dispatch -import ReactiveSwift /// Defines a type that receives debug information about a running workflow hierarchy. public protocol WorkflowDebugger { @@ -31,18 +31,31 @@ public protocol WorkflowDebugger { func didUpdate(snapshot: WorkflowHierarchyDebugSnapshot, updateInfo: WorkflowUpdateDebugInfo) } -/// Manages an active workflow hierarchy. -public final class WorkflowHost { - private let (outputEvent, outputEventObserver) = Signal.pipe() +/// This protocol provides a way for an output extension to be added to both WorkflowHost and WorkflowHostingController in WorkflowReactiveSwift. +public protocol _WorkflowOutputPublisher { + associatedtype Output + + var outputPublisher: AnyPublisher { get } +} +/// Manages an active workflow hierarchy. +public final class WorkflowHost: _WorkflowOutputPublisher { // @testable let rootNode: WorkflowNode - private let mutableRendering: MutableProperty + private let renderingSubject: CurrentValueSubject + private let outputSubject = PassthroughSubject() /// Represents the `Rendering` produced by the root workflow in the hierarchy. New `Rendering` values are produced /// as state transitions occur within the hierarchy. - public let rendering: Property + public var rendering: WorkflowType.Rendering { + renderingSubject.value + } + + /// A Publisher containing rendering events produced by the root workflow in the hierarchy. + public var renderingPublisher: AnyPublisher { + renderingSubject.eraseToAnyPublisher() + } /// Context object to pass down to descendant nodes in the tree. let context: HostContext @@ -88,8 +101,8 @@ public final class WorkflowHost { parentSession: nil ) - self.mutableRendering = MutableProperty(rootNode.render()) - self.rendering = Property(mutableRendering) + self.renderingSubject = CurrentValueSubject(rootNode.render()) + rootNode.enableEvents() debugger?.didEnterInitialState(snapshot: rootNode.makeDebugSnapshot()) @@ -130,12 +143,12 @@ public final class WorkflowHost { private func handle(output: WorkflowNode.Output) { let shouldRender = !shouldSkipRenderForOutput(output) if shouldRender { - mutableRendering.value = rootNode.render() + renderingSubject.send(rootNode.render()) } // Always emit an output, regardless of whether a render occurs if let outputEvent = output.outputEvent { - outputEventObserver.send(value: outputEvent) + outputSubject.send(outputEvent) } debugger?.didUpdate( @@ -149,9 +162,9 @@ public final class WorkflowHost { } } - /// A signal containing output events emitted by the root workflow in the hierarchy. - public var output: Signal { - outputEvent + /// A publisher containing output events emitted by the root workflow in the hierarchy. + public var outputPublisher: AnyPublisher { + outputSubject.eraseToAnyPublisher() } } diff --git a/Workflow/Tests/AnyWorkflowTests.swift b/Workflow/Tests/AnyWorkflowTests.swift index fdd448375..cd9ece567 100644 --- a/Workflow/Tests/AnyWorkflowTests.swift +++ b/Workflow/Tests/AnyWorkflowTests.swift @@ -14,7 +14,7 @@ * limitations under the License. */ -import ReactiveSwift +import Combine import XCTest @testable import Workflow @@ -40,19 +40,21 @@ public class AnyWorkflowTests: XCTestCase { let host = WorkflowHost(workflow: OnOutputWorkflow()) let renderingExpectation = expectation(description: "Waiting for rendering") - host.rendering.producer.startWithValues { rendering in + let cancellable = host.renderingPublisher.sink { rendering in if rendering { renderingExpectation.fulfill() } } let outputExpectation = expectation(description: "Waiting for output") - host.output.observeValues { output in + let outputCancellable = host.outputPublisher.sink { output in if output { outputExpectation.fulfill() } } wait(for: [renderingExpectation, outputExpectation], timeout: 1) + cancellable.cancel() + outputCancellable.cancel() } func testOnlyWrapsOnce() { diff --git a/Workflow/Tests/ConcurrencyTests.swift b/Workflow/Tests/ConcurrencyTests.swift index 4ee601a00..dbafe550a 100644 --- a/Workflow/Tests/ConcurrencyTests.swift +++ b/Workflow/Tests/ConcurrencyTests.swift @@ -29,7 +29,7 @@ final class ConcurrencyTests: XCTestCase { var first = true var observedScreen: TestScreen? - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.sink { rendering in if first { expectation.fulfill() first = false @@ -37,22 +37,20 @@ final class ConcurrencyTests: XCTestCase { observedScreen = rendering } - let initialScreen = host.rendering.value + let initialScreen = host.rendering XCTAssertEqual(0, initialScreen.count) initialScreen.update() // This update happens immediately as a new rendering is generated synchronously. - XCTAssertEqual(1, host.rendering.value.count) + XCTAssertEqual(1, host.rendering.count) wait(for: [expectation], timeout: 1.0) guard let screen = observedScreen else { XCTFail("Screen was not updated.") - disposable?.dispose() return } XCTAssertEqual(1, screen.count) - - disposable?.dispose() + cancellable.cancel() } // Events emitted between `render` on a workflow and `enableEvents` are queued and will be delivered asynchronously after rendering is updated. @@ -62,7 +60,7 @@ final class ConcurrencyTests: XCTestCase { let renderingExpectation = expectation(description: "Waiting on rendering values.") var first = true - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { rendering in if first { first = false // Emit an event when the rendering is first received. @@ -72,7 +70,7 @@ final class ConcurrencyTests: XCTestCase { } } - let initialScreen = host.rendering.value + let initialScreen = host.rendering XCTAssertEqual(0, initialScreen.count) // Updating the screen will cause two events - the `update` here, and the update caused by the first time the rendering changes. @@ -80,9 +78,8 @@ final class ConcurrencyTests: XCTestCase { waitForExpectations(timeout: 1) - XCTAssertEqual(2, host.rendering.value.count) - - disposable?.dispose() + XCTAssertEqual(2, host.rendering.count) + cancellable.cancel() } func test_multipleQueuedEvents() { @@ -91,7 +88,7 @@ final class ConcurrencyTests: XCTestCase { let renderingExpectation = expectation(description: "Waiting on rendering values.") var renderingValuesCount = 0 - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { rendering in if renderingValuesCount == 0 { // Emit two events. rendering.update() @@ -107,7 +104,7 @@ final class ConcurrencyTests: XCTestCase { renderingValuesCount += 1 } - let initialScreen = host.rendering.value + let initialScreen = host.rendering XCTAssertEqual(0, initialScreen.count) // Updating the screen will cause three events. @@ -115,9 +112,8 @@ final class ConcurrencyTests: XCTestCase { waitForExpectations(timeout: 1) - XCTAssertEqual(3, host.rendering.value.count) - - disposable?.dispose() + XCTAssertEqual(3, host.rendering.count) + cancellable.cancel() } // A `sink` is invalidated after a single action has been received. However, if the next `render` pass uses a sink @@ -127,20 +123,20 @@ final class ConcurrencyTests: XCTestCase { let host = WorkflowHost(workflow: TestWorkflow()) // Capture the initial screen and corresponding closure that uses the original sink. - let initialScreen = host.rendering.value + let initialScreen = host.rendering XCTAssertEqual(0, initialScreen.count) // Send an action to the workflow. This invalidates this sink, but the next render pass declares a // sink of the same type. initialScreen.update() - let secondScreen = host.rendering.value + let secondScreen = host.rendering XCTAssertEqual(1, secondScreen.count) // Send an action from the original screen and sink. It should be proxied through the most recent sink. initialScreen.update() - let thirdScreen = host.rendering.value + let thirdScreen = host.rendering XCTAssertEqual(2, thirdScreen.count) } @@ -150,14 +146,14 @@ final class ConcurrencyTests: XCTestCase { let host = WorkflowHost(workflow: OneShotWorkflow()) // Capture the initial screen and corresponding closure that uses the original sink. - let initialScreen = host.rendering.value + let initialScreen = host.rendering XCTAssertEqual(0, initialScreen.count) // Send an action to the workflow. This invalidates this sink, but the next render pass declares a // sink of the same type. initialScreen.update() - let secondScreen = host.rendering.value + let secondScreen = host.rendering XCTAssertEqual(1, secondScreen.count) // Calling `update` uses the original sink. Historically this would be expected @@ -169,7 +165,7 @@ final class ConcurrencyTests: XCTestCase { // If the sink *was* still valid, this would be correct. However, it should just fail and be `1` still. // XCTAssertEqual(2, secondScreen.count) // Actual expected result, if we had not fatal errored. - XCTAssertEqual(1, host.rendering.value.count) + XCTAssertEqual(1, host.rendering.count) struct OneShotWorkflow: Workflow { typealias Output = Never @@ -232,7 +228,7 @@ final class ConcurrencyTests: XCTestCase { var first = true let renderingsComplete = expectation(description: "Waiting for renderings") - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { rendering in if first { first = false rendering.update() @@ -241,7 +237,7 @@ final class ConcurrencyTests: XCTestCase { } } - let initialScreen = host.rendering.value + let initialScreen = host.rendering initialScreen.update() waitForExpectations(timeout: 1) @@ -249,21 +245,20 @@ final class ConcurrencyTests: XCTestCase { XCTAssertEqual(2, debugger.snapshots.count) XCTAssertEqual("1", debugger.snapshots[0].stateDescription) XCTAssertEqual("2", debugger.snapshots[1].stateDescription) - - disposable?.dispose() + cancellable.cancel() } func test_childWorkflowsAreSynchronous() { let host = WorkflowHost(workflow: ParentWorkflow()) - let initialScreen = host.rendering.value + let initialScreen = host.rendering XCTAssertEqual(0, initialScreen.count) initialScreen.update() // This update happens immediately as a new rendering is generated synchronously. // Both the child updates from the action (incrementing state by 1) as well as the // parent from the output (incrementing its state by 10) - XCTAssertEqual(11, host.rendering.value.count) + XCTAssertEqual(11, host.rendering.count) struct ParentWorkflow: Workflow { struct State { @@ -318,15 +313,15 @@ final class ConcurrencyTests: XCTestCase { let renderingExpectation = XCTestExpectation() let outputExpectation = XCTestExpectation() - let outDisposable = host.output.signal.observeValues { output in + let outputCancellable = host.outputPublisher.sink { output in outputExpectation.fulfill() } - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.sink { rendering in renderingExpectation.fulfill() } - let screen = host.rendering.value + let screen = host.rendering XCTAssertEqual(0, screen.count) @@ -335,10 +330,10 @@ final class ConcurrencyTests: XCTestCase { wait(for: [renderingExpectation, outputExpectation], timeout: 1.0) - XCTAssertEqual(101, host.rendering.value.count) + XCTAssertEqual(101, host.rendering.count) - disposable?.dispose() - outDisposable?.dispose() + cancellable.cancel() + outputCancellable.cancel() } // Since event pipes are reused for the same type, validate that the `AnyWorkflowAction` @@ -348,18 +343,18 @@ final class ConcurrencyTests: XCTestCase { func test_multipleAnyWorkflowAction_sinksDontOverrideEachOther() { let host = WorkflowHost(workflow: AnyActionWorkflow()) - let initialScreen = host.rendering.value + let initialScreen = host.rendering XCTAssertEqual(0, initialScreen.count) // Update using the first action. initialScreen.updateFirst() - let secondScreen = host.rendering.value + let secondScreen = host.rendering XCTAssertEqual(1, secondScreen.count) // Update using the second action. secondScreen.updateSecond() - XCTAssertEqual(11, host.rendering.value.count) + XCTAssertEqual(11, host.rendering.count) struct AnyActionWorkflow: Workflow { enum Output { diff --git a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift index bce1f2276..fc005d125 100644 --- a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift +++ b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift @@ -33,16 +33,16 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { let host = WorkflowHost(workflow: CounterWorkflow()) var renderCount = 0 - let disposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - defer { disposable?.dispose() } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + defer { cancellable.cancel() } XCTAssertEqual(renderCount, 0) - XCTAssertEqual(host.rendering.value.count, 0) + XCTAssertEqual(host.rendering.count, 0) - host.rendering.value.noOpAction() + host.rendering.noOpAction() XCTAssertEqual(renderCount, 0) - XCTAssertEqual(host.rendering.value.count, 0) + XCTAssertEqual(host.rendering.count, 0) } func test_reRendersWhenNoStateChangeAndRenderOnlyIfStateChangedDisabled() { @@ -50,16 +50,16 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { let host = WorkflowHost(workflow: CounterWorkflow()) var renderCount = 0 - let disposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - defer { disposable?.dispose() } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + defer { cancellable.cancel() } XCTAssertEqual(renderCount, 0) - XCTAssertEqual(host.rendering.value.count, 0) + XCTAssertEqual(host.rendering.count, 0) - host.rendering.value.noOpAction() + host.rendering.noOpAction() XCTAssertEqual(renderCount, 1) - XCTAssertEqual(host.rendering.value.count, 0) + XCTAssertEqual(host.rendering.count, 0) } } @@ -67,16 +67,16 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { let host = WorkflowHost(workflow: CounterWorkflow()) var renderCount = 0 - let disposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - defer { disposable?.dispose() } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + defer { cancellable.cancel() } XCTAssertEqual(renderCount, 0) - XCTAssertEqual(host.rendering.value.count, 0) + XCTAssertEqual(host.rendering.count, 0) - host.rendering.value.incrementAction() + host.rendering.incrementAction() XCTAssertEqual(renderCount, 1) - XCTAssertEqual(host.rendering.value.count, 1) + XCTAssertEqual(host.rendering.count, 1) } func test_skipsRenderWithVoidStateAndPropertyAccess() { @@ -85,16 +85,16 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { var renderCount = 0 var outputs: [Int] = [] - let renderDisposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - defer { renderDisposable?.dispose() } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + defer { cancellable.cancel() } - let outputDisposable = host.output.observeValues { outputs.append($0) } - defer { outputDisposable?.dispose() } + let outputCancellable = host.outputPublisher.sink(receiveValue: { outputs.append($0) }) + defer { outputCancellable.cancel() } XCTAssertEqual(renderCount, 0) XCTAssertEqual(outputs, []) - host.rendering.value.action() + host.rendering.action() XCTAssertEqual(renderCount, 0) XCTAssertEqual(outputs, [42]) @@ -104,12 +104,12 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { let host = WorkflowHost(workflow: ParentWorkflow()) var renderCount = 0 - let disposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - defer { disposable?.dispose() } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + defer { cancellable.cancel() } XCTAssertEqual(renderCount, 0) - host.rendering.value.childAction() + host.rendering.childAction() XCTAssertEqual(renderCount, 1) } @@ -118,8 +118,8 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { let host = WorkflowHost(workflow: CounterWorkflow()) var renderCount = 0 - let disposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - defer { disposable?.dispose() } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + defer { cancellable.cancel() } XCTAssertEqual(renderCount, 0) @@ -134,16 +134,16 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { var renderCount = 0 var outputCount = 0 - let renderDisposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - defer { renderDisposable?.dispose() } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + defer { cancellable.cancel() } - let outputDisposable = host.output.observeValues { _ in outputCount += 1 } - defer { outputDisposable?.dispose() } + let outputCancellable = host.outputPublisher.sink(receiveValue: { _ in outputCount += 1 }) + defer { outputCancellable.cancel() } XCTAssertEqual(renderCount, 0) XCTAssertEqual(outputCount, 0) - host.rendering.value.emitOutputAction() + host.rendering.emitOutputAction() XCTAssertEqual(renderCount, 0) XCTAssertEqual(outputCount, 1) @@ -151,17 +151,17 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { func test_eventPipesWorkWhenRenderSkipped() { let host = WorkflowHost(workflow: CounterWorkflow()) - let initialRendering = host.rendering.value + let initialRendering = host.rendering var renderCount = 0 var outputCount = 0 - let renderDisposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - let outputDisposable = host.output.observeValues { _ in outputCount += 1 } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + let outputCancellable = host.outputPublisher.sink(receiveValue: { _ in outputCount += 1 }) defer { - renderDisposable?.dispose() - outputDisposable?.dispose() + cancellable.cancel() + outputCancellable.cancel() } XCTAssertEqual(outputCount, 0) @@ -180,17 +180,17 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { func test_eventPipesWorkWhenRenderNotSkipped() { let host = WorkflowHost(workflow: CounterWorkflow()) - let initialRendering = host.rendering.value + let initialRendering = host.rendering var renderCount = 0 var outputCount = 0 - let renderDisposable = host.rendering.signal.observeValues { _ in renderCount += 1 } - let outputDisposable = host.output.observeValues { _ in outputCount += 1 } + let cancellable = host.renderingPublisher.dropFirst().sink(receiveValue: { _ in renderCount += 1 }) + let outputCancellable = host.outputPublisher.sink(receiveValue: { _ in outputCount += 1 }) defer { - renderDisposable?.dispose() - outputDisposable?.dispose() + cancellable.cancel() + outputCancellable.cancel() } XCTAssertEqual(outputCount, 0) diff --git a/Workflow/Tests/StateMutationSinkTests.swift b/Workflow/Tests/StateMutationSinkTests.swift index 55e8976c0..8cf38ad36 100644 --- a/Workflow/Tests/StateMutationSinkTests.swift +++ b/Workflow/Tests/StateMutationSinkTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import ReactiveSwift import Workflow import XCTest @@ -28,14 +29,14 @@ final class StateMutationSinkTests: XCTestCase { func test_initialValue() { let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output)) - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) } func test_singleUpdate() { let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output)) let gotValueExpectation = expectation(description: "Got expected value") - host.rendering.producer.startWithValues { val in + let cancellable = host.renderingPublisher.sink { val in if val == 100 { gotValueExpectation.fulfill() } @@ -43,6 +44,7 @@ final class StateMutationSinkTests: XCTestCase { input.send(value: 100) waitForExpectations(timeout: 1, handler: nil) + cancellable.cancel() } func test_multipleUpdates() { @@ -51,7 +53,7 @@ final class StateMutationSinkTests: XCTestCase { let gotValueExpectation = expectation(description: "Got expected value") var values: [Int] = [] - host.rendering.producer.startWithValues { val in + let cancellable = host.renderingPublisher.sink { val in values.append(val) if val == 300 { gotValueExpectation.fulfill() @@ -63,6 +65,7 @@ final class StateMutationSinkTests: XCTestCase { input.send(value: 300) XCTAssertEqual(values, [0, 100, 200, 300]) waitForExpectations(timeout: 1, handler: nil) + cancellable.cancel() } fileprivate struct TestWorkflow: Workflow { diff --git a/Workflow/Tests/SubtreeManagerTests.swift b/Workflow/Tests/SubtreeManagerTests.swift index 9d0687d1a..d4dad8a16 100644 --- a/Workflow/Tests/SubtreeManagerTests.swift +++ b/Workflow/Tests/SubtreeManagerTests.swift @@ -15,7 +15,6 @@ */ import IssueReporting -import ReactiveSwift import XCTest @testable import Workflow diff --git a/Workflow/Tests/WorkflowHostTests.swift b/Workflow/Tests/WorkflowHostTests.swift index e324f85c5..637ace05a 100644 --- a/Workflow/Tests/WorkflowHostTests.swift +++ b/Workflow/Tests/WorkflowHostTests.swift @@ -24,11 +24,11 @@ final class WorkflowHostTests: XCTestCase { func test_updatedInputCausesRenderPass() { let host = WorkflowHost(workflow: TestWorkflow(step: .first)) - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) host.update(workflow: TestWorkflow(step: .second)) - XCTAssertEqual(2, host.rendering.value) + XCTAssertEqual(2, host.rendering) } fileprivate struct TestWorkflow: Workflow { @@ -62,18 +62,15 @@ final class WorkflowHost_EventEmissionTests: XCTestCase { // Previous versions of Workflow would fatalError under this scenario func test_event_sent_to_invalidated_sink_during_action_handling() { let host = WorkflowHost(workflow: Parent()) - let (lifetime, token) = ReactiveSwift.Lifetime.make() - defer { _ = token } - let initialRendering = host.rendering.value + let initialRendering = host.rendering var observedRenderCount = 0 XCTAssertEqual(initialRendering.eventCount, 0) - host - .rendering - .signal - .take(during: lifetime) - .observeValues { rendering in + let cancelable = host + .renderingPublisher + .dropFirst() + .sink { rendering in XCTAssertEqual(rendering.eventCount, 1) // emit another event using an old rendering @@ -95,9 +92,10 @@ final class WorkflowHost_EventEmissionTests: XCTestCase { drainMainQueueBySpinningRunLoop() // Ensure the invalidated sink doesn't process the event - let nextRendering = host.rendering.value + let nextRendering = host.rendering XCTAssertEqual(nextRendering.eventCount, 1) XCTAssertEqual(observedRenderCount, 1) + cancelable.cancel() } func test_reentrant_event_during_render() { @@ -108,20 +106,17 @@ final class WorkflowHost_EventEmissionTests: XCTestCase { WorkflowHost(workflow: ReentrancyWorkflow()) } - let (lifetime, token) = ReactiveSwift.Lifetime.make() - defer { _ = token } - let initialRendering = host.rendering.value + let initialRendering = host.rendering var emitReentrantEvent = false let renderExpectation = expectation(description: "render") renderExpectation.expectedFulfillmentCount = 2 - host - .rendering - .signal - .take(during: lifetime) - .observeValues { val in + let cancelable = host + .renderingPublisher + .dropFirst() + .sink { val in defer { renderExpectation.fulfill() } defer { emitReentrantEvent = true } guard !emitReentrantEvent else { return } @@ -145,6 +140,8 @@ final class WorkflowHost_EventEmissionTests: XCTestCase { initialRendering.sink.send(.event) waitForExpectations(timeout: 1) + + cancelable.cancel() } } @@ -205,7 +202,7 @@ struct WorkflowHost_SinkEventHandlerTests { ) } - let rendering = host.rendering.value + let rendering = host.rendering let eventHandler = host.sinkEventHandler #expect(eventHandler.state == .ready) @@ -254,7 +251,7 @@ struct WorkflowHost_SinkEventHandlerTests { ) } - let rendering = host.rendering.value + let rendering = host.rendering let eventHandler = host.sinkEventHandler var didEmit = false diff --git a/WorkflowCombine/Tests/PublisherTests.swift b/WorkflowCombine/Tests/PublisherTests.swift index e4984571b..5021ecfdd 100644 --- a/WorkflowCombine/Tests/PublisherTests.swift +++ b/WorkflowCombine/Tests/PublisherTests.swift @@ -36,7 +36,7 @@ class PublisherTests: XCTestCase { let expectation = XCTestExpectation() var outputValue: Int? - let disposable = host.output.signal.observeValues { output in + let cancellable = host.outputPublisher.sink { output in outputValue = output expectation.fulfill() } @@ -44,7 +44,7 @@ class PublisherTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual(1, outputValue) - disposable?.dispose() + cancellable.cancel() } func test_multipleOutputs() { @@ -56,7 +56,7 @@ class PublisherTests: XCTestCase { let expectation = XCTestExpectation() var outputValues = [Int]() - let disposable = host.output.signal.observeValues { output in + let cancellable = host.outputPublisher.sink { output in outputValues.append(output) expectation.fulfill() } @@ -64,7 +64,7 @@ class PublisherTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual([1, 2, 3], outputValues) - disposable?.dispose() + cancellable.cancel() } func test_publisher_isDisposedIfNotUsedInWorkflow() { diff --git a/WorkflowCombine/Tests/WorkerTests.swift b/WorkflowCombine/Tests/WorkerTests.swift index 96fc5e1a9..2b5e66716 100644 --- a/WorkflowCombine/Tests/WorkerTests.swift +++ b/WorkflowCombine/Tests/WorkerTests.swift @@ -42,16 +42,17 @@ class WorkerTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.rendering.signal.observeValues { rendering in + + let cancellable = host.renderingPublisher.dropFirst().sink { rendering in expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() } // A worker declared on a first `render` pass that is not on a subsequent should have the work cancelled. @@ -154,7 +155,7 @@ class WorkerTests: XCTestCase { let host = WorkflowHost(workflow: WF()) var outputs: [Int] = [] - host.output.signal.observeValues { output in + let cancellable = host.outputPublisher.sink { output in outputs.append(output) if outputs.count == 2 { @@ -165,6 +166,7 @@ class WorkerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) XCTAssertEqual(outputs, [1, 2]) + cancellable.cancel() } } diff --git a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift index 8156eea55..93d4262ad 100644 --- a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift @@ -26,16 +26,16 @@ final class AsyncOperationWorkerTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() } func testAsyncWorkerRunsOnlyOnce() { @@ -44,19 +44,19 @@ final class AsyncOperationWorkerTests: XCTestCase { ) var expectation = XCTestExpectation() - var disposable = host.rendering.signal.observeValues { rendering in + var cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } @@ -66,9 +66,9 @@ final class AsyncOperationWorkerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) // If the render value is 1 then the state has not been incremented // by running the worker's async operation again. - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() } func testCancelAsyncOperationWorker() { diff --git a/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift b/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift index c829412aa..5721f451d 100644 --- a/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift @@ -10,16 +10,16 @@ class AsyncSequenceWorkerTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value.intValue) + XCTAssertEqual(0, host.rendering.intValue) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(1, host.rendering.value.intValue) + XCTAssertEqual(1, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() } func testNotEquivalentWorker() { @@ -32,49 +32,49 @@ class AsyncSequenceWorkerTests: XCTestCase { // Set to observe renderings. // This expectation should be called after the IntWorker runs // and updates the state. - var disposable = host.rendering.signal.observeValues { rendering in + var cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Test to make sure the initial state of the workflow is correct. - XCTAssertEqual(0, host.rendering.value.intValue) + XCTAssertEqual(0, host.rendering.intValue) // Wait for the worker to run. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering after the worker runs is correct. - XCTAssertEqual(1, host.rendering.value.intValue) + XCTAssertEqual(1, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings. // This expectation should be called after the add one action is sent. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Send an addOne action to add 1 to the state. - host.rendering.value.addOne() + host.rendering.addOne() // Wait for the action to trigger a render. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering equals 2 now that the action has run. - XCTAssertEqual(2, host.rendering.value.intValue) + XCTAssertEqual(2, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings // Since isEquivalent is set to false in the worker // the worker should run again and update the rendering. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Wait for worker to run. wait(for: [expectation], timeout: 1) // Verify the rendering changed after the worker is run. - XCTAssertEqual(1, host.rendering.value.intValue) + XCTAssertEqual(1, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() } func testEquivalentWorker() { @@ -87,41 +87,41 @@ class AsyncSequenceWorkerTests: XCTestCase { // Set to observe renderings. // This expectation should be called after the IntWorker runs // and updates the state. - var disposable = host.rendering.signal.observeValues { rendering in + var cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Test to make sure the initial state of the workflow is correct. - XCTAssertEqual(0, host.rendering.value.intValue) + XCTAssertEqual(0, host.rendering.intValue) // Wait for the worker to run. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering after the worker runs is correct. - XCTAssertEqual(1, host.rendering.value.intValue) + XCTAssertEqual(1, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings. // This expectation should be called after the add one action is sent. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Send an addOne action to add 1 to the state. - host.rendering.value.addOne() + host.rendering.addOne() // Wait for the action to trigger a render. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering equals 2 now that the action has run. - XCTAssertEqual(2, host.rendering.value.intValue) + XCTAssertEqual(2, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() // Set to observe renderings // This expectation should be called after the workflow is updated. // After the host is updated with a new workflow instance the // initial state should be 2. expectation = XCTestExpectation() - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } @@ -130,15 +130,15 @@ class AsyncSequenceWorkerTests: XCTestCase { // Wait for the workflow to render after being updated. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering matches the existing state. - XCTAssertEqual(2, host.rendering.value.intValue) + XCTAssertEqual(2, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() // The workflow should not produce another rendering. expectation = XCTestExpectation() // The expectation is inverted because there should not be another rendering // since the worker returned isEquivalent is true. expectation.isInverted = true - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in // This should not be called! expectation.fulfill() } @@ -146,9 +146,9 @@ class AsyncSequenceWorkerTests: XCTestCase { // Wait to see if the expection is fullfulled. wait(for: [expectation], timeout: 1) // Verify the rendering didn't change and is still 2. - XCTAssertEqual(2, host.rendering.value.intValue) + XCTAssertEqual(2, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() } func testChangingIsEquivalent() { @@ -161,41 +161,41 @@ class AsyncSequenceWorkerTests: XCTestCase { // Set to observe renderings. // This expectation should be called after the IntWorker runs and // updates the state. - var disposable = host.rendering.signal.observeValues { rendering in + var cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Test to make sure the initial state of the workflow is correct. - XCTAssertEqual(0, host.rendering.value.intValue) + XCTAssertEqual(0, host.rendering.intValue) // Wait for the worker to run. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering after the worker runs is correct. - XCTAssertEqual(1, host.rendering.value.intValue) + XCTAssertEqual(1, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings. // This expectation should be called after the add one action is sent. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Send an addOne action to add 1 to the state. - host.rendering.value.addOne() + host.rendering.addOne() // Wait for the action to trigger a render. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering equals 2 now that the action has run. - XCTAssertEqual(2, host.rendering.value.intValue) + XCTAssertEqual(2, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() // Set to observe renderings. // This expectation should be called after the workflow is updated. // After the host is updated with a new workflow instance the // initial state should be 2. expectation = XCTestExpectation() - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } @@ -204,23 +204,23 @@ class AsyncSequenceWorkerTests: XCTestCase { // Wait for the workflow to render after being updated. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering matches the existing state. - XCTAssertEqual(2, host.rendering.value.intValue) + XCTAssertEqual(2, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings // Since isEquivalent is set to false in the worker // the worker should run again and update the rendering. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Wait for worker to run. wait(for: [expectation], timeout: 1) // Verify the rendering changed after the worker is run. - XCTAssertEqual(1, host.rendering.value.intValue) + XCTAssertEqual(1, host.rendering.intValue) - disposable?.dispose() + cancellable.cancel() } func testContinuousIntWorker() { @@ -231,18 +231,18 @@ class AsyncSequenceWorkerTests: XCTestCase { let expectation = XCTestExpectation() expectation.expectedFulfillmentCount = 5 var expectedInt = 0 - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { rendering in expectedInt += 1 XCTAssertEqual(expectedInt, rendering) expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(expectedInt, host.rendering.value) + XCTAssertEqual(expectedInt, host.rendering) - disposable?.dispose() + cancellable.cancel() } func testExpectedWorker() { diff --git a/WorkflowConcurrency/Tests/WorkerTests.swift b/WorkflowConcurrency/Tests/WorkerTests.swift index c969ff103..6b80145df 100644 --- a/WorkflowConcurrency/Tests/WorkerTests.swift +++ b/WorkflowConcurrency/Tests/WorkerTests.swift @@ -26,16 +26,16 @@ class WorkerTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() } func testWorkflowUpdate() { @@ -48,26 +48,26 @@ class WorkerTests: XCTestCase { // Set to observe renderings // This expectation should be called after the TaskTestWorker runs and // updates the state. - var disposable = host.rendering.signal.observeValues { rendering in + var cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Test to make sure the initial state of the workflow is correct. - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) // Wait for the worker to run. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering after the worker runs is correct. - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings // This expectation should be called after the workflow is updated. // After the host is updated with a new workflow instance the // initial state should be 1. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } @@ -77,20 +77,22 @@ class WorkerTests: XCTestCase { // Wait for the workflow to render after being updated. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering matches the initial state. - XCTAssertEqual(7, host.rendering.value) + XCTAssertEqual(7, host.rendering) + + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings // This expectation should be called when the worker runs. // The worker isEquivalent is false because we have changed the initialState. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Wait for the worker to trigger a rendering. wait(for: [expectation], timeout: 1.0) // Check to make sure the rendering is correct. - XCTAssertEqual(8, host.rendering.value) + XCTAssertEqual(8, host.rendering) } func testWorkflowKeyChange() { @@ -103,26 +105,26 @@ class WorkerTests: XCTestCase { // Set to observe renderings // This expectation should be called after the TaskTestWorker runs and // updates the state. - var disposable = host.rendering.signal.observeValues { rendering in + var cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } // Test to make sure the initial state of the workflow is correct. - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) // Wait for the worker to run. wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering after the worker runs is correct. - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() expectation = XCTestExpectation() // Set to observe renderings // This expectation should be called after the workflow is updated. // After the host is updated with a new workflow instance the // initial state should be 1. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } @@ -133,13 +135,13 @@ class WorkerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) // Test to make sure the rendering matches the existing state // since the inititalState didn't change. - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) expectation = XCTestExpectation() // Set to observe renderings // This expectation should be called when the worker runs. // The worker should run because the key was changed for the workflow. - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } @@ -147,7 +149,7 @@ class WorkerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) // Check to make sure the rendering is correct. // The worker adds one to the initialState so this should be 1. - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) } func testExpectedWorker() { diff --git a/WorkflowReactiveSwift/Sources/WorkflowOutputPublisher+OutputSignal.swift b/WorkflowReactiveSwift/Sources/WorkflowOutputPublisher+OutputSignal.swift new file mode 100644 index 000000000..9e61bbce2 --- /dev/null +++ b/WorkflowReactiveSwift/Sources/WorkflowOutputPublisher+OutputSignal.swift @@ -0,0 +1,27 @@ +import Combine +import ReactiveSwift +import Workflow + +extension _WorkflowOutputPublisher { + public var output: Signal { + Signal.unserialized { observer, lifetime in + let cancellable = outputPublisher.sink( + receiveCompletion: { completion in + switch completion { + case .finished: + observer.sendCompleted() + + case .failure(let error): + observer.send(error: error) + } + }, + receiveValue: { value in + observer.send(value: value) + } + ) + lifetime.observeEnded { + cancellable.cancel() + } + } + } +} diff --git a/WorkflowReactiveSwift/Tests/WorkerTests.swift b/WorkflowReactiveSwift/Tests/WorkerTests.swift index a18b60b52..91f52932d 100644 --- a/WorkflowReactiveSwift/Tests/WorkerTests.swift +++ b/WorkflowReactiveSwift/Tests/WorkerTests.swift @@ -43,16 +43,16 @@ class WorkerTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() } // A worker declared on a first `render` pass that is not on a subsequent should have the work cancelled. diff --git a/WorkflowRxSwift/Tests/WorkerTests.swift b/WorkflowRxSwift/Tests/WorkerTests.swift index d865a51b6..49356a5a2 100644 --- a/WorkflowRxSwift/Tests/WorkerTests.swift +++ b/WorkflowRxSwift/Tests/WorkerTests.swift @@ -44,16 +44,16 @@ class WorkerTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { _ in expectation.fulfill() } - XCTAssertEqual(0, host.rendering.value) + XCTAssertEqual(0, host.rendering) wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(1, host.rendering.value) + XCTAssertEqual(1, host.rendering) - disposable?.dispose() + cancellable.cancel() } // A worker declared on a first `render` pass that is not on a subsequent should have the work cancelled. diff --git a/WorkflowSwiftUI/Sources/Workflow+Preview.swift b/WorkflowSwiftUI/Sources/Workflow+Preview.swift index 67631d6a5..456683f6d 100644 --- a/WorkflowSwiftUI/Sources/Workflow+Preview.swift +++ b/WorkflowSwiftUI/Sources/Workflow+Preview.swift @@ -1,8 +1,8 @@ #if canImport(UIKit) #if DEBUG +import Combine import Foundation -import ReactiveSwift import SwiftUI import Workflow import WorkflowUI @@ -49,8 +49,8 @@ private struct PreviewView: UIViewControllerRepresentabl ) let coordinator = context.coordinator - coordinator.outputDisposable?.dispose() - coordinator.outputDisposable = controller.output.observeValues(onOutput) + coordinator.outputCancellable?.cancel() + coordinator.outputCancellable = controller.outputPublisher.sink(receiveValue: onOutput) return controller } @@ -61,8 +61,8 @@ private struct PreviewView: UIViewControllerRepresentabl ) { let coordinator = context.coordinator - coordinator.outputDisposable?.dispose() - coordinator.outputDisposable = controller.output.observeValues(onOutput) + coordinator.outputCancellable?.cancel() + coordinator.outputCancellable = controller.outputPublisher.sink(receiveValue: onOutput) controller.customizeEnvironment = customizeEnvironment controller.update(workflow: workflow) @@ -72,10 +72,10 @@ private struct PreviewView: UIViewControllerRepresentabl Coordinator() } - // This coordinator allows us to manage the lifetime of the WorkflowHostingController's `output` - // signal observation that's used to provide an `onOutput` callback to consumers. + // This coordinator allows us to manage the lifetime of the WorkflowHostingController's `outputPublisher` + // publisher observation that's used to provide an `onOutput` callback to consumers. final class Coordinator { - var outputDisposable: Disposable? + var outputCancellable: AnyCancellable? } } diff --git a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift index bad7fd9dd..48c4f71e1 100644 --- a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift +++ b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift @@ -16,18 +16,18 @@ #if canImport(UIKit) -import ReactiveSwift +import Combine import UIKit @_spi(ViewEnvironmentWiring) import ViewEnvironmentUI import Workflow /// Drives view controllers from a root Workflow. -public final class WorkflowHostingController: WorkflowUIViewController where ScreenType: Screen { +public final class WorkflowHostingController: WorkflowUIViewController, _WorkflowOutputPublisher where ScreenType: Screen { public typealias CustomizeEnvironment = (inout ViewEnvironment) -> Void /// Emits output events from the bound workflow. - public var output: Signal { - workflowHost.output + public var outputPublisher: AnyPublisher { + workflowHost.outputPublisher } /// An environment customization that will be applied to the environment of the root screen. @@ -37,14 +37,14 @@ public final class WorkflowHostingController: WorkflowUIView /// The currently displayed screen - the most recent rendering from the hosted workflow public var screen: ScreenType { - workflowHost.rendering.value + workflowHost.rendering } private(set) var rootViewController: UIViewController private let workflowHost: WorkflowHost> - private let (lifetime, token) = Lifetime.make() + private var cancellable: AnyCancellable? private var lastEnvironmentAncestorPath: EnvironmentAncestorPath? @@ -65,7 +65,6 @@ public final class WorkflowHostingController: WorkflowUIView self.rootViewController = workflowHost .rendering - .value .viewControllerDescription(environment: customizedEnvironment) .buildViewController() @@ -79,18 +78,17 @@ public final class WorkflowHostingController: WorkflowUIView addChild(rootViewController) rootViewController.didMove(toParent: self) - workflowHost - .rendering - .signal - .take(during: lifetime) - .observeValues { [weak self] screen in + self.cancellable = workflowHost + .renderingPublisher + .dropFirst() + .sink(receiveValue: { [weak self] screen in guard let self else { return } update( screen: screen, environmentAncestorPath: environmentAncestorPath ) - } + }) } /// Updates the root Workflow in this container. @@ -138,7 +136,7 @@ public final class WorkflowHostingController: WorkflowUIView let environmentAncestorPath = environmentAncestorPath if environmentAncestorPath != lastEnvironmentAncestorPath { update( - screen: workflowHost.rendering.value, + screen: workflowHost.rendering, environmentAncestorPath: environmentAncestorPath ) } @@ -203,7 +201,7 @@ extension WorkflowHostingController: ViewEnvironmentObserving { public func environmentDidChange() { update( - screen: workflowHost.rendering.value, + screen: workflowHost.rendering, environmentAncestorPath: environmentAncestorPath ) } @@ -213,7 +211,7 @@ extension WorkflowHostingController: ViewEnvironmentObserving { extension WorkflowHostingController: SingleScreenContaining { public var primaryScreen: any Screen { - workflowHost.rendering.value + workflowHost.rendering } } diff --git a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift index 3eea06dcf..749f96510 100644 --- a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift +++ b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift @@ -86,7 +86,7 @@ class WorkflowHostingControllerTests: XCTestCase { let expectation = XCTestExpectation(description: "Output") - let disposable = container.output.observeValues { value in + let cancellable = container.outputPublisher.sink { value in XCTAssertEqual(3, value) expectation.fulfill() } @@ -95,7 +95,7 @@ class WorkflowHostingControllerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } func test_container_with_anyworkflow() { @@ -105,7 +105,7 @@ class WorkflowHostingControllerTests: XCTestCase { let expectation = XCTestExpectation(description: "Output") - let disposable = container.output.observeValues { value in + let cancellable = container.outputPublisher.sink { value in XCTAssertEqual(3, value) expectation.fulfill() } @@ -114,7 +114,7 @@ class WorkflowHostingControllerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } func test_container_update_causes_rerender() { @@ -148,7 +148,7 @@ class WorkflowHostingControllerTests: XCTestCase { let expectation = XCTestExpectation(description: "Second output") // First output comes before we subscribe - let disposable = container.output.observeValues { value in + let cancellable = container.outputPublisher.sink { value in XCTAssertEqual(3, value) expectation.fulfill() } @@ -158,7 +158,7 @@ class WorkflowHostingControllerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } func test_environment_bridging() throws {