React
Merging props
A consumer's props over a component's: its handlers run first and can prevent the component's, and both receive the element.
Behaviour
mergeProps
The consumer's props are over the component's
expect(mergeProps({ role: "grid", tabIndex: 0 }, { tabIndex: -1, title: "Products" })).toEqual({
role: "grid",
tabIndex: -1,
title: "Products",
});A prop passed as undefined — forwarded by a wrapper that was given none — is not given
expect(mergeProps({ tabIndex: 0 }, { tabIndex: undefined })).toEqual({ tabIndex: 0 });Event handlers compose: the consumer's runs first, and its preventDefault keeps the component's from running
const order: string[] = [];
const merged = mergeProps(
{ onKeyDown: () => void order.push("component") },
{ onKeyDown: () => void order.push("consumer") },
);
// A handler is given its event whether it names it or not.
(merged.onKeyDown as (e: unknown) => void)(event());
expect(order).toEqual(["consumer", "component"]);
const quiet = mergeProps(
{ onKeyDown: () => void order.push("never") },
{ onKeyDown: (e: { preventDefault: () => void }) => e.preventDefault() },
);
quiet.onKeyDown(event());
expect(order).not.toContain("never");Only handlers compose: another function the consumer gives replaces the component's
const ours = () => "ours";
const theirs = () => "theirs";
expect(mergeProps({ render: ours }, { render: theirs }).render).toBe(theirs);Refs merge: both receive the element
const ours = createRef<string>();
const theirs = vi.fn();
const merged = mergeProps({ ref: ours }, { ref: theirs });
(merged.ref as (node: string) => void)("element");
expect([ours.current, theirs.mock.calls[0]![0]]).toEqual(["element", "element"]);mergeRefs
One ref callback sets every ref it was given, and its cleanup clears them, or calls theirs
const object = createRef<string>();
const plain = vi.fn();
const cleanup = vi.fn();
const withCleanup = vi.fn((_node: string | null) => cleanup);
const merged = mergeRefs<string>(object, plain, withCleanup, undefined);
const done = merged("element") as () => void;
expect([object.current, plain.mock.lastCall![0], withCleanup.mock.lastCall![0]]).toEqual([
"element",
"element",
"element",
]);
done();
expect(object.current).toBeNull();
expect(plain.mock.lastCall![0]).toBeNull();
expect(cleanup).toHaveBeenCalledOnce();
// A ref with a cleanup of its own is not also called with null.
expect(withCleanup).toHaveBeenCalledOnce();composeEventHandlers
Without a consumer's handler, the component's runs
const ours = vi.fn();
composeEventHandlers(undefined, ours)(event());
expect(ours).toHaveBeenCalledOnce();React
Install
npx shadcn@latest add https://ui.publictheta.com/r/merge-props.jsonSource
src/lib/merge-props.ts
import type { Ref } from "react";
import { composeEventHandlers } from "./compose-event-handlers.ts";
import { mergeRefs } from "./merge-refs.ts";
type Props = Record<string, unknown>;
/**
* The consumer's props over the component's, except that event handlers compose
* — both run, the consumer's first, and its `preventDefault()` skips the
* component's — and refs merge, so both receive the element.
*/
export function mergeProps<T extends object, U extends object>(ours: T, theirs: U): T & U {
const merged = { ...ours, ...theirs } as Props;
for (const key of Object.keys(theirs)) {
const mine = (ours as Props)[key];
const yours = (theirs as Props)[key];
// A prop passed as `undefined` — forwarded from a wrapper that was given none — is not given.
if (yours === undefined) {
merged[key] = mine;
continue;
}
if (key === "ref" && mine !== undefined && yours !== undefined) {
merged[key] = mergeRefs(mine as Ref<unknown>, yours as Ref<unknown>);
} else if (/^on[A-Z]/.test(key) && typeof mine === "function" && typeof yours === "function") {
merged[key] = composeEventHandlers(
yours as (event: { defaultPrevented: boolean }) => void,
mine as (event: { defaultPrevented: boolean }) => void,
);
}
}
return merged as T & U;
}