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/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..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 @@ -1,9 +1,10 @@ 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'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; +import { useDebouncedAsync } from '../../../hooks/use-debounced-async'; import { useMessages } from '../../../localization'; import type { UserProfileEditPasswordValue, @@ -38,7 +39,6 @@ export function useUserProfileEditPasswordController({ const validationError = useMessages('errors').generic; const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); - const [passwordFeedback, setPasswordFeedback] = useState(); const form = useForm({ initialValues, @@ -66,35 +66,15 @@ 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]); + // 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.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 new file mode 100644 index 00000000000..fc4464cf883 --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-debounced-async.test.ts @@ -0,0 +1,136 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../__tests__/async'; +import { useDebouncedAsync } from '../use-debounced-async'; + +const DELAY = 300; + +interface Props { + value: string; + enabled?: boolean; +} + +function setup(run: (value: string, options: { signal: AbortSignal }) => 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, isError: false, 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, isError: false, 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'), isError: true, isPending: false }); + }); + + it('reports a failure even when the check rejects without a reason', async () => { + const { result } = setup( + () => { + const check = deferred(); + check.reject(); + return 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 () => { + 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, isError: false, 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..5a9f2b38f7e --- /dev/null +++ b/packages/mosaic/src/hooks/use-debounced-async.ts @@ -0,0 +1,85 @@ +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; + isError: boolean; + isPending: boolean; +} + +interface Settled { + value: TValue; + data: TData | undefined; + error: unknown; + isError: boolean; +} + +/** + * 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. + * + * `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, + * 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, isError: false }), + (error: unknown) => settle({ data: undefined, error, isError: true }), + ); + }, delayMs); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value, enabled, delayMs]); + + if (!enabled) { + 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), + }; +}