From 80c6db8cdd7bebc34564384db812084a2d520d23 Mon Sep 17 00:00:00 2001 From: Tito Bouzout Date: Wed, 29 Apr 2026 15:21:17 -0300 Subject: [PATCH] adds equals:true --- .changeset/equals-true-run-once-memo.md | 14 ++++ packages/solid-signals/src/core/core.ts | 7 +- packages/solid-signals/src/core/index.ts | 1 + packages/solid-signals/src/core/types.ts | 2 +- packages/solid-signals/src/index.ts | 1 + packages/solid-signals/src/signals.ts | 24 ++++--- .../solid-signals/tests/createMemo.test.ts | 66 +++++++++++++++++++ packages/solid/CHEATSHEET.md | 2 + 8 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 .changeset/equals-true-run-once-memo.md diff --git a/.changeset/equals-true-run-once-memo.md b/.changeset/equals-true-run-once-memo.md new file mode 100644 index 000000000..14ff17bf2 --- /dev/null +++ b/.changeset/equals-true-run-once-memo.md @@ -0,0 +1,14 @@ +--- +"solid-js": minor +--- + +Adds `equals: true` to the signal/memo options, the symmetric counterpart of +`equals: false`. Where `equals: false` always notifies subscribers, `equals: true` +never does — the cached value is frozen at the first computed result and +downstream consumers see a constant. Backed by a new exported helper +`isAlwaysEqual` (mirror of `isEqual`). + +The compute function still runs when its dependencies change; the new value is +just discarded by the equality check, so subscribers and reads keep returning +the original. For writable memos, setter writes are likewise dropped — the +"always equal" guarantee applies uniformly. diff --git a/packages/solid-signals/src/core/core.ts b/packages/solid-signals/src/core/core.ts index b1402ec22..0f95165a7 100644 --- a/packages/solid-signals/src/core/core.ts +++ b/packages/solid-signals/src/core/core.ts @@ -341,7 +341,8 @@ export function computed( (options?.ownedWrite ? CONFIG_OWNED_WRITE : 0) | (!context || options?.lazy ? CONFIG_AUTO_DISPOSE : 0) | (snapshotCaptureActive && ownerInSnapshotScope(context) ? CONFIG_IN_SNAPSHOT_SCOPE : 0), - _equals: options?.equals != null ? options.equals : isEqual, + _equals: + options?.equals === true ? isAlwaysEqual : options?.equals != null ? options.equals : isEqual, _unobserved: options?.unobserved, _disposal: null, _queue: context?._queue ?? globalQueue, @@ -478,6 +479,10 @@ export function isEqual(a: T, b: T): boolean { return a === b; } +export function isAlwaysEqual(_a: T, _b: T): boolean { + return true; +} + /** * When set to a component name string, any reactive read that is not inside a nested tracking * scope will log a dev-mode warning. Managed automatically by `untrack(fn, strictReadLabel)`. diff --git a/packages/solid-signals/src/core/index.ts b/packages/solid-signals/src/core/index.ts index 33f1e24e8..f1efd14ed 100644 --- a/packages/solid-signals/src/core/index.ts +++ b/packages/solid-signals/src/core/index.ts @@ -1,6 +1,7 @@ export { ContextNotFoundError, NoOwnerError, NotReadyError } from "./error.js"; export { isEqual, + isAlwaysEqual, untrack, runWithOwner, computed, diff --git a/packages/solid-signals/src/core/types.ts b/packages/solid-signals/src/core/types.ts index f54d34cc0..10f7c3b3f 100644 --- a/packages/solid-signals/src/core/types.ts +++ b/packages/solid-signals/src/core/types.ts @@ -17,7 +17,7 @@ export interface NodeOptions { id?: string; name?: string; transparent?: boolean; - equals?: ((prev: T, next: T) => boolean) | false; + equals?: ((prev: T, next: T) => boolean) | false | true; ownedWrite?: boolean; /** Exclude this signal from snapshot capture (internal — not part of public API) */ _noSnapshot?: boolean; diff --git a/packages/solid-signals/src/index.ts b/packages/solid-signals/src/index.ts index 803d1ba94..eca7c8915 100644 --- a/packages/solid-signals/src/index.ts +++ b/packages/solid-signals/src/index.ts @@ -17,6 +17,7 @@ export { isDisposed, getObserver, isEqual, + isAlwaysEqual, untrack, isPending, latest, diff --git a/packages/solid-signals/src/signals.ts b/packages/solid-signals/src/signals.ts index 92c7f845e..b0b7ce9cb 100644 --- a/packages/solid-signals/src/signals.ts +++ b/packages/solid-signals/src/signals.ts @@ -189,12 +189,16 @@ export interface MemoOptions { /** When true, the owner is invisible to the ID scheme -- inherits parent ID and doesn't consume a childCount slot */ transparent?: boolean; /** - * Custom equality function, or `false` to always notify subscribers. + * Custom equality function, or `false` to always notify subscribers, or + * `true` to never notify them — the cached value is frozen at the first + * computed result and downstream consumers see a constant. The compute + * function still re-runs when its dependencies change, but the new value + * is discarded by the equality check (backed by `isAlwaysEqual`). + * * Defaults to reference equality (`isEqual`). Pass a comparator (e.g. - * `(a, b) => a.id === b.id`) for value-based equality, or `false` to - * notify on every recompute regardless of equality. + * `(a, b) => a.id === b.id`) for value-based equality. */ - equals?: false | ((prev: T, next: T) => boolean); + equals?: true | false | ((prev: T, next: T) => boolean); /** Callback invoked when the computed loses all subscribers */ unobserved?: () => void; /** @@ -223,7 +227,7 @@ export type NoInfer = [T][T extends any ? 0 : never]; * // Plain signal * const [state, setState] = createSignal(value, options?: SignalOptions); * // Writable memo (function overload) - * const [state, setState] = createSignal(fn, initialValue?, options?: SignalOptions & MemoOptions); + * const [state, setState] = createSignal(fn, initialValue?, options?: Omit, "equals"> & MemoOptions); * ``` * @param value initial value of the state; if empty, the state's type will automatically extended with undefined * @param options optional object with a name for debugging purposes and equals, a comparator function for the previous and next value to allow fine-grained control over the reactivity @@ -253,11 +257,11 @@ export function createSignal(): Signal; export function createSignal(value: Exclude, options?: SignalOptions): Signal; export function createSignal( fn: ComputeFunction, - options?: SignalOptions & MemoOptions + options?: Omit, "equals"> & MemoOptions ): Signal; export function createSignal( first?: T | ComputeFunction, - second?: SignalOptions & MemoOptions + second?: Omit, "equals"> & MemoOptions ): Signal { if (typeof first === "function") { const node = computed(first as any, second as any); @@ -594,7 +598,7 @@ export function resolve(fn: () => T): Promise { * // Plain optimistic signal * const [state, setState] = createOptimistic(value, options?: SignalOptions); * // Writable optimistic memo (function overload) - * const [state, setState] = createOptimistic(fn, options?: SignalOptions & MemoOptions); + * const [state, setState] = createOptimistic(fn, options?: Omit, "equals"> & MemoOptions); * ``` * @param value initial value of the signal; if empty, the signal's type will automatically extended with undefined * @param options optional object with a name for debugging purposes and equals, a comparator function for the previous and next value to allow fine-grained control over the reactivity @@ -622,11 +626,11 @@ export function createOptimistic( ): Signal; export function createOptimistic( fn: ComputeFunction, - options?: SignalOptions & MemoOptions + options?: Omit, "equals"> & MemoOptions ): Signal; export function createOptimistic( first?: T | ComputeFunction, - second?: SignalOptions & MemoOptions + second?: Omit, "equals"> & MemoOptions ): Signal { if (typeof first === "function") { const node = optimisticComputed(first as any, second as any); diff --git a/packages/solid-signals/tests/createMemo.test.ts b/packages/solid-signals/tests/createMemo.test.ts index 1c968ef5f..7747091c2 100644 --- a/packages/solid-signals/tests/createMemo.test.ts +++ b/packages/solid-signals/tests/createMemo.test.ts @@ -257,6 +257,72 @@ it("should ignore equals before memo initialization", () => { expect($a()).toBe(1); }); +describe("equals: true (always-equal)", () => { + it("should freeze the cached value and never re-notify subscribers", () => { + const [$x, setX] = createSignal(1); + const downstream = vi.fn(); + let read!: () => number; + + createRoot(() => { + const $a = createMemo(() => $x() * 10, { equals: true }); + createEffect($a, downstream); + read = () => $a(); + }); + flush(); + + expect(read()).toBe(10); + expect(downstream).toHaveBeenCalledTimes(1); + + setX(2); + flush(); + expect(read()).toBe(10); + expect(downstream).toHaveBeenCalledTimes(1); + + setX(3); + flush(); + expect(read()).toBe(10); + expect(downstream).toHaveBeenCalledTimes(1); + }); + + it("should freeze a writable memo's value against both deps and setter writes", () => { + const [$x, setX] = createSignal(1); + let read!: () => number; + let setA!: (v: number) => void; + + createRoot(() => { + const [$a, set] = createSignal(() => $x() + 100, { equals: true }); + read = () => $a(); + setA = set as (v: number) => void; + }); + + expect(read()).toBe(101); + + setX(2); + flush(); + expect(read()).toBe(101); + + setA(999); + flush(); + expect(read()).toBe(101); + }); + + it("should defer first run when combined with lazy", () => { + const compute = vi.fn(() => 42); + const $a = createMemo(compute, { equals: true, lazy: true }); + + expect(compute).toHaveBeenCalledTimes(0); + expect($a()).toBe(42); + expect(compute).toHaveBeenCalledTimes(1); + }); + + it("should reject equals: true on plain signals at the type level", () => { + // @ts-expect-error -- equals: true is memo-only + createSignal(0, { equals: true }); + // sanity: the writable-memo overload still accepts it + createSignal(() => 0, { equals: true }); + }); +}); + it("should route init errors through the boundary without a memo fallback", () => { createRoot(() => { createErrorBoundary( diff --git a/packages/solid/CHEATSHEET.md b/packages/solid/CHEATSHEET.md index 7b3538cf6..e59e7b774 100644 --- a/packages/solid/CHEATSHEET.md +++ b/packages/solid/CHEATSHEET.md @@ -49,6 +49,8 @@ createSignal(0, { ownedWrite: true }); // allow writes from inside own createSignal(0, { unobserved: () => cleanup() });// fires when no subscribers createMemo(fn, { lazy: true }); // defer first compute until read; autodispose when unobserved createMemo(fn, { equals: (a, b) => a.id === b.id }); +createMemo(fn, { equals: false }); // always notify (every recompute propagates) +createMemo(fn, { equals: true }); // never notify (cached value frozen at first result) ``` **Reads update only after flush.** `setX(v); x()` returns the *previous* value until the next microtask or `flush()`.