Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/free-rats-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

feat: added `useDebounce.raw` for use inside `$derived`
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script lang="ts">
import { useDebounce } from "./use-debounce.svelte.js";

export const debounced = useDebounce(() => {}, 100);
debounced();
</script>

<div>pending={debounced.pending}</div>
166 changes: 116 additions & 50 deletions packages/runed/src/lib/utilities/use-debounce/use-debounce.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { MaybeGetter } from "$lib/internal/types.js";
import { extract } from "../extract/extract.svelte.js";

type UseDebounceReturn<Args extends unknown[], Return> = ((
type UseDebounceReturn<Args extends unknown[], Return, IncludePending extends boolean = false> = ((
this: unknown,
...args: Args
) => Promise<Return>) & {
cancel: () => void;
runScheduledNow: () => Promise<void>;
pending: boolean;
};
} & (IncludePending extends true ? { readonly pending: boolean } : {});

type DebounceContext<Return> = {
timeout: ReturnType<typeof setTimeout> | null;
Expand All @@ -18,36 +17,28 @@ type DebounceContext<Return> = {
promise: Promise<Return>;
};

/**
* Function that takes a callback, and returns a debounced version of it.
* When calling the debounced function, it will wait for the specified time
* before calling the original callback. If the debounced function is called
* again before the time has passed, the timer will be reset.
*
* You can await the debounced function to get the value when it is eventually
* called.
*
* The second parameter is the time to wait before calling the original callback.
* Alternatively, it can also be a getter function that returns the time to wait.
*
* @see {@link https://runed.dev/docs/utilities/use-debounce}
*
* @param callback The callback to call when the time has passed.
* @param wait The length of time to wait in ms, defaults to 250.
*/
export function useDebounce<Args extends unknown[], Return>(
function useDebounceInternal<
Args extends unknown[],
Return,
IncludePending extends boolean = false,
>(
callback: (...args: Args) => Return,
wait?: MaybeGetter<number | undefined>
): UseDebounceReturn<Args, Return> {
let context = $state<DebounceContext<Return> | null>(null);
const wait$ = $derived(extract(wait, 250));
options: {
/** Must be getter/setter pair to ensure reactivity */
context: DebounceContext<Return> | null;
/** Whether to include the reactive `pending` property */
includePending: IncludePending;
wait?: MaybeGetter<number | undefined>;
}
): UseDebounceReturn<Args, Return, IncludePending> {
const wait$ = $derived(extract(options.wait, 250));

function debounced(this: unknown, ...args: Args) {
if (context) {
if (options.context) {
// Old context will be reused so callers awaiting the promise will get the
// new value
if (context.timeout) {
clearTimeout(context.timeout);
if (options.context.timeout) {
clearTimeout(options.context.timeout);
}
} else {
// No old context, create a new one
Expand All @@ -58,7 +49,7 @@ export function useDebounce<Args extends unknown[], Return>(
reject = rej;
});

context = {
options.context = {
timeout: null,
runner: null,
promise,
Expand All @@ -67,12 +58,12 @@ export function useDebounce<Args extends unknown[], Return>(
};
}

context.runner = async () => {
options.context.runner = async () => {
// Grab the context and reset it
// -> new debounced calls will create a new context
if (!context) return;
const ctx = context;
context = null;
if (!options.context) return;
const ctx = options.context;
options.context = null;

try {
ctx.resolve(await callback.apply(this, args));
Expand All @@ -81,42 +72,117 @@ export function useDebounce<Args extends unknown[], Return>(
}
};

context.timeout = setTimeout(context.runner, wait$);
options.context.timeout = setTimeout(options.context.runner, wait$);

return context.promise;
return options.context.promise;
}

debounced.cancel = async () => {
if (!context || context.timeout === null) {
if (!options.context || options.context.timeout === null) {
// Wait one event loop to see if something triggered the debounced function
await new Promise((resolve) => setTimeout(resolve, 0));
if (!context || context.timeout === null) return;
if (!options.context || options.context.timeout === null) return;
}

clearTimeout(context.timeout);
context.reject("Cancelled");
context = null;
clearTimeout(options.context.timeout);
options.context.reject("Cancelled");
options.context = null;
};

debounced.runScheduledNow = async () => {
if (!context || !context.timeout) {
if (!options.context || !options.context.timeout) {
// Wait one event loop to see if something triggered the debounced function
await new Promise((resolve) => setTimeout(resolve, 0));
if (!context || !context.timeout) return;
if (!options.context || !options.context.timeout) return;
}

clearTimeout(context.timeout);
context.timeout = null;
clearTimeout(options.context.timeout);
options.context.timeout = null;

await context.runner?.();
await options.context.runner?.();
};

Object.defineProperty(debounced, "pending", {
enumerable: true,
get() {
return !!context?.timeout;
if (options.includePending) {
Object.defineProperty(debounced, "pending", {
enumerable: true,
get() {
return !!options.context?.timeout;
},
});
}

return debounced as unknown as UseDebounceReturn<Args, Return, IncludePending>;
}

/**
* Non-reactive version of {@link useDebounce}.
*
* This is safe to be used inside `$derived`, but lacks the reactive `pending` property.
*
* @example
* ```ts
* const debounced = useDebounce.raw(() => {}, 100);
* const value = $derived(debounced());
* ```
*
* @see {@link https://runed.dev/docs/utilities/use-debounce}
*
* @param callback The callback to call when the time has passed.
* @param wait The length of time to wait in ms, defaults to 250.
*/
function useDebounceRaw<Args extends unknown[], Return>(
callback: (...args: Args) => Return,
wait?: MaybeGetter<number | undefined>
): UseDebounceReturn<Args, Return, false> {
let context: DebounceContext<Return> | null = null;

return useDebounceInternal(callback, {
get context() {
return context;
},
set context(value) {
context = value;
},
wait,
includePending: false,
});
}

function useDebounce_<Args extends unknown[], Return>(
callback: (...args: Args) => Return,
wait?: MaybeGetter<number | undefined>
): UseDebounceReturn<Args, Return, true> {
let context = $state<DebounceContext<Return> | null>(null);

return debounced as unknown as UseDebounceReturn<Args, Return>;
return useDebounceInternal(callback, {
get context() {
return context;
},
set context(value) {
context = value;
},
wait,
includePending: true,
});
}

/**
* Function that takes a callback, and returns a debounced version of it.
* When calling the debounced function, it will wait for the specified time
* before calling the original callback. If the debounced function is called
* again before the time has passed, the timer will be reset.
*
* You can await the debounced function to get the value when it is eventually
* called.
*
* The second parameter is the time to wait before calling the original callback.
* Alternatively, it can also be a getter function that returns the time to wait.
*
* @see {@link https://runed.dev/docs/utilities/use-debounce}
*
* @param callback The callback to call when the time has passed.
* @param wait The length of time to wait in ms, defaults to 250.
*/
export const useDebounce = Object.assign(useDebounce_, {
raw: useDebounceRaw,
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, vi } from "vitest";
import { useDebounce } from "./use-debounce.svelte.js";
import { testWithEffect } from "$lib/test/util.svelte.js";
import { render } from "@testing-library/svelte";
import UseDebounceReactivityTest from "./use-debounce-reactivity.test.svelte";

describe("useDebounce", () => {
testWithEffect("Function does not get called immediately", async () => {
Expand Down Expand Up @@ -124,4 +126,33 @@ describe("useDebounce", () => {
debounced().catch(() => {});
await debounced.runScheduledNow();
});

testWithEffect("Is reactive", async () => {
const instance = render(UseDebounceReactivityTest);

expect(instance.component.debounced.pending).toBeTruthy();
expect(instance.queryByText("pending=true")).toBeInTheDocument();

await new Promise((resolve) => setTimeout(resolve, 200));

expect(instance.component.debounced.pending).toBeFalsy();
expect(instance.queryByText("pending=true")).not.toBeInTheDocument();
expect(instance.queryByText("pending=false")).toBeInTheDocument();

instance.unmount();
});

testWithEffect("raw is safe to use inside $derived", async () => {
const debounced = useDebounce.raw(() => {}, 100);

{
const value = $derived(debounced());
await value;
}

{
const value = $derived.by(() => debounced());
await value;
}
});
});
14 changes: 14 additions & 0 deletions sites/docs/src/content/utilities/use-debounce.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,17 @@ after a specified duration of inactivity.
<button onclick={logCount.cancel} disabled={!logCount.pending}>Cancel message</button>
<p>{logged || "Press the button!"}</p>
```

---

If you need to use this utility inside `$derived`, you can use `useDebounce.raw` instead. This has
the downside of not having reactive properties like `pending`.

```svelte
<script lang="ts">
import { useDebounce } from "runed";

const debounced = useDebounce.raw(() => {}, 100);
const value = $derived(debounced());
</script>
```