Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions Documentation/ArchitecturalComparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -58,7 +58,7 @@ The most direct comparison to EffectView. TCA targets SwiftUI with a Redux-shape

**Effect model:** `Effect<Action>` 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.

Expand Down Expand Up @@ -119,19 +119,19 @@ 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:

```swift
// 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))
}
```

Expand All @@ -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
```

Expand Down
13 changes: 12 additions & 1 deletion Documentation/BridgingEventDrivenAndImperative.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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)
}
Expand Down
20 changes: 10 additions & 10 deletions Documentation/CorrectByConstruction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
])
Expand All @@ -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
Expand Down Expand Up @@ -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)):
Expand All @@ -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.
Expand Down Expand Up @@ -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() {
Expand All @@ -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.

Expand Down
43 changes: 43 additions & 0 deletions Documentation/GitWorkflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 17 additions & 11 deletions Documentation/Recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
```
Expand All @@ -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))
}
}
])
Expand All @@ -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):
Expand All @@ -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.
The test checks what changed immediately. If needed, separate tests can exercise the returned effect path.
45 changes: 41 additions & 4 deletions Documentation/RuntimeDesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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.
The design is intentional, not accidental.
Loading
Loading