diff --git a/.mise.toml b/.mise.toml index ad6f21f9c..4975e18d8 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,5 +1,7 @@ [tools] -tuist = "4.23.0" +# 4.27.0+ is required to parse swift-tools-version 6.0 package manifests +# (older versions fail decoding the manifest's build-settings format). +tuist = "4.27.0" swiftformat = "0.54.2" [settings] diff --git a/Package.swift b/Package.swift index 5ecff8d8c..8538a83f3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import CompilerPluginSupport @@ -209,7 +209,7 @@ let package = Package( path: "ViewEnvironmentUI/Sources" ), ], - swiftLanguageVersions: [.v5] + swiftLanguageModes: [.v5] ) // MARK: Helpers @@ -222,8 +222,14 @@ extension PackageDescription.Product { } } +let swift6Targets: Set = ["Workflow"] + for target in package.targets { var settings = target.swiftSettings ?? [] - settings.append(.enableExperimentalFeature("StrictConcurrency=targeted")) + if swift6Targets.contains(target.name) { + settings.append(.swiftLanguageMode(.v6)) + } else { + settings.append(.enableExperimentalFeature("StrictConcurrency=targeted")) + } target.swiftSettings = settings } diff --git a/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift b/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift index 4b8e29c97..b00a56f25 100644 --- a/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift +++ b/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift @@ -55,6 +55,7 @@ extension AsyncWorkerWorkflow { extension AsyncWorkerWorkflow { typealias Rendering = MessageScreen + @MainActor func render(state: AsyncWorkerWorkflow.State, context: RenderContext) -> Rendering { NetworkRequestWorker() .mapOutput { result in diff --git a/Samples/SampleApp/Sources/DemoWorkflow.swift b/Samples/SampleApp/Sources/DemoWorkflow.swift index d022df9e8..d21a7b697 100644 --- a/Samples/SampleApp/Sources/DemoWorkflow.swift +++ b/Samples/SampleApp/Sources/DemoWorkflow.swift @@ -133,6 +133,7 @@ struct RefreshWorker: Worker { extension DemoWorkflow { typealias Rendering = DemoScreen + @MainActor func render(state: DemoWorkflow.State, context: RenderContext) -> Rendering { let color: UIColor = switch state.colorState { case .red: diff --git a/Samples/SampleApp/Sources/RootWorkflow.swift b/Samples/SampleApp/Sources/RootWorkflow.swift index 543c7ebfc..d82d18b5e 100644 --- a/Samples/SampleApp/Sources/RootWorkflow.swift +++ b/Samples/SampleApp/Sources/RootWorkflow.swift @@ -61,6 +61,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = CrossFadeScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { switch state { case .welcome: diff --git a/Samples/SampleApp/Sources/WelcomeWorkflow.swift b/Samples/SampleApp/Sources/WelcomeWorkflow.swift index ae77ea767..dc8bde1ff 100644 --- a/Samples/SampleApp/Sources/WelcomeWorkflow.swift +++ b/Samples/SampleApp/Sources/WelcomeWorkflow.swift @@ -64,6 +64,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) return WelcomeScreen( diff --git a/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift b/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift index 2a6a05d89..a38fa147a 100644 --- a/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift +++ b/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift @@ -63,6 +63,7 @@ extension DemoWorkflow { private static let colors: [UIColor] = [.red, .blue, .green, .yellow] private static let complimentaryColors: [UIColor] = [.blue, .green, .yellow, .purple] + @MainActor func render(state: State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) @@ -75,6 +76,7 @@ extension DemoWorkflow { ) } + @MainActor private func leadingScreenFor(state: State, context: RenderContext) -> AnyScreen { let sink = context.makeSink(of: Action.self) diff --git a/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift b/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift index 7cee4000b..9446699f5 100644 --- a/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift +++ b/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift @@ -165,6 +165,7 @@ extension AuthenticationWorkflow { extension AuthenticationWorkflow { typealias Rendering = AlertContainerScreen>> + @MainActor func render(state: AuthenticationWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) @@ -235,6 +236,7 @@ extension AuthenticationWorkflow { ) } + @MainActor private func twoFactorScreen(error: AuthenticationService.AuthenticationError?, intermediateSession: String, sink: Sink) -> BackStackScreen.Item { let title: String = if let authenticationError = error { authenticationError.localizedDescription diff --git a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift index 55e7261a2..23e5c1d85 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift @@ -71,6 +71,7 @@ extension LoginWorkflow { extension LoginWorkflow { typealias Rendering = LoginScreen + @MainActor func render(state: LoginWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) diff --git a/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift b/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift index a38a7ae06..9b0080872 100644 --- a/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift +++ b/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift @@ -77,6 +77,7 @@ extension ConfirmQuitWorkflow { extension ConfirmQuitWorkflow { typealias Rendering = (ConfirmQuitScreen, Alert?) + @MainActor func render(state: ConfirmQuitWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) var alert: Alert? diff --git a/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift b/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift index 7c10603ed..048b45d6b 100644 --- a/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift +++ b/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift @@ -86,6 +86,7 @@ extension RunGameWorkflow { extension RunGameWorkflow { typealias Rendering = AlertContainerScreen>> + @MainActor func render(state: RunGameWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) var modals: [ModalContainerScreenModal] = [] @@ -160,6 +161,7 @@ extension RunGameWorkflow { return AlertContainerScreen(baseScreen: modalContainerScreen, alert: alert) } + @MainActor private func newGameScreen(sink: Sink, playerX: String, playerO: String) -> NewGameScreen { NewGameScreen( playerX: playerX, diff --git a/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift b/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift index e8f27fc81..b250ff811 100644 --- a/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift +++ b/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift @@ -85,6 +85,7 @@ extension TakeTurnsWorkflow { extension TakeTurnsWorkflow { typealias Rendering = GamePlayScreen + @MainActor func render(state: TakeTurnsWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) diff --git a/Samples/TicTacToe/Sources/Main/MainWorkflow.swift b/Samples/TicTacToe/Sources/Main/MainWorkflow.swift index c3f214b70..dfb7fecb2 100644 --- a/Samples/TicTacToe/Sources/Main/MainWorkflow.swift +++ b/Samples/TicTacToe/Sources/Main/MainWorkflow.swift @@ -67,6 +67,7 @@ extension MainWorkflow { extension MainWorkflow { typealias Rendering = AlertContainerScreen>> + @MainActor func render(state: MainWorkflow.State, context: RenderContext) -> Rendering { switch state { case .authenticating: diff --git a/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift b/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift index e486f00e2..e3119ebec 100644 --- a/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift @@ -21,6 +21,7 @@ import XCTest @testable import TicTacToe +@MainActor class AuthenticationWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift b/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift index 18595b1e3..6dcc68248 100644 --- a/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import TicTacToe +@MainActor class ConfirmQuitWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/LoginWorkflowTests.swift b/Samples/TicTacToe/Tests/LoginWorkflowTests.swift index e26df9902..8def01844 100644 --- a/Samples/TicTacToe/Tests/LoginWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/LoginWorkflowTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import TicTacToe +@MainActor class LoginWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/MainWorkflowTests.swift b/Samples/TicTacToe/Tests/MainWorkflowTests.swift index 491ad3f62..cf27ea3be 100644 --- a/Samples/TicTacToe/Tests/MainWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/MainWorkflowTests.swift @@ -22,6 +22,7 @@ import XCTest @testable import TicTacToe +@MainActor class MainWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift b/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift index 1339175bd..bc9bbcd3c 100644 --- a/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift @@ -21,6 +21,7 @@ import XCTest @testable import TicTacToe +@MainActor class RunGameWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift b/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift index 9244a6881..6c71f15ed 100644 --- a/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import TicTacToe +@MainActor class TakeTurnsWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift index 6691e9bd9..8650f927c 100644 --- a/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -80,6 +80,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift index a7c8add14..2fbbc8e09 100644 --- a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift @@ -90,6 +90,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Create a sink to handle the back action from the TodoListWorkflow to log out. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift index 2bc3b6818..41f4db001 100644 --- a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -87,6 +87,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift index 07273df9a..0610c7fbd 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift @@ -88,6 +88,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Delete the `let sink = context.makeSink(of: ...) as we no longer need a sink. diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift index ad9abb8ef..e93a51475 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift @@ -109,6 +109,7 @@ extension TodoEditWorkflow { extension TodoEditWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoEditWorkflow.State, context: RenderContext) -> Rendering { // The sink is used to send actions back to this workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift index cdeb914dc..baa63156f 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift @@ -120,6 +120,7 @@ extension TodoListWorkflow { extension TodoListWorkflow { typealias Rendering = [BackStackScreen.Item] + @MainActor func render(state: TodoListWorkflow.State, context: RenderContext) -> Rendering { // Define a sink to be able to send actions. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift index 2bc3b6818..41f4db001 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -87,6 +87,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift index e4e950a77..4b627680d 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift @@ -90,6 +90,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Our list of back stack items. Will always include the "WelcomeScreen". var backStackItems: [BackStackScreen.Item] = [] diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift index ad9abb8ef..e93a51475 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift @@ -109,6 +109,7 @@ extension TodoEditWorkflow { extension TodoEditWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoEditWorkflow.State, context: RenderContext) -> Rendering { // The sink is used to send actions back to this workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift index 9b94a9e35..84baca1a8 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift @@ -97,6 +97,7 @@ extension TodoListWorkflow { extension TodoListWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoListWorkflow.State, context: RenderContext) -> Rendering { // Define a sink to be able to send actions. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift index 7cc6a949a..bf9ff69f6 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift @@ -139,6 +139,7 @@ extension TodoWorkflow { extension TodoWorkflow { typealias Rendering = [BackStackScreen.Item] + @MainActor func render(state: TodoWorkflow.State, context: RenderContext) -> Rendering { let todoListItem = TodoListWorkflow(name: name, todos: state.todos) .mapOutput { output -> ListAction in diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift index 2bc3b6818..41f4db001 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -87,6 +87,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift index 90ff592de..0cdd1d3c4 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift @@ -88,6 +88,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Our list of back stack items. Will always include the "WelcomeScreen". var backStackItems: [BackStackScreen.Item] = [] diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift index ad9abb8ef..e93a51475 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift @@ -109,6 +109,7 @@ extension TodoEditWorkflow { extension TodoEditWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoEditWorkflow.State, context: RenderContext) -> Rendering { // The sink is used to send actions back to this workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift index 9b94a9e35..84baca1a8 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift @@ -97,6 +97,7 @@ extension TodoListWorkflow { extension TodoListWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoListWorkflow.State, context: RenderContext) -> Rendering { // Define a sink to be able to send actions. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift index 53ee93f44..ec1f4b80f 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift @@ -139,6 +139,7 @@ extension TodoWorkflow { extension TodoWorkflow { typealias Rendering = [BackStackScreen.Item] + @MainActor func render(state: TodoWorkflow.State, context: RenderContext) -> Rendering { let todoListItem = TodoListWorkflow(name: name, todos: state.todos) .mapOutput { output -> ListAction in diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift index f550aad42..ee6fc3eea 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -92,6 +92,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift index 4fd2af96a..dffa0ade8 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift @@ -25,6 +25,7 @@ import XCTest // Import `WorkflowUI` as testable so that the wrappedScreen in `AnyScreen` can be accessed. @testable import WorkflowUI +@MainActor class RootWorkflowTests: XCTestCase { func testWelcomeRendering() throws { RootWorkflow() diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift index f1e382883..9735847ca 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift @@ -18,6 +18,7 @@ import WorkflowTesting import XCTest @testable import Tutorial5Complete +@MainActor class TodoEditWorkflowTests: XCTestCase { func testAction() throws { TodoEditWorkflow.Action diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift index 60ae3dae8..ca72c0648 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift @@ -18,6 +18,7 @@ import WorkflowTesting import XCTest @testable import Tutorial5Complete +@MainActor class TodoListWorkflowTests: XCTestCase { func testActions() throws { TodoListWorkflow.Action diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift index f18b93b72..07aa2d795 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift @@ -21,6 +21,7 @@ import WorkflowUI import XCTest @testable import Tutorial5Complete +@MainActor class TodoWorkflowTests: XCTestCase { func testSelectingTodo() throws { let todos: [TodoModel] = [TodoModel(title: "Title", note: "Note")] diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift index 795572990..e36d74e5b 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift @@ -18,6 +18,7 @@ import WorkflowTesting import XCTest @testable import Tutorial5Complete +@MainActor class WelcomeWorkflowTests: XCTestCase { func testNameUpdates() throws { WelcomeWorkflow.Action diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift index 438c64a6b..70778995d 100644 --- a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift @@ -49,6 +49,7 @@ extension DemoWorkflow { extension DemoWorkflow { typealias Rendering = DemoScreen + @MainActor func render(state: DemoWorkflow.State, context: RenderContext) -> Rendering { // Combine-based worker example DemoWorker() diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift index 95030f1f9..10f817680 100644 --- a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift @@ -11,6 +11,7 @@ import WorkflowTesting import XCTest @testable import WorkflowCombineSampleApp +@MainActor class DemoWorkflowTests: XCTestCase { func test_demoWorkflow_publishesNewDate() { let expectedDate = Date(timeIntervalSince1970: 0) diff --git a/Workflow/Sources/AnyWorkflow.swift b/Workflow/Sources/AnyWorkflow.swift index 852d318ac..56b783428 100644 --- a/Workflow/Sources/AnyWorkflow.swift +++ b/Workflow/Sources/AnyWorkflow.swift @@ -67,7 +67,7 @@ extension AnyWorkflow { /// /// - Returns: A type erased workflow with the new output type (the rendering type remains unchanged). public func mapOutput( - _ transform: @escaping (Output) -> NewOutput + _ transform: @escaping @MainActor (Output) -> NewOutput ) -> AnyWorkflow { let storage = storage.mapOutput(transform: transform) return AnyWorkflow(storage: storage) @@ -95,10 +95,11 @@ extension AnyWorkflow { /// That type information *is* present in our storage object, however, so we /// pass the context down to that storage object which will ultimately call /// through to `context.render(workflow:key:reducer:)`. + @MainActor func render( context: RenderContext, key: String, - outputMap: @escaping (Output) -> Action + outputMap: @escaping @MainActor (Output) -> Action ) -> Rendering where Action.WorkflowType == Parent { storage.render(context: context, key: key, outputMap: outputMap) } @@ -111,10 +112,11 @@ extension AnyWorkflow { fileprivate class AnyStorage { var base: Any { fatalError() } + @MainActor func render( context: RenderContext, key: String, - outputMap: @escaping (Output) -> Action + outputMap: @escaping @MainActor (Output) -> Action ) -> Rendering where Action.WorkflowType == Parent { fatalError() } @@ -123,7 +125,7 @@ extension AnyWorkflow { fatalError() } - func mapOutput(transform: @escaping (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { + func mapOutput(transform: @escaping @MainActor (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { fatalError() } @@ -138,9 +140,9 @@ extension AnyWorkflow { fileprivate final class Storage: AnyStorage { let workflow: T let renderingTransform: (T.Rendering) -> Rendering - let outputTransform: (T.Output) -> Output + let outputTransform: @MainActor (T.Output) -> Output - init(workflow: T, renderingTransform: @escaping (T.Rendering) -> Rendering, outputTransform: @escaping (T.Output) -> Output) { + init(workflow: T, renderingTransform: @escaping (T.Rendering) -> Rendering, outputTransform: @escaping @MainActor (T.Output) -> Output) { self.workflow = workflow self.renderingTransform = renderingTransform self.outputTransform = outputTransform @@ -152,19 +154,20 @@ extension AnyWorkflow { T.self } + @MainActor override func render( context: RenderContext, key: String, - outputMap: @escaping (Output) -> Action + outputMap: @escaping @MainActor (Output) -> Action ) -> Rendering where Action.WorkflowType == Parent { - let outputMap: (T.Output) -> Action = { [outputTransform] output in + let outputMap: @MainActor (T.Output) -> Action = { [outputTransform] output in outputMap(outputTransform(output)) } let rendering = context.render(workflow: workflow, key: key, outputMap: outputMap) return renderingTransform(rendering) } - override func mapOutput(transform: @escaping (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { + override func mapOutput(transform: @escaping @MainActor (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { AnyWorkflow.Storage( workflow: workflow, renderingTransform: renderingTransform, @@ -187,7 +190,7 @@ extension AnyWorkflow { } extension AnyWorkflowConvertible { - public func mapOutput(_ transform: @escaping (Output) -> NewOutput) -> AnyWorkflow { + public func mapOutput(_ transform: @escaping @MainActor (Output) -> NewOutput) -> AnyWorkflow { asAnyWorkflow().mapOutput(transform) } diff --git a/Workflow/Sources/AnyWorkflowConvertible.swift b/Workflow/Sources/AnyWorkflowConvertible.swift index 2ff9c44b2..6cd9fda2d 100644 --- a/Workflow/Sources/AnyWorkflowConvertible.swift +++ b/Workflow/Sources/AnyWorkflowConvertible.swift @@ -37,6 +37,7 @@ extension AnyWorkflowConvertible { /// - Parameter key: A string that uniquely identifies this workflow. /// /// - Returns: The `Rendering` generated by the workflow. + @MainActor public func rendered( in context: RenderContext, key: String = "" @@ -46,6 +47,7 @@ extension AnyWorkflowConvertible { asAnyWorkflow().render(context: context, key: key, outputMap: { $0 }) } + @MainActor public func rendered( in context: RenderContext, key: String = "", @@ -56,6 +58,7 @@ extension AnyWorkflowConvertible { asAnyWorkflow().render(context: context, key: key, outputMap: { outputMap($0) }) } + @MainActor public func rendered( in context: RenderContext, key: String = "" @@ -76,6 +79,7 @@ extension AnyWorkflowConvertible where Output == Never { /// - Parameter key: A string that uniquely identifies this workflow. /// /// - Returns: The `Rendering` generated by the workflow. + @MainActor public func rendered(in context: RenderContext, key: String = "") -> Rendering { // Convenience for workflow that have no output allowing them to be rendered with any context @@ -89,6 +93,7 @@ extension AnyWorkflowConvertible where Output == Never { } extension AnyWorkflowConvertible where Rendering == Void { + @MainActor public func running( in context: RenderContext, key: String = "", @@ -101,6 +106,7 @@ extension AnyWorkflowConvertible where Rendering == Void { } extension AnyWorkflowConvertible where Rendering == Void, Output: WorkflowAction { + @MainActor public func running( in context: RenderContext, key: String = "" @@ -116,7 +122,7 @@ extension AnyWorkflowConvertible { /// /// - Parameter apply: On `Output`, mutate `State` as necessary and return new `Output` (or `nil`). public func onOutput( - _ apply: @escaping (inout Parent.State, Output) -> Parent.Output? + _ apply: @escaping @MainActor (inout Parent.State, Output) -> Parent.Output? ) -> AnyWorkflow> { asAnyWorkflow() .mapOutput { output in diff --git a/Workflow/Sources/AsyncMulticaster.swift b/Workflow/Sources/AsyncMulticaster.swift new file mode 100644 index 000000000..0629e8ada --- /dev/null +++ b/Workflow/Sources/AsyncMulticaster.swift @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Fans values out to any number of independent `AsyncStream` consumers. +/// +/// Each call to `makeStream` returns an independent stream: every consumer +/// receives every value yielded after its stream was created (subject to the +/// requested buffering policy), optionally preceded by an initial value. +/// Streams finish when `finish()` is called or when the multicaster is +/// deallocated. +@MainActor +final class AsyncMulticaster { + private var continuations: [UUID: AsyncStream.Continuation] = [:] + private var isFinished = false + + nonisolated init() {} + + func finish() { + isFinished = true + let existing = continuations + continuations.removeAll() + for continuation in existing.values { + continuation.finish() + } + } + + deinit { + // Continuations are Sendable; finishing them from a nonisolated + // deinit is safe. (Stored-property access is permitted in deinit.) + for continuation in continuations.values { + continuation.finish() + } + } +} + +// Yielding values into the fan-out streams requires `Element` to be `Sendable`: +// consumers may iterate their streams from arbitrary isolation domains, and a +// single value is delivered to every consumer. +extension AsyncMulticaster where Element: Sendable { + func makeStream( + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy, + initial: Element? = nil + ) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream( + of: Element.self, + bufferingPolicy: bufferingPolicy + ) + + guard !isFinished else { + continuation.finish() + return stream + } + + if let initial { + continuation.yield(initial) + } + + let id = UUID() + continuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.continuations[id] = nil + } + } + + return stream + } + + func yield(_ element: Element) { + for continuation in continuations.values { + continuation.yield(element) + } + } +} diff --git a/Workflow/Sources/Debugging.swift b/Workflow/Sources/Debugging.swift index f8159a42e..f6b6b50bf 100644 --- a/Workflow/Sources/Debugging.swift +++ b/Workflow/Sources/Debugging.swift @@ -18,7 +18,7 @@ import IssueReporting #endif -public struct WorkflowUpdateDebugInfo: Codable, Equatable { +public struct WorkflowUpdateDebugInfo: Codable, Equatable, Sendable { public var workflowType: String public var kind: Kind @@ -29,7 +29,7 @@ public struct WorkflowUpdateDebugInfo: Codable, Equatable { } extension WorkflowUpdateDebugInfo { - public indirect enum Kind: Equatable { + public indirect enum Kind: Equatable, Sendable { case didUpdate(source: Source) case childDidUpdate(WorkflowUpdateDebugInfo) } @@ -76,7 +76,7 @@ extension WorkflowUpdateDebugInfo.Kind: Codable { } extension WorkflowUpdateDebugInfo { - public indirect enum Source: Equatable { + public indirect enum Source: Equatable, Sendable { case external case worker case sideEffect diff --git a/Workflow/Sources/DeinitFinalization.swift b/Workflow/Sources/DeinitFinalization.swift new file mode 100644 index 000000000..1cee7cd2a --- /dev/null +++ b/Workflow/Sources/DeinitFinalization.swift @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Tracks re-entrancy into the workflow runtime's main-actor operations: +/// render passes, action application, and the output cascades they produce +/// (including any work Combine subscribers perform synchronously in +/// response). +/// +/// Deinit finalization consults this to decide whether teardown may run +/// inline. A reference released while the runtime is mid-operation must not +/// tear down inline: observer callbacks, subject completions, and subtree +/// teardown would interleave with the in-flight operation that performed +/// the release. +@MainActor +enum WorkflowRuntimeActivity { + private(set) static var depth = 0 + + private static var pendingFinalizations: [@MainActor () -> Void] = [] + + /// Runs `operation` with the runtime marked as active. Nesting is + /// expected: event cascades re-enter through multiple entry points. + /// When the outermost operation exits, finalizations deferred during it + /// run immediately — still within the same runloop callout, so teardown + /// timing stays at pre-deferral runloop granularity rather than slipping + /// to a later main-queue drain. + static func perform(_ operation: () throws -> T) rethrows -> T { + depth += 1 + defer { + depth -= 1 + if depth == 0 { + drainPendingFinalizations() + } + } + return try operation() + } + + /// Defers `finalize` until the outermost in-flight operation exits. + /// Must only be called while the runtime is active (`depth > 0`). + static func enqueueFinalization(_ finalize: @escaping @MainActor () -> Void) { + pendingFinalizations.append(finalize) + } + + private static func drainPendingFinalizations() { + // A finalization can release references whose deinits enqueue more + // finalizations (via a nested `perform`, drained there) or run + // inline (depth is 0 here); loop until no stragglers remain. + while !pendingFinalizations.isEmpty { + let pending = pendingFinalizations + pendingFinalizations.removeAll() + for finalize in pending { + finalize() + } + } + } +} + +/// Runs main-actor finalization work from a (nonisolated) `deinit`. +/// +/// This exists because the natural tool — an `isolated deinit` (SE-0371) — +/// crashes the Swift 6.3.2 optimizer when compiling isolated deinits of +/// generic classes in release builds, and because isolated deinit's +/// dispatch rule (inline only from task contexts) doesn't match what +/// callers observe in practice: synchronous code that drops the last +/// reference to a workflow host expects teardown side effects (side-effect +/// terminations, observer callbacks) to have happened when the drop +/// returns, exactly as a plain inline `deinit` behaves. +/// +/// So the rule here is based on what the runtime is doing rather than on +/// task context: +/// - On the main thread with the runtime quiescent, finalize inline — +/// teardown is synchronously observable, matching a plain `deinit`. +/// - If the runtime is mid-operation (the release happened during a render +/// pass or action cascade), defer until the outermost operation exits, so +/// teardown can't interleave with the in-flight operation but still +/// completes within the same runloop callout. Deferring to a `Task` +/// instead would push teardown to a later main-queue drain, which lets +/// unrelated main-queue work interleave with it and shifts the runloop +/// timing that snapshot tests and leak detectors observe. +/// - If the release happened off the main thread, hop onto the main actor. +/// +/// A deferred finalization runs with the runtime quiescent, so the child +/// deinits it triggers finalize inline: an entire subtree tears down at a +/// single point rather than one deferral hop per tree level. +func finalizeFromDeinit(_ finalize: @escaping @MainActor () -> Void) { + if Thread.isMainThread { + // Dynamically safe: the main thread is the main actor's executor. + MainActor.assumeIsolated { + if WorkflowRuntimeActivity.depth == 0 { + finalize() + } else { + WorkflowRuntimeActivity.enqueueFinalization(finalize) + } + } + } else { + Task { @MainActor in + finalize() + } + } +} diff --git a/Workflow/Sources/DispatchQueue+Workflow.swift b/Workflow/Sources/DispatchQueue+Workflow.swift index 00b119382..8b2f15312 100644 --- a/Workflow/Sources/DispatchQueue+Workflow.swift +++ b/Workflow/Sources/DispatchQueue+Workflow.swift @@ -17,6 +17,10 @@ import Foundation extension DispatchQueue { + /// The queue the Workflow runtime historically executed on. The runtime is + /// now `@MainActor`-isolated; this alias is retained for source + /// compatibility with existing schedulers (e.g. WorkflowReactiveSwift) and + /// will be formally deprecated in a future release. @_spi(WorkflowInternals) public static let workflowExecution: DispatchQueue = .main } diff --git a/Workflow/Sources/RenderContext.swift b/Workflow/Sources/RenderContext.swift index 60fe2af4a..ed633b3a4 100644 --- a/Workflow/Sources/RenderContext.swift +++ b/Workflow/Sources/RenderContext.swift @@ -46,6 +46,7 @@ import Foundation /// /// The infrastructure then performs a render pass on the child to obtain its /// `Rendering` value, which is then returned to the caller. +@MainActor public class RenderContext: RenderContextType { private(set) var isValid = true @@ -66,7 +67,7 @@ public class RenderContext: RenderContextType { func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where WorkflowType == Action.WorkflowType { fatalError() } @@ -127,7 +128,7 @@ public class RenderContext: RenderContextType { override func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where WorkflowType == Action.WorkflowType { @@ -158,13 +159,14 @@ public class RenderContext: RenderContextType { } } +@MainActor protocol RenderContextType: AnyObject { associatedtype WorkflowType: Workflow func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where Action.WorkflowType == WorkflowType func makeSink( @@ -177,6 +179,36 @@ protocol RenderContextType: AnyObject { ) } +extension RenderContext { + /// Execute an asynchronous side-effect action. + /// + /// `action` runs in a `Task` owned by the workflow node the first time a + /// side-effect is run with a given `key`. Calls with the same `key` on + /// subsequent renders are ignored. If, after a render pass, a previously + /// used `key` is no longer used, the `Task` is cancelled — cancellation is + /// cooperative, so long-running work should check `Task.isCancelled` or + /// use cancellation-aware APIs. + /// + /// Prefer this over the `Lifetime`-based variant for new code. + /// + /// - Parameters: + /// - key: represents the block of work that needs to be executed. + /// - action: an async block of work to execute. + public func runSideEffect( + key: AnyHashable, + action: @escaping @Sendable () async -> Void + ) { + runSideEffect(key: key) { lifetime in + let task = Task { + await action() + } + lifetime.onEnded { + task.cancel() + } + } + } +} + extension RenderContext { public func makeSink( of eventType: Event.Type, diff --git a/Workflow/Sources/RuntimeConfiguration.swift b/Workflow/Sources/RuntimeConfiguration.swift index 75ed8d398..c5a25611b 100644 --- a/Workflow/Sources/RuntimeConfiguration.swift +++ b/Workflow/Sources/RuntimeConfiguration.swift @@ -41,9 +41,11 @@ public enum Runtime { _defaultConfiguration = config } + @MainActor private static var _defaultConfiguration = Configuration() /// The configuration active for the current task, falling back to the default configuration. + @MainActor package static var configuration: Configuration { _currentConfiguration ?? _defaultConfiguration } @@ -53,6 +55,7 @@ public enum Runtime { /// - Parameters: /// - override: An option block to reconfigure the current configuration value. /// - operation: The operation to perform with the customized configuration. + @MainActor public static func withConfiguration( override: ((inout Configuration) -> Void)? = nil, operation: () -> T @@ -71,7 +74,7 @@ public enum Runtime { extension Runtime { /// Configuration options for the Workflow runtime. - public struct Configuration: Equatable { + public struct Configuration: Equatable, Sendable { /// The default runtime configuration. static let `default` = Configuration() diff --git a/Workflow/Sources/Sink.swift b/Workflow/Sources/Sink.swift index 1fd9ded0f..6468076a6 100644 --- a/Workflow/Sources/Sink.swift +++ b/Workflow/Sources/Sink.swift @@ -17,17 +17,22 @@ /// Sink is a type that receives incoming values (commonly events or `WorkflowAction`) /// /// Use `RenderContext.makeSink` to create instances. -public struct Sink { - private let onValue: (Value) -> Void +/// +/// Sinks deliver values into the Workflow runtime, which runs on the main +/// actor, so `send` is main-actor-isolated. A `Sink` value itself is +/// `Sendable` and may be captured and stored anywhere. +public struct Sink: Sendable { + private let onValue: @MainActor (Value) -> Void /// Initializes a new sink with the given closure. - public init(_ onValue: @escaping (Value) -> Void) { + public init(_ onValue: @escaping @MainActor (Value) -> Void) { self.onValue = onValue } /// Sends a new event into the sink. /// /// - Parameter event: The value to send into the sink. + @MainActor public func send(_ value: Value) { onValue(value) } @@ -52,7 +57,7 @@ public struct Sink { /// *input* types of its API. /// /// - Parameter transform: An escaping closure that transforms `T` into `Event`. - public func contraMap(_ transform: @escaping (NewValue) -> Value) -> Sink { + public func contraMap(_ transform: @escaping @MainActor (NewValue) -> Value) -> Sink { Sink { value in send(transform(value)) } diff --git a/Workflow/Sources/StateMutationSink.swift b/Workflow/Sources/StateMutationSink.swift index 3f9f6df5c..75f973040 100644 --- a/Workflow/Sources/StateMutationSink.swift +++ b/Workflow/Sources/StateMutationSink.swift @@ -40,8 +40,12 @@ public struct StateMutationSink { /// Sends message to `StateMutationSink` to update `State`'s value using the provided closure. /// + /// Sinks deliver values into the Workflow runtime, which runs on the main actor, so `send` + /// is main-actor-isolated. + /// /// - Parameters: /// - update: The `State` mutation to perform. + @MainActor public func send(_ update: @escaping (inout WorkflowType.State) -> Void) { sink.send( AnyWorkflowAction { state, _ in @@ -53,9 +57,13 @@ public struct StateMutationSink { /// Sends message to `StateMutationSink` to update `State`'s value at `KeyPath` with `Value`. /// + /// Sinks deliver values into the Workflow runtime, which runs on the main actor, so `send` + /// is main-actor-isolated. + /// /// - Parameters: /// - keyPath: Key path of `State` whose value needs to be mutated. /// - value: Value to update `State` with. + @MainActor public func send(_ keyPath: WritableKeyPath, value: Value) { send { $0[keyPath: keyPath] = value } } diff --git a/Workflow/Sources/SubtreeManager.swift b/Workflow/Sources/SubtreeManager.swift index 150376383..8d4a5a3ad 100644 --- a/Workflow/Sources/SubtreeManager.swift +++ b/Workflow/Sources/SubtreeManager.swift @@ -23,6 +23,7 @@ import IssueReporting extension WorkflowNode { /// Manages the subtree of a workflow. Specifically, this type encapsulates the logic required to update and manage /// the lifecycle of nested workflows across multiple render passes. + @MainActor final class SubtreeManager { var onUpdate: ((Output) -> Void)? @@ -199,6 +200,7 @@ extension WorkflowNode.SubtreeManager { extension WorkflowNode.SubtreeManager { /// The workflow context implementation used by the subtree manager. + @MainActor fileprivate final class Context: RenderContextType { private(set) var eventPipes: [EventPipe] @@ -240,7 +242,7 @@ extension WorkflowNode.SubtreeManager { func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where WorkflowType == Action.WorkflowType { @@ -328,6 +330,7 @@ extension WorkflowNode.SubtreeManager { // MARK: - Reusable Sink extension WorkflowNode.SubtreeManager { + @MainActor fileprivate struct SinkStore { private var previousSinks: [ObjectIdentifier: AnyReusableSink] private(set) var usedSinks: [ObjectIdentifier: AnyReusableSink] @@ -364,6 +367,7 @@ extension WorkflowNode.SubtreeManager { } /// Type-erased base class for reusable sinks. + @MainActor fileprivate class AnyReusableSink { /// The callback to invoke when an event is to be handled. let onSinkEvent: OnSinkEvent? @@ -376,6 +380,8 @@ extension WorkflowNode.SubtreeManager { } fileprivate final class ReusableSink: AnyReusableSink where Action.WorkflowType == WorkflowType { + /// Main-actor entry point: `Sink.send` is main-actor-isolated, so + /// actions always enter the runtime on the main actor. func handle(action: Action) { if let onSinkEvent { handleWithSinkEventHandler(action: action, onSinkEvent: onSinkEvent) @@ -392,7 +398,7 @@ extension WorkflowNode.SubtreeManager { if case .pending = eventPipe.validationState { // Workflow is currently processing an `event`. // Scheduling it to be processed after. - DispatchQueue.workflowExecution.async { [weak self] in + Task { @MainActor [weak self] in self?.eventPipe.handle(event: output) } return @@ -404,9 +410,6 @@ extension WorkflowNode.SubtreeManager { action: Action, onSinkEvent: OnSinkEvent ) { - // new `SinkEventHandler` logic - dispatchPrecondition(condition: .onQueue(DispatchQueue.workflowExecution)) - // If we can process now, forward through the `EventPipe` let immediatePerform: () -> Void = { let output = Output.update( @@ -431,6 +434,7 @@ extension WorkflowNode.SubtreeManager { // MARK: - EventPipe extension WorkflowNode.SubtreeManager { + @MainActor final class EventPipe { var validationState: ValidationState enum ValidationState { @@ -448,8 +452,16 @@ extension WorkflowNode.SubtreeManager { } func handle(event: Output) { - dispatchPrecondition(condition: .onQueue(DispatchQueue.workflowExecution)) + // Every event cascade (sink sends, worker outputs, child output + // bubbling) enters the runtime here, so this marks the whole + // cascade — action application through the resulting render — + // as active for deinit-finalization dispatch. + WorkflowRuntimeActivity.perform { + handleMarkedActive(event: event) + } + } + private func handleMarkedActive(event: Output) { let isReentrantCall = isHandlingEvent isHandlingEvent = true defer { isHandlingEvent = isReentrantCall } @@ -529,6 +541,7 @@ extension WorkflowNode.SubtreeManager { extension WorkflowNode.SubtreeManager { /// Abstract base class for running children in the subtree. + @MainActor class AnyChildWorkflow { fileprivate var eventPipe: EventPipe @@ -547,11 +560,11 @@ extension WorkflowNode.SubtreeManager { fileprivate final class ChildWorkflow: AnyChildWorkflow { private let node: WorkflowNode - private var outputMap: (W.Output) -> any WorkflowAction + private var outputMap: @MainActor (W.Output) -> any WorkflowAction init( workflow: W, - outputMap: @escaping (W.Output) -> any WorkflowAction, + outputMap: @escaping @MainActor (W.Output) -> any WorkflowAction, eventPipe: EventPipe, key: String, hostContext: HostContext, @@ -582,7 +595,7 @@ extension WorkflowNode.SubtreeManager { func update( workflow: W, - outputMap: @escaping (W.Output) -> any WorkflowAction, + outputMap: @escaping @MainActor (W.Output) -> any WorkflowAction, eventPipe: EventPipe ) { self.outputMap = outputMap diff --git a/Workflow/Sources/Workflow.swift b/Workflow/Sources/Workflow.swift index df4a3f99c..9d92f99c1 100644 --- a/Workflow/Sources/Workflow.swift +++ b/Workflow/Sources/Workflow.swift @@ -63,12 +63,14 @@ public protocol Workflow: AnyWorkflowConvertible { /// This method is invoked once when a workflow node comes into existence. /// /// - Returns: The initial state for the workflow. + @MainActor func makeInitialState() -> State /// Called when a new workflow is passed down from the parent to an existing workflow node. /// /// - Parameter previousWorkflow: The workflow before the update. /// - Parameter state: The current state. + @MainActor func workflowDidChange(from previousWorkflow: Self, state: inout State) /// Called by the internal Workflow infrastructure to "render" the current state into `Rendering`. @@ -78,16 +80,19 @@ public protocol Workflow: AnyWorkflowConvertible { /// - Parameter context: The workflow context is the composition point for the workflow tree. To use a nested /// workflow, instantiate it based on the current state, then call `rendered(in:key:outputMap:)`. /// This will return the child's `Rendering` type after creating or updating the nested workflow. + @MainActor func render(state: State, context: RenderContext) -> Rendering } extension Workflow { + @MainActor public func workflowDidChange(from previousWorkflow: Self, state: inout State) {} } /// When State is Void, provide empty `makeInitialState` and `workflowDidChange` /// implementations, making a “stateless workflow”. extension Workflow where State == Void { + @MainActor public func makeInitialState() -> State { () } diff --git a/Workflow/Sources/WorkflowAction.swift b/Workflow/Sources/WorkflowAction.swift index 6503df1db..06a769028 100644 --- a/Workflow/Sources/WorkflowAction.swift +++ b/Workflow/Sources/WorkflowAction.swift @@ -30,6 +30,7 @@ public protocol WorkflowAction { /// the workflow hierarchy to this workflow's parent. /// > Warning: The `context` parameter should not escape from implementations of this requirement. /// Attempting to access the instance after `apply()` has returned is a client error and will crash. + @MainActor func apply( toState state: inout WorkflowType.State, context: ApplyContext @@ -38,7 +39,7 @@ public protocol WorkflowAction { extension WorkflowAction { /// Closure type signature matching `WorkflowAction`'s `apply()` method. - public typealias ActionApplyClosure = (inout WorkflowType.State, ApplyContext) -> WorkflowType.Output? + public typealias ActionApplyClosure = @MainActor (inout WorkflowType.State, ApplyContext) -> WorkflowType.Output? } /// A type-erased workflow action. @@ -56,6 +57,7 @@ public struct AnyWorkflowAction: WorkflowAction { /// Creates a type-erased workflow action that wraps the given instance. /// /// - Parameter base: A workflow action to wrap. + @MainActor public init(_ base: E) where E.WorkflowType == WorkflowType { if let anyEvent = base as? AnyWorkflowAction { self = anyEvent @@ -89,7 +91,7 @@ public struct AnyWorkflowAction: WorkflowAction { /// - Parameter apply: the apply function for the resulting action. @_disfavoredOverload public init( - _ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?, + _ apply: @escaping @MainActor (inout WorkflowType.State) -> WorkflowType.Output?, fileID: StaticString = #fileID, line: UInt = #line ) { @@ -120,6 +122,7 @@ extension AnyWorkflowAction { /// Creates a type-erased workflow action that simply sends the given output event. /// /// - Parameter output: The output event to send when this action is applied. + @MainActor public init(sendingOutput output: WorkflowType.Output) { self = AnyWorkflowAction { _, _ in output diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 813f5f6de..545bca0eb 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -32,6 +32,7 @@ public protocol WorkflowDebugger { } /// Manages an active workflow hierarchy. +@MainActor public final class WorkflowHost { private let outputSubject = PassthroughSubject() @@ -40,6 +41,15 @@ public final class WorkflowHost { private let renderingSubject: CurrentValueSubject + private let renderingMulticaster = AsyncMulticaster() + private let outputMulticaster = AsyncMulticaster() + + /// Erased hooks into the multicasters, installed lazily by the `renderings` + /// and `outputs` accessors (whose extensions know the element types are + /// `Sendable`). `nil` until the corresponding stream is first requested. + private var yieldRendering: ((WorkflowType.Rendering) -> Void)? + private var yieldOutput: ((WorkflowType.Output) -> Void)? + /// 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 var rendering: WorkflowType.Rendering { @@ -62,6 +72,11 @@ public final class WorkflowHost { let sinkEventHandler: SinkEventHandler + /// Finalization work to perform when the host deinits. Stored as a + /// main-actor closure so the (nonisolated) deinit can schedule it on the + /// main actor without capturing the non-Sendable subjects directly. + private let finalizeOnMainActor: @MainActor () -> Void + /// Initializes a new host with the given workflow at the root. /// /// - Parameter workflow: The root workflow in the hierarchy @@ -91,13 +106,22 @@ public final class WorkflowHost { onSinkEvent: sinkEventCallback ) - self.rootNode = WorkflowNode( + let rootNode = WorkflowNode( workflow: workflow, hostContext: context, parentSession: nil ) + self.rootNode = rootNode + + self.renderingSubject = WorkflowRuntimeActivity.perform { + CurrentValueSubject(rootNode.render()) + } + + self.finalizeOnMainActor = { [renderingSubject, outputSubject] in + renderingSubject.send(completion: .finished) + outputSubject.send(completion: .finished) + } - self.renderingSubject = CurrentValueSubject(rootNode.render()) rootNode.enableEvents() debugger?.didEnterInitialState(snapshot: rootNode.makeDebugSnapshot()) @@ -108,18 +132,19 @@ public final class WorkflowHost { } deinit { - renderingSubject.send(completion: .finished) - outputSubject.send(completion: .finished) + finalizeFromDeinit(finalizeOnMainActor) } /// Update the input for the workflow. Will cause a render pass. public func update(workflow: WorkflowType) { - if context.runtimeConfig.useSinkEventHandler { - sinkEventHandler.withEventHandlingSuspended { + WorkflowRuntimeActivity.perform { + if context.runtimeConfig.useSinkEventHandler { + sinkEventHandler.withEventHandlingSuspended { + updateRootNode(workflow: workflow) + } + } else { updateRootNode(workflow: workflow) } - } else { - updateRootNode(workflow: workflow) } } @@ -141,14 +166,23 @@ public final class WorkflowHost { } private func handle(output: WorkflowNode.Output) { + WorkflowRuntimeActivity.perform { + handleMarkedActive(output: output) + } + } + + private func handleMarkedActive(output: WorkflowNode.Output) { let shouldRender = !shouldSkipRenderForOutput(output) if shouldRender { - renderingSubject.send(rootNode.render()) + let rendering = rootNode.render() + renderingSubject.send(rendering) + yieldRendering?(rendering) } // Always emit an output, regardless of whether a render occurs if let outputEvent = output.outputEvent { outputSubject.send(outputEvent) + yieldOutput?(outputEvent) } debugger?.didUpdate( @@ -168,6 +202,45 @@ public final class WorkflowHost { } } +extension WorkflowHost where WorkflowType.Rendering: Sendable { + /// An asynchronous sequence of the `Rendering` values produced by the root + /// workflow in the hierarchy. Yields the most recent `Rendering` when + /// iteration begins, followed by a new value after each subsequent render + /// pass. A slow consumer only ever observes the latest rendering; stale + /// intermediate values are dropped. + /// + /// Each access returns an independent stream. Obtain a fresh stream per + /// consumer; a single stream must not be iterated more than once. + public var renderings: AsyncStream { + if yieldRendering == nil { + yieldRendering = { [renderingMulticaster] in + renderingMulticaster.yield($0) + } + } + return renderingMulticaster.makeStream( + bufferingPolicy: .bufferingNewest(1), + initial: renderingSubject.value + ) + } +} + +extension WorkflowHost where WorkflowType.Output: Sendable { + /// An asynchronous sequence of the output events emitted by the root + /// workflow in the hierarchy. Every output emitted after the stream is + /// created is delivered, in order — the stream buffers without dropping. + /// + /// Each access returns an independent stream. Obtain a fresh stream per + /// consumer; a single stream must not be iterated more than once. + public var outputs: AsyncStream { + if yieldOutput == nil { + yieldOutput = { [outputMulticaster] in + outputMulticaster.yield($0) + } + } + return outputMulticaster.makeStream(bufferingPolicy: .unbounded) + } +} + // MARK: - Conditional Rendering Utilities extension WorkflowHost { @@ -228,6 +301,7 @@ typealias OnSinkEvent = ( /// Handles events from 'Sinks' such that runtime-level event handling state is appropriately /// managed, and attempts to perform reentrant action handling can be detected and dealt with. +@MainActor final class SinkEventHandler { enum State { /// Ready to handle an event. @@ -259,7 +333,13 @@ final class SinkEventHandler { withEventHandlingSuspended(immediate) case .busy: - DispatchQueue.workflowExecution.async(execute: deferred) + // Delivery stays on the main actor; relative ordering with other + // main-queue work is best-effort per Task scheduling semantics. + // Non-Sendable captures are legal because creation context and + // Task isolation are both MainActor (no region crossing). + Task { @MainActor in + deferred() + } } } diff --git a/Workflow/Sources/WorkflowLogger.swift b/Workflow/Sources/WorkflowLogger.swift index 4f75eb45b..8bd8368b1 100644 --- a/Workflow/Sources/WorkflowLogger.swift +++ b/Workflow/Sources/WorkflowLogger.swift @@ -24,6 +24,7 @@ extension OSLog { /// The active log handle to use when logging. If `WorkflowLogging.osLoggingSupported` is /// `true`, defaults to the `workflow` handle, otherwise defaults to the shared `.disabled` /// handle. + @MainActor fileprivate static var active: OSLog = WorkflowLogging.isOSLoggingAllowed ? .workflow : .disabled } @@ -47,9 +48,9 @@ extension WorkflowLogging { } extension WorkflowLogging { - public struct Config { + public struct Config: Sendable { /// Configuration options to control logging during a render pass. - public enum RenderLoggingMode { + public enum RenderLoggingMode: Sendable { /// No data will be recorded for WorkflowNode render timings. case none @@ -81,6 +82,7 @@ extension WorkflowLogging { /// /// If you wish for more control over what the runtime will log, you may additionally specify /// a custom value for `WorkflowLogging.config`. + @MainActor public static var enabled: Bool { get { OSLog.active === OSLog.workflow } set { @@ -90,6 +92,7 @@ extension WorkflowLogging { } /// Configuration options used to determine which activities are logged. + @MainActor public static var config: Config = .rootRendersAndActions } @@ -114,7 +117,12 @@ final class SignpostRef { enum WorkflowLogger { // MARK: Workflows - static func logWorkflowStarted(ref: WorkflowNode) { + /// Logs the start of a node's "Alive" interval. `ref` anchors the signpost + /// identity and must be the same object later passed to + /// `logWorkflowFinished(ref:)`; a `SignpostRef` owned by the node lets the + /// end of the interval be logged after the node itself is gone. + @MainActor + static func logWorkflowStarted(ref: AnyObject, workflowType: String) { guard WorkflowLogging.isOSLoggingAllowed, WorkflowLogging.config.logLifetimes @@ -127,11 +135,12 @@ enum WorkflowLogger { name: "Alive", signpostID: signpostID, "Workflow: %{public}@", - String(describing: WorkflowType.self) + workflowType ) } - static func logWorkflowFinished(ref: WorkflowNode) { + @MainActor + static func logWorkflowFinished(ref: AnyObject) { guard WorkflowLogging.isOSLoggingAllowed, WorkflowLogging.config.logLifetimes @@ -141,6 +150,7 @@ enum WorkflowLogger { os_signpost(.end, log: .active, name: "Alive", signpostID: signpostID) } + @MainActor static func logSinkEvent(ref: AnyObject, action: Action) { guard WorkflowLogging.isOSLoggingAllowed, @@ -160,6 +170,7 @@ enum WorkflowLogger { // MARK: Rendering + @MainActor static func logWorkflowStartedRendering( ref: WorkflowNode ) { @@ -178,6 +189,7 @@ enum WorkflowLogger { ) } + @MainActor static func logWorkflowFinishedRendering( ref: WorkflowNode ) { @@ -191,6 +203,7 @@ enum WorkflowLogger { // MARK: - Utilities + @MainActor private static func shouldLogRenderTimings( isRootNode: @autoclosure () -> Bool ) -> Bool { diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index ea934de02..b5c3abd8e 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -15,6 +15,7 @@ */ /// Manages a running workflow. +@MainActor final class WorkflowNode { /// The current `State` of the node's `Workflow`. private var state: WorkflowType.State @@ -41,6 +42,15 @@ final class WorkflowNode { lazy var hasVoidState: Bool = WorkflowType.State.self == Void.self + /// Anchors the node's "Alive" signpost interval to a pointer identity + /// that the deinit-scheduled finalization can safely outlive `self` with. + private let signpostRef = SignpostRef() + + /// Finalization work to perform when the node deinits. Stored as a + /// main-actor closure so the (nonisolated) deinit can schedule it on the + /// main actor without capturing `self` or its non-Sendable dependencies. + private let finalizeOnMainActor: @MainActor () -> Void + init( workflow: WorkflowType, key: String = "", @@ -64,13 +74,27 @@ final class WorkflowNode { self.state = workflow.makeInitialState() + self.finalizeOnMainActor = { [subtreeManager, observer = hostContext.observer, session, signpostRef] in + // Capturing the subtree manager defers the subtree's teardown + // (child deinits, side-effect terminations) to this scheduled + // finalization, so it can't interleave with the main-actor work + // that released the node — e.g. an in-progress render pass. + withExtendedLifetime(subtreeManager) { + observer?.sessionDidEnd(session) + WorkflowLogger.logWorkflowFinished(ref: signpostRef) + } + } + observer?.workflowDidMakeInitialState( workflow, initialState: state, session: session ) - WorkflowLogger.logWorkflowStarted(ref: self) + WorkflowLogger.logWorkflowStarted( + ref: signpostRef, + workflowType: String(describing: WorkflowType.self) + ) subtreeManager.onUpdate = { [weak self] output in self?.handle(subtreeOutput: output) @@ -78,8 +102,7 @@ final class WorkflowNode { } deinit { - observer?.sessionDidEnd(session) - WorkflowLogger.logWorkflowFinished(ref: self) + finalizeFromDeinit(finalizeOnMainActor) } /// Handles an event produced by the subtree manager diff --git a/Workflow/Sources/WorkflowObserver.swift b/Workflow/Sources/WorkflowObserver.swift index f6693e686..ce663036a 100644 --- a/Workflow/Sources/WorkflowObserver.swift +++ b/Workflow/Sources/WorkflowObserver.swift @@ -104,14 +104,22 @@ public protocol WorkflowObserver { /// - An optional reference to a parent `WorkflowSession`, to differentiate root nodes. public struct WorkflowSession { public struct Identifier: Hashable { + @MainActor private static var _nextRawID: UInt64 = 0 + + @MainActor private static func _makeNextSessionID() -> UInt64 { let nextID = _nextRawID _nextRawID &+= 1 return nextID } - let rawIdentifier: UInt64 = Self._makeNextSessionID() + let rawIdentifier: UInt64 + + @MainActor + init() { + self.rawIdentifier = Self._makeNextSessionID() + } } /// As structs cannot contain stored properties of their own type, we use an indirect enum @@ -134,7 +142,7 @@ public struct WorkflowSession { public let renderKey: String - public let sessionID = Identifier() + public let sessionID: Identifier private let _indirectParent: IndirectParent public var parent: WorkflowSession? { @@ -146,12 +154,12 @@ public struct WorkflowSession { } } - /// Creates a new `WorkflowSession` instance. Note, construction of this type - /// is not safe to perform concurrently with respect to other instance initialization. + /// Creates a new `WorkflowSession` instance. /// - Parameters: /// - workflow: The associated `Workflow` instance /// - renderKey: The string key used to render `workflow` /// - parent: The parent Workflow's session, if any + @MainActor init( workflow: WorkflowType, renderKey: String, @@ -159,6 +167,7 @@ public struct WorkflowSession { ) { self.workflowType = WorkflowType.self self.renderKey = renderKey + self.sessionID = Identifier() self._indirectParent = IndirectParent(parent) } } @@ -346,9 +355,11 @@ public protocol ObserversInterceptor { @_spi(WorkflowGlobalObservation) public enum WorkflowObservation { + @MainActor private static var _sharedInterceptorStorage: ObserversInterceptor = NoOpObserversInterceptor() /// The `DefaultObserversProvider` used by all runtimes. + @MainActor public static var sharedObserversInterceptor: ObserversInterceptor! { get { _sharedInterceptorStorage diff --git a/Workflow/Tests/AnyWorkflowActionTests.swift b/Workflow/Tests/AnyWorkflowActionTests.swift index 6af882ebe..685ab23ff 100644 --- a/Workflow/Tests/AnyWorkflowActionTests.swift +++ b/Workflow/Tests/AnyWorkflowActionTests.swift @@ -17,6 +17,7 @@ import XCTest @testable import Workflow +@MainActor final class AnyWorkflowActionTests: XCTestCase { func testRetainsBaseActionTypeInfo() { let action = ExampleAction() diff --git a/Workflow/Tests/AnyWorkflowTests.swift b/Workflow/Tests/AnyWorkflowTests.swift index 3c4faeb68..d723c196d 100644 --- a/Workflow/Tests/AnyWorkflowTests.swift +++ b/Workflow/Tests/AnyWorkflowTests.swift @@ -18,6 +18,7 @@ import Combine import XCTest @testable import Workflow +@MainActor public class AnyWorkflowTests: XCTestCase { func testRendersWrappedWorkflow() { let workflow = AnyWorkflow(SimpleWorkflow(string: "asdf")) @@ -106,6 +107,7 @@ extension PassthroughWorkflow { State() } + @MainActor func render(state: State, context: RenderContext>) -> Rendering { child.rendered(in: context) } diff --git a/Workflow/Tests/AsyncMulticasterTests.swift b/Workflow/Tests/AsyncMulticasterTests.swift new file mode 100644 index 000000000..0b2f8bcfa --- /dev/null +++ b/Workflow/Tests/AsyncMulticasterTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import Workflow + +@MainActor +final class AsyncMulticasterTests: XCTestCase { + func test_eachConsumerReceivesEveryValue() async { + let multicaster = AsyncMulticaster() + + let streamA = multicaster.makeStream(bufferingPolicy: .unbounded) + let streamB = multicaster.makeStream(bufferingPolicy: .unbounded) + + multicaster.yield(1) + multicaster.yield(2) + multicaster.finish() + + var receivedA: [Int] = [] + for await value in streamA { + receivedA.append(value) + } + var receivedB: [Int] = [] + for await value in streamB { + receivedB.append(value) + } + + XCTAssertEqual(receivedA, [1, 2]) + XCTAssertEqual(receivedB, [1, 2]) + } + + func test_initialValueIsYieldedFirst() async { + let multicaster = AsyncMulticaster() + let stream = multicaster.makeStream(bufferingPolicy: .unbounded, initial: 0) + + multicaster.yield(1) + multicaster.finish() + + var received: [Int] = [] + for await value in stream { + received.append(value) + } + XCTAssertEqual(received, [0, 1]) + } + + func test_bufferingNewestDropsStaleValues() async { + let multicaster = AsyncMulticaster() + let stream = multicaster.makeStream(bufferingPolicy: .bufferingNewest(1), initial: 0) + + // Consumer hasn't started; only the newest value should survive. + multicaster.yield(1) + multicaster.yield(2) + multicaster.yield(3) + multicaster.finish() + + var received: [Int] = [] + for await value in stream { + received.append(value) + } + XCTAssertEqual(received, [3]) + } + + func test_streamsMadeAfterFinishAreImmediatelyFinished() async { + let multicaster = AsyncMulticaster() + multicaster.finish() + + let stream = multicaster.makeStream(bufferingPolicy: .unbounded) + var received: [Int] = [] + for await value in stream { + received.append(value) + } + XCTAssertEqual(received, []) + } + + func test_streamsMadeAfterFinishAreImmediatelyFinished_evenWithInitialValue() async { + let multicaster = AsyncMulticaster() + multicaster.finish() + + let stream = multicaster.makeStream(bufferingPolicy: .unbounded, initial: 42) + var received: [Int] = [] + for await value in stream { + received.append(value) + } + XCTAssertEqual(received, []) + } + + func test_deallocation_finishesStreams() async { + var multicaster: AsyncMulticaster? = AsyncMulticaster() + let stream = multicaster!.makeStream(bufferingPolicy: .unbounded) + multicaster!.yield(1) + multicaster = nil + + var received: [Int] = [] + for await value in stream { + received.append(value) + } + XCTAssertEqual(received, [1]) + } +} diff --git a/Workflow/Tests/AsyncSideEffectTests.swift b/Workflow/Tests/AsyncSideEffectTests.swift new file mode 100644 index 000000000..8f4265791 --- /dev/null +++ b/Workflow/Tests/AsyncSideEffectTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import Workflow + +@MainActor +final class AsyncSideEffectTests: XCTestCase { + func test_asyncSideEffect_starts_and_isCancelledWhenKeyDisappears() async { + let started = expectation(description: "side effect started") + let cancelled = expectation(description: "side effect cancelled") + + let host = WorkflowHost( + workflow: ToggleWorkflow( + runEffect: true, + onStart: { started.fulfill() }, + onCancel: { cancelled.fulfill() } + ) + ) + + await fulfillment(of: [started], timeout: 1) + + // Re-render without the side effect: the key disappears, the Task + // must receive cooperative cancellation. + host.update(workflow: ToggleWorkflow(runEffect: false, onStart: {}, onCancel: {})) + + await fulfillment(of: [cancelled], timeout: 1) + } + + func test_asyncSideEffect_runsOnlyOncePerKey_acrossRenders() async { + let counter = StartCounter() + let ran = expectation(description: "side effect ran") + // Over-fulfillment (a second Task starting) fails the test. + ran.assertForOverFulfill = true + + let host = WorkflowHost( + workflow: CountingWorkflow(counter: counter, onRun: { ran.fulfill() }) + ) + host.update(workflow: CountingWorkflow(counter: counter, onRun: { ran.fulfill() })) + host.update(workflow: CountingWorkflow(counter: counter, onRun: { ran.fulfill() })) + + await fulfillment(of: [ran], timeout: 1) + XCTAssertEqual(counter.count, 1) + } + + // MARK: - Fixtures + + fileprivate final class StartCounter: @unchecked Sendable { + private let lock = NSLock() + private var _count = 0 + var count: Int { lock.withLock { _count } } + func increment() { lock.withLock { _count += 1 } } + } + + fileprivate struct ToggleWorkflow: Workflow { + var runEffect: Bool + var onStart: @Sendable () -> Void + var onCancel: @Sendable () -> Void + + typealias State = Void + typealias Rendering = Void + + func render(state: State, context: RenderContext) { + if runEffect { + let onStart = onStart + let onCancel = onCancel + context.runSideEffect(key: "effect") { + onStart() + do { + try await Task.sleep(nanoseconds: 10000000000) + } catch is CancellationError { + onCancel() + } catch {} + } + } + } + } + + fileprivate struct CountingWorkflow: Workflow { + var counter: StartCounter + var onRun: @Sendable () -> Void + + typealias State = Void + typealias Rendering = Void + + func render(state: State, context: RenderContext) { + let counter = counter + let onRun = onRun + context.runSideEffect(key: "count") { + counter.increment() + onRun() + } + } + } +} diff --git a/Workflow/Tests/ConcurrencyTests.swift b/Workflow/Tests/ConcurrencyTests.swift index 400ca8743..856a699f3 100644 --- a/Workflow/Tests/ConcurrencyTests.swift +++ b/Workflow/Tests/ConcurrencyTests.swift @@ -18,6 +18,7 @@ import Combine import XCTest @testable import Workflow +@MainActor final class ConcurrencyTests: XCTestCase { // Applying an action from a sink must synchronously update the rendering. func test_sinkRenderLoopIsSynchronous() { diff --git a/Workflow/Tests/HostContextTests.swift b/Workflow/Tests/HostContextTests.swift index 846bbf87d..e9a22fa29 100644 --- a/Workflow/Tests/HostContextTests.swift +++ b/Workflow/Tests/HostContextTests.swift @@ -2,6 +2,7 @@ import XCTest @testable import Workflow +@MainActor final class HostContextTests: XCTestCase { func test_conditional_debug_info_no_debugger() { let subject = HostContext.testing(debugger: nil) diff --git a/Workflow/Tests/PerformanceTests.swift b/Workflow/Tests/PerformanceTests.swift index cbd0ff6ee..022486b54 100644 --- a/Workflow/Tests/PerformanceTests.swift +++ b/Workflow/Tests/PerformanceTests.swift @@ -18,6 +18,7 @@ import XCTest @testable import Workflow +@MainActor class PerformanceTests: XCTestCase { var noOpObserver: WorkflowObserver { struct NoOpObserverImpl: WorkflowObserver {} diff --git a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift index ba69c4a27..a70fc82f2 100644 --- a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift +++ b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift @@ -20,6 +20,7 @@ import XCTest @_spi(WorkflowRuntimeConfig) @testable import Workflow +@MainActor final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { override func invokeTest() { Runtime.withConfiguration { cfg in @@ -208,6 +209,7 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { } } +@MainActor private func withRenderOnlyIfStateChangedDisabled( _ perform: () -> Void ) { diff --git a/Workflow/Tests/SinkEventHandlerTests.swift b/Workflow/Tests/SinkEventHandlerTests.swift index 68107e4f7..944ef21f9 100644 --- a/Workflow/Tests/SinkEventHandlerTests.swift +++ b/Workflow/Tests/SinkEventHandlerTests.swift @@ -18,6 +18,7 @@ import Testing @testable import Workflow +@MainActor struct SinkEventHandlerTests { @Test func initialState() async throws { diff --git a/Workflow/Tests/StateMutationSinkTests.swift b/Workflow/Tests/StateMutationSinkTests.swift index 06a376335..f6711acf7 100644 --- a/Workflow/Tests/StateMutationSinkTests.swift +++ b/Workflow/Tests/StateMutationSinkTests.swift @@ -18,6 +18,7 @@ import Combine import Workflow import XCTest +@MainActor final class StateMutationSinkTests: XCTestCase { var input: PassthroughSubject! diff --git a/Workflow/Tests/SubtreeManagerTests.swift b/Workflow/Tests/SubtreeManagerTests.swift index d4dad8a16..9daba0a4f 100644 --- a/Workflow/Tests/SubtreeManagerTests.swift +++ b/Workflow/Tests/SubtreeManagerTests.swift @@ -19,6 +19,7 @@ import XCTest @testable import Workflow +@MainActor final class SubtreeManagerTests: XCTestCase { func test_maintainsChildrenBetweenRenderPasses() { let manager = WorkflowNode.SubtreeManager() @@ -335,6 +336,7 @@ private struct TestWorkflow: Workflow { // MARK: Testing conveniences extension WorkflowSession { + @MainActor fileprivate static func testing() -> WorkflowSession { struct SessionTestWorkflow: Workflow { typealias State = Void diff --git a/Workflow/Tests/TestUtilities.swift b/Workflow/Tests/TestUtilities.swift index 226f7f5c7..8f20330a6 100644 --- a/Workflow/Tests/TestUtilities.swift +++ b/Workflow/Tests/TestUtilities.swift @@ -60,15 +60,16 @@ struct StateTransitioningWorkflow: Workflow { // MARK: - HostContext extension HostContext { + @MainActor static func testing( observer: WorkflowObserver? = nil, debugger: WorkflowDebugger? = nil, - runtimeConfig: Runtime.Configuration = Runtime.configuration + runtimeConfig: Runtime.Configuration? = nil ) -> HostContext { HostContext( observer: observer, debugger: debugger, - runtimeConfig: runtimeConfig, + runtimeConfig: runtimeConfig ?? Runtime.configuration, onSinkEvent: { perform, _ in perform() } ) } diff --git a/Workflow/Tests/WorkflowHostAsyncTests.swift b/Workflow/Tests/WorkflowHostAsyncTests.swift new file mode 100644 index 000000000..09d4e73e4 --- /dev/null +++ b/Workflow/Tests/WorkflowHostAsyncTests.swift @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import XCTest +@testable import Workflow + +@MainActor +final class WorkflowHostAsyncTests: XCTestCase { + func test_renderings_conflatesToNewestForSlowConsumer() async { + let host = WorkflowHost(workflow: EchoWorkflow(value: 1)) + let renderings = host.renderings + + // No consumer is iterating yet; .bufferingNewest(1) keeps only the + // most recent value (updates overwrite the buffered initial value). + host.update(workflow: EchoWorkflow(value: 2)) + host.update(workflow: EchoWorkflow(value: 3)) + + var iterator = renderings.makeAsyncIterator() + let first = await iterator.next() + XCTAssertEqual(first, 3) + } + + func test_renderings_currentValueFirst_whenConsumedBeforeUpdates() async { + let host = WorkflowHost(workflow: EchoWorkflow(value: 1)) + var iterator = host.renderings.makeAsyncIterator() + + let first = await iterator.next() + XCTAssertEqual(first, 1) + } + + func test_outputs_deliversEveryOutputInOrder_noDrops() async { + let host = WorkflowHost(workflow: OutputEmittingWorkflow()) + let outputs = host.outputs + let sink = host.rendering // Rendering is the event-sending closure + + // Emit a burst BEFORE consuming: a .values-style bridge would drop + // these; the buffered stream must not. + for i in 0 ..< 100 { + sink(i) + } + + var received: [Int] = [] + var iterator = outputs.makeAsyncIterator() + for _ in 0 ..< 100 { + if let value = await iterator.next() { + received.append(value) + } + } + + XCTAssertEqual(received, Array(0 ..< 100)) + } + + func test_streams_finishWhenHostIsDeallocated() async { + var host: WorkflowHost? = WorkflowHost(workflow: EchoWorkflow(value: 1)) + let renderings = host!.renderings + host = nil + + var received: [Int] = [] + for await value in renderings { + received.append(value) + } + // Initial value was buffered; then the stream finished. + XCTAssertEqual(received, [1]) + } + + // MARK: - Fixtures + + fileprivate struct EchoWorkflow: Workflow { + var value: Int + typealias State = Void + typealias Rendering = Int + func render(state: State, context: RenderContext) -> Int { value } + } + + /// Renders a closure that emits its argument as an Output. + fileprivate struct OutputEmittingWorkflow: Workflow { + typealias State = Void + typealias Output = Int + typealias Rendering = (Int) -> Void + + func render(state: State, context: RenderContext) -> Rendering { + let sink = context.makeOutputSink() + return { sink.send($0) } + } + } +} diff --git a/Workflow/Tests/WorkflowHostTests.swift b/Workflow/Tests/WorkflowHostTests.swift index a5147d5d4..7bc07f033 100644 --- a/Workflow/Tests/WorkflowHostTests.swift +++ b/Workflow/Tests/WorkflowHostTests.swift @@ -20,6 +20,7 @@ import XCTest @_spi(WorkflowRuntimeConfig) @testable import Workflow +@MainActor final class WorkflowHostTests: XCTestCase { func test_updatedInputCausesRenderPass() { let host = WorkflowHost(workflow: TestWorkflow(step: .first)) @@ -58,6 +59,7 @@ final class WorkflowHostTests: XCTestCase { // MARK: Event Emission Tests +@MainActor final class WorkflowHost_EventEmissionTests: XCTestCase { // Previous versions of Workflow would fatalError under this scenario func test_event_sent_to_invalidated_sink_during_action_handling() { @@ -396,6 +398,7 @@ extension WorkflowHost_EventEmissionTests { // MARK: Lifecycle Tests +@MainActor final class WorkflowHost_LifecycleTests: XCTestCase { func test_renderingPublisherCompletesWhenHostIsReleased() { var host: WorkflowHost? = WorkflowHost(workflow: OutputWorkflow()) diff --git a/Workflow/Tests/WorkflowNodeTests.swift b/Workflow/Tests/WorkflowNodeTests.swift index 96ad90d78..35626b4fd 100644 --- a/Workflow/Tests/WorkflowNodeTests.swift +++ b/Workflow/Tests/WorkflowNodeTests.swift @@ -17,6 +17,7 @@ import XCTest @testable import Workflow +@MainActor final class WorkflowNodeTests: XCTestCase { func test_rendersSimpleWorkflow() { let node = WorkflowNode(workflow: SimpleWorkflow(string: "Foo")) @@ -334,6 +335,7 @@ extension CompositeWorkflow { State() } + @MainActor func render(state: State, context: RenderContext>) -> Rendering { Rendering( aRendering: a @@ -414,6 +416,7 @@ extension EventEmittingWorkflow { case helloWorld } + @MainActor func render(state: State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Event.self) diff --git a/Workflow/Tests/WorkflowObserverTests.swift b/Workflow/Tests/WorkflowObserverTests.swift index c6f210fe0..b47114f3c 100644 --- a/Workflow/Tests/WorkflowObserverTests.swift +++ b/Workflow/Tests/WorkflowObserverTests.swift @@ -18,6 +18,7 @@ import XCTest @testable @_spi(WorkflowGlobalObservation) import Workflow +@MainActor final class WorkflowObserverTests: XCTestCase { private var observer: TestObserver! @@ -56,6 +57,11 @@ final class WorkflowObserverTests: XCTestCase { } XCTAssertNil(weakHost, "host expected to deallocate") + + // Deinit finalization (including `sessionDidEnd`) is scheduled onto + // the main actor rather than run inline; let it run. + drainMainQueueBySpinningRunLoop() + XCTAssertNotNil(beganSession) XCTAssertNotNil(beganSession?.sessionID) XCTAssertEqual(beganSession?.sessionID, endedSession?.sessionID) @@ -653,6 +659,7 @@ private struct InjectableWorkflow: Workflow { extension WorkflowSession { fileprivate var workflowTypeString: String { String(describing: workflowType) } + @MainActor fileprivate static var testingSession: Self { WorkflowSession( workflow: Parent(), diff --git a/WorkflowCombine/Sources/Publisher+Extensions.swift b/WorkflowCombine/Sources/Publisher+Extensions.swift index b65cf7ea1..fcdd1dd10 100644 --- a/WorkflowCombine/Sources/Publisher+Extensions.swift +++ b/WorkflowCombine/Sources/Publisher+Extensions.swift @@ -16,13 +16,17 @@ import Workflow /// but was limited in the fact that rendering was only available to `AnyPublisher`s. /// this solutions makes it so that all publishers can render its view. extension Publisher where Failure == Never { + @MainActor public func running(in context: RenderContext, key: String = "") where Output == AnyWorkflowAction { asAnyWorkflow().rendered(in: context, key: key, outputMap: { $0 }) } - public func mapOutput(_ transform: @escaping (Output) -> NewOutput) -> AnyWorkflow { + // Note: `@Sendable` is spelled explicitly here because this module compiles in Swift 5 mode; + // in the core module's Swift 6 mode, `@MainActor` function types are implicitly `Sendable`, + // so this is the same type as `AnyWorkflow.mapOutput`'s parameter. + public func mapOutput(_ transform: @escaping @MainActor @Sendable (Output) -> NewOutput) -> AnyWorkflow { asAnyWorkflow().mapOutput(transform) } diff --git a/WorkflowCombine/TestingTests/PublisherTests.swift b/WorkflowCombine/TestingTests/PublisherTests.swift index dbe280cd8..e4d8e47e7 100644 --- a/WorkflowCombine/TestingTests/PublisherTests.swift +++ b/WorkflowCombine/TestingTests/PublisherTests.swift @@ -12,6 +12,7 @@ import WorkflowTesting import XCTest @testable import WorkflowCombineTesting +@MainActor class PublisherTests: XCTestCase { func testPublisherWorkflow() { TestWorkflow() diff --git a/WorkflowCombine/TestingTests/TestingTests.swift b/WorkflowCombine/TestingTests/TestingTests.swift index 1019d5149..497a317b0 100644 --- a/WorkflowCombine/TestingTests/TestingTests.swift +++ b/WorkflowCombine/TestingTests/TestingTests.swift @@ -21,6 +21,7 @@ import WorkflowCombineTesting import WorkflowTesting import XCTest +@MainActor class WorkflowCombineTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() diff --git a/WorkflowCombine/Tests/PublisherTests.swift b/WorkflowCombine/Tests/PublisherTests.swift index 5021ecfdd..25aeaad5d 100644 --- a/WorkflowCombine/Tests/PublisherTests.swift +++ b/WorkflowCombine/Tests/PublisherTests.swift @@ -21,6 +21,7 @@ import WorkflowCombineTesting import XCTest @testable import WorkflowCombine +@MainActor class PublisherTests: XCTestCase { func test_publisherWorkflow_usesSideEffectWithKey() { PublisherWorkflow(publisher: Just(1)) diff --git a/WorkflowCombine/Tests/WorkerTests.swift b/WorkflowCombine/Tests/WorkerTests.swift index be465c1af..3618949c1 100644 --- a/WorkflowCombine/Tests/WorkerTests.swift +++ b/WorkflowCombine/Tests/WorkerTests.swift @@ -19,6 +19,7 @@ import Workflow import XCTest @testable import WorkflowCombine +@MainActor class WorkerTests: XCTestCase { func testExpectedWorker() { PublisherTestWorkflow(key: "123") diff --git a/WorkflowConcurrency/Sources/Worker.swift b/WorkflowConcurrency/Sources/Worker.swift index c0695086d..8eea2695e 100644 --- a/WorkflowConcurrency/Sources/Worker.swift +++ b/WorkflowConcurrency/Sources/Worker.swift @@ -74,7 +74,7 @@ struct WorkerWorkflow: Workflow { } logger.logOutput() logger.logFinished(status: "Finished") - await send(output) + send(output) } lifetime.onEnded { task.cancel() diff --git a/WorkflowConcurrency/TestingTests/TestingTests.swift b/WorkflowConcurrency/TestingTests/TestingTests.swift index e049eedb7..0fc3f27af 100644 --- a/WorkflowConcurrency/TestingTests/TestingTests.swift +++ b/WorkflowConcurrency/TestingTests/TestingTests.swift @@ -20,6 +20,7 @@ import WorkflowConcurrencyTesting import WorkflowTesting import XCTest +@MainActor class WorkflowConcurrencyTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() diff --git a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift index a4ea70830..c5432907e 100644 --- a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift @@ -20,6 +20,7 @@ import WorkflowTesting import XCTest @testable import WorkflowConcurrency +@MainActor final class AsyncOperationWorkerTests: XCTestCase { func testWorkerOutput() { let host = WorkflowHost( diff --git a/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift b/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift index 716494e67..40ef77fc1 100644 --- a/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift @@ -4,6 +4,7 @@ import WorkflowTesting import XCTest @testable import WorkflowConcurrency +@MainActor class AsyncSequenceWorkerTests: XCTestCase { func testWorkerOutput() { let host = WorkflowHost( diff --git a/WorkflowConcurrency/Tests/WorkerTests.swift b/WorkflowConcurrency/Tests/WorkerTests.swift index 2e180c130..328bb8d01 100644 --- a/WorkflowConcurrency/Tests/WorkerTests.swift +++ b/WorkflowConcurrency/Tests/WorkerTests.swift @@ -20,6 +20,7 @@ import WorkflowTesting import XCTest @testable import WorkflowConcurrency +@MainActor class WorkerTests: XCTestCase { func testWorkerOutput() { let host = WorkflowHost( diff --git a/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift b/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift index 7f3e098da..87dc77a74 100644 --- a/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift +++ b/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift @@ -20,6 +20,7 @@ import Workflow import WorkflowReactiveSwiftTesting import XCTest +@MainActor class SignalProducerTests: XCTestCase { func test_signalProducerWorkflow() { TestWorkflow() diff --git a/WorkflowReactiveSwift/TestingTests/TestingTests.swift b/WorkflowReactiveSwift/TestingTests/TestingTests.swift index 5c93791a3..e10f22e5c 100644 --- a/WorkflowReactiveSwift/TestingTests/TestingTests.swift +++ b/WorkflowReactiveSwift/TestingTests/TestingTests.swift @@ -21,6 +21,7 @@ import WorkflowReactiveSwiftTesting import WorkflowTesting import XCTest +@MainActor class WorkflowReactiveSwiftTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() diff --git a/WorkflowReactiveSwift/Tests/SignalProducerTests.swift b/WorkflowReactiveSwift/Tests/SignalProducerTests.swift index 8855f9769..b3d56f94a 100644 --- a/WorkflowReactiveSwift/Tests/SignalProducerTests.swift +++ b/WorkflowReactiveSwift/Tests/SignalProducerTests.swift @@ -21,6 +21,7 @@ import XCTest @testable import Workflow @testable import WorkflowReactiveSwift +@MainActor class SignalProducerTests: XCTestCase { func test_signalProducerWorkflow_usesSideEffectWithKey() { let signalProducer = SignalProducer(value: 1) diff --git a/WorkflowReactiveSwift/Tests/SignalTests.swift b/WorkflowReactiveSwift/Tests/SignalTests.swift index d61ed034e..0fa1ba623 100644 --- a/WorkflowReactiveSwift/Tests/SignalTests.swift +++ b/WorkflowReactiveSwift/Tests/SignalTests.swift @@ -20,6 +20,7 @@ import ReactiveSwift import XCTest @testable import Workflow +@MainActor class SignalTests: XCTestCase { func test_output() { let (signal, observer) = Signal.pipe() diff --git a/WorkflowReactiveSwift/Tests/WorkerTests.swift b/WorkflowReactiveSwift/Tests/WorkerTests.swift index f57fa9e97..c10576297 100644 --- a/WorkflowReactiveSwift/Tests/WorkerTests.swift +++ b/WorkflowReactiveSwift/Tests/WorkerTests.swift @@ -21,6 +21,7 @@ import WorkflowTesting import XCTest @testable import WorkflowReactiveSwift +@MainActor class WorkerTests: XCTestCase { func testExpectedWorker() { SignalProducerTestWorkflow(key: "123") diff --git a/WorkflowRxSwift/TestingTests/ObservableTests.swift b/WorkflowRxSwift/TestingTests/ObservableTests.swift index 08b957425..5f0048f23 100644 --- a/WorkflowRxSwift/TestingTests/ObservableTests.swift +++ b/WorkflowRxSwift/TestingTests/ObservableTests.swift @@ -20,6 +20,7 @@ import Workflow import WorkflowRxSwiftTesting import XCTest +@MainActor class ObservableTests: XCTestCase { func testObservableWorkflow() { TestWorkflow() diff --git a/WorkflowRxSwift/TestingTests/TestingTests.swift b/WorkflowRxSwift/TestingTests/TestingTests.swift index 6ff6299fa..aa413ba99 100644 --- a/WorkflowRxSwift/TestingTests/TestingTests.swift +++ b/WorkflowRxSwift/TestingTests/TestingTests.swift @@ -21,6 +21,7 @@ import WorkflowRxSwiftTesting import WorkflowTesting import XCTest +@MainActor class WorkflowReactiveSwiftTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() diff --git a/WorkflowRxSwift/Tests/ObservableTests.swift b/WorkflowRxSwift/Tests/ObservableTests.swift index 7fa07b2a0..1f8b02153 100644 --- a/WorkflowRxSwift/Tests/ObservableTests.swift +++ b/WorkflowRxSwift/Tests/ObservableTests.swift @@ -21,6 +21,7 @@ import WorkflowTesting import XCTest @testable import WorkflowRxSwift +@MainActor class ObservableTests: XCTestCase { func test_observableWorkflow_usesSideEffectWithKey() { let observable = Observable.just(1) diff --git a/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift b/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift index 8e75c8ba7..ee6da5950 100644 --- a/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift +++ b/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift @@ -22,6 +22,7 @@ import Workflow import WorkflowReactiveSwift import XCTest +@MainActor class Rx_ReactiveWorkersTests: XCTestCase { func test_outputs_fromRxSwiftAndReactiveSwift() { let host = WorkflowHost( diff --git a/WorkflowRxSwift/Tests/WorkerTests.swift b/WorkflowRxSwift/Tests/WorkerTests.swift index 361f93b81..f4094611a 100644 --- a/WorkflowRxSwift/Tests/WorkerTests.swift +++ b/WorkflowRxSwift/Tests/WorkerTests.swift @@ -22,6 +22,7 @@ import WorkflowTesting import XCTest @testable import WorkflowRxSwift +@MainActor class WorkerTests: XCTestCase { func testExpectedWorker() { ObservableTestWorkflow(key: "123") diff --git a/WorkflowSwiftUI/Sources/Store.swift b/WorkflowSwiftUI/Sources/Store.swift index dddb919cb..291312bcc 100644 --- a/WorkflowSwiftUI/Sources/Store.swift +++ b/WorkflowSwiftUI/Sources/Store.swift @@ -65,8 +65,12 @@ public final class Store: Perceptible { /// executes normally by default. private func withPerceptionCheckSuppressed(_ operation: () -> T) -> T { #if DEBUG && canImport(Observation) + // `Runtime.configuration` is main-actor-isolated. Store state is read + // from SwiftUI view bodies on the main actor in practice; if a debug + // read happens elsewhere, skip suppression rather than trap. if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), - Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation + Thread.isMainThread, + MainActor.assumeIsolated({ Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation }) { return _PerceptionLocals.$skipPerceptionChecking.withValue(true, operation: operation) } @@ -174,7 +178,11 @@ extension Store { readState(keyPath: state) } set { - model[keyPath: sink].send(action(newValue)) + // SwiftUI bindings are written on the main thread; `Sink.send` + // is main-actor-isolated. + MainActor.assumeIsolated { + model[keyPath: sink].send(action(newValue)) + } } } } diff --git a/WorkflowSwiftUI/Tests/StoreTests.swift b/WorkflowSwiftUI/Tests/StoreTests.swift index 4fde169df..a59d12f20 100644 --- a/WorkflowSwiftUI/Tests/StoreTests.swift +++ b/WorkflowSwiftUI/Tests/StoreTests.swift @@ -6,6 +6,7 @@ import SwiftUI import XCTest @testable import WorkflowSwiftUI +@MainActor final class StoreTests: XCTestCase { func test_stateRead() { var state = State() diff --git a/WorkflowTesting/Sources/Internal/RenderExpectations.swift b/WorkflowTesting/Sources/Internal/RenderExpectations.swift index 061c4cb1c..9604a2a4a 100644 --- a/WorkflowTesting/Sources/Internal/RenderExpectations.swift +++ b/WorkflowTesting/Sources/Internal/RenderExpectations.swift @@ -59,6 +59,7 @@ extension RenderTester { self.line = line } + @MainActor func apply(context: ContextType) where ContextType: RenderContextType, ContextType.WorkflowType == WorkflowType {} } @@ -70,6 +71,7 @@ extension RenderTester { super.init(key: key, file: file, line: line) } + @MainActor override func apply(context: ContextType) where ContextType: RenderContextType, ContextType.WorkflowType == WorkflowType { let sink = context.makeSink(of: ActionType.self) sink.send(action) diff --git a/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift b/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift index 0970d2f24..91f6574b4 100644 --- a/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift +++ b/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift @@ -49,7 +49,7 @@ extension RenderTester { self.applyContext = applyContext } - func render(workflow: Child, key: String, outputMap: @escaping (Child.Output) -> Action) -> Child.Rendering where Action.WorkflowType == WorkflowType { + func render(workflow: Child, key: String, outputMap: @escaping @MainActor (Child.Output) -> Action) -> Child.Rendering where Action.WorkflowType == WorkflowType { let matchingTypes = expectedWorkflows.compactMap { $0 as? ExpectedWorkflow } guard let expectedWorkflow = matchingTypes.first(where: { $0.key == key }) else { let sameTypeDifferentKeys = matchingTypes.map(\.key) diff --git a/WorkflowTesting/Sources/WorkflowActionTester.swift b/WorkflowTesting/Sources/WorkflowActionTester.swift index e352a54eb..c1868ebe5 100644 --- a/WorkflowTesting/Sources/WorkflowActionTester.swift +++ b/WorkflowTesting/Sources/WorkflowActionTester.swift @@ -110,6 +110,7 @@ public struct WorkflowActionTester where A /// /// - returns: A new state tester containing the state and output (if any) after the update. @discardableResult + @MainActor public func send(action: Action) -> WorkflowActionTester where Action.WorkflowType == WorkflowType { diff --git a/WorkflowTesting/Sources/WorkflowRenderTester.swift b/WorkflowTesting/Sources/WorkflowRenderTester.swift index 7bf9b527b..ae8a63c3f 100644 --- a/WorkflowTesting/Sources/WorkflowRenderTester.swift +++ b/WorkflowTesting/Sources/WorkflowRenderTester.swift @@ -28,6 +28,7 @@ extension Workflow { } /// Returns a `RenderTester` with an initial state provided by `self.makeInitialState()` + @MainActor public func renderTester() -> RenderTester { renderTester(initialState: makeInitialState()) } @@ -257,6 +258,7 @@ public struct RenderTester { /// - assertions: A closure called with the produced rendering for verification /// - Returns: A `RenderTesterResult` that can be used to verify expected resulting state or outputs. @discardableResult + @MainActor public func render( file: StaticString = #file, line: UInt = #line, assertions: (WorkflowType.Rendering) throws -> Void diff --git a/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift b/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift index a13ee4f88..de0b909ab 100644 --- a/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift +++ b/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import WorkflowTesting +@MainActor struct SwiftTestingCompatibilityTests { @Test func testInternalFailureRecordsExpectationFailure_swiftTesting() { @@ -32,6 +33,7 @@ struct SwiftTestingCompatibilityTests { } } +@MainActor final class XCTestCompatibilityTests: XCTestCase { func testInternalFailureRecordsExpectationFailure_xctest() { XCTExpectFailure { diff --git a/WorkflowTesting/Tests/WorkflowActionTesterTests.swift b/WorkflowTesting/Tests/WorkflowActionTesterTests.swift index 719f7dbcc..e98a9852f 100644 --- a/WorkflowTesting/Tests/WorkflowActionTesterTests.swift +++ b/WorkflowTesting/Tests/WorkflowActionTesterTests.swift @@ -19,6 +19,7 @@ import Testing import Workflow @testable import WorkflowTesting +@MainActor struct WorkflowActionTesterTests { @Test func stateTransitions() { TestAction @@ -144,6 +145,7 @@ extension WorkflowActionTesterTests { // that xcodebuild bug is resolved. import XCTest +@MainActor final class WorkflowActionTesterExpectedFailureTests: XCTestCase { func test_old_api_errors_accessing_optional_through_apply_context_without_proper_setup() { withExpectedIssue("reading optional value through context without workflow should fail but not crash") { diff --git a/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift b/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift index 90955f0e6..c1865ed9e 100644 --- a/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift +++ b/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift @@ -23,6 +23,7 @@ import XCTest /// WorkflowRenderTesterFailureTests does. /// /// Tests that the assertion failures actually assert failures. +@MainActor final class WorkflowRenderTesterFailureTests: XCTestCase { var expectedFailureStrings: [String] = [] diff --git a/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift b/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift index 7d48949a2..0d5b9f781 100644 --- a/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift +++ b/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift @@ -19,6 +19,7 @@ import Testing import Workflow import WorkflowTesting +@MainActor struct WorkflowRenderTesterTests { @Test func render() { let renderTester = TestWorkflow(initialText: "initial").renderTester() diff --git a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift index 1d300df56..0c3dcea68 100644 --- a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift +++ b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift @@ -216,4 +216,15 @@ extension WorkflowHostingController: SingleScreenContaining { } } +// MARK: - Async Output Stream + +extension WorkflowHostingController where Output: Sendable { + /// An asynchronous sequence of output events from the bound workflow. + /// Each access returns an independent stream; obtain a fresh stream per + /// consumer. + public var outputs: AsyncStream { + workflowHost.outputs + } +} + #endif diff --git a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift index 723feced1..8dccfb869 100644 --- a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift +++ b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift @@ -99,6 +99,20 @@ class WorkflowHostingControllerTests: XCTestCase { cancellable.cancel() } + func test_outputs_asyncStream_deliversWorkflowOutput() async { + let (signal, observer) = Signal.pipe() + let workflow = SubscribingWorkflow(subscription: signal) + let container = WorkflowHostingController(workflow: workflow) + + var iterator = container.outputs.makeAsyncIterator() + + observer.send(value: 3) + + let output = await iterator.next() + + XCTAssertEqual(3, output) + } + func test_container_with_anyworkflow() { let (signal, observer) = Signal.pipe() let workflow = SubscribingWorkflow(subscription: signal)