diff --git a/Sources/Flow/Flow.docc/CoreElements.md b/Sources/Flow/Flow.docc/CoreElements.md index 4066076..3e571e2 100644 --- a/Sources/Flow/Flow.docc/CoreElements.md +++ b/Sources/Flow/Flow.docc/CoreElements.md @@ -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( @@ -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 diff --git a/Sources/Flow/Store/ActionTask.swift b/Sources/Flow/Store/ActionTask.swift index e23bded..801f80f 100644 --- a/Sources/Flow/Store/ActionTask.swift +++ b/Sources/Flow/Store/ActionTask.swift @@ -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) } diff --git a/Sources/Flow/Store/FlowError.swift b/Sources/Flow/Store/FlowError.swift index 901006d..ff1d833 100644 --- a/Sources/Flow/Store/FlowError.swift +++ b/Sources/Flow/Store/FlowError.swift @@ -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 @@ -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. + """ } } @@ -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) + """ } } @@ -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" } } } diff --git a/Sources/Flow/Store/Store.swift b/Sources/Flow/Store/Store.swift index 31bdc62..0ae0f29 100644 --- a/Sources/Flow/Store/Store.swift +++ b/Sources/Flow/Store/Store.swift @@ -330,32 +330,34 @@ public final class Store { 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 } } }