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. diff --git a/packages/memo/README.md b/packages/memo/README.md index d3e5bdb6a..f4dcbb8a4 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,34 @@ 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. + +### 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/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 1219bb3d5..267da847b 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,116 @@ 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 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. + * + * 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 + * @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(); + }); + }); +});