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.

Add validation trigger policies

Woodrow Melling (May 22, 2026, 11:54 AM -0600) 7a85603c ab0c8bc7

+240 -67
+181 -65
Sources/ComposableForms/Form.swift
··· 4 4 public typealias ValidationErrors = [AnyHashable: [ErrorMessage]] 5 5 public typealias FieldValidationStates = [AnyHashable: FieldValidationState] 6 6 7 + public struct ValidationPolicy: Sendable, Hashable { 8 + public init(triggers: ValidationTriggers) { 9 + self.triggers = triggers 10 + } 11 + 12 + public static let all = Self(triggers: .all) 13 + public static let defaultAsync = Self(triggers: .userActions) 14 + public static let defaultSync = Self(triggers: .all) 15 + public static let fieldSubmitted = Self(triggers: .fieldSubmitted) 16 + public static let focusLoss = Self(triggers: .focusLoss) 17 + public static let formSubmitted = Self(triggers: .formSubmitted) 18 + public static let observedChange = Self(triggers: .observedChange) 19 + public static let userActions = Self(triggers: .userActions) 20 + 21 + public var triggers: ValidationTriggers 22 + 23 + public func runs(on trigger: ValidationTrigger) -> Bool { 24 + self.triggers.contains(trigger) 25 + } 26 + } 27 + 28 + public enum ValidationTrigger: Sendable, Hashable { 29 + case fieldSubmitted 30 + case focusLoss 31 + case formSubmitted 32 + case observedChange 33 + } 34 + 35 + public struct ValidationTriggers: Sendable, OptionSet, Hashable { 36 + public init(rawValue: Int) { 37 + self.rawValue = rawValue 38 + } 39 + 40 + public static let fieldSubmitted = Self(rawValue: 1 << 0) 41 + public static let focusLoss = Self(rawValue: 1 << 1) 42 + public static let formSubmitted = Self(rawValue: 1 << 2) 43 + public static let observedChange = Self(rawValue: 1 << 3) 44 + 45 + public static let all: Self = [ 46 + .fieldSubmitted, 47 + .focusLoss, 48 + .formSubmitted, 49 + .observedChange, 50 + ] 51 + public static let userActions: Self = [ 52 + .fieldSubmitted, 53 + .focusLoss, 54 + .formSubmitted, 55 + ] 56 + 57 + public let rawValue: Int 58 + 59 + public func contains(_ trigger: ValidationTrigger) -> Bool { 60 + switch trigger { 61 + case .fieldSubmitted: 62 + self.contains(ValidationTriggers.fieldSubmitted) 63 + case .focusLoss: 64 + self.contains(ValidationTriggers.focusLoss) 65 + case .formSubmitted: 66 + self.contains(ValidationTriggers.formSubmitted) 67 + case .observedChange: 68 + self.contains(ValidationTriggers.observedChange) 69 + } 70 + } 71 + } 72 + 7 73 public struct ScopedFormFieldID: Hashable { 8 74 public init(scope: AnyHashable, field: AnyHashable) { 9 75 self.field = field ··· 92 158 case sync((Value) throws -> ValidationResult) 93 159 } 94 160 95 - public init(_ validate: @escaping (Value) -> ValidationResult) { 161 + public init( 162 + policy: ValidationPolicy? = nil, 163 + _ validate: @escaping (Value) -> ValidationResult 164 + ) { 165 + self.policy = policy ?? .defaultSync 96 166 self.run = .sync(validate) 97 167 } 98 168 99 - public init(_ validate: @escaping (Value) throws -> ValidationResult) { 169 + public init( 170 + policy: ValidationPolicy? = nil, 171 + _ validate: @escaping (Value) throws -> ValidationResult 172 + ) { 173 + self.policy = policy ?? .defaultSync 100 174 self.run = .sync(validate) 101 175 } 102 176 103 177 public init( 178 + policy: ValidationPolicy? = nil, 104 179 _ validate: nonisolated(nonsending) @escaping (Value) async throws -> ValidationResult 105 180 ) { 181 + self.policy = policy ?? .defaultAsync 106 182 self.run = .async(validate) 107 183 } 108 184 109 185 public init( 110 186 _ validate: @escaping (Value) -> Bool, 111 - error: ErrorMessage 187 + error: ErrorMessage, 188 + policy: ValidationPolicy? = nil 112 189 ) { 190 + self.policy = policy ?? .defaultSync 113 191 self.run = .sync { value in 114 192 validate(value) ? .valid : .invalid(error) 115 193 } ··· 117 195 118 196 public init( 119 197 _ validate: @escaping (Value) throws -> Bool, 120 - error: ErrorMessage 198 + error: ErrorMessage, 199 + policy: ValidationPolicy? = nil 121 200 ) { 201 + self.policy = policy ?? .defaultSync 122 202 self.run = .sync { value in 123 203 try validate(value) ? .valid : .invalid(error) 124 204 } ··· 127 207 public init( 128 208 _ validate: @escaping (Value) -> Bool, 129 209 error message: LocalizedStringResource, 130 - subtitle: LocalizedStringResource? = nil 210 + subtitle: LocalizedStringResource? = nil, 211 + policy: ValidationPolicy? = nil 131 212 ) { 132 213 self.init( 133 214 validate, 134 - error: ErrorMessage(message: message, subtitle: subtitle) 215 + error: ErrorMessage(message: message, subtitle: subtitle), 216 + policy: policy 135 217 ) 136 218 } 137 219 138 220 public init( 139 221 _ validate: @escaping (Value) throws -> Bool, 140 222 error message: LocalizedStringResource, 141 - subtitle: LocalizedStringResource? = nil 223 + subtitle: LocalizedStringResource? = nil, 224 + policy: ValidationPolicy? = nil 142 225 ) { 143 226 self.init( 144 227 validate, 145 - error: ErrorMessage(message: message, subtitle: subtitle) 228 + error: ErrorMessage(message: message, subtitle: subtitle), 229 + policy: policy 146 230 ) 147 231 } 148 232 149 233 public init( 150 234 error: ErrorMessage, 235 + policy: ValidationPolicy? = nil, 151 236 _ validate: @escaping (Value) -> Bool 152 237 ) { 153 - self.init(validate, error: error) 238 + self.init(validate, error: error, policy: policy) 154 239 } 155 240 156 241 public init( 157 242 error: ErrorMessage, 243 + policy: ValidationPolicy? = nil, 158 244 _ validate: @escaping (Value) throws -> Bool 159 245 ) { 160 - self.init(validate, error: error) 246 + self.init(validate, error: error, policy: policy) 161 247 } 162 248 163 249 public init( 164 250 error message: LocalizedStringResource, 165 251 subtitle: LocalizedStringResource? = nil, 252 + policy: ValidationPolicy? = nil, 166 253 _ validate: @escaping (Value) -> Bool 167 254 ) { 168 - self.init(validate, error: message, subtitle: subtitle) 255 + self.init(validate, error: message, subtitle: subtitle, policy: policy) 169 256 } 170 257 171 258 public init( 172 259 error message: LocalizedStringResource, 173 260 subtitle: LocalizedStringResource? = nil, 261 + policy: ValidationPolicy? = nil, 174 262 _ validate: @escaping (Value) throws -> Bool 175 263 ) { 176 - self.init(validate, error: message, subtitle: subtitle) 264 + self.init(validate, error: message, subtitle: subtitle, policy: policy) 177 265 } 178 266 267 + public var policy: ValidationPolicy 179 268 public var run: Run 180 269 } 181 270 ··· 214 303 public struct FormFieldDefinition<Value> { 215 304 fileprivate init( 216 305 id: AnyHashable, 217 - hasAsyncValidation: Bool, 306 + hasAsyncValidation: @escaping (ValidationTrigger) -> Bool, 307 + hasSkippedValidation: @escaping (ValidationTrigger) -> Bool, 218 308 observedValue: @escaping (Value) -> AnyEquatable, 219 - validateAsync: nonisolated(nonsending) @escaping (Value) async -> [ErrorMessage], 220 - validateSync: @escaping (Value) -> [ErrorMessage] 309 + validateAsync: nonisolated(nonsending) @escaping (Value, ValidationTrigger) async -> [ErrorMessage], 310 + validateSync: @escaping (Value, ValidationTrigger) -> ValidationEvaluation 221 311 ) { 222 312 self.hasAsyncValidation = hasAsyncValidation 313 + self.hasSkippedValidation = hasSkippedValidation 223 314 self.id = id 224 315 self.observedValue = observedValue 225 316 self.validateAsync = validateAsync ··· 244 335 @ValidationBuilder<FieldValue> validations: () -> [Validation<FieldValue>] = { [] } 245 336 ) { 246 337 let validations = validations() 247 - self.hasAsyncValidation = validations.hasAsyncValidation() 338 + self.hasAsyncValidation = { trigger in 339 + validations.hasAsyncValidation(on: trigger) 340 + } 341 + self.hasSkippedValidation = { trigger in 342 + validations.hasSkippedValidation(on: trigger) 343 + } 248 344 self.id = id 249 345 self.observedValue = { value in 250 346 AnyEquatable(value[keyPath: keyPath]) 251 347 } 252 - self.validateAsync = { value in 253 - await validations.validateAsync(value[keyPath: keyPath]) 348 + self.validateAsync = { value, trigger in 349 + await validations.validateAsync(value[keyPath: keyPath], on: trigger) 254 350 } 255 - self.validateSync = { value in 256 - validations.validateSync(value[keyPath: keyPath]) 351 + self.validateSync = { value, trigger in 352 + validations.validateSync(value[keyPath: keyPath], on: trigger) 257 353 } 258 354 } 259 355 260 - fileprivate var hasAsyncValidation: Bool 356 + fileprivate var hasAsyncValidation: (ValidationTrigger) -> Bool 357 + fileprivate var hasSkippedValidation: (ValidationTrigger) -> Bool 261 358 public var id: AnyHashable 262 359 fileprivate var observedValue: (Value) -> AnyEquatable 263 - fileprivate var validateAsync: nonisolated(nonsending) (Value) async -> [ErrorMessage] 264 - fileprivate var validateSync: (Value) -> [ErrorMessage] 360 + fileprivate var validateAsync: nonisolated(nonsending) (Value, ValidationTrigger) async -> [ErrorMessage] 361 + fileprivate var validateSync: (Value, ValidationTrigger) -> ValidationEvaluation 265 362 } 266 363 267 364 @Feature ··· 315 412 ) 316 413 ), 317 414 hasAsyncValidation: field.hasAsyncValidation, 415 + hasSkippedValidation: field.hasSkippedValidation, 318 416 observedValue: { value in 319 417 field.observedValue(value[keyPath: keyPath]) 320 418 }, 321 - validateAsync: { value in 322 - await field.validateAsync(value[keyPath: keyPath]) 419 + validateAsync: { value, trigger in 420 + await field.validateAsync(value[keyPath: keyPath], trigger) 323 421 }, 324 - validateSync: { value in 325 - field.validateSync(value[keyPath: keyPath]) 422 + validateSync: { value, trigger in 423 + field.validateSync(value[keyPath: keyPath], trigger) 326 424 } 327 425 ) 328 426 } ··· 376 474 public var body: some Feature { 377 475 let runAsyncValidation: (PendingAsyncValidation<Value>) -> Void = { validation in 378 476 store.addTask { 379 - let errors = await validation.validateAsync(validation.value) 477 + let errors = await validation.validateAsync(validation.value, validation.trigger) 380 478 try store.modify { state in 381 479 guard validation.observedValue(state.value) == validation.validatedValue else { 382 480 return 383 481 } 384 - state.validation[validation.field] = FieldValidationState(errors: errors) 482 + if errors.isEmpty, !validation.marksValid { 483 + state.validation[validation.field] = nil 484 + } else { 485 + state.validation[validation.field] = FieldValidationState(errors: errors) 486 + } 385 487 } 386 488 } 387 489 } ··· 393 495 self.validate( 394 496 field, 395 497 state: &state, 396 - trigger: .userAction, 498 + trigger: .fieldSubmitted, 397 499 runAsyncValidation: runAsyncValidation 398 500 ) 399 501 } ··· 404 506 self.validate( 405 507 field.id, 406 508 state: &state, 407 - trigger: .userAction, 509 + trigger: .formSubmitted, 408 510 runAsyncValidation: runAsyncValidation 409 511 ) 410 512 } ··· 417 519 self.validate( 418 520 oldField, 419 521 state: &state, 420 - trigger: .userAction, 522 + trigger: .focusLoss, 421 523 runAsyncValidation: runAsyncValidation 422 524 ) 423 525 } ··· 479 581 guard let definition = self.fields.first(where: { $0.id == field }) else { 480 582 return 481 583 } 482 - let errors = definition.validateSync(state.value) 483 - guard errors.isEmpty else { 484 - state.validation[field] = .invalid(errors) 584 + let syncResult = definition.validateSync(state.value, trigger) 585 + guard syncResult.errors.isEmpty else { 586 + state.validation[field] = .invalid(syncResult.errors) 485 587 return 486 588 } 487 - guard definition.hasAsyncValidation else { 488 - state.validation[field] = .valid 489 - return 490 - } 491 - guard trigger.runsAsyncValidation else { 492 - state.validation[field] = nil 589 + guard definition.hasAsyncValidation(trigger) else { 590 + if syncResult.didRun, !definition.hasSkippedValidation(trigger) { 591 + state.validation[field] = .valid 592 + } else { 593 + state.validation[field] = nil 594 + } 493 595 return 494 596 } 495 597 let value = state.value ··· 497 599 runAsyncValidation( 498 600 PendingAsyncValidation( 499 601 field: field, 602 + marksValid: !definition.hasSkippedValidation(trigger), 500 603 observedValue: definition.observedValue, 501 604 validatedValue: definition.observedValue(value), 502 605 validateAsync: definition.validateAsync, 606 + trigger: trigger, 503 607 value: value 504 608 ) 505 609 ) ··· 508 612 509 613 private struct PendingAsyncValidation<Value> { 510 614 var field: AnyHashable 615 + var marksValid: Bool 511 616 var observedValue: (Value) -> AnyEquatable 512 617 var validatedValue: AnyEquatable 513 - var validateAsync: nonisolated(nonsending) (Value) async -> [ErrorMessage] 618 + var validateAsync: nonisolated(nonsending) (Value, ValidationTrigger) async -> [ErrorMessage] 619 + var trigger: ValidationTrigger 514 620 var value: Value 515 621 } 516 622 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 - } 528 - } 529 - } 530 - 531 623 extension Form.State { 532 624 fileprivate func hasErrors(for field: AnyHashable) -> Bool { 533 625 !(self.validation[field]?.errors ?? []).isEmpty ··· 561 653 var values: [AnyHashable: AnyEquatable] 562 654 } 563 655 656 + private struct ValidationEvaluation { 657 + var didRun = false 658 + var errors: [ErrorMessage] = [] 659 + } 660 + 564 661 private extension Array { 565 - func hasAsyncValidation<Value>() -> Bool where Element == Validation<Value> { 662 + func hasAsyncValidation<Value>(on trigger: ValidationTrigger) -> Bool 663 + where Element == Validation<Value> { 566 664 self.contains { validation in 567 665 guard case .async = validation.run else { 568 666 return false 569 667 } 570 - return true 668 + return validation.policy.runs(on: trigger) 571 669 } 572 670 } 573 671 574 - nonisolated(nonsending) func validateAsync<Value>(_ value: Value) async -> [ErrorMessage] 672 + func hasSkippedValidation<Value>(on trigger: ValidationTrigger) -> Bool 575 673 where Element == Validation<Value> { 674 + self.contains { validation in 675 + !validation.policy.runs(on: trigger) 676 + } 677 + } 678 + 679 + nonisolated(nonsending) func validateAsync<Value>( 680 + _ value: Value, 681 + on trigger: ValidationTrigger 682 + ) async -> [ErrorMessage] where Element == Validation<Value> { 576 683 var errors: [ErrorMessage] = [] 577 684 for validation in self { 578 685 guard case let .async(validate) = validation.run else { 686 + continue 687 + } 688 + guard validation.policy.runs(on: trigger) else { 579 689 continue 580 690 } 581 691 do { ··· 592 702 return errors 593 703 } 594 704 595 - func validateSync<Value>(_ value: Value) -> [ErrorMessage] 596 - where Element == Validation<Value> { 597 - var errors: [ErrorMessage] = [] 705 + func validateSync<Value>( 706 + _ value: Value, 707 + on trigger: ValidationTrigger 708 + ) -> ValidationEvaluation where Element == Validation<Value> { 709 + var result = ValidationEvaluation() 598 710 for validation in self { 599 711 guard case let .sync(validate) = validation.run else { 600 712 continue 601 713 } 714 + guard validation.policy.runs(on: trigger) else { 715 + continue 716 + } 717 + result.didRun = true 602 718 do { 603 719 switch try validate(value) { 604 720 case let .invalid(messages): 605 - errors.append(contentsOf: messages) 721 + result.errors.append(contentsOf: messages) 606 722 case .valid: 607 723 break 608 724 } 609 725 } catch { 610 - errors.append(ErrorMessage(error)) 726 + result.errors.append(ErrorMessage(error)) 611 727 } 612 728 } 613 - return errors 729 + return result 614 730 } 615 731 } 616 732
+59 -2
Tests/ComposableFormsTests/FormTests.swift
··· 329 329 330 330 #expect(await probe.callCount() == 0) 331 331 } 332 + 333 + @Test 334 + @MainActor 335 + func asyncValidationPolicyCanRunOnlyOnFormSubmit() async { 336 + let probe = AsyncValidationProbe() 337 + let store = TestStore( 338 + initialState: AsyncTestForm.State( 339 + value: AsyncTestValue(username: "blob") 340 + ) 341 + ) { 342 + AsyncTestForm(probe: probe, asyncPolicy: .formSubmitted) 343 + } 344 + 345 + store.send(.fieldSubmitted(AsyncFieldID.username)) 346 + #expect(await probe.callCount() == 0) 347 + #expect(store.state.validation[AsyncFieldID.username] == nil) 348 + 349 + let task = store.send(.submittedForm) { 350 + $0.validation[AsyncFieldID.username] = .validating() 351 + } 352 + await probe.waitForValidation() 353 + await probe.finish(.valid) 354 + await task?.value 355 + let expectation = store.expect { 356 + $0.validation[AsyncFieldID.username] = .valid 357 + } 358 + await expectation?.value 359 + } 360 + 361 + @Test 362 + @MainActor 363 + func syncValidationPolicyCanRunOnlyOnFormSubmit() { 364 + let store = TestStore(initialState: SubmitOnlyTestForm.State(value: AsyncTestValue())) { 365 + SubmitOnlyTestForm() 366 + } 367 + 368 + store.send(.fieldSubmitted(AsyncFieldID.username)) 369 + #expect(store.state.validation[AsyncFieldID.username] == nil) 370 + 371 + store.send(.submittedForm) { 372 + $0.validation[AsyncFieldID.username] = .invalid(.usernameRequired) 373 + } 374 + } 332 375 } 333 376 334 377 private typealias TestForm = Form<TestValue> 335 378 private typealias NestedTestForm = Form<NestedValue> 336 379 private typealias AsyncTestForm = Form<AsyncTestValue> 380 + private typealias SubmitOnlyTestForm = Form<AsyncTestValue> 337 381 338 382 private actor AsyncValidationProbe { 339 383 private var continuations: [CheckedContinuation<ValidationResult, Never>] = [] ··· 432 476 } 433 477 434 478 private extension AsyncTestForm { 435 - init(probe: AsyncValidationProbe) { 479 + init( 480 + probe: AsyncValidationProbe, 481 + asyncPolicy: ValidationPolicy? = nil 482 + ) { 436 483 self = Form<AsyncTestValue> { 437 484 Form.Field(\.username) { 438 485 Validation(error: .usernameRequired) { !$0.isEmpty } 439 - Validation { value async -> ValidationResult in 486 + Validation(policy: asyncPolicy) { value async -> ValidationResult in 440 487 await probe.validate(value) 441 488 } 489 + } 490 + } 491 + } 492 + } 493 + 494 + private extension SubmitOnlyTestForm { 495 + init() { 496 + self = Form<AsyncTestValue> { 497 + Form.Field(\.username) { 498 + Validation(error: .usernameRequired, policy: .formSubmitted) { !$0.isEmpty } 442 499 } 443 500 } 444 501 }