A library for helping build high quality forms in SwiftUI and TCA
0

Configure Feed

Select the types of activity you want to include in your feed.

Run async form validations

Woodrow Melling (May 22, 2026, 10:40 AM -0600) ab0c8bc7 6e1b9b1f

+305 -26
+160 -26
Sources/ComposableForms/Form.swift
··· 61 61 public let subtitle: LocalizedStringResource? 62 62 } 63 63 64 - public enum ValidationResult: Equatable { 64 + public enum ValidationResult: Sendable, Equatable { 65 65 case invalid([ErrorMessage]) 66 66 case valid 67 67 ··· 85 85 } 86 86 87 87 public struct Validation<Value> { 88 + public typealias AsyncRun = nonisolated(nonsending) (Value) async throws -> ValidationResult 89 + 88 90 public enum Run { 89 - case async((Value) async throws -> ValidationResult) 91 + case async(AsyncRun) 90 92 case sync((Value) throws -> ValidationResult) 91 93 } 92 94 ··· 98 100 self.run = .sync(validate) 99 101 } 100 102 101 - public init(_ validate: @escaping (Value) async throws -> ValidationResult) { 103 + public init( 104 + _ validate: nonisolated(nonsending) @escaping (Value) async throws -> ValidationResult 105 + ) { 102 106 self.run = .async(validate) 103 107 } 104 108 ··· 210 214 public struct FormFieldDefinition<Value> { 211 215 fileprivate init( 212 216 id: AnyHashable, 217 + hasAsyncValidation: Bool, 213 218 observedValue: @escaping (Value) -> AnyEquatable, 214 - validate: @escaping (Value) -> [ErrorMessage] 219 + validateAsync: nonisolated(nonsending) @escaping (Value) async -> [ErrorMessage], 220 + validateSync: @escaping (Value) -> [ErrorMessage] 215 221 ) { 222 + self.hasAsyncValidation = hasAsyncValidation 216 223 self.id = id 217 224 self.observedValue = observedValue 218 - self.validate = validate 225 + self.validateAsync = validateAsync 226 + self.validateSync = validateSync 219 227 } 220 228 221 229 public init<FieldValue: Equatable>( ··· 236 244 @ValidationBuilder<FieldValue> validations: () -> [Validation<FieldValue>] = { [] } 237 245 ) { 238 246 let validations = validations() 247 + self.hasAsyncValidation = validations.hasAsyncValidation() 239 248 self.id = id 240 249 self.observedValue = { value in 241 250 AnyEquatable(value[keyPath: keyPath]) 242 251 } 243 - self.validate = { value in 244 - validations.validate(value[keyPath: keyPath]) 252 + self.validateAsync = { value in 253 + await validations.validateAsync(value[keyPath: keyPath]) 254 + } 255 + self.validateSync = { value in 256 + validations.validateSync(value[keyPath: keyPath]) 245 257 } 246 258 } 247 259 260 + fileprivate var hasAsyncValidation: Bool 248 261 public var id: AnyHashable 249 262 fileprivate var observedValue: (Value) -> AnyEquatable 250 - fileprivate var validate: (Value) -> [ErrorMessage] 263 + fileprivate var validateAsync: nonisolated(nonsending) (Value) async -> [ErrorMessage] 264 + fileprivate var validateSync: (Value) -> [ErrorMessage] 251 265 } 252 266 253 267 @Feature ··· 300 314 field: field.id 301 315 ) 302 316 ), 317 + hasAsyncValidation: field.hasAsyncValidation, 303 318 observedValue: { value in 304 319 field.observedValue(value[keyPath: keyPath]) 305 320 }, 306 - validate: { value in 307 - field.validate(value[keyPath: keyPath]) 321 + validateAsync: { value in 322 + await field.validateAsync(value[keyPath: keyPath]) 323 + }, 324 + validateSync: { value in 325 + field.validateSync(value[keyPath: keyPath]) 308 326 } 309 327 ) 310 328 } ··· 356 374 private let fields: [FormFieldDefinition<Value>] 357 375 358 376 public var body: some Feature { 377 + let runAsyncValidation: (PendingAsyncValidation<Value>) -> Void = { validation in 378 + store.addTask { 379 + let errors = await validation.validateAsync(validation.value) 380 + try store.modify { state in 381 + guard validation.observedValue(state.value) == validation.validatedValue else { 382 + return 383 + } 384 + state.validation[validation.field] = FieldValidationState(errors: errors) 385 + } 386 + } 387 + } 388 + 359 389 Update { state, action in 360 390 switch action { 361 391 case let .fieldSubmitted(field): 362 392 if state.focusedField != field { 363 - self.validate(field, state: &state) 393 + self.validate( 394 + field, 395 + state: &state, 396 + trigger: .userAction, 397 + runAsyncValidation: runAsyncValidation 398 + ) 364 399 } 365 400 state.focusedField = self.nextField(after: field) 366 401 367 402 case .submittedForm: 368 403 for field in self.fields { 369 - self.validate(field.id, state: &state) 404 + self.validate( 405 + field.id, 406 + state: &state, 407 + trigger: .userAction, 408 + runAsyncValidation: runAsyncValidation 409 + ) 370 410 } 371 411 } 372 412 } ··· 374 414 guard let oldField, oldField != newField else { 375 415 return 376 416 } 377 - self.validate(oldField, state: &state) 417 + self.validate( 418 + oldField, 419 + state: &state, 420 + trigger: .userAction, 421 + runAsyncValidation: runAsyncValidation 422 + ) 378 423 } 379 424 .onChange(of: self.snapshot(store.value)) { oldSnapshot, newSnapshot, state in 380 425 for field in self.changedFields(from: oldSnapshot, to: newSnapshot) { ··· 382 427 continue 383 428 } 384 429 if state.hasErrors(for: field) { 385 - self.validate(field, state: &state) 430 + self.validate( 431 + field, 432 + state: &state, 433 + trigger: .observedChange, 434 + runAsyncValidation: runAsyncValidation 435 + ) 386 436 } else { 387 437 state.validation[field] = nil 388 438 } ··· 420 470 ) 421 471 } 422 472 423 - private func validate(_ field: AnyHashable, state: inout State) { 473 + private func validate( 474 + _ field: AnyHashable, 475 + state: inout State, 476 + trigger: ValidationTrigger, 477 + runAsyncValidation: (PendingAsyncValidation<Value>) -> Void 478 + ) { 424 479 guard let definition = self.fields.first(where: { $0.id == field }) else { 425 480 return 426 481 } 427 - let errors = definition.validate(state.value) 428 - state.validation[field] = FieldValidationState(errors: errors) 482 + let errors = definition.validateSync(state.value) 483 + guard errors.isEmpty else { 484 + state.validation[field] = .invalid(errors) 485 + return 486 + } 487 + guard definition.hasAsyncValidation else { 488 + state.validation[field] = .valid 489 + return 490 + } 491 + guard trigger.runsAsyncValidation else { 492 + state.validation[field] = nil 493 + return 494 + } 495 + let value = state.value 496 + state.validation[field] = .validating() 497 + runAsyncValidation( 498 + PendingAsyncValidation( 499 + field: field, 500 + observedValue: definition.observedValue, 501 + validatedValue: definition.observedValue(value), 502 + validateAsync: definition.validateAsync, 503 + value: value 504 + ) 505 + ) 506 + } 507 + } 508 + 509 + private struct PendingAsyncValidation<Value> { 510 + var field: AnyHashable 511 + var observedValue: (Value) -> AnyEquatable 512 + var validatedValue: AnyEquatable 513 + var validateAsync: nonisolated(nonsending) (Value) async -> [ErrorMessage] 514 + var value: Value 515 + } 516 + 517 + private enum ValidationTrigger { 518 + case observedChange 519 + case userAction 520 + 521 + var runsAsyncValidation: Bool { 522 + switch self { 523 + case .observedChange: 524 + false 525 + case .userAction: 526 + true 527 + } 429 528 } 430 529 } 431 530 ··· 463 562 } 464 563 465 564 private extension Array { 466 - func validate<Value>(_ value: Value) -> [ErrorMessage] where Element == Validation<Value> { 565 + func hasAsyncValidation<Value>() -> Bool where Element == Validation<Value> { 566 + self.contains { validation in 567 + guard case .async = validation.run else { 568 + return false 569 + } 570 + return true 571 + } 572 + } 573 + 574 + nonisolated(nonsending) func validateAsync<Value>(_ value: Value) async -> [ErrorMessage] 575 + where Element == Validation<Value> { 576 + var errors: [ErrorMessage] = [] 577 + for validation in self { 578 + guard case let .async(validate) = validation.run else { 579 + continue 580 + } 581 + do { 582 + switch try await validate(value) { 583 + case let .invalid(messages): 584 + errors.append(contentsOf: messages) 585 + case .valid: 586 + break 587 + } 588 + } catch { 589 + errors.append(ErrorMessage(error)) 590 + } 591 + } 592 + return errors 593 + } 594 + 595 + func validateSync<Value>(_ value: Value) -> [ErrorMessage] 596 + where Element == Validation<Value> { 467 597 var errors: [ErrorMessage] = [] 468 598 for validation in self { 469 599 guard case let .sync(validate) = validation.run else { ··· 477 607 break 478 608 } 479 609 } catch { 480 - errors.append( 481 - ErrorMessage( 482 - message: LocalizedStringResource( 483 - stringLiteral: error.localizedDescription 484 - ), 485 - originalError: error 486 - ) 487 - ) 610 + errors.append(ErrorMessage(error)) 488 611 } 489 612 } 490 613 return errors 491 614 } 492 615 } 616 + 617 + private extension ErrorMessage { 618 + init(_ error: any Error) { 619 + self.init( 620 + message: LocalizedStringResource( 621 + stringLiteral: error.localizedDescription 622 + ), 623 + originalError: error 624 + ) 625 + } 626 + }
+145
Tests/ComposableFormsTests/FormTests.swift
··· 240 240 $0.validation[NestedFieldID.profileDisplayName] = .valid 241 241 } 242 242 } 243 + 244 + @Test 245 + @MainActor 246 + func submittedFormRunsAsyncValidation() async { 247 + let probe = AsyncValidationProbe() 248 + let store = TestStore( 249 + initialState: AsyncTestForm.State( 250 + value: AsyncTestValue(username: "blob") 251 + ) 252 + ) { 253 + AsyncTestForm(probe: probe) 254 + } 255 + 256 + let task = store.send(.submittedForm) { 257 + $0.validation[AsyncFieldID.username] = .validating() 258 + } 259 + await probe.waitForValidation() 260 + await probe.finish(.invalid(.usernameUnavailable)) 261 + await task?.value 262 + let expectation = store.expect { 263 + $0.validation[AsyncFieldID.username] = .invalid(.usernameUnavailable) 264 + } 265 + await expectation?.value 266 + } 267 + 268 + @Test 269 + @MainActor 270 + func asyncValidationIgnoresStaleResults() async { 271 + let probe = AsyncValidationProbe() 272 + let store = TestStore( 273 + initialState: AsyncTestForm.State( 274 + value: AsyncTestValue(username: "blob") 275 + ) 276 + ) { 277 + AsyncTestForm(probe: probe) 278 + } 279 + 280 + let task = store.send(.submittedForm) { 281 + $0.validation[AsyncFieldID.username] = .validating() 282 + } 283 + await probe.waitForValidation() 284 + 285 + store.modify { 286 + $0.value.username = "fresh" 287 + } changes: { 288 + $0.validation.removeValue(forKey: AsyncFieldID.username) 289 + } 290 + 291 + await probe.finish(.invalid(.usernameUnavailable)) 292 + await task?.value 293 + #expect(store.state.validation[AsyncFieldID.username] == nil) 294 + } 295 + 296 + @Test 297 + @MainActor 298 + func observedValueChangeClearsAsyncErrorWithoutRunningAsyncValidation() async { 299 + let probe = AsyncValidationProbe() 300 + let store = TestStore( 301 + initialState: AsyncTestForm.State( 302 + value: AsyncTestValue(username: "blob"), 303 + validation: [AsyncFieldID.username: .invalid(.usernameUnavailable)] 304 + ) 305 + ) { 306 + AsyncTestForm(probe: probe) 307 + } 308 + 309 + store.modify { 310 + $0.value.username = "fresh" 311 + } changes: { 312 + $0.validation.removeValue(forKey: AsyncFieldID.username) 313 + } 314 + 315 + #expect(await probe.callCount() == 0) 316 + } 317 + 318 + @Test 319 + @MainActor 320 + func submittedFormDoesNotRunAsyncValidationWhenSyncValidationFails() async { 321 + let probe = AsyncValidationProbe() 322 + let store = TestStore(initialState: AsyncTestForm.State(value: AsyncTestValue())) { 323 + AsyncTestForm(probe: probe) 324 + } 325 + 326 + store.send(.submittedForm) { 327 + $0.validation[AsyncFieldID.username] = .invalid(.usernameRequired) 328 + } 329 + 330 + #expect(await probe.callCount() == 0) 331 + } 243 332 } 244 333 245 334 private typealias TestForm = Form<TestValue> 246 335 private typealias NestedTestForm = Form<NestedValue> 336 + private typealias AsyncTestForm = Form<AsyncTestValue> 337 + 338 + private actor AsyncValidationProbe { 339 + private var continuations: [CheckedContinuation<ValidationResult, Never>] = [] 340 + private var values: [String] = [] 341 + private var waiters: [CheckedContinuation<Void, Never>] = [] 342 + 343 + func callCount() -> Int { 344 + self.values.count 345 + } 346 + 347 + func finish(_ result: ValidationResult) { 348 + self.continuations.removeFirst().resume(returning: result) 349 + } 350 + 351 + func validate(_ value: String) async -> ValidationResult { 352 + self.values.append(value) 353 + return await withCheckedContinuation { continuation in 354 + self.continuations.append(continuation) 355 + self.waiters.forEach { $0.resume() } 356 + self.waiters.removeAll() 357 + } 358 + } 359 + 360 + func waitForValidation() async { 361 + guard self.continuations.isEmpty else { 362 + return 363 + } 364 + await withCheckedContinuation { continuation in 365 + self.waiters.append(continuation) 366 + } 367 + } 368 + } 247 369 248 370 private enum FieldID { 249 371 static var name: AnyHashable { AnyHashable(\TestValue.name) } 250 372 static var status: AnyHashable { AnyHashable(\TestValue.status) } 373 + } 374 + 375 + private enum AsyncFieldID { 376 + static var username: AnyHashable { AnyHashable(\AsyncTestValue.username) } 251 377 } 252 378 253 379 private enum NestedFieldID { ··· 288 414 var status = "" 289 415 } 290 416 417 + private struct AsyncTestValue: Equatable { 418 + var username = "" 419 + } 420 + 291 421 private extension TestForm { 292 422 init() { 293 423 self = Form<TestValue> { ··· 296 426 } 297 427 Form.Field(\.status) { 298 428 Validation(error: "Status is required") { !$0.isEmpty } 429 + } 430 + } 431 + } 432 + } 433 + 434 + private extension AsyncTestForm { 435 + init(probe: AsyncValidationProbe) { 436 + self = Form<AsyncTestValue> { 437 + Form.Field(\.username) { 438 + Validation(error: .usernameRequired) { !$0.isEmpty } 439 + Validation { value async -> ValidationResult in 440 + await probe.validate(value) 441 + } 299 442 } 300 443 } 301 444 } ··· 324 467 static let handleRequired = Self(message: "Handle is required") 325 468 static let nameRequired = Self(message: "Name is required") 326 469 static let statusRequired = Self(message: "Status is required") 470 + static let usernameRequired = Self(message: "Username is required") 471 + static let usernameUnavailable = Self(message: "Username is unavailable") 327 472 }