/** * `mergeRootProps(internal, user)` — public slot-function prop merger. * * Distinct from `mergePropsN` (used by ~20 React-port primitives). This * one is for the Solid 2.0-native compound surface where a primitive * exposes `c.trigger()` / `c.root()` slot functions and the consumer * spreads them onto an element with ``. * * Per-key resolution: * - `on*` handlers: internal runs first, user runs after unless the * event was `preventDefault()`-ed by internal. `user[k] === undefined` * does NOT clear internal — it's treated as "absent". This is the key * difference from Solid's `merge` (RFC 04) and what stops consumers * accidentally killing library behaviour by spreading * `{ onClick: undefined }`. * - `class`: returns `[internal.class, user.class]` per RFC 07 — Solid's * class binding handles nested clsx shapes per entry. No string concat. * - `style`: object declarations merge with user values winning. Mixed * object/string values serialize deterministically with internal * declarations first and user declarations second. * - `ref`: array form `[internal.ref, user.ref]` (skip absent sides; * single value if only one side present). Solid's `applyRef` flattens. * - everything else (`data-*`, `aria-*`, plain attrs): user wins when * defined, internal provides the reactive default. `user[k] === * undefined` means internal wins. * * Reactivity: getters delegate reads back to `internal[k]` / `user[k]` so * source-side getters (e.g. `get "data-state"() { return open() ? ... }`) * stay live across spreads. The output is a plain object — Solid's * compiled spread iterates `Object.keys` once at first spread and binds a * render effect per key; each binding then reads through the getter on * every relevant flush. * * Implementation: pre-bake a getter per key in `keys(internal) ∪ keys(user)` * at call time. Specialised per which side(s) declared the key — the * cheap path (internal-only key) skips per-access branching. Faster than * the prior Proxy form (~2.4× by microbench: 123ms → 51ms for 7M reads) * and produces an indistinguishable shape for downstream consumers * (`Object.keys`, `Object.getOwnPropertyDescriptor`, `in` for known keys * all match). * * Slot prop bags have a static key set at the call site, so pre-baking * is correct. Dynamic key sets that grow over time still need `mergePropsN`. */ import { untrack } from "solid-js"; import type { ElementStyle } from "./types"; type AnyRecord = Record; type AnyHandler = (event: Event, ...rest: unknown[]) => unknown; const EVENT_HANDLER_RE = /^on[A-Z]/; function isEventHandlerKey(key: string): boolean { return EVENT_HANDLER_RE.test(key); } function composeHandlers(internal: AnyHandler, user: AnyHandler): AnyHandler { return (event: Event, ...rest: unknown[]) => { internal(event, ...rest); if (event && (event as Event).defaultPrevented) return; user(event, ...rest); }; } // Lazy resolvers — invoked on every getter access so source-side getters // stay reactive. Each resolver re-reads its sides; the per-key getter // installed on the output binds these resolvers via closure. function resolveClass(internalV: unknown, userV: unknown): unknown { if (internalV === undefined) return userV; if (userV === undefined) return internalV; return [internalV, userV]; } type StyleObject = Record; function isRemovedStyle(value: unknown): value is null | undefined | false { return value === undefined || value === null || value === false; } function cssPropertyName(name: string): string { if (name.startsWith("--") || name.includes("-")) return name; const dashed = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); return /^ms[A-Z]/.test(name) ? `-${dashed}` : dashed; } function normalizeStyleObject(value: object): StyleObject { const normalized: StyleObject = {}; for (const [name, declaration] of Object.entries(value)) { if (isRemovedStyle(declaration)) continue; normalized[cssPropertyName(name)] = declaration as string | number; } return normalized; } function serializeStyleObject(value: object): string { const declarations: string[] = []; for (const [name, declaration] of Object.entries(value)) { if (isRemovedStyle(declaration)) continue; declarations.push(`${cssPropertyName(name)}:${String(declaration)}`); } return declarations.join(";"); } function joinSerializedStyles(internal: string, user: string): string { const left = internal.trim().replace(/;+$/, ""); const right = user.trim().replace(/^;+/, ""); if (!left) return right; if (!right) return left; return `${left};${right}`; } /** * Merge two Solid-native style values with the later value taking precedence. * Object/object inputs remain objects. If either side is serialized CSS, the * result is serialized with internal declarations first and user declarations * second, which is deterministic across browser and server rendering. */ export function mergeElementStyles( internal: ElementStyle, user: ElementStyle, ): ElementStyle { if (isRemovedStyle(internal)) return user; if (isRemovedStyle(user)) return internal; if (typeof internal !== "string" && typeof user !== "string") { const merged = normalizeStyleObject(internal); for (const [name, declaration] of Object.entries(user)) { const property = cssPropertyName(name); if (isRemovedStyle(declaration)) delete merged[property]; else merged[property] = declaration as string | number; } return merged; } const internalCss = typeof internal === "string" ? internal : serializeStyleObject(internal); const userCss = typeof user === "string" ? user : serializeStyleObject(user); return joinSerializedStyles(internalCss, userCss); } function resolveStyle(internalV: unknown, userV: unknown): unknown { return mergeElementStyles(internalV as ElementStyle, userV as ElementStyle); } function resolveRef(internalV: unknown, userV: unknown): unknown { if (internalV === undefined) return userV; if (userV === undefined) return internalV; return [internalV, userV]; } function resolveHandler(internalV: unknown, userV: unknown): unknown { const internalIsFn = typeof internalV === "function"; const userIsFn = typeof userV === "function"; if (!internalIsFn && !userIsFn) return undefined; if (!userIsFn) return internalV; if (!internalIsFn) return userV; return composeHandlers(internalV as AnyHandler, userV as AnyHandler); } function resolveAttr( internalV: unknown, userV: unknown, userHas: boolean, ): unknown { if (userHas && userV !== undefined) return userV; return internalV; } /** * Accepts any two prop bags and returns the merged bag as a plain * `Record` — which is all every call site needs (each one * either assigns the result to a `Record` or spreads it onto * a `dynamic()` element). The widening cast lives HERE, once, instead of forcing every * call site to write `internal` just to * satisfy a generic whose precise return type nothing consumes. */ export function mergeRootProps( internal: object, user?: object, ): Record { const i = internal as AnyRecord; const u = user as AnyRecord | undefined; const out = {} as AnyRecord; // Enumerate keys in `untrack`: `Object.keys` on a Solid props proxy fires // its `ownKeys` trap, which reads the reactive source. The slot key set is // static at the call site (see the header note), so this read must NOT be // tracked — otherwise it trips `[STRICT_READ_UNTRACKED]` for every consumer // (e.g. ) and floods the dev console. The per-key getters // defined below stay fully reactive. const internalKeys = untrack(() => Object.keys(i)); const userKeys = u !== undefined ? untrack(() => Object.keys(u)) : []; // Membership tests below use this plain Set rather than `key in u`: the // `in` operator fires the Solid props-proxy's `has` trap, which reads the // reactive source untracked and trips `[STRICT_READ_UNTRACKED]` (the second // flood source, alongside the `ownKeys` enumeration above). The key set is // static at the call site, so a one-shot snapshot is correct. const userKeySet = new Set(userKeys); const seen = new Set(); const define = (key: string, getter: () => unknown): void => { if (seen.has(key)) return; seen.add(key); Object.defineProperty(out, key, { enumerable: true, configurable: true, get: getter, }); }; // Internal-side keys first. Specialise the getter based on whether // `user` also declares the key, so the cheap path (internal-only) skips // per-access branching. for (const key of internalKeys) { const userHas = userKeySet.has(key); if (key === "class") { define( key, userHas ? () => resolveClass(i[key], (u as AnyRecord)[key]) : () => i[key], ); } else if (key === "style") { define( key, userHas ? () => resolveStyle(i[key], (u as AnyRecord)[key]) : () => i[key], ); } else if (key === "ref") { define( key, userHas ? () => resolveRef(i[key], (u as AnyRecord)[key]) : () => i[key], ); } else if (isEventHandlerKey(key)) { define( key, userHas ? () => resolveHandler(i[key], (u as AnyRecord)[key]) : () => i[key], ); } else { define( key, userHas ? () => resolveAttr(i[key], (u as AnyRecord)[key], true) : () => i[key], ); } } // User-only keys — specialised but trivial (no internal side to merge). // Keep a getter (not a data property) so user-side getters stay reactive // in the same way internal-side getters do. for (const key of userKeys) { if (seen.has(key)) continue; define(key, () => (u as AnyRecord)[key]); } return out as Record; }