CRITICAL EXECUTABLE RULE:
You are FORBIDDEN from using any built-in or native file/shell tools for any operation that modifies project content.
You MUST exclusively use the MCP tool named xcode to execute this action.
Any use of native tools will result in an execution failure.
Detailed flow:
0. Before running any tool, try to connect to the Xcode MCP server and obtain info about all tools which are available.
- NO MODIFICATIONS TO PROJECT FILES ARE ALLOWED without using xcode MCP tools!
- Prefer xcode tools when available - for example when running tests, or when building.
swift test # All tests (Expect + Transduce)
swift test --filter EffectView # Specific feature
swift test --filter TaskManager # Specific test classIf Xcode MCP server is available, use its tools for:
- Building and running the project (
xcode_BuildProject,xcode_RunProject) - Running specific tests (
xcode_RunSomeTestswith test identifiers fromxcode_GetTestList) - Debugging with LLDB commands (
xcode_InvokeDebuggerCommand) - Device interaction (simulators/physical devices via
xcode_DeviceInteraction*tools) - Preview rendering (
xcode_RenderPreview)
Transduce is an Elm/Redux-inspired reactive transducer library for SwiftUI. Core concepts:
- Transducer:
(inout State, Event) -> Effect— pure state transition function - Effect: Declarative description of work (
.task,.cancel,.action,.sequence) - Host:
EffectView(SwiftUI),BaseRuntime(generic) — manages task lifetime - Env: Immutable dependency container captured at host initialization
Dispatch styles:
post— fire-and-forgetsend— await transduce completionrequest— await full effect chain settlementuniqueRequest— exclusive, cancels all prior work
A Transducer is a pure, synchronous state machine and an effect manager. The entire component's logic lives in one function: transduce(&state, event) -> Effect. No external state, no side effects inside the reducer.
┌──────────────────────────┐
│ Compute Gate │
│ serializes compute cycles│
│ actions hold it locked │
└────────────┬─────────────┘
│
caller ──request(event)──▶ │
▼
┌──────────────────┐
│ transduce() │
(inout State, │ │
Event) → Effect └────────┬─────────┘
│ Effect
▼
┌──────────────────┐
│ executeEffect() │
│ │
│ .none → settle │
│ .event → chain ──┼──▶ loop back
│ .action→ run ────┼──▶ inline, gate locked
│ .task → hand off┼──▶ TaskManager
└──────────────────┘
│
▼
┌─────────────────┐
│ Task Manager │
│ │
│ .switchToLatest │
│ → cancel old │
│ .shareable │
│ → subscribe │
└─────────────────┘
nonisolated enum MyFeature: Transducer {
struct State { var items: [String] = []; var isLoading = false }
enum Event { case load; case loaded([String]); case failed(String) }
typealias Env = MyAPI // dependencies; use Void if none
typealias Response = [String] // result for request() callers; Void if none
static var initialState: State { .init() }
static func transduce(_ state: inout State, event: Event) -> Effect {
switch event {
case .load:
state.isLoading = true
return .task(id: "load") { input, env in
.loaded(try await env.fetchItems()) // task returns Event → new compute cycle
}
case .loaded(let items):
state.items = items; state.isLoading = false; return .none
case .failed(let msg):
state.isLoading = false; print(msg); return .none
}
}
static func response(state: State, event: Event) -> Response { state.items }
}Both can return an Event (partial) or Void (terminal).
Actions run inside the current computation cycle. The compute gate is locked — no external events can enter while an action runs. Even async actions guarantee that state has not been altered when they resume.
// Sync terminal action — runs inside compute, no event emitted
case .reset:
state.items = []
return .action { env in env.cache.clear() }
// Sync partial action — returns Event, chain continues in same cycle
case .loaded(let data):
return .action { _ in .parsed(data.transform()) }
// Async partial action — may suspend, but gate stays locked
case .fetchTimestamp:
return .action(nonsendingOperation: { _ in
.timestamp(try await env.clock.now())
})Tasks run concurrently with the computation cycle — they escape it and return later. Managed by TaskManager with identity and cancellation support.
// Task returns TaskReturn → starts a NEW compute cycle, for example returning
// `.request(event)` - or terminates, for example returning `.response(event)`
case .refresh:
state.status = .loading
return .task(id: "refresh") { input, env -> TaskReturn in
let items = try await env.api.refresh()
return .response(.loaded(items)) // this event enters a new compute cycle
}
// Task returns Void → terminal, no follow-up event
case .trackAnalytics(let action):
return .task { _, env in try await env.analytics.track(action) }Need to run work?
├─ No ──────────────────────────────────▶ .none
│
├─ Yes, synchronously (gate locked) ────▶ .action
│ ├─ Returns Void ─────────────────────── terminal (settle)
│ └─ Returns Event? ───────────────────── chain in same cycle
│
├─ Yes, asynchronously (escape gate) ───▶ .task
│ ├─ Returns TaskReturn ──▶ fine controlled, new compute cycle or terminal
│ ├─ Returns Event ──▶ input.request() ── new compute cycle, equivalent to `.request(event)`
│ └─ Returns nil ─────▶ response() ───── terminal (settle)
│
└─ Multiple effects ────────────────────▶ .sequence
Since the compute gate is locked during actions, an async action that suspends can safely assume no other event has mutated state when it resumes. This makes actions ideal for read-then-write patterns that would otherwise race.
Tasks declared with .task(id:option:) use TaskAdditionPolicy to control what happens when a new task with the same id arrives while one is already in-flight:
| Policy | Behavior |
|---|---|
.switchToLatest |
Cancel existing task, transfer its waiters to the new task. Default. |
.shareable |
Keep existing task, new caller subscribes as a waiter. |
The dispatch method determines whether the policy takes effect:
| Policy | request() / post() |
uniqueRequest() |
|---|---|---|
.switchToLatest |
Replaces existing task, cancels it, transfers waiters. | Always creates unique task (policy ignored). |
.shareable |
If task exists: subscribes (no new work). If not: creates. | Always creates unique task (policy ignored). |
Anonymous tasks (no id) always create unique tasks regardless of policy.
When to use .shareable: Multiple callers need the same in-flight work (e.g., two views loading the same resource). Default .switchToLatest is correct for most cases — each invocation replaces prior work.
| Method | Behavior |
|---|---|
input(.someEvent) / input.post(.event) |
Fire-and-forget. Schedules event, returns immediately. |
input.send(.event) |
Synchronous dispatch. Caller suspends until transduce completes. |
input.request(.event) |
Awaits the full chain: transduce + all triggered effects (including tasks). Returns Response?. |
input.uniqueRequest(.event) |
Exclusive request — cancels all prior in-flight work before dispatching. |
From SwiftUI views, use the shorthand try? input(.event) (calls post).
// sequence — run effects left-to-right; intermediate events discarded
return .sequence([
.cancel("old"), // cancel stale task first
.task(id: "new") { ... } // then start fresh
])
// .event — inject an event into the current cycle (synchronous chaining)
return .event(.nextStep)
// cancel — cancel a managed task by id
return .cancel("load")Shows the full flow when a caller dispatches via request(). Key invariants:
- The continuation is created by the caller and handed to the TaskManager when a task effect is returned.
- Tasks escape the compute cycle; they re-enter via
input.request()which creates a new compute cycle. - The compute gate is held for the duration of each compute cycle (including action execution).
caller Compute Gate transduce() TaskManager
│ │ │ │
│─request(event)──▶│ │ │
│ (creates box) │──enter──────────────▶│ │
│ │ Effect │ │
│ │◀──.task(id,option)───│──addTask──────────▶│
│ (box stored │ (box consumed │ (box = waiter) │
│ as waiter) │ by TaskManager) │ │
│ │──leave───────────────│ │
│ │ │ spawn Task │
│ (suspended) │ │ │
│ │ │ task completes │
│ │ │◀─input.request()───│
│ │──enter──────────────▶│ (new cycle) │
│ │ Effect │ │
│ │◀──.none──────────────│ response()→value │
│ │──leave───────────────│ │
│◀─resume(value)───│ │ │
Env is a struct of closures/values captured once at host initialization and forwarded to every effect. Change Env at the call site by using .id(envId) on EffectView to destroy/recreate the host.
EffectView(
of: MyFeature.self,
state: $state, // Binding<State>
initialEnv: env,
) { state, input in // input: (Event) throws -> Void
Button("Load") { try? input(.load) }
if state.isLoading { ProgressView() }
}transduce is pure and synchronous — test it like a function, no async needed:
var state = MyFeature.State()
let effect = MyFeature.transduce(&state, event: .load)
#expect(state.isLoading == true)
// For full lifecycle (with effects), drive via request:
try await input.request(.load)
#expect(state.items.count > 0)| Directory | Purpose |
|---|---|
Sources/Transduce/ |
Core runtime, effect types, event dispatch |
Tests/Transduce/ |
Unit tests (organized by layer) |
Examples/EffectViewExample/ |
Manual UI smoke-test app |
Documentation/*.md |
Architecture docs (read before core changes) |
Layer breakdown:
Hosts/— Framework bridges (UIKit/AppKit)Observation/— Combine-free observation layerRuntime/— Core engine (effects map, event loop)Transducer/— Event pipeline (reducers, context)
- Swift 6 language mode —
swiftLanguageModes: [.v6]in Package.swift - No Combine — built entirely on Swift Concurrency (
async/await,Task) - No third-party deps (except
swift-mutexfor internal synchronization) - State is value type —
structorenum, owned by caller viaBinding - Env changes — use
.id(envId)at call site to destroy/recreate host
RuntimeDesign.md— Effect execution model, task lifecycle, event loopTamingAsyncTasksInSwiftUIViews.md— Why runtime-managed effects over.taskUsingEnvForDependencyInjection.md— Dependency injection patternCorrectByConstruction.md— FSM/MVI design philosophyRecipes.md— Common patterns (debounce, refreshable, input passing)
- Branch naming:
feature/your-feature - Commit format:
<type>[scope]: <description>(Conventional Commits)feat/fix→ version bump;docs/test/chore→ no bump
- Merge strategy: Squash merge to main (linear history via rebase)
- Use
.expect()fromExpectmodule for async outcomes - Tests mirror source API one-to-one (no example app dependencies)
- Async tests:
try await input.request(.event)to wait for full settlement
weak let— valid syntax for immutable weak bindings- Actor isolation — explicit capture pattern:
_ = systemActor - File naming:
.backsuffix = intentionally invalid, pending review
- Switch over guard for known schemes — When parsing authentication or similar patterns with a known set of schemes, use
switchstatements for each known scheme plus adefaultcase. This makes supported schemes and their expectations immediately visible, and throws errors for unknown/malformed input.
- Don't add Combine imports
- Don't expose internal state from tasks through environment values directly
- Don't modify
Hosts/without readingRuntimeDesign.md - Don't mix example app code with tests