Skip to content

Commit b86361c

Browse files
committed
feat: Add debounce utility
1 parent bd1717a commit b86361c

7 files changed

Lines changed: 167 additions & 86 deletions

File tree

src/lib/components/input/PasswordInput.svelte

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { validatePassword } from '$lib/inputvalidation/passwordValidator';
88
import type { AnyComponent } from '$lib/types/AnyComponent';
99
import type { ValidationResult } from '$lib/types/ValidationResult';
10-
import type { TimeoutHandle } from '$lib/types/WAPI';
10+
import { debounce } from '$lib/utils/debounce';
1111
import type { Snippet } from 'svelte';
1212
import type { FullAutoFill } from 'svelte/elements';
1313
import PasswordStrengthMeter from './impl/PasswordStrengthMeter.svelte';
@@ -41,44 +41,32 @@
4141
}: Props = $props();
4242
4343
let validationResult = $state<ValidationResult | null>(null);
44-
let passwordDebounce: TimeoutHandle | undefined;
45-
function checkHIBP(str: string) {
46-
// Stop the previous debounce timer if it exists
47-
clearTimeout(passwordDebounce);
48-
49-
// Set the validation result to the checking availability state
50-
validationResult = { valid: false, message: 'Checking password...' };
51-
52-
// Start a new username request in 500ms
53-
passwordDebounce = setTimeout(async () => {
54-
// 500ms has passed, check if the password has been pwned
55-
try {
56-
// Make the API request
57-
const pwnedCount = await checkPwnedCount(str);
58-
59-
// Map the response to a validation result
60-
if (pwnedCount > 0) {
61-
// Password has been pwned, change the validation result
62-
validationResult = {
63-
valid: false,
64-
message: `Password detected in ${pwnedCount} data ${pwnedCount == 1 ? 'breach' : 'breaches'}`,
65-
link: {
66-
text: "What's this?",
67-
href: 'https://haveibeenpwned.com/Passwords',
68-
},
69-
};
70-
} else {
71-
// Password is ok, return the successful validation result from the basic validation step
72-
validationResult = { valid: true };
73-
}
74-
} catch (error) {
75-
// Show an error toast
76-
await handleApiError(error);
7744
78-
// We shouldnt block the user from submitting the form if the pwned password check fails
45+
const requestHIBP = debounce(async (str: string) => {
46+
try {
47+
const pwnedCount = await checkPwnedCount(str);
48+
if (pwnedCount > 0) {
49+
validationResult = {
50+
valid: false,
51+
message: `Password detected in ${pwnedCount} data ${pwnedCount == 1 ? 'breach' : 'breaches'}`,
52+
link: {
53+
text: "What's this?",
54+
href: 'https://haveibeenpwned.com/Passwords',
55+
},
56+
};
57+
} else {
7958
validationResult = { valid: true };
8059
}
81-
}, 500);
60+
} catch (error) {
61+
await handleApiError(error);
62+
// Don't block submit if the pwned check fails
63+
validationResult = { valid: true };
64+
}
65+
}, 500);
66+
67+
function checkHIBP(str: string) {
68+
validationResult = { valid: false, message: 'Checking password...' };
69+
requestHIBP(str);
8270
}
8371
8472
$effect(() => {

src/lib/components/input/UsernameInput.svelte

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
} from '$lib/inputvalidation/usernameValidator';
1212
import type { AnyComponent } from '$lib/types/AnyComponent';
1313
import type { ValidationResult } from '$lib/types/ValidationResult';
14-
import type { TimeoutHandle } from '$lib/types/WAPI';
14+
import { debounce } from '$lib/utils/debounce';
1515
import type { Snippet } from 'svelte';
1616
import type { FullAutoFill } from 'svelte/elements';
1717
@@ -39,51 +39,40 @@
3939
4040
let checkResponses = $state<Map<string, ValidationResult>>(new Map());
4141
let validationResult = $state<ValidationResult | null>(null);
42-
let usernameDebounce: TimeoutHandle | undefined;
43-
function checkUsernameAvailability(username: string) {
44-
// Stop the previous debounce timer if it exists
45-
clearTimeout(usernameDebounce);
4642
43+
const requestAvailability = debounce(async (username: string) => {
44+
try {
45+
const response = await accountV2Api.accountCheckUsername({ username });
46+
validationResult = mapUsernameCheckResponse(response);
47+
checkResponses.set(username, validationResult);
48+
} catch (error) {
49+
await handleApiError(error, (err) => {
50+
if (!isValidationError(err)) {
51+
validationResult = UsernameInternalServerErrorValRes;
52+
return false;
53+
}
54+
55+
const apiValRes = mapToValRes(err, 'Username');
56+
if (apiValRes !== null) {
57+
validationResult = apiValRes;
58+
return true;
59+
}
60+
61+
return false;
62+
});
63+
}
64+
}, 250);
65+
66+
function checkUsernameAvailability(username: string) {
4767
const entry = checkResponses.get(username);
4868
if (entry !== undefined) {
69+
requestAvailability.cancel();
4970
validationResult = entry;
50-
usernameDebounce = undefined;
5171
return;
5272
}
5373
54-
// Set the validation result to the checking availability state
5574
validationResult = UsernameCheckingAvailabilityValRes;
56-
57-
// Start a new username request in 250ms
58-
usernameDebounce = setTimeout(async () => {
59-
// 250ms has passed, check if the username is available
60-
try {
61-
// Make the API request
62-
const response = await accountV2Api.accountCheckUsername({ username });
63-
64-
// Map the response to a validation result
65-
validationResult = mapUsernameCheckResponse(response);
66-
67-
checkResponses.set(username, validationResult);
68-
} catch (error) {
69-
// Show an error toast
70-
await handleApiError(error, (err) => {
71-
if (!isValidationError(err)) {
72-
// Set the validation result to the internal server error state
73-
validationResult = UsernameInternalServerErrorValRes;
74-
return false;
75-
}
76-
77-
const apiValRes = mapToValRes(err, 'Username');
78-
if (apiValRes !== null) {
79-
validationResult = apiValRes;
80-
return true;
81-
}
82-
83-
return false;
84-
});
85-
}
86-
}, 250);
75+
requestAvailability(username);
8776
}
8877
8978
$effect(() => {

src/lib/utils/debounce.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { debounce } from './debounce';
3+
4+
describe('debounce', () => {
5+
beforeEach(() => vi.useFakeTimers());
6+
afterEach(() => vi.useRealTimers());
7+
8+
it('delays invocation until the timer elapses', () => {
9+
const fn = vi.fn();
10+
const d = debounce(fn, 100);
11+
12+
d('a');
13+
expect(fn).not.toHaveBeenCalled();
14+
vi.advanceTimersByTime(99);
15+
expect(fn).not.toHaveBeenCalled();
16+
vi.advanceTimersByTime(1);
17+
expect(fn).toHaveBeenCalledExactlyOnceWith('a');
18+
});
19+
20+
it('collapses rapid calls into one invocation with the latest args', () => {
21+
const fn = vi.fn();
22+
const d = debounce(fn, 100);
23+
24+
d('a');
25+
vi.advanceTimersByTime(50);
26+
d('b');
27+
vi.advanceTimersByTime(50);
28+
d('c');
29+
vi.advanceTimersByTime(100);
30+
31+
expect(fn).toHaveBeenCalledExactlyOnceWith('c');
32+
});
33+
34+
it('cancel() drops the pending invocation', () => {
35+
const fn = vi.fn();
36+
const d = debounce(fn, 100);
37+
38+
d('a');
39+
d.cancel();
40+
vi.advanceTimersByTime(200);
41+
42+
expect(fn).not.toHaveBeenCalled();
43+
});
44+
45+
it('flush() runs the pending invocation immediately', () => {
46+
const fn = vi.fn();
47+
const d = debounce(fn, 100);
48+
49+
d('a');
50+
d.flush();
51+
expect(fn).toHaveBeenCalledExactlyOnceWith('a');
52+
53+
vi.advanceTimersByTime(200);
54+
expect(fn).toHaveBeenCalledOnce();
55+
});
56+
57+
it('flush() is a no-op when no call is pending', () => {
58+
const fn = vi.fn();
59+
const d = debounce(fn, 100);
60+
61+
d.flush();
62+
expect(fn).not.toHaveBeenCalled();
63+
});
64+
});

src/lib/utils/debounce.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export interface Debounced<Args extends unknown[]> {
2+
(...args: Args): void;
3+
cancel(): void;
4+
flush(): void;
5+
}
6+
7+
export function debounce<Args extends unknown[]>(
8+
fn: (...args: Args) => void,
9+
delay: number
10+
): Debounced<Args> {
11+
let handle: ReturnType<typeof setTimeout> | undefined;
12+
let pending: Args | undefined;
13+
14+
const debounced = ((...args: Args) => {
15+
clearTimeout(handle);
16+
pending = args;
17+
handle = setTimeout(() => {
18+
handle = undefined;
19+
const p = pending;
20+
pending = undefined;
21+
if (p) fn(...p);
22+
}, delay);
23+
}) as Debounced<Args>;
24+
25+
debounced.cancel = () => {
26+
clearTimeout(handle);
27+
handle = undefined;
28+
pending = undefined;
29+
};
30+
31+
debounced.flush = () => {
32+
if (handle === undefined) return;
33+
clearTimeout(handle);
34+
handle = undefined;
35+
const p = pending;
36+
pending = undefined;
37+
if (p) fn(...p);
38+
};
39+
40+
return debounced;
41+
}

src/lib/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './debounce';
12
export * from './encoding';
23
export * from './entropy';
34
export * from './math';

src/routes/(app)/admin/blacklists/+page.svelte

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import { Separator } from '$lib/components/ui/separator';
1616
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
1717
import type { ValidationResult } from '$lib/types/ValidationResult';
18-
import type { TimeoutHandle } from '$lib/types/WAPI';
18+
import { debounce } from '$lib/utils/debounce';
1919
2020
registerBreadcrumbs(() => [{ label: 'Blacklists' }]);
2121
@@ -139,28 +139,28 @@
139139
}
140140
}
141141
142-
let usernameDebounce: TimeoutHandle | undefined;
142+
const debouncedLoadUsernames = debounce(loadUsernames, 400);
143143
$effect(() => {
144-
clearTimeout(usernameDebounce);
145144
if (usernameEntry.length == 0) {
145+
debouncedLoadUsernames.cancel();
146146
loadUsernames();
147147
return;
148148
}
149149
150-
usernameDebounce = setTimeout(() => loadUsernames(), 400);
150+
debouncedLoadUsernames();
151151
});
152152
153-
let emailDebounce: TimeoutHandle | undefined;
153+
const debouncedLoadEmails = debounce(loadEmails, 400);
154154
$effect(() => {
155-
clearTimeout(emailDebounce);
156155
if (emailEntry.length == 0) {
156+
debouncedLoadEmails.cancel();
157157
loadEmails();
158158
return;
159159
}
160160
161161
if (!emailEntryValid) return;
162162
163-
emailDebounce = setTimeout(() => loadEmails(), 400);
163+
debouncedLoadEmails();
164164
});
165165
</script>
166166

src/routes/(app)/admin/users/+page.svelte

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
import { CardHeader, CardTitle } from '$lib/components/ui/card';
8686
import { Input } from '$lib/components/ui/input';
8787
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
88-
import type { TimeoutHandle } from '$lib/types/WAPI';
88+
import { debounce } from '$lib/utils/debounce';
8989
9090
let isFetching = $state(false);
9191
@@ -117,10 +117,8 @@
117117
page = Math.floor(response.offset / response.limit) + 1;
118118
}
119119
120-
let searchDebounce: TimeoutHandle | undefined;
120+
const applyFilterQuery = debounce((query: string | undefined) => (filterQuery = query), 800);
121121
$effect(() => {
122-
clearTimeout(searchDebounce);
123-
124122
const queries: string[] = [];
125123
126124
const nameQ = createSearchQuery('name', nameSearch);
@@ -132,7 +130,7 @@
132130
const query = queries.length > 0 ? queries.join(' and ') : undefined;
133131
if (query === filterQuery) return;
134132
135-
searchDebounce = setTimeout(() => (filterQuery = query), 800);
133+
applyFilterQuery(query);
136134
});
137135
138136
$effect(() => {

0 commit comments

Comments
 (0)