Skip to content

Commit 80c6db8

Browse files
committed
adds equals:true
1 parent 59dd11f commit 80c6db8

8 files changed

Lines changed: 105 additions & 12 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"solid-js": minor
3+
---
4+
5+
Adds `equals: true` to the signal/memo options, the symmetric counterpart of
6+
`equals: false`. Where `equals: false` always notifies subscribers, `equals: true`
7+
never does — the cached value is frozen at the first computed result and
8+
downstream consumers see a constant. Backed by a new exported helper
9+
`isAlwaysEqual` (mirror of `isEqual`).
10+
11+
The compute function still runs when its dependencies change; the new value is
12+
just discarded by the equality check, so subscribers and reads keep returning
13+
the original. For writable memos, setter writes are likewise dropped — the
14+
"always equal" guarantee applies uniformly.

packages/solid-signals/src/core/core.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,8 @@ export function computed<T>(
341341
(options?.ownedWrite ? CONFIG_OWNED_WRITE : 0) |
342342
(!context || options?.lazy ? CONFIG_AUTO_DISPOSE : 0) |
343343
(snapshotCaptureActive && ownerInSnapshotScope(context) ? CONFIG_IN_SNAPSHOT_SCOPE : 0),
344-
_equals: options?.equals != null ? options.equals : isEqual,
344+
_equals:
345+
options?.equals === true ? isAlwaysEqual : options?.equals != null ? options.equals : isEqual,
345346
_unobserved: options?.unobserved,
346347
_disposal: null,
347348
_queue: context?._queue ?? globalQueue,
@@ -478,6 +479,10 @@ export function isEqual<T>(a: T, b: T): boolean {
478479
return a === b;
479480
}
480481

482+
export function isAlwaysEqual<T>(_a: T, _b: T): boolean {
483+
return true;
484+
}
485+
481486
/**
482487
* When set to a component name string, any reactive read that is not inside a nested tracking
483488
* scope will log a dev-mode warning. Managed automatically by `untrack(fn, strictReadLabel)`.

packages/solid-signals/src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { ContextNotFoundError, NoOwnerError, NotReadyError } from "./error.js";
22
export {
33
isEqual,
4+
isAlwaysEqual,
45
untrack,
56
runWithOwner,
67
computed,

packages/solid-signals/src/core/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface NodeOptions<T> {
1717
id?: string;
1818
name?: string;
1919
transparent?: boolean;
20-
equals?: ((prev: T, next: T) => boolean) | false;
20+
equals?: ((prev: T, next: T) => boolean) | false | true;
2121
ownedWrite?: boolean;
2222
/** Exclude this signal from snapshot capture (internal — not part of public API) */
2323
_noSnapshot?: boolean;

packages/solid-signals/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {
1717
isDisposed,
1818
getObserver,
1919
isEqual,
20+
isAlwaysEqual,
2021
untrack,
2122
isPending,
2223
latest,

packages/solid-signals/src/signals.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,16 @@ export interface MemoOptions<T> {
189189
/** When true, the owner is invisible to the ID scheme -- inherits parent ID and doesn't consume a childCount slot */
190190
transparent?: boolean;
191191
/**
192-
* Custom equality function, or `false` to always notify subscribers.
192+
* Custom equality function, or `false` to always notify subscribers, or
193+
* `true` to never notify them — the cached value is frozen at the first
194+
* computed result and downstream consumers see a constant. The compute
195+
* function still re-runs when its dependencies change, but the new value
196+
* is discarded by the equality check (backed by `isAlwaysEqual`).
197+
*
193198
* Defaults to reference equality (`isEqual`). Pass a comparator (e.g.
194-
* `(a, b) => a.id === b.id`) for value-based equality, or `false` to
195-
* notify on every recompute regardless of equality.
199+
* `(a, b) => a.id === b.id`) for value-based equality.
196200
*/
197-
equals?: false | ((prev: T, next: T) => boolean);
201+
equals?: true | false | ((prev: T, next: T) => boolean);
198202
/** Callback invoked when the computed loses all subscribers */
199203
unobserved?: () => void;
200204
/**
@@ -223,7 +227,7 @@ export type NoInfer<T extends any> = [T][T extends any ? 0 : never];
223227
* // Plain signal
224228
* const [state, setState] = createSignal<T>(value, options?: SignalOptions<T>);
225229
* // Writable memo (function overload)
226-
* const [state, setState] = createSignal<T>(fn, initialValue?, options?: SignalOptions<T> & MemoOptions<T>);
230+
* const [state, setState] = createSignal<T>(fn, initialValue?, options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>);
227231
* ```
228232
* @param value initial value of the state; if empty, the state's type will automatically extended with undefined
229233
* @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<T>(): Signal<T | undefined>;
253257
export function createSignal<T>(value: Exclude<T, Function>, options?: SignalOptions<T>): Signal<T>;
254258
export function createSignal<T>(
255259
fn: ComputeFunction<T>,
256-
options?: SignalOptions<T> & MemoOptions<T>
260+
options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
257261
): Signal<T>;
258262
export function createSignal<T>(
259263
first?: T | ComputeFunction<T>,
260-
second?: SignalOptions<T> & MemoOptions<T>
264+
second?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
261265
): Signal<T | undefined> {
262266
if (typeof first === "function") {
263267
const node = computed<T>(first as any, second as any);
@@ -594,7 +598,7 @@ export function resolve<T>(fn: () => T): Promise<T> {
594598
* // Plain optimistic signal
595599
* const [state, setState] = createOptimistic<T>(value, options?: SignalOptions<T>);
596600
* // Writable optimistic memo (function overload)
597-
* const [state, setState] = createOptimistic<T>(fn, options?: SignalOptions<T> & MemoOptions<T>);
601+
* const [state, setState] = createOptimistic<T>(fn, options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>);
598602
* ```
599603
* @param value initial value of the signal; if empty, the signal's type will automatically extended with undefined
600604
* @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<T>(
622626
): Signal<T>;
623627
export function createOptimistic<T>(
624628
fn: ComputeFunction<T>,
625-
options?: SignalOptions<T> & MemoOptions<T>
629+
options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
626630
): Signal<T>;
627631
export function createOptimistic<T>(
628632
first?: T | ComputeFunction<T>,
629-
second?: SignalOptions<T> & MemoOptions<T>
633+
second?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
630634
): Signal<T | undefined> {
631635
if (typeof first === "function") {
632636
const node = optimisticComputed<T>(first as any, second as any);

packages/solid-signals/tests/createMemo.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,72 @@ it("should ignore equals before memo initialization", () => {
257257
expect($a()).toBe(1);
258258
});
259259

260+
describe("equals: true (always-equal)", () => {
261+
it("should freeze the cached value and never re-notify subscribers", () => {
262+
const [$x, setX] = createSignal(1);
263+
const downstream = vi.fn();
264+
let read!: () => number;
265+
266+
createRoot(() => {
267+
const $a = createMemo(() => $x() * 10, { equals: true });
268+
createEffect($a, downstream);
269+
read = () => $a();
270+
});
271+
flush();
272+
273+
expect(read()).toBe(10);
274+
expect(downstream).toHaveBeenCalledTimes(1);
275+
276+
setX(2);
277+
flush();
278+
expect(read()).toBe(10);
279+
expect(downstream).toHaveBeenCalledTimes(1);
280+
281+
setX(3);
282+
flush();
283+
expect(read()).toBe(10);
284+
expect(downstream).toHaveBeenCalledTimes(1);
285+
});
286+
287+
it("should freeze a writable memo's value against both deps and setter writes", () => {
288+
const [$x, setX] = createSignal(1);
289+
let read!: () => number;
290+
let setA!: (v: number) => void;
291+
292+
createRoot(() => {
293+
const [$a, set] = createSignal(() => $x() + 100, { equals: true });
294+
read = () => $a();
295+
setA = set as (v: number) => void;
296+
});
297+
298+
expect(read()).toBe(101);
299+
300+
setX(2);
301+
flush();
302+
expect(read()).toBe(101);
303+
304+
setA(999);
305+
flush();
306+
expect(read()).toBe(101);
307+
});
308+
309+
it("should defer first run when combined with lazy", () => {
310+
const compute = vi.fn(() => 42);
311+
const $a = createMemo(compute, { equals: true, lazy: true });
312+
313+
expect(compute).toHaveBeenCalledTimes(0);
314+
expect($a()).toBe(42);
315+
expect(compute).toHaveBeenCalledTimes(1);
316+
});
317+
318+
it("should reject equals: true on plain signals at the type level", () => {
319+
// @ts-expect-error -- equals: true is memo-only
320+
createSignal(0, { equals: true });
321+
// sanity: the writable-memo overload still accepts it
322+
createSignal(() => 0, { equals: true });
323+
});
324+
});
325+
260326
it("should route init errors through the boundary without a memo fallback", () => {
261327
createRoot(() => {
262328
createErrorBoundary(

packages/solid/CHEATSHEET.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ createSignal(0, { ownedWrite: true }); // allow writes from inside own
4949
createSignal(0, { unobserved: () => cleanup() });// fires when no subscribers
5050
createMemo(fn, { lazy: true }); // defer first compute until read; autodispose when unobserved
5151
createMemo(fn, { equals: (a, b) => a.id === b.id });
52+
createMemo(fn, { equals: false }); // always notify (every recompute propagates)
53+
createMemo(fn, { equals: true }); // never notify (cached value frozen at first result)
5254
```
5355

5456
**Reads update only after flush.** `setX(v); x()` returns the *previous* value until the next microtask or `flush()`.

0 commit comments

Comments
 (0)