diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb83ac..4aad188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `EffectView` — SwiftUI view implementing an Elm-style update loop with explicit side effects. - `Effect` — enum with `.task`, `.action`, `.event`, `.cancel`, and `.sequence` cases. -- `Input` — event dispatcher with `send(_:)` (sync), `enqueue(_:)` (any actor), and `perform(_:)` (async, suspends until the update chain completes). +- `Input` — event dispatcher with `send(_:)` (sync), `post(_:)` (fire-and-forget), and `request(_:)` (async, suspends until the update chain completes). - `EnvReader` — helper view for reading an environment value and passing it to a content closure. - `initialEvent` parameter on `EffectView` for firing a startup event on first appear. - `initialEnv` parameter for injecting dependencies captured for the lifetime of the view identity. -- Hosted test suite covering lifecycle, state propagation, `initialEvent`, `perform`, task effects, cancel, action chains, sequence effects, identity reset, and env forwarding. +- Hosted test suite covering lifecycle, state propagation, `initialEvent`, `request`, task effects, cancel, action chains, sequence effects, identity reset, and env forwarding. - GitHub Actions CI workflow (`swift test` on macOS). [Unreleased]: https://github.com/couchdeveloper/EffectView/compare/0.1.0...HEAD diff --git a/Documentation/ArchitecturalComparison.md b/Documentation/ArchitecturalComparison.md index 328701f..182acb5 100644 --- a/Documentation/ArchitecturalComparison.md +++ b/Documentation/ArchitecturalComparison.md @@ -26,7 +26,7 @@ In the most common implementation, `@Observable` properties are fully public and **Effect model:** Async work is launched imperatively. A method calls `Task { ... }` and the task is anonymous. If the same method is called twice, two tasks run concurrently against the same state, potentially interleaving writes in any order. -**Task lifecycle:** Unmanaged. The developer manually holds `Task` handles and calls `.cancel()`. Easy to forget. Tasks outlive the view if the ViewModel is retained elsewhere. +**Task lifecycle:** Unmanaged. The developer manually holds `Task` handles and calls `cancel()`. Easy to forget. Tasks outlive the view if the ViewModel is retained elsewhere. **Dispatch:** Direct method calls. `viewModel.loadMovies()` is synchronous: it starts a task and returns immediately. There is no way to await the *completion* of the state change the task will eventually cause — not without adding a separate async method or a continuation. @@ -58,7 +58,7 @@ The most direct comparison to EffectView. TCA targets SwiftUI with a Redux-shape **Effect model:** `Effect` wraps async sequences or `Effect.run { send in ... }` closures. Effects dispatch further actions by calling `send(.someAction)` inside the closure. The reducer remains synchronous. -**Task lifecycle:** Effects are identified by a `CancelID`. Cancellation requires dispatching a separate action that the reducer handles by returning `.cancel(id:)`. The framework manages the actual task. This works well but the cancellation logic is split from the creation logic. +**Task lifecycle:** Effects are identified by a `CancelID`. Cancellation requires dispatching a separate action that the reducer handles by returning `cancel(id:)`. The framework manages the actual task. This works well but the cancellation logic is split from the creation logic. **Dispatch:** Fire-and-forget from outside the store. `store.send(.loadMovies)` enqueues the action. There is no API to await the completion of the effect chain. Inside `Effect.run`, `send` is async — it awaits the action being processed — but only for one level; there is no recursive settle. @@ -119,7 +119,7 @@ ViewState ──(user action)──▶ update ──▶ effect ──▶ store.s **Effect model:** `update` returns an `Effect` value — a description, never an execution. The library executes it. `update` is synchronous, has no `async` annotation, and cannot perform work directly. The same event on the same state always produces the same `Effect` description. -**Task lifecycle:** Tasks are created by returning `.run(id:)`, `.request(id:)`, or `.task(id:)` from `update`. They are cancelled by returning `.cancel(...)` from `update`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks. +**Task lifecycle:** Tasks are created by returning `run(id:)`, `request(id:)`, or `task(id:)` from `update`. They are cancelled by returning `cancel(...)` from `update`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks. A task is automatically cancelled and replaced if `update` returns new work with the same identifier before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management: @@ -127,11 +127,11 @@ A task is automatically cancelled and replaced if `update` returns new work with // update: case .queryChanged(let q): state.query = q - return .run(id: "search") { input, env in + return run(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } let results = await env.search(q) - try? input.post(.resultsLoaded(results)) + try input.post(.resultsLoaded(results)) } ``` @@ -151,11 +151,13 @@ case .queryChanged(let q): // spinner shown until the full load cycle completes } ``` +Note: in this case it is safe to write `try?` since we can ignore the error when attempting to dispatch an event when it happens within the `refresh` modifier. + And it provides natural backpressure in observation loops — the loop does not advance to wait for the next store change until the view has fully processed the current one: ```swift -try? await input.request(.storeChanged(newCount: count)) +try await input.request(.storeChanged(newCount: count)) // next observation cycle waits here ``` diff --git a/Documentation/BridgingEventDrivenAndImperative.md b/Documentation/BridgingEventDrivenAndImperative.md index 90d1959..338e71a 100644 --- a/Documentation/BridgingEventDrivenAndImperative.md +++ b/Documentation/BridgingEventDrivenAndImperative.md @@ -24,6 +24,10 @@ spinner disappears before the data arrives: } ``` +Note: in this case it is safe to write `try?` since we can ignore the error when +attempting to dispatch an event when it happens within the `refresh` modifier. + + The same issue arises for any SwiftUI feature that awaits an async closure: `task(id:)`, `searchable` with an async suggestions closure, button actions in `.toolbar`, sheet confirmations, and so on. @@ -83,6 +87,9 @@ consequence of that event. } ``` +Note: in this case it is safe to write `try?` since we can ignore the error when +attempting to dispatch an event when it happens within the `refresh` modifier. + `update` handles `.refresh` by returning a `.task` that fetches data and sends `.loaded(data)`. `request` resumes when the task closure returns. @@ -100,13 +107,17 @@ Button("Save") { } ``` +Note: in this case it is safe to write `try?` since we can ignore the error when +attempting to dispatch an event when it happens within the button action. + + ### Async `task(id:)` When the app regains foreground, re-fetch only if the previous task has settled: ```swift -.task(id: appPhase) { +task(id: appPhase) { if appPhase == .active { try? await input.request(.resumeIfNeeded) } diff --git a/Documentation/CorrectByConstruction.md b/Documentation/CorrectByConstruction.md index 7802337..c3537ec 100644 --- a/Documentation/CorrectByConstruction.md +++ b/Documentation/CorrectByConstruction.md @@ -96,14 +96,14 @@ static func update( case (_, .searchTapped(let query)): state = .loading(query: query) - return .sequence([ - .cancel("search"), - .run(id: "search") { input, env in + return sequence([ + cancel("search"), + run(id: "search") { input, env in do { let movies = try await env.search(query: query) - try? input.post(.resultsReceived(movies)) + try input.post(.resultsReceived(movies)) } catch { - try? input.post(.requestFailed(error.localizedDescription)) + try input.post(.requestFailed(error.localizedDescription)) } } ]) @@ -118,7 +118,7 @@ static func update( case (.loading, .cancelTapped): state = .idle - return .cancel("search") + return cancel("search") default: return nil // event not valid in current state — ignore it @@ -163,7 +163,7 @@ Feature: Movie search // Scenario: User starts a search case (_, .searchTapped(let query)): state = .loading(query: query) - return .run(id: "search") { ... } + return run(id: "search") { ... } // Scenario: Search returns results case (.loading(let query), .resultsReceived(let movies)): @@ -173,7 +173,7 @@ case (.loading(let query), .resultsReceived(let movies)): // Scenario: User cancels while loading case (.loading, .cancelTapped): state = .idle - return .cancel("search") + return cancel("search") ``` The scenarios *are* the implementation. The mapping is near 1:1. @@ -211,7 +211,7 @@ struct SearchStateTests { let effect = update(state: &state, event: .cancelTapped) #expect(state == .idle) - // effect is .cancel("search") + // effect is cancel("search") } @Test func cancelInIdleStateIsIgnored() { @@ -238,7 +238,7 @@ You can also derive a transition table from the test suite and verify it matches The enum state model eliminates category 1 entirely: the Swift type system won't let you represent `isLoading == true && errorMessage != nil` if your states are an enum. -Category 2 is handled by the update function being processed serially on `@MainActor`. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `update` in sequence: the second `searchTapped` hits the `.sequence([.cancel("search"), .run(id: "search") { ... }])` branch and replaces the in-flight task cleanly. +Category 2 is handled by the update function being processed serially on `@MainActor`. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `update` in sequence: the second `searchTapped` hits the `sequence([cancel("search"), run(id: "search") { ... }])` branch and replaces the in-flight task cleanly. Concurrency exists — tasks genuinely run in the background — but concurrency never *touches* state directly. It only delivers events. The update function remains a simple, synchronous function. diff --git a/Documentation/GitWorkflow.md b/Documentation/GitWorkflow.md index af11b7a..ef385ca 100644 --- a/Documentation/GitWorkflow.md +++ b/Documentation/GitWorkflow.md @@ -113,6 +113,49 @@ Add SPI badges, fix docs URL, and harden test helper with timeout a configurable timeout; remove redundant readyExpectation pattern ``` +For example, if your branch contains a few rough commits: + +``` +pick ba56d23 WIP +pick 8cc13c5 fix typos +pick 8231aa0 Improve README.md +``` + +Run: + +```bash +git fetch origin +git rebase -i origin/main +``` + +Then edit the rebase todo so the first commit is kept and the rest are squashed into it: + +``` +pick ba56d23 WIP +squash 8cc13c5 fix typos +squash 8231aa0 Improve README.md +``` + +When Git opens the combined commit message, replace the rough messages with the PR title and body. For this branch, that could be: + +``` +Polish effect API surface and dispatch documentation + +Summary: +- Hide effect implementation details behind TransducerEffect +- Rename runtime boundary error type to RuntimeError +- Comment out unfinished low-level Transducer.run API +- Clarify post, send, and request dispatch semantics +- Add README framing for Transducer as reducer-like but effect-emitting +- Expand docs around dispatch failure and try? usage +- Fix typos and stale terminology across docs, tests, and examples + +Testing: +- swift test +``` + +After pushing the squashed branch, GitHub usually pre-fills the PR title and description from that single commit message. + --- ## 6. Push the branch diff --git a/Documentation/Recipes.md b/Documentation/Recipes.md index ca38fff..8863ddc 100644 --- a/Documentation/Recipes.md +++ b/Documentation/Recipes.md @@ -16,6 +16,12 @@ Button("Retry") { } ``` +Note: in this case it is safe to write `try?` since we can ignore the error when +attempting to dispatch an event when it happens within the button actions or +whithin the onChange closure. + + + `post` is fire-and-forget. It schedules the event and returns immediately. ## Pull to refresh from the child view @@ -78,15 +84,15 @@ case .queryChanged(let query): state.query = query state.isLoading = true - return .run(id: "search") { input, env in + return run(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } do { let results = try await env.search(query) - try? input.post(.resultsLoaded(results)) + try input.post(.resultsLoaded(results)) } catch { - try? input.post(.searchFailed(error.localizedDescription)) + try input.post(.searchFailed(error.localizedDescription)) } } ``` @@ -100,14 +106,14 @@ Use `sequence` when one step should happen before another. ```swift case .refresh: state.isRefreshing = true - return .sequence([ - .cancel("load"), - .run(id: "refresh") { input, env in + return sequence([ + cancel("load"), + run(id: "refresh") { input, env in do { let items = try await env.loadItems() - try? input.post(.loaded(items)) + try input.post(.loaded(items)) } catch { - try? input.post(.loadFailed(error.localizedDescription)) + try input.post(.loadFailed(error.localizedDescription)) } } ]) @@ -125,8 +131,8 @@ struct Env: Sendable { } case .startObserving: - return .observe(\.store, keyPath: \.count) { input, count in - try? await input.request(.countChanged(count)) + return observe(\.store, keyPath: \.count) { input, count in + try await input.request(.countChanged(count)) } case .countChanged(let count): @@ -150,4 +156,4 @@ XCTAssertTrue(state.isLoading) XCTAssertNotNil(effect) ``` -The test checks what changed immediately. If needed, separate tests can exercise the returned effect path. \ No newline at end of file +The test checks what changed immediately. If needed, separate tests can exercise the returned effect path. diff --git a/Documentation/RuntimeDesign.md b/Documentation/RuntimeDesign.md index 02a5c5f..b387a08 100644 --- a/Documentation/RuntimeDesign.md +++ b/Documentation/RuntimeDesign.md @@ -156,13 +156,13 @@ The runtime exposes three relevant caller-facing modes: These modes differ in how much of the event chain the caller waits for, but they all rely on the same underlying serialization rules for regular events. -### Fire-and-forget +### Fire-and-forget dispatch (`post`) A fire-and-forget call schedules work and returns immediately. The caller does not wait for `compute(...)` to run. This is intentionally the weakest back pressure mode. It is useful, but it is also the deliberate escape hatch: callers can create pending work without themselves awaiting admission. -### Synchronous send +### Synchronous disaptch (`send`) A synchronous `send` means: @@ -172,12 +172,49 @@ A synchronous `send` means: The important nuance is that "synchronous" here is semantic, not literally non-suspending. If an async action runs inline, `send` may suspend while still preserving single-entry regular event reduction. -### Request +### Request/response dispatch (`request`) A request carries a continuation through the event chain until the chain terminates, transfers the continuation into a managed task, or throws out of `compute(...)`. This is the runtime's bridge between event-driven logic and ordinary async/await callers. +### When dispatch fails + +Dispatching an event into the runtime can fail for various reasons. For example: an internal event buffer may be full, the actor may have been cancelled, the actor may already be deinitialized, or the transducer may have been cancelled. + +Depending on the context, some of these failures are benign. For example, in a SwiftUI Button action: + +```swift +Button("Start") { + send(.start) +} +``` +In this case, when sending the "start" event fails, it is often not a critical error. A user may just try again. + +However, there are other cases where a failure means a critical error. For example, in an operation when it finishes and the transducer logic awaits and requires a completion event: + + ```swift + static func refreshMovies() -> Effect { + run(id: "refresh") { input, env in + let result = await env.movieFetch() + try? input(.fetchMoviesCompletion(result)) // Do not use `try?` when dispatching completion events + } +} +``` +In the case above, if event dispatch fails and the error is ignored (`try?`), the transducer will never receive a completion event. This might mean it stays in "loading" mode indefinitely and silently ignores any other event unless it sees the completion event. + +Thus, when the event cannot be dispatched, it is better to forward the failure into the system, that is, letting it throw the error: + ```swift + static func refreshMovies() -> Effect { + run(id: "refresh") { input, env in + let result = await env.movieFetch() + try input(.fetchMoviesCompletion(result)) + } +} +``` +The runtime now detects the error, treats it as a critical failure, and cancels the transducer. Now, the transducer "knows" it is in a failure mode, and any attempt to send events into it will fail early at the call site. + + --- ## Async actions @@ -342,4 +379,4 @@ The runtime is intentionally built around four ideas: This gives `EffectView` a runtime that stays small in code size while still supporting async actions, request/response bridging, runtime-managed tasks, immediate interruption, and future runtime control features. -The design is intentional, not accidental. \ No newline at end of file +The design is intentional, not accidental. diff --git a/Documentation/TamingAsyncTasksInSwiftUIViews.md b/Documentation/TamingAsyncTasksInSwiftUIViews.md index f5170dc..4e38a25 100644 --- a/Documentation/TamingAsyncTasksInSwiftUIViews.md +++ b/Documentation/TamingAsyncTasksInSwiftUIViews.md @@ -64,7 +64,7 @@ This means a task can be cancelled because a parent view re-rendered and changed A common pattern for debounced search is: ```swift -.task(id: query) { +task(id: query) { try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } results = await search(query) @@ -81,7 +81,7 @@ The usual workaround is to reach for a ViewModel that holds an array of `Task` h ### Explicit cancellation on user intent is not straightforward -If the user taps a Cancel button, you want to stop the running task immediately. With `.task`, there is no handle to call `.cancel()` on. The modifier owns the task and exposes no cancellation API. The workarounds involve either changing the `id:` value (which also restarts), or storing a `Task` handle externally — at which point you're managing task lifetime manually, outside of SwiftUI's model. +If the user taps a Cancel button, you want to stop the running task immediately. With `.task`, there is no handle to call `cancel()` on. The modifier owns the task and exposes no cancellation API. The workarounds involve either changing the `id:` value (which also restarts), or storing a `Task` handle externally — at which point you're managing task lifetime manually, outside of SwiftUI's model. ### Coordination between tasks is manual @@ -130,7 +130,7 @@ List(state.items, id: \.self) { Text($0) } ```swift case .refresh: - return .task(id: "refresh") { input, env in + return task(id: "refresh") { input, env in do { let items = try await env.fetch() return try await input.request(.loaded(items)) @@ -158,7 +158,7 @@ Cancellation is a first-class event returned from `update`: ```swift case .cancelTapped: - return .cancel("fetch") + return cancel("fetch") ``` That's it. No stored `Task` handle, no flag, no `id:` dance. @@ -169,19 +169,19 @@ Because task identifiers can be created at runtime, you can start as many tasks ```swift case .startDownload(let id): - return .run(id: "download-\(id)") { input, env in + return run(id: "download-\(id)") { input, env in do { let data = try await env.download(id) - try? input.post(.downloaded(id, data)) + try input.post(.downloaded(id, data)) } catch { - try? input.post( + try input.post( .downloadFailed(id, error.localizedDescription) ) } } case .cancelDownload(let id): - return .cancel("download-\(id)") + return cancel("download-\(id)") ``` No ViewModel, no array of handles, no manual lifecycle. @@ -193,11 +193,11 @@ Starting a task whose identifier is already running cancels the previous run fir ```swift case .queryChanged(let q): state.query = q - return .run(id: "search") { input, env in + return run(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } let results = await env.search(q) - try? input.post(.resultsLoaded(results)) + try input.post(.resultsLoaded(results)) } ``` diff --git a/Documentation/UsingEnvForDependencyInjection.md b/Documentation/UsingEnvForDependencyInjection.md index ac9a749..0f3aeaa 100644 --- a/Documentation/UsingEnvForDependencyInjection.md +++ b/Documentation/UsingEnvForDependencyInjection.md @@ -78,15 +78,15 @@ And inside a task, `env` is simply the injected value: ```swift case (_, .searchTapped(let query)): state = .loading(query: query) - return .sequence([ - .cancel("search"), - .run(id: "search") { input, env in + return sequence([ + cancel("search"), + run(id: "search") { input, env in env.trackQuery(query) do { let movies = try await env.search(query) - try? input.post(.resultsReceived(movies)) + try input.post(.resultsReceived(movies)) } catch { - try? input.post(.requestFailed(error.localizedDescription)) + try input.post(.requestFailed(error.localizedDescription)) } } ]) diff --git a/EffectView.code-workspace b/EffectView.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/EffectView.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/Examples/EffectViewExample/EffectViewExample/Counter.swift b/Examples/EffectViewExample/EffectViewExample/Counter.swift index 8be6ec6..6f90cce 100644 --- a/Examples/EffectViewExample/EffectViewExample/Counter.swift +++ b/Examples/EffectViewExample/EffectViewExample/Counter.swift @@ -46,7 +46,7 @@ extension Counter.Transducer: EffectView::Transducer { try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec print("tick") input(.tick) - } catch {} // most likeley, the counter task has been cancelled; ignore it. + } catch {} // most likely, the counter task has been cancelled; ignore it. } } case .tick: diff --git a/README.md b/README.md index 9296875..9f288b1 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Most ViewModels start small and quickly turn into this: - two-way bindings further complicate the code and edge cases get missed - tests get harder to write, and mocks replace logic instead of verifying it -The SwiftUI `task` modifer behavior is often surprising in practice. A timer started from a tab's root view is cancelled when the user switches tabs, then restarted when the view appears again. Work you expected to keep running gets torn down and started again just because the view went off-screen. +The SwiftUI `task` modifier behavior is often surprising in practice. A timer started from a tab's root view is cancelled when the user switches tabs, then restarted when the view appears again. Work you expected to keep running gets torn down and started again just because the view went off-screen. ## The solution @@ -32,6 +32,10 @@ With EffectView, you move a feature's logic into a small stand-alone enum that d If more work is needed, `update` returns an effect: just a function, possibly async, that the runtime executes one step later and where those side effects happen. That split keeps the logic easy to read and easy to test. +If you know Redux or TCA, a `Transducer` plays a similar role to what those architectures often call a reducer. EffectView uses "transducer" because `update` does more than reduce state from an event: it also emits the next effect for the runtime to execute. + +The example below is a small debounced search feature. Read it as a transition table: query changes put the feature into a loading state and start a named search task; response events then settle the state back into either results or an error. + ```swift import EffectView import SwiftUI @@ -61,15 +65,15 @@ enum SearchFeature: Transducer { state.isLoading = true state.errorMessage = nil - return .run(id: "search") { input, env in + return run(id: "search") { input, env in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } do { let results = try await env.search(query) - try? input.post(.searchResponse(results)) + try input.post(.searchResponse(results)) } catch { - try? input.post(.searchFailed(error.localizedDescription)) + try input.post(.searchFailed(error.localizedDescription)) } } @@ -155,4 +159,4 @@ Add `EffectView` to your target dependencies. ## License -Apache License, Version 2.0 \ No newline at end of file +Apache License, Version 2.0 diff --git a/Sources/EffectView/EffectObservable/EffectObservable.Input.swift b/Sources/EffectView/EffectObservable/EffectObservable.Input.swift index 763b2df..b01d1bd 100644 --- a/Sources/EffectView/EffectObservable/EffectObservable.Input.swift +++ b/Sources/EffectView/EffectObservable/EffectObservable.Input.swift @@ -66,7 +66,7 @@ extension EffectObservable { @MainActor public func send(_ event: sending Event) async throws { guard let actor else { - throw RuntimeUnavailable.actorDeallocated + throw RuntimeError.actorDeallocated } try await actor.send(event) } @@ -84,7 +84,7 @@ extension EffectObservable { @inline(__always) public func post(_ event: sending Event) throws { guard let actor else { - throw RuntimeUnavailable.actorDeallocated + throw RuntimeError.actorDeallocated } try actor.checkRuntimeAvailability() Task { @MainActor in @@ -129,7 +129,7 @@ extension EffectObservable { _ event: Event ) async throws -> Output? where Output: Sendable, Event: Sendable { guard let actor else { - throw RuntimeUnavailable.actorDeallocated + throw RuntimeError.actorDeallocated } return try await actor.request(event) } diff --git a/Sources/EffectView/EffectObservable/EffectObservable.swift b/Sources/EffectView/EffectObservable/EffectObservable.swift index 688a06d..3470990 100644 --- a/Sources/EffectView/EffectObservable/EffectObservable.swift +++ b/Sources/EffectView/EffectObservable/EffectObservable.swift @@ -36,7 +36,7 @@ public final class EffectObservable< @ObservationIgnored private var runtimeSend: Send? @ObservationIgnored - nonisolated(unsafe) private var runtimeUnavailable: RuntimeUnavailable? + nonisolated(unsafe) private var runtimeUnavailable: RuntimeError? @ObservationIgnored private var initialEvent: Event? @ObservationIgnored @@ -77,8 +77,7 @@ public final class EffectObservable< do { try await send(event, input: _input) } catch { - print("could not process initial event: \(error)") - // TODO: consider sending a control event + try? send.control(.systemError(error)) } } } @@ -119,13 +118,13 @@ public final class EffectObservable< public func send(_ event: Event) async throws { try checkRuntimeAvailability() guard let send = runtimeSend else { - throw RuntimeUnavailable.actorCancelled + throw RuntimeError.actorCancelled } do { try await send(event, input: _input) } catch { let boundaryError = runtimeBoundaryError(for: error) - if let runtimeUnavailable = boundaryError as? RuntimeUnavailable { + if let runtimeUnavailable = boundaryError as? RuntimeError { self.runtimeUnavailable = runtimeUnavailable } throw boundaryError @@ -182,11 +181,9 @@ public final class EffectObservable< /// `CancellationError` if accepted work is later cancelled. /// - Returns: the `Output?` value produced by the terminal `.task` closure. public func request(_ event: Event) async throws -> Output? { - // TODO: consider to to add a Task cancellation handler which sends a corresponding control event to the transducer. - // The transducer's action on this is currently "implementation defined". It *could* have no effect on the task operation, or it *could* cancel it. try checkRuntimeAvailability() guard let send = self.runtimeSend, let input = _input else { - throw RuntimeUnavailable.actorCancelled + throw RuntimeError.actorCancelled } return try await withCheckedThrowingContinuation { (continuation: Continuation) in Task { @@ -194,7 +191,7 @@ public final class EffectObservable< try await send.send(MainActor.shared, event, input, continuation) } catch { let boundaryError = runtimeBoundaryError(for: error) - if let runtimeUnavailable = boundaryError as? RuntimeUnavailable { + if let runtimeUnavailable = boundaryError as? RuntimeError { self.runtimeUnavailable = runtimeUnavailable } continuation.resume(throwing: boundaryError) @@ -218,7 +215,7 @@ public final class EffectObservable< /// gentle teardown as an event handled by the transducer itself, then call /// `cancel()` when the host is ready to discard the runtime. public func cancel() { - cancelRuntime(with: RuntimeUnavailable.actorCancelled) + cancelRuntime(with: RuntimeError.actorCancelled) } /// Cancels the observable runtime with a caller-provided system error. diff --git a/Sources/EffectView/EffectObservable/Transducer.observe.swift b/Sources/EffectView/EffectObservable/Transducer.observe.swift index 98a92d8..902b4e7 100644 --- a/Sources/EffectView/EffectObservable/Transducer.observe.swift +++ b/Sources/EffectView/EffectObservable/Transducer.observe.swift @@ -26,7 +26,7 @@ extension Transducer where Effect == TransducerEffect { /// ``` /// /// The named task (`"observe"` by default) is cancelled automatically when the view - /// disappears, or immediately when `update` returns `.cancel(name)`. + /// disappears, or immediately when `update` returns `cancel(name)`. /// /// - Parameters: /// - envKeyPath: Key path from `Env` to the `@Observable` object. The object is held @@ -37,7 +37,6 @@ extension Transducer where Effect == TransducerEffect { /// - handler: Called with `input` and the current value on the initial read and on /// every subsequent change. `async` — use `await input.request(…)` to wait for the /// view to settle before the next observation cycle. - // TODO: possibly use a async *throwing* function for the operation and forward the error @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) public static func observe( _ envKeyPath: KeyPath, @@ -60,16 +59,14 @@ extension Transducer where Effect == TransducerEffect { } } catch is CancellationError { // Transducer logic has cancelled. Do not rethrow. - // Expected termination path for explicit .cancel(name) or view teardown. + // Expected termination path for explicit cancel(name) or view teardown. } catch is ObservationTerminationError { - // TODO: consider rethrow // IFF we would rethrow the error, the update function is responsible // to catch and handle it, or otherwise it becomes a critical // system error. // For now we end the observation quietly. } catch { - // TODO: consider rethrow // IFF we would rethrow the error, the update function is responsible // to catch and handle it, or otherwise it becomes a critical // system error. @@ -118,7 +115,7 @@ extension Transducer where Effect == TransducerEffect { } } catch is CancellationError { - // Expected termination path for explicit .cancel(name) or view teardown. + // Expected termination path for explicit cancel(name) or view teardown. } catch is ObservationTerminationError { // Observed object deallocated; end the observation quietly. } catch { @@ -149,7 +146,7 @@ extension Transducer where Effect == TransducerEffect { /// ``` /// /// The named task (`"observe"` by default) is cancelled automatically when the view - /// disappears, or immediately when `update` returns `.cancel(name)`. + /// disappears, or immediately when `update` returns `cancel(name)`. /// /// - Parameters: /// - object: The `@Observable` object to watch. Held weakly inside the task so the @@ -181,7 +178,7 @@ extension Transducer where Effect == TransducerEffect { await operation(input, try observedValue(weakObject, keyPath: box)) } } catch is CancellationError { - // Expected termination path for explicit .cancel(name) or view teardown. + // Expected termination path for explicit cancel(name) or view teardown. } catch is ObservationTerminationError { // Observed object deallocated; end the observation quietly. } catch { @@ -228,7 +225,7 @@ extension Transducer where Effect == TransducerEffect { await isolatedOperation(input, try observedValue(weakObject, keyPath: box), isolation) } } catch is CancellationError { - // Expected termination path for explicit .cancel(name) or view teardown. + // Expected termination path for explicit cancel(name) or view teardown. } catch is ObservationTerminationError { // Observed object deallocated; end the observation quietly. } catch { diff --git a/Sources/EffectView/EffectView/EffectView.swift b/Sources/EffectView/EffectView/EffectView.swift index 6b05fbd..ba849ad 100644 --- a/Sources/EffectView/EffectView/EffectView.swift +++ b/Sources/EffectView/EffectView/EffectView.swift @@ -23,6 +23,8 @@ import SwiftUI /// } /// } /// ``` +/// > Note: In this case it is safe to write `try?` since we can ignore the error when +/// attempting to dispatch an event when it happens within the Button action. /// /// ### Using `Env` for dependencies /// @@ -145,8 +147,7 @@ public struct EffectView< do { try await Input(send!).send(event) } catch { - // TODO: improve handling this error - print("initial event failed to be processed") + try? self.send?.control(.systemError(error)) } } } diff --git a/Sources/EffectView/EffectView/EffectViewInput.swift b/Sources/EffectView/EffectView/EffectViewInput.swift index 466ee41..136ea77 100644 --- a/Sources/EffectView/EffectView/EffectViewInput.swift +++ b/Sources/EffectView/EffectView/EffectViewInput.swift @@ -103,10 +103,10 @@ public struct EffectViewInput: TransducerInput, Identifiable, Sen /// non-isolated callback without waiting for `update` to run. /// /// - Parameter event: The event to enqueue into the runtime. - /// - Throws: This implementation does not throw synchronously. Runtime failures + /// - Note: This implementation does not throw synchronously. Runtime failures /// surface only inside the scheduled task that later processes the event. @inline(__always) - public func post(_ event: sending Event) throws { + public func post(_ event: sending Event) { Task { @MainActor in try await _send(event, self, nil) } @@ -163,7 +163,7 @@ public struct EffectViewInput: TransducerInput, Identifiable, Sen /// - Throws: Any error that ``post(_:)`` would throw for the same event. @inline(__always) public func callAsFunction(_ event: sending Event) throws { - try post(event) + post(event) } } diff --git a/Sources/EffectView/Transducer/Errors.swift b/Sources/EffectView/Transducer/Errors.swift index 541733b..654d251 100644 --- a/Sources/EffectView/Transducer/Errors.swift +++ b/Sources/EffectView/Transducer/Errors.swift @@ -1,8 +1,7 @@ import Foundation -// TODO: we can simplify this. Rename to "CancellationError". CancellationError may have an underlying error specifying the detailed reason. /// Boundary error indicating that the runtime can no longer accept or complete work. -public enum RuntimeUnavailable: LocalizedError, Equatable, Sendable { +public enum RuntimeError: LocalizedError, Equatable, Sendable { /// The host cancelled the runtime before this call could proceed. case actorCancelled @@ -25,7 +24,6 @@ public enum RuntimeUnavailable: LocalizedError, Equatable, Sendable { return "The runtime is unavailable because it has forcibly terminated because of a critical error." case .cancelled: return "The runtime is unavailable because it has been cancelled." - } } } @@ -34,16 +32,8 @@ func runtimeBoundaryError(for error: any Swift.Error) -> any Swift.Error { if error is CancellationError { return error } - if let runtimeUnavailable = error as? RuntimeUnavailable { + if let runtimeUnavailable = error as? RuntimeError { return runtimeUnavailable } - return RuntimeUnavailable.systemError -} - -enum RuntimeError: Swift.Error { - - // could not perform send, because Send is deallocated - case sendUnavailable - - case noInput + return RuntimeError.systemError } diff --git a/Sources/EffectView/Transducer/TaskManager.swift b/Sources/EffectView/Transducer/TaskManager.swift index a64b965..f150554 100644 --- a/Sources/EffectView/Transducer/TaskManager.swift +++ b/Sources/EffectView/Transducer/TaskManager.swift @@ -69,7 +69,7 @@ final class TaskManager { if let error { throw error } else { - throw RuntimeUnavailable.cancelled + throw RuntimeError.cancelled } } } @@ -101,9 +101,9 @@ final class TaskManager { private var latchedShutdownError: any Swift.Error { switch state { case .active: - return RuntimeUnavailable.cancelled + return RuntimeError.cancelled case .cancelling(let error), .cancelled(let error): - return error ?? RuntimeUnavailable.cancelled + return error ?? RuntimeError.cancelled } } @@ -257,7 +257,7 @@ final class TaskManager { // `systemActor` establishes a reference cycle - until after the task // finishes. This is important to know when implementing an "FSM Effect // Actor" based on Swift Actors. That is, a proper implementation of an - // "FSM Effect Actor" should always have a `cancel()` method which cancells + // "FSM Effect Actor" should always have a `cancel()` method which cancels // all running tasks and additionally prevents enqueueing new ones. let task = Task(name: taskKey.string, priority: priority) { [weak self] in _ = systemActor @@ -270,14 +270,6 @@ final class TaskManager { } switch result { case .failure(let error): - // TODO: Triple check: Does a CancellationError not mean a "system error"?? - // We only have a CancellationError that is not a "system error" when - // this task cancellation was explicitly caused by the transducer. That is: - // the managed task is itself cancelled, or in this context: - // if `error is CancellationError && Task.isCancelled` equals true. - // Thus, a "system error" can be a CancellationError as well. - // Now, the code is implemented (fixed) accordingly - but also: - // TODO: assert this in a dedicated unit test if error is CancellationError && Task.isCancelled { self?.finish(taskKey: taskKey, id: id, result: result) } else { @@ -320,9 +312,9 @@ final class TaskManager { // Currently, with TaskKey being hashed on the identifier, // this can happen, when a subsequent task cancels the previous // one (aka `switchToLatest`), and the previous task has not - // been completed (and removed) *before* the new taks has been - // inserted into the ductionary with the *same* key. When previous - // taks eventually completes, it there's no entry with its `id` + // been completed (and removed) *before* the new task has been + // inserted into the dictionary with the *same* key. When the previous + // task eventually completes, there is no entry with its `id` // anymore. /* nothing */ } diff --git a/Sources/EffectView/Transducer/Transducer.Effects.swift b/Sources/EffectView/Transducer/Transducer.Effects.swift index 946b208..81cc727 100644 --- a/Sources/EffectView/Transducer/Transducer.Effects.swift +++ b/Sources/EffectView/Transducer/Transducer.Effects.swift @@ -32,12 +32,12 @@ extension Transducer where Effect == TransducerEffect { /// forwarded to any caller suspended on ``Input/request(_:)``. /// /// - Returns: The effect. - // TODO: Need to exaplain clearly what it means, when an operation throws. - // Usually, operations shouls not fail, but in some cases, the operation may use + // TODO: Explain clearly what it means when an operation throws. + // Usually, operations should not fail, but in some cases, the operation may use // an input to send events back to the system and *this* input can fail due to a "system error". System // errors are critical errors - that is, it might mean, the actor is deallocated, - // or a potential event buffer did overflow or some other system error occured, - // That means, the transducer is not guaranteed to perform correctly anymoer. The + // or a potential event buffer overflowed, or some other system error occurred. + // That means the transducer is no longer guaranteed to perform correctly. The // best course of action is to tear down the transducer and actor, and forward // the error to event senders and waiters. @inline(__always) @@ -47,7 +47,7 @@ extension Transducer where Effect == TransducerEffect { option: TaskExecutionOption = .switchToLatest, operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Output? ) -> Effect { - ._task(id: id, priority: priority, option: option, operation: operation) + .init(._task(id: id, priority: priority, option: option, operation: operation)) } /// Returns an effect which when invoked starts an async throwing operation isolated to the system actor @@ -89,7 +89,7 @@ extension Transducer where Effect == TransducerEffect { option: TaskExecutionOption = .switchToLatest, isolatedOperation: @escaping (any TransducerInput, Env, isolated any Actor) async throws -> Output? ) -> Effect { - ._taskIsolated(id: id, priority: priority, option: option, isolatedOperation: isolatedOperation) + .init(._taskIsolated(id: id, priority: priority, option: option, isolatedOperation: isolatedOperation)) } /// Return an effect which when invoked executes a synchronous step that may produce the next @@ -116,7 +116,7 @@ extension Transducer where Effect == TransducerEffect { public static func action( _ action: @escaping (Env) -> Event? ) -> Effect { - ._actionSync(action) + .init(._actionSync(action)) } /// Return an effect which when invoked executes an async step on a user specified global actor @@ -142,7 +142,7 @@ extension Transducer where Effect == TransducerEffect { public static func action( _ action: @escaping @Sendable @isolated(any) (Env) async -> sending Event? ) -> Effect { - ._actionAsync(action) + .init(._actionAsync(action)) } /// Return an effect which when invoked executes an async step on the system actor @@ -168,7 +168,7 @@ extension Transducer where Effect == TransducerEffect { public static func action( _ action: @escaping (Env, isolated any Actor) async -> sending Event? ) -> Effect { - ._actionAsyncIsolated(action) + .init(._actionAsyncIsolated(action)) } /// Returns an effect which when invoked feeds `event` back into `update` immediately, in @@ -178,7 +178,7 @@ extension Transducer where Effect == TransducerEffect { /// - Returns: An effect. @inline(__always) public static func event(_ event: Event) -> Effect { - ._event(event) + .init(._event(event)) } /// Returns an effect which cancels the running task with the given identifier, if any. @@ -187,7 +187,7 @@ extension Transducer where Effect == TransducerEffect { /// - Returns: An effect. @inline(__always) public static func cancel(_ id: TaskIdentifier) -> Effect { - ._cancel(id) + .init(._cancel(id)) } /// Returns an effect which contains a sequence of effects. The effects will be executed @@ -195,11 +195,11 @@ extension Transducer where Effect == TransducerEffect { /// /// ```swift /// // Cancel a stale load before starting a refresh: - /// return .sequence([.cancel("load"), .refreshMovies()]) + /// return sequence([cancel("load"), .refreshMovies()]) /// ``` /// /// - Important: Intermediate effects must be synchronous and terminal (`.cancel` - /// or side-effect `.action` closures). An intermediate effect that returns an + /// or side-effect `action` closures). An intermediate effect that returns an /// event is not supported — the event is silently discarded. Use a dedicated /// `update` step for event-producing chains instead. /// @@ -207,7 +207,7 @@ extension Transducer where Effect == TransducerEffect { /// - Returns: An effect. @inline(__always) public static func sequence(_ effects: [Effect]) -> Effect { - ._sequence(effects) + .init(._sequence(effects)) } } @@ -226,7 +226,7 @@ extension Transducer where Effect == TransducerEffect { /// racing late failure from the operation. /// /// ```swift - /// return .run(id: "ticker") { input, env in + /// return run(id: "ticker") { input, env in /// do { /// while true { /// try await env.clock.sleep(for: .seconds(1)) @@ -250,10 +250,10 @@ extension Transducer where Effect == TransducerEffect { option: TaskExecutionOption = .switchToLatest, operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async -> Void ) -> Effect where Env: Sendable { - ._task(id: id, priority: priority, option: option) { input, env in + .init(._task(id: id, priority: priority, option: option) { input, env in await operation(input, env) return nil - } + }) } /// Returns an effect which, when invoked, starts a fire-and-forget async task that communicates @@ -268,7 +268,7 @@ extension Transducer where Effect == TransducerEffect { /// racing late failure from the operation. /// /// ```swift - /// return .run(id: "ticker") { input, env in + /// return run(id: "ticker") { input, env in /// do { /// while true { /// try await env.clock.sleep(for: .seconds(1)) @@ -294,14 +294,14 @@ extension Transducer where Effect == TransducerEffect { option: TaskExecutionOption = .switchToLatest, isolatedOperation: @escaping (any TransducerInput, Env, isolated any Actor) async -> Void ) -> Effect where Env: Sendable { - ._taskIsolated(id: id, priority: priority, option: option) { input, env, isolation in + .init(._taskIsolated(id: id, priority: priority, option: option) { input, env, isolation in precondition( systemActor != nil && systemActor === isolation, "taskIsolated requires a non-nil matching system actor. Actor hosts must provide isolation. Expected \(String(describing: systemActor)), got \(isolation)." ) await isolatedOperation(input, env, isolation) return nil - } + }) } @@ -337,7 +337,7 @@ extension Transducer where Effect == TransducerEffect { option: TaskExecutionOption = .switchToLatest, operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async -> Output? ) -> Effect { - ._task(id: id, priority: priority, option: option, operation: operation) + .init(._task(id: id, priority: priority, option: option, operation: operation)) } } diff --git a/Sources/EffectView/Transducer/Transducer.run.swift b/Sources/EffectView/Transducer/Transducer.run.swift index 872eb3e..fed5da6 100644 --- a/Sources/EffectView/Transducer/Transducer.run.swift +++ b/Sources/EffectView/Transducer/Transducer.run.swift @@ -1,3 +1,5 @@ +#if false // Feature run is not yet implemented + extension Transducer where Effect == TransducerEffect, Env: Sendable, Output: Sendable { /// Runs the transducer runtime directly with an explicit low-level send handle. @@ -28,10 +30,11 @@ extension Transducer where Effect == TransducerEffect, Env: } } -// TODO: when implemente, remove it +// TODO: when implemented, remove it /// Placeholder error for the unfinished low-level ``Transducer/run(systemActor:send:initialState:input:)`` API. public enum RunError: Error, Sendable { /// The requested runtime entry point has not been implemented yet. case notImplemented } +#endif diff --git a/Sources/EffectView/Transducer/Transducer.swift b/Sources/EffectView/Transducer/Transducer.swift index 4da4e68..537bef2 100644 --- a/Sources/EffectView/Transducer/Transducer.swift +++ b/Sources/EffectView/Transducer/Transducer.swift @@ -111,9 +111,6 @@ extension Transducer where Effect == TransducerEffect { } - // TODO: document clearly when and why this function throws. - // Note: *ideally* it should not throw - /// Processes a regular event through `update` until the chain terminates. /// /// > Important: On normal return, `continuation` has been fully consumed: it was either @@ -121,6 +118,8 @@ extension Transducer where Effect == TransducerEffect { /// later completion. If this function throws, it does not resume the /// continuation; the caller must handle the thrown error and decide how the /// waiting request should complete. + /// + /// - Throws: Throws when the task manager has been cancelled. static func compute>( systemActor: isolated any Actor = #isolation, event: Event, @@ -154,9 +153,7 @@ extension Transducer where Effect == TransducerEffect { assert(cont == nil) } - // TODO: document clearly when and why this function throws. - // Note: *ideally* it should not throw. - // Note: it seems, the only case when it throws is a kind of precondition. + // Throws when the task manager is cancelled private static func executeEffect>( systemActor: isolated any Actor = #isolation, _ effect: Effect, @@ -165,12 +162,12 @@ extension Transducer where Effect == TransducerEffect { input: Input?, env: Env ) async throws -> (Event?, Continuation?) where Output: Sendable, Env: Sendable, Input: Sendable { - switch effect { + switch effect.type { case ._task(id: let identifier, priority: let priority, let option, operation: let operation): guard let input else { - // TODO: Check if this should be better a precondition - throw RuntimeError.noInput + preconditionFailure("No Input value given when creating a task") } + try taskManager.checkCancellation() taskManager.addTask( with: identifier, option: option, @@ -184,9 +181,9 @@ extension Transducer where Effect == TransducerEffect { case ._taskIsolated(id: let identifier, priority: let priority, let option, isolatedOperation: let isolatedOperation): guard let input else { - // TODO: Check if this should be better a precondition - throw RuntimeError.noInput + preconditionFailure("No Input value given when creating a task") } + try taskManager.checkCancellation() taskManager.addTask( with: identifier, option: option, diff --git a/Sources/EffectView/Transducer/TransducerEffect.swift b/Sources/EffectView/Transducer/TransducerEffect.swift index e048bbb..c2be5e5 100644 --- a/Sources/EffectView/Transducer/TransducerEffect.swift +++ b/Sources/EffectView/Transducer/TransducerEffect.swift @@ -8,7 +8,7 @@ /// /// ```swift /// // Fire-and-forget task: -/// return .run(id: "ticker") { input, env in +/// return run(id: "ticker") { input, env in /// while true { /// try await env.clock.sleep(for: .seconds(1)) /// input(.tick) @@ -16,13 +16,13 @@ /// } /// /// // Perform-driven task (caller awaits result): -/// return .request(id: "load") { input, env in +/// return request(id: "load") { input, env in /// let user = await env.api.fetchUser() /// return await input.request(.loaded(user)) /// } /// /// // Synchronous step — next event returned inline: -/// return .action { env in +/// return action { env in /// env.analytics.track(.buttonTapped) /// return .next /// } @@ -43,15 +43,15 @@ /// - `Env`: The dependency environment forwarded into every task and action closure. /// - `Output`: The value type returned to a caller suspended on ``Input/request(_:)``. /// Use `Void` when no return value is needed. -// TODO: Need to exaplain clearly what it means, when an operation throws. -// Usually, operations shouls not fail, but in some cases, the operation may use -// an input to send events back to the system and *this* input can fail due to a "system error". System -// errors are critical errors - that is, it might mean, the actor is deallocated, -// or a potential event buffer did overflow or some other system error occured, -// That means, the transducer is not guaranteed to perform correctly anymoer. The -// best course of action is to tear down the transducer and actor, and forward -// the error to event senders and waiters. -public enum TransducerEffect { +public struct TransducerEffect { + let type: EffectType + + init(_ type: EffectType) { + self.type = type + } +} + +enum EffectType { case none diff --git a/Tests/EffectViewTests/AsyncActionRuntimeTests.swift b/Tests/EffectViewTests/AsyncActionRuntimeTests.swift index 26fd0cd..e0ad75c 100644 --- a/Tests/EffectViewTests/AsyncActionRuntimeTests.swift +++ b/Tests/EffectViewTests/AsyncActionRuntimeTests.swift @@ -129,7 +129,7 @@ struct AsyncActionRuntimeTests { do { _ = try await waiter.value Issue.record("Expected accepted request to receive RuntimeUnavailable.actorCancelled") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorCancelled) } catch { Issue.record("Unexpected waiter error: \(error)") @@ -176,4 +176,4 @@ struct AsyncActionRuntimeTests { } } -#endif \ No newline at end of file +#endif diff --git a/Tests/EffectViewTests/EffectViewTests.swift b/Tests/EffectViewTests/EffectViewTests.swift index fd402f1..ccb4f68 100644 --- a/Tests/EffectViewTests/EffectViewTests.swift +++ b/Tests/EffectViewTests/EffectViewTests.swift @@ -295,14 +295,14 @@ struct EffectViewTests { // state. If it fails, send a corresponding failure event. // Note: If we throw within an effect closure, we feed // this error back into the system which is considered - // a "system error". It preferred to handle the error or + // a "system error". It is preferable to handle the error or // to send a corresponding error event back. do { try await Task.sleep(for: .milliseconds(1)) // simulate remote work } catch { // not handled here in the test. - // Production code should send a service error, for - // example: `let output try await input.request(Event.serviceFailed(error)) ` + // Production code should send a service error event, + // for example: `let output = try await input.request(Event.serviceFailed(error))` // then return `output`. } let result = "hello" @@ -380,7 +380,7 @@ struct EffectViewTests { do { _ = try await input.request(.load) Issue.record("Expected second request to throw RuntimeUnavailable.runtimeFailed") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .systemError) } catch { Issue.record("Unexpected second request error: \(error)") @@ -439,7 +439,7 @@ struct EffectViewTests { state.running = true return task(id: "ticker") { input, _ in do { - // run infinitely - or until "ticker" tasks gets cancelled + // run indefinitely, or until the "ticker" task gets cancelled while true { try await Task.sleep(nanoseconds: 20_000_000) // 20 ms try input.post(Event.tick) @@ -762,7 +762,7 @@ struct EffectViewTests { @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) @MainActor @Test func cancelObservationTaskStopsHandlerInvocations() async throws { - // Verify that .cancel("observe") prevents further handler calls. + // Verify that cancel("observe") prevents further handler calls. // After cancellation, mutating the observable must not deliver new values. struct ObsEnv: Sendable { let counter: ObservableCounter } class InvocationLog: @unchecked Sendable { var count = 0 } @@ -812,8 +812,8 @@ struct EffectViewTests { guard let input = capturedInput else { Issue.record("Input not captured"); return } // Start observing; the initial value (0) is delivered via state. - // Caution: DO NOT use `request` for a observation task, because it won't finishe before it gets cancelled! - try input.post(.start) + // Caution: DO NOT use `request` for an observation task, because it will not finish before it gets cancelled. + input.post(.start) try await firstTickExpectation.await(nanoseconds: timeout) // Mutate the counter; the handler should fire once more. @@ -907,12 +907,12 @@ private func counterUpdate( return nil case .start: state.running = true - return ._task(id: "ticker", priority: nil, option: .switchToLatest) { input, _ in + return .init(._task(id: "ticker", priority: nil, option: .switchToLatest) { input, _ in try input.post(.ticked) - } + }) case .stop: state.running = false - return ._cancel("ticker") + return .init(._cancel("ticker")) case .ticked: state.count += 1 return nil @@ -948,14 +948,14 @@ private func loaderUpdate( case .load: state.isLoading = true state.error = nil - return ._task(id: "fetch", priority: nil, option: .switchToLatest) { input, env in + return .init(._task(id: "fetch", priority: nil, option: .switchToLatest) { input, env in do { let items = try await env.fetch() try input.post(.loaded(items)) } catch { try input.post(.failed(error.localizedDescription)) } - } + }) case .loaded(let items): state.isLoading = false state.items = items @@ -1026,7 +1026,7 @@ struct EffectTypeTests { var state = CounterState() let effect = counterUpdate(state: &state, event: .start) #expect(state.running == true) - guard case ._task(id: let name, _, _, _) = effect, name == "ticker" else { + guard case ._task(id: let name, _, _, _) = effect?.type, name == "ticker" else { Issue.record(#"Expected .task(name: "ticker")"#) return } @@ -1036,7 +1036,7 @@ struct EffectTypeTests { var state = CounterState(count: 0, running: true) let effect = counterUpdate(state: &state, event: .stop) #expect(state.running == false) - guard case ._cancel(let name) = effect else { + guard case ._cancel(let name) = effect?.type else { Issue.record("Expected .cancel") return } @@ -1046,7 +1046,7 @@ struct EffectTypeTests { @Test func loadReturnsNamedFetchTask() { var state = LoaderState() let effect = loaderUpdate(state: &state, event: .load) - guard case ._task(id: let name, _, _, _) = effect, name == "fetch" else { + guard case ._task(id: let name, _, _, _) = effect?.type, name == "fetch" else { Issue.record(#"Expected .task(name: "fetch")"#) return } @@ -1054,8 +1054,9 @@ struct EffectTypeTests { @Test func actionEffectInvokesClosureAndReturnsEvent() { enum Ev: Equatable, Sendable { case a, b } - let effect = TransducerEffect._actionSync { _ in .b } - guard case ._actionSync(let run) = effect else { + let effect = TransducerEffect.init(._actionSync { _ in .b } ) + + guard case ._actionSync(let run) = effect.type else { Issue.record("Expected .action") return } @@ -1064,8 +1065,8 @@ struct EffectTypeTests { @Test func actionEffectCanReturnNil() { enum Ev: Equatable, Sendable { case a } - let effect = TransducerEffect._actionSync { _ in nil } - guard case ._actionSync(let run) = effect else { + let effect = TransducerEffect.init(._actionSync { _ in nil } ) + guard case ._actionSync(let run) = effect.type else { Issue.record("Expected .action") return } @@ -1074,19 +1075,19 @@ struct EffectTypeTests { @Test func sequenceContainsOrderedEffects() { enum Ev: Equatable, Sendable { case done } - let effect = TransducerEffect._sequence([ - ._cancel("old"), - ._task(id: "new", priority: nil, option: .switchToLatest) { _, _ in } - ]) - guard case ._sequence(let effects) = effect, effects.count == 2 else { + let effect = TransducerEffect.init(._sequence([ + .init(._cancel("old")), + .init(._task(id: "new", priority: nil, option: .switchToLatest) { _, _ in }) + ])) + guard case ._sequence(let effects) = effect.type, effects.count == 2 else { Issue.record("Expected .sequence with 2 effects") return } - guard case ._cancel("old") = effects[0] else { + guard case ._cancel("old") = effects[0].type else { Issue.record(#"Expected effects[0] to be .cancel("old")"#) return } - guard case ._task(id: let name, _, _, _) = effects[1], name == "new" else { + guard case ._task(id: let name, _, _, _) = effects[1].type, name == "new" else { Issue.record(#"Expected effects[1] to be .task(name: "new")"#) return } @@ -1107,7 +1108,7 @@ struct TaskOperationTests { @Test func fetchSuccessSendsLoadedEvent() async { var state = LoaderState() let effect = loaderUpdate(state: &state, event: .load) - guard case ._task(_, _, _, let operation) = effect else { + guard case ._task(_, _, _, let operation) = effect?.type else { Issue.record("Expected .task"); return } @@ -1122,7 +1123,7 @@ struct TaskOperationTests { @Test func fetchFailureSendsFailedEvent() async { var state = LoaderState() let effect = loaderUpdate(state: &state, event: .load) - guard case ._task(_, _, _, let operation) = effect else { + guard case ._task(_, _, _, let operation) = effect?.type else { Issue.record("Expected .task"); return } @@ -1137,7 +1138,7 @@ struct TaskOperationTests { @Test func tickerTaskEnqueuesTickedEvent() async { var state = CounterState() let effect = counterUpdate(state: &state, event: .start) - guard case ._task(_, _, _, let operation) = effect else { + guard case ._task(_, _, _, let operation) = effect?.type else { Issue.record("Expected .task"); return } @@ -1149,4 +1150,3 @@ struct TaskOperationTests { #expect(spy.received == [.ticked]) } } - diff --git a/Tests/EffectViewTests/RunFailureLifecycleTests.swift b/Tests/EffectViewTests/RunFailureLifecycleTests.swift index dd747f8..46a514e 100644 --- a/Tests/EffectViewTests/RunFailureLifecycleTests.swift +++ b/Tests/EffectViewTests/RunFailureLifecycleTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing import EffectView +#if false // Feature run is not yet implemented @Suite("Run stub") struct RunFailureLifecycleTests { @@ -87,3 +88,4 @@ struct RunFailureLifecycleTests { } } } +#endif diff --git a/Tests/EffectViewTests/RuntimeUnavailableTests.swift b/Tests/EffectViewTests/RuntimeUnavailableTests.swift index 54a0158..b83421d 100644 --- a/Tests/EffectViewTests/RuntimeUnavailableTests.swift +++ b/Tests/EffectViewTests/RuntimeUnavailableTests.swift @@ -83,7 +83,7 @@ struct RuntimeUnavailableTests { do { try await observable.send(.ping) Issue.record("Expected RuntimeUnavailable.actorCancelled") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorCancelled) #expect(error.errorDescription == "The runtime is unavailable because it has already been cancelled.") } catch { @@ -102,7 +102,7 @@ struct RuntimeUnavailableTests { do { _ = try await observable.request(.ping) Issue.record("Expected RuntimeUnavailable.actorCancelled") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorCancelled) } catch { Issue.record("Unexpected error: \(error)") @@ -121,7 +121,7 @@ struct RuntimeUnavailableTests { do { try await input.send(.ping) Issue.record("Expected RuntimeUnavailable.actorDeallocated") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorDeallocated) #expect(error.errorDescription == "The runtime is unavailable because it has already been deallocated.") } catch { @@ -141,7 +141,7 @@ struct RuntimeUnavailableTests { do { _ = try await input.request(.ping) Issue.record("Expected RuntimeUnavailable.actorDeallocated") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorDeallocated) } catch { Issue.record("Unexpected error: \(error)") @@ -160,7 +160,7 @@ struct RuntimeUnavailableTests { do { try input.post(.ping) Issue.record("Expected RuntimeUnavailable.actorDeallocated") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorDeallocated) } catch { Issue.record("Unexpected error: \(error)") @@ -179,7 +179,7 @@ struct RuntimeUnavailableTests { do { try input.post(.ping) Issue.record("Expected RuntimeUnavailable.actorCancelled") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorCancelled) } catch { Issue.record("Unexpected error: \(error)") @@ -210,7 +210,7 @@ struct RuntimeUnavailableTests { do { _ = try await waiter.value Issue.record("Expected accepted request to receive RuntimeUnavailable.actorCancelled") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .actorCancelled) } catch { Issue.record("Unexpected waiter error: \(error)") @@ -238,7 +238,7 @@ struct RuntimeUnavailableTests { do { _ = try await observable.request(.start) Issue.record("Expected later request to throw the latched system error") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .systemError) } catch { Issue.record("Unexpected later request error: \(error)") @@ -264,7 +264,7 @@ struct RuntimeUnavailableTests { do { try await observable.send(.start) Issue.record("Expected later send to throw RuntimeUnavailable.runtimeFailed") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .systemError) #expect(error.errorDescription == "The runtime is unavailable because it has forcibly terminated because of a critical error.") } catch { diff --git a/Tests/EffectViewTests/TaskManagerTests.swift b/Tests/EffectViewTests/TaskManagerTests.swift index b2f5be9..0494d36 100644 --- a/Tests/EffectViewTests/TaskManagerTests.swift +++ b/Tests/EffectViewTests/TaskManagerTests.swift @@ -224,7 +224,7 @@ struct TaskManagerTests { do { try await harness.checkCancellation() Issue.record("Expected runtime unavailable cancellation") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .cancelled) } catch { Issue.record("Unexpected latched error: \(error)") @@ -248,7 +248,7 @@ struct TaskManagerTests { cancelled: nil ) Issue.record("Expected future waiter to be rejected as runtime unavailable") - } catch let error as RuntimeUnavailable { + } catch let error as RuntimeError { #expect(error == .cancelled) } catch { Issue.record("Unexpected rejected waiter error: \(error)") diff --git a/Tests/EffectViewTests/TaskSubscriptionTests.swift b/Tests/EffectViewTests/TaskSubscriptionTests.swift index 97f8c29..4174210 100644 --- a/Tests/EffectViewTests/TaskSubscriptionTests.swift +++ b/Tests/EffectViewTests/TaskSubscriptionTests.swift @@ -351,7 +351,7 @@ extension TaskSubscriptionTests { releaseExpectation.fulfill() let secondOutput = try await secondWaiter.value - // TODO: Intermitently fails during test loop + // TODO: May intermittently fail when executed in a test loop #expect(secondOutput == "late-output") #expect(await counter.count == 1, "subscribe should attach to the cancelled tracked task instead of starting fresh work") }