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 All @@ -270,6 +279,20 @@ const BaseSignInContent: FC<BaseSignInProps> = ({

const isLoading: boolean = externalIsLoading || isSubmitting;

/**
* Component type for which forms validation will be applicable
*/
const acceptableType = [
'TEXT_INPUT',
'PASSWORD_INPUT',
'EMAIL_INPUT',
'PHONE_INPUT',
'OTP_INPUT',
'SELECT',
'DATE_INPUT',
'CUSTOM',
];

/**
* Handle error responses and extract meaningful error messages
* Uses the transformer's extractErrorMessage function for consistency
Expand Down Expand Up @@ -300,15 +323,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({

const processComponents = (comps: EmbeddedFlowComponent[]): any => {
comps.forEach((component: any) => {
if (
component.type === 'TEXT_INPUT' ||
component.type === 'PASSWORD_INPUT' ||
component.type === 'EMAIL_INPUT' ||
component.type === 'PHONE_INPUT' ||
component.type === 'OTP_INPUT' ||
component.type === 'SELECT' ||
component.type === 'DATE_INPUT'
) {
if (acceptableType.includes(component.type)) {
const identifier: string = component.ref;
const ruleValidator = buildValidatorFromRules(component.validation);
fields.push({
Expand Down Expand Up @@ -360,6 +375,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
fields: formFields,
initialValues: {},
requiredMessage: t('validations.required.field.error'),
revalidateOnChangeAfterBlur,
validateOnBlur: true,
validateOnChange: false,
});
Expand Down
47 changes: 34 additions & 13 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 @@ -435,19 +444,25 @@ const SignIn: FC<SignInProps> = ({
};

/**
* Handle terminal flow responses (Error and Complete) shared by initializeFlow and handleSubmit.
* Throws on an Error status so the caller's catch block can propagate it to BaseSignIn.
* Returns true when a Complete status was handled (caller should return), false otherwise.
* Handle ERROR and COMPLETE responses. Returns true if fully handled.
* ERROR + executionId: recoverable — session preserved for retry.
* ERROR + no executionId: terminal — clear state and surface the error.
*/
const handleTerminalResponse = async (response: EmbeddedSignInFlowResponse): Promise<boolean> => {
// Handle Error flow status - flow has failed and is invalidated
if (response.flowStatus === EmbeddedSignInFlowStatus.Error) {
if (response.executionId) {
// Recoverable: session still alive. Show inline error without firing onError.
setExecutionId(response.executionId);
await setChallengeToken(response.challengeToken ?? null);
setIsFlowInitialized(true);
setFlowError(new Error(extractErrorMessage(response, t)));
return true;
}
// Terminal: backend invalidated the session — clear all state.
await clearFlowState();
const err: any = new Error(extractErrorMessage(response, t));
setError(err);
setError(new Error(extractErrorMessage(response, t)));
cleanupFlowUrlParams();
// Throw the error so it's caught by the catch block and propagated to BaseSignIn
throw err;
return true;
}

if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) {
Expand Down Expand Up @@ -588,6 +603,11 @@ const SignIn: FC<SignInProps> = ({
// session lets the backend return COMPLETE immediately with no UI components. Without
// this the UI would fall through with no components and spin forever.
if (await handleTerminalResponse(response)) {
// Only reset the init gate for unrecoverable Error responses so the user can retry;
// Complete-without-redirect would otherwise kick off a fresh flow unintentionally.
if (response.flowStatus === EmbeddedSignInFlowStatus.Error && !response.executionId) {
initializationAttemptedRef.current = false;
}
return;
}

Expand Down Expand Up @@ -814,6 +834,11 @@ const SignIn: FC<SignInProps> = ({
return;
}

// Handle terminal flow statuses before normalization.
if (await handleTerminalResponse(response)) {
return;
}

const {
executionId: normalizedExecutionId,
components: normalizedComponents,
Expand All @@ -827,11 +852,6 @@ const SignIn: FC<SignInProps> = ({
meta,
);

// Handle terminal flow statuses (Error throws, Complete redirects and returns true).
if (await handleTerminalResponse(response)) {
return;
}

// Always update challenge token on any INCOMPLETE response — token rotates every step.
await setChallengeToken(response.challengeToken ?? null);

Expand Down Expand Up @@ -1004,6 +1024,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