From c5bdb7227406a6d62e5f33a17bf1df4b5fc7d737 Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Tue, 12 May 2026 19:41:35 +0200 Subject: [PATCH] feat: add Effect.observe for @Observable store observation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ObservationHelpers.swift to the library with two public APIs: - Effect.observe(_:keyPath:name:priority:handler:) — observes a key path on an @Observable object passed directly. Returns a named, cancellable task effect. - Effect.observe(_envKeyPath:keyPath:name:priority:handler:) — env key path overload; resolves the object from Env inside the task, so nothing is captured at update time. - observeKeyPath(_:keyPath:handler:) — free function for use inside custom .task effects. On macOS/iOS 26+ uses Observations.untilFinished (structured, cooperative cancellation). On earlier OS versions falls back to recursive withObservationTracking. KeyPath sendability is handled internally via a private SendableKeyPath: @unchecked Sendable wrapper, so callers require no SE-0418 flag and no & Sendable annotation. Package.swift: enable InferSendableFromCaptures for both targets. README.md: update Recipes section with the Observe an external @Observable store recipe, covering both overloads. Add RemoteCounter example demonstrating the pattern: a @MainActor CounterStore observed via .observe(\.store, keyPath: \.count), with all writes going through the store's own event API. Add Documentation/ArchitecturalComparison.md comparing EffectView against MVVM, Redux, TCA, Elm, and Elixir/GenServer across state model, effect model, task lifecycle, dispatch semantics, and testability. --- Documentation/ArchitecturalComparison.md | 189 ++++++++++++++++++ .../project.pbxproj | 12 +- .../EffectViewExample/App.swift | 10 + .../EffectViewExample/RemoteCounter.swift | 156 +++++++++++++++ Package.swift | 8 +- README.md | 47 +++++ Sources/EffectView/ObservationHelpers.swift | 172 ++++++++++++++++ 7 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 Documentation/ArchitecturalComparison.md create mode 100644 Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift create mode 100644 Sources/EffectView/ObservationHelpers.swift diff --git a/Documentation/ArchitecturalComparison.md b/Documentation/ArchitecturalComparison.md new file mode 100644 index 0000000..fd6190c --- /dev/null +++ b/Documentation/ArchitecturalComparison.md @@ -0,0 +1,189 @@ +# Architectural Comparison + +EffectView is not a new idea. It translates a family of well-established patterns — Elm, Redux, Elixir/GenServer — into idiomatic SwiftUI, using Swift's own concurrency model rather than fighting it. + +This document maps EffectView against the patterns iOS developers are most likely to know, across five dimensions that matter in practice: + +1. **State model** — what kinds of state exist, who owns each kind, and who can mutate it +2. **Effect/side-effect model** — how async work is described and executed +3. **Task lifecycle** — who creates tasks, who cancels them, and what scopes their lifetime +4. **Dispatch semantics** — what it means to "send" an event or action +5. **Testability** — what you need to construct to exercise the logic + +--- + +## The patterns + +### MVVM with `@Observable` + +The default iOS pattern today. State lives in an `@Observable` class (the ViewModel). Methods on the ViewModel mutate state directly and launch async work via unstructured `Task { }` calls. + +MVVM is a pattern, not a framework — it has no canonical implementation and no enforced conventions. Teams establish their own rules about what goes in the ViewModel, how state is exposed, and how async work is managed. Those rules exist only in documentation and code review; the compiler enforces nothing. + +**State model:** All state — ephemeral view state, references to shared stores, and cached results from persistence — collapses into the ViewModel class. There is no structural distinction between them. + +In the most common implementation, `@Observable` properties are fully public and read-write. Any code that holds a reference to the ViewModel can read *and* write any property at any time. This is widely recognised as an anti-pattern: a view that writes `viewModel.isLoading = false` directly bypasses whatever invariants the ViewModel was trying to maintain. The conventional remedy is to mark properties `private(set)` and expose mutation only through methods — but this is unenforced convention, and it still doesn't prevent two methods (or two tasks running the same method) from racing against each other. + +**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. + +**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. + +**Testability:** Requires constructing the whole ViewModel. Async side effects run in unstructured tasks that the test cannot directly observe. Testing requires `@MainActor` isolation, `XCTestExpectation`, or polling. + +--- + +### Redux + +A global store holds the entire application state. A single pure *reducer* function handles all state mutations: `(State, Action) -> State`. Side effects are expressed as values (thunks, sagas, or middleware intercepts) that the framework executes outside the reducer. + +**State model:** One global tree. All state — ephemeral UI, shared domain state, persistent cache — lives in the same structure. Slices are selected via key paths or selectors. Any component can subscribe to any slice. + +**Effect model:** Effects are values returned from (or intercepted by) middleware. The reducer itself is pure. The middleware layer executes async work and dispatches further actions when done. + +**Task lifecycle:** Middleware-managed. Cancellation requires dispatching a cancel action that middleware intercepts. The discipline is enforced by convention, not by the type system. + +**Dispatch:** Fire-and-forget. `store.dispatch(.loadMovies)` enqueues an action and returns immediately. There is no standard mechanism to await the completion of the effect chain that action triggers. + +**Testability:** The reducer is trivially testable — pure function, no async. Testing effects requires a test middleware or mock store. Testing the interaction between state changes and effects requires more setup. + +--- + +### The Composable Architecture (TCA) + +The most direct comparison to EffectView. TCA targets SwiftUI with a Redux-shaped architecture: a `@Reducer` macro generates a store, actions map to state mutations and `Effect` return values. + +**State model:** Composable tree of child stores. Parent features compose child features using `Scope` and `IfLetStore`. Shared state is managed via `@Shared` property wrappers with explicit persistence strategies. + +**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. + +**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. + +**Testability:** `TestStore` provides a structured assertion API: `await store.send(.event)` followed by `await store.receive(.resultEvent)`. Well-designed and expressive, but requires the TCA-specific test harness. + +--- + +### Elm Architecture + +The direct ancestor of all patterns in this family. An Elm application is defined by three things: `Model` (state), `Msg` (events), and `update : Msg -> Model -> (Model, Cmd Msg)` (transition function). The runtime handles rendering and command execution. + +**State model:** A single immutable value, replaced on every update. No shared mutable state exists — inter-component communication is message-passing only. Ephemeral UI state and domain state are co-located in `Model` but structurally distinguished by the developer. + +**Effect model:** `Cmd Msg` is a description of work to perform. The Elm runtime executes it and delivers the result as a new `Msg`. The update function never performs async work directly — it only describes it. + +**Task lifecycle:** The runtime owns all tasks. Subscriptions (`Sub Msg`) are a separate concept: the runtime diffs the current subscription set against the previous one on each render cycle and starts or stops accordingly. There is no imperative cancel. + +**Dispatch:** All messages are fire-and-forget. The runtime processes them one at a time, but there is no mechanism for a command to await the completion of the message it eventually triggers. Sequencing requires modelling intermediate states explicitly in `Model`. + +**Testability:** The update function is a pure function. No framework, no async, no mocking. Test by calling `update msg model` and asserting on the returned `(model, cmd)`. + +--- + +### Elixir / Phoenix LiveView / GenServer + +The most conceptually illuminating comparison. A `GenServer` is an actor with a single `handle_call` / `handle_cast` callback — the structural equivalent of `update`. State is private to the process; all mutations go through the callback; side effects are either synchronous return values or out-of-band messages sent to other processes. + +Phoenix LiveView's `handle_event` maps almost directly to EffectView's `update`: it receives the current socket (state), an event name, and parameters, mutates the socket, and optionally pushes async work via `Task.async` or `send_update`. + +**State model:** Process-local. Each LiveView socket / GenServer process owns its own state exclusively. Shared state between processes requires explicit message passing or a shared ETS table — it is never implicit. + +**Effect model:** Side effects are first-class values in the return tuple: `{:noreply, socket, {:continue, :load_data}}`. Async work spawns a child process that sends a message back when done. + +**Task lifecycle:** OTP supervision trees manage process lifecycles. A process that crashes is restarted by its supervisor. When a LiveView socket disconnects, its process terminates and all associated state is cleaned up automatically. + +**Dispatch:** `GenServer.call` is synchronous — the caller blocks until the server processes the message and replies. `GenServer.cast` is fire-and-forget. LiveView's `handle_event` is cast-style. There is no direct equivalent of `perform` — sequencing requires chaining `handle_info` messages via `{:continue, ...}`. + +**Testability:** GenServer logic is testable by sending messages to a test process and asserting on state or replies. LiveView provides `Phoenix.LiveViewTest` for end-to-end socket testing. + +--- + +## EffectView + +EffectView translates the Elm/GenServer model into idiomatic SwiftUI — using `@State`, structured concurrency, and `@MainActor` as the runtime rather than a custom one. + +**State model:** Three kinds of state are structurally distinct: + +- **Ephemeral state** (`ViewState`) — a plain value type owned by `@State`. Lives and dies with the view's identity. Nothing outside the view can read or write it. +- **Shared state** — an `@Observable` object passed via `Env` or the SwiftUI environment. A view *observes a named slice* of shared state via `.observe(\.store, keyPath: \.count)` and receives changes as events into its own `update` loop. The view never writes shared state directly — it sends events to the store's own mutation API. +- **Persistent state** — always external. Effects read from or write to persistence, then translate results back into events. `update` never sees storage directly. + +The read/write relationship with shared state is asymmetric by construction: + +``` +Store ──(observed slice)──▶ events ──▶ update ──▶ ViewState (read) +ViewState ──(user action)──▶ update ──▶ effect ──▶ store.send (write) +``` + +**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 `.task(name:)` from `update`. They are cancelled by returning `.cancel(name)` 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 named task is automatically cancelled and replaced if `update` returns a new `.task` with the same name before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management: + +```swift +// update: +case .queryChanged(let q): + state.query = q + return .task(name: "search") { input, env in // cancels any prior "search" task + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + let results = await env.search(q) + await input.perform(.resultsLoaded(results)) + } +``` + +**Dispatch semantics:** `Input` exposes three levels, chosen at the call site: + +| Method | Semantics | Use when | +|---|---|---| +| `input(.event)` | Fire-and-forget. Enqueues on `@MainActor`, returns immediately. | Button handlers, `onChange`, observation fire-and-forget | +| `await input.send(.event)` | Waits until `update` has processed the event. State is settled; effects are only *started*. | Caller needs to read resulting state, doesn't care about downstream work | +| `await input.perform(.event)` | Suspends until the full effect chain — `update`, the returned effect, and any effects it triggers recursively — has settled. | Pull-to-refresh spinners, sequential task steps, observation loop backpressure | + +`perform` is the mechanism that makes SwiftUI's `.refreshable` work naturally: + +```swift +.refreshable { + await input.perform(.refresh) // spinner shown until the full load cycle completes +} +``` + +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 +await input.perform(.storeChanged(newCount: count)) // next observation cycle waits here +``` + +No rate-limiting code, no semaphores. The dispatch semantic *is* the backpressure mechanism. + +**Testability:** `update` is a static function — `(inout ViewState, Event) -> Effect?`. No framework, no `@MainActor`, no mocking. The full transition logic is exercisable from a plain `XCTest`: + +```swift +var state = MyView.ViewState() +let effect = MyView.update(&state, event: .loadMovies) +XCTAssertEqual(state.isLoading, true) +// inspect the returned Effect description if needed +``` + +--- + +## Summary + +| | MVVM | Redux | TCA | Elm | Elixir/GenServer | EffectView | +|---|---|---|---|---|---|---| +| **Ephemeral state owner** | ViewModel class | Global store | Feature store | Model value | Process-local | `ViewState` value | +| **Shared state access** | Direct reference | Global selector | `@Shared` wrapper | Message-passing only | Explicit IPC | Read-only slice via `.observe` | +| **Mutation authority** | Anyone with a reference | Reducer only | Reducer only | `update` only | `handle_*` only | `update` only | +| **Effect description** | Imperative `Task { }` | Middleware value | `Effect` | `Cmd Msg` | Return tuple | `Effect` | +| **Task creation** | Anywhere | Middleware | `Effect.run` | Runtime | Spawn | `update` return value | +| **Task cancellation** | Manual handle | Dispatch cancel action | `CancelID` action | Runtime subscription diff | Process termination | `update` return value | +| **Task scope** | ViewModel lifetime | App lifetime | Store lifetime | Runtime | Process tree | View identity | +| **Dispatch levels** | Synchronous call | Fire-and-forget | Fire-and-forget (+ async `send` inside effects) | Fire-and-forget | `call` (sync) / `cast` (async) | `enqueue` / `send` / `perform` | +| **Test surface** | Full class construction | Reducer pure function | `TestStore` harness | Pure `update` function | Process message passing | Static pure function | + +The common thread in the well-designed patterns (Elm, GenServer, TCA, EffectView) is the same: a single authoritative transition function that owns all state mutations and returns effect descriptions. The differences are in scope (global vs. local), dispatch semantics, task lifecycle management, and how much framework ceremony is required to express the pattern. + +EffectView's position is that the SwiftUI runtime already provides the scope, lifecycle, and concurrency model — the only missing piece is a structured way to describe and manage effects. The library adds that piece and nothing else. diff --git a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj index fc110a2..c769731 100644 --- a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj +++ b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj @@ -329,6 +329,8 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -385,6 +387,8 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; @@ -410,6 +414,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; PRODUCT_BUNDLE_IDENTIFIER = com.andreas.grosam.home.SimpleEffectView; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -417,7 +422,6 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -443,6 +447,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures"; PRODUCT_BUNDLE_IDENTIFIER = com.andreas.grosam.home.SimpleEffectView; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -450,7 +455,6 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -471,7 +475,6 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EffectViewExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EffectViewExample"; }; @@ -493,7 +496,6 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EffectViewExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EffectViewExample"; }; @@ -513,7 +515,6 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = SimpleEffectView; }; @@ -533,7 +534,6 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = SimpleEffectView; }; diff --git a/Examples/EffectViewExample/EffectViewExample/App.swift b/Examples/EffectViewExample/EffectViewExample/App.swift index 119045a..3747db9 100644 --- a/Examples/EffectViewExample/EffectViewExample/App.swift +++ b/Examples/EffectViewExample/EffectViewExample/App.swift @@ -15,6 +15,16 @@ struct EffectViewExampleApp: App { .tabItem { Label("Movies", systemImage: "film") } + + // Requires the Observation framework + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + RemoteCounter.Views.ContentView() + .tabItem { + Label("Remote Counter", systemImage: "arrow.trianglehead.2.clockwise") + } + } else { + // Fallback on earlier versions + } } } } diff --git a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift new file mode 100644 index 0000000..e3b6011 --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift @@ -0,0 +1,156 @@ +import SwiftUI +import Foundation +import EffectView + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +public enum RemoteCounter { + public enum Views {} +} + +// MARK: - Remote Store + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +extension RemoteCounter { + + /// A minimal FSM actor implemented with `@Observable`. + /// Private state, a public read-only projection, and an event-driven mutation API. + /// Observers must use `withObservationTracking` (or wrap it) — the store itself + /// does not publish a stream. + @Observable @MainActor + final class CounterStore: Sendable { + + enum Event { case increment, decrement, reset } + + private(set) var count: Int = 0 + + func send(_ event: Event) { + switch event { + case .increment: count += 1 + case .decrement: count -= 1 + case .reset: count = 0 + } + } + } +} + +// MARK: - Views + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +extension RemoteCounter.Views { + + struct ContentView: View { + + /// The store lives here — single instance for this subtree. + @State private var store = RemoteCounter.CounterStore() + + var body: some View { + CounterView(env: .init(store: store)) + } + } + + struct CounterView: View { + + struct ViewState { + var count: Int = 0 + var lastDelta: Int = 0 + } + + enum Event { + case start + case storeChanged(newCount: Int) + case incrementTapped + case decrementTapped + case resetTapped + } + + struct Env: Identifiable { + let id: UUID = .init() + let store: RemoteCounter.CounterStore + } + + @State private var state = ViewState() + let env: Env + + @MainActor + static func update( + _ state: inout ViewState, + event: Event + ) -> Effect? { + switch event { + + case .start: + return .observe( + \.store, keyPath: \.count, + name: "observe-store-count" + ) { @MainActor input, value in + print("observe-store-count: ", value) + await input.perform(.storeChanged(newCount: value)) + } + + case .storeChanged(let newCount): + // The only path that writes the mirrored value. + print("received event: \(event)") + + state.lastDelta = newCount - state.count + state.count = newCount + return nil + + case .incrementTapped: + return .task { _, env in + await env.store.send(.increment) + } + + case .decrementTapped: + return .task { _, env in + await env.store.send(.decrement) + } + + case .resetTapped: + return .task { _, env in + await env.store.send(.reset) + } + } + } + + var body: some View { + EffectView( + state: $state, + initialEvent: .start, + initialEnv: env, + update: Self.update(_:event:) + ) { state, send in + VStack(spacing: 20) { + Text("\(state.count)") + .font(Font.largeTitle.monospacedDigit()) + Text(deltaLabel(state.lastDelta)) + .foregroundStyle(.secondary) + HStack(spacing: 24) { + Button("−") { send(.decrementTapped) } + Button("+") { send(.incrementTapped) } + Button("Reset") { send(.resetTapped) } + } + .buttonStyle(.bordered) + } + } + .id(env.id) + } + + private func deltaLabel(_ delta: Int) -> String { + switch delta { + case 0: return " " + case 1...: return "+\(delta)" + default: return "\(delta)" + } + } + } +} + +// MARK: - Previews + +#Preview { + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + RemoteCounter.Views.ContentView() + } else { + Text("RemoteCounter not available on this OS version") + } +} diff --git a/Package.swift b/Package.swift index f903f2a..4a1b359 100644 --- a/Package.swift +++ b/Package.swift @@ -26,13 +26,19 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "EffectView" + name: "EffectView", + swiftSettings: [ + .enableUpcomingFeature("InferSendableFromCaptures"), + ] ), .testTarget( name: "EffectViewTests", dependencies: [ "EffectView", .product(name: "Mutex", package: "swift-mutex"), + ], + swiftSettings: [ + .enableUpcomingFeature("InferSendableFromCaptures"), ] ), ], diff --git a/README.md b/README.md index f58ba1f..3ca471a 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,53 @@ return .task { input, env in --- +### Observe an external `@Observable` store + +Use `.observe` to mirror a property from an external `@Observable` object into `ViewState`. The handler is called with the initial value immediately and again on every subsequent change. The named task is cancelled automatically when the view disappears. + +Two overloads are available depending on where the observable object comes from. + +**When the store lives in `Env`** — use the env key path overload. The object is resolved inside the task, so nothing is captured at `update` time: + +```swift +struct Env: Identifiable { + let id: UUID = .init() + let store: CounterStore // an @Observable @MainActor object +} + +// update: +case .start: + return .observe(\.store, keyPath: \.count, name: "observe-count") { input, count in + await input.perform(.countChanged(count)) // perform waits before the next cycle + } + +case .countChanged(let count): + state.count = count // the only path that writes the mirrored value + return nil + +case .incrementTapped: + return .task { _, env in + await env.store.send(.increment) // write via the store's own event API + } +``` + +**When the store comes from elsewhere** — for example imported into the view by a previous event — use the direct overload and pass the object itself: + +```swift +// update: +case .storeReceived(let store): + state.store = store + return .observe(store, keyPath: \.count, name: "observe-count") { input, count in + await input.perform(.countChanged(count)) + } +``` + +The effect holds the object weakly; the observation loop exits automatically if the object is deallocated before the task is cancelled. + +> **Requires** iOS 17 / macOS 14 or later (the `@Observable` macro minimum deployment). + +--- + ## Contributing Contributions are welcome. Please follow the [Git workflow](Documentation/GitWorkflow.md) used in this project. diff --git a/Sources/EffectView/ObservationHelpers.swift b/Sources/EffectView/ObservationHelpers.swift new file mode 100644 index 0000000..88932f1 --- /dev/null +++ b/Sources/EffectView/ObservationHelpers.swift @@ -0,0 +1,172 @@ +import Observation + +// MARK: - Effect.observe + +extension Effect { + + /// Observes a key path on an `@Observable` object resolved from the environment, + /// dispatching events as the value changes. + /// + /// The handler is invoked with the **initial value** immediately, then again on every + /// subsequent change, until the task is cancelled or the object is deallocated. + /// + /// The object is resolved from the environment inside the task, so the effect captures + /// only a key path rather than the object itself. Use ``Input/perform(_:)`` in the + /// handler so the loop waits for the view to settle before advancing: + /// + /// ```swift + /// // update: + /// case .start: + /// return .observe(\.store, keyPath: \.count) { input, count in + /// await input.perform(.countChanged(count)) + /// } + /// ``` + /// + /// The named task (`"observe"` by default) is cancelled automatically when the view + /// disappears, or immediately when `update` returns `.cancel(name)`. + /// + /// - Parameters: + /// - envKeyPath: Key path from `Env` to the `@Observable` object. The object is held + /// weakly inside the task; the loop exits when it is deallocated. + /// - keyPath: The property on the object to observe. + /// - name: Optional name for the underlying task. Defaults to `"observe"`. + /// - priority: Optional `TaskPriority` for the underlying task. + /// - handler: Called with `input` and the current value on the initial read and on + /// every subsequent change. `async` — use `await input.perform(…)` to wait for the + /// view to settle before the next observation cycle. + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + public static func observe( + _ envKeyPath: KeyPath, + keyPath: KeyPath, + name: String? = "observe", + priority: TaskPriority? = nil, + handler: @escaping @MainActor @Sendable (Input, Value) async -> Void + ) -> Self + where Object: Observable & AnyObject & Sendable, Value: Sendable + { + let box = SendableKeyPath(keyPath: keyPath) + let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) + return .task(name: name, priority: priority) { @MainActor input, env in + let object = env[keyPath: envKeyPathBox.keyPath] + await observeKeyPath(object, keyPath: box.keyPath) { value in + await handler(input, value) + } + } + } + + /// Observes a key path on an `@Observable` object and dispatches events as values change. + /// + /// The handler is invoked with the **initial value** immediately, then again on every + /// subsequent change, until the task is cancelled or the object is deallocated. + /// + /// The `input` parameter gives the handler the same three dispatch strategies + /// (``Input/enqueue(_:)``, ``Input/send(_:)``, ``Input/perform(_:)``) available in any + /// other effect. For observation you will typically want ``Input/perform(_:)`` so the loop + /// waits for the EffectView to process each change before advancing to the next one: + /// + /// ```swift + /// // update: + /// case .storeReceived(let store): + /// return .observe(store, keyPath: \.count) { input, count in + /// await input.perform(.countChanged(count)) + /// } + /// ``` + /// + /// The named task (`"observe"` by default) is cancelled automatically when the view + /// disappears, or immediately when `update` returns `.cancel(name)`. + /// + /// - Parameters: + /// - object: The `@Observable` object to watch. Held weakly inside the task so the + /// effect does not extend the object's lifetime. The loop exits when `object` is + /// deallocated. + /// - keyPath: The property to observe. + /// - name: Optional name for the underlying task. Defaults to `"observe"`. + /// - priority: Optional `TaskPriority` for the underlying task. + /// - handler: Called with `input` and the current value on the initial read and on + /// every subsequent change. `async` — use `await input.perform(…)` to wait for the + /// view to settle before the next observation cycle. + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + public static func observe( + _ object: Object, + keyPath: KeyPath, + name: String? = "observe", + priority: TaskPriority? = nil, + handler: @escaping @MainActor @Sendable (Input, Value) async -> Void + ) -> Self + where Object: Observable & AnyObject & Sendable, Value: Sendable + { + let box = SendableKeyPath(keyPath: keyPath) + return .task(name: name, priority: priority) { @MainActor input, _ in + await observeKeyPath(object, keyPath: box.keyPath) { value in + await handler(input, value) + } + } + } + +} + +// MARK: - Internal helpers + +/// A minimal `@unchecked Sendable` box for `KeyPath`. +/// +/// `KeyPath` is a value type with no mutable state — it is intrinsically safe to share +/// across concurrency domains. This wrapper makes that explicit so key path values can be +/// captured in `@Sendable` closures without requiring `SE-0418` (`InferSendableFromCaptures`) +/// at every call site. +private struct SendableKeyPath: @unchecked Sendable { + let keyPath: KeyPath +} + +/// Observes a key path on an `@Observable` object, calling `handler` with each new value +/// until the current task is cancelled or `object` is deallocated. +/// +/// On macOS 26+ / iOS 26+ uses `Observations.untilFinished` — a structured `AsyncSequence` +/// with cooperative cancellation. On earlier OS versions falls back to +/// `withObservationTracking` with recursive re-registration. +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +@MainActor +public func observeKeyPath( + _ object: Object, + keyPath: KeyPath, + handler: @escaping @MainActor @Sendable (Value) async -> Void +) async where Object: Observable & AnyObject & Sendable, Value: Sendable { + let box = SendableKeyPath(keyPath: keyPath) + if #available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, *) { + let observations = Observations.untilFinished { [weak object] in + guard let object else { return .finish } + return .next(object[keyPath: box.keyPath]) + } + for await value in observations { + await handler(value) + } + } else { + // Seed the initial value — withObservationTracking only fires on *changes*. + await handler(object[keyPath: box.keyPath]) + _observeKeyPath_legacy(object, keyPath: box, handler: handler) + } +} + +/// Legacy recursive helper for `observeKeyPath` on macOS < 26. +/// +/// `onChange` fires *before* the new value is committed and on an arbitrary thread, so a +/// child `Task` hops to `@MainActor` to read the settled value. One unstructured task may +/// outlive cancellation by a single iteration — this is benign because `input.perform` on +/// a completed `EffectView` is a no-op. +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +@MainActor +private func _observeKeyPath_legacy( + _ object: Object, + keyPath box: SendableKeyPath, + handler: @escaping @MainActor @Sendable (Value) async -> Void +) where Object: Observable & AnyObject & Sendable, Value: Sendable { + withObservationTracking { + _ = object[keyPath: box.keyPath] + } onChange: { + guard !Task.isCancelled else { return } + Task { @MainActor [weak object] in + guard let object else { return } + await handler(object[keyPath: box.keyPath]) + _observeKeyPath_legacy(object, keyPath: box, handler: handler) + } + } +}