diff --git a/.changeset/add-memo-utility.md b/.changeset/add-memo-utility.md new file mode 100644 index 00000000..ddad1b7a --- /dev/null +++ b/.changeset/add-memo-utility.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +Add `memo` utility — a `$derived` wrapper that lets you specify exactly which sources should trigger recomputation, the same way `watch` does for `$effect`. diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index 55f528b8..459a3256 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -11,6 +11,7 @@ export * from "./is-idle/index.js"; export * from "./is-in-viewport/index.js"; export * from "./is-mounted/index.js"; export * from "./is-document-visible/index.js"; +export * from "./memo/index.js"; export * from "./on-click-outside/index.js"; export * from "./persisted-state/index.js"; export * from "./pressed-keys/index.js"; diff --git a/packages/runed/src/lib/utilities/memo/index.ts b/packages/runed/src/lib/utilities/memo/index.ts new file mode 100644 index 00000000..817fe657 --- /dev/null +++ b/packages/runed/src/lib/utilities/memo/index.ts @@ -0,0 +1 @@ +export * from "./memo.svelte.js"; diff --git a/packages/runed/src/lib/utilities/memo/memo.svelte.ts b/packages/runed/src/lib/utilities/memo/memo.svelte.ts new file mode 100644 index 00000000..4d471d75 --- /dev/null +++ b/packages/runed/src/lib/utilities/memo/memo.svelte.ts @@ -0,0 +1,36 @@ +import { untrack } from "svelte"; +import type { Getter } from "$lib/internal/types.js"; + +/** + * The reactive value produced by `memo`. Read it via `.current`. + */ +export interface Memo { + readonly current: T; +} + +class MemoImpl implements Memo { + #value: T; + + constructor(compute: () => T) { + this.#value = $derived.by(compute); + } + + get current(): T { + return this.#value; + } +} + +export function memo( + sources: Getter | Array>, + compute: () => R +): Memo { + return new MemoImpl(() => { + // Touch the sources so they register as dependencies. + if (Array.isArray(sources)) { + for (const source of sources) source(); + } else { + sources(); + } + return untrack(compute); + }); +} diff --git a/packages/runed/src/lib/utilities/memo/memo.test.svelte.ts b/packages/runed/src/lib/utilities/memo/memo.test.svelte.ts new file mode 100644 index 00000000..0c622b55 --- /dev/null +++ b/packages/runed/src/lib/utilities/memo/memo.test.svelte.ts @@ -0,0 +1,99 @@ +import { describe, expect } from "vitest"; +import { memo } from "./memo.svelte.js"; +import { testWithEffect } from "$lib/test/util.svelte.js"; +import { sleep } from "$lib/internal/utils/sleep.js"; + +describe("memo", () => { + testWithEffect("recomputes when a tracked source changes", async () => { + let count = $state(2); + const doubled = memo( + () => count, + () => count * 2 + ); + + expect(doubled.current).toBe(4); + + count = 5; + await sleep(0); + expect(doubled.current).toBe(10); + }); + + testWithEffect("does not recompute when an untracked source changes", async () => { + let tracked = $state(1); + let untracked = $state(100); + let runs = 0; + + const value = memo( + () => tracked, + () => { + runs++; + // reading `untracked` here must NOT register as a dependency + return tracked + untracked; + } + ); + + expect(value.current).toBe(101); + expect(runs).toBe(1); + + untracked = 200; + await sleep(0); + expect(value.current).toBe(101); + expect(runs).toBe(1); + + tracked = 2; + await sleep(0); + expect(value.current).toBe(202); + expect(runs).toBe(2); + }); + + testWithEffect("supports an array of sources", async () => { + let a = $state(1); + let b = $state(2); + + const sum = memo( + [() => a, () => b], + () => a + b + ); + + expect(sum.current).toBe(3); + + a = 10; + await sleep(0); + expect(sum.current).toBe(12); + + b = 20; + await sleep(0); + expect(sum.current).toBe(30); + }); + + testWithEffect("only the listed sources trigger recomputation", async () => { + let tracked = $state(1); + let alsoRead = $state(10); + let runs = 0; + + const value = memo( + () => tracked, + () => { + runs++; + return tracked + alsoRead; + } + ); + + expect(value.current).toBe(11); + expect(runs).toBe(1); + + // Changing a value that's read inside the compute body but not declared + // as a source should not cause a recomputation. + alsoRead = 20; + await sleep(0); + expect(value.current).toBe(11); + expect(runs).toBe(1); + + // Changing the declared source should cause a recomputation, and it will + // pick up the latest value of the un-tracked read as well. + tracked = 2; + await sleep(0); + expect(value.current).toBe(22); + expect(runs).toBe(2); + }); +}); diff --git a/sites/docs/src/content/utilities/memo.md b/sites/docs/src/content/utilities/memo.md new file mode 100644 index 00000000..3e3ad26a --- /dev/null +++ b/sites/docs/src/content/utilities/memo.md @@ -0,0 +1,55 @@ +--- +title: memo +description: Compute a derived value from explicit dependencies +category: Reactivity +--- + +Runes provide a handy way of computing a value from reactive sources: +[`$derived`](https://svelte.dev/docs/svelte/$derived). It automatically detects which inner +values are read, and re-computes when they change. + +`$derived` is great, but sometimes you want to manually specify which values should trigger +recomputation. 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. + +`memo` does exactly that. It accepts a getter function, which returns the dependencies of the +computation, and a compute function that produces the value. + +## Usage + +### memo + +Computes a value whenever one of the sources changes. The result is exposed through `current`. + + +```ts +import { memo } from "runed"; + +let count = $state(2); +const doubled = memo(() => count, () => count * 2); + +doubled.current; // 4 +``` + +You can also send in an array of sources. + + +```ts +let a = $state(1); +let b = $state(2); +const sum = memo([() => a, () => b], () => a + b); +``` + +Reads inside the compute function are not tracked, so you can freely access other reactive state +without it triggering a recomputation. + + +```ts +let count = $state(0); +let multiplier = $state(2); + +// only `count` is tracked — changing `multiplier` will not recompute the value, +// but the next recomputation (triggered by `count`) will read its latest value. +const value = memo(() => count, () => count * multiplier); +```