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 + +```