/**
* `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;
}