diff --git a/.changeset/free-rats-run.md b/.changeset/free-rats-run.md
new file mode 100644
index 00000000..493037a5
--- /dev/null
+++ b/.changeset/free-rats-run.md
@@ -0,0 +1,5 @@
+---
+"runed": minor
+---
+
+feat: added `useDebounce.raw` for use inside `$derived`
diff --git a/packages/runed/src/lib/utilities/use-debounce/use-debounce-reactivity.test.svelte b/packages/runed/src/lib/utilities/use-debounce/use-debounce-reactivity.test.svelte
new file mode 100644
index 00000000..8ca271cd
--- /dev/null
+++ b/packages/runed/src/lib/utilities/use-debounce/use-debounce-reactivity.test.svelte
@@ -0,0 +1,8 @@
+
+
+
pending={debounced.pending}
diff --git a/packages/runed/src/lib/utilities/use-debounce/use-debounce.svelte.ts b/packages/runed/src/lib/utilities/use-debounce/use-debounce.svelte.ts
index eaa7afed..3d1dc182 100644
--- a/packages/runed/src/lib/utilities/use-debounce/use-debounce.svelte.ts
+++ b/packages/runed/src/lib/utilities/use-debounce/use-debounce.svelte.ts
@@ -1,14 +1,13 @@
import type { MaybeGetter } from "$lib/internal/types.js";
import { extract } from "../extract/extract.svelte.js";
-type UseDebounceReturn = ((
+type UseDebounceReturn = ((
this: unknown,
...args: Args
) => Promise) & {
cancel: () => void;
runScheduledNow: () => Promise;
- pending: boolean;
-};
+} & (IncludePending extends true ? { readonly pending: boolean } : {});
type DebounceContext = {
timeout: ReturnType | null;
@@ -18,36 +17,28 @@ type DebounceContext = {
promise: Promise;
};
-/**
- * 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(
+function useDebounceInternal<
+ Args extends unknown[],
+ Return,
+ IncludePending extends boolean = false,
+>(
callback: (...args: Args) => Return,
- wait?: MaybeGetter
-): UseDebounceReturn {
- let context = $state | null>(null);
- const wait$ = $derived(extract(wait, 250));
+ options: {
+ /** Must be getter/setter pair to ensure reactivity */
+ context: DebounceContext | null;
+ /** Whether to include the reactive `pending` property */
+ includePending: IncludePending;
+ wait?: MaybeGetter;
+ }
+): UseDebounceReturn {
+ 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
@@ -58,7 +49,7 @@ export function useDebounce(
reject = rej;
});
- context = {
+ options.context = {
timeout: null,
runner: null,
promise,
@@ -67,12 +58,12 @@ export function useDebounce(
};
}
- 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));
@@ -81,42 +72,117 @@ export function useDebounce(
}
};
- 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;
+}
+
+/**
+ * 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(
+ callback: (...args: Args) => Return,
+ wait?: MaybeGetter
+): UseDebounceReturn {
+ let context: DebounceContext | null = null;
+
+ return useDebounceInternal(callback, {
+ get context() {
+ return context;
+ },
+ set context(value) {
+ context = value;
},
+ wait,
+ includePending: false,
});
+}
+
+function useDebounce_(
+ callback: (...args: Args) => Return,
+ wait?: MaybeGetter
+): UseDebounceReturn {
+ let context = $state | null>(null);
- return debounced as unknown as UseDebounceReturn;
+ 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,
+});
diff --git a/packages/runed/src/lib/utilities/use-debounce/use-debounce.test.svelte.ts b/packages/runed/src/lib/utilities/use-debounce/use-debounce.test.svelte.ts
index d9619b4a..fc1e03ae 100644
--- a/packages/runed/src/lib/utilities/use-debounce/use-debounce.test.svelte.ts
+++ b/packages/runed/src/lib/utilities/use-debounce/use-debounce.test.svelte.ts
@@ -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 () => {
@@ -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;
+ }
+ });
});
diff --git a/sites/docs/src/content/utilities/use-debounce.md b/sites/docs/src/content/utilities/use-debounce.md
index 91af71d8..832c1b74 100644
--- a/sites/docs/src/content/utilities/use-debounce.md
+++ b/sites/docs/src/content/utilities/use-debounce.md
@@ -52,3 +52,17 @@ after a specified duration of inactivity.
{logged || "Press the button!"}
```
+
+---
+
+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
+
+```