From 992e422e085aa2a4cbc3fbc06ebb95bae636b561 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Thu, 7 May 2026 10:17:54 +0200 Subject: [PATCH 1/2] feat: add memo utility `memo` is to `$derived` what `watch` is to `$effect`: it accepts an explicit list of source getters and only recomputes when one of them changes. Reads inside the compute body are not tracked. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/add-memo-utility.md | 5 + packages/runed/src/lib/utilities/index.ts | 1 + .../runed/src/lib/utilities/memo/index.ts | 1 + .../src/lib/utilities/memo/memo.svelte.ts | 57 +++++++++ .../lib/utilities/memo/memo.test.svelte.ts | 110 ++++++++++++++++++ sites/docs/src/content/utilities/memo.md | 56 +++++++++ 6 files changed, 230 insertions(+) create mode 100644 .changeset/add-memo-utility.md create mode 100644 packages/runed/src/lib/utilities/memo/index.ts create mode 100644 packages/runed/src/lib/utilities/memo/memo.svelte.ts create mode 100644 packages/runed/src/lib/utilities/memo/memo.test.svelte.ts create mode 100644 sites/docs/src/content/utilities/memo.md 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..105a2bdc --- /dev/null +++ b/packages/runed/src/lib/utilities/memo/memo.svelte.ts @@ -0,0 +1,57 @@ +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, R>( + sources: { [K in keyof T]: Getter }, + compute: ( + values: T, + previousValues: { [K in keyof T]: T[K] | undefined } + ) => R +): Memo; + +export function memo( + source: Getter, + compute: (value: T, previousValue: T | undefined) => R +): Memo; + +export function memo( + sources: Getter | Array>, + compute: ( + values: T | Array, + previousValues: T | undefined | Array + ) => R +): Memo { + // Match `watch`'s convention: arrays start with `[]` so the callback can + // destructure on the first run, single sources start with `undefined`. + let previousValues: T | undefined | Array = Array.isArray(sources) + ? [] + : undefined; + + return new MemoImpl(() => { + const values = Array.isArray(sources) + ? sources.map((source) => source()) + : sources(); + const result = untrack(() => compute(values, previousValues)); + previousValues = values; + return result; + }); +} 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..7f4b4ce8 --- /dev/null +++ b/packages/runed/src/lib/utilities/memo/memo.test.svelte.ts @@ -0,0 +1,110 @@ +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, + (n) => n * 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, + (n) => { + runs++; + // reading `untracked` here must NOT register as a dependency + return n + untracked; + } + ); + + expect(value.current).toBe(101); + expect(runs).toBe(1); + + untracked = 200; + await sleep(0); + // reading current shouldn't recompute since the dep didn't change + expect(value.current).toBe(101); + expect(runs).toBe(1); + + tracked = 2; + await sleep(0); + expect(value.current).toBe(202); + expect(runs).toBe(2); + }); + + testWithEffect("passes the previous value to the compute fn", async () => { + let count = $state(0); + const seen: Array<[number, number | undefined]> = []; + + const value = memo( + () => count, + (curr, prev) => { + seen.push([curr, prev]); + return curr; + } + ); + + // Force the first computation. + expect(value.current).toBe(0); + expect(seen).toEqual([[0, undefined]]); + + count = 1; + await sleep(0); + expect(value.current).toBe(1); + expect(seen).toEqual([ + [0, undefined], + [1, 0], + ]); + }); + + testWithEffect("supports an array of sources", async () => { + let a = $state(1); + let b = $state(2); + + const sum = memo([() => a, () => b], ([x, y]) => x + y); + + 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("array sources start with an empty array as previous", () => { + return new Promise((resolve) => { + const a = $state(1); + const b = $state(2); + + const value = memo([() => a, () => b], ([x, y], [px, py]) => { + expect(x).toBe(1); + expect(y).toBe(2); + expect(px).toBe(undefined); + expect(py).toBe(undefined); + resolve(); + return x + y; + }); + + // trigger the derivation + void value.current; + }); + }); +}); diff --git a/sites/docs/src/content/utilities/memo.md b/sites/docs/src/content/utilities/memo.md new file mode 100644 index 00000000..d2628dbb --- /dev/null +++ b/sites/docs/src/content/utilities/memo.md @@ -0,0 +1,56 @@ +--- +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 that returns the dependencies, and a +compute function that produces the value. Only the dependencies are tracked. + +## 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, (n) => n * 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]) => a + b); +``` + +The compute function receives two arguments: the current value of the sources, and the previous +value. + + +```ts +let count = $state(0); +const message = memo(() => count, (curr, prev) => { + return `count is ${curr}, was ${prev}`; +}); +``` + +Reads inside the compute function are not tracked, so you can freely access other reactive state +without it triggering a recomputation. From 2ea24d17593b99a3c89a11c2db403f2d7df5d1b9 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Thu, 7 May 2026 10:28:11 +0200 Subject: [PATCH 2/2] refactor(memo): drop value args from compute, mirror watch's API split The first argument now exists solely to register dependencies; the compute callback takes no arguments and just produces the value. This matches the shape of `watch` more directly: arg 1 = "what triggers reactivity", arg 2 = "what to do". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/utilities/memo/memo.svelte.ts | 41 +++------ .../lib/utilities/memo/memo.test.svelte.ts | 83 ++++++++----------- sites/docs/src/content/utilities/memo.md | 23 +++-- 3 files changed, 57 insertions(+), 90 deletions(-) diff --git a/packages/runed/src/lib/utilities/memo/memo.svelte.ts b/packages/runed/src/lib/utilities/memo/memo.svelte.ts index 105a2bdc..4d471d75 100644 --- a/packages/runed/src/lib/utilities/memo/memo.svelte.ts +++ b/packages/runed/src/lib/utilities/memo/memo.svelte.ts @@ -20,38 +20,17 @@ class MemoImpl implements Memo { } } -export function memo, R>( - sources: { [K in keyof T]: Getter }, - compute: ( - values: T, - previousValues: { [K in keyof T]: T[K] | undefined } - ) => R -): Memo; - -export function memo( - source: Getter, - compute: (value: T, previousValue: T | undefined) => R -): Memo; - -export function memo( - sources: Getter | Array>, - compute: ( - values: T | Array, - previousValues: T | undefined | Array - ) => R +export function memo( + sources: Getter | Array>, + compute: () => R ): Memo { - // Match `watch`'s convention: arrays start with `[]` so the callback can - // destructure on the first run, single sources start with `undefined`. - let previousValues: T | undefined | Array = Array.isArray(sources) - ? [] - : undefined; - return new MemoImpl(() => { - const values = Array.isArray(sources) - ? sources.map((source) => source()) - : sources(); - const result = untrack(() => compute(values, previousValues)); - previousValues = values; - return result; + // 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 index 7f4b4ce8..0c622b55 100644 --- a/packages/runed/src/lib/utilities/memo/memo.test.svelte.ts +++ b/packages/runed/src/lib/utilities/memo/memo.test.svelte.ts @@ -8,7 +8,7 @@ describe("memo", () => { let count = $state(2); const doubled = memo( () => count, - (n) => n * 2 + () => count * 2 ); expect(doubled.current).toBe(4); @@ -25,10 +25,10 @@ describe("memo", () => { const value = memo( () => tracked, - (n) => { + () => { runs++; // reading `untracked` here must NOT register as a dependency - return n + untracked; + return tracked + untracked; } ); @@ -37,7 +37,6 @@ describe("memo", () => { untracked = 200; await sleep(0); - // reading current shouldn't recompute since the dep didn't change expect(value.current).toBe(101); expect(runs).toBe(1); @@ -47,36 +46,14 @@ describe("memo", () => { expect(runs).toBe(2); }); - testWithEffect("passes the previous value to the compute fn", async () => { - let count = $state(0); - const seen: Array<[number, number | undefined]> = []; - - const value = memo( - () => count, - (curr, prev) => { - seen.push([curr, prev]); - return curr; - } - ); - - // Force the first computation. - expect(value.current).toBe(0); - expect(seen).toEqual([[0, undefined]]); - - count = 1; - await sleep(0); - expect(value.current).toBe(1); - expect(seen).toEqual([ - [0, undefined], - [1, 0], - ]); - }); - testWithEffect("supports an array of sources", async () => { let a = $state(1); let b = $state(2); - const sum = memo([() => a, () => b], ([x, y]) => x + y); + const sum = memo( + [() => a, () => b], + () => a + b + ); expect(sum.current).toBe(3); @@ -89,22 +66,34 @@ describe("memo", () => { expect(sum.current).toBe(30); }); - testWithEffect("array sources start with an empty array as previous", () => { - return new Promise((resolve) => { - const a = $state(1); - const b = $state(2); - - const value = memo([() => a, () => b], ([x, y], [px, py]) => { - expect(x).toBe(1); - expect(y).toBe(2); - expect(px).toBe(undefined); - expect(py).toBe(undefined); - resolve(); - return x + y; - }); - - // trigger the derivation - void value.current; - }); + 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 index d2628dbb..3e3ad26a 100644 --- a/sites/docs/src/content/utilities/memo.md +++ b/sites/docs/src/content/utilities/memo.md @@ -13,8 +13,8 @@ recomputation. Svelte provides an `untrack` function, allowing you to specify th _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 that returns the dependencies, and a -compute function that produces the value. Only the dependencies are 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 @@ -27,7 +27,7 @@ Computes a value whenever one of the sources changes. The result is exposed thro import { memo } from "runed"; let count = $state(2); -const doubled = memo(() => count, (n) => n * 2); +const doubled = memo(() => count, () => count * 2); doubled.current; // 4 ``` @@ -38,19 +38,18 @@ 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]) => a + b); +const sum = memo([() => a, () => b], () => a + b); ``` -The compute function receives two arguments: the current value of the sources, and the previous -value. +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); -const message = memo(() => count, (curr, prev) => { - return `count is ${curr}, was ${prev}`; -}); -``` +let multiplier = $state(2); -Reads inside the compute function are not tracked, so you can freely access other reactive state -without it triggering a recomputation. +// 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); +```