From 3617dcf68e7e0bb727957dcdd47e96fdbc737cc7 Mon Sep 17 00:00:00 2001 From: Sajid Mannikeri Date: Fri, 17 Jul 2026 00:39:31 +0530 Subject: [PATCH 1/2] Add validation on change Signed-off-by: Sajid Mannikeri --- .../auth/Recovery/BaseRecovery.tsx | 9 ++ .../presentation/auth/SignIn/BaseSignIn.tsx | 10 ++ .../presentation/auth/SignIn/SignIn.tsx | 10 ++ .../presentation/auth/SignUp/BaseSignUp.tsx | 10 ++ packages/react/src/hooks/useForm.ts | 94 +++++++++++++------ 5 files changed, 104 insertions(+), 29 deletions(-) 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 From b0ce359dcd8fd7f175724143ec3e3659bd7ea6e4 Mon Sep 17 00:00:00 2001 From: Sajid Mannikeri Date: Fri, 17 Jul 2026 00:39:31 +0530 Subject: [PATCH 2/2] Add validation on change Signed-off-by: Sajid Mannikeri --- .../presentation/auth/SignIn/BaseSignIn.tsx | 56 ++++++++++--------- .../presentation/auth/SignIn/SignIn.tsx | 34 ++++++----- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx index 97cc63e..82415d4 100644 --- a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {cx} from '@emotion/css'; +import { cx } from '@emotion/css'; import { withVendorCSSClassPrefix, EmbeddedSignInFlowRequest, @@ -26,7 +26,7 @@ import { Preferences, buildValidatorFromRules, } from '@thunderid/browser'; -import {FC, useEffect, useMemo, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; +import { FC, useEffect, useMemo, useState, useCallback, useContext, ReactElement, ReactNode } from 'react'; import useStyles from './BaseSignIn.styles'; import ComponentRendererContext, { ComponentRendererMap, @@ -36,16 +36,16 @@ import useFlow from '../../../../contexts/Flow/useFlow'; import ComponentPreferencesContext from '../../../../contexts/I18n/ComponentPreferencesContext'; import useTheme from '../../../../contexts/Theme/useTheme'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; -import {FormField, useForm} from '../../../../hooks/useForm'; +import { FormField, useForm } from '../../../../hooks/useForm'; import useTranslation from '../../../../hooks/useTranslation'; import composeAffixedInputs from '../../../../utils/composeAffixedInputs'; -import {extractErrorMessage} from '../../../../utils/flowTransformer'; +import { extractErrorMessage } from '../../../../utils/flowTransformer'; import AlertPrimitive from '../../../primitives/Alert/Alert'; // eslint-disable-next-line import/no-named-as-default -import CardPrimitive, {CardProps} from '../../../primitives/Card/Card'; +import CardPrimitive, { CardProps } from '../../../primitives/Card/Card'; import Spinner from '../../../primitives/Spinner/Spinner'; import Typography from '../../../primitives/Typography/Typography'; -import {renderSignInComponents} from '../AuthOptionFactory'; +import { renderSignInComponents } from '../AuthOptionFactory'; /** * Render props for custom UI rendering @@ -94,7 +94,7 @@ export interface BaseSignInRenderProps { /** * Flow messages */ - messages: {message: string; type: string}[]; + messages: { message: string; type: string }[]; /** * Flow metadata returned by the platform (v2 only). `null` while loading or unavailable. @@ -119,7 +119,7 @@ export interface BaseSignInRenderProps { /** * Function to validate the form */ - validateForm: () => {fieldErrors: Record; isValid: boolean}; + validateForm: () => { fieldErrors: Record; isValid: boolean }; /** * Form values @@ -267,11 +267,11 @@ const BaseSignInContent: FC = ({ serverFieldErrors = null, revalidateOnChangeAfterBlur = false, }: BaseSignInProps): ReactElement => { - const {meta, vendor} = useThunderID(); - const {theme} = useTheme(); + const { meta, vendor } = useThunderID(); + const { theme } = useTheme(); const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); - const {t} = useTranslation(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); + const { t } = useTranslation(); + const { subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages } = useFlow(); const styles: any = useStyles(theme, theme.vars.colors.text.primary); const [isSubmitting, setIsSubmitting] = useState(false); @@ -279,6 +279,20 @@ const BaseSignInContent: FC = ({ 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 @@ -309,15 +323,7 @@ const BaseSignInContent: FC = ({ 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({ @@ -470,7 +476,7 @@ const BaseSignInContent: FC = ({ // For V2, we always send inputs and action payload = { ...payload, - ...(component.id && {action: component.id}), + ...(component.id && { action: component.id }), inputs: filteredInputs, }; @@ -583,7 +589,7 @@ const BaseSignInContent: FC = ({ touched: touchedFields, validateForm: () => { const result: any = validateForm(); - return {fieldErrors: result.errors, isValid: result.isValid}; + return { fieldErrors: result.errors, isValid: result.isValid }; }, values: formValues, }; @@ -605,7 +611,7 @@ const BaseSignInContent: FC = ({ variant={variant} > -
+
@@ -713,7 +719,7 @@ const BaseSignInContent: FC = ({ * * ``` */ -const BaseSignIn: FC = ({preferences, ...rest}: BaseSignInProps): ReactElement => { +const BaseSignIn: FC = ({ preferences, ...rest }: BaseSignInProps): ReactElement => { const content: ReactElement = ( diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index 0733a41..3c50fdb 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -444,19 +444,25 @@ const SignIn: FC = ({ }; /** - * 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 => { - // 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) { @@ -597,6 +603,8 @@ const SignIn: FC = ({ // 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)) { + // Reset the init gate so a terminal error doesn't leave the user stuck without retry. + initializationAttemptedRef.current = false; return; } @@ -823,6 +831,11 @@ const SignIn: FC = ({ return; } + // Handle terminal flow statuses before normalization. + if (await handleTerminalResponse(response)) { + return; + } + const { executionId: normalizedExecutionId, components: normalizedComponents, @@ -836,11 +849,6 @@ const SignIn: FC = ({ 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);