From b4b50fed6e229cbdb994ea0123b7a1d7633e3b7d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 10:09:56 -0400 Subject: [PATCH 1/5] refactor(mosaic): move password check timing into the form hook --- .changeset/mosaic-form-async-checks.md | 2 + .../src/components/form/form.machine.ts | 95 ++++--- .../form/use-form.edit-password.test.ts | 5 +- .../src/components/form/use-form.test.ts | 257 ++++++++++-------- .../mosaic/src/components/form/use-form.ts | 122 +++++++-- ...r-profile-edit-password.controller.test.ts | 15 +- .../user-profile-edit-password.controller.ts | 43 +-- .../user-profile-edit-password.dialog.tsx | 12 +- .../user-profile-password-section.tsx | 1 - 9 files changed, 319 insertions(+), 233 deletions(-) create mode 100644 .changeset/mosaic-form-async-checks.md diff --git a/.changeset/mosaic-form-async-checks.md b/.changeset/mosaic-form-async-checks.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-form-async-checks.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index c90577f3d88..cc1a5880996 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -14,11 +14,13 @@ export type FieldValidator = ( export type AsyncFieldValidator = ( value: TValue, values: TValues, + options: { signal: AbortSignal }, ) => Promise; export interface FieldConfig { validate?: FieldValidator; validateAsync?: AsyncFieldValidator; + debounceMs?: number; } export type FieldsConfig = { [K in keyof TValues]?: FieldConfig }; @@ -44,7 +46,6 @@ export interface FormContext extends FormDeps { touched: Partial>; async: Partial>; error: FormError | undefined; - submitQueued: boolean; } export type FormEvent = @@ -65,55 +66,39 @@ function syncFeedback( return context.fields?.[name]?.validate?.(context.values[name], context.values); } -function settledAsyncFeedback( +function blockingFeedback( context: FormContext, name: keyof TValues, ): FieldFeedback | undefined { - const state = context.async[name]; - return state?.pending === true ? undefined : state?.feedback; -} - -export function validatorFeedback( - context: FormContext, - name: keyof TValues, -): FieldFeedback | undefined { - return syncFeedback(context, name) ?? context.async[name]?.feedback; + const submitError = context.error?.fields?.[name]; + return submitError === undefined ? syncFeedback(context, name) : { type: 'error', message: submitError }; } export function fieldFeedback( context: FormContext, name: keyof TValues, ): FieldFeedback | undefined { - const submitError = context.error?.fields?.[name]; - return submitError === undefined ? validatorFeedback(context, name) : { type: 'error', message: submitError }; + const feedback = blockingFeedback(context, name); + const hidden = feedback?.type === 'error' && context.touched[name] !== true; + return (hidden ? undefined : feedback) ?? context.async[name]?.feedback; } export function firstInvalid(context: FormContext): keyof TValues | undefined { - return keysOf(context.values).find( - name => (syncFeedback(context, name) ?? settledAsyncFeedback(context, name))?.type === 'error', - ); + return keysOf(context.values).find(name => syncFeedback(context, name)?.type === 'error'); } export function isValid(context: FormContext): boolean { return context.canSubmit(context.values) && firstInvalid(context) === undefined; } -function isValidating(context: FormContext): boolean { - return keysOf(context.values).some(name => context.async[name]?.pending === true); -} - function submitOrStay( context: FormContext, patch: Partial>, ): TransitionResult, FormState> { - const next = { ...context, ...patch }; - if (!isValid(next)) { - return { context: { ...patch, submitQueued: false } }; + if (!isValid({ ...context, ...patch })) { + return { context: patch }; } - if (isValidating(next)) { - return { context: { ...patch, submitQueued: true } }; - } - return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; + return { target: 'submitting', context: { ...patch, error: undefined } }; } function displayableFields( @@ -178,12 +163,26 @@ function withoutField( function asyncStateFor( context: FormContext, name: keyof TValues, - value: TValues[keyof TValues], ): AsyncFieldState | undefined { - if (context.fields?.[name]?.validateAsync === undefined || value === initialOf(context)[name]) { + const value = context.values[name]; + const current = context.async[name]; + if ( + context.fields?.[name]?.validateAsync === undefined || + (value === initialOf(context)[name] && context.touched[name] !== true) + ) { return undefined; } - return { value, feedback: context.async[name]?.feedback, pending: true }; + if (current?.value === value) { + return current; + } + return { value, feedback: current?.feedback, pending: true }; +} + +function withAsyncState( + context: FormContext, + name: keyof TValues, +): Partial> { + return { ...context.async, [name]: asyncStateFor(context, name) }; } type FormState = 'editing' | 'submitting'; @@ -201,29 +200,36 @@ export function createFormMachine(deps: FormDeps ({ - context: { - values: { ...context.values, [event.name]: event.value }, - async: { ...context.async, [event.name]: asyncStateFor(context, event.name, event.value) }, - error: withoutField(context.error, event.name), - submitQueued: false, - }, - }), - TOUCH: ({ context, event }) => ({ context: { touched: { ...context.touched, [event.name]: true } } }), + CHANGE: ({ context, event }) => { + const next = { ...context, values: { ...context.values, [event.name]: event.value } }; + return { + context: { + values: next.values, + async: withAsyncState(next, event.name), + error: withoutField(context.error, event.name), + }, + }; + }, + TOUCH: ({ context, event }) => { + const next = { ...context, touched: { ...context.touched, [event.name]: true } }; + return { context: { touched: next.touched, async: withAsyncState(next, event.name) } }; + }, VALIDATED: ({ context, event }) => { if (context.async[event.name]?.value !== event.value) { return undefined; } - const async = { - ...context.async, - [event.name]: { value: event.value, feedback: event.feedback, pending: false }, + return { + context: { + async: { + ...context.async, + [event.name]: { value: event.value, feedback: event.feedback, pending: false }, + }, + }, }; - return context.submitQueued ? submitOrStay(context, { async }) : { context: { async } }; }, SUBMIT: ({ context }) => submitOrStay(context, { touched: mapKeys(context.values, (): true => true) }), RESET: ({ context, event }) => ({ @@ -233,7 +239,6 @@ export function createFormMachine(deps: FormDeps { act(() => result.current.setValue('currentPassword', 'old-secret')); act(() => result.current.setValue('newPassword', 'short')); await act(tick); - expect(result.current.fields.newPassword.feedback).toBeUndefined(); - expect(result.current.canSubmit).toBe(false); - - act(() => result.current.touch('newPassword')); expect(result.current.fields.newPassword.feedback).toEqual({ type: 'error', message: 'Your password must contain 8 or more characters.', }); + expect(result.current.canSubmit).toBe(true); act(() => result.current.setValue('newPassword', 'longenough')); expect(result.current.fields.newPassword.isValidating).toBe(true); diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index f7b9ce8be8c..b20bd173a73 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -337,9 +337,8 @@ describe('useForm', () => { expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); }); - it('keeps the last async feedback while the next check runs and lets a stale error queue a submit', async () => { + it('keeps the last async feedback while the next check runs', async () => { const checks = new Map>>(); - const onSubmit = vi.fn(resolved); const { result } = renderHook(() => useForm({ initialValues: { username: '' }, @@ -352,7 +351,7 @@ describe('useForm', () => { }, }, }, - onSubmit, + onSubmit: resolved, }), ); act(() => result.current.setValue('username', 'ab')); @@ -363,147 +362,189 @@ describe('useForm', () => { act(() => result.current.setValue('username', 'abc')); expect(result.current.fields.username.isValidating).toBe(true); expect(result.current.fields.username.feedback).toEqual({ type: 'success', message: 'Available' }); - await act(async () => { - checks.get('abc')?.resolve({ type: 'error', message: 'Taken' }); - await tick(); - }); - act(() => result.current.touch('username')); - expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); - act(() => result.current.setValue('username', 'abcd')); - expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); - expect(result.current.canSubmit).toBe(true); - act(() => result.current.submit()); - expect(result.current.isSubmitting).toBe(true); - await act(async () => { - checks.get('abcd')?.resolve(undefined); - await tick(); - }); - expect(onSubmit).toHaveBeenCalledWith({ username: 'abcd' }); - expect(result.current.fields.username.feedback).toBeUndefined(); }); - it('queues a submit while async validation is pending and runs it once the field validates', async () => { - const check = deferred(); + it('shows async errors before the field is touched without blocking or delaying submit', async () => { + const checks = new Map>>(); const onSubmit = vi.fn(resolved); const { result } = renderHook(() => useForm({ initialValues: { password: '' }, - fields: { password: { validateAsync: () => check.promise } }, + fields: { + password: { + validateAsync: (value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }, + }, + }, onSubmit, }), ); - act(() => result.current.setValue('password', 'ab')); - expect(result.current.canSubmit).toBe(true); - act(() => result.current.submit()); - expect(onSubmit).not.toHaveBeenCalled(); - expect(result.current.isSubmitting).toBe(true); - expect(result.current.canSubmit).toBe(false); + act(() => result.current.setValue('password', 'a')); await act(async () => { - check.resolve(undefined); + checks.get('a')?.resolve({ type: 'error', message: 'Weak' }); await tick(); }); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith({ password: 'ab' }); - expect(result.current.isSubmitting).toBe(false); - }); + expect(result.current.fields.password.touched).toBe(false); + expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Weak' }); + expect(result.current.canSubmit).toBe(true); - it('focuses the first registered control in error when a queued submit is rejected by its check', async () => { - const check = deferred(); - const onSubmit = vi.fn(resolved); - const { result } = renderHook(() => - useForm({ - initialValues: { username: '' }, - fields: { username: { validateAsync: () => check.promise } }, - onSubmit, - }), - ); - const input = document.body.appendChild(document.createElement('input')); - result.current.register('username').ref(input); - act(() => result.current.setValue('username', 'ab')); - act(() => result.current.submit()); - expect(result.current.isSubmitting).toBe(true); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.fields.password.isValidating).toBe(true); await act(async () => { - check.resolve({ type: 'error', message: 'Taken' }); + result.current.submit(); await tick(); }); - expect(onSubmit).not.toHaveBeenCalled(); - expect(result.current.isSubmitting).toBe(false); - expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); - expect(document.activeElement).toBe(input); - input.remove(); - }); - - it('drops a queued submit when the field changes or its validation fails', async () => { - const checks = new Map>>(); - const validateAsync = (value: string) => { - const check = deferred(); - checks.set(value, check); - return check.promise; - }; - const onSubmit = vi.fn(resolved); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ password: 'ab' }); + }); + + it.each([ + ['rejects', () => Promise.reject(new Error('Network'))], + [ + 'throws synchronously', + () => { + throw new Error('Nope'); + }, + ], + ])('shows the generic message when an async validator %s', async (_, validateAsync) => { const { result } = renderHook(() => - useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit }), + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), ); - act(() => result.current.setValue('password', 'a')); - act(() => result.current.submit()); - expect(result.current.isSubmitting).toBe(true); act(() => result.current.setValue('password', 'ab')); - expect(result.current.isSubmitting).toBe(false); - act(() => result.current.submit()); - await act(async () => { - checks.get('ab')?.resolve({ type: 'error', message: 'Weak' }); - await tick(); + await act(tick); + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.fields.password.feedback).toEqual({ + type: 'error', + message: 'Something went wrong. Please try again.', }); - expect(onSubmit).not.toHaveBeenCalled(); - expect(result.current.isSubmitting).toBe(false); - expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Weak' }); + expect(result.current.canSubmit).toBe(true); }); - it('treats a rejected async validator as no feedback', async () => { - const validateAsync = vi.fn(() => Promise.reject(new Error('Network'))); + it('skips the initial value until the field is touched, then checks every value', async () => { + const validateAsync = vi.fn((value: string) => Promise.resolve({ type: 'info', message: value })); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, fields: { username: { validateAsync } }, onSubmit: resolved }), + ); + act(() => result.current.setValue('username', 'alexc')); + await act(tick); + expect(result.current.fields.username.feedback).toEqual({ type: 'info', message: 'alexc' }); + act(() => result.current.setValue('username', 'alex')); + await act(tick); + expect(validateAsync).toHaveBeenCalledTimes(1); + expect(result.current.fields.username.feedback).toBeUndefined(); + expect(result.current.fields.username.isValidating).toBe(false); + + act(() => result.current.touch('username')); + await act(tick); + expect(validateAsync).toHaveBeenLastCalledWith('alex', { username: 'alex' }, { signal: expect.any(AbortSignal) }); + expect(result.current.fields.username.feedback).toEqual({ type: 'info', message: 'alex' }); + + act(() => result.current.setValue('username', 'alexc')); + act(() => result.current.setValue('username', 'alex')); + await act(tick); + expect(validateAsync).toHaveBeenCalledTimes(4); + expect(result.current.fields.username.feedback).toEqual({ type: 'info', message: 'alex' }); + }); + + it('does not check again on touch when the current value is already checked', async () => { + const validateAsync = vi.fn(() => Promise.resolve(undefined)); const { result } = renderHook(() => useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), ); act(() => result.current.setValue('password', 'ab')); await act(tick); - expect(result.current.fields.password.isValidating).toBe(false); - expect(result.current.fields.password.feedback).toBeUndefined(); - expect(result.current.canSubmit).toBe(true); + act(() => result.current.touch('password')); + await act(tick); + expect(validateAsync).toHaveBeenCalledTimes(1); }); - it('treats an async validator that throws synchronously as no feedback', async () => { - const { result } = renderHook(() => - useForm({ - initialValues: { password: '' }, - fields: { - password: { - validateAsync: () => { - throw new Error('Nope'); - }, - }, - }, - onSubmit: resolved, - }), + it('debounces checks on change and runs a waiting one at once on touch', async () => { + vi.useFakeTimers(); + try { + const validateAsync = vi.fn((value: string) => Promise.resolve({ type: 'info', message: value })); + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { password: { validateAsync, debounceMs: 300 } }, + onSubmit: resolved, + }), + ); + act(() => result.current.setValue('password', 'a')); + await act(() => vi.advanceTimersByTimeAsync(200)); + act(() => result.current.setValue('password', 'ab')); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(validateAsync).not.toHaveBeenCalled(); + expect(result.current.fields.password.isValidating).toBe(true); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(validateAsync).toHaveBeenCalledExactlyOnceWith( + 'ab', + { password: 'ab' }, + { signal: expect.any(AbortSignal) }, + ); + expect(result.current.fields.password.feedback).toEqual({ type: 'info', message: 'ab' }); + + act(() => result.current.setValue('password', 'abc')); + act(() => result.current.touch('password')); + await act(() => vi.advanceTimersByTimeAsync(0)); + expect(validateAsync).toHaveBeenCalledTimes(2); + expect(result.current.fields.password.feedback).toEqual({ type: 'info', message: 'abc' }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('aborts a running check and ignores its result when the value changes, on reset and on unmount', async () => { + const checks: { signal: AbortSignal; check: ReturnType> }[] = []; + const validateAsync = (_value: string, _values: unknown, { signal }: { signal: AbortSignal }) => { + const check = deferred(); + checks.push({ signal, check }); + return check.promise; + }; + const { result, unmount } = renderHook(() => + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), ); + act(() => result.current.setValue('password', 'a')); + act(() => result.current.setValue('password', 'ab')); + expect(checks.map(({ signal }) => signal.aborted)).toEqual([true, false]); + + act(() => result.current.reset()); + expect(checks[1]?.signal.aborted).toBe(true); await act(async () => { - result.current.setValue('password', 'a'); + checks[1]?.check.resolve({ type: 'error', message: 'Weak' }); await tick(); }); - expect(result.current.fields.password.isValidating).toBe(false); expect(result.current.fields.password.feedback).toBeUndefined(); + + act(() => result.current.setValue('password', 'abc')); + unmount(); + expect(checks[2]?.signal.aborted).toBe(true); }); - it('skips async validation when the value returns to its initial value', async () => { - const validateAsync = vi.fn(() => Promise.resolve(undefined)); - const { result } = renderHook(() => - useForm({ initialValues: { username: 'alex' }, fields: { username: { validateAsync } }, onSubmit: resolved }), - ); - act(() => result.current.setValue('username', 'alexc')); - expect(validateAsync).toHaveBeenCalledTimes(1); - act(() => result.current.setValue('username', 'alex')); - await act(tick); - expect(validateAsync).toHaveBeenCalledTimes(1); - expect(result.current.fields.username.isValidating).toBe(false); + it('cancels a waiting debounce on reset and unmount', () => { + vi.useFakeTimers(); + try { + const validateAsync = vi.fn(() => Promise.resolve(undefined)); + const { result, unmount } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { password: { validateAsync, debounceMs: 300 } }, + onSubmit: resolved, + }), + ); + act(() => result.current.setValue('password', 'a')); + act(() => result.current.reset()); + expect(vi.getTimerCount()).toBe(0); + expect(result.current.fields.password.isValidating).toBe(false); + act(() => result.current.setValue('password', 'b')); + unmount(); + expect(vi.getTimerCount()).toBe(0); + expect(validateAsync).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); it('prefers a sync validator result over the async one for the same field', async () => { diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index 3c2867fa0d2..81d84be9642 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -1,4 +1,4 @@ -import { useCallback, useId, useRef } from 'react'; +import { useCallback, useEffect, useId, useRef } from 'react'; import { useErrorText, useMessages } from '../../localization'; import type { StateMachine } from '../../machine/types'; @@ -79,6 +79,11 @@ export interface UseFormResult { type ElementRef = (element: HTMLElement | null) => void; +interface Check { + controller: AbortController; + flush: () => void; +} + const always = () => true; export function useForm(options: UseFormOptions): UseFormResult { @@ -87,6 +92,7 @@ export function useForm(options: UseFormOptions const errorText = useErrorText(); const elements = useRef(new Map()); const refs = useRef(new Map()); + const checks = useRef(new Map()); const deps = { initialValues: options.initialValues, @@ -110,28 +116,95 @@ export function useForm(options: UseFormOptions elements.current.get(invalid)?.focus(); } }, [actor]); - const setValue = useCallback( - (name: K, value: TValues[K]) => { - send({ type: 'CHANGE', name, value }); - const { async, fields, values: next } = actor.getSnapshot().context; + const cancelCheck = useCallback((name: keyof TValues) => { + const check = checks.current.get(name); + checks.current.delete(name); + check?.controller.abort(); + check?.flush(); + }, []); + const cancelChecks = useCallback(() => { + for (const name of checks.current.keys()) { + cancelCheck(name); + } + }, [cancelCheck]); + useEffect(() => cancelChecks, [cancelChecks]); + + const startCheck = useCallback( + (name: keyof TValues, debounceMs: number) => { + const { async, fields, values: current, fallbackMessage } = actor.getSnapshot().context; const validateAsync = fields?.[name]?.validateAsync; - if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { + if (validateAsync === undefined || async[name]?.pending !== true) { return; } + const value = current[name]; + const controller = new AbortController(); + const failure: FieldFeedback = { type: 'error', message: fallbackMessage }; + const checked = new Promise(resolve => { + let timer: ReturnType | undefined; + let started = false; + const flush = () => { + clearTimeout(timer); + if (started) { + return; + } + started = true; + if (controller.signal.aborted) { + resolve(undefined); + return; + } + try { + resolve(validateAsync(value, current, { signal: controller.signal })); + } catch { + resolve(failure); + } + }; + checks.current.set(name, { controller, flush }); + if (debounceMs > 0) { + timer = setTimeout(flush, debounceMs); + } else { + flush(); + } + }); const settle = (feedback: FieldFeedback | undefined) => { - const { submitQueued } = actor.getSnapshot().context; - send({ type: 'VALIDATED', name, value, feedback }); - if (submitQueued) { - focusFirstInvalid(); + if (controller.signal.aborted) { + return; } + checks.current.delete(name); + send({ type: 'VALIDATED', name, value, feedback }); }; - void new Promise(resolve => resolve(validateAsync(value, next))).then(settle, () => - settle(undefined), - ); + void checked.then(settle, () => settle(failure)); + }, + [actor, send], + ); + const followCheck = useCallback( + (name: keyof TValues, previous: unknown, debounceMs: number) => { + if (actor.getSnapshot().context.async[name] === previous) { + return false; + } + cancelCheck(name); + startCheck(name, debounceMs); + return true; + }, + [actor, cancelCheck, startCheck], + ); + const setValue = useCallback( + (name: K, value: TValues[K]) => { + const { async, fields } = actor.getSnapshot().context; + send({ type: 'CHANGE', name, value }); + followCheck(name, async[name], fields?.[name]?.debounceMs ?? 0); + }, + [actor, followCheck, send], + ); + const touch = useCallback( + (name: keyof TValues) => { + const previous = actor.getSnapshot().context.async[name]; + send({ type: 'TOUCH', name }); + if (!followCheck(name, previous, 0)) { + checks.current.get(name)?.flush(); + } }, - [actor, focusFirstInvalid, send], + [actor, followCheck, send], ); - const touch = useCallback((name: keyof TValues) => send({ type: 'TOUCH', name }), [send]); const refFor = useCallback((name: keyof TValues): ElementRef => { const existing = refs.current.get(name); if (existing !== undefined) { @@ -158,7 +231,13 @@ export function useForm(options: UseFormOptions }, [submit], ); - const reset = useCallback((nextValues?: TValues) => send({ type: 'RESET', values: nextValues }), [send]); + const reset = useCallback( + (nextValues?: TValues) => { + cancelChecks(); + send({ type: 'RESET', values: nextValues }); + }, + [cancelChecks, send], + ); const register = >(name: K): RegisteredField => ({ name, @@ -175,15 +254,13 @@ export function useForm(options: UseFormOptions ref: refFor(name), }); - const isSubmitting = snapshot.value === 'submitting' || context.submitQueued; + const isSubmitting = snapshot.value === 'submitting'; const initial = initialOf(context); const fields = mapKeys(values, (name): FormField => { - const feedback = fieldFeedback(context, name); - const touched = context.touched[name] === true; return { - feedback: feedback?.type === 'error' && !touched ? undefined : feedback, + feedback: fieldFeedback(context, name), isValidating: context.async[name]?.pending === true, - touched, + touched: context.touched[name] === true, isDirty: !Object.is(values[name], initial[name]), }; }); @@ -194,8 +271,7 @@ export function useForm(options: UseFormOptions fields, error: context.error?.message, get isSubmitting() { - const current = actor.getSnapshot(); - return current.value === 'submitting' || current.context.submitQueued; + return actor.getSnapshot().value === 'submitting'; }, isDirty: keysOf(values).some(name => fields[name].isDirty), canSubmit: !isSubmitting && isValid(context), diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 2ab0426e474..16abc25425f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -18,7 +18,10 @@ describe('useUserProfileEditPasswordController timing', () => { act(() => result.current.onOpenChange(true)); act(() => result.current.form.setValue('newPassword', 'new password')); await act(() => vi.advanceTimersByTimeAsync(350)); - expect(result.current.passwordFeedback).toMatchObject({ type: 'error', message: expect.any(String) }); + expect(result.current.form.fields.newPassword.feedback).toMatchObject({ + type: 'error', + message: expect.any(String), + }); } finally { vi.useRealTimers(); } @@ -37,11 +40,11 @@ describe('useUserProfileEditPasswordController timing', () => { act(() => result.current.onOpenChange(true)); act(() => result.current.form.setValue('newPassword', 'first password')); await act(() => vi.advanceTimersByTimeAsync(350)); - expect(result.current.passwordFeedback).toEqual(feedback); + expect(result.current.form.fields.newPassword.feedback).toEqual(feedback); act(() => result.current.form.setValue('newPassword', 'second password')); - expect(result.current.passwordFeedback).toEqual(feedback); + expect(result.current.form.fields.newPassword.feedback).toEqual(feedback); act(() => result.current.onOpenChange(false)); - expect(result.current.passwordFeedback).toBeUndefined(); + expect(result.current.form.fields.newPassword.feedback).toBeUndefined(); } finally { vi.useRealTimers(); } @@ -92,9 +95,9 @@ describe('useUserProfileEditPasswordController timing', () => { await older.promise; }); - expect(result.current.passwordFeedback).toEqual({ type: 'success', message: 'Strong password.' }); + expect(result.current.form.fields.newPassword.feedback).toEqual({ type: 'success', message: 'Strong password.' }); act(() => result.current.onOpenChange(false)); - expect(result.current.passwordFeedback).toBeUndefined(); + expect(result.current.form.fields.newPassword.feedback).toBeUndefined(); }); it('ignores dismissal and duplicate saves in the same event as submission', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index f28707b9884..d5bfa564728 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,5 +1,5 @@ import { DEBOUNCE_MS } from '@clerk/shared/internal/clerk-js/constants'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; @@ -27,7 +27,6 @@ export interface UserProfileEditPasswordController { isOpen: boolean; onOpenChange: (open: boolean) => void; form: UseFormResult; - passwordFeedback: FieldFeedback | undefined; } export function useUserProfileEditPasswordController({ @@ -35,14 +34,16 @@ export function useUserProfileEditPasswordController({ onSubmit, validatePassword, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { - const validationError = useMessages('errors').generic; const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); - const [passwordFeedback, setPasswordFeedback] = useState(); const form = useForm({ initialValues, fields: { + newPassword: validatePassword && { + validateAsync: password => validatePassword(password), + debounceMs: DEBOUNCE_MS, + }, confirmPassword: { // TODO: Discuss showing success feedback when the confirmation matches, as legacy does. https://github.com/clerk/javascript/pull/9930#discussion_r4150406791 validate: (value, values) => @@ -64,38 +65,6 @@ export function useUserProfileEditPasswordController({ }, }); - const password = form.values.newPassword; - const passwordLeft = form.fields.newPassword.touched; - useEffect(() => { - // TODO: Discuss keeping the password hint hidden on open or showing it immediately when the field autofocuses. https://github.com/clerk/javascript/pull/9930#discussion_r4150863181 - if (!isOpen || (password === '' && !passwordLeft) || !validatePassword) { - setPasswordFeedback(undefined); - return; - } - - let active = true; - const timeout = setTimeout(() => { - void Promise.resolve() - .then(() => validatePassword(password)) - .then( - feedback => { - if (active) { - setPasswordFeedback(feedback); - } - }, - () => { - if (active) { - setPasswordFeedback({ type: 'error', message: validationError }); - } - }, - ); - }, DEBOUNCE_MS); - return () => { - active = false; - clearTimeout(timeout); - }; - }, [isOpen, password, passwordLeft, validatePassword, validationError]); - const onOpenChange = (open: boolean) => { if (form.isSubmitting) { return; @@ -104,5 +73,5 @@ export function useUserProfileEditPasswordController({ setIsOpen(open); }; - return { isOpen, onOpenChange, form, passwordFeedback }; + return { isOpen, onOpenChange, form }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 10794c94afd..6a62448ca88 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -8,7 +8,7 @@ import { Checkbox } from '../../../components/checkbox'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import type { FieldFeedback, UseFormResult } from '../../../components/form'; +import type { UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { useMessages } from '../../../localization'; @@ -18,7 +18,6 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { - passwordFeedback?: FieldFeedback; identifier?: string; open: boolean; onOpenChange: (open: boolean) => void; @@ -29,7 +28,6 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ - passwordFeedback, identifier = '', open, onOpenChange, @@ -95,7 +93,6 @@ export function UserProfileEditPasswordDialog({ inputRef={showCurrentPassword ? undefined : initialFocusRef} label={m.newPasswordLabel} name='newPassword' - advisoryFeedback={passwordFeedback} /> ; inputRef?: RefObject; name: UserProfileEditPasswordField; - advisoryFeedback?: FieldFeedback; }) { const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); const [focused, setFocused] = useState(false); const { feedback } = form.fields[name]; - const message = feedback?.type === 'error' ? feedback : advisoryFeedback; - const feedbackType = message?.type === 'info' && !focused ? 'error' : message?.type; + const feedbackType = feedback?.type === 'info' && !focused ? 'error' : feedback?.type; const { ref, ...control } = form.register(name); const mergedRef = useMergeRefs([ref, inputRef]); @@ -199,7 +193,7 @@ function PasswordField({ - + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index a89862fc1dd..19cd00c0e2b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -63,7 +63,6 @@ function PasswordEditor({ model }: { model: Extract Date: Mon, 5 Oct 2026 12:35:26 -0400 Subject: [PATCH 2/5] refactor(mosaic): extract the debounced password check into useDebouncedAsync --- .../src/components/form/form.machine.ts | 95 +++---- .../form/use-form.edit-password.test.ts | 5 +- .../src/components/form/use-form.test.ts | 257 ++++++++---------- .../mosaic/src/components/form/use-form.ts | 122 ++------- ...r-profile-edit-password.controller.test.ts | 15 +- .../user-profile-edit-password.controller.ts | 20 +- .../user-profile-edit-password.dialog.tsx | 12 +- .../user-profile-password-section.tsx | 1 + .../__tests__/use-debounced-async.test.ts | 123 +++++++++ .../mosaic/src/hooks/use-debounced-async.ts | 78 ++++++ 10 files changed, 412 insertions(+), 316 deletions(-) create mode 100644 packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts create mode 100644 packages/mosaic/src/hooks/use-debounced-async.ts diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index cc1a5880996..c90577f3d88 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -14,13 +14,11 @@ export type FieldValidator = ( export type AsyncFieldValidator = ( value: TValue, values: TValues, - options: { signal: AbortSignal }, ) => Promise; export interface FieldConfig { validate?: FieldValidator; validateAsync?: AsyncFieldValidator; - debounceMs?: number; } export type FieldsConfig = { [K in keyof TValues]?: FieldConfig }; @@ -46,6 +44,7 @@ export interface FormContext extends FormDeps { touched: Partial>; async: Partial>; error: FormError | undefined; + submitQueued: boolean; } export type FormEvent = @@ -66,39 +65,55 @@ function syncFeedback( return context.fields?.[name]?.validate?.(context.values[name], context.values); } -function blockingFeedback( +function settledAsyncFeedback( context: FormContext, name: keyof TValues, ): FieldFeedback | undefined { - const submitError = context.error?.fields?.[name]; - return submitError === undefined ? syncFeedback(context, name) : { type: 'error', message: submitError }; + const state = context.async[name]; + return state?.pending === true ? undefined : state?.feedback; +} + +export function validatorFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + return syncFeedback(context, name) ?? context.async[name]?.feedback; } export function fieldFeedback( context: FormContext, name: keyof TValues, ): FieldFeedback | undefined { - const feedback = blockingFeedback(context, name); - const hidden = feedback?.type === 'error' && context.touched[name] !== true; - return (hidden ? undefined : feedback) ?? context.async[name]?.feedback; + const submitError = context.error?.fields?.[name]; + return submitError === undefined ? validatorFeedback(context, name) : { type: 'error', message: submitError }; } export function firstInvalid(context: FormContext): keyof TValues | undefined { - return keysOf(context.values).find(name => syncFeedback(context, name)?.type === 'error'); + return keysOf(context.values).find( + name => (syncFeedback(context, name) ?? settledAsyncFeedback(context, name))?.type === 'error', + ); } export function isValid(context: FormContext): boolean { return context.canSubmit(context.values) && firstInvalid(context) === undefined; } +function isValidating(context: FormContext): boolean { + return keysOf(context.values).some(name => context.async[name]?.pending === true); +} + function submitOrStay( context: FormContext, patch: Partial>, ): TransitionResult, FormState> { - if (!isValid({ ...context, ...patch })) { - return { context: patch }; + const next = { ...context, ...patch }; + if (!isValid(next)) { + return { context: { ...patch, submitQueued: false } }; } - return { target: 'submitting', context: { ...patch, error: undefined } }; + if (isValidating(next)) { + return { context: { ...patch, submitQueued: true } }; + } + return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; } function displayableFields( @@ -163,26 +178,12 @@ function withoutField( function asyncStateFor( context: FormContext, name: keyof TValues, + value: TValues[keyof TValues], ): AsyncFieldState | undefined { - const value = context.values[name]; - const current = context.async[name]; - if ( - context.fields?.[name]?.validateAsync === undefined || - (value === initialOf(context)[name] && context.touched[name] !== true) - ) { + if (context.fields?.[name]?.validateAsync === undefined || value === initialOf(context)[name]) { return undefined; } - if (current?.value === value) { - return current; - } - return { value, feedback: current?.feedback, pending: true }; -} - -function withAsyncState( - context: FormContext, - name: keyof TValues, -): Partial> { - return { ...context.async, [name]: asyncStateFor(context, name) }; + return { value, feedback: context.async[name]?.feedback, pending: true }; } type FormState = 'editing' | 'submitting'; @@ -200,36 +201,29 @@ export function createFormMachine(deps: FormDeps { - const next = { ...context, values: { ...context.values, [event.name]: event.value } }; - return { - context: { - values: next.values, - async: withAsyncState(next, event.name), - error: withoutField(context.error, event.name), - }, - }; - }, - TOUCH: ({ context, event }) => { - const next = { ...context, touched: { ...context.touched, [event.name]: true } }; - return { context: { touched: next.touched, async: withAsyncState(next, event.name) } }; - }, + CHANGE: ({ context, event }) => ({ + context: { + values: { ...context.values, [event.name]: event.value }, + async: { ...context.async, [event.name]: asyncStateFor(context, event.name, event.value) }, + error: withoutField(context.error, event.name), + submitQueued: false, + }, + }), + TOUCH: ({ context, event }) => ({ context: { touched: { ...context.touched, [event.name]: true } } }), VALIDATED: ({ context, event }) => { if (context.async[event.name]?.value !== event.value) { return undefined; } - return { - context: { - async: { - ...context.async, - [event.name]: { value: event.value, feedback: event.feedback, pending: false }, - }, - }, + const async = { + ...context.async, + [event.name]: { value: event.value, feedback: event.feedback, pending: false }, }; + return context.submitQueued ? submitOrStay(context, { async }) : { context: { async } }; }, SUBMIT: ({ context }) => submitOrStay(context, { touched: mapKeys(context.values, (): true => true) }), RESET: ({ context, event }) => ({ @@ -239,6 +233,7 @@ export function createFormMachine(deps: FormDeps { act(() => result.current.setValue('currentPassword', 'old-secret')); act(() => result.current.setValue('newPassword', 'short')); await act(tick); + expect(result.current.fields.newPassword.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(false); + + act(() => result.current.touch('newPassword')); expect(result.current.fields.newPassword.feedback).toEqual({ type: 'error', message: 'Your password must contain 8 or more characters.', }); - expect(result.current.canSubmit).toBe(true); act(() => result.current.setValue('newPassword', 'longenough')); expect(result.current.fields.newPassword.isValidating).toBe(true); diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index b20bd173a73..f7b9ce8be8c 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -337,8 +337,9 @@ describe('useForm', () => { expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); }); - it('keeps the last async feedback while the next check runs', async () => { + it('keeps the last async feedback while the next check runs and lets a stale error queue a submit', async () => { const checks = new Map>>(); + const onSubmit = vi.fn(resolved); const { result } = renderHook(() => useForm({ initialValues: { username: '' }, @@ -351,7 +352,7 @@ describe('useForm', () => { }, }, }, - onSubmit: resolved, + onSubmit, }), ); act(() => result.current.setValue('username', 'ab')); @@ -362,189 +363,147 @@ describe('useForm', () => { act(() => result.current.setValue('username', 'abc')); expect(result.current.fields.username.isValidating).toBe(true); expect(result.current.fields.username.feedback).toEqual({ type: 'success', message: 'Available' }); + await act(async () => { + checks.get('abc')?.resolve({ type: 'error', message: 'Taken' }); + await tick(); + }); + act(() => result.current.touch('username')); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + act(() => result.current.setValue('username', 'abcd')); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(result.current.canSubmit).toBe(true); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + checks.get('abcd')?.resolve(undefined); + await tick(); + }); + expect(onSubmit).toHaveBeenCalledWith({ username: 'abcd' }); + expect(result.current.fields.username.feedback).toBeUndefined(); }); - it('shows async errors before the field is touched without blocking or delaying submit', async () => { - const checks = new Map>>(); + it('queues a submit while async validation is pending and runs it once the field validates', async () => { + const check = deferred(); const onSubmit = vi.fn(resolved); const { result } = renderHook(() => useForm({ initialValues: { password: '' }, - fields: { - password: { - validateAsync: (value: string) => { - const check = deferred(); - checks.set(value, check); - return check.promise; - }, - }, - }, + fields: { password: { validateAsync: () => check.promise } }, onSubmit, }), ); - act(() => result.current.setValue('password', 'a')); - await act(async () => { - checks.get('a')?.resolve({ type: 'error', message: 'Weak' }); - await tick(); - }); - expect(result.current.fields.password.touched).toBe(false); - expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Weak' }); - expect(result.current.canSubmit).toBe(true); - act(() => result.current.setValue('password', 'ab')); - expect(result.current.fields.password.isValidating).toBe(true); + expect(result.current.canSubmit).toBe(true); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.canSubmit).toBe(false); await act(async () => { - result.current.submit(); + check.resolve(undefined); await tick(); }); - expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ password: 'ab' }); - }); - - it.each([ - ['rejects', () => Promise.reject(new Error('Network'))], - [ - 'throws synchronously', - () => { - throw new Error('Nope'); - }, - ], - ])('shows the generic message when an async validator %s', async (_, validateAsync) => { + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ password: 'ab' }); + expect(result.current.isSubmitting).toBe(false); + }); + + it('focuses the first registered control in error when a queued submit is rejected by its check', async () => { + const check = deferred(); + const onSubmit = vi.fn(resolved); const { result } = renderHook(() => - useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), + useForm({ + initialValues: { username: '' }, + fields: { username: { validateAsync: () => check.promise } }, + onSubmit, + }), ); - act(() => result.current.setValue('password', 'ab')); - await act(tick); - expect(result.current.fields.password.isValidating).toBe(false); - expect(result.current.fields.password.feedback).toEqual({ - type: 'error', - message: 'Something went wrong. Please try again.', + const input = document.body.appendChild(document.createElement('input')); + result.current.register('username').ref(input); + act(() => result.current.setValue('username', 'ab')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + check.resolve({ type: 'error', message: 'Taken' }); + await tick(); }); - expect(result.current.canSubmit).toBe(true); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(document.activeElement).toBe(input); + input.remove(); }); - it('skips the initial value until the field is touched, then checks every value', async () => { - const validateAsync = vi.fn((value: string) => Promise.resolve({ type: 'info', message: value })); + it('drops a queued submit when the field changes or its validation fails', async () => { + const checks = new Map>>(); + const validateAsync = (value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }; + const onSubmit = vi.fn(resolved); const { result } = renderHook(() => - useForm({ initialValues: { username: 'alex' }, fields: { username: { validateAsync } }, onSubmit: resolved }), + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit }), ); - act(() => result.current.setValue('username', 'alexc')); - await act(tick); - expect(result.current.fields.username.feedback).toEqual({ type: 'info', message: 'alexc' }); - act(() => result.current.setValue('username', 'alex')); - await act(tick); - expect(validateAsync).toHaveBeenCalledTimes(1); - expect(result.current.fields.username.feedback).toBeUndefined(); - expect(result.current.fields.username.isValidating).toBe(false); - - act(() => result.current.touch('username')); - await act(tick); - expect(validateAsync).toHaveBeenLastCalledWith('alex', { username: 'alex' }, { signal: expect.any(AbortSignal) }); - expect(result.current.fields.username.feedback).toEqual({ type: 'info', message: 'alex' }); - - act(() => result.current.setValue('username', 'alexc')); - act(() => result.current.setValue('username', 'alex')); - await act(tick); - expect(validateAsync).toHaveBeenCalledTimes(4); - expect(result.current.fields.username.feedback).toEqual({ type: 'info', message: 'alex' }); + act(() => result.current.setValue('password', 'a')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.isSubmitting).toBe(false); + act(() => result.current.submit()); + await act(async () => { + checks.get('ab')?.resolve({ type: 'error', message: 'Weak' }); + await tick(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Weak' }); }); - it('does not check again on touch when the current value is already checked', async () => { - const validateAsync = vi.fn(() => Promise.resolve(undefined)); + it('treats a rejected async validator as no feedback', async () => { + const validateAsync = vi.fn(() => Promise.reject(new Error('Network'))); const { result } = renderHook(() => useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), ); act(() => result.current.setValue('password', 'ab')); await act(tick); - act(() => result.current.touch('password')); - await act(tick); - expect(validateAsync).toHaveBeenCalledTimes(1); - }); - - it('debounces checks on change and runs a waiting one at once on touch', async () => { - vi.useFakeTimers(); - try { - const validateAsync = vi.fn((value: string) => Promise.resolve({ type: 'info', message: value })); - const { result } = renderHook(() => - useForm({ - initialValues: { password: '' }, - fields: { password: { validateAsync, debounceMs: 300 } }, - onSubmit: resolved, - }), - ); - act(() => result.current.setValue('password', 'a')); - await act(() => vi.advanceTimersByTimeAsync(200)); - act(() => result.current.setValue('password', 'ab')); - await act(() => vi.advanceTimersByTimeAsync(299)); - expect(validateAsync).not.toHaveBeenCalled(); - expect(result.current.fields.password.isValidating).toBe(true); - await act(() => vi.advanceTimersByTimeAsync(1)); - expect(validateAsync).toHaveBeenCalledExactlyOnceWith( - 'ab', - { password: 'ab' }, - { signal: expect.any(AbortSignal) }, - ); - expect(result.current.fields.password.feedback).toEqual({ type: 'info', message: 'ab' }); - - act(() => result.current.setValue('password', 'abc')); - act(() => result.current.touch('password')); - await act(() => vi.advanceTimersByTimeAsync(0)); - expect(validateAsync).toHaveBeenCalledTimes(2); - expect(result.current.fields.password.feedback).toEqual({ type: 'info', message: 'abc' }); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.fields.password.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(true); }); - it('aborts a running check and ignores its result when the value changes, on reset and on unmount', async () => { - const checks: { signal: AbortSignal; check: ReturnType> }[] = []; - const validateAsync = (_value: string, _values: unknown, { signal }: { signal: AbortSignal }) => { - const check = deferred(); - checks.push({ signal, check }); - return check.promise; - }; - const { result, unmount } = renderHook(() => - useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), + it('treats an async validator that throws synchronously as no feedback', async () => { + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { + password: { + validateAsync: () => { + throw new Error('Nope'); + }, + }, + }, + onSubmit: resolved, + }), ); - act(() => result.current.setValue('password', 'a')); - act(() => result.current.setValue('password', 'ab')); - expect(checks.map(({ signal }) => signal.aborted)).toEqual([true, false]); - - act(() => result.current.reset()); - expect(checks[1]?.signal.aborted).toBe(true); await act(async () => { - checks[1]?.check.resolve({ type: 'error', message: 'Weak' }); + result.current.setValue('password', 'a'); await tick(); }); + expect(result.current.fields.password.isValidating).toBe(false); expect(result.current.fields.password.feedback).toBeUndefined(); - - act(() => result.current.setValue('password', 'abc')); - unmount(); - expect(checks[2]?.signal.aborted).toBe(true); }); - it('cancels a waiting debounce on reset and unmount', () => { - vi.useFakeTimers(); - try { - const validateAsync = vi.fn(() => Promise.resolve(undefined)); - const { result, unmount } = renderHook(() => - useForm({ - initialValues: { password: '' }, - fields: { password: { validateAsync, debounceMs: 300 } }, - onSubmit: resolved, - }), - ); - act(() => result.current.setValue('password', 'a')); - act(() => result.current.reset()); - expect(vi.getTimerCount()).toBe(0); - expect(result.current.fields.password.isValidating).toBe(false); - act(() => result.current.setValue('password', 'b')); - unmount(); - expect(vi.getTimerCount()).toBe(0); - expect(validateAsync).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + it('skips async validation when the value returns to its initial value', async () => { + const validateAsync = vi.fn(() => Promise.resolve(undefined)); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, fields: { username: { validateAsync } }, onSubmit: resolved }), + ); + act(() => result.current.setValue('username', 'alexc')); + expect(validateAsync).toHaveBeenCalledTimes(1); + act(() => result.current.setValue('username', 'alex')); + await act(tick); + expect(validateAsync).toHaveBeenCalledTimes(1); + expect(result.current.fields.username.isValidating).toBe(false); }); it('prefers a sync validator result over the async one for the same field', async () => { diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index 81d84be9642..3c2867fa0d2 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useRef } from 'react'; +import { useCallback, useId, useRef } from 'react'; import { useErrorText, useMessages } from '../../localization'; import type { StateMachine } from '../../machine/types'; @@ -79,11 +79,6 @@ export interface UseFormResult { type ElementRef = (element: HTMLElement | null) => void; -interface Check { - controller: AbortController; - flush: () => void; -} - const always = () => true; export function useForm(options: UseFormOptions): UseFormResult { @@ -92,7 +87,6 @@ export function useForm(options: UseFormOptions const errorText = useErrorText(); const elements = useRef(new Map()); const refs = useRef(new Map()); - const checks = useRef(new Map()); const deps = { initialValues: options.initialValues, @@ -116,95 +110,28 @@ export function useForm(options: UseFormOptions elements.current.get(invalid)?.focus(); } }, [actor]); - const cancelCheck = useCallback((name: keyof TValues) => { - const check = checks.current.get(name); - checks.current.delete(name); - check?.controller.abort(); - check?.flush(); - }, []); - const cancelChecks = useCallback(() => { - for (const name of checks.current.keys()) { - cancelCheck(name); - } - }, [cancelCheck]); - useEffect(() => cancelChecks, [cancelChecks]); - - const startCheck = useCallback( - (name: keyof TValues, debounceMs: number) => { - const { async, fields, values: current, fallbackMessage } = actor.getSnapshot().context; + const setValue = useCallback( + (name: K, value: TValues[K]) => { + send({ type: 'CHANGE', name, value }); + const { async, fields, values: next } = actor.getSnapshot().context; const validateAsync = fields?.[name]?.validateAsync; - if (validateAsync === undefined || async[name]?.pending !== true) { + if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { return; } - const value = current[name]; - const controller = new AbortController(); - const failure: FieldFeedback = { type: 'error', message: fallbackMessage }; - const checked = new Promise(resolve => { - let timer: ReturnType | undefined; - let started = false; - const flush = () => { - clearTimeout(timer); - if (started) { - return; - } - started = true; - if (controller.signal.aborted) { - resolve(undefined); - return; - } - try { - resolve(validateAsync(value, current, { signal: controller.signal })); - } catch { - resolve(failure); - } - }; - checks.current.set(name, { controller, flush }); - if (debounceMs > 0) { - timer = setTimeout(flush, debounceMs); - } else { - flush(); - } - }); const settle = (feedback: FieldFeedback | undefined) => { - if (controller.signal.aborted) { - return; - } - checks.current.delete(name); + const { submitQueued } = actor.getSnapshot().context; send({ type: 'VALIDATED', name, value, feedback }); + if (submitQueued) { + focusFirstInvalid(); + } }; - void checked.then(settle, () => settle(failure)); - }, - [actor, send], - ); - const followCheck = useCallback( - (name: keyof TValues, previous: unknown, debounceMs: number) => { - if (actor.getSnapshot().context.async[name] === previous) { - return false; - } - cancelCheck(name); - startCheck(name, debounceMs); - return true; - }, - [actor, cancelCheck, startCheck], - ); - const setValue = useCallback( - (name: K, value: TValues[K]) => { - const { async, fields } = actor.getSnapshot().context; - send({ type: 'CHANGE', name, value }); - followCheck(name, async[name], fields?.[name]?.debounceMs ?? 0); - }, - [actor, followCheck, send], - ); - const touch = useCallback( - (name: keyof TValues) => { - const previous = actor.getSnapshot().context.async[name]; - send({ type: 'TOUCH', name }); - if (!followCheck(name, previous, 0)) { - checks.current.get(name)?.flush(); - } + void new Promise(resolve => resolve(validateAsync(value, next))).then(settle, () => + settle(undefined), + ); }, - [actor, followCheck, send], + [actor, focusFirstInvalid, send], ); + const touch = useCallback((name: keyof TValues) => send({ type: 'TOUCH', name }), [send]); const refFor = useCallback((name: keyof TValues): ElementRef => { const existing = refs.current.get(name); if (existing !== undefined) { @@ -231,13 +158,7 @@ export function useForm(options: UseFormOptions }, [submit], ); - const reset = useCallback( - (nextValues?: TValues) => { - cancelChecks(); - send({ type: 'RESET', values: nextValues }); - }, - [cancelChecks, send], - ); + const reset = useCallback((nextValues?: TValues) => send({ type: 'RESET', values: nextValues }), [send]); const register = >(name: K): RegisteredField => ({ name, @@ -254,13 +175,15 @@ export function useForm(options: UseFormOptions ref: refFor(name), }); - const isSubmitting = snapshot.value === 'submitting'; + const isSubmitting = snapshot.value === 'submitting' || context.submitQueued; const initial = initialOf(context); const fields = mapKeys(values, (name): FormField => { + const feedback = fieldFeedback(context, name); + const touched = context.touched[name] === true; return { - feedback: fieldFeedback(context, name), + feedback: feedback?.type === 'error' && !touched ? undefined : feedback, isValidating: context.async[name]?.pending === true, - touched: context.touched[name] === true, + touched, isDirty: !Object.is(values[name], initial[name]), }; }); @@ -271,7 +194,8 @@ export function useForm(options: UseFormOptions fields, error: context.error?.message, get isSubmitting() { - return actor.getSnapshot().value === 'submitting'; + const current = actor.getSnapshot(); + return current.value === 'submitting' || current.context.submitQueued; }, isDirty: keysOf(values).some(name => fields[name].isDirty), canSubmit: !isSubmitting && isValid(context), diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 16abc25425f..2ab0426e474 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -18,10 +18,7 @@ describe('useUserProfileEditPasswordController timing', () => { act(() => result.current.onOpenChange(true)); act(() => result.current.form.setValue('newPassword', 'new password')); await act(() => vi.advanceTimersByTimeAsync(350)); - expect(result.current.form.fields.newPassword.feedback).toMatchObject({ - type: 'error', - message: expect.any(String), - }); + expect(result.current.passwordFeedback).toMatchObject({ type: 'error', message: expect.any(String) }); } finally { vi.useRealTimers(); } @@ -40,11 +37,11 @@ describe('useUserProfileEditPasswordController timing', () => { act(() => result.current.onOpenChange(true)); act(() => result.current.form.setValue('newPassword', 'first password')); await act(() => vi.advanceTimersByTimeAsync(350)); - expect(result.current.form.fields.newPassword.feedback).toEqual(feedback); + expect(result.current.passwordFeedback).toEqual(feedback); act(() => result.current.form.setValue('newPassword', 'second password')); - expect(result.current.form.fields.newPassword.feedback).toEqual(feedback); + expect(result.current.passwordFeedback).toEqual(feedback); act(() => result.current.onOpenChange(false)); - expect(result.current.form.fields.newPassword.feedback).toBeUndefined(); + expect(result.current.passwordFeedback).toBeUndefined(); } finally { vi.useRealTimers(); } @@ -95,9 +92,9 @@ describe('useUserProfileEditPasswordController timing', () => { await older.promise; }); - expect(result.current.form.fields.newPassword.feedback).toEqual({ type: 'success', message: 'Strong password.' }); + expect(result.current.passwordFeedback).toEqual({ type: 'success', message: 'Strong password.' }); act(() => result.current.onOpenChange(false)); - expect(result.current.form.fields.newPassword.feedback).toBeUndefined(); + expect(result.current.passwordFeedback).toBeUndefined(); }); it('ignores dismissal and duplicate saves in the same event as submission', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index d5bfa564728..49675d39e83 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -4,6 +4,7 @@ import { useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; +import { useDebouncedAsync } from '../../../hooks/use-debounced-async'; import { useMessages } from '../../../localization'; import type { UserProfileEditPasswordValue, @@ -27,6 +28,7 @@ export interface UserProfileEditPasswordController { isOpen: boolean; onOpenChange: (open: boolean) => void; form: UseFormResult; + passwordFeedback: FieldFeedback | undefined; } export function useUserProfileEditPasswordController({ @@ -34,16 +36,13 @@ export function useUserProfileEditPasswordController({ onSubmit, validatePassword, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { + const validationError = useMessages('errors').generic; const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); const form = useForm({ initialValues, fields: { - newPassword: validatePassword && { - validateAsync: password => validatePassword(password), - debounceMs: DEBOUNCE_MS, - }, confirmPassword: { // TODO: Discuss showing success feedback when the confirmation matches, as legacy does. https://github.com/clerk/javascript/pull/9930#discussion_r4150406791 validate: (value, values) => @@ -65,6 +64,17 @@ export function useUserProfileEditPasswordController({ }, }); + const password = form.values.newPassword; + const passwordLeft = form.fields.newPassword.touched; + // TODO: Discuss keeping the password hint hidden on open or showing it immediately when the field autofocuses. https://github.com/clerk/javascript/pull/9930#discussion_r4150863181 + const strength = useDebouncedAsync( + password, + value => (validatePassword ? validatePassword(value) : Promise.resolve(undefined)), + { delayMs: DEBOUNCE_MS, enabled: isOpen && (password !== '' || passwordLeft) && validatePassword !== undefined }, + ); + const passwordFeedback: FieldFeedback | undefined = + strength.error === undefined ? strength.data : { type: 'error', message: validationError }; + const onOpenChange = (open: boolean) => { if (form.isSubmitting) { return; @@ -73,5 +83,5 @@ export function useUserProfileEditPasswordController({ setIsOpen(open); }; - return { isOpen, onOpenChange, form }; + return { isOpen, onOpenChange, form, passwordFeedback }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 6a62448ca88..10794c94afd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -8,7 +8,7 @@ import { Checkbox } from '../../../components/checkbox'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import type { UseFormResult } from '../../../components/form'; +import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { useMessages } from '../../../localization'; @@ -18,6 +18,7 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { + passwordFeedback?: FieldFeedback; identifier?: string; open: boolean; onOpenChange: (open: boolean) => void; @@ -28,6 +29,7 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ + passwordFeedback, identifier = '', open, onOpenChange, @@ -93,6 +95,7 @@ export function UserProfileEditPasswordDialog({ inputRef={showCurrentPassword ? undefined : initialFocusRef} label={m.newPasswordLabel} name='newPassword' + advisoryFeedback={passwordFeedback} /> ; inputRef?: RefObject; name: UserProfileEditPasswordField; + advisoryFeedback?: FieldFeedback; }) { const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); const [focused, setFocused] = useState(false); const { feedback } = form.fields[name]; - const feedbackType = feedback?.type === 'info' && !focused ? 'error' : feedback?.type; + const message = feedback?.type === 'error' ? feedback : advisoryFeedback; + const feedbackType = message?.type === 'info' && !focused ? 'error' : message?.type; const { ref, ...control } = form.register(name); const mergedRef = useMergeRefs([ref, inputRef]); @@ -193,7 +199,7 @@ function PasswordField({ - + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 19cd00c0e2b..a89862fc1dd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -63,6 +63,7 @@ function PasswordEditor({ model }: { model: Extract Promise, initial: Props) { + return renderHook(({ value, enabled }: Props) => useDebouncedAsync(value, run, { delayMs: DELAY, enabled }), { + initialProps: initial, + }); +} + +describe('useDebouncedAsync', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs only the latest value once it stops changing', async () => { + const run = vi.fn((value: string) => Promise.resolve(`checked ${value}`)); + const { result, rerender } = setup(run, { value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY - 1)); + rerender({ value: 'ab' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY - 1)); + expect(run).not.toHaveBeenCalled(); + expect(result.current.isPending).toBe(true); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(run).toHaveBeenCalledExactlyOnceWith('ab', { signal: expect.any(AbortSignal) }); + expect(result.current).toEqual({ data: 'checked ab', error: undefined, isPending: false }); + }); + + it('keeps the last result while the next value is checked', async () => { + const run = vi.fn((value: string) => Promise.resolve(`checked ${value}`)); + const { result, rerender } = setup(run, { value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + rerender({ value: 'b' }); + expect(result.current).toEqual({ data: 'checked a', error: undefined, isPending: true }); + }); + + it('aborts and ignores a check that is overtaken by a new value', async () => { + const first = deferred(); + const signals: AbortSignal[] = []; + const run = vi.fn((value: string, { signal }: { signal: AbortSignal }) => { + signals.push(signal); + return value === 'a' ? first.promise : Promise.resolve('checked b'); + }); + const { result, rerender } = setup(run, { value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + rerender({ value: 'b' }); + expect(signals[0]?.aborted).toBe(true); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + await act(async () => { + first.resolve('checked a'); + await first.promise; + }); + expect(result.current.data).toBe('checked b'); + }); + + it.each([ + ['rejects', () => Promise.reject(new Error('down'))], + [ + 'throws', + () => { + throw new Error('down'); + }, + ], + ])('reports an error when the check %s', async (_, run) => { + const { result } = setup(run, { value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + expect(result.current).toEqual({ data: undefined, error: new Error('down'), isPending: false }); + }); + + it('clears the result and cancels pending work when disabled', async () => { + const run = vi.fn((value: string) => Promise.resolve(`checked ${value}`)); + const { result, rerender } = setup(run, { value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + rerender({ value: 'b', enabled: false }); + expect(result.current).toEqual({ data: undefined, error: undefined, isPending: false }); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('does not restart when the check function changes identity', async () => { + const calls: string[] = []; + const { rerender } = renderHook( + ({ value }: Props) => + useDebouncedAsync( + value, + v => { + calls.push(v); + return Promise.resolve(v); + }, + { delayMs: DELAY }, + ), + { initialProps: { value: 'a' } }, + ); + await act(() => vi.advanceTimersByTimeAsync(DELAY - 1)); + rerender({ value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(calls).toEqual(['a']); + }); + + it('aborts a running check on unmount', async () => { + const signals: AbortSignal[] = []; + const run = (_: string, { signal }: { signal: AbortSignal }) => { + signals.push(signal); + return new Promise(() => {}); + }; + const { unmount } = setup(run, { value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + unmount(); + expect(signals[0]?.aborted).toBe(true); + }); +}); diff --git a/packages/mosaic/src/hooks/use-debounced-async.ts b/packages/mosaic/src/hooks/use-debounced-async.ts new file mode 100644 index 00000000000..07924891a60 --- /dev/null +++ b/packages/mosaic/src/hooks/use-debounced-async.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface DebouncedAsyncOptions { + /** Wait this long after the last change before running. */ + delayMs: number; + /** When `false`, clears the result and cancels any waiting or running check. Defaults to `true`. */ + enabled?: boolean; +} + +export interface DebouncedAsyncResult { + data: TData | undefined; + error: unknown; + isPending: boolean; +} + +interface Settled { + value: TValue; + data: TData | undefined; + error: unknown; +} + +/** + * Runs `run(value)` once `value` has stopped changing for `delayMs`, and returns the latest result. + * The previous result stays visible while the next one is pending. A new value, disabling or + * unmounting aborts the `signal` and drops any result that arrives afterwards. + * + * @example + * const strength = useDebouncedAsync(password, (value, { signal }) => checkStrength(value, { signal }), { + * delayMs: 300, + * enabled: password !== '', + * }); + */ +export function useDebouncedAsync( + value: TValue, + run: (value: TValue, options: { signal: AbortSignal }) => Promise, + { delayMs, enabled = true }: DebouncedAsyncOptions, +): DebouncedAsyncResult { + const runRef = useRef(run); + const [settled, setSettled] = useState>(); + + useEffect(() => { + runRef.current = run; + }); + + useEffect(() => { + if (!enabled) { + setSettled(undefined); + return; + } + const controller = new AbortController(); + const settle = (result: Omit, 'value'>) => { + if (!controller.signal.aborted) { + setSettled({ value, ...result }); + } + }; + const timer = setTimeout(() => { + void Promise.resolve() + .then(() => runRef.current(value, { signal: controller.signal })) + .then( + data => settle({ data, error: undefined }), + (error: unknown) => settle({ data: undefined, error }), + ); + }, delayMs); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value, enabled, delayMs]); + + if (!enabled) { + return { data: undefined, error: undefined, isPending: false }; + } + return { + data: settled?.data, + error: settled?.error, + isPending: settled === undefined || !Object.is(settled.value, value), + }; +} From 771629fc5988b9a5f65663a109a26aa38315479a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 6 Oct 2026 09:39:35 -0400 Subject: [PATCH 3/5] fix(mosaic): tell a failed check apart from an empty result in useDebouncedAsync --- .../user-profile-edit-password.controller.ts | 5 +++-- .../hooks/__tests__/use-debounced-async.test.ts | 16 ++++++++++++---- packages/mosaic/src/hooks/use-debounced-async.ts | 9 ++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 49675d39e83..4404801d7dc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -72,8 +72,9 @@ export function useUserProfileEditPasswordController({ value => (validatePassword ? validatePassword(value) : Promise.resolve(undefined)), { delayMs: DEBOUNCE_MS, enabled: isOpen && (password !== '' || passwordLeft) && validatePassword !== undefined }, ); - const passwordFeedback: FieldFeedback | undefined = - strength.error === undefined ? strength.data : { type: 'error', message: validationError }; + const passwordFeedback: FieldFeedback | undefined = strength.isError + ? { type: 'error', message: validationError } + : strength.data; const onOpenChange = (open: boolean) => { if (form.isSubmitting) { diff --git a/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts b/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts index 5c2e0c8343c..6e583223d0f 100644 --- a/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts +++ b/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts @@ -35,7 +35,7 @@ describe('useDebouncedAsync', () => { expect(result.current.isPending).toBe(true); await act(() => vi.advanceTimersByTimeAsync(1)); expect(run).toHaveBeenCalledExactlyOnceWith('ab', { signal: expect.any(AbortSignal) }); - expect(result.current).toEqual({ data: 'checked ab', error: undefined, isPending: false }); + expect(result.current).toEqual({ data: 'checked ab', error: undefined, isError: false, isPending: false }); }); it('keeps the last result while the next value is checked', async () => { @@ -43,7 +43,7 @@ describe('useDebouncedAsync', () => { const { result, rerender } = setup(run, { value: 'a' }); await act(() => vi.advanceTimersByTimeAsync(DELAY)); rerender({ value: 'b' }); - expect(result.current).toEqual({ data: 'checked a', error: undefined, isPending: true }); + expect(result.current).toEqual({ data: 'checked a', error: undefined, isError: false, isPending: true }); }); it('aborts and ignores a check that is overtaken by a new value', async () => { @@ -76,7 +76,15 @@ describe('useDebouncedAsync', () => { ])('reports an error when the check %s', async (_, run) => { const { result } = setup(run, { value: 'a' }); await act(() => vi.advanceTimersByTimeAsync(DELAY)); - expect(result.current).toEqual({ data: undefined, error: new Error('down'), isPending: false }); + expect(result.current).toEqual({ data: undefined, error: new Error('down'), isError: true, isPending: false }); + }); + + it('reports a failure even when the check rejects without a reason', async () => { + const check = deferred(); + check.reject(); + const { result } = setup(() => check.promise, { value: 'a' }); + await act(() => vi.advanceTimersByTimeAsync(DELAY)); + expect(result.current.isError).toBe(true); }); it('clears the result and cancels pending work when disabled', async () => { @@ -84,7 +92,7 @@ describe('useDebouncedAsync', () => { const { result, rerender } = setup(run, { value: 'a' }); await act(() => vi.advanceTimersByTimeAsync(DELAY)); rerender({ value: 'b', enabled: false }); - expect(result.current).toEqual({ data: undefined, error: undefined, isPending: false }); + expect(result.current).toEqual({ data: undefined, error: undefined, isError: false, isPending: false }); await act(() => vi.advanceTimersByTimeAsync(DELAY)); expect(run).toHaveBeenCalledTimes(1); }); diff --git a/packages/mosaic/src/hooks/use-debounced-async.ts b/packages/mosaic/src/hooks/use-debounced-async.ts index 07924891a60..cfebc3ddd57 100644 --- a/packages/mosaic/src/hooks/use-debounced-async.ts +++ b/packages/mosaic/src/hooks/use-debounced-async.ts @@ -10,6 +10,7 @@ export interface DebouncedAsyncOptions { export interface DebouncedAsyncResult { data: TData | undefined; error: unknown; + isError: boolean; isPending: boolean; } @@ -17,6 +18,7 @@ interface Settled { value: TValue; data: TData | undefined; error: unknown; + isError: boolean; } /** @@ -57,8 +59,8 @@ export function useDebouncedAsync( void Promise.resolve() .then(() => runRef.current(value, { signal: controller.signal })) .then( - data => settle({ data, error: undefined }), - (error: unknown) => settle({ data: undefined, error }), + data => settle({ data, error: undefined, isError: false }), + (error: unknown) => settle({ data: undefined, error, isError: true }), ); }, delayMs); return () => { @@ -68,11 +70,12 @@ export function useDebouncedAsync( }, [value, enabled, delayMs]); if (!enabled) { - return { data: undefined, error: undefined, isPending: false }; + return { data: undefined, error: undefined, isError: false, isPending: false }; } return { data: settled?.data, error: settled?.error, + isError: settled?.isError === true, isPending: settled === undefined || !Object.is(settled.value, value), }; } From 8164c303f74c5a35d97dbc7f2c957ecaf38ea871 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 6 Oct 2026 09:40:55 -0400 Subject: [PATCH 4/5] docs(mosaic): note that useDebouncedAsync compares value by reference --- packages/mosaic/src/hooks/use-debounced-async.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/mosaic/src/hooks/use-debounced-async.ts b/packages/mosaic/src/hooks/use-debounced-async.ts index cfebc3ddd57..5a9f2b38f7e 100644 --- a/packages/mosaic/src/hooks/use-debounced-async.ts +++ b/packages/mosaic/src/hooks/use-debounced-async.ts @@ -26,6 +26,10 @@ interface Settled { * The previous result stays visible while the next one is pending. A new value, disabling or * unmounting aborts the `signal` and drops any result that arrives afterwards. * + * `value` is compared with `Object.is`, so it must be referentially stable across renders. An + * object or array created during render (such as an un-memoized `[]` or `{}`) counts as a new + * value every render and restarts the debounce. Pass a primitive or memoize it. + * * @example * const strength = useDebouncedAsync(password, (value, { signal }) => checkStrength(value, { signal }), { * delayMs: 300, From 7ebf853190d7b9b7bb48a78dae49f44667cf7c12 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 6 Oct 2026 09:52:45 -0400 Subject: [PATCH 5/5] test(mosaic): reject inside the check so the rejection is handled --- .../src/hooks/__tests__/use-debounced-async.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts b/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts index 6e583223d0f..fc4464cf883 100644 --- a/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts +++ b/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts @@ -80,9 +80,14 @@ describe('useDebouncedAsync', () => { }); it('reports a failure even when the check rejects without a reason', async () => { - const check = deferred(); - check.reject(); - const { result } = setup(() => check.promise, { value: 'a' }); + const { result } = setup( + () => { + const check = deferred(); + check.reject(); + return check.promise; + }, + { value: 'a' }, + ); await act(() => vi.advanceTimersByTimeAsync(DELAY)); expect(result.current.isError).toBe(true); });