From b35062352937cb0a8f5175e48bafa6d221d9f840 Mon Sep 17 00:00:00 2001 From: Clinton Selke Date: Thu, 30 Apr 2026 19:46:02 +1000 Subject: [PATCH 1/4] added createRcMemo primitive --- packages/memo/src/index.ts | 104 +++++++++++++++ packages/memo/test/rcMemo.test.ts | 203 ++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 packages/memo/test/rcMemo.test.ts diff --git a/packages/memo/src/index.ts b/packages/memo/src/index.ts index 1219bb3d5..edea115c2 100644 --- a/packages/memo/src/index.ts +++ b/packages/memo/src/index.ts @@ -17,6 +17,7 @@ import { type Owner, type SignalOptions, DEV, + getListener, } from "solid-js"; import { isServer } from "solid-js/web"; import { debounce, throttle } from "@solid-primitives/scheduled"; @@ -423,6 +424,109 @@ export function createLazyMemo( }; } +/** + * Reference counted `createMemo`. The memo calculation will only run when there is at least one listener. + * + * Once the number of listeners drops to zero, the internal memo will be disposed after a microtask. + * If a new listener is added later, the memo will be re-constructed. + * + * Unlike `createLazyMemo`, the internal memo's lifetime is managed by the number of listeners, + * and it will stop updating when there are no listeners. + * + * @param calc pure reactive calculation returning some value + * @param value initial value for the calculation + * @param options computation options + * @returns accessor of the calculated value + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/memo#createRcMemo + * + * @example + * ```ts + * const double = createRcMemo(() => count() * 2) + * ``` + */ +export function createRcMemo( + calc: (prev: T) => T, + value: T, + options?: EffectOptions, +): Accessor; + +export function createRcMemo( + calc: (prev: T | undefined) => T, + value?: undefined, + options?: EffectOptions, +): Accessor; + +export function createRcMemo( + calc: (prev: T | undefined) => T, + value?: T, + options?: EffectOptions, +): Accessor { + if (isServer) { + let calculated = false; + return () => { + if (!calculated) { + calculated = true; + value = calc(value); + } + return value as T; + }; + } + let existing: { + memo: Accessor, + dispose: () => void, + refCount: number, + } | undefined = undefined; + let lastSeenValue: T | undefined = value; + // For capturing context + const owner = getOwner(); + // + return () => { + if (getListener() == null) { + if (existing === undefined) { + return calc(undefined); + } else { + return existing.memo(); + } + } else { + if (existing == undefined) { + runWithOwner(owner, () => { + existing = createRoot((dispose) => { + return { + memo: createMemo( + (prev) => { + let result = calc(prev); + lastSeenValue = result; + return result; + }, + lastSeenValue, + options, + ), + dispose, + refCount: 1, + }; + }); + }); + } else { + existing.refCount++; + } + let existing2 = existing!; + onCleanup(() => { + existing2.refCount--; + if (existing2.refCount == 0) { + queueMicrotask(() => { + if (existing2.refCount == 0) { + existing2.dispose(); + existing = undefined; + } + }); + } + }); + return existing2.memo(); + } + }; +} + export type CacheCalculation = (key: Key, prev: Value | undefined) => Value; export type CacheKeyAccessor = (key: Key) => Value; export type CacheOptions = MemoOptions & { size?: number }; diff --git a/packages/memo/test/rcMemo.test.ts b/packages/memo/test/rcMemo.test.ts new file mode 100644 index 000000000..a4912551c --- /dev/null +++ b/packages/memo/test/rcMemo.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from "vitest"; +import { createRcMemo } from "../src/index.js"; +import { createComputed, createContext, createRoot, createSignal, getOwner, onCleanup, useContext } from "solid-js"; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const nextTick = () => new Promise(resolve => queueMicrotask(resolve)); + +describe("createRcMemo", () => { + it("only creates a memo when there are listeners", async () => { + const [count, setCount] = createSignal(0); + let runs = 0; + const memo = createRcMemo(() => { + runs++; + return count(); + }); + + expect(runs).toBe(0); + + // Initial access without listener + expect(memo()).toBe(0); + expect(runs).toBe(1); + + setCount(1); + // Still 1 because it's not tracking without listener + expect(runs).toBe(1); + expect(memo()).toBe(1); + expect(runs).toBe(2); + + // Add listener + await createRoot(async dispose => { + createComputed(() => { + memo(); + }); + expect(runs).toBe(3); + + setCount(2); + expect(runs).toBe(4); + expect(memo()).toBe(2); + expect(runs).toBe(4); // Should be memoized + + dispose(); + await nextTick(); + }); + + // After disposal and microtask, memo should be gone + setCount(3); + expect(runs).toBe(4); + expect(memo()).toBe(3); + expect(runs).toBe(5); + }); + + it("disposes the memo after a microtask when there are no listeners", async () => { + const [count, setCount] = createSignal(0); + let runs = 0; + let disposed = false; + const memo = createRcMemo(() => { + runs++; + onCleanup(() => { + disposed = true; + }); + return count(); + }); + + await createRoot(async dispose => { + createComputed(() => { + memo(); + }); + expect(runs).toBe(1); + expect(disposed).toBe(false); + + dispose(); + expect(disposed).toBe(false); // Not yet disposed, waiting for microtask + + await nextTick(); + expect(disposed).toBe(true); + }); + }); + + it("keeps the memo alive if a new listener is added within the same microtask", async () => { + const [count, setCount] = createSignal(0); + let runs = 0; + let disposed = false; + const memo = createRcMemo(() => { + runs++; + onCleanup(() => { + disposed = true; + }); + return count(); + }); + + const dispose1 = createRoot(dispose => { + createComputed(() => { + memo(); + }); + return dispose; + }); + + expect(runs).toBe(1); + dispose1(); + + // Before microtask, add another listener + createRoot(dispose => { + createComputed(() => { + memo(); + }); + // Should reuse existing memo + expect(runs).toBe(1); + expect(disposed).toBe(false); + dispose(); + }); + + await nextTick(); + expect(disposed).toBe(true); + }); + + it("supports multiple listeners", async () => { + const [count, setCount] = createSignal(0); + let runs = 0; + let disposed = false; + const memo = createRcMemo(() => { + runs++; + onCleanup(() => { + disposed = true; + }); + return count(); + }); + + const dispose1 = createRoot(dispose => { + createComputed(() => { + memo(); + }); + return dispose; + }); + + const dispose2 = createRoot(dispose => { + createComputed(() => { + memo(); + }); + return dispose; + }); + + expect(runs).toBe(1); + + dispose1(); + await nextTick(); + expect(disposed).toBe(false); + + dispose2(); + await nextTick(); + expect(disposed).toBe(true); + }); + + it("passes context to the inner memo", async () => { + const MyContext = createContext(123); + let capturedContext: any; + + const { memo, dispose } = createRoot(dispose => { + const memo = createRcMemo(() => { + capturedContext = useContext(MyContext); + return 0; + }); + return { memo, dispose }; + }); + + createRoot(dis => { + createComputed(() => { + memo(); + }); + dis(); + }); + + expect(capturedContext).toBe(123); + + dispose(); + }); + + it("persists the last seen value as initial value for the next recreation", async () => { + const [count, setCount] = createSignal(0); + let capturedPrev: any; + const memo = createRcMemo(prev => { + capturedPrev = prev; + return count(); + }); + + // 1st life + await createRoot(async dispose => { + createComputed(() => memo()); + expect(capturedPrev).toBeUndefined(); + setCount(1); + expect(capturedPrev).toBe(0); + dispose(); + await nextTick(); + }); + + // 2nd life + await createRoot(async dispose => { + createComputed(() => memo()); + expect(capturedPrev).toBe(1); // Should have persisted the last value + dispose(); + await nextTick(); + }); + }); +}); From 7e73e3f42c8a6b88ea6130c4485b7d2df4ea9647 Mon Sep 17 00:00:00 2001 From: Clinton Selke Date: Fri, 1 May 2026 10:58:28 +1000 Subject: [PATCH 2/4] additional documentation for createRcMemo; using type-safe === instead of == --- packages/memo/README.md | 25 +++++++++++++++++++++++++ packages/memo/package.json | 1 + packages/memo/src/index.ts | 15 ++++++++++----- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/memo/README.md b/packages/memo/README.md index d3e5bdb6a..a6995468a 100644 --- a/packages/memo/README.md +++ b/packages/memo/README.md @@ -14,6 +14,7 @@ Collection of custom `createMemo` primitives. They extend it's functionality whi - [`createLatestMany`](#createlatestmany) - A combined memo of a list of sources, returns the value of all last updated ones. - [`createWritableMemo`](#createwritablememo) - Solid's `createMemo` which value can be overwritten by a setter. - [`createLazyMemo`](#createlazymemo) - Lazily evaluated memo. Will run the calculation only if is being listened to. +- [`createRcMemo`](#creatercmemo) - Reference counted memo. Calculation runs only when there is at least one listener. - [`createPureReaction`](#createpurereaction) - A `createReaction` that runs before render _(non-batching)_. - [`createMemoCache`](#creatememocache) - Custom, lazily-evaluated, memo, with caching based on keys. - [`createReducer`](#createreducer) - Primitive for updating signal in a predictable way. @@ -183,6 +184,30 @@ const double = createMemo(() => getDouble(count())); https://codesandbox.io/s/solid-primitives-memo-demo-3w0oz?file=/index.tsx +## `createRcMemo` + +Reference counted `createMemo`. The memo calculation will only run when there is at least one listener. + +Once the number of listeners drops to zero, the internal memo will be disposed after a microtask. If a new listener is added later, the internal memo will be re-constructed. + +Unlike `createLazyMemo`, the internal memo's lifetime is managed by the number of listeners, and it will stop updating when there are no listeners. + +### How to use it + +It's usage is almost the same as Solid's `createMemo`. However, it doesn't need to be placed inside a reactive root, as it manages its own internal owner. If it is created inside a reactive root, it will carry the context of that root. + +```ts +import { createRcMemo } from "@solid-primitives/memo"; + +// use like a createMemo +const double = createRcMemo(() => count() * 2); +double(); // T: number +``` + +### Note on `prev` value + +Because the internal memo is disposed when there are no listeners, the `prev` value in the calculation function will become stale if the internal memo is reconstructed. + ## `createDebouncedMemo` `createDebouncedMemo` is deprecated. Please use `createSchedule` from [`@solid-primitives/schedule`](https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#readme) instead. diff --git a/packages/memo/package.json b/packages/memo/package.json index 69f8afd59..0b70acd3a 100644 --- a/packages/memo/package.json +++ b/packages/memo/package.json @@ -21,6 +21,7 @@ "createLatestMany", "createWritableMemo", "createLazyMemo", + "createRcMemo", "createPureReaction", "createMemoCache", "createReducer" diff --git a/packages/memo/src/index.ts b/packages/memo/src/index.ts index edea115c2..6a0718164 100644 --- a/packages/memo/src/index.ts +++ b/packages/memo/src/index.ts @@ -428,11 +428,16 @@ export function createLazyMemo( * Reference counted `createMemo`. The memo calculation will only run when there is at least one listener. * * Once the number of listeners drops to zero, the internal memo will be disposed after a microtask. - * If a new listener is added later, the memo will be re-constructed. + * If a new listener is added later, the internal memo will be re-constructed. * * Unlike `createLazyMemo`, the internal memo's lifetime is managed by the number of listeners, * and it will stop updating when there are no listeners. * + * It can be created outside of a reactive root, as it manages its own internal owner. + * If it is created inside a reactive root, it will carry the context of that root. + * + * **Note:** Because the internal memo is disposed when there are no listeners, the `prev` value in the calculation function will become stale if the internal memo is reconstructed. + * * @param calc pure reactive calculation returning some value * @param value initial value for the calculation * @param options computation options @@ -482,14 +487,14 @@ export function createRcMemo( const owner = getOwner(); // return () => { - if (getListener() == null) { + if (getListener() === null) { if (existing === undefined) { return calc(undefined); } else { return existing.memo(); } } else { - if (existing == undefined) { + if (existing === undefined) { runWithOwner(owner, () => { existing = createRoot((dispose) => { return { @@ -513,9 +518,9 @@ export function createRcMemo( let existing2 = existing!; onCleanup(() => { existing2.refCount--; - if (existing2.refCount == 0) { + if (existing2.refCount === 0) { queueMicrotask(() => { - if (existing2.refCount == 0) { + if (existing2.refCount === 0) { existing2.dispose(); existing = undefined; } From 6e975162e0710c3faff726b9f06a62a31e298a62 Mon Sep 17 00:00:00 2001 From: Clinton Selke Date: Fri, 1 May 2026 11:00:44 +1000 Subject: [PATCH 3/4] documented server side behavour for createRcMemo --- packages/memo/README.md | 4 ++++ packages/memo/src/index.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/memo/README.md b/packages/memo/README.md index a6995468a..f4dcbb8a4 100644 --- a/packages/memo/README.md +++ b/packages/memo/README.md @@ -208,6 +208,10 @@ double(); // T: number Because the internal memo is disposed when there are no listeners, the `prev` value in the calculation function will become stale if the internal memo is reconstructed. +### Server-side behavior + +On the server, `createRcMemo` will calculate the value lazily when first accessed and then cache it for subsequent calls. + ## `createDebouncedMemo` `createDebouncedMemo` is deprecated. Please use `createSchedule` from [`@solid-primitives/schedule`](https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#readme) instead. diff --git a/packages/memo/src/index.ts b/packages/memo/src/index.ts index 6a0718164..267da847b 100644 --- a/packages/memo/src/index.ts +++ b/packages/memo/src/index.ts @@ -436,6 +436,8 @@ export function createLazyMemo( * It can be created outside of a reactive root, as it manages its own internal owner. * If it is created inside a reactive root, it will carry the context of that root. * + * On the server, it will calculate the value lazily when first accessed and then cache it. + * * **Note:** Because the internal memo is disposed when there are no listeners, the `prev` value in the calculation function will become stale if the internal memo is reconstructed. * * @param calc pure reactive calculation returning some value From 0ebdd1ad51ad876786693fb2d0809b01d4a2e859 Mon Sep 17 00:00:00 2001 From: Clinton Selke Date: Fri, 1 May 2026 15:31:38 +1000 Subject: [PATCH 4/4] added changeset for createRcMemo primitive --- .changeset/lovely-bags-sin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lovely-bags-sin.md diff --git a/.changeset/lovely-bags-sin.md b/.changeset/lovely-bags-sin.md new file mode 100644 index 000000000..715f18b67 --- /dev/null +++ b/.changeset/lovely-bags-sin.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/memo": minor +--- + +Added createRcMemo primitive for memos that are only awake while there are listeners.