/** * Merge multiple props objects — like Solid's `merge`, but with Base UI's * special cases: `class`, `style` and `on*` handlers are composed rather * than overwritten, and `undefined` values are **skipped** (so default props * from earlier sources win when a later override is absent, matching Base * UI React's `mergeProps` semantics, not Solid's `merge` which treats * `undefined` as a real value). * * The merge is lazy and reactive: every property access re-reads the source * array, and the returned object's own-keys set re-collects on each * enumeration. That means state-attr projections that introduce new `data-*` * keys over time are seen by consumers as those keys appear. * * Behaviour: * - `class`: concatenated string ("a b c"). Objects/arrays (clsx-style) are * flattened into a single string. * - `style`: merged shallowly, right-most wins per property. * - `on*`: chained. Each handler runs right-to-left; the right-most handler * can call `preventBaseUIHandler()` on the event to stop earlier handlers. * - everything else: right-most non-`undefined` wins. * * **Strict-read note:** The proxy's `ownKeys` and `getOwnPropertyDescriptor` * traps fire during body-time enumeration (Solid's `mergeProps` runs * `Object.getOwnPropertyNames` synchronously in the consumer component's * body, which is an intentionally-untracked scope). The reads inside those * traps are intentional snapshots — Solid stores the resulting descriptors * (which point back at our reactive getter via `getOwnPropertyDescriptor`) * so reactivity flows through later, when consumers read individual keys * inside their own tracked scopes. We wrap the enumeration-trap bodies in * `untrack` to silence the false-positive `[STRICT_READ_UNTRACKED]` warning * that the component-body label scope would otherwise raise on every * source-key probe. */ import { untrack } from "solid-js"; export type PropsLike = Record; export type PropsSource = PropsLike | undefined; const EVENT_HANDLER_RE = /^on[A-Z]/; function isEventHandlerKey(key: string): boolean { return EVENT_HANDLER_RE.test(key); } function classToString(value: unknown): string { if (value == null || value === false) return ""; if (typeof value === "string") return value; if (typeof value === "number") return String(value); if (Array.isArray(value)) { let out = ""; for (const part of value) { const s = classToString(part); if (s) out = out ? `${out} ${s}` : s; } return out; } if (typeof value === "object") { let out = ""; for (const [key, v] of Object.entries(value as Record)) { if (v) out = out ? `${out} ${key}` : key; } return out; } return ""; } function mergeClass(sources: readonly PropsSource[]): string | undefined { let out = ""; for (const src of sources) { if (!src) continue; const v = (src as PropsLike).class; if (v == null) continue; const s = classToString(v); if (s) out = out ? `${out} ${s}` : s; } return out || undefined; } function mergeStyle( sources: readonly PropsSource[], ): object | string | undefined { let merged: Record | undefined; let lastString: string | undefined; for (const src of sources) { if (!src) continue; const v = (src as PropsLike).style; if (v == null) continue; if (typeof v === "string") { lastString = v; merged = undefined; continue; } lastString = undefined; merged = merged ? Object.assign(merged, v) : { ...(v as object) }; } return merged ?? lastString; } type AnyHandler = (...args: unknown[]) => unknown; interface BaseUIEventLike { baseUIHandlerPrevented?: boolean; preventBaseUIHandler?: () => void; } function looksLikeEvent(value: unknown): value is Event & BaseUIEventLike { return ( value != null && typeof value === "object" && "type" in (value as object) ); } function armEvent(event: Event & BaseUIEventLike): void { event.preventBaseUIHandler = () => { event.baseUIHandlerPrevented = true; }; } function chainHandlers(handlers: AnyHandler[]): AnyHandler | undefined { if (handlers.length === 0) return undefined; const [first] = handlers; if (handlers.length === 1 && first) { return (...args: unknown[]) => { const ev = args[0]; if (looksLikeEvent(ev)) armEvent(ev); return first(...args); }; } return (...args: unknown[]) => { const ev = args[0]; if (looksLikeEvent(ev)) armEvent(ev); let lastResult: unknown; for (let i = handlers.length - 1; i >= 0; i--) { const h = handlers[i]; if (!h) continue; lastResult = h(...args); if (looksLikeEvent(ev) && ev.baseUIHandlerPrevented) break; } return lastResult; }; } function resolveKey(key: string, sources: readonly PropsSource[]): unknown { if (key === "class") return mergeClass(sources); if (key === "style") return mergeStyle(sources); if (isEventHandlerKey(key)) { const handlers: AnyHandler[] = []; for (const src of sources) { if (!src) continue; const h = (src as PropsLike)[key]; if (typeof h === "function") handlers.push(h as AnyHandler); } return chainHandlers(handlers); } for (let i = sources.length - 1; i >= 0; i--) { const src = sources[i]; if (!src) continue; if (!(key in src)) continue; const v = (src as PropsLike)[key]; if (v === undefined) continue; return v; } return undefined; } /** * Does any source declare `key` as a getter (vs a data property)? A getter * is intentionally lazy — its value at enumeration time isn't meaningful, * and invoking it during enumeration can cascade into child component * body evaluation (which re-establishes `strictRead` under the child's * label, defeating the outer `untrack` and surfacing * `[STRICT_READ_UNTRACKED]`). For these keys we trust the producer's * intent and include them unconditionally; the downstream descriptor we * return from `getOwnPropertyDescriptor` still gates the actual value * through a getter, so RFC 07 absence semantics still hold for any * eventual `undefined` value. */ function hasGetterForKey( sources: readonly PropsSource[], key: string, ): boolean { for (const src of sources) { if (!src) continue; const desc = Object.getOwnPropertyDescriptor(src, key); if (desc && typeof desc.get === "function") return true; } return false; } function collectOwnKeys(sources: readonly PropsSource[]): string[] { // `untrack` here clears `strictRead` for the duration of the // enumeration. This trap is invoked from body-time `Object.keys` / // `getOwnPropertyNames` paths (Solid's `mergeProps`, JSX spread setup), // which are intentionally non-tracking and intentionally take a key // snapshot — the reactivity flows through the per-key getter // descriptors we return from `getOwnPropertyDescriptor`, not through // this enumeration. See module doc. return untrack(() => { const keys = new Set(); for (const src of sources) { if (!src) continue; for (const k of Object.keys(src)) keys.add(k); } // Mirror the resolve-semantics: if every source's value at `k` is // `undefined` (the skip case), the key shouldn't appear as "own" // either. Without this, Solid 2 DOM spreads would see // `class`/`value` as present-with-`undefined` — meaningfully // different from absent (RFC 07). const out: string[] = []; for (const k of keys) { if (hasGetterForKey(sources, k)) { out.push(k); continue; } if (resolveKey(k, sources) !== undefined) out.push(k); } return out; }); } /** * Merge an array of props sources into a reactive proxy. Every lookup re-reads * the sources, so getters (including state-projection getters added by * `renderElement`) stay live. * * **Solid 2 quirk**: attributes whose initial getter value is `undefined` are * absent from `ownKeys` at first spread and will NOT become reactive later — * the DOM binding never subscribes. Consumers that need a late-binding * attribute (e.g. `aria-labelledby`) must seed the key (`""`) so it stays * in `ownKeys`, or project it imperatively via `setAttribute` (see * ``'s `aria-invalid` handling). See * `test/internals/merge-props-undefined-attr.test.tsx` for the pinned * verdict. */ export function mergePropsN(sources: readonly PropsSource[]): PropsLike { return new Proxy({} as PropsLike, { get(_t, key: string | symbol) { if (typeof key === "symbol") return undefined; return resolveKey(key, sources); }, has(_t, key: string | symbol) { if (typeof key === "symbol") return false; // `has` must agree with `get`: if every source resolves the key // to `undefined`, the proxy should report it as absent. // Otherwise Solid 2's DOM binding layer would treat // `` as "present with undefined" rather // than "missing" — meaningfully different for form controls per // RFC 07. return resolveKey(key, sources) !== undefined; }, ownKeys() { return collectOwnKeys(sources); }, getOwnPropertyDescriptor(_t, key: string | symbol) { if (typeof key === "symbol") return undefined; // Same asymmetry fix as `has`: keys that resolve to undefined // aren't "own" even if the source object listed them. The // presence-probe is wrapped in `untrack` for the same // strict-read reason as `collectOwnKeys` — Solid's `mergeProps` // calls this trap synchronously during component-body setup. // The returned descriptor's `get` runs in the consumer's later // (tracked) scope, so reactivity is preserved. // // Skip the probe for keys backed by a getter on any source — // see `hasGetterForKey`. if ( !hasGetterForKey(sources, key) && untrack(() => resolveKey(key, sources)) === undefined ) return undefined; // Return a getter-based descriptor so consumers that copy // descriptors (e.g. `@solidjs/web`'s `omit`) preserve reactivity // rather than snapshotting the current value. return { enumerable: true, configurable: true, get: () => resolveKey(key, sources), }; }, }); }