From e5b11274ce3d785e41824a6bf6796f4d6bcbccfb Mon Sep 17 00:00:00 2001 From: David Peng Date: Tue, 11 Feb 2025 22:26:25 +0800 Subject: [PATCH] add implementation, test, and docs --- packages/runed/package.json | 1 + packages/runed/src/lib/utilities/index.ts | 1 + .../runed/src/lib/utilities/resource/index.ts | 1 + .../lib/utilities/resource/msw-handlers.ts | 46 ++ .../lib/utilities/resource/resource.svelte.ts | 430 ++++++++++++++++++ .../resource/resource.test.svelte.ts | 410 +++++++++++++++++ pnpm-lock.yaml | 331 +++++++++++++- sites/docs/src/content/utilities/resource.md | 247 ++++++++++ .../src/lib/components/demos/resource.svelte | 43 ++ .../src/routes/api/search.json/search.json | 2 +- 10 files changed, 1507 insertions(+), 5 deletions(-) create mode 100644 packages/runed/src/lib/utilities/resource/index.ts create mode 100644 packages/runed/src/lib/utilities/resource/msw-handlers.ts create mode 100644 packages/runed/src/lib/utilities/resource/resource.svelte.ts create mode 100644 packages/runed/src/lib/utilities/resource/resource.test.svelte.ts create mode 100644 sites/docs/src/content/utilities/resource.md create mode 100644 sites/docs/src/lib/components/demos/resource.svelte diff --git a/packages/runed/package.json b/packages/runed/package.json index 79cabcb9..f5d2d6aa 100644 --- a/packages/runed/package.json +++ b/packages/runed/package.json @@ -60,6 +60,7 @@ "@vitest/coverage-v8": "^1.5.1", "@vitest/ui": "^1.6.0", "jsdom": "^24.0.0", + "msw": "^2.7.0", "publint": "^0.1.9", "resize-observer-polyfill": "^1.5.1", "svelte": "^5.11.0", diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index 2b36e79d..e6678377 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -21,3 +21,4 @@ export * from "./persisted-state/index.js"; export * from "./use-geolocation/index.js"; export * from "./context/index.js"; export * from "./is-in-viewport/index.js"; +export * from "./resource/index.js"; \ No newline at end of file diff --git a/packages/runed/src/lib/utilities/resource/index.ts b/packages/runed/src/lib/utilities/resource/index.ts new file mode 100644 index 00000000..1d185c73 --- /dev/null +++ b/packages/runed/src/lib/utilities/resource/index.ts @@ -0,0 +1 @@ +export * from "./resource.svelte.js"; \ No newline at end of file diff --git a/packages/runed/src/lib/utilities/resource/msw-handlers.ts b/packages/runed/src/lib/utilities/resource/msw-handlers.ts new file mode 100644 index 00000000..5a6be9af --- /dev/null +++ b/packages/runed/src/lib/utilities/resource/msw-handlers.ts @@ -0,0 +1,46 @@ +import { http, delay, HttpResponse } from "msw"; + +export type ResponseData = { + id: number; + name: string; + email: string; +}; + +export type SearchResponseData = { + results: { id: number; title: string }[]; + page: number; + total: number; +}; + +export const handlers = [ + // Basic user endpoint + http.get("https://api.example.com/users/:id", async ({ params }) => { + await delay(50); + return HttpResponse.json({ + id: Number(params.id), + name: `User ${params.id}`, + email: `user${params.id}@example.com`, + }); + }), + + // Search endpoint with query params + http.get("https://api.example.com/search", ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get("q"); + const page = Number(url.searchParams.get("page")) || 1; + + return HttpResponse.json({ + results: [ + { id: page * 1, title: `Result 1 for ${query}` }, + { id: page * 2, title: `Result 2 for ${query}` }, + ], + page, + total: 10, + }); + }), + + // Endpoint that can fail + http.get("https://api.example.com/error-prone", () => { + return new HttpResponse(null, { status: 500 }); + }), +]; diff --git a/packages/runed/src/lib/utilities/resource/resource.svelte.ts b/packages/runed/src/lib/utilities/resource/resource.svelte.ts new file mode 100644 index 00000000..e3ba4598 --- /dev/null +++ b/packages/runed/src/lib/utilities/resource/resource.svelte.ts @@ -0,0 +1,430 @@ +import { watch } from "$lib/utilities/watch/index.js"; +import type { Getter } from "$lib/internal/types.js"; + +/** + * Configuration options for the resource function + */ +export type ResourceOptions = { + /** Skip initial fetch when true */ + lazy?: boolean; + /** Only fetch once when true */ + once?: boolean; + /** Initial value for the resource */ + initialValue?: Data; + /** Debounce time in milliseconds */ + debounce?: number; + /** Throttle time in milliseconds */ + throttle?: number; +}; + +/** + * Core state of a resource + */ +export type ResourceState = { + /** Current value of the resource */ + current: Data | undefined; + /** Whether the resource is currently loading */ + loading: boolean; + /** Error if the fetch failed */ + error: Error | undefined; +}; + +/** + * Return type of the resource function, extends ResourceState with additional methods + */ +export type ResourceReturn = ResourceState & { + /** Update the resource value directly */ + mutate: (value: Data) => void; + /** Re-run the fetcher with current values */ + refetch: (info?: RefetchInfo) => Promise; +}; + +export type ResourceFetcherRefetchInfo = { + /** Previous data return from fetcher */ + data: Data | undefined; + /** Whether the fetcher is currently refetching or it can be the value you passed to refetch. */ + refetching: RefetchInfo | boolean; + /** A cleanup function that will be called when the source is invalidated and the fetcher is about to re-run */ + onCleanup: (fn: () => void) => void; + /** AbortSignal for cancelling fetch requests */ + signal: AbortSignal; +}; + +export type ResourceFetcher = ( + /** Current value of the source */ + value: Source extends Array + ? { + [K in keyof Source]: Source[K]; + } + : Source, + /** Previous value of the source */ + previousValue: Source extends Array + ? { + [K in keyof Source]: Source[K]; + } + : Source | undefined, + info: ResourceFetcherRefetchInfo +) => Promise; + +// Helper functions for debounce and throttle +function debounce Promise>( + fn: T, + delay: number +): (...args: Parameters) => Promise>> { + let timeoutId: ReturnType; + let lastResolve: ((value: Awaited>) => void) | null = null; + + return (...args: Parameters) => { + return new Promise>>((resolve) => { + if (lastResolve) { + lastResolve(undefined as Awaited>); + } + lastResolve = resolve; + + clearTimeout(timeoutId); + timeoutId = setTimeout(async () => { + const result = await fn(...args); + if (lastResolve) { + lastResolve(result); + lastResolve = null; + } + }, delay); + }); + }; +} + +function throttle Promise>( + fn: T, + delay: number +): (...args: Parameters) => Promise>> { + let lastRun = 0; + let lastPromise: Promise>> | null = null; + + return (...args: Parameters) => { + const now = Date.now(); + + if (lastRun && now - lastRun < delay) { + return lastPromise ?? Promise.resolve(undefined as Awaited>); + } + + lastRun = now; + lastPromise = fn(...args); + return lastPromise; + }; +} + +function runResource< + Source, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Source, + Awaited>, + RefetchInfo + > = ResourceFetcher, +>( + source: Getter | Array>, + fetcher: Fetcher, + options: ResourceOptions>> = {}, + effectFn: ( + fn: ( + values: Array, + previousValues: undefined | Array + ) => void | VoidFunction, + options?: { lazy?: boolean } + ) => void +): ResourceReturn>, RefetchInfo> { + const { + lazy = false, + once = false, + initialValue, + debounce: debounceTime, + throttle: throttleTime, + } = options; + + // Create state + let current = $state> | undefined>(initialValue); + let loading = $state(false); + let error = $state(undefined); + let cleanupFns = $state void>>([]); + + // Helper function to run cleanup functions + const runCleanup = () => { + cleanupFns.forEach((fn) => fn()); + cleanupFns = []; + }; + + // Helper function to register cleanup + const onCleanup = (fn: () => void) => { + cleanupFns = [...cleanupFns, fn]; + }; + + // Create the base fetcher function + const baseFetcher = async ( + value: Source | Array, + previousValue: Source | undefined | Array, + refetching: RefetchInfo | boolean = false + ): Promise> | undefined> => { + try { + loading = true; + error = undefined; + runCleanup(); + + // Create new AbortController for this fetch + const controller = new AbortController(); + onCleanup(() => controller.abort()); + + const result = await fetcher(value as any, previousValue as any, { + data: current, + refetching, + onCleanup, + signal: controller.signal, + }); + + current = result; + return result; + } catch (e) { + if (!(e instanceof DOMException && e.name === "AbortError")) { + error = e as Error; + } + return undefined; + } finally { + loading = false; + } + }; + + // Apply debounce or throttle if specified + const runFetcher = debounceTime + ? debounce(baseFetcher, debounceTime) + : throttleTime + ? throttle(baseFetcher, throttleTime) + : baseFetcher; + + // Setup effect + const sources = Array.isArray(source) ? source : [source]; + let prevValues: unknown[] | undefined; + + effectFn( + (values, previousValues) => { + // Skip if once and already ran + if (once && prevValues) { + return; + } + + // Skip if values haven't changed + if (prevValues && JSON.stringify(values) === JSON.stringify(prevValues)) { + return; + } + + prevValues = values; + runFetcher( + Array.isArray(source) ? values : values[0]!, + Array.isArray(source) ? previousValues : previousValues?.[0] + ); + }, + { lazy } + ); + + return { + get current() { + return current; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + mutate: (value: Awaited>) => { + current = value; + }, + refetch: (info?: RefetchInfo) => { + const values = sources.map((s) => s()); + return runFetcher( + Array.isArray(source) ? values : values[0]!, + Array.isArray(source) ? values : values[0], + info ?? true + ); + }, + }; +} + +/** + * Creates a reactive resource that combines reactive dependency tracking with async data fetching. + * This version uses watch under the hood and runs after render. + * For pre-render execution, use resource.pre(). + * + * Features: + * - Automatic request cancellation when dependencies change + * - Built-in loading and error states + * - Support for initial values and lazy loading + * - Type-safe reactive dependencies + * + * @example + * ```typescript + * // Basic usage with automatic request cancellation + * const userResource = resource( + * () => userId, + * async (newId, prevId, { signal }) => { + * const res = await fetch(`/api/users/${newId}`, { signal }); + * return res.json(); + * } + * ); + * + * // Multiple dependencies + * const searchResource = resource( + * [() => query, () => page], + * async ([query, page], [prevQuery, prevPage], { signal }) => { + * const res = await fetch( + * `/api/search?q=${query}&page=${page}`, + * { signal } + * ); + * return res.json(); + * }, + * { lazy: true } + * ); + * + * // Custom cleanup with built-in request cancellation + * const streamResource = resource( + * () => streamId, + * async (id, prevId, { signal, onCleanup }) => { + * const eventSource = new EventSource(`/api/stream/${id}`); + * onCleanup(() => eventSource.close()); + * + * const res = await fetch(`/api/stream/${id}/init`, { signal }); + * return res.json(); + * } + * ); + * ``` + */ +// For single source +export function resource< + Source, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Source, + Awaited>, + RefetchInfo + > = ResourceFetcher, +>( + source: Getter, + fetcher: Fetcher, + options?: ResourceOptions>> +): ResourceReturn>, RefetchInfo>; + +// For array of sources +export function resource< + Sources extends Array, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Sources, + Awaited>, + RefetchInfo + > = ResourceFetcher, +>( + sources: { [K in keyof Sources]: Getter }, + fetcher: Fetcher, + options?: ResourceOptions>> +): ResourceReturn>, RefetchInfo>; + +export function resource< + Source, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Source, + Awaited>, + RefetchInfo + > = ResourceFetcher, +>( + source: Getter | Array>, + fetcher: Fetcher, + options?: ResourceOptions>> +): ResourceReturn>, RefetchInfo> { + return runResource(source, fetcher, options, (fn, options) => { + const sources = Array.isArray(source) ? source : [source]; + const getters = () => sources.map((s) => s()); + watch( + getters, + (values, previousValues) => { + fn(values, previousValues ?? []); + }, + options + ); + }); +} + +/** + * Helper function to create a resource with pre-effect (runs before render). + * Uses watch.pre internally instead of watch for pre-render execution. + * Includes all features of the standard resource including automatic request cancellation. + * + * @example + * ```typescript + * // Pre-render resource with automatic cancellation + * const data = resource.pre( + * () => query, + * async (newQuery, prevQuery, { signal }) => { + * const res = await fetch(`/api/search?q=${newQuery}`, { signal }); + * return res.json(); + * } + * ); + * ``` + */ +// For single source +export function resourcePre< + Source, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Source, + Awaited>, + RefetchInfo + > = ResourceFetcher, +>( + source: Getter, + fetcher: Fetcher, + options?: ResourceOptions>> +): ResourceReturn>, RefetchInfo>; + +// For array of sources +export function resourcePre< + Sources extends Array, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Sources, + Awaited>, + RefetchInfo + > = ResourceFetcher, +>( + sources: { + [K in keyof Sources]: Getter; + }, + fetcher: Fetcher, + options?: ResourceOptions>> +): ResourceReturn>, RefetchInfo>; + +export function resourcePre< + Source, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Source, + Awaited>, + RefetchInfo + > = ResourceFetcher, +>( + source: Getter | Array>, + fetcher: Fetcher, + options?: ResourceOptions>> +): ResourceReturn>, RefetchInfo> { + return runResource(source, fetcher, options, (fn, options) => { + const sources = Array.isArray(source) ? source : [source]; + const getter = () => sources.map((s) => s()); + watch.pre( + getter, + (values, previousValues) => { + fn(values, previousValues ?? []); + }, + options + ); + }); +} + +resource.pre = resourcePre; diff --git a/packages/runed/src/lib/utilities/resource/resource.test.svelte.ts b/packages/runed/src/lib/utilities/resource/resource.test.svelte.ts new file mode 100644 index 00000000..6c3e5ffc --- /dev/null +++ b/packages/runed/src/lib/utilities/resource/resource.test.svelte.ts @@ -0,0 +1,410 @@ +import { describe, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { setupServer } from "msw/node"; +import { testWithEffect } from "$lib/test/util.svelte.js"; +import { sleep } from "$lib/internal/utils/sleep.js"; +import { resource } from "./index.js"; +import { handlers, type ResponseData, type SearchResponseData } from "./msw-handlers.js"; + +const server = setupServer(...handlers); + +// Setup MSW +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); +afterEach(() => server.resetHandlers()); + +describe("resource", () => { + describe("core functionality", () => { + testWithEffect("basic fetching and state management", async () => { + let userId = $state(1); + let renderOrder = $state([]); + let previousData = $state(undefined); + let cleanUpMessage = $state(""); + + const userResource = resource( + () => userId, + async (id, _, { data, signal, onCleanup }): Promise => { + renderOrder.push("fetch"); + previousData = data; + const response = await fetch(`https://api.example.com/users/${id}`, { signal }); + onCleanup(() => { + cleanUpMessage = "cleanup"; + }); + if (!response.ok) throw new Error("Failed to fetch user"); + return response.json(); + } + ); + + renderOrder.push("render"); + + // Initial state + expect(userResource.current).toBe(undefined); + expect(userResource.error).toBe(undefined); + await sleep(25); + expect(userResource.loading).toBe(true); + + // Resolved state + await sleep(50); + expect(userResource.loading).toBe(false); + expect(userResource.current).toEqual({ + id: 1, + name: "User 1", + email: "user1@example.com", + }); + expect(renderOrder).toEqual(["render", "fetch"]); + expect(previousData).toBe(undefined); + + // State after update + userId = 2; + await sleep(100); + expect(userResource.current).toEqual({ + id: 2, + name: "User 2", + email: "user2@example.com", + }); + expect(previousData).toEqual({ + id: 1, + name: "User 1", + email: "user1@example.com", + }); + expect(cleanUpMessage).toEqual("cleanup"); + }); + + testWithEffect("handles multiple dependencies", async () => { + let searchQuery = $state("test"); + let page = $state(1); + + const searchResource = resource([() => searchQuery, () => page], async ([query, pageNum]) => { + const response = await fetch(`https://api.example.com/search?q=${query}&page=${pageNum}`); + if (!response.ok) throw new Error("Search failed"); + return response.json(); + }); + + // Initial state + await sleep(50); + expect(searchResource.current).toEqual({ + results: [ + { id: 1, title: "Result 1 for test" }, + { id: 2, title: "Result 2 for test" }, + ], + page: 1, + total: 10, + }); + + // Update dependencies + page = 2; + await sleep(50); + expect(searchResource.current?.results[0].id).toBe(2); + }); + }); + + describe("error handling", () => { + testWithEffect("handles network errors", async () => { + const errorResource = resource( + () => "error", + async () => { + const response = await fetch("https://api.example.com/error-prone"); + if (!response.ok) throw new Error("Request failed"); + return response.json(); + } + ); + + await sleep(50); + expect(errorResource.loading).toBe(false); + expect(errorResource.error).toBeDefined(); + expect(errorResource.current).toBe(undefined); + }); + + testWithEffect("handles request cancellation", async () => { + let userId = $state(1); + let cancelCount = $state(0); + + const userResource = resource( + () => userId, + async (id, _, { signal }): Promise => { + signal.addEventListener("abort", () => { + cancelCount++; + }); + const response = await fetch(`https://api.example.com/users/${id}`, { signal }); + if (!response.ok) throw new Error("Failed to fetch user"); + return response.json(); + } + ); + + // Trigger multiple updates rapidly + await sleep(0); + userId = 2; + await sleep(0); + userId = 3; + await sleep(0); + userId = 4; + + await sleep(100); + expect(cancelCount).toBeGreaterThan(0); + expect(userResource.current?.id).toBe(4); + }); + }); + + describe("configuration options", () => { + testWithEffect("lazy prevents initial fetch regardless of initialValue", async () => { + let userId = $state(1); + let fetchCount = $state(0); + + // Case 1: lazy without initialValue + const lazyResource = resource( + () => userId, + async (id, _, { signal }): Promise => { + fetchCount++; + const response = await fetch(`https://api.example.com/users/${id}`, { signal }); + if (!response.ok) throw new Error("Failed to fetch user"); + return response.json(); + }, + { lazy: true } + ); + + // Case 2: lazy with initialValue + let lazyWithInitialCount = $state(0); + const lazyResourceWithInitial = resource( + () => userId, + async (id, _, { signal }): Promise => { + lazyWithInitialCount++; + const response = await fetch(`https://api.example.com/users/${id}`, { signal }); + if (!response.ok) throw new Error("Failed to fetch user"); + return response.json(); + }, + { + lazy: true, + initialValue: { id: 0, name: "Initial", email: "initial@example.com" }, + } + ); + + await sleep(0); + // Case 1 + expect(lazyResource.loading).toBe(false); + expect(fetchCount).toBe(0); + expect(lazyResource.current).toBe(undefined); + // Case 2 + expect(lazyResourceWithInitial.loading).toBe(false); + expect(lazyWithInitialCount).toBe(0); + expect(lazyResourceWithInitial.current).toEqual({ + id: 0, + name: "Initial", + email: "initial@example.com", + }); + + // Both resources should fetch when dependency changes + userId = 2; + await sleep(100); + // Case 1 + expect(lazyResource.current?.id).toBe(2); + expect(fetchCount).toBe(1); + // Case 2 + expect(lazyResourceWithInitial.current?.id).toBe(2); + expect(lazyWithInitialCount).toBe(1); + }); + + testWithEffect("initialValue provides initial state but doesn't prevent fetch", async () => { + let userId = $state(1); + let fetchCount = $state(0); + + const resourceWithInitial = resource( + () => userId, + async (id) => { + fetchCount++; + const response = await fetch(`https://api.example.com/users/${id}`); + return response.json(); + }, + { + initialValue: { id: 0, name: "Initial", email: "initial@example.com" }, + } + ); + + // Initially has the initialValue + expect(resourceWithInitial.current).toEqual({ + id: 0, + name: "Initial", + email: "initial@example.com", + }); + + // But still performs the initial fetch + await sleep(100); + expect(fetchCount).toBe(1); + expect(resourceWithInitial.current).toEqual({ + id: 1, + name: "User 1", + email: "user1@example.com", + }); + }); + + testWithEffect("supports once option", async () => { + let userId = $state(1); + let fetchCount = $state(0); + + const onceResource = resource( + () => userId, + async (id): Promise => { + fetchCount++; + const response = await fetch(`https://api.example.com/users/${id}`); + return response.json(); + }, + { once: true } + ); + + await sleep(100); + const firstFetchCount = fetchCount; + + userId = 2; + userId = 3; + await sleep(200); + + expect(fetchCount).toBe(firstFetchCount); + expect(onceResource.current?.id).toBe(1); + }); + + testWithEffect("debounces rapid changes", async () => { + let searchQuery = $state("test"); + let fetchCount = $state(0); + + const searchResource = resource( + () => searchQuery, + async (query): Promise => { + fetchCount++; + const response = await fetch(`https://api.example.com/search?q=${query}`); + return response.json(); + }, + { debounce: 50 } + ); + + // Make rapid changes + await sleep(0); + searchQuery = "a"; + await sleep(10); + searchQuery = "ab"; + await sleep(10); + searchQuery = "abc"; + await sleep(10); + searchQuery = "abcd"; + + // Initial state while debouncing + expect(fetchCount).toBe(0); + + // Wait for debounce to complete + await sleep(100); + expect(fetchCount).toBe(1); + expect(searchResource.loading).toBe(false); + expect(searchResource.current?.results[0].title).toContain("abcd"); + + // Single change after delay should trigger immediately + searchQuery = "final"; + await sleep(100); + expect(fetchCount).toBe(2); + }); + + testWithEffect("throttles rapid changes", async () => { + let id = $state(1); + let fetchCount = $state(0); + let fetchTimes: number[] = $state([]); + + resource( + () => id, + async (id): Promise => { + fetchCount++; + fetchTimes.push(Date.now()); + const response = await fetch(`https://api.example.com/users/${id}`); + return response.json(); + }, + { throttle: 100 } + ); + + // Make rapid changes + id = 2; + await sleep(20); + id = 3; + await sleep(20); + id = 4; + await sleep(20); + id = 5; + + await sleep(200); + + // Check time between fetches + const timeDiffs = fetchTimes.slice(1).map((time, i) => time - fetchTimes[i]); + const allThrottled = timeDiffs.every((diff) => diff >= 95); // Allow small margin of error + expect(allThrottled).toBe(true); + + // Should have fewer fetches than value changes + expect(fetchCount).toBeLessThan(5); + }); + }); + + describe("mutation and refetching", () => { + testWithEffect("supports direct mutation", async () => { + let userId = $state(1); + + const userResource = resource( + () => userId, + async (id): Promise => { + const response = await fetch(`https://api.example.com/users/${id}`); + return response.json(); + } + ); + + await sleep(100); + const newData = { id: 999, name: "Mutated", email: "mutated@example.com" }; + userResource.mutate(newData); + expect(userResource.current).toEqual(newData); + }); + + testWithEffect("supports refetching with info", async () => { + let userId = $state(1); + let refetchInfo = $state(false); + + const userResource = resource( + () => userId, + async (id, _, { refetching }): Promise => { + refetchInfo = refetching; + const response = await fetch(`https://api.example.com/users/${id}`); + return response.json(); + } + ); + + await sleep(100); + const customInfo = { reason: "test" }; + await userResource.refetch(customInfo); + expect(refetchInfo).toEqual(customInfo); + }); + }); +}); + +describe("resource.pre", () => { + testWithEffect("runs before render with proper cleanup", async () => { + let renderOrder = $state([]); + let userId = $state(1); + let cleanupCount = $state(0); + + const preResource = resource.pre( + () => userId, + async (id, _, { signal, onCleanup }): Promise => { + renderOrder.push("fetch"); + onCleanup(() => { + cleanupCount++; + }); + + const response = await fetch(`https://api.example.com/users/${id}`, { + signal, + }); + return response.json(); + } + ); + + renderOrder.push("render"); + await sleep(100); + + expect(renderOrder).toEqual(["fetch", "render"]); + expect(preResource.current?.id).toBe(1); + + userId = 2; + await sleep(100); + expect(cleanupCount).toBe(1); + expect(preResource.current?.id).toBe(2); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 917a9a73..4f7655ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,7 @@ importers: version: 6.6.3 '@testing-library/svelte': specifier: ^5.2.0 - version: 5.2.6(svelte@5.11.0)(vite@5.4.11(@types/node@20.17.9)(lightningcss@1.28.2)(terser@5.36.0))(vitest@1.6.0(@types/node@20.17.9)(@vitest/ui@1.6.0)(jsdom@24.1.3)(lightningcss@1.28.2)(terser@5.36.0)) + version: 5.2.6(svelte@5.11.0)(vite@5.4.11(@types/node@20.17.9)(lightningcss@1.28.2)(terser@5.36.0))(vitest@1.6.0) '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) @@ -98,13 +98,16 @@ importers: version: 20.17.9 '@vitest/coverage-v8': specifier: ^1.5.1 - version: 1.6.0(vitest@1.6.0(@types/node@20.17.9)(@vitest/ui@1.6.0)(jsdom@24.1.3)(lightningcss@1.28.2)(terser@5.36.0)) + version: 1.6.0(vitest@1.6.0) '@vitest/ui': specifier: ^1.6.0 version: 1.6.0(vitest@1.6.0) jsdom: specifier: ^24.0.0 version: 24.1.3 + msw: + specifier: ^2.7.0 + version: 2.7.0(@types/node@20.17.9)(typescript@5.7.2) publint: specifier: ^0.1.9 version: 0.1.16 @@ -215,6 +218,15 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bundled-es-modules/cookie@2.0.1': + resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} + + '@bundled-es-modules/statuses@1.0.1': + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + + '@bundled-es-modules/tough-cookie@0.1.6': + resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@changesets/apply-release-plan@7.0.6': resolution: {integrity: sha512-TKhVLtiwtQOgMAC0fCJfmv93faiViKSDqr8oMEqrnNs99gtSC1sZh/aEMS9a+dseU1ESZRCK+ofLgGY7o0fw/Q==} @@ -917,6 +929,37 @@ packages: cpu: [x64] os: [win32] + '@inquirer/confirm@5.1.5': + resolution: {integrity: sha512-ZB2Cz8KeMINUvoeDi7IrvghaVkYT2RB0Zb31EaLWOE87u276w4wnApv0SH2qWaJ3r0VSUa3BIuz7qAV2ZvsZlg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.1.6': + resolution: {integrity: sha512-Bwh/Zk6URrHwZnSSzAZAKH7YgGYi0xICIBDFOqBQoXNNAzBHw/bgXgLmChfp+GyR3PnChcTbiCTZGC6YJNJkMA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.10': + resolution: {integrity: sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.4': + resolution: {integrity: sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@internationalized/date@3.6.0': resolution: {integrity: sha512-+z6ti+CcJnRlLHok/emGEsWQhe7kfSmEW+/6qCzvKY67YPh7YOBfvc7+/+NXq+zJlbArg30tYpqLjNgcAYv2YQ==} @@ -961,6 +1004,10 @@ packages: '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} + '@mswjs/interceptors@0.37.6': + resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} + engines: {node: '>=18'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -973,6 +1020,15 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} @@ -1290,6 +1346,12 @@ packages: '@types/node@20.17.9': resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==} + '@types/statuses@2.0.5': + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1416,6 +1478,10 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1546,6 +1612,14 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1700,6 +1774,9 @@ packages: emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.17.1: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} @@ -1733,6 +1810,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1942,6 +2023,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -1999,6 +2084,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.10.0: + resolution: {integrity: sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2040,6 +2129,9 @@ packages: hastscript@9.0.0: resolution: {integrity: sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -2129,6 +2221,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2136,6 +2232,9 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2587,10 +2686,24 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.7.0: + resolution: {integrity: sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2659,6 +2772,9 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -2993,6 +3109,10 @@ packages: remove-markdown@0.5.5: resolution: {integrity: sha512-lMR8tOtDqazFT6W2bZidoXwkptMdF3pCxpri0AEokHg0sZlC2GdoLqnoaxsEj1o7/BtXV1MKtT3YviA1t7rW7g==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -3139,6 +3259,10 @@ packages: stacktracey@2.1.8: resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} @@ -3146,6 +3270,13 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -3316,6 +3447,14 @@ packages: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.34.0: + resolution: {integrity: sha512-Qcg88ZJsJvRcUijtD6supagRSDf0y1FPZh4NroJpwRkoPYj6gGNidREwTgDuC0Pmq0PVAAzL8C8BZW7xhx5Q4A==} + engines: {node: '>=16'} + typescript-eslint@8.16.0: resolution: {integrity: sha512-wDkVmlY6O2do4V+lZd0GtRfbtXbeD0q9WygwXXSJnC1xorE8eqyC2L1tJimqpSeFrOzRlYtWnUp/uzgHQOgfBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3527,6 +3666,14 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3552,6 +3699,10 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -3561,6 +3712,14 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3569,6 +3728,10 @@ packages: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + engines: {node: '>=18'} + youch@3.3.4: resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} @@ -3615,6 +3778,19 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@bundled-es-modules/cookie@2.0.1': + dependencies: + cookie: 0.7.2 + + '@bundled-es-modules/statuses@1.0.1': + dependencies: + statuses: 2.0.1 + + '@bundled-es-modules/tough-cookie@0.1.6': + dependencies: + '@types/tough-cookie': 4.0.5 + tough-cookie: 4.1.4 + '@changesets/apply-release-plan@7.0.6': dependencies: '@changesets/config': 3.0.4 @@ -4156,6 +4332,32 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inquirer/confirm@5.1.5(@types/node@20.17.9)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@20.17.9) + '@inquirer/type': 3.0.4(@types/node@20.17.9) + optionalDependencies: + '@types/node': 20.17.9 + + '@inquirer/core@10.1.6(@types/node@20.17.9)': + dependencies: + '@inquirer/figures': 1.0.10 + '@inquirer/type': 3.0.4(@types/node@20.17.9) + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 20.17.9 + + '@inquirer/figures@1.0.10': {} + + '@inquirer/type@3.0.4(@types/node@20.17.9)': + optionalDependencies: + '@types/node': 20.17.9 + '@internationalized/date@3.6.0': dependencies: '@swc/helpers': 0.5.15 @@ -4239,6 +4441,15 @@ snapshots: - acorn - supports-color + '@mswjs/interceptors@0.37.6': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4251,6 +4462,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@polka/url@1.0.0-next.28': {} '@rollup/rollup-android-arm-eabi@4.28.0': @@ -4553,7 +4773,7 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/svelte@5.2.6(svelte@5.11.0)(vite@5.4.11(@types/node@20.17.9)(lightningcss@1.28.2)(terser@5.36.0))(vitest@1.6.0(@types/node@20.17.9)(@vitest/ui@1.6.0)(jsdom@24.1.3)(lightningcss@1.28.2)(terser@5.36.0))': + '@testing-library/svelte@5.2.6(svelte@5.11.0)(vite@5.4.11(@types/node@20.17.9)(lightningcss@1.28.2)(terser@5.36.0))(vitest@1.6.0)': dependencies: '@testing-library/dom': 10.4.0 svelte: 5.11.0 @@ -4607,6 +4827,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/statuses@2.0.5': {} + + '@types/tough-cookie@4.0.5': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -4695,7 +4919,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.17.9)(@vitest/ui@1.6.0)(jsdom@24.1.3)(lightningcss@1.28.2)(terser@5.36.0))': + '@vitest/coverage-v8@1.6.0(vitest@1.6.0)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -4783,6 +5007,10 @@ snapshots: ansi-colors@4.1.3: {} + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4905,6 +5133,14 @@ snapshots: ci-info@3.9.0: {} + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} collapse-white-space@2.1.0: {} @@ -5018,6 +5254,8 @@ snapshots: emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 @@ -5122,6 +5360,8 @@ snapshots: '@esbuild/win32-ia32': 0.24.0 '@esbuild/win32-x64': 0.24.0 + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -5375,6 +5615,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-func-name@2.0.2: {} get-source@2.0.12: @@ -5434,6 +5676,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.10.0: {} + has-flag@4.0.0: {} hasown@2.0.2: @@ -5543,6 +5787,8 @@ snapshots: property-information: 6.5.0 space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -5622,12 +5868,16 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-hexadecimal@2.0.1: {} + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -6355,8 +6605,35 @@ snapshots: ms@2.1.3: {} + msw@2.7.0(@types/node@20.17.9)(typescript@5.7.2): + dependencies: + '@bundled-es-modules/cookie': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@bundled-es-modules/tough-cookie': 0.1.6 + '@inquirer/confirm': 5.1.5(@types/node@20.17.9) + '@mswjs/interceptors': 0.37.6 + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.5 + graphql: 16.10.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + strict-event-emitter: 0.5.1 + type-fest: 4.34.0 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.7.2 + transitivePeerDependencies: + - '@types/node' + mustache@4.2.0: {} + mute-stream@2.0.0: {} + nanoid@3.3.8: {} natural-compare@1.4.0: {} @@ -6420,6 +6697,8 @@ snapshots: outdent@0.5.0: {} + outvariant@1.4.3: {} + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -6743,6 +7022,8 @@ snapshots: remove-markdown@0.5.5: {} + require-directory@2.1.1: {} + requires-port@1.0.0: {} resize-observer-polyfill@1.5.1: {} @@ -6921,10 +7202,20 @@ snapshots: as-table: 1.0.55 get-source: 2.0.12 + statuses@2.0.1: {} + std-env@3.8.0: {} stoppable@1.1.0: {} + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -7095,6 +7386,10 @@ snapshots: type-detect@4.1.0: {} + type-fest@0.21.3: {} + + type-fest@4.34.0: {} + typescript-eslint@8.16.0(eslint@9.16.0(jiti@2.4.1))(typescript@5.7.2): dependencies: '@typescript-eslint/eslint-plugin': 8.16.0(@typescript-eslint/parser@8.16.0(eslint@9.16.0(jiti@2.4.1))(typescript@5.7.2))(eslint@9.16.0(jiti@2.4.1))(typescript@5.7.2) @@ -7348,6 +7643,18 @@ snapshots: - supports-color - utf-8-validate + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@8.18.0: {} @@ -7358,14 +7665,30 @@ snapshots: xxhash-wasm@1.1.0: {} + y18n@5.0.8: {} + yaml@1.10.2: {} yaml@2.6.1: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yocto-queue@1.1.1: {} + yoctocolors-cjs@2.1.2: {} + youch@3.3.4: dependencies: cookie: 0.7.2 diff --git a/sites/docs/src/content/utilities/resource.md b/sites/docs/src/content/utilities/resource.md new file mode 100644 index 00000000..422364d3 --- /dev/null +++ b/sites/docs/src/content/utilities/resource.md @@ -0,0 +1,247 @@ +--- +title: resource +description: Watch for changes and runs async data fetching +category: Reactivity +--- + + + +In SvelteKit, using load functions is the primary approach for data fetching. While you can handle +reactive data fetching by using `URLSearchParams` for query parameters, there are cases where you +might need more flexibility at the component level. + +This is where `resource` comes in - it's a utility that seamlessly combines reactive state +management with async data fetching. + +Built on top of `watch`, it runs after rendering by default, but also provides a pre-render option +via `resource.pre()`. + +## Demo + + + +## Usage + +```svelte + + + + +{#if searchResults.loading} +
Loading...
+{:else if searchResults.error} +
Error: {searchResults.error.message}
+{:else} +
    + {#each searchResults.current ?? [] as result} +
  • {result.title}
  • + {/each} +
+{/if} +``` + +## Features + +- **Automatic Request Cancellation**: When dependencies change, in-flight requests are automatically + canceled +- **Loading & Error States**: Built-in states for loading and error handling +- **Debouncing & Throttling**: Optional debounce and throttle options for rate limiting +- **Type Safety**: Full TypeScript support with inferred types +- **Multiple Dependencies**: Support for tracking multiple reactive dependencies +- **Custom Cleanup**: Register cleanup functions that run before refetching +- **Pre-render Support**: `resource.pre()` for pre-render execution + +## Advanced Usage + +### Multiple Dependencies + +```svelte + +``` + +### Custom Cleanup + +```svelte + +``` + +### Pre-render Execution + +```svelte + +``` + +## Configuration Options + +### `lazy` + +When true, skips the initial fetch. The resource will only fetch when dependencies change or +`refetch()` is called. + +### `once` + +When true, only fetches once. Subsequent dependency changes won't trigger new fetches. + +### `initialValue` + +Provides an initial value for the resource before the first fetch completes. Useful if you already +have the data and don't want to wait for the fetch to complete. + +### `debounce` + +Time in milliseconds to debounce rapid changes. Useful for search inputs. + +The debounce implementation will cancel pending requests and only execute the last one after the +specified delay. + +### `throttle` + +Time in milliseconds to throttle rapid changes. Useful for real-time updates. + +The throttle implementation will ensure that requests are spaced at least by the specified delay, +returning the pending promise if called too soon. + + +Note that you should use either debounce or throttle, not both - if both are specified, debounce takes precedence. + + +## Type Definitions + +```ts +type ResourceOptions = { + /** Skip initial fetch when true */ + lazy?: boolean; + /** Only fetch once when true */ + once?: boolean; + /** Initial value for the resource */ + initialValue?: Data; + /** Debounce time in milliseconds */ + debounce?: number; + /** Throttle time in milliseconds */ + throttle?: number; +}; + +type ResourceState = { + /** Current value of the resource */ + current: Data | undefined; + /** Whether the resource is currently loading */ + loading: boolean; + /** Error if the fetch failed */ + error: Error | undefined; +}; + +type ResourceReturn = ResourceState & { + /** Update the resource value directly */ + mutate: (value: Data) => void; + /** Re-run the fetcher with current values */ + refetch: (info?: RefetchInfo) => Promise; +}; + +type ResourceFetcherRefetchInfo = { + /** Previous data return from fetcher */ + data: Data | undefined; + /** Whether the fetcher is currently refetching or it can be the value you passed to refetch. */ + refetching: RefetchInfo | boolean; + /** A cleanup function that will be called when the source is invalidated and the fetcher is about to re-run */ + onCleanup: (fn: () => void) => void; + /** AbortSignal for cancelling fetch requests */ + signal: AbortSignal; +}; + +type ResourceFetcher = ( + /** Current value of the source */ + value: Source extends Array + ? { + [K in keyof Source]: Source[K]; + } + : Source, + /** Previous value of the source */ + previousValue: Source extends Array + ? { + [K in keyof Source]: Source[K]; + } + : Source | undefined, + info: ResourceFetcherRefetchInfo +) => Promise; + +function resource< + Source, + RefetchInfo = unknown, + Fetcher extends ResourceFetcher< + Source, + Awaited>, + RefetchInfo + > = ResourceFetcher +>( + source: Getter, + fetcher: Fetcher, + options?: ResourceOptions>> +): ResourceReturn>, RefetchInfo>; +``` diff --git a/sites/docs/src/lib/components/demos/resource.svelte b/sites/docs/src/lib/components/demos/resource.svelte new file mode 100644 index 00000000..830ebbdb --- /dev/null +++ b/sites/docs/src/lib/components/demos/resource.svelte @@ -0,0 +1,43 @@ + + + +
+ Post id: +
+ +
+
+
+ Status: {searchResource.loading ? "Loading..." : "Ready"} +
+ {#if searchResource.error} +
+ Error: {searchResource.error.message} +
+ {/if} +
+ {#each searchResource.current ?? [] as result} +
{JSON.stringify(result, null, 2)}
+ {/each} +
+
+
+ + +
diff --git a/sites/docs/src/routes/api/search.json/search.json b/sites/docs/src/routes/api/search.json/search.json index 36670e8e..341e389b 100644 --- a/sites/docs/src/routes/api/search.json/search.json +++ b/sites/docs/src/routes/api/search.json/search.json @@ -1 +1 @@ -[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking If you need to provide a custom document / shadowRoot, you can use the $2 utility instead, which provides a more flexible API. Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default true */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"IsSupported","href":"/docs/utilities/is-supported","description":"Determine if a feature is supported by the environment before using it.","content":"Usage import { IsSupported } from \"runed\"; const isSupported = new IsSupported(() => navigator && \"geolocation\" in navigator); if (isSupported.current) { // Do something with the geolocation API } Type Definition class IsSupported { readonly current: boolean; } `"},{"title":"onClickOutside","href":"/docs/utilities/on-click-outside","description":"Handle clicks outside of a specified element.","content":" import Demo from '$lib/components/demos/on-click-outside.svelte'; onClickOutside detects clicks that occur outside a specified element's boundaries and executes a callback function. It's commonly used for dismissible dropdowns, modals, and other interactive components. Demo Basic Usage import { onClickOutside } from \"runed\"; let container = $state()!; onClickOutside( () => container, () => console.log(\"clicked outside\") ); I'm outside the container Advanced Usage Controlled Listener The function returns control methods to programmatically manage the listener, start and stop and a reactive read-only property enabled to check the current status of the listeners. import { onClickOutside } from \"runed\"; let container = $state(); const clickOutside = onClickOutside( () => container, () => console.log(\"Clicked outside\") ); Status: {clickOutside.enabled ? \"Enabled\" : \"Disabled\"} Disable Enable Immediate By default, onClickOutside will start listening for clicks outside the element immediately. You can opt to disabled this behavior by passing { immediate: false } to the options argument. const clickOutside = onClickOutside( () => container, () => console.log(\"clicked outside\"), { immediate: false } ); // later when you want to start the listener clickOutside.start(); `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useActiveElement","href":"/docs/utilities/use-active-element","description":"Get a reactive reference to the currently focused element in the document.","content":" import Demo from '$lib/components/demos/use-active-element.svelte'; import { PropField } from '@svecodocs/kit' useActiveElement is used to get the currently focused element in the document. If you don't need to provide a custom document / shadowRoot, you can use the $2 state instead, as it provides a simpler API. This utility behaves similarly to document.activeElement but with additional features such as: Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Demo Usage import { useActiveElement } from \"runed\"; const activeElement = useActiveElement(); {#if activeElement.current} The active element is: {activeElement.current.localName} {:else} No active element found {/if} Options The following options can be passed via the first argument to useActiveElement: The document or shadow root to track focus within. The window to use for focus tracking. "},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.position.coords, null, 2)} Located at: {location.position.timestamp} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly position: Omit; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file +[{"title":"Getting Started","href":"/docs/getting-started","description":"Learn how to install and use Runed in your projects.","content":"Installation Install Runed using your favorite package manager: npm install runed Usage Import one of the utilities you need to either a .svelte or .svelte.js|ts file and start using it: import { activeElement } from \"runed\"; let inputElement = $state(); {#if activeElement.current === inputElement} The input element is active! {/if} or import { activeElement } from \"runed\"; function logActiveElement() { $effect(() => { console.log(\"Active element is \", activeElement.current); }); } logActiveElement(); `"},{"title":"Introduction","href":"/docs/index","description":"Runes are magic, but what good is magic if you don't have a wand?","content":"Runed is a collection of utilities for Svelte 5 that make composing powerful applications and libraries a breeze, leveraging the power of $2. Why Runed? Svelte 5 Runes unlock immense power by providing a set of primitives that allow us to build impressive applications and libraries with ease. However, building complex applications often requires more than just the primitives provided by Svelte Runes. Runed takes those primitives to the next level by providing: Powerful Utilities**: A set of carefully crafted utility functions and classes that simplify common tasks and reduce boilerplate. Collective Efforts**: We often find ourselves writing the same utility functions over and over again. Runed aims to provide a single source of truth for these utilities, allowing the community to contribute, test, and benefit from them. Consistency**: A consistent set of APIs and behaviors across all utilities, so you can focus on building your projects instead of constantly learning new APIs. Reactivity First**: Powered by Svelte 5's new reactivity system, Runed utilities are designed to handle reactive state and side effects with ease. Type Safety**: Full TypeScript support to catch errors early and provide a better developer experience. Ideas and Principles Embrace the Magic of Runes Svelte Runes are a powerful new paradigm. Runed fully embraces this concept and explores its potential. Our goal is to make working with Runes feel as natural and intuitive as possible. Enhance, Don't Replace Runed is not here to replace Svelte's core functionality, but to enhance and extend it. Our utilities should feel like a natural extension of Svelte, not a separate framework. Progressive Complexity Simple things should be simple, complex things should be possible. Runed provides easy-to-use defaults while allowing for advanced customization when needed. Open Source and Community Collaboration Runed is an open-source, MIT licensed project that welcomes all forms of contributions from the community. Whether it's bug reports, feature requests, or code contributions, your input will help make Runed the best it can be."},{"title":"activeElement","href":"/docs/utilities/active-element","description":"Track and access the currently focused DOM element","content":" import Demo from '$lib/components/demos/active-element.svelte'; activeElement provides reactive access to the currently focused DOM element in your application, similar to document.activeElement but with reactive updates. Updates synchronously with DOM focus changes Returns null when no element is focused Safe to use with SSR (Server-Side Rendering) Lightweight alternative to manual focus tracking Searches through Shadow DOM boundaries for the true active element Demo Usage import { activeElement } from \"runed\"; Currently active element: {activeElement.current?.localName ?? \"No active element found\"} Custom Document If you wish to scope the focus tracking within a custom document or shadow root, you can pass a DocumentOrShadowRoot to the ActiveElement options: import { ActiveElement } from \"runed\"; const activeElement = new ActiveElement({ document: shadowRoot }); Type Definition interface ActiveElement { readonly current: Element | null; } `"},{"title":"AnimationFrames","href":"/docs/utilities/animation-frames","description":"A wrapper for requestAnimationFrame with FPS control and frame metrics","content":" import Demo from '$lib/components/demos/animation-frames.svelte'; AnimationFrames provides a declarative API over the browser's $2, offering FPS limiting capabilities and frame metrics while handling cleanup automatically. Demo Usage import { AnimationFrames } from \"runed\"; import { Slider } from \"../ui/slider\"; // Check out shadcn-svelte! let frames = $state(0); let fpsLimit = $state(10); let delta = $state(0); const animation = new AnimationFrames( (args) => { frames++; delta = args.delta; }, { fpsLimit: () => fpsLimit } ); const stats = $derived( Frames: ${frames}\\nFPS: ${animation.fps.toFixed(0)}\\nDelta: ${delta.toFixed(0)}ms ); {stats} {animation.running ? \"Stop\" : \"Start\"} FPS limit: {fpsLimit}{fpsLimit === 0 ? \" (not limited)\" : \"\"} (fpsLimit = value[0] ?? 0)} min={0} max={144} /> `"},{"title":"Context","href":"/docs/utilities/context","description":"A wrapper around Svelte's Context API that provides type safety and improved ergonomics for sharing data between components.","content":" import { Steps, Step, Callout } from '@svecodocs/kit'; Context allows you to pass data through the component tree without explicitly passing props through every level. It's useful for sharing data that many components need, like themes, authentication state, or localization preferences. The Context class provides a type-safe way to define, set, and retrieve context values. Usage Creating a Context First, create a Context instance with the type of value it will hold: import { Context } from \"runed\"; export const myTheme = new Context(\"theme\"); Creating a Context instance only defines the context - it doesn't actually set any value. The value passed to the constructor (\"theme\" in this example) is just an identifier used for debugging and error messages. Think of this step as creating a \"container\" that will later hold your context value. The container is typed (in this case to only accept \"light\" or \"dark\" as values) but remains empty until you explicitly call myTheme.set() during component initialization. This separation between defining and setting context allows you to: Keep context definitions in separate files Reuse the same context definition across different parts of your app Maintain type safety throughout your application Set different values for the same context in different component trees Setting Context Values Set the context value in a parent component during initialization. import { myTheme } from \"./context\"; let { data, children } = $props(); myTheme.set(data.theme); {@render children?.()} Context must be set during component initialization, similar to lifecycle functions like onMount. You cannot set context inside event handlers or callbacks. Reading Context Values Child components can access the context using get() or getOr() import { myTheme } from \"./context\"; const theme = myTheme.get(); // or with a fallback value if the context is not set const theme = myTheme.getOr(\"light\"); Type Definition class Context { /** @param name The name of the context. This is used for generating the context key and error messages. */ constructor(name: string) {} /** The key used to get and set the context. * It is not recommended to use this value directly. Instead, use the methods provided by this class. */ get key(): symbol; /** Checks whether this has been set in the context of a parent component. * Must be called during component initialization. */ exists(): boolean; /** Retrieves the context that belongs to the closest parent component. * Must be called during component initialization. * @throws An error if the context does not exist. */ get(): TContext; /** Retrieves the context that belongs to the closest parent component, or the given fallback value if the context does not exist. * Must be called during component initialization. */ getOr(fallback: TFallback): TContext | TFallback; /** Associates the given value with the current component and returns it. * Must be called during component initialization. */ set(context: TContext): TContext; } `"},{"title":"Debounced","href":"/docs/utilities/debounced","description":"A wrapper over `useDebounce` that returns a debounced state.","content":" import Demo from '$lib/components/demos/debounced.svelte'; Demo Usage This is a simple wrapper over $2 that returns a debounced state. import { Debounced } from \"runed\"; let search = $state(\"\"); const debounced = new Debounced(() => search, 500); You searched for: {debounced.current} You may cancel the pending update, run it immediately, or set a new value. Setting a new value immediately also cancels any pending updates. let count = $state(0); const debounced = new Debounced(() => count, 500); count = 1; debounced.cancel(); // after a while... console.log(debounced.current); // Still 0! count = 2; console.log(debounced.current); // Still 0! debounced.setImmediately(count); console.log(debounced.current); // 2 count = 3; console.log(debounced.current); // 2 await debounced.updateImmediately(); console.log(debounced.current); // 3 `"},{"title":"ElementRect","href":"/docs/utilities/element-rect","description":"Track element dimensions and position reactively","content":" import Demo from '$lib/components/demos/element-rect.svelte'; ElementRect provides reactive access to an element's dimensions and position information, automatically updating when the element's size or position changes. Demo Usage import { ElementRect } from \"runed\"; let el = $state(); const rect = new ElementRect(() => el); Width: {rect.width} Height: {rect.height} {JSON.stringify(rect.current, null, 2)} Type Definition type Rect = Omit; interface ElementRectOptions { initialRect?: DOMRect; } class ElementRect { constructor(node: MaybeGetter, options?: ElementRectOptions); readonly current: Rect; readonly width: number; readonly height: number; readonly top: number; readonly left: number; readonly right: number; readonly bottom: number; readonly x: number; readonly y: number; } `"},{"title":"ElementSize","href":"/docs/utilities/element-size","description":"Track element dimensions reactively","content":" import Demo from '$lib/components/demos/element-size.svelte'; ElementSize provides reactive access to an element's width and height, automatically updating when the element's dimensions change. Similar to ElementRect but focused only on size measurements. Demo Usage import { ElementSize } from \"runed\"; let el = $state() as HTMLElement; const size = new ElementSize(() => el); Width: {size.width} Height: {size.height} Type Definition interface ElementSize { readonly width: number; readonly height: number; } `"},{"title":"FiniteStateMachine","href":"/docs/utilities/finite-state-machine","description":"Defines a strongly-typed finite state machine.","content":" import Demo from '$lib/components/demos/finite-state-machine.svelte'; Demo type MyStates = \"disabled\" | \"idle\" | \"running\"; type MyEvents = \"toggleEnabled\" | \"start\" | \"stop\"; const f = new FiniteStateMachine(\"disabled\", { disabled: { toggleEnabled: \"idle\" }, idle: { toggleEnabled: \"disabled\", start: \"running\" }, running: { _enter: () => { f.debounce(2000, \"stop\"); }, stop: \"idle\", toggleEnabled: \"disabled\" } }); Usage Finite state machines (often abbreviated as \"FSMs\") are useful for tracking and manipulating something that could be in one of many different states. It centralizes the definition of every possible state and the events that might trigger a transition from one state to another. Here is a state machine describing a simple toggle switch: import { FiniteStateMachine } from \"runed\"; type MyStates = \"on\" | \"off\"; type MyEvents = \"toggle\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\" } }); The first argument to the FiniteStateMachine constructor is the initial state. The second argument is an object with one key for each state. Each state then describes which events are valid for that state, and which state that event should lead to. In the above example of a simple switch, there are two states (on and off). The toggle event in either state leads to the other state. You send events to the FSM using f.send. To send the toggle event, invoke f.send('toggle'). Actions Maybe you want fancier logic for an event handler, or you want to conditionally transition into another state. Instead of strings, you can use actions. An action is a function that returns a state. An action can receive parameters, and it can use those parameters to dynamically choose which state should come next. It can also prevent a state transition by returning nothing. type MyStates = \"on\" | \"off\" | \"cooldown\"; const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { if (isTuesday) { // Switch can only turn on during Tuesdays return \"on\"; } // All other days, nothing is returned and state is unchanged. } }, on: { toggle: (heldMillis: number) => { // You can also dynamically return the next state! // Only turn off if switch is depressed for 3 seconds if (heldMillis > 3000) { return \"off\"; } } } }); Lifecycle methods You can define special handlers that are invoked whenever a state is entered or exited: const f = new FiniteStateMachine('off', { off: { toggle: 'on' _enter: (meta) => { console.log('switch is off') } _exit: (meta) => { console.log('switch is no longer off') } }, on: { toggle: 'off' _enter: (meta) => { console.log('switch is on') } _exit: (meta) => { console.log('switch is no longer on') } } }); The lifecycle methods are invoked with a metadata object containing some useful information: from: the name of the event that is being exited to: the name of the event that is being entered event: the name of the event which has triggered the transition args: (optional) you may pass additional metadata when invoking an action with f.send('theAction', additional, params, as, args) The _enter handler for the initial state is called upon creation of the FSM. It is invoked with both the from and event fields set to null. Wildcard handlers There is one special state used as a fallback: *. If you have the fallback state, and you attempt to send() an event that is not handled by the current state, then it will try to find a handler for that event on the * state before discarding the event: const f = new FiniteStateMachine('off', { off: { toggle: 'on' }, on: { toggle: 'off' } '*': { emergency: 'off' } }); // will always result in the switch turning off. f.send('emergency'); Debouncing Frequently, you want to transition to another state after some time has elapsed. To do this, use the debounce method: f.send(\"toggle\"); // turn on immediately f.debounce(5000, \"toggle\"); // turn off in 5000 milliseconds If you re-invoke debounce with the same event, it will cancel the existing timer and start the countdown over: // schedule a toggle in five seconds f.debounce(5000, \"toggle\"); // ... less than 5000ms elapses ... f.debounce(5000, \"toggle\"); // The second call cancels the original timer, and starts a new one You can also use debounce in both actions and lifecycle methods. In both of the following examples, the lightswitch will turn itself off five seconds after it was turned on: const f = new FiniteStateMachine(\"off\", { off: { toggle: () => { f.debounce(5000, \"toggle\"); return \"on\"; } }, on: { toggle: \"off\" } }); const f = new FiniteStateMachine(\"off\", { off: { toggle: \"on\" }, on: { toggle: \"off\", _enter: () => { f.debounce(5000, \"toggle\"); } } }); Notes FiniteStateMachine is a loving rewrite of $2. FSMs are ideal for representing many different kinds of systems and interaction patterns. FiniteStateMachine is an intentionally minimalistic implementation. If you're looking for a more powerful FSM library, $2 is an excellent library with more features — and a steeper learning curve."},{"title":"IsFocusWithin","href":"/docs/utilities/is-focus-within","description":"A utility that tracks whether any descendant element has focus within a specified container element.","content":" import Demo from '$lib/components/demos/is-focus-within.svelte'; IsFocusWithin reactively tracks focus state within a container element, updating automatically when focus changes. Demo Usage import { IsFocusWithin } from \"runed\"; let formElement = $state(); const focusWithinForm = new IsFocusWithin(() => formElement); Focus within form: {focusWithinForm.current} Submit Type Definition class IsFocusWithin { constructor(node: MaybeGetter); readonly current: boolean; } `"},{"title":"IsIdle","href":"/docs/utilities/is-idle","description":"Track if a user is idle and the last time they were active.","content":" import Demo from '$lib/components/demos/is-idle.svelte'; IsIdle tracks user activity and determines if they're idle based on a configurable timeout. It monitors mouse movement, keyboard input, and touch events to detect user interaction. Demo Usage import { AnimationFrames, IsIdle } from \"runed\"; const idle = new IsIdle({ timeout: 1000 }); Idle: {idle.current} Last active: {new Date(idle.lastActive).toLocaleTimeString()} Type Definitions interface IsIdleOptions { /** The events that should set the idle state to true * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: MaybeGetter; /** The timeout in milliseconds before the idle state is set to true. Defaults to 60 seconds. * @default 60000 */ timeout?: MaybeGetter; /** Detect document visibility changes * @default false */ detectVisibilityChanges?: MaybeGetter; /** The initial state of the idle property * @default false */ initialState?: boolean; } class IsIdle { constructor(options?: IsIdleOptions); readonly current: boolean; readonly lastActive: number; } `"},{"title":"IsInViewport","href":"/docs/utilities/is-in-viewport","description":"Track if an element is visible within the current viewport.","content":" import Demo from '$lib/components/demos/is-in-viewport.svelte'; IsInViewport uses the $2 utility to track if an element is visible within the current viewport. It accepts an element or getter that returns an element and an optional options object that aligns with the $2 utility options. Demo Usage import { IsInViewport } from \"runed\"; let targetNode = $state()!; const inViewport = new IsInViewport(() => targetNode); Target node Target node in viewport: {inViewport.current} Type Definition import { type UseIntersectionObserverOptions } from \"runed\"; export type IsInViewportOptions = UseIntersectionObserverOptions; export declare class IsInViewport { constructor(node: MaybeGetter, options?: IsInViewportOptions); get current(): boolean; } "},{"title":"IsMounted","href":"/docs/utilities/is-mounted","description":"A class that returns the mounted state of the component it's called in.","content":" import Demo from '$lib/components/demos/is-mounted.svelte'; Demo Usage import { IsMounted } from \"runed\"; const isMounted = new IsMounted(); Which is a shorthand for one of the following: import { onMount } from \"svelte\"; const isMounted = $state({ current: false }); onMount(() => { isMounted.current = true; }); or import { untrack } from \"svelte\"; const isMounted = $state({ current: false }); $effect(() => { untrack(() => (isMounted.current = true)); }); `"},{"title":"onClickOutside","href":"/docs/utilities/on-click-outside","description":"Handle clicks outside of a specified element.","content":" import Demo from '$lib/components/demos/on-click-outside.svelte'; import DemoDialog from '$lib/components/demos/on-click-outside-dialog.svelte'; import { PropField } from '@svecodocs/kit' onClickOutside detects clicks that occur outside a specified element's boundaries and executes a callback function. It's commonly used for dismissible dropdowns, modals, and other interactive components. Demo Basic Usage import { onClickOutside } from \"runed\"; let container = $state()!; onClickOutside( () => container, () => console.log(\"clicked outside\") ); I'm outside the container Advanced Usage Controlled Listener The function returns control methods to programmatically manage the listener, start and stop and a reactive read-only property enabled to check the current status of the listeners. import { onClickOutside } from \"runed\"; let dialog = $state()!; const clickOutside = onClickOutside( () => dialog, () => { dialog.close(); clickOutside.stop(); }, { immediate: false } ); function openDialog() { dialog.showModal(); clickOutside.start(); } function closeDialog() { dialog.close(); clickOutside.stop(); } Open Dialog Close Dialog Here's an example of using onClickOutside with a ``: Options Whether the click outside handler is enabled by default or not. If set to false, the handler will not be active until enabled by calling the returned start function. Controls whether focus events from iframes trigger the callback. Since iframe click events don't bubble to the parent document, you may want to enable this if you need to detect when users interact with iframe content. The document object to use, defaults to the global document. The window object to use, defaults to the global window. Type Definitions export type OnClickOutsideOptions = ConfigurableWindow & ConfigurableDocument & { /** Whether the click outside handler is enabled by default or not. If set to false, the handler will not be active until enabled by calling the returned start function * @default true */ immediate?: boolean; /** Controls whether focus events from iframes trigger the callback. * Since iframe click events don't bubble to the parent document, you may want to enable this if you need to detect when users interact with iframe content. * @default false */ detectIframe?: boolean; }; /** A utility that calls a given callback when a click event occurs outside of a specified container element. * @template T - The type of the container element, defaults to HTMLElement. @param {MaybeElementGetter} container - The container element or a getter function that returns the container element. @param {() => void} callback - The callback function to call when a click event occurs outside of the container. @param {OnClickOutsideOptions} [opts={}] - Optional configuration object. @param {ConfigurableDocument} [opts.document=defaultDocument] - The document object to use, defaults to the global document. @param {boolean} [opts.immediate=true] - Whether the click outside handler is enabled by default or not. @param {boolean} [opts.detectIframe=false] - Controls whether focus events from iframes trigger the callback. * @see {@link https://runed.dev/docs/utilities/on-click-outside} */ export declare function onClickOutside( container: MaybeElementGetter, callback: (event: PointerEvent | FocusEvent) => void, opts?: OnClickOutsideOptions ): { /** Stop listening for click events outside the container. */ stop: () => boolean; /** Start listening for click events outside the container. */ start: () => boolean; /** Whether the click outside handler is currently enabled or not. */ readonly enabled: boolean; }; `"},{"title":"PersistedState","href":"/docs/utilities/persisted-state","description":"A reactive state manager that persists and synchronizes state across browser sessions and tabs using Web Storage APIs.","content":" import Demo from '$lib/components/demos/persisted-state.svelte'; import { Callout } from '@svecodocs/kit' PersistedState provides a reactive state container that automatically persists data to browser storage and optionally synchronizes changes across browser tabs in real-time. Demo You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs. Usage Initialize PersistedState by providing a unique key and an initial value for the state. import { PersistedState } from \"runed\"; const count = new PersistedState(\"count\", 0); count.current++}>Increment count.current--}>Decrement (count.current = 0)}>Reset Count: {count.current} Configuration Options PersistedState includes an options object that allows you to customize the behavior of the state manager. const state = new PersistedState(\"user-preferences\", initialValue, { // Use sessionStorage instead of localStorage (default: 'local') storage: \"session\", // Disable cross-tab synchronization (default: true) syncTabs: false, // Custom serialization handlers serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); Storage Options 'local': Data persists until explicitly cleared 'session': Data persists until the browser session ends Cross-Tab Synchronization When syncTabs is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. Custom Serialization Provide custom serialize and deserialize functions to handle complex data types: import superjson from \"superjson\"; // Example with Date objects const lastAccessed = new PersistedState(\"last-accessed\", new Date(), { serializer: { serialize: superjson.stringify, deserialize: superjson.parse } }); `"},{"title":"PressedKeys","href":"/docs/utilities/pressed-keys","description":"Tracks which keys are currently pressed","content":" import Demo from '$lib/components/demos/pressed-keys.svelte'; Demo Usage With an instance of PressedKeys, you can use the has method. const keys = new PressedKeys(); const isArrowDownPressed = $derived(keys.has(\"ArrowDown\")); const isCtrlAPressed = $derived(keys.has(\"Control\", \"a\")); Or get all of the currently pressed keys: const keys = new PressedKeys(); console.log(keys.all()); `"},{"title":"Previous","href":"/docs/utilities/previous","description":"A utility that tracks and provides access to the previous value of a reactive getter.","content":" import Demo from '$lib/components/demos/previous.svelte'; The Previous utility creates a reactive wrapper that maintains the previous value of a getter function. This is particularly useful when you need to compare state changes or implement transition effects. Demo Usage import { Previous } from \"runed\"; let count = $state(0); const previous = new Previous(() => count); count++}>Count: {count} Previous: {${previous.current}} Type Definition class Previous { constructor(getter: () => T); readonly current: T; // Previous value } `"},{"title":"resource","href":"/docs/utilities/resource","description":"Creates a reactive resource that combines reactive dependency tracking with async data fetching.","content":" import Demo from '$lib/components/demos/resource.svelte'; Demo Usage import { resource } from \"runed\"; let searchQuery = $state(\"react\"); const searchResults = resource( () => searchQuery, async (query) => { const response = await fetch(/api/search?q=${query}); if (!response.ok) throw new Error(\"Search failed\"); return response.json(); }, { debounce: 300 } // Debounce searches by 300ms ); {#if searchResults.loading} Loading... {:else if searchResults.error} Error: {searchResults.error.message} {:else} {#each searchResults.current?.results ?? [] as result} {result.title} {/each} {/if} searchResults.refetch()}> Refresh Results Features Automatic Request Cancellation**: When dependencies change, in-flight requests are automatically canceled Loading & Error States**: Built-in states for loading and error handling Debouncing & Throttling**: Optional debounce and throttle options for rate limiting Type Safety**: Full TypeScript support with inferred types Multiple Dependencies**: Support for tracking multiple reactive dependencies Custom Cleanup**: Register cleanup functions that run before refetching Pre-render Support**: resource.pre() for pre-render execution Advanced Usage Multiple Dependencies const results = resource([() => query, () => page], async ([query, page]) => { const res = await fetch(/api/search?q=${query}&page=${page}); return res.json(); }); Custom Cleanup const stream = resource( () => streamId, async (id, _, { signal, onCleanup }) => { const eventSource = new EventSource(/api/stream/${id}); onCleanup(() => eventSource.close()); const res = await fetch(/api/stream/${id}/init, { signal }); return res.json(); } ); Pre-render Execution const data = resource.pre( () => query, async (query) => { const res = await fetch(/api/search?q=${query}); return res.json(); } ); Type Definitions type ResourceOptions = { /** Skip initial fetch when true */ lazy?: boolean; /** Only fetch once when true */ once?: boolean; /** Initial value for the resource */ initialValue?: Data; /** Debounce time in milliseconds */ debounce?: number; /** Throttle time in milliseconds */ throttle?: number; }; type ResourceState = { /** Current value of the resource */ current: Data | undefined; /** Whether the resource is currently loading */ loading: boolean; /** Error if the fetch failed */ error: Error | undefined; }; type ResourceReturn = ResourceState & { /** Update the resource value directly */ mutate: (value: Data) => void; /** Re-run the fetcher with current values */ refetch: (info?: RefetchInfo) => Promise; }; Configuration Options lazy When true, skips the initial fetch. The resource will only fetch when dependencies change or refetch() is called. once When true, only fetches once. Subsequent dependency changes won't trigger new fetches. initialValue Provides an initial value for the resource before the first fetch completes. debounce Time in milliseconds to debounce rapid changes. Useful for search inputs. throttle Time in milliseconds to throttle rapid changes. Useful for real-time updates."},{"title":"StateHistory","href":"/docs/utilities/state-history","description":"Track state changes with undo/redo capabilities","content":" import Demo from '$lib/components/demos/state-history.svelte'; Demo Usage StateHistory tracks a getter's return value, logging each change into an array. A setter is also required to use the undo and redo functions. import { StateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); history.log[0]; // { snapshot: 0, timestamp: ... } Besides log, the returned object contains undo and redo functionality. import { useStateHistory } from \"runed\"; let count = $state(0); const history = new StateHistory(() => count, (c) => (count = c)); function format(ts: number) { return new Date(ts).toLocaleString(); } {count} count++}>Increment count--}>Decrement Undo Redo `"},{"title":"useDebounce","href":"/docs/utilities/use-debounce","description":"A higher-order function that debounces the execution of a function.","content":" import Demo from '$lib/components/demos/use-debounce.svelte'; useDebounce is a utility function that creates a debounced version of a callback function. Debouncing prevents a function from being called too frequently by delaying its execution until after a specified duration of inactivity. Demo Usage import { useDebounce } from \"runed\"; let count = $state(0); let logged = $state(\"\"); let isFirstTime = $state(true); let debounceDuration = $state(1000); const logCount = useDebounce( () => { if (isFirstTime) { isFirstTime = false; logged = You pressed the button ${count} times!; } else { logged = You pressed the button ${count} times since last time!; } count = 0; }, () => debounceDuration ); function ding() { count++; logCount(); } DING DING DING Run now Cancel message {logged || \"Press the button!\"} `"},{"title":"useEventListener","href":"/docs/utilities/use-event-listener","description":"A function that attaches an automatically disposed event listener.","content":" import Demo from '$lib/components/demos/use-event-listener.svelte'; Demo Usage The useEventListener function is particularly useful for attaching event listeners to elements you don't directly control. For instance, if you need to listen for events on the document body or window and can't use ``, or if you receive an element reference from a parent component. Example: Tracking Clicks on the Document // ClickLogger.ts import { useEventListener } from \"runed\"; export class ClickLogger { #clicks = $state(0); constructor() { useEventListener( () => document.body, \"click\", () => this.#clicks++ ); } get clicks() { return this.#clicks; } } This ClickLogger class tracks the number of clicks on the document body using the useEventListener function. Each time a click occurs, the internal counter increments. Svelte Component Usage import { ClickLogger } from \"./ClickLogger.ts\"; const logger = new ClickLogger(); You've clicked the document {logger.clicks} {logger.clicks === 1 ? \"time\" : \"times\"} In the component above, we create an instance of the ClickLogger class to monitor clicks on the document. The displayed text updates dynamically based on the recorded click count. Key Points Automatic Cleanup:** The event listener is removed automatically when the component is destroyed or when the element reference changes. Lazy Initialization:** The target element can be defined using a function, enabling flexible and dynamic behavior. Convenient for Global Listeners:** Ideal for scenarios where attaching event listeners directly to the DOM elements is cumbersome or impractical."},{"title":"useGeolocation","href":"/docs/utilities/use-geolocation","description":"Reactive access to the browser's Geolocation API.","content":" import Demo from '$lib/components/demos/use-geolocation.svelte'; useGeolocation is a reactive wrapper around the browser's $2. Demo Usage import { useGeolocation } from \"runed\"; const location = useGeolocation(); Coords: {JSON.stringify(location.position.coords, null, 2)} Located at: {location.position.timestamp} Error: {JSON.stringify(location.error, null, 2)} Is Supported: {location.isSupported} Pause Resume Type Definitions type UseGeolocationOptions = Partial & { /** Whether to start the watcher immediately upon creation. If set to false, the watcher will only start tracking the position when resume() is called. * @defaultValue true */ immediate?: boolean; }; type UseGeolocationReturn = { readonly isSupported: boolean; readonly position: Omit; readonly error: GeolocationPositionError | null; readonly isPaused: boolean; pause: () => void; resume: () => void; }; `"},{"title":"useIntersectionObserver","href":"/docs/utilities/use-intersection-observer","description":"Watch for intersection changes of a target element.","content":" import Demo from '$lib/components/demos/use-intersection-observer.svelte'; import { Callout } from '@svecodocs/kit' Demo Usage With a reference to an element, you can use the useIntersectionObserver utility to watch for intersection changes of the target element. import { useIntersectionObserver } from \"runed\"; let target = $state(null); let root = $state(null); let isIntersecting = $state(false); useIntersectionObserver( () => target, (entries) => { const entry = entries[0]; if (!entry) return; isIntersecting = entry.isIntersecting; }, { root: () => root } ); {#if isIntersecting} Target is intersecting {:else} Target is not intersecting {/if} Pause You can pause the intersection observer at any point by calling the pause method. const observer = useIntersectionObserver(/* ... */); observer.pause(); Resume You can resume the intersection observer at any point by calling the resume method. const observer = useIntersectionObserver(/* ... */); observer.resume(); Stop You can stop the intersection observer at any point by calling the stop method. const observer = useIntersectionObserver(/* ... */); observer.stop(); isActive You can check if the intersection observer is active by checking the isActive property. This property cannot be destructured as it is a getter. You must access it directly from the observer. const observer = useIntersectionObserver(/* ... */); if (observer.isActive) { // do something } `"},{"title":"useMutationObserver","href":"/docs/utilities/use-mutation-observer","description":"Observe changes in an element","content":" import Demo from '$lib/components/demos/use-mutation-observer.svelte'; Demo Usage With a reference to an element, you can use the useMutationObserver hook to observe changes in the element. import { useMutationObserver } from \"runed\"; let el = $state(null); const messages = $state([]); let className = $state(\"\"); let style = $state(\"\"); useMutationObserver( () => el, (mutations) => { const mutation = mutations[0]; if (!mutation) return; messages.push(mutation.attributeName!); }, { attributes: true } ); setTimeout(() => { className = \"text-brand\"; }, 1000); setTimeout(() => { style = \"font-style: italic;\"; }, 1500); {#each messages as text} Mutation Attribute: {text} {:else} No mutations yet {/each} You can stop the mutation observer at any point by calling the stop method. const { stop } = useMutationObserver(/* ... */); stop(); `"},{"title":"useResizeObserver","href":"/docs/utilities/use-resize-observer","description":"Detects changes in the size of an element","content":" import Demo from '$lib/components/demos/use-resize-observer.svelte'; Demo Usage With a reference to an element, you can use the useResizeObserver utility to detect changes in the size of an element. import { useResizeObserver } from \"runed\"; let el = $state(null); let text = $state(\"\"); useResizeObserver( () => el, (entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; text = width: ${width};\\nheight: ${height};; } ); You can stop the resize observer at any point by calling the stop method. const { stop } = useResizeObserver(/* ... */); stop(); `"},{"title":"watch","href":"/docs/utilities/watch","description":"Watch for changes and run a callback","content":"Runes provide a handy way of running a callback when reactive values change: $2. It automatically detects when inner values change, and re-runs the callback. $effect is great, but sometimes you want to manually specify which values should trigger the callback. Svelte provides an untrack function, allowing you to specify that a dependency shouldn't be tracked, but it doesn't provide a way to say that only certain values should be tracked. watch does exactly that. It accepts a getter function, which returns the dependencies of the effect callback. Usage watch Runs a callback whenever one of the sources change. import { watch } from \"runed\"; let count = $state(0); watch(() => count, () => { console.log(count); } ); The callback receives two arguments: The current value of the sources, and the previous value. let count = $state(0); watch(() => count, (curr, prev) => { console.log(count is ${curr}, was ${prev}); } ); You can also send in an array of sources: let age = $state(20); let name = $state(\"bob\"); watch([() => age, () => name], ([age, name], [prevAge, prevName]) => { // ... } watch also accepts an options object. watch(sources, callback, { // First run will only happen after sources change when set to true. // By default, its false. lazy: true }); watch.pre watch.pre is similar to watch, but it uses $2 under the hood. watchOnce In case you want to run the callback only once, you can use watchOnce and watchOnce.pre. It functions identically to the watch and watch.pre otherwise, but it does not accept any options object."}] \ No newline at end of file