Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/equals-true-run-once-memo.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/solid-signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,8 @@ export function computed<T>(
(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,
Expand Down Expand Up @@ -478,6 +479,10 @@ export function isEqual<T>(a: T, b: T): boolean {
return a === b;
}

export function isAlwaysEqual<T>(_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)`.
Expand Down
1 change: 1 addition & 0 deletions packages/solid-signals/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { ContextNotFoundError, NoOwnerError, NotReadyError } from "./error.js";
export {
isEqual,
isAlwaysEqual,
untrack,
runWithOwner,
computed,
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-signals/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface NodeOptions<T> {
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;
Expand Down
1 change: 1 addition & 0 deletions packages/solid-signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
isDisposed,
getObserver,
isEqual,
isAlwaysEqual,
untrack,
isPending,
latest,
Expand Down
24 changes: 14 additions & 10 deletions packages/solid-signals/src/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,16 @@ export interface MemoOptions<T> {
/** 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;
/**
Expand Down Expand Up @@ -223,7 +227,7 @@ export type NoInfer<T extends any> = [T][T extends any ? 0 : never];
* // Plain signal
* const [state, setState] = createSignal<T>(value, options?: SignalOptions<T>);
* // Writable memo (function overload)
* const [state, setState] = createSignal<T>(fn, initialValue?, options?: SignalOptions<T> & MemoOptions<T>);
* const [state, setState] = createSignal<T>(fn, initialValue?, options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>);
* ```
* @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
Expand Down Expand Up @@ -253,11 +257,11 @@ export function createSignal<T>(): Signal<T | undefined>;
export function createSignal<T>(value: Exclude<T, Function>, options?: SignalOptions<T>): Signal<T>;
export function createSignal<T>(
fn: ComputeFunction<T>,
options?: SignalOptions<T> & MemoOptions<T>
options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
): Signal<T>;
export function createSignal<T>(
first?: T | ComputeFunction<T>,
second?: SignalOptions<T> & MemoOptions<T>
second?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
): Signal<T | undefined> {
if (typeof first === "function") {
const node = computed<T>(first as any, second as any);
Expand Down Expand Up @@ -594,7 +598,7 @@ export function resolve<T>(fn: () => T): Promise<T> {
* // Plain optimistic signal
* const [state, setState] = createOptimistic<T>(value, options?: SignalOptions<T>);
* // Writable optimistic memo (function overload)
* const [state, setState] = createOptimistic<T>(fn, options?: SignalOptions<T> & MemoOptions<T>);
* const [state, setState] = createOptimistic<T>(fn, options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>);
* ```
* @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
Expand Down Expand Up @@ -622,11 +626,11 @@ export function createOptimistic<T>(
): Signal<T>;
export function createOptimistic<T>(
fn: ComputeFunction<T>,
options?: SignalOptions<T> & MemoOptions<T>
options?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
): Signal<T>;
export function createOptimistic<T>(
first?: T | ComputeFunction<T>,
second?: SignalOptions<T> & MemoOptions<T>
second?: Omit<SignalOptions<T>, "equals"> & MemoOptions<T>
): Signal<T | undefined> {
if (typeof first === "function") {
const node = optimisticComputed<T>(first as any, second as any);
Expand Down
66 changes: 66 additions & 0 deletions packages/solid-signals/tests/createMemo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/solid/CHEATSHEET.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
Loading