import type { JSX } from "@solidjs/web"; /** * `` provider + `useCompositeListItem` hook. * * Owns a per-item `Element → metadata` map, observes DOM mutations to * discover re-ordering, and exposes: * 1. a plain-array `elementsRef` (ordered by DOM position) * 2. a `subscribeMapChange` helper so items can learn their index * 3. a `mapTick` signal that bumps whenever `register`/`unregister` fire, * driving the sorted-map memo in a Solid-idiomatic way * * Mirrors `packages/react/src/internals/composite/list/CompositeList.tsx` in * Base UI React. The Solid translation: * * - React's `useState(0)` counter becomes a `createSignal(0)` + a hidden * `latestTick` integer (we write to it from `register`/`unregister` which * are called from unowned event/effect scope; the signal uses * `ownedWrite: true` so we don't trip `SIGNAL_WRITE_IN_OWNED_SCOPE` when an * `onCleanup` removes an item). * - React's `useMemo` becomes `createMemo`. The memo re-reads on `tick` * changes and walks the map in document order. * - React's `useIsoLayoutEffect` calls become split `createRenderEffect`s * (for DOM-level sync) and `createEffect`s (for listener dispatch). * - The sibling-unit `MutationObserver` is hooked up in a render-effect * keyed on the sorted map; when elements are re-ordered in the DOM, the * observer bumps `tick` so consumers re-sort on the next microtask. * * `useCompositeListItem` takes a per-call `source` symbol (created once per * invocation) which is how we identify items across re-registrations — Solid * has no component-instance identity for refs to hang off of, so we use an * explicit token. The returned ref callback registers the element on first * call and unregisters via `onCleanup` when the owning scope disposes. */ import { type Accessor, createMemo, createRenderEffect, createSignal, onCleanup, untrack, } from "solid-js"; import { CompositeListContext, type CompositeMapListener, type CompositeMetadata, eraseCompositeListContext, useCompositeListContext, } from "./composite-context"; import { sortByDocumentPosition } from "./composite-utils"; export interface CompositeListProps { children: JSX.Element; /** * Plain array that the list keeps in DOM order. Consumers (notably the * composite root) read it inside event handlers, so it is NOT wrapped in * a signal — direct mutation is expected. Passing a pre-allocated array * is supported and matches Base UI React's `elementsRef` ergonomics. */ elementsRef: Array; /** Parallel labels array for typeahead; optional. */ labelsRef?: Array | undefined; /** * Fired whenever the sorted map changes. Runs in an effect, so consumers * can read reactive values inside the callback body without warnings. */ onMapChange?: CompositeMapListener | undefined; } /** * Internal entry for the per-item source registry. We keep the `source` * symbol as the primary key so re-registrations from the same item (e.g. * when its element is unmounted and a new one is mounted in the same * lifetime) replace the old entry cleanly. */ interface SourceEntry { node: Element; metadata: Metadata | null; } export function CompositeList( props: CompositeListProps, ): JSX.Element { // Source-of-truth registry. Keyed by the per-item `source` symbol so // registration identity survives element swaps. const sources = new Map>(); // Listener set for `subscribeMapChange`. We keep it off-signal since // subscription lifetimes are managed by disposer functions, not tracked. const listeners = new Set>(); // Rerender tick. This is the "re-read" signal — every mutation bumps it // and the `sortedMap` memo re-runs. The signal uses `ownedWrite: true` // because we write to it from the unowned `register`/`unregister` // callbacks (and from the mutation observer's microtask), not from // owned reactive scope. const [tick, setTick] = createSignal(0, { ownedWrite: true }); const bump = (): void => { setTick((t) => t + 1); }; const register = ( source: symbol, node: Element, metadata: Metadata, ): void => { sources.set(source, { node, metadata: metadata ?? null }); bump(); }; const unregister = (source: symbol): void => { sources.delete(source); bump(); }; const subscribeMapChange = ( fn: CompositeMapListener, ): (() => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; }; // Sorted map. Recomputes on `tick` — filters disconnected elements (so // `compareDocumentPosition` doesn't return inconsistent results) and // stamps each entry with its `index` for consumers. const sortedMap: Accessor | null>> = createMemo(() => { // Track the tick signal so the memo re-runs on register/unregister. tick(); const out = new Map | null>(); const nodes: Element[] = []; const meta = new Map(); for (const entry of sources.values()) { if (!entry.node.isConnected) continue; nodes.push(entry.node); meta.set(entry.node, entry.metadata); } nodes.sort(sortByDocumentPosition); nodes.forEach((node, index) => { const m = meta.get(node); out.set(node, { ...(m ?? ({} as Metadata)), index }); }); return out; }); // Wire up the MutationObserver. Runs in a render effect so ordering // changes (e.g. a re-ordering its rows) bump the tick before the // next flush. We re-bind the observer when the sorted map's node set // changes so we're always observing the current parents. createRenderEffect( () => { // Track sortedMap so re-attach happens on real ordering changes. return sortedMap(); }, (map: Map | null>) => { if (typeof MutationObserver !== "function" || map.size === 0) { return undefined; } const observer = new MutationObserver((entries) => { // Count add/remove pairs: when the same node is both added and // removed (i.e. re-ordered), it cancels out. Only re-sort when // the churn set ends up *empty*, matching Base UI React. const diff = new Set(); const update = (node: Node) => { if (diff.has(node)) diff.delete(node); else diff.add(node); }; entries.forEach((entry) => { entry.removedNodes.forEach(update); entry.addedNodes.forEach(update); }); if (diff.size === 0) bump(); }); const parents = new Set(); map.forEach((_, node) => { if (node.parentElement) parents.add(node.parentElement); }); parents.forEach((parent) => { observer.observe(parent, { childList: true }); }); return () => { observer.disconnect(); }; }, ); // Sync the shared elementsRef + labelsRef arrays with the sorted map and // fan out map changes to subscribers + the optional `onMapChange` prop. // Runs as a render effect (layout-timing) so consumers that read the map // during the same flush — e.g. `` measurements or the // composite root's keyboard handler — see registered items before paint. // Matches Base UI React's `useIsoLayoutEffect` dispatch in `CompositeList`. createRenderEffect( () => sortedMap(), (map: Map | null>) => { const elements = props.elementsRef; const labels = props.labelsRef; elements.length = map.size; if (labels) labels.length = map.size; let i = 0; for (const [node] of map) { elements[i] = node as HTMLElement; i += 1; } listeners.forEach((fn) => { fn(map); }); props.onMapChange?.(map); return undefined; }, ); // `props.elementsRef` / `props.labelsRef` are captured once at setup — // they're stable mutable refs from the consumer, not a value that needs // to track. Wrap in `untrack` so the reads don't trip // `[STRICT_READ_UNTRACKED]` from this list's component-body scope. const contextValue = untrack(() => eraseCompositeListContext({ register, unregister, subscribeMapChange, elementsRef: props.elementsRef, labelsRef: props.labelsRef, }), ); return ( {props.children} ); } export interface UseCompositeListItemParameters { /** Consumer-supplied metadata carried alongside the index. */ metadata?: Metadata | undefined; /** Label for typeahead (when the root opts into labels). */ label?: string | null | undefined; /** Fallback label source — used when `label` is omitted. */ textRef?: { current: HTMLElement | null } | undefined; /** * Override the index the item reports (skips registration). Matches the * Base UI React "externalIndex" escape hatch for components that know * their index up front. */ index?: number | undefined; } export interface UseCompositeListItemReturn { /** Stable ref callback. Composes with other refs via Solid's array form. */ ref: (node: HTMLElement | null) => void; /** Accessor returning the item's current index (or `-1` before register). */ index: Accessor; } /** * Register the rendered element with the nearest ``. Returns * a ref callback + an accessor for the item's current index. * * The `source` symbol is created once per call so re-registrations (e.g. the * owner scope re-running) stay identified across element swaps. Cleanup on * scope disposal unregisters automatically. */ export function useCompositeListItem( params: UseCompositeListItemParameters = {}, ): UseCompositeListItemReturn { const ctx = useCompositeListContext(); // Per-call identity token. Using a symbol (DECISION) avoids React's // useRef-of-symbol dance and makes the registration entry unambiguous // across element swaps. const source = Symbol("composite-list-item"); // Hold onto the registered node so we can keep our own index in sync // whenever the list re-sorts. We also need it for unregister. let currentNode: Element | null = null; // `index` signal. `-1` means "not yet registered" (matches React). Uses // ownedWrite because the writes happen in the subscription callback / // register path, which runs from unowned scope. The `params.index` read // is one-shot setup (consumer-supplied initial index), so untrack it so // callers that invoke this hook from an effect-apply scope don't trip // `[STRICT_READ_UNTRACKED]` on the synchronous signal-init read. const [index, setIndex] = createSignal(untrack(() => params.index) ?? -1, { ownedWrite: true, }); // Subscribe to map changes so our index stays live. We need the cleanup // to fire on scope disposal and to deregister if we leave the list // entirely. const unsubscribe = ctx.subscribeMapChange( (map: Map | null>) => { // Subscription fires from inside the list's `createEffect` apply // when items register/unregister. The `params.*` reads here are // snapshots driving a one-shot label-cache write, not // subscriptions. We pull each `params` access through its own // `untrack` call so any nested reactive accessors (props proxies // can wrap memos around getters) don't inherit the surrounding // effect-apply strict-read context. const paramsIndex = untrack(() => params.index); if (paramsIndex != null) return; if (!currentNode) return; const entry = map.get(currentNode); const i = entry?.index; if (typeof i !== "number") return; setIndex(i); const labels = ctx.labelsRef; if (!labels) return; const paramsLabel = untrack(() => params.label); const paramsTextContent = untrack( () => params.textRef?.current?.textContent, ); labels[i] = paramsLabel !== undefined ? (paramsLabel ?? null) : (paramsTextContent ?? currentNode.textContent ?? null); }, ); onCleanup(() => { unsubscribe(); if (currentNode) { ctx.unregister(source); currentNode = null; } }); // Beta 8: ref callbacks fire *before* the element is inserted into the // DOM. If we called `ctx.register` directly from the ref, the // downstream `sortedMap` memo (which filters on `isConnected`) would // see the node as disconnected and drop it — and since the list's // MutationObserver only re-bumps on re-ordering, the subsequent // insertion never triggers a re-sort. We capture the node in the ref // but defer the `register` call to a render effect, which fires during // `flush()` after DOM insertion has completed. const [nodeSignal, setNodeSignal] = createSignal(null, { ownedWrite: true, }); // `params.index` and `params.metadata` are consumer inputs read here as // snapshots — index for the static-explicit branch, metadata for the // register call. Wrapping in `untrack` keeps the ref callback and // register-effect's apply scope from tripping // `[STRICT_READ_UNTRACKED]` on those reads. const hasExplicitIndex = untrack(() => params.index) != null; const ref = (node: HTMLElement | null): void => { if (hasExplicitIndex) { currentNode = node; return; } setNodeSignal(node); }; if (!hasExplicitIndex) { createRenderEffect( () => nodeSignal(), (node) => { if (currentNode && currentNode !== node) { ctx.unregister(source); } currentNode = node; if (node) { const metadata = untrack(() => params.metadata); ctx.register(source, node, (metadata ?? null) as Metadata); } }, ); } return { ref, index }; }