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
20 changes: 20 additions & 0 deletions Sources/Flow/Flow.docc/CoreElements.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ return .cancel(id: "save", returning: .cancelled)

**Task executing multiple tasks sequentially**

Static tasks (compile-time):

```swift
// Execute multiple steps in sequence
return .concatenate(
Expand All @@ -226,6 +228,24 @@ return .concatenate(
)
```

Dynamic tasks (runtime) - guard against empty:

```swift
// Process items dynamically
let tasks = items.map { item in
ActionTask.run { state in
try await process(item)
}
}

// Empty arrays require explicit handling
guard !tasks.isEmpty else {
return .none // Or handle empty case appropriately
}

return try .concatenate(tasks)
```

Each task executes after the previous one completes. The final task's result is returned to the view.

## Next Steps
Expand Down
39 changes: 26 additions & 13 deletions Sources/Flow/Store/ActionTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -474,30 +474,43 @@ extension ActionTask {
/// - Parameter tasks: Variadic list of tasks to concatenate
/// - Returns: A single task that runs all tasks sequentially and returns the last meaningful result
public static func concatenate(_ tasks: ActionTask...) -> ActionTask {
concatenate(tasks)
// Variadic guarantees at least one element at call site
// Safe to force try because tasks cannot be empty
// swiftlint:disable:next force_try
try! concatenate(tasks)
}

/// Concatenates an array of tasks to run sequentially.
///
/// This is the array version of the variadic `concatenate` method.
/// Useful when you have a dynamic number of tasks.
/// Throws `FlowError.noTasksToExecute` if the task array is empty.
/// This strict behavior helps catch logic errors early.
///
/// ## Example
/// ## Example: Dynamic Tasks with Guard
/// ```swift
/// // Process items one by one
/// let processTasks = items.map { item in
/// ActionTask.run { state in
/// state.processed.append(try await process(item))
/// }
/// let tasks = items.map { item in
/// ActionTask.run { state in try await process(item) }
/// }
///
/// // Explicit handling of empty case
/// guard !tasks.isEmpty else {
/// return .none // Empty is intentional
/// }
/// return .concatenate(processTasks)
///
/// return try .concatenate(tasks)
/// ```
///
/// ## Example: Propagating Error
/// ```swift
/// // Let the error propagate if empty is unexpected
/// return try .concatenate(tasks) // May throw
/// ```
///
/// - Parameter tasks: Array of tasks to concatenate
/// - Parameter tasks: Array of tasks to concatenate (must not be empty)
/// - Returns: A single task that runs all tasks sequentially
public static func concatenate(_ tasks: [ActionTask]) -> ActionTask {
/// - Throws: `FlowError.noTasksToExecute` if tasks array is empty
public static func concatenate(_ tasks: [ActionTask]) throws -> ActionTask {
guard let first = tasks.first else {
fatalError("concatenate requires at least one task")
throw FlowError.noTasksToExecute(context: "concatenate(_:)")
}
// TCA-style reduce pattern implementing Monoid
return tasks.dropFirst().reduce(first) { $0.concatenate(with: $1) }
Expand Down
51 changes: 51 additions & 0 deletions Sources/Flow/Store/FlowError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ public enum FlowError: Error {
/// - message: The error message
/// - underlying: Optional underlying error
case custom(message: String, underlying: Error? = nil)

/// No tasks provided to concatenate.
///
/// Thrown when attempting to concatenate an empty task array.
/// This typically indicates a logic error where tasks were expected but none were generated.
///
/// ## Recovery
/// If empty task lists are valid for your use case, explicitly check before concatenating:
/// ```swift
/// guard !tasks.isEmpty else {
/// return .none // Explicitly handle empty case
/// }
/// return try .concatenate(tasks)
/// ```
///
/// ## Debugging
/// Check why the task array is empty:
/// - Is the data source empty?
/// - Is there a filter that's too restrictive?
/// - Is there a mapping error?
///
/// - Parameter context: Additional context about where the error occurred
case noTasksToExecute(context: String? = nil)
}

// MARK: - LocalizedError Conformance
Expand Down Expand Up @@ -164,6 +187,18 @@ extension FlowError: LocalizedError {
return "\(message): \(underlying.localizedDescription)"
}
return message

case .noTasksToExecute(let context):
if let context = context {
return """
No tasks to execute in \(context). Empty task arrays are not allowed. \
If this is intentional, explicitly return .none instead.
"""
}
return """
No tasks to execute. Empty task arrays are not allowed. \
If empty is valid, explicitly check and return .none.
"""
}
}

Expand All @@ -187,6 +222,16 @@ extension FlowError: LocalizedError {

case .custom:
return nil

case .noTasksToExecute:
return """
Check if empty is expected:

guard !tasks.isEmpty else {
return .none // Explicit: empty is OK
}
return try .concatenate(tasks)
"""
}
}

Expand All @@ -210,6 +255,12 @@ extension FlowError: LocalizedError {

case .custom(_, let underlying):
return underlying?.localizedDescription

case .noTasksToExecute(let context):
if let context = context {
return "Empty task array in \(context)"
}
return "Empty task array provided to concatenate"
}
}
}
Expand Down
38 changes: 20 additions & 18 deletions Sources/Flow/Store/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -330,32 +330,34 @@ public final class Store<F: Feature> {
case .concatenated:
// Flatten concatenate tree for sequential iteration (O(n) → O(1) depth)
let tasks = task.flattenConcatenated()
var lastResult: F.ActionResult?

for task in tasks {
// INVARIANT: flattenConcatenated() always returns ≥1 element
//
// Proof:
// 1. concatenate([]) now throws FlowError.noTasksToExecute
// 2. .concatenated can only be constructed via concatenate()
// 3. Therefore, .concatenated always contains ≥1 task
// 4. flattenConcatenated() preserves this property
//
// If this precondition fails, there's a bug in ActionTask construction
precondition(
!tasks.isEmpty,
"Implementation error: concatenated task list is empty. " +
"This should be impossible due to concatenate() throwing on empty arrays."
)

var lastResult: F.ActionResult = try await self.executeTask(tasks[0])

for task in tasks.dropFirst() {
// Check for cancellation between sequential tasks
guard !Task.isCancelled else {
throw StoreError.cancelled
}

let result = try await self.executeTask(task)

// Update the last result
lastResult = result
lastResult = try await self.executeTask(task)
}

// Return the last result, or throw if none found (all were .just)
guard let result = lastResult else {
// All tasks were .just with Void - return Void
if F.ActionResult.self is Void.Type {
// Safe to return () as F.ActionResult when ActionResult == Void
// swiftlint:disable:next force_cast
return (() as! F.ActionResult)
}
throw StoreError.cancelled
}

return result
return lastResult
}
}
}
Loading