diff --git a/.changeset/eager-areas-turn.md b/.changeset/eager-areas-turn.md new file mode 100644 index 000000000..ccba70248 --- /dev/null +++ b/.changeset/eager-areas-turn.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/refs": minor +--- + +feat(refs): add createRef primitive diff --git a/packages/refs/README.md b/packages/refs/README.md index b510c5dff..05f40659c 100644 --- a/packages/refs/README.md +++ b/packages/refs/README.md @@ -13,6 +13,7 @@ Collection of primitives, components and directives that help managing reference ##### Primitives: - [`mergeRefs`](#mergerefs) - Utility for chaining multiple `ref` assignments with `props.ref` forwarding. +- [`createRef`](#createref) - Reactive callback-ref with listeners, element accessor and cleanup. - [`resolveElements`](#resolveelements) - Utility for resolving recursively nested JSX children to a single element or an array of elements. - [`resolveFirst`](#resolvefirst) - Utility for resolving recursively nested JSX children in search of the first element that matches a predicate. - [``](#refs) - Get up-to-date references of the multiple children elements. @@ -190,6 +191,45 @@ interface RefProps { } ``` +## `createRef` + +A reactive callback-ref that **stores the element**, lets you add/remove +mount-and-update listeners, and cleans itself up when the owner disposes. + +### How to use it + +```tsx +import { createRef, RefRef } from "@solid-primitives/refs"; +import { onCleanup } from "solid-js"; + +function ripple(e: MouseEvent) { + const btn = e.currentTarget as HTMLElement; + // … ripple-effect code … +} + +export function RippleButton(props: { children: string }) { + // create the ref and attach the ripple listener on first mount + const { + setter: internalRef, // JSX callback-ref + element, // () => HTMLElement | undefined + addEventOnChange, // add more listeners later + } = createRef(el => el.addEventListener("click", ripple), true); + + // (optional) forward the ref to a parent + const forwardedRef = (el: HTMLButtonElement | undefined) => console.log("parent got", el); + const ref = RefRef(internalRef, forwardedRef); // combine both refs + + // manual cleanup that runs in addition to createRef’s auto-cleanup + onCleanup(() => element()?.removeEventListener("click", ripple)); + + return ( + + ); +} +``` + ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/refs/src/createRef.ts b/packages/refs/src/createRef.ts new file mode 100644 index 000000000..54c2fa161 --- /dev/null +++ b/packages/refs/src/createRef.ts @@ -0,0 +1,179 @@ +import { onCleanup } from "solid-js"; + +/* ---------------------------------------------------------------- *\ + * Types +\* ---------------------------------------------------------------- */ + +export type RefSetter = (el: T | undefined) => void | (() => void); +export type RawRef = T | RefSetter | undefined; + +export interface ReturnCreateRef { + /** Callback-ref to drop in JSX: `
` */ + setter: RefSetter; + /** Current element or `undefined` if unmounted */ + element: () => T | undefined; + /** Add a listener that fires every time the ref mounts or changes */ + addEventOnChange(onChange: RefSetter, key?: string, once?: boolean): void; + /** Remove a listener; returns `true` if it existed */ + removeEventOnChange(key: string): boolean; + /** + * Clear internal state. + * @param clearEvents Remove all non-default listeners + * @param clearDefaultEvent Remove the `"default"` listener too + * @param eventsOnly Keep `element` untouched (useful for hot-reload) + */ + cleanup( + clearEvents?: boolean, + clearDefaultEvent?: boolean, + eventsOnly?: boolean + ): void; +} + +/* ---------------------------------------------------------------- *\ + * createRef +\* ---------------------------------------------------------------- */ + +/** + * Reactive callback-ref with listeners, `element()` accessor and `cleanup()`. + * + * ```tsx + * const { setter: ref, element } = createRef(); + * ``` + * + * @param initialOnChange initial listener (optional) + * @param once set to `true` to run the initial listener only on first mount + */ +export function createRef( + initialOnChange?: RefSetter, + once: boolean = false +): ReturnCreateRef { + + /* ---------- Internal state ------------------------------------- */ + type EventData = { + func: RefSetter; + once: boolean; + cleanupFn?: () => void; + }; + + let element_: T | undefined; + let listeners: Map | undefined = initialOnChange + ? new Map([["default", { func: initialOnChange, once }]]) + : undefined; + + /* ---------- JSX ref callback ----------------------------------- */ + const setter: RefSetter = (el) => { + if (el === element_ || !globalThis.document) return; + + if (element_ && listeners) { + for (const data of listeners.values()) data.cleanupFn?.(); + } + + element_ = el; + if (!listeners) return; + + for (const [key, data] of listeners.entries()) { + const maybeCleanup = data.func(el); + if (typeof maybeCleanup === "function") data.cleanupFn = maybeCleanup; + + if (data.once) { + data.cleanupFn?.(); + listeners.delete(key); + } + } + }; + + /* ---------- Public helpers ------------------------------------- */ + const element = () => element_; + + function addEventOnChange( + onChange: RefSetter, + key: string = "default", + once: boolean = false + ): void { + listeners?.get(key)?.cleanupFn?.(); + + const data: EventData = { func: onChange, once }; + if (listeners) listeners.set(key, data); + else listeners = new Map([[key, data]]); + } + + const removeEventOnChange = (key: string) => { + listeners?.get(key)?.cleanupFn?.(); + return listeners ? listeners.delete(key) : false; + }; + + function purgeEvents( + events: Map, + clearEvents: boolean, + clearDefaultEvent: boolean + ): Map | undefined { + for (const [key, data] of events) { + if (clearEvents || (clearDefaultEvent && key === "default")) { + data.cleanupFn?.(); + } + } + + if (clearEvents && clearDefaultEvent) return undefined; + + if (clearEvents && !clearDefaultEvent) { + const def = events.get("default"); + return def ? new Map([["default", { ...def }]]) : undefined; + } + + if (clearDefaultEvent) { + events.delete("default"); + } + return events; + } + + function cleanup( + clearEvents: boolean = true, + clearDefaultEvent: boolean = true, + eventsOnly: boolean = false + ): void { + if (eventsOnly && !clearEvents && !clearDefaultEvent) + throw new Error( + "Incompatible parameters: 'eventsOnly=true' requires either 'clearEvents=true' OR 'clearDefaultEvent=true'" + ); + + if (listeners) listeners = purgeEvents(listeners, clearEvents, clearDefaultEvent); + + if (!eventsOnly) element_ = undefined; + } + + /* ---------- Auto-cleanup when owner disposes -------------------- */ + onCleanup(() => cleanup()); + + return { + setter, + element, + addEventOnChange, + removeEventOnChange, + cleanup, + }; +} + +/* ---------------------------------------------------------------- *\ + * Extra helpers +\* ---------------------------------------------------------------- */ + +/** Execute `fn(...args)` only if `fn` is a function; otherwise return `false`. */ +export function exeIsFunc unknown>( + fn: T, + ...args: Parameters +): ReturnType; +export function exeIsFunc(fn: unknown, ...args: unknown[]): false; +export function exeIsFunc(fn: unknown, ...args: unknown[]): unknown { + return fn && typeof fn === "function" ? fn(...args) : false; +} + +/** Combine two refs into one (calls both). */ +export function RefRef< + A extends HTMLElement = HTMLElement, + B extends HTMLElement = HTMLElement +>(aRef: RawRef, bRef: RawRef): RefSetter { + return (el) => { + exeIsFunc(aRef, el as A); + exeIsFunc(bRef, el as unknown as B); + }; +} \ No newline at end of file diff --git a/packages/refs/src/index.ts b/packages/refs/src/index.ts index b930f4077..1484e352f 100644 --- a/packages/refs/src/index.ts +++ b/packages/refs/src/index.ts @@ -277,3 +277,5 @@ export function Ref(props: { ref: Ref; children: JSX.Elemen return resolved as unknown as JSX.Element; } + +export * from "./createRef"; \ No newline at end of file diff --git a/packages/refs/test/createRef.test.tsx b/packages/refs/test/createRef.test.tsx new file mode 100644 index 000000000..9a72317e2 --- /dev/null +++ b/packages/refs/test/createRef.test.tsx @@ -0,0 +1,195 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + describe, + it, + expect, + vi, + afterAll, + beforeEach, + beforeAll, + afterEach, +} from "vitest"; +import { createRoot } from "solid-js"; +import { createRef, RefRef } from "../src/createRef"; + +function newDiv() { + return document.createElement("div"); +} + +/* ---------------------------------------------------------------- *\ + * 1. Basic behaviours +\* ---------------------------------------------------------------- */ + +describe("createRef – basic", () => { + it("fires persistent listener on every change", () => + createRoot(dispose => { + const spy = vi.fn(); + const { setter, addEventOnChange } = createRef(); + + addEventOnChange(spy, "spy"); // once = false (default) + + setter(newDiv()); + setter(newDiv()); + expect(spy).toHaveBeenCalledTimes(2); + dispose(); + })); + + it("fires `once` listener only once", () => + createRoot(dispose => { + const spy = vi.fn(); + const { setter } = createRef(spy, true); + setter(newDiv()); + setter(newDiv()); + expect(spy).toHaveBeenCalledTimes(1); + dispose(); + })); + + it("element() always returns current element", () => + createRoot(dispose => { + const { setter, element } = createRef(); + const el1 = newDiv(); + const el2 = newDiv(); + setter(el1); + expect(element()).toBe(el1); + setter(el2); + expect(element()).toBe(el2); + dispose(); + })); +}); + +/* ---------------------------------------------------------------- *\ + * 2. Listeners add/remove after mount +\* ---------------------------------------------------------------- */ + +describe("createRef – dynamic listeners", () => { + it("listener added AFTER mount fires on next change", () => + createRoot(dispose => { + const spy = vi.fn(); + const { setter, addEventOnChange } = createRef(); + + setter(newDiv()); // mount ― no listener yet + addEventOnChange(spy, "late"); + setter(newDiv()); // change ― should trigger + expect(spy).toHaveBeenCalledTimes(1); + dispose(); + })); + + it("removeEventOnChange prevents future calls", () => + createRoot(dispose => { + const spy = vi.fn(); + const { setter, addEventOnChange, removeEventOnChange } = + createRef(); + + addEventOnChange(spy, "x"); + setter(newDiv()); + removeEventOnChange("x"); + setter(newDiv()); + expect(spy).toHaveBeenCalledTimes(1); + dispose(); + })); +}); + +/* ---------------------------------------------------------------- *\ + * 3. cleanup() variations +\* ---------------------------------------------------------------- */ + +describe("createRef – cleanup()", () => { + it("eventsOnly=true keeps element intact", () => + createRoot(dispose => { + const { setter, element, cleanup } = createRef(); + const el = newDiv(); + setter(el); + cleanup(true, false, true); // remove events only + expect(element()).toBe(el); + dispose(); + })); + + it("cleanup(false) resets element", () => + createRoot(dispose => { + const { setter, element, cleanup } = createRef(); + setter(newDiv()); + cleanup(); // default clears element + events + expect(element()).toBeUndefined(); + dispose(); + })); + + it("cleanup throws on incompatible params", () => + createRoot(dispose => { + const { cleanup } = createRef(); + expect(() => cleanup(false, false, true)).toThrow(); + dispose(); + })); +}); + +/* ---------------------------------------------------------------- *\ + * 4. RefRef composition +\* ---------------------------------------------------------------- */ + +describe("RefRef", () => { + it("calls both refs in order", () => { + const log: string[] = []; + const a = (el?: HTMLElement) => log.push("a"); + const b = (el?: HTMLElement) => log.push("b"); + const combo = RefRef(a, b); + combo(newDiv()); + expect(log).toEqual(["a", "b"]); + }); +}); + +/* ---------------------------------------------------------------- *\ + * 5. SSR fallback +\* ---------------------------------------------------------------- */ + +describe("createRef – SSR", () => { + const origWindow = globalThis.window; + beforeAll(() => { + // @ts-expect-error – simulate SSR + delete globalThis.window; + }); + afterAll(() => { + globalThis.window = origWindow; + }); + + it("returns noop object when window is undefined", () => { + const ref = createRef(); + expect(ref.element()).toBeUndefined(); + expect(() => ref.setter(newDiv())).not.toThrow(); + expect(ref.cleanup()).toBeUndefined(); + }); +}); + +/* ---------------------------------------------------------------- *\ + * 6. Stress test +\* ---------------------------------------------------------------- */ + +describe("createRef – stress", () => { + it("handles 10k listeners without leaking", () => + createRoot(dispose => { + const { setter, addEventOnChange, removeEventOnChange } = + createRef(); + + const listeners: Array<() => void> = []; + for (let i = 0; i < 10_000; i++) { + const id = `l${i}`; + const fn = vi.fn(); + listeners.push(fn); + addEventOnChange(fn, id); + } + + setter(newDiv()); + listeners.forEach(fn => expect(fn).toHaveBeenCalledTimes(1)); + + // remove half, change element again + for (let i = 0; i < 5_000; i++) removeEventOnChange(`l${i}`); + + setter(newDiv()); + + // first half should stay at 1, second half at 2 + listeners.forEach((fn, i) => + expect(fn).toHaveBeenCalledTimes(i < 5_000 ? 1 : 2), + ); + + dispose(); + }), + { timeout: 5_000 }); // keep CI safe +});