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 @@ -189,13 +189,6 @@ const BaseRecoveryContent: FC<BaseRecoveryProps> = ({
if (component.required && (!value || value.trim() === '')) {
return t('validations.required.field.error');
}
if (
(component.type === EmbeddedFlowComponentType.EmailInput || component.variant === 'EMAIL') &&
value &&
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
) {
return t('field.email.invalid');
}
// Evaluate declarative validation rules from meta.components[].validation.
if (ruleValidator && value) {
const ruleMessage = ruleValidator(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
const {meta, vendor} = useThunderID();
const {theme} = useTheme();
const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext);
const {t} = useTranslation();
const {t, currentLanguage} = useTranslation();
const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow();
const styles: any = useStyles(theme, theme.vars.colors.text.primary);

Expand Down Expand Up @@ -319,14 +319,6 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
if (component.required && (!value || value.trim() === '')) {
return t('validations.required.field.error');
}
// Add email validation if it's an email field
if (
(component.type === 'EMAIL_INPUT' || component.variant === 'EMAIL') &&
value &&
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
) {
return t('field.email.invalid');
}
// Run declarative rules from meta.components[].validation.
if (ruleValidator && value) {
const ruleMessage = ruleValidator(value);
Expand Down Expand Up @@ -378,6 +370,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
validateForm,
touchAllFields,
reset: resetForm,
revalidateTouchedFields,
} = form;

// Project server-side fieldErrors into form state. `setTouchedFields` is used instead
Expand All @@ -400,6 +393,12 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
setFormErrors(errors);
}, [serverFieldErrors, setFormErrors, setTouchedFields, clearFormErrors]);

// Re-translate displayed validation errors when the UI language changes.
// revalidateTouchedFields is stable, so this effect fires only on language change.
useEffect(() => {
revalidateTouchedFields();
}, [currentLanguage, revalidateTouchedFields]);

/**
* Handle input value changes.
* Only updates the value without marking as touched.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,14 +427,6 @@ const BaseSignUpContent: FC<BaseSignUpProps> = ({
if (component.required && (!value || value.trim() === '')) {
return t('validations.required.field.error');
}
// Add email validation if it's an email field
if (
(component.type === EmbeddedFlowComponentType.EmailInput || component.variant === 'EMAIL') &&
value &&
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
) {
return t('field.email.invalid');
}
// Evaluate declarative validation rules from meta.components[].validation.
if (ruleValidator && value) {
const ruleMessage = ruleValidator(value);
Expand Down
52 changes: 51 additions & 1 deletion packages/react/src/hooks/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {useState, useCallback, FormEvent} from 'react';
import {useState, useCallback, useRef, FormEvent} from 'react';

/**
* Generic form field configuration
Expand Down Expand Up @@ -163,6 +163,11 @@ export interface UseFormReturn<T extends Record<string, string>> {
* Validate all fields
*/
validateForm: () => ValidationResult;
/**
* Re-run validation for all touched fields that have a client-side config,
* refreshing stored error strings to the current language.
*/
revalidateTouchedFields: () => void;
/**
* Current form values
*/
Expand Down Expand Up @@ -229,6 +234,9 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
const [errors, setFormErrors] = useState<Record<keyof T, string>>({} as Record<keyof T, string>);
const [isSubmitted, setIsSubmitted] = useState(false);

// Ref to track which fields have client-side validation rules. Errors injected via
// serErrors are not added here, so revalidateTouchedFields preserves server-side errors.
const clientErrorFieldRef = useRef(new Set<keyof T>());
// Get field configuration by name
const getFieldConfig: (name: keyof T) => FormField | undefined = useCallback(
(name: keyof T): FormField | undefined => fields.find((field: FormField) => field.name === name),
Expand All @@ -246,6 +254,40 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
[values, getFieldConfig, requiredMessage],
);

// "Latest value" ref so revalidateTouchedFields can read fresh validators without
// appearing in any effect dep array (avoids exhaustive-deps violations at call sites).
const validateFieldRef = useRef(validateField);
validateFieldRef.current = validateField;

// Re-translate all, client-validated error strings on language change.
// Stable identity ([] deps): uses only refs and the stable state setter.
const revalidateTouchedFields: () => void = useCallback((): void => {
setFormErrors((prevErrors: Record<keyof T, string>) => {
if (Object.keys(prevErrors).length === 0) return prevErrors;

const newErrors: Record<keyof T, string> = {...prevErrors};
let changed = false;

(Object.keys(prevErrors) as Array<keyof T>).forEach((name: keyof T) => {
// Skip errors that were not produced by client-side validation - preserve server messages.
if (!clientErrorFieldRef.current.has(name)) return;

const freshError: string | null = validateFieldRef.current(name);
if (freshError === prevErrors[name]) return;

changed = true;
if (freshError) {
newErrors[name] = freshError;
} else {
delete newErrors[name];
clientErrorFieldRef.current.delete(name);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

return changed ? newErrors : prevErrors;
});
}, []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Validate the entire form
const validateForm: () => ValidationResult = useCallback((): ValidationResult => {
const newErrors: Record<keyof T, string> = {} as Record<keyof T, string>;
Expand Down Expand Up @@ -307,8 +349,10 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
const newErrors: Record<keyof T, string> = {...prev};
if (error) {
newErrors[name] = error;
clientErrorFieldRef.current.add(name);
} else {
delete newErrors[name];
clientErrorFieldRef.current.delete(name);
}
return newErrors;
});
Expand Down Expand Up @@ -339,8 +383,10 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
const newErrors: Record<keyof T, string> = {...prev};
if (error) {
newErrors[name] = error;
clientErrorFieldRef.current.add(name);
} else {
delete newErrors[name];
clientErrorFieldRef.current.delete(name);
}
return newErrors;
});
Expand Down Expand Up @@ -375,6 +421,7 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
// Validate all fields
const validation: ValidationResult = validateForm();
setFormErrors(validation.errors as Record<keyof T, string>);
clientErrorFieldRef.current = new Set(Object.keys(validation.errors) as Array<keyof T>);
}, [fields, validateForm]);

// Set a field error
Expand All @@ -399,6 +446,7 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
// Clear all errors
const clearErrors: () => void = useCallback((): void => {
setFormErrors({} as Record<keyof T, string>);
clientErrorFieldRef.current.clear();
}, []);

// Reset form to initial state
Expand All @@ -407,6 +455,7 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
setFormTouched({} as Record<keyof T, boolean>);
setFormErrors({} as Record<keyof T, string>);
setIsSubmitted(false);
clientErrorFieldRef.current.clear();
}, [initialValues]);

// Handle form submission
Expand Down Expand Up @@ -455,6 +504,7 @@ export const useForm = <T extends Record<string, string>>(config: UseFormConfig<
isSubmitted,
isValid,
reset,
revalidateTouchedFields,
setError,
setErrors,
setTouched,
Expand Down
Loading