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
2 changes: 1 addition & 1 deletion Documentation/ArchitecturalComparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ 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 .task(id: "search") { input, env in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
let results = await env.search(q)
Expand Down
45 changes: 25 additions & 20 deletions Documentation/BridgingEventDrivenAndImperative.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ has settled and returns an optional `Output` value, or throws if the runtime
cannot accept or complete the request:

```swift
.refreshable {
try? await input.request(.refresh)
// resumes only when .refresh has been
// fully processed and the effect settled
}
ContentView()
.refreshable {
try? await input.request(.refresh)
// resumes only when .refresh has been
// fully processed and the effect settled
}
```

The spinner stays active for exactly as long as the work takes — no polling,
Expand Down Expand Up @@ -82,9 +83,10 @@ consequence of that event.
### Pull-to-refresh

```swift
.refreshable {
try? await input.request(.refresh)
}
ContentView()
.refreshable {
try? await input.request(.refresh)
}
```

Note: in this case it is safe to write `try?` since we can ignore the error when
Expand Down Expand Up @@ -117,11 +119,12 @@ When the app regains foreground, re-fetch only if the previous task has
settled:

```swift
task(id: appPhase) {
if appPhase == .active {
try? await input.request(.resumeIfNeeded)
ContentView()
.task(id: appPhase) {
if appPhase == .active {
try? await input.request(.resumeIfNeeded)
}
}
}
```

### Testing
Expand Down Expand Up @@ -156,9 +159,10 @@ launched by that action complete.

```swift
// TCA
.refreshable {
await store.send(.refresh).finish()
}
ContentView()
.refreshable {
await store.send(.refresh).finish()
}
```

This works well for `.refreshable` and handles common edge cases correctly.
Expand Down Expand Up @@ -194,11 +198,12 @@ As a result, bridging to `.refreshable` requires a workaround: a state flag

```swift
// ImmutableData — workaround required
.refreshable {
try? dispatcher.dispatch(action: .refresh)
// must poll or observe isRefreshing to know
// when to let the closure return
}
ContentView()
.refreshable {
try? dispatcher.dispatch(action: .refresh)
// must poll or observe isRefreshing to know
// when to let the closure return
}
```

This is the class of problem that prompted the "event-driven doesn't work
Expand Down
8 changes: 4 additions & 4 deletions Documentation/CorrectByConstruction.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ static func update(

case (_, .searchTapped(let query)):
state = .loading(query: query)
return sequence([
return .sequence([
cancel("search"),
run(id: "search") { input, env in
do {
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 .task(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
34 changes: 23 additions & 11 deletions Documentation/Recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ Button("Retry") {
}
```

Use a short cut. The following is equivalent to the above:
```swift
Button("Retry") {
try? input(.retryTapped)
}

.onChange(of: query) {
try? input(.queryChanged($0))
}
```


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
within the onChange closure.
Expand Down Expand Up @@ -84,15 +96,15 @@ case .queryChanged(let query):
state.query = query
state.isLoading = true

return run(id: "search") { input, env in
return .task(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(.resultsLoaded(results))
} catch {
try input.post(.searchFailed(error.localizedDescription))
try input(.searchFailed(error.localizedDescription))
}
}
```
Expand All @@ -106,14 +118,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"),
.task(id: "refresh") { input, env in
do {
let items = try await env.loadItems()
try input.post(.loaded(items))
try input(.loaded(items))
} catch {
try input.post(.loadFailed(error.localizedDescription))
try input(.loadFailed(error.localizedDescription))
}
}
])
Expand All @@ -131,7 +143,7 @@ struct Env: Sendable {
}

case .startObserving:
return observe(\.store, keyPath: \.count) { input, count in
return .observe(\.store, keyPath: \.count) { input, count, env in
try await input.request(.countChanged(count))
}

Expand Down Expand Up @@ -211,7 +223,7 @@ enum SearchFeature: Transducer {
switch (state, event) {
case (.start, .start):
state = .initializing
return task { _, env in
return .task { _, env in
.didInitialize(try await env.makeClient())
}

Expand All @@ -220,7 +232,7 @@ enum SearchFeature: Transducer {
return nil

case (.idle(let client), .refresh(let query)):
return task { _, _ in
return .task { _, _ in
try await client.search(query)
}

Expand Down
2 changes: 1 addition & 1 deletion Documentation/SwiftUIFirst.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Logic lives in the transducer's transition function:
static func update(
_ state: inout State,
event: Event
) -> Self.Effect?
) -> Effect?
```

This function is not an object. It has no stored properties, no lifecycle, and no hidden shared state. It is easier to read, easier to test, and easier to trace than a ViewModel — and it does more, because it explicitly models every side effect as a return value rather than as a fire-and-forget call inside an async method.
Expand Down
9 changes: 5 additions & 4 deletions Documentation/TamingAsyncTasksInSwiftUIViews.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ 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) {
ContentView()
.task(id: query) {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = await search(query)
Expand Down Expand Up @@ -130,7 +131,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))
Expand Down Expand Up @@ -169,7 +170,7 @@ 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 .task(id: "download-\(id)") { input, env in
do {
let data = try await env.download(id)
try input.post(.downloaded(id, data))
Expand All @@ -193,7 +194,7 @@ 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 .task(id: "search") { input, env in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
let results = await env.search(q)
Expand Down
8 changes: 4 additions & 4 deletions Documentation/UsingEnvForDependencyInjection.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,17 @@ The transducer's `update` requirement now becomes:
static func update(
_ state: inout MovieSearchState,
event: MovieSearchEvent
) -> Self.Effect?
) -> Effect?
```

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"),
.task(id: "search") { input, env in
env.trackQuery(query)
do {
let movies = try await env.search(query)
Expand Down
4 changes: 2 additions & 2 deletions Examples/EffectViewExample/EffectViewExample/Counter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ extension Counter.Transducer: Transducer {
switch event {
case .start:
state.counter = 0
return task(id: "Counter") { input, env in
return .task(id: "Counter") { input, env in
while true {
do {
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec
Expand All @@ -52,7 +52,7 @@ extension Counter.Transducer: Transducer {
case .tick:
state.counter += 1; return nil
case .stop:
return cancel("Counter")
return .cancel("Counter")
}
}

Expand Down
8 changes: 4 additions & 4 deletions Examples/EffectViewExample/EffectViewExample/Movies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ extension Movies.Transducer: Transducer {
case .refresh:
// Always supersedes a pending load; named task cancels any prior refresh.
state.mode = .refreshing
return sequence([cancel("load"), refreshMovies()])
return .sequence([.cancel("load"), refreshMovies()])

case .loaded(let movies):
state.mode = .idle
Expand All @@ -123,7 +123,7 @@ extension Movies.Transducer: Transducer {

case .cancel:
state.mode = .idle
return cancel("load")
return .cancel("load")

case .dismiss:
state.mode = .idle
Expand All @@ -133,7 +133,7 @@ extension Movies.Transducer: Transducer {


static func loadMovies() -> Effect {
task(id: "load") { input, env in
Effect.task(id: "load") { input, env in
do {
let movies = try await env.movieFetch()
try input(.loaded(movies))
Expand All @@ -145,7 +145,7 @@ extension Movies.Transducer: Transducer {

static func refreshMovies() -> Effect {
// Note: a refresh action
task(id: "refresh") { input, env in
Effect.task(id: "refresh") { input, env in
do {
let movies = try await env.movieFetch()
try input(.loaded(movies))
Expand Down
10 changes: 5 additions & 5 deletions Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ extension RemoteCounter.Transducer: Transducer {
switch event {

case .start:
return observe(
return .observe(
\.store, keyPath: \.count,
id: "observe-store-count"
) { input, value in
) { input, value, env in
print("observation-handler store.count: ", value)
try? await input.request(.storeChanged(newCount: value))
}
Expand All @@ -92,19 +92,19 @@ extension RemoteCounter.Transducer: Transducer {

case .incrementTapped:
print("incrementTapped")
return task { input, env in
return .task { input, env in
await env.store.send(.increment)
}

case .decrementTapped:
print("decrementTapped")
return task { _, env in
return .task { _, env in
await env.store.send(.decrement)
}

case .resetTapped:
print("resetTapped")
return task { _, env in
return .task { _, env in
await env.store.send(.reset)
}
}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ enum SearchFeature: Transducer {
state.isLoading = true
state.errorMessage = nil

return task(id: "search") { input, env in
return .task(id: "search") { input, env in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }

Expand Down
Loading
Loading