diff --git a/Package.swift b/Package.swift index a79577081..5ecff8d8c 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( .macOS(.v12), .watchOS(.v8), .macCatalyst(.v16), - .tvOS(.v12), + .tvOS(.v13), ], products: [ // MARK: Workflow @@ -71,7 +71,6 @@ let package = Package( name: "Workflow", dependencies: [ .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - .product(name: "ReactiveSwift", package: "ReactiveSwift"), ], path: "Workflow/Sources" ), 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/Samples/Tutorial/Tutorial5.md b/Samples/Tutorial/Tutorial5.md index 5181012dc..f3d3c8589 100644 --- a/Samples/Tutorial/Tutorial5.md +++ b/Samples/Tutorial/Tutorial5.md @@ -659,7 +659,7 @@ Add another test to `RootWorkflowTests`. We will run the tree of workflows in a // 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 { @@ -672,7 +672,7 @@ Add another test to `RootWorkflowTests`. We will run the tree of workflows in a // 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 { @@ -685,7 +685,7 @@ Add another test to `RootWorkflowTests`. We will run the tree of workflows in a // 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 { @@ -706,7 +706,7 @@ Add another test to `RootWorkflowTests`. We will run the tree of workflows in a // 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 { @@ -730,7 +730,7 @@ Add another test to `RootWorkflowTests`. We will run the tree of workflows in a // 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 { @@ -779,7 +779,7 @@ Add another test to `RootWorkflowTests`. We will run the tree of workflows in a // 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..813f5f6de 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 { @@ -33,16 +33,25 @@ public protocol WorkflowDebugger { /// Manages an active workflow hierarchy. public final class WorkflowHost { - private let (outputEvent, outputEventObserver) = Signal.pipe() + private let outputSubject = PassthroughSubject() // @testable let rootNode: WorkflowNode - private let mutableRendering: MutableProperty + private let renderingSubject: CurrentValueSubject - /// Represents the `Rendering` produced by the root workflow in the hierarchy. New `Rendering` values are produced + /// The current `Rendering` produced by the root workflow in the hierarchy. A new `Rendering` value is produced /// as state transitions occur within the hierarchy. - public let rendering: Property + public var rendering: WorkflowType.Rendering { + renderingSubject.value + } + + /// A publisher of the `Rendering` values produced by the root workflow in the hierarchy. Emits the current + /// `Rendering` when subscribed to, followed by a new value after each subsequent render pass. Use + /// `dropFirst()` to observe only changes. + public var renderingPublisher: AnyPublisher { + renderingSubject.eraseToAnyPublisher() + } /// Context object to pass down to descendant nodes in the tree. let context: HostContext @@ -88,8 +97,7 @@ 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()) @@ -99,6 +107,11 @@ public final class WorkflowHost { } } + deinit { + renderingSubject.send(completion: .finished) + outputSubject.send(completion: .finished) + } + /// Update the input for the workflow. Will cause a render pass. public func update(workflow: WorkflowType) { if context.runtimeConfig.useSinkEventHandler { @@ -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 of the 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..3c4faeb68 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,18 +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 renderingCancellable = host.renderingPublisher.dropFirst().sink { rendering in if rendering { renderingExpectation.fulfill() } } + defer { renderingCancellable.cancel() } let outputExpectation = expectation(description: "Waiting for output") - host.output.observeValues { output in + let outputCancellable = host.outputPublisher.sink { output in if output { outputExpectation.fulfill() } } + defer { outputCancellable.cancel() } + wait(for: [renderingExpectation, outputExpectation], timeout: 1) } diff --git a/Workflow/Tests/ConcurrencyTests.swift b/Workflow/Tests/ConcurrencyTests.swift index 4ee601a00..400ca8743 100644 --- a/Workflow/Tests/ConcurrencyTests.swift +++ b/Workflow/Tests/ConcurrencyTests.swift @@ -14,12 +14,10 @@ * limitations under the License. */ -import class Workflow.Lifetime +import Combine import XCTest @testable import Workflow -import ReactiveSwift - final class ConcurrencyTests: XCTestCase { // Applying an action from a sink must synchronously update the rendering. func test_sinkRenderLoopIsSynchronous() { @@ -29,7 +27,7 @@ final class ConcurrencyTests: XCTestCase { var first = true var observedScreen: TestScreen? - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { rendering in if first { expectation.fulfill() first = false @@ -37,22 +35,22 @@ 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() + cancellable.cancel() 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,9 @@ final class ConcurrencyTests: XCTestCase { waitForExpectations(timeout: 1) - XCTAssertEqual(2, host.rendering.value.count) + XCTAssertEqual(2, host.rendering.count) - disposable?.dispose() + cancellable.cancel() } func test_multipleQueuedEvents() { @@ -91,7 +89,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 +105,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 +113,9 @@ final class ConcurrencyTests: XCTestCase { waitForExpectations(timeout: 1) - XCTAssertEqual(3, host.rendering.value.count) + XCTAssertEqual(3, host.rendering.count) - disposable?.dispose() + cancellable.cancel() } // A `sink` is invalidated after a single action has been received. However, if the next `render` pass uses a sink @@ -127,20 +125,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 +148,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 +167,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 +230,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 +239,7 @@ final class ConcurrencyTests: XCTestCase { } } - let initialScreen = host.rendering.value + let initialScreen = host.rendering initialScreen.update() waitForExpectations(timeout: 1) @@ -250,20 +248,20 @@ final class ConcurrencyTests: XCTestCase { 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 +316,15 @@ final class ConcurrencyTests: XCTestCase { let renderingExpectation = XCTestExpectation() let outputExpectation = XCTestExpectation() - let outDisposable = host.output.signal.observeValues { output in + let outCancellable = host.outputPublisher.sink { output in outputExpectation.fulfill() } - let disposable = host.rendering.signal.observeValues { rendering in + let cancellable = host.renderingPublisher.dropFirst().sink { rendering in renderingExpectation.fulfill() } - let screen = host.rendering.value + let screen = host.rendering XCTAssertEqual(0, screen.count) @@ -335,10 +333,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() + outCancellable.cancel() } // Since event pipes are reused for the same type, validate that the `AnyWorkflowAction` @@ -348,18 +346,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 { @@ -439,12 +437,12 @@ final class ConcurrencyTests: XCTestCase { // MARK: - Test Types fileprivate class TestSignal { - let (signal, observer) = Signal.pipe() + let subject = PassthroughSubject() var sent: Bool = false func send(value: Int) { if !sent { - observer.send(value: value) + subject.send(value) sent = true } } @@ -516,28 +514,25 @@ final class ConcurrencyTests: XCTestCase { case .signal: context.runSideEffect(key: "signal1") { lifetime in - signal.signal - .take(during: lifetime.reactiveLifetime) - .observeValues { _ in - sink.send(.update) - } + let cancellable = signal.subject.sink { _ in + sink.send(.update) + } + lifetime.onEnded { cancellable.cancel() } } case .doubleSubscribing(secondSignal: let signal2): context.runSideEffect(key: "signal2") { lifetime in - signal2.signal - .take(during: lifetime.reactiveLifetime) - .observeValues { _ in - sink.send(.secondUpdate) - } + let cancellable = signal2.subject.sink { _ in + sink.send(.secondUpdate) + } + lifetime.onEnded { cancellable.cancel() } } context.runSideEffect(key: "signal1") { lifetime in - signal.signal - .take(during: lifetime.reactiveLifetime) - .observeValues { _ in - sink.send(.update) - } + let cancellable = signal.subject.sink { _ in + sink.send(.update) + } + lifetime.onEnded { cancellable.cancel() } } } @@ -548,13 +543,3 @@ final class ConcurrencyTests: XCTestCase { } } } - -extension Lifetime { - fileprivate var reactiveLifetime: ReactiveSwift.Lifetime { - let (lifetime, token) = ReactiveSwift.Lifetime.make() - onEnded { - token.dispose() - } - return lifetime - } -} diff --git a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift index bce1f2276..ba69c4a27 100644 --- a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift +++ b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift @@ -14,7 +14,7 @@ * limitations under the License. */ -import ReactiveSwift +import Combine import XCTest @_spi(WorkflowRuntimeConfig) @@ -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 { _ 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 { _ 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 { _ 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 renderCancellable = host.renderingPublisher.dropFirst().sink { _ in renderCount += 1 } + defer { renderCancellable.cancel() } - let outputDisposable = host.output.observeValues { outputs.append($0) } - defer { outputDisposable?.dispose() } + let outputCancellable = host.outputPublisher.sink { 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 { _ 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 { _ 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 renderCancellable = host.renderingPublisher.dropFirst().sink { _ in renderCount += 1 } + defer { renderCancellable.cancel() } - let outputDisposable = host.output.observeValues { _ in outputCount += 1 } - defer { outputDisposable?.dispose() } + let outputCancellable = host.outputPublisher.sink { _ 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 renderCancellable = host.renderingPublisher.dropFirst().sink { _ in renderCount += 1 } + let outputCancellable = host.outputPublisher.sink { _ in outputCount += 1 } defer { - renderDisposable?.dispose() - outputDisposable?.dispose() + renderCancellable.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 renderCancellable = host.renderingPublisher.dropFirst().sink { _ in renderCount += 1 } + let outputCancellable = host.outputPublisher.sink { _ in outputCount += 1 } defer { - renderDisposable?.dispose() - outputDisposable?.dispose() + renderCancellable.cancel() + outputCancellable.cancel() } XCTAssertEqual(outputCount, 0) diff --git a/Workflow/Tests/StateMutationSinkTests.swift b/Workflow/Tests/StateMutationSinkTests.swift index 55e8976c0..06a376335 100644 --- a/Workflow/Tests/StateMutationSinkTests.swift +++ b/Workflow/Tests/StateMutationSinkTests.swift @@ -14,54 +14,55 @@ * limitations under the License. */ -import ReactiveSwift +import Combine import Workflow import XCTest final class StateMutationSinkTests: XCTestCase { - var output: Signal! - var input: Signal.Observer! + var input: PassthroughSubject! override func setUp() { - (output, input) = Signal.pipe() + input = PassthroughSubject() } func test_initialValue() { - let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output)) - XCTAssertEqual(0, host.rendering.value) + let host = WorkflowHost(workflow: TestWorkflow(value: 100, publisher: input.eraseToAnyPublisher())) + XCTAssertEqual(0, host.rendering) } func test_singleUpdate() { - let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output)) + let host = WorkflowHost(workflow: TestWorkflow(value: 100, publisher: input.eraseToAnyPublisher())) let gotValueExpectation = expectation(description: "Got expected value") - host.rendering.producer.startWithValues { val in + let cancellable = host.renderingPublisher.dropFirst().sink { val in if val == 100 { gotValueExpectation.fulfill() } } + defer { cancellable.cancel() } - input.send(value: 100) + input.send(100) waitForExpectations(timeout: 1, handler: nil) } func test_multipleUpdates() { - let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output)) + let host = WorkflowHost(workflow: TestWorkflow(value: 100, publisher: input.eraseToAnyPublisher())) let gotValueExpectation = expectation(description: "Got expected value") var values: [Int] = [] - host.rendering.producer.startWithValues { val in + let cancellable = host.renderingPublisher.dropFirst().sink { val in values.append(val) if val == 300 { gotValueExpectation.fulfill() } } + defer { cancellable.cancel() } - input.send(value: 100) - input.send(value: 200) - input.send(value: 300) - XCTAssertEqual(values, [0, 100, 200, 300]) + input.send(100) + input.send(200) + input.send(300) + XCTAssertEqual(values, [100, 200, 300]) waitForExpectations(timeout: 1, handler: nil) } @@ -70,7 +71,7 @@ final class StateMutationSinkTests: XCTestCase { typealias Rendering = Int let value: Int - let signal: Signal + let publisher: AnyPublisher func makeInitialState() -> Int { 0 @@ -79,11 +80,11 @@ final class StateMutationSinkTests: XCTestCase { func render(state: State, context: RenderContext) -> Rendering { let stateMutationSink = context.makeStateMutationSink() context.runSideEffect(key: "") { lifetime in - let disposable = signal.observeValues { val in + let cancellable = publisher.sink { val in stateMutationSink.send(\State.self, value: val) } lifetime.onEnded { - disposable?.dispose() + cancellable.cancel() } } return state 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..a5147d5d4 100644 --- a/Workflow/Tests/WorkflowHostTests.swift +++ b/Workflow/Tests/WorkflowHostTests.swift @@ -14,7 +14,7 @@ * limitations under the License. */ -import ReactiveSwift +import Combine import Testing import XCTest @@ -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 cancellable = host + .renderingPublisher + .dropFirst() + .sink { rendering in XCTAssertEqual(rendering.eventCount, 1) // emit another event using an old rendering @@ -86,6 +83,7 @@ final class WorkflowHost_EventEmissionTests: XCTestCase { observedRenderCount += 1 } + defer { cancellable.cancel() } // send an event and cause a re-render initialRendering.eventHandler() @@ -95,7 +93,7 @@ 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) } @@ -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 cancellable = 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) + + cancellable.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 @@ -396,3 +393,93 @@ extension WorkflowHost_EventEmissionTests { } } } + +// MARK: Lifecycle Tests + +final class WorkflowHost_LifecycleTests: XCTestCase { + func test_renderingPublisherCompletesWhenHostIsReleased() { + var host: WorkflowHost? = WorkflowHost(workflow: OutputWorkflow()) + + var receivedValueCount = 0 + var receivedCompletion = false + let cancellable = host!.renderingPublisher.sink( + receiveCompletion: { _ in receivedCompletion = true }, + receiveValue: { _ in receivedValueCount += 1 } + ) + defer { cancellable.cancel() } + + // The initial rendering is replayed on subscription. + XCTAssertEqual(receivedValueCount, 1) + XCTAssertFalse(receivedCompletion) + + host = nil + + // The subscription retains the underlying subject, so the host must + // explicitly complete the stream when it deinitializes. + XCTAssertTrue(receivedCompletion) + XCTAssertEqual(receivedValueCount, 1) + } + + func test_outputPublisherCompletesWhenHostIsReleased() { + var host: WorkflowHost? = WorkflowHost(workflow: OutputWorkflow()) + + var receivedOutputs: [Int] = [] + var receivedCompletion = false + let cancellable = host!.outputPublisher.sink( + receiveCompletion: { _ in receivedCompletion = true }, + receiveValue: { receivedOutputs.append($0) } + ) + defer { cancellable.cancel() } + + host!.rendering.sendOutput(42) + XCTAssertEqual(receivedOutputs, [42]) + XCTAssertFalse(receivedCompletion) + + host = nil + + XCTAssertTrue(receivedCompletion) + XCTAssertEqual(receivedOutputs, [42]) + } + + func test_hostIsReleasedWhileSubscriptionsAreRetained() { + var cancellables: [AnyCancellable] = [] + weak var weakHost: WorkflowHost? + + autoreleasepool { + let host = WorkflowHost(workflow: OutputWorkflow()) + weakHost = host + cancellables.append(host.renderingPublisher.sink { _ in }) + cancellables.append(host.outputPublisher.sink { _ in }) + } + + XCTAssertNil(weakHost, "Retained sinks should not keep the host alive") + cancellables.removeAll() + } + + fileprivate struct OutputWorkflow: Workflow { + typealias State = Void + typealias Output = Int + + struct Rendering { + var sendOutput: (Int) -> Void + } + + func render(state: Void, context: RenderContext) -> Rendering { + let sink = context.makeSink(of: Action.self) + return Rendering(sendOutput: { sink.send(Action.emit($0)) }) + } + + enum Action: WorkflowAction { + typealias WorkflowType = OutputWorkflow + + case emit(Int) + + func apply(toState state: inout Void, context: ApplyContext) -> Int? { + switch self { + case .emit(let value): + value + } + } + } + } +} 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..be465c1af 100644 --- a/WorkflowCombine/Tests/WorkerTests.swift +++ b/WorkflowCombine/Tests/WorkerTests.swift @@ -42,16 +42,16 @@ 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,13 +154,14 @@ 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 { expectation.fulfill() } } + defer { cancellable.cancel() } wait(for: [expectation], timeout: 1.0) diff --git a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift index 8156eea55..a4ea70830 100644 --- a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import Workflow import WorkflowTesting import XCTest @@ -26,16 +27,16 @@ final class AsyncOperationWorkerTests: 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() } func testAsyncWorkerRunsOnlyOnce() { @@ -44,19 +45,19 @@ final class AsyncOperationWorkerTests: XCTestCase { ) var expectation = XCTestExpectation() - var disposable = host.rendering.signal.observeValues { rendering in + var 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() expectation = XCTestExpectation() - disposable = host.rendering.signal.observeValues { rendering in + cancellable = host.renderingPublisher.dropFirst().sink { rendering in expectation.fulfill() } @@ -66,9 +67,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..716494e67 100644 --- a/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift @@ -1,3 +1,4 @@ +import Combine import Workflow import WorkflowTesting import XCTest @@ -10,16 +11,16 @@ class AsyncSequenceWorkerTests: 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.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 +33,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 { rendering 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 { rendering 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 { rendering 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 +88,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 { rendering 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 { rendering 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 { rendering in expectation.fulfill() } @@ -130,15 +131,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 { rendering in // This should not be called! expectation.fulfill() } @@ -146,9 +147,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 +162,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 { rendering 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 { rendering 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 { rendering in expectation.fulfill() } @@ -204,23 +205,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 { rendering 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 +232,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..2e180c130 100644 --- a/WorkflowConcurrency/Tests/WorkerTests.swift +++ b/WorkflowConcurrency/Tests/WorkerTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import Workflow import WorkflowTesting import XCTest @@ -26,16 +27,16 @@ 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() } func testWorkflowUpdate() { @@ -48,26 +49,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 { rendering 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 { rendering in expectation.fulfill() } @@ -77,20 +78,20 @@ 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) 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 { rendering 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 +104,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 { rendering 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 { rendering in expectation.fulfill() } @@ -133,13 +134,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 { rendering in expectation.fulfill() } @@ -147,7 +148,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/Tests/SignalProducerTests.swift b/WorkflowReactiveSwift/Tests/SignalProducerTests.swift index 30c84cf36..8855f9769 100644 --- a/WorkflowReactiveSwift/Tests/SignalProducerTests.swift +++ b/WorkflowReactiveSwift/Tests/SignalProducerTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import ReactiveSwift import WorkflowTesting import XCTest @@ -38,7 +39,7 @@ class SignalProducerTests: 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() } @@ -46,7 +47,7 @@ class SignalProducerTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual(1, outputValue) - disposable?.dispose() + cancellable.cancel() } func test_multipleOutputs() { @@ -58,7 +59,7 @@ class SignalProducerTests: 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() } @@ -66,7 +67,7 @@ class SignalProducerTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual([1, 2, 3], outputValues) - disposable?.dispose() + cancellable.cancel() } func test_signalProducer_isDisposedIfNotUsedInWorkflow() { diff --git a/WorkflowReactiveSwift/Tests/SignalTests.swift b/WorkflowReactiveSwift/Tests/SignalTests.swift index ee612607c..d61ed034e 100644 --- a/WorkflowReactiveSwift/Tests/SignalTests.swift +++ b/WorkflowReactiveSwift/Tests/SignalTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import Foundation import ReactiveSwift import XCTest @@ -28,12 +29,12 @@ class SignalTests: 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() } defer { - disposable?.dispose() + cancellable.cancel() } observer.send(value: 1) @@ -50,14 +51,14 @@ class SignalTests: 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) if outputValues.count == 3 { expectation.fulfill() } } defer { - disposable?.dispose() + cancellable.cancel() } observer.send(value: 1) diff --git a/WorkflowReactiveSwift/Tests/WorkerTests.swift b/WorkflowReactiveSwift/Tests/WorkerTests.swift index a18b60b52..f57fa9e97 100644 --- a/WorkflowReactiveSwift/Tests/WorkerTests.swift +++ b/WorkflowReactiveSwift/Tests/WorkerTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import ReactiveSwift import Workflow import WorkflowTesting @@ -43,16 +44,16 @@ 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. @@ -153,13 +154,14 @@ 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 { expectation.fulfill() } } + defer { cancellable.cancel() } wait(for: [expectation], timeout: 1.0) diff --git a/WorkflowRxSwift/Tests/ObservableTests.swift b/WorkflowRxSwift/Tests/ObservableTests.swift index 67885e001..7fa07b2a0 100644 --- a/WorkflowRxSwift/Tests/ObservableTests.swift +++ b/WorkflowRxSwift/Tests/ObservableTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import RxSwift import Workflow import WorkflowTesting @@ -38,7 +39,7 @@ class ObservableTests: 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() } @@ -46,7 +47,7 @@ class ObservableTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual(1, outputValue) - disposable?.dispose() + cancellable.cancel() } func test_multipleOutputs() { @@ -58,7 +59,7 @@ class ObservableTests: 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() } @@ -66,7 +67,7 @@ class ObservableTests: XCTestCase { wait(for: [expectation], timeout: 1) XCTAssertEqual([1, 2, 3], outputValues) - disposable?.dispose() + cancellable.cancel() } func test_observable_isDisposedIfNotUsedInWorkflow() { diff --git a/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift b/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift index 446104d9d..8e75c8ba7 100644 --- a/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift +++ b/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import Foundation import ReactiveSwift import RxSwift @@ -28,7 +29,7 @@ class Rx_ReactiveWorkersTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.output.signal.observeValues { output in + let cancellable = host.outputPublisher.sink { output in if output.reactiveOutputReceived, output.rxOutputReceived { expectation.fulfill() } @@ -36,7 +37,7 @@ class Rx_ReactiveWorkersTests: XCTestCase { wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } func test_observes_on_main_queue() { @@ -77,14 +78,14 @@ class Rx_ReactiveWorkersTests: XCTestCase { ) let expectation = XCTestExpectation() - let disposable = host.output.signal.observeValues { output in + let cancellable = host.outputPublisher.sink { output in if output == .finished { expectation.fulfill() } } wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } } diff --git a/WorkflowRxSwift/Tests/WorkerTests.swift b/WorkflowRxSwift/Tests/WorkerTests.swift index d865a51b6..361f93b81 100644 --- a/WorkflowRxSwift/Tests/WorkerTests.swift +++ b/WorkflowRxSwift/Tests/WorkerTests.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import Combine import Foundation import RxSwift import Workflow @@ -44,16 +45,16 @@ 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. @@ -153,13 +154,14 @@ class WorkerTests: XCTestCase { let host = WorkflowHost(workflow: WF()) - host.output.signal.observeValues { output in + let cancellable = host.outputPublisher.sink { output in outputs.append(output) if outputs.count == 2 { expectation.fulfill() } } + defer { cancellable.cancel() } wait(for: [expectation], timeout: 1.0) diff --git a/WorkflowSwiftUI/Sources/Workflow+Preview.swift b/WorkflowSwiftUI/Sources/Workflow+Preview.swift index 67631d6a5..7f2ddd34b 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,7 @@ private struct PreviewView: UIViewControllerRepresentabl ) let coordinator = context.coordinator - coordinator.outputDisposable?.dispose() - coordinator.outputDisposable = controller.output.observeValues(onOutput) + coordinator.outputCancellable = controller.outputPublisher.sink(receiveValue: onOutput) return controller } @@ -61,8 +60,7 @@ private struct PreviewView: UIViewControllerRepresentabl ) { let coordinator = context.coordinator - coordinator.outputDisposable?.dispose() - coordinator.outputDisposable = controller.output.observeValues(onOutput) + coordinator.outputCancellable = controller.outputPublisher.sink(receiveValue: onOutput) controller.customizeEnvironment = customizeEnvironment controller.update(workflow: workflow) @@ -73,9 +71,9 @@ private struct PreviewView: UIViewControllerRepresentabl } // 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. + // subscription 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..1d300df56 100644 --- a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift +++ b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift @@ -16,7 +16,7 @@ #if canImport(UIKit) -import ReactiveSwift +import Combine import UIKit @_spi(ViewEnvironmentWiring) import ViewEnvironmentUI import Workflow @@ -26,8 +26,8 @@ public final class WorkflowHostingController: WorkflowUIView 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 renderingCancellable: AnyCancellable? private var lastEnvironmentAncestorPath: EnvironmentAncestorPath? @@ -65,7 +65,6 @@ public final class WorkflowHostingController: WorkflowUIView self.rootViewController = workflowHost .rendering - .value .viewControllerDescription(environment: customizedEnvironment) .buildViewController() @@ -79,11 +78,11 @@ public final class WorkflowHostingController: WorkflowUIView addChild(rootViewController) rootViewController.didMove(toParent: self) - workflowHost - .rendering - .signal - .take(during: lifetime) - .observeValues { [weak self] screen in + // Drop the initial replayed rendering; it was already consumed above to build the root view controller. + self.renderingCancellable = workflowHost + .renderingPublisher + .dropFirst() + .sink { [weak self] screen in guard let self else { return } update( @@ -138,7 +137,7 @@ public final class WorkflowHostingController: WorkflowUIView let environmentAncestorPath = environmentAncestorPath if environmentAncestorPath != lastEnvironmentAncestorPath { update( - screen: workflowHost.rendering.value, + screen: workflowHost.rendering, environmentAncestorPath: environmentAncestorPath ) } @@ -203,7 +202,7 @@ extension WorkflowHostingController: ViewEnvironmentObserving { public func environmentDidChange() { update( - screen: workflowHost.rendering.value, + screen: workflowHost.rendering, environmentAncestorPath: environmentAncestorPath ) } @@ -213,7 +212,7 @@ extension WorkflowHostingController: ViewEnvironmentObserving { extension WorkflowHostingController: SingleScreenContaining { public var primaryScreen: any Screen { - workflowHost.rendering.value + workflowHost.rendering } } diff --git a/WorkflowUI/Tests/DescribedViewControllerTests.swift b/WorkflowUI/Tests/DescribedViewControllerTests.swift index 14c4915b8..d6835e3f7 100644 --- a/WorkflowUI/Tests/DescribedViewControllerTests.swift +++ b/WorkflowUI/Tests/DescribedViewControllerTests.swift @@ -18,7 +18,7 @@ import XCTest -import ReactiveSwift +import Combine import Workflow @testable import WorkflowUI @@ -184,12 +184,12 @@ class DescribedViewControllerTests: XCTestCase { expectation.expectedFulfillmentCount = 2 var observedSizes: [CGSize] = [] - let disposable = WorkflowHostingController.preferredContentSizeSignal.observeValues { + let cancellable = WorkflowHostingController.preferredContentSizePublisher.sink { observedSizes.append($0) expectation.fulfill() } - defer { disposable?.dispose() } + defer { cancellable.cancel() } _ = WorkflowHostingController.view describedViewController.update(screen: screenB, environment: .empty) @@ -214,12 +214,12 @@ class DescribedViewControllerTests: XCTestCase { expectation.expectedFulfillmentCount = 3 var observedSizes: [CGSize] = [] - let disposable = WorkflowHostingController.preferredContentSizeSignal.observeValues { + let cancellable = WorkflowHostingController.preferredContentSizePublisher.sink { observedSizes.append($0) expectation.fulfill() } - defer { disposable?.dispose() } + defer { cancellable.cancel() } _ = WorkflowHostingController.view describedViewController.update(screen: screenB, environment: .empty) @@ -265,9 +265,11 @@ fileprivate enum TestScreen: Screen, Equatable { fileprivate class WorkflowHostingController: UIViewController { let describedViewController: DescribedViewController - var preferredContentSizeSignal: Signal { signal.skipRepeats() } + var preferredContentSizePublisher: AnyPublisher { + subject.removeDuplicates().eraseToAnyPublisher() + } - private let (signal, sink) = Signal.pipe() + private let subject = PassthroughSubject() init(describedViewController: DescribedViewController) { self.describedViewController = describedViewController @@ -293,7 +295,7 @@ fileprivate class WorkflowHostingController: UIViewController { guard container === describedViewController else { return } - sink.send(value: container.preferredContentSize) + subject.send(container.preferredContentSize) } } diff --git a/WorkflowUI/Tests/ViewControllerDescriptionTests.swift b/WorkflowUI/Tests/ViewControllerDescriptionTests.swift index f5e227d58..3eec0be1d 100644 --- a/WorkflowUI/Tests/ViewControllerDescriptionTests.swift +++ b/WorkflowUI/Tests/ViewControllerDescriptionTests.swift @@ -18,7 +18,7 @@ import XCTest -import ReactiveSwift +import Combine import Workflow @testable import WorkflowUI diff --git a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift index 3eea06dcf..723feced1 100644 --- a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift +++ b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift @@ -18,6 +18,7 @@ import XCTest +import Combine import ReactiveSwift import UIKit import Workflow @@ -86,7 +87,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 +96,7 @@ class WorkflowHostingControllerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } func test_container_with_anyworkflow() { @@ -105,7 +106,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 +115,7 @@ class WorkflowHostingControllerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } func test_container_update_causes_rerender() { @@ -148,7 +149,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 +159,7 @@ class WorkflowHostingControllerTests: XCTestCase { wait(for: [expectation], timeout: 1.0) - disposable?.dispose() + cancellable.cancel() } func test_environment_bridging() throws {