diff --git a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx index 60b1ef6..872ad12 100644 --- a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx +++ b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx @@ -95,6 +95,13 @@ export interface BaseRecoveryProps { * Component-level preferences to override global i18n and theme settings. */ preferences?: Preferences; + /** + * When a field has been blurred at least once, re-run validation on every subsequent + * keystroke so a rendered error clears the moment the value becomes valid. Doesn't + * affect fields that have never been blurred — the user isn't shown errors while + * initially typing. Default `false` preserves prior behavior. + */ + revalidateOnChangeAfterBlur?: boolean; showLogo?: boolean; showSubtitle?: boolean; showTitle?: boolean; @@ -125,6 +132,7 @@ const BaseRecoveryContent: FC = ({ children, showTitle = true, showSubtitle = true, + revalidateOnChangeAfterBlur = false, }: BaseRecoveryProps): ReactElement => { const {theme, colorScheme} = useTheme(); const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); @@ -236,6 +244,7 @@ const BaseRecoveryContent: FC = ({ fields: formFields, initialValues: {}, requiredMessage: t('validations.required.field.error'), + revalidateOnChangeAfterBlur, validateOnBlur: true, validateOnChange: false, }); diff --git a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx index 9ad1639..97cc63e 100644 --- a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx @@ -226,6 +226,14 @@ export interface BaseSignInProps { */ serverFieldErrors?: FieldError[] | null; + /** + * When a field has been blurred at least once, re-run validation on every subsequent + * keystroke so a rendered error clears the moment the value becomes valid. Doesn't + * affect fields that have never been blurred — the user isn't shown errors while + * initially typing. Default `false` preserves prior behavior. + */ + revalidateOnChangeAfterBlur?: boolean; + /** * Size variant for the component. */ @@ -257,6 +265,7 @@ const BaseSignInContent: FC = ({ additionalData = {}, isTimeoutDisabled = false, serverFieldErrors = null, + revalidateOnChangeAfterBlur = false, }: BaseSignInProps): ReactElement => { const {meta, vendor} = useThunderID(); const {theme} = useTheme(); @@ -360,6 +369,7 @@ const BaseSignInContent: FC = ({ fields: formFields, initialValues: {}, requiredMessage: t('validations.required.field.error'), + revalidateOnChangeAfterBlur, validateOnBlur: true, validateOnChange: false, }); diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index 5ae2656..0733a41 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -134,6 +134,14 @@ export interface SignInProps { */ preferences?: Preferences; + /** + * When a field has been blurred at least once, re-run validation on every subsequent + * keystroke so a rendered error clears the moment the value becomes valid. Doesn't + * affect fields that have never been blurred — the user isn't shown errors while + * initially typing. Default `false` preserves prior behavior. + */ + revalidateOnChangeAfterBlur?: boolean; + /** * Size variant for the component. */ @@ -226,6 +234,7 @@ const SignIn: FC = ({ onError, variant, children, + revalidateOnChangeAfterBlur, }: SignInProps): ReactElement => { const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta, getStorageManager, scopes, vendor} = useThunderID(); @@ -1004,6 +1013,7 @@ const SignIn: FC = ({ size={size} variant={variant} preferences={preferences} + revalidateOnChangeAfterBlur={revalidateOnChangeAfterBlur} serverFieldErrors={serverFieldErrors} /> ); diff --git a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx index 0a8a6d5..1e66b1a 100644 --- a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx @@ -228,6 +228,14 @@ export interface BaseSignUpProps { */ preferences?: Preferences; + /** + * When a field has been blurred at least once, re-run validation on every subsequent + * keystroke so a rendered error clears the moment the value becomes valid. Doesn't + * affect fields that have never been blurred — the user isn't shown errors while + * initially typing. Default `false` preserves prior behavior. + */ + revalidateOnChangeAfterBlur?: boolean; + /** * Whether to redirect after sign-up. */ @@ -282,6 +290,7 @@ const BaseSignUpContent: FC = ({ children, showTitle = true, showSubtitle = true, + revalidateOnChangeAfterBlur = false, }: BaseSignUpProps): ReactElement => { const {theme, colorScheme} = useTheme(); const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); @@ -471,6 +480,7 @@ const BaseSignUpContent: FC = ({ fields: formFields, initialValues: {}, requiredMessage: t('validations.required.field.error'), + revalidateOnChangeAfterBlur, validateOnBlur: true, validateOnChange: false, }); diff --git a/packages/react/src/hooks/useForm.ts b/packages/react/src/hooks/useForm.ts index 1b667fd..29e7846 100644 --- a/packages/react/src/hooks/useForm.ts +++ b/packages/react/src/hooks/useForm.ts @@ -52,12 +52,24 @@ export interface UseFormConfig> { * Custom required field validation message */ requiredMessage?: string; + /** + * When a field has been touched (blurred at least once), re-run validation on every + * subsequent change so a rendered error clears the moment the value becomes valid. + * + * Independent of `validateOnChange`: this only affects fields that have already been + * blurred, so users don't see errors while initially typing. Default `false` to + * preserve prior behavior; enable per-form for a "correct-as-you-type" UX. + */ + revalidateOnChangeAfterBlur?: boolean; /** * Whether to validate on blur (default: true) */ validateOnBlur?: boolean; /** - * Whether to validate on change (default: false) + * Whether to validate on change (default: false). When true, every keystroke + * triggers validation — including the first, which surfaces errors before the user + * has had a chance to finish typing. Prefer `revalidateOnChangeAfterBlur` for the + * common "clear the error as the user corrects" UX. */ validateOnChange?: boolean; /** @@ -66,6 +78,26 @@ export interface UseFormConfig> { validator?: (values: T) => Record; } +/** + * Pure per-field validator. Kept outside `useForm` so `setValue` can validate against + * the value it just wrote — inline validation via the closure-based `validateField` + * inside the hook would read stale `values` (state is committed asynchronously). + */ +const computeFieldError = ( + value: string, + fieldConfig: FormField | undefined, + requiredMessage: string, +): string | null => { + if (fieldConfig?.required && (!value || value.trim() === '')) { + return requiredMessage; + } + if (fieldConfig?.validator) { + const fieldError: string | null = fieldConfig.validator(value); + if (fieldError) return fieldError; + } + return null; +}; + /** * Return type for the useForm hook */ @@ -202,6 +234,7 @@ export const useForm = >(config: UseFormConfig< validator, validateOnChange = false, validateOnBlur = true, + revalidateOnChangeAfterBlur = false, requiredMessage = 'This field is required', } = config; @@ -217,24 +250,13 @@ export const useForm = >(config: UseFormConfig< [fields], ); - // Validate a single field + // Validate a single field against currently-committed state. Used by the blur path + // (where `values` is already up-to-date) and by `validateForm`. const validateField: (name: keyof T) => string | null = useCallback( (name: keyof T): string | null => { const value: string = values[name] || ''; const fieldConfig: FormField | undefined = getFieldConfig(name); - - // Check required validation - if (fieldConfig?.required && (!value || value.trim() === '')) { - return requiredMessage; - } - - // Run custom field validator - if (fieldConfig?.validator) { - const fieldError: string | null = fieldConfig.validator(value); - if (fieldError) return fieldError; - } - - return null; + return computeFieldError(value, fieldConfig, requiredMessage); }, [values, getFieldConfig, requiredMessage], ); @@ -270,7 +292,19 @@ export const useForm = >(config: UseFormConfig< // Check if form is currently valid const isValid: boolean = Object.keys(errors).length === 0; - // Set a single field value + // Set a single field value. + // + // Validation policy: + // - `validateOnChange: true` → validate on every keystroke. + // - `revalidateOnChangeAfterBlur: true` → validate only if the field has + // already been touched (blurred once), + // so errors clear as the user corrects + // without appearing while first typing. + // - both false → no on-change validation. + // + // Validation runs against the NEXT value (the string being written) rather than + // going through `validateField` which reads the stale closed-over `values`. This + // prevents the one-keystroke-lag bug where the error clears one character late. const setValue: (name: keyof T, value: string) => void = useCallback( (name: keyof T, value: string): void => { setFormValues((prev: T) => ({ @@ -278,21 +312,23 @@ export const useForm = >(config: UseFormConfig< [name]: value, })); - // Validate on change if enabled - if (validateOnChange) { - const error: string | null = validateField(name); - setFormErrors((prev: Record) => { - const newErrors: Record = {...prev}; - if (error) { - newErrors[name] = error; - } else { - delete newErrors[name]; - } - return newErrors; - }); + const shouldValidate: boolean = validateOnChange || (revalidateOnChangeAfterBlur && touched[name] === true); + if (!shouldValidate) { + return; } + + const error: string | null = computeFieldError(value, getFieldConfig(name), requiredMessage); + setFormErrors((prev: Record) => { + const newErrors: Record = {...prev}; + if (error) { + newErrors[name] = error; + } else { + delete newErrors[name]; + } + return newErrors; + }); }, - [validateField, validateOnChange], + [validateOnChange, revalidateOnChangeAfterBlur, touched, getFieldConfig, requiredMessage], ); // Set multiple field values