Skip to content

Latest commit

 

History

History
340 lines (268 loc) · 15.4 KB

File metadata and controls

340 lines (268 loc) · 15.4 KB

AGENTS.md — EffectComponents

Critical Rules

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.

  1. NO MODIFICATIONS TO PROJECT FILES ARE ALLOWED without using xcode MCP tools!
  2. Prefer xcode tools when available - for example when running tests, or when building.

Quick Start

swift test                          # All tests (Expect + Transduce)
swift test --filter EffectView      # Specific feature
swift test --filter TaskManager     # Specific test class

If Xcode MCP server is available, use its tools for:

  • Building and running the project (xcode_BuildProject, xcode_RunProject)
  • Running specific tests (xcode_RunSomeTests with test identifiers from xcode_GetTestList)
  • Debugging with LLDB commands (xcode_InvokeDebuggerCommand)
  • Device interaction (simulators/physical devices via xcode_DeviceInteraction* tools)
  • Preview rendering (xcode_RenderPreview)

Architecture

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-forget
  • send — await transduce completion
  • request — await full effect chain settlement
  • uniqueRequest — exclusive, cancels all prior work

Transducer Capabilities (for implementing new components)

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.

Component architecture

                ┌──────────────────────────┐
                │       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    │
                             └─────────────────┘

Anatomy of a Transducer

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 }
}

Two kinds of effects: Actions and Tasks

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) }

Effect type decision tree

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

Key insight: actions guarantee state immutability during execution

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.

Task overlap policy (TaskAdditionPolicy)

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.

Four dispatch styles

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).

Effect composition

// 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")

Request lifecycle

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)───│                     │                    │

Dependency injection via Env

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.

Minimal SwiftUI integration

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() }
}

Testing a Transducer

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)

Project Structure

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 layer
  • Runtime/ — Core engine (effects map, event loop)
  • Transducer/ — Event pipeline (reducers, context)

Critical Conventions

  1. Swift 6 language modeswiftLanguageModes: [.v6] in Package.swift
  2. No Combine — built entirely on Swift Concurrency (async/await, Task)
  3. No third-party deps (except swift-mutex for internal synchronization)
  4. State is value typestruct or enum, owned by caller via Binding
  5. Env changes — use .id(envId) at call site to destroy/recreate host

Documentation Priority (read before touching core)

  1. RuntimeDesign.md — Effect execution model, task lifecycle, event loop
  2. TamingAsyncTasksInSwiftUIViews.md — Why runtime-managed effects over .task
  3. UsingEnvForDependencyInjection.md — Dependency injection pattern
  4. CorrectByConstruction.md — FSM/MVI design philosophy
  5. Recipes.md — Common patterns (debounce, refreshable, input passing)

Git Workflow

  • 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)

Test Conventions

  • Use .expect() from Expect module 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

Swift 6.2 Patterns (see SKILLS-Swift62Patterns.md)

  • weak let — valid syntax for immutable weak bindings
  • Actor isolation — explicit capture pattern: _ = systemActor
  • File naming: .back suffix = intentionally invalid, pending review

Axioms

  • Switch over guard for known schemes — When parsing authentication or similar patterns with a known set of schemes, use switch statements for each known scheme plus a default case. This makes supported schemes and their expectations immediately visible, and throws errors for unknown/malformed input.

What to Avoid

  • Don't add Combine imports
  • Don't expose internal state from tasks through environment values directly
  • Don't modify Hosts/ without reading RuntimeDesign.md
  • Don't mix example app code with tests