Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,6 +132,7 @@ const BaseRecoveryContent: FC<BaseRecoveryProps> = ({
children,
showTitle = true,
showSubtitle = true,
revalidateOnChangeAfterBlur = false,
}: BaseRecoveryProps): ReactElement => {
const {theme, colorScheme} = useTheme();
const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext);
Expand Down Expand Up @@ -236,6 +244,7 @@ const BaseRecoveryContent: FC<BaseRecoveryProps> = ({
fields: formFields,
initialValues: {},
requiredMessage: t('validations.required.field.error'),
revalidateOnChangeAfterBlur,
validateOnBlur: true,
validateOnChange: false,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -257,6 +265,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
additionalData = {},
isTimeoutDisabled = false,
serverFieldErrors = null,
revalidateOnChangeAfterBlur = false,
}: BaseSignInProps): ReactElement => {
const {meta, vendor} = useThunderID();
const {theme} = useTheme();
Expand Down Expand Up @@ -360,6 +369,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
fields: formFields,
initialValues: {},
requiredMessage: t('validations.required.field.error'),
revalidateOnChangeAfterBlur,
validateOnBlur: true,
validateOnChange: false,
});
Expand Down
10 changes: 10 additions & 0 deletions packages/react/src/components/presentation/auth/SignIn/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -226,6 +234,7 @@ const SignIn: FC<SignInProps> = ({
onError,
variant,
children,
revalidateOnChangeAfterBlur,
}: SignInProps): ReactElement => {
const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta, getStorageManager, scopes, vendor} =
useThunderID();
Expand Down Expand Up @@ -1004,6 +1013,7 @@ const SignIn: FC<SignInProps> = ({
size={size}
variant={variant}
preferences={preferences}
revalidateOnChangeAfterBlur={revalidateOnChangeAfterBlur}
serverFieldErrors={serverFieldErrors}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -282,6 +290,7 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
children,
showTitle = true,
showSubtitle = true,
revalidateOnChangeAfterBlur = false,
}: BaseSignUpProps): ReactElement => {
const {theme, colorScheme} = useTheme();
const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext);
Expand Down Expand Up @@ -471,6 +480,7 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
fields: formFields,
initialValues: {},
requiredMessage: t('validations.required.field.error'),
revalidateOnChangeAfterBlur,
validateOnBlur: true,
validateOnChange: false,
});
Expand Down
94 changes: 65 additions & 29 deletions packages/react/src/hooks/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,24 @@ export interface UseFormConfig<T extends Record<string, string>> {
* 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;
/**
Expand All @@ -66,6 +78,26 @@ export interface UseFormConfig<T extends Record<string, string>> {
validator?: (values: T) => Record<string, string>;
}

/**
* 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
*/
Expand Down Expand Up @@ -202,6 +234,7 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
validator,
validateOnChange = false,
validateOnBlur = true,
revalidateOnChangeAfterBlur = false,
requiredMessage = 'This field is required',
} = config;

Expand All @@ -217,24 +250,13 @@ export const useForm = <T extends Record<string, string>>(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],
);
Expand Down Expand Up @@ -270,29 +292,43 @@ export const useForm = <T extends Record<string, string>>(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) => ({
...prev,
[name]: value,
}));

// Validate on change if enabled
if (validateOnChange) {
const error: string | null = validateField(name);
setFormErrors((prev: Record<keyof T, string>) => {
const newErrors: Record<keyof T, string> = {...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<keyof T, string>) => {
const newErrors: Record<keyof T, string> = {...prev};
if (error) {
newErrors[name] = error;
} else {
delete newErrors[name];
}
return newErrors;
});
},
[validateField, validateOnChange],
[validateOnChange, revalidateOnChangeAfterBlur, touched, getFieldConfig, requiredMessage],
);

// Set multiple field values
Expand Down
Loading