Skip to content

Commit 3413ff0

Browse files
refactor(mosaic): extract the debounced password check into useDebouncedAsync (#10065)
1 parent 31ecb38 commit 3413ff0

4 files changed

Lines changed: 234 additions & 31 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts‎

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { DEBOUNCE_MS } from '@clerk/shared/internal/clerk-js/constants';
2-
import { useEffect, useState } from 'react';
2+
import { useState } from 'react';
33

44
import type { UseFormResult } from '../../../components/form';
55
import { useForm } from '../../../components/form';
66
import type { FieldFeedback } from '../../../components/form/form-submit-error';
7+
import { useDebouncedAsync } from '../../../hooks/use-debounced-async';
78
import { useMessages } from '../../../localization';
89
import type {
910
UserProfileEditPasswordValue,
@@ -38,7 +39,6 @@ export function useUserProfileEditPasswordController({
3839
const validationError = useMessages('errors').generic;
3940
const m = useMessages('userProfilePasswordSection');
4041
const [isOpen, setIsOpen] = useState(false);
41-
const [passwordFeedback, setPasswordFeedback] = useState<FieldFeedback>();
4242

4343
const form = useForm({
4444
initialValues,
@@ -66,35 +66,15 @@ export function useUserProfileEditPasswordController({
6666

6767
const password = form.values.newPassword;
6868
const passwordLeft = form.fields.newPassword.touched;
69-
useEffect(() => {
70-
// 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
71-
if (!isOpen || (password === '' && !passwordLeft) || !validatePassword) {
72-
setPasswordFeedback(undefined);
73-
return;
74-
}
75-
76-
let active = true;
77-
const timeout = setTimeout(() => {
78-
void Promise.resolve()
79-
.then(() => validatePassword(password))
80-
.then(
81-
feedback => {
82-
if (active) {
83-
setPasswordFeedback(feedback);
84-
}
85-
},
86-
() => {
87-
if (active) {
88-
setPasswordFeedback({ type: 'error', message: validationError });
89-
}
90-
},
91-
);
92-
}, DEBOUNCE_MS);
93-
return () => {
94-
active = false;
95-
clearTimeout(timeout);
96-
};
97-
}, [isOpen, password, passwordLeft, validatePassword, validationError]);
69+
// 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
70+
const strength = useDebouncedAsync(
71+
password,
72+
value => (validatePassword ? validatePassword(value) : Promise.resolve(undefined)),
73+
{ delayMs: DEBOUNCE_MS, enabled: isOpen && (password !== '' || passwordLeft) && validatePassword !== undefined },
74+
);
75+
const passwordFeedback: FieldFeedback | undefined = strength.isError
76+
? { type: 'error', message: validationError }
77+
: strength.data;
9878

9979
const onOpenChange = (open: boolean) => {
10080
if (form.isSubmitting) {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { deferred } from '../../__tests__/async';
5+
import { useDebouncedAsync } from '../use-debounced-async';
6+
7+
const DELAY = 300;
8+
9+
interface Props {
10+
value: string;
11+
enabled?: boolean;
12+
}
13+
14+
function setup(run: (value: string, options: { signal: AbortSignal }) => Promise<string>, initial: Props) {
15+
return renderHook(({ value, enabled }: Props) => useDebouncedAsync(value, run, { delayMs: DELAY, enabled }), {
16+
initialProps: initial,
17+
});
18+
}
19+
20+
describe('useDebouncedAsync', () => {
21+
beforeEach(() => {
22+
vi.useFakeTimers();
23+
});
24+
afterEach(() => {
25+
vi.useRealTimers();
26+
});
27+
28+
it('runs only the latest value once it stops changing', async () => {
29+
const run = vi.fn((value: string) => Promise.resolve(`checked ${value}`));
30+
const { result, rerender } = setup(run, { value: 'a' });
31+
await act(() => vi.advanceTimersByTimeAsync(DELAY - 1));
32+
rerender({ value: 'ab' });
33+
await act(() => vi.advanceTimersByTimeAsync(DELAY - 1));
34+
expect(run).not.toHaveBeenCalled();
35+
expect(result.current.isPending).toBe(true);
36+
await act(() => vi.advanceTimersByTimeAsync(1));
37+
expect(run).toHaveBeenCalledExactlyOnceWith('ab', { signal: expect.any(AbortSignal) });
38+
expect(result.current).toEqual({ data: 'checked ab', error: undefined, isError: false, isPending: false });
39+
});
40+
41+
it('keeps the last result while the next value is checked', async () => {
42+
const run = vi.fn((value: string) => Promise.resolve(`checked ${value}`));
43+
const { result, rerender } = setup(run, { value: 'a' });
44+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
45+
rerender({ value: 'b' });
46+
expect(result.current).toEqual({ data: 'checked a', error: undefined, isError: false, isPending: true });
47+
});
48+
49+
it('aborts and ignores a check that is overtaken by a new value', async () => {
50+
const first = deferred<string>();
51+
const signals: AbortSignal[] = [];
52+
const run = vi.fn((value: string, { signal }: { signal: AbortSignal }) => {
53+
signals.push(signal);
54+
return value === 'a' ? first.promise : Promise.resolve('checked b');
55+
});
56+
const { result, rerender } = setup(run, { value: 'a' });
57+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
58+
rerender({ value: 'b' });
59+
expect(signals[0]?.aborted).toBe(true);
60+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
61+
await act(async () => {
62+
first.resolve('checked a');
63+
await first.promise;
64+
});
65+
expect(result.current.data).toBe('checked b');
66+
});
67+
68+
it.each([
69+
['rejects', () => Promise.reject(new Error('down'))],
70+
[
71+
'throws',
72+
() => {
73+
throw new Error('down');
74+
},
75+
],
76+
])('reports an error when the check %s', async (_, run) => {
77+
const { result } = setup(run, { value: 'a' });
78+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
79+
expect(result.current).toEqual({ data: undefined, error: new Error('down'), isError: true, isPending: false });
80+
});
81+
82+
it('reports a failure even when the check rejects without a reason', async () => {
83+
const { result } = setup(
84+
() => {
85+
const check = deferred<string>();
86+
check.reject();
87+
return check.promise;
88+
},
89+
{ value: 'a' },
90+
);
91+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
92+
expect(result.current.isError).toBe(true);
93+
});
94+
95+
it('clears the result and cancels pending work when disabled', async () => {
96+
const run = vi.fn((value: string) => Promise.resolve(`checked ${value}`));
97+
const { result, rerender } = setup(run, { value: 'a' });
98+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
99+
rerender({ value: 'b', enabled: false });
100+
expect(result.current).toEqual({ data: undefined, error: undefined, isError: false, isPending: false });
101+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
102+
expect(run).toHaveBeenCalledTimes(1);
103+
});
104+
105+
it('does not restart when the check function changes identity', async () => {
106+
const calls: string[] = [];
107+
const { rerender } = renderHook(
108+
({ value }: Props) =>
109+
useDebouncedAsync(
110+
value,
111+
v => {
112+
calls.push(v);
113+
return Promise.resolve(v);
114+
},
115+
{ delayMs: DELAY },
116+
),
117+
{ initialProps: { value: 'a' } },
118+
);
119+
await act(() => vi.advanceTimersByTimeAsync(DELAY - 1));
120+
rerender({ value: 'a' });
121+
await act(() => vi.advanceTimersByTimeAsync(1));
122+
expect(calls).toEqual(['a']);
123+
});
124+
125+
it('aborts a running check on unmount', async () => {
126+
const signals: AbortSignal[] = [];
127+
const run = (_: string, { signal }: { signal: AbortSignal }) => {
128+
signals.push(signal);
129+
return new Promise<string>(() => {});
130+
};
131+
const { unmount } = setup(run, { value: 'a' });
132+
await act(() => vi.advanceTimersByTimeAsync(DELAY));
133+
unmount();
134+
expect(signals[0]?.aborted).toBe(true);
135+
});
136+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
export interface DebouncedAsyncOptions {
4+
/** Wait this long after the last change before running. */
5+
delayMs: number;
6+
/** When `false`, clears the result and cancels any waiting or running check. Defaults to `true`. */
7+
enabled?: boolean;
8+
}
9+
10+
export interface DebouncedAsyncResult<TData> {
11+
data: TData | undefined;
12+
error: unknown;
13+
isError: boolean;
14+
isPending: boolean;
15+
}
16+
17+
interface Settled<TValue, TData> {
18+
value: TValue;
19+
data: TData | undefined;
20+
error: unknown;
21+
isError: boolean;
22+
}
23+
24+
/**
25+
* Runs `run(value)` once `value` has stopped changing for `delayMs`, and returns the latest result.
26+
* The previous result stays visible while the next one is pending. A new value, disabling or
27+
* unmounting aborts the `signal` and drops any result that arrives afterwards.
28+
*
29+
* `value` is compared with `Object.is`, so it must be referentially stable across renders. An
30+
* object or array created during render (such as an un-memoized `[]` or `{}`) counts as a new
31+
* value every render and restarts the debounce. Pass a primitive or memoize it.
32+
*
33+
* @example
34+
* const strength = useDebouncedAsync(password, (value, { signal }) => checkStrength(value, { signal }), {
35+
* delayMs: 300,
36+
* enabled: password !== '',
37+
* });
38+
*/
39+
export function useDebouncedAsync<TValue, TData>(
40+
value: TValue,
41+
run: (value: TValue, options: { signal: AbortSignal }) => Promise<TData>,
42+
{ delayMs, enabled = true }: DebouncedAsyncOptions,
43+
): DebouncedAsyncResult<TData> {
44+
const runRef = useRef(run);
45+
const [settled, setSettled] = useState<Settled<TValue, TData>>();
46+
47+
useEffect(() => {
48+
runRef.current = run;
49+
});
50+
51+
useEffect(() => {
52+
if (!enabled) {
53+
setSettled(undefined);
54+
return;
55+
}
56+
const controller = new AbortController();
57+
const settle = (result: Omit<Settled<TValue, TData>, 'value'>) => {
58+
if (!controller.signal.aborted) {
59+
setSettled({ value, ...result });
60+
}
61+
};
62+
const timer = setTimeout(() => {
63+
void Promise.resolve()
64+
.then(() => runRef.current(value, { signal: controller.signal }))
65+
.then(
66+
data => settle({ data, error: undefined, isError: false }),
67+
(error: unknown) => settle({ data: undefined, error, isError: true }),
68+
);
69+
}, delayMs);
70+
return () => {
71+
clearTimeout(timer);
72+
controller.abort();
73+
};
74+
}, [value, enabled, delayMs]);
75+
76+
if (!enabled) {
77+
return { data: undefined, error: undefined, isError: false, isPending: false };
78+
}
79+
return {
80+
data: settled?.data,
81+
error: settled?.error,
82+
isError: settled?.isError === true,
83+
isPending: settled === undefined || !Object.is(settled.value, value),
84+
};
85+
}

0 commit comments

Comments
 (0)