From bb86c5790b9ef5c61f46445f1cd895f0e156f650 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 18:20:15 -0400 Subject: [PATCH 1/2] feat(headless): remove renderElement, consolidate into useRender renderElement has no callers left after the primitive migration. Move the shared prop types (RenderProp, ComponentProps, DefaultProps, StateAttributesMapping) and mergeProps into use-render.tsx, repoint the utils barrel there, and delete render-element.tsx. The mergeProps unit tests and the ComponentProps type test are ported alongside. --- packages/headless/src/utils/index.ts | 3 +- .../src/utils/render-element.test.tsx | 213 ------------------ .../headless/src/utils/render-element.tsx | 172 -------------- ...element.test-d.ts => use-render.test-d.ts} | 4 +- .../headless/src/utils/use-render.test.tsx | 50 +++- packages/headless/src/utils/use-render.tsx | 100 +++++++- 6 files changed, 146 insertions(+), 396 deletions(-) delete mode 100644 packages/headless/src/utils/render-element.test.tsx delete mode 100644 packages/headless/src/utils/render-element.tsx rename packages/headless/src/utils/{render-element.test-d.ts => use-render.test-d.ts} (83%) diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index eae4ccedc32..47abab801d6 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1,4 +1,3 @@ export { cssVars } from './css-vars'; export { resetLayoutStyles } from './reset-layout-styles'; -export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, renderElement } from './render-element'; -export { useRender } from './use-render'; +export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, useRender } from './use-render'; diff --git a/packages/headless/src/utils/render-element.test.tsx b/packages/headless/src/utils/render-element.test.tsx deleted file mode 100644 index eea05a55b85..00000000000 --- a/packages/headless/src/utils/render-element.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { mergeProps, renderElement } from './render-element'; - -afterEach(() => { - cleanup(); -}); - -describe('mergeProps', () => { - it('merges two plain objects', () => { - const result = mergeProps<'div'>({ id: 'a', 'data-x': '1' }, { 'data-y': '2' }); - expect(result).toEqual({ id: 'a', 'data-x': '1', 'data-y': '2' }); - }); - - it('second argument overrides first for plain props', () => { - const result = mergeProps<'div'>({ id: 'a' }, { id: 'b' }); - expect(result.id).toBe('b'); - }); - - it('chains event handlers', () => { - const first = vi.fn(); - const second = vi.fn(); - const result = mergeProps<'button'>({ onClick: first }, { onClick: second }); - - (result.onClick as (...args: unknown[]) => void)(); - - expect(first).toHaveBeenCalledTimes(1); - expect(second).toHaveBeenCalledTimes(1); - }); - - it('shallow-merges style objects', () => { - const result = mergeProps<'div'>( - { style: { color: 'red', fontSize: 14 } }, - { style: { color: 'blue', padding: 8 } }, - ); - expect(result.style).toEqual({ - color: 'blue', - fontSize: 14, - padding: 8, - }); - }); - - it('concatenates className', () => { - const result = mergeProps<'div'>({ className: 'base' }, { className: 'extra' }); - expect(result.className).toBe('base extra'); - }); - - it('does not chain non-event-handler functions', () => { - const ref1 = vi.fn(); - const ref2 = vi.fn(); - const result = mergeProps<'div'>({ ref: ref1 }, { ref: ref2 }); - // ref is not an event handler (doesn't match /^on[A-Z]/), so it's overwritten - expect(result.ref).toBe(ref2); - }); -}); - -describe('renderElement', () => { - it('renders default tag when no render prop', () => { - const element = renderElement({ - defaultTagName: 'span', - props: { 'data-testid': 'test', children: 'hello' }, - }); - - render(element); - - const el = screen.getByTestId('test'); - expect(el.tagName).toBe('SPAN'); - expect(el).toHaveTextContent('hello'); - }); - - it('renders via render function', () => { - const element = renderElement({ - defaultTagName: 'div', - render: props =>
, - props: { 'data-testid': 'test', children: 'content' }, - }); - - render(element); - - const el = screen.getByTestId('test'); - expect(el.tagName).toBe('ARTICLE'); - }); - - it('returns null when enabled is false', () => { - const element = renderElement({ - defaultTagName: 'div', - enabled: false, - props: { children: 'hidden' }, - }); - - expect(element).toBeNull(); - }); - - it('renders when enabled is true', () => { - const element = renderElement({ - defaultTagName: 'div', - enabled: true, - props: { 'data-testid': 'test', children: 'visible' }, - }); - - render(element); - - expect(screen.getByTestId('test')).toBeInTheDocument(); - }); - - it('applies state attributes via stateAttributesMapping', () => { - const element = renderElement({ - defaultTagName: 'button', - state: { open: true }, - stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - }, - props: { 'data-testid': 'test' }, - }); - - render(element); - - const el = screen.getByTestId('test'); - expect(el).toHaveAttribute('data-cl-open', ''); - expect(el).not.toHaveAttribute('data-cl-closed'); - }); - - it('applies false state attributes via stateAttributesMapping', () => { - const element = renderElement({ - defaultTagName: 'button', - state: { open: false }, - stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - }, - props: { 'data-testid': 'test' }, - }); - - render(element); - - const el = screen.getByTestId('test'); - expect(el).toHaveAttribute('data-cl-closed', ''); - expect(el).not.toHaveAttribute('data-cl-open'); - }); - - it('skips null return from stateAttributesMapping', () => { - const element = renderElement({ - defaultTagName: 'div', - state: { selected: false }, - stateAttributesMapping: { - selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), - }, - props: { 'data-testid': 'test' }, - }); - - render(element); - - const el = screen.getByTestId('test'); - expect(el).not.toHaveAttribute('data-cl-selected'); - }); - - it('merges multiple state attribute mappings', () => { - const element = renderElement({ - defaultTagName: 'div', - state: { selected: true, active: true, disabled: false }, - stateAttributesMapping: { - selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), - active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), - }, - props: { 'data-testid': 'test' }, - }); - - render(element); - - const el = screen.getByTestId('test'); - expect(el).toHaveAttribute('data-cl-selected', ''); - expect(el).toHaveAttribute('data-cl-active', ''); - expect(el).not.toHaveAttribute('data-cl-disabled'); - }); - - it('state attributes override props', () => { - const element = renderElement({ - defaultTagName: 'div', - state: { open: true }, - stateAttributesMapping: { - open: () => ({ 'data-cl-open': '' }), - }, - props: { 'data-testid': 'test', 'data-cl-open': 'should-be-overridden' }, - }); - - render(element); - - const el = screen.getByTestId('test'); - expect(el).toHaveAttribute('data-cl-open', ''); - }); - - it('passes computed props to render function', () => { - const renderFn = vi.fn(props =>
); - - renderElement({ - defaultTagName: 'div', - render: renderFn, - state: { open: true }, - stateAttributesMapping: { - open: (v: boolean) => (v ? { 'data-cl-open': '' } : null), - }, - props: { id: 'test' }, - }); - - expect(renderFn).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'test', - 'data-cl-open': '', - }), - ); - }); -}); diff --git a/packages/headless/src/utils/render-element.tsx b/packages/headless/src/utils/render-element.tsx deleted file mode 100644 index 170dc75d588..00000000000 --- a/packages/headless/src/utils/render-element.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import * as React from 'react'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** - * A render prop: a function that receives computed HTML props and returns a JSX element. - */ -export type RenderProp> = (props: Props) => React.ReactElement; - -/** - * Props accepted by any primitive part. Extends the native props for `Tag` - * and adds the optional `render` escape hatch, narrowed to that tag's props. - */ -export type ComponentProps = React.ComponentPropsWithRef & { - render?: RenderProp>; -}; - -/** - * The props a primitive part applies to its own rendered element. Extends the - * native props for `Tag` and additionally permits internal `data-*` attributes - * (e.g. `data-cl-slot`), which `@types/react` intentionally omits from its - * element prop types. - * - * Use with `satisfies` to type-check authored default props — this validates - * every key against the real element props while still allowing our `data-*` - * attributes, instead of laundering the whole object past the checker with an - * `as` assertion. - */ -export type DefaultProps = React.ComponentPropsWithRef & - Record<`data-${string}`, string>; - -/** - * Maps state keys to functions that return data-attribute objects (or null). - */ -export type StateAttributesMapping = { - [K in keyof S]?: (value: S[K]) => Record | null; -}; - -// --------------------------------------------------------------------------- -// mergeProps -// --------------------------------------------------------------------------- - -type PropsInput = - | React.ComponentPropsWithRef - | Record; - -/** - * Merges two props objects. Event handlers are chained (both fire), - * `style` is shallow-merged, `className` is concatenated, and - * everything else is overwritten by the second argument. - */ -export function mergeProps( - a: PropsInput, - b: PropsInput, -): Record; -export function mergeProps(a: Record, b: Record): Record { - const merged: Record = { ...a }; - - for (const key of Object.keys(b)) { - const bVal = b[key]; - - if ( - key.charCodeAt(0) === 111 /* o */ && - key.charCodeAt(1) === 110 /* n */ && - key.charCodeAt(2) >= 65 /* A */ && - key.charCodeAt(2) <= 90 /* Z */ && - typeof a[key] === 'function' && - typeof bVal === 'function' - ) { - // Chain event handlers — internal fires first, consumer fires second - const aHandler = a[key] as (...args: unknown[]) => void; - const bHandler = bVal as (...args: unknown[]) => void; - merged[key] = (...args: unknown[]) => { - aHandler(...args); - bHandler(...args); - }; - } else if (key === 'style' && a.style && bVal) { - merged.style = { ...(a.style as object), ...(bVal as object) }; - } else if (key === 'className' && typeof a.className === 'string' && typeof bVal === 'string') { - merged.className = `${a.className} ${bVal}`; - } else { - merged[key] = bVal; - } - } - - return merged; -} - -// --------------------------------------------------------------------------- -// renderElement -// --------------------------------------------------------------------------- - -interface RenderElementParamsBase< - Tag extends keyof React.JSX.IntrinsicElements, - State extends Record = Record, -> { - /** Fallback HTML tag when `render` is not provided. */ - defaultTagName: Tag; - /** Render prop from the consumer, narrowed to the element's native props. */ - render?: RenderProp>; - /** State object. Keys are mapped to data attributes via `stateAttributesMapping`. */ - state?: State; - /** Custom mapping from state keys to data-attribute objects. */ - stateAttributesMapping?: StateAttributesMapping; - /** Merged props to spread onto the element. */ - props?: Record; -} - -interface RenderElementParamsConditional< - Tag extends keyof React.JSX.IntrinsicElements, - State extends Record = Record, -> extends RenderElementParamsBase { - /** When false, returns null (used by Positioners gated on `mounted`). */ - enabled: boolean; -} - -interface RenderElementParamsAlways< - Tag extends keyof React.JSX.IntrinsicElements, - State extends Record = Record, -> extends RenderElementParamsBase { - enabled?: never; -} - -/** - * Renders a primitive part element with render-prop support, conditional - * rendering, and state-to-data-attribute mapping. - * - * When `enabled` is omitted the element is always rendered (returns ReactElement). - * When `enabled` is passed the element may be skipped (returns ReactElement | null). - */ -export function renderElement< - Tag extends keyof React.JSX.IntrinsicElements, - State extends Record = Record, ->(params: RenderElementParamsAlways): React.ReactElement; -export function renderElement< - Tag extends keyof React.JSX.IntrinsicElements, - State extends Record = Record, ->(params: RenderElementParamsConditional): React.ReactElement | null; -export function renderElement< - Tag extends keyof React.JSX.IntrinsicElements, - State extends Record = Record, ->(params: RenderElementParamsBase & { enabled?: boolean }): React.ReactElement | null { - const { defaultTagName, render, enabled = true, state, stateAttributesMapping, props } = params; - - if (!enabled) { - return null; - } - - // Build data attributes from state - let dataAttrs: Record = {}; - if (state && stateAttributesMapping) { - for (const key of Object.keys(stateAttributesMapping) as Array) { - const mapper = stateAttributesMapping[key]; - if (mapper) { - const attrs = mapper(state[key]); - if (attrs) { - dataAttrs = { ...dataAttrs, ...attrs }; - } - } - } - } - - const computedProps = { ...props, ...dataAttrs }; - - if (render) { - return render(computedProps as React.ComponentPropsWithRef); - } - - return React.createElement(defaultTagName, computedProps); -} diff --git a/packages/headless/src/utils/render-element.test-d.ts b/packages/headless/src/utils/use-render.test-d.ts similarity index 83% rename from packages/headless/src/utils/render-element.test-d.ts rename to packages/headless/src/utils/use-render.test-d.ts index 5fc7e09df2c..5da4b4a387d 100644 --- a/packages/headless/src/utils/render-element.test-d.ts +++ b/packages/headless/src/utils/use-render.test-d.ts @@ -1,9 +1,9 @@ import type React from 'react'; import { describe, expectTypeOf, test } from 'vitest'; -import type { ComponentProps } from './render-element'; +import type { ComponentProps } from './use-render'; -describe('render-element', () => { +describe('use-render', () => { test('render prop arg is narrowed to the element tag props, not the generic HTMLAttributes', () => { type Props = ComponentProps<'button'>; type RenderArg = NonNullable extends (props: infer P) => React.ReactElement ? P : never; diff --git a/packages/headless/src/utils/use-render.test.tsx b/packages/headless/src/utils/use-render.test.tsx index 6589b95c7b8..a26c3cdb0cb 100644 --- a/packages/headless/src/utils/use-render.test.tsx +++ b/packages/headless/src/utils/use-render.test.tsx @@ -2,12 +2,60 @@ import { cleanup, render, renderHook, screen } from '@testing-library/react'; import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { useRender } from './use-render'; +import { mergeProps, useRender } from './use-render'; afterEach(() => { cleanup(); }); +describe('mergeProps', () => { + it('merges two plain objects', () => { + const result = mergeProps<'div'>({ id: 'a', 'data-x': '1' }, { 'data-y': '2' }); + expect(result).toEqual({ id: 'a', 'data-x': '1', 'data-y': '2' }); + }); + + it('second argument overrides first for plain props', () => { + const result = mergeProps<'div'>({ id: 'a' }, { id: 'b' }); + expect(result.id).toBe('b'); + }); + + it('chains event handlers', () => { + const first = vi.fn(); + const second = vi.fn(); + const result = mergeProps<'button'>({ onClick: first }, { onClick: second }); + + (result.onClick as (...args: unknown[]) => void)(); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('shallow-merges style objects', () => { + const result = mergeProps<'div'>( + { style: { color: 'red', fontSize: 14 } }, + { style: { color: 'blue', padding: 8 } }, + ); + expect(result.style).toEqual({ + color: 'blue', + fontSize: 14, + padding: 8, + }); + }); + + it('concatenates className', () => { + const result = mergeProps<'div'>({ className: 'base' }, { className: 'extra' }); + expect(result.className).toBe('base extra'); + }); + + it('does not chain non-event-handler functions', () => { + const ref1 = vi.fn(); + const ref2 = vi.fn(); + const result = mergeProps<'div'>({ ref: ref1 }, { ref: ref2 }); + // ref is not an event handler (doesn't match /^on[A-Z]/), so it's overwritten + expect(result.ref).toBe(ref2); + }); +}); + describe('useRender', () => { it('renders the default tag when no render prop', () => { function C() { diff --git a/packages/headless/src/utils/use-render.tsx b/packages/headless/src/utils/use-render.tsx index 32c4093f3ff..bf1197e7ee4 100644 --- a/packages/headless/src/utils/use-render.tsx +++ b/packages/headless/src/utils/use-render.tsx @@ -1,8 +1,97 @@ import { useMergeRefs } from '@floating-ui/react'; import * as React from 'react'; -import type { DefaultProps, RenderProp, StateAttributesMapping } from './render-element'; -import { mergeProps } from './render-element'; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A render prop: a function that receives computed HTML props and returns a JSX element. + */ +export type RenderProp> = (props: Props) => React.ReactElement; + +/** + * Props accepted by any primitive part. Extends the native props for `Tag` + * and adds the optional `render` escape hatch, narrowed to that tag's props. + */ +export type ComponentProps = React.ComponentPropsWithRef & { + render?: RenderProp>; +}; + +/** + * The props a primitive part applies to its own rendered element. Extends the + * native props for `Tag` and additionally permits internal `data-*` attributes + * (e.g. `data-cl-slot`), which `@types/react` intentionally omits from its + * element prop types. + * + * Use with `satisfies` to type-check authored default props — this validates + * every key against the real element props while still allowing our `data-*` + * attributes, instead of laundering the whole object past the checker with an + * `as` assertion. + */ +export type DefaultProps = React.ComponentPropsWithRef & + Record<`data-${string}`, string>; + +/** + * Maps state keys to functions that return data-attribute objects (or null). + */ +export type StateAttributesMapping = { + [K in keyof S]?: (value: S[K]) => Record | null; +}; + +// --------------------------------------------------------------------------- +// mergeProps +// --------------------------------------------------------------------------- + +type PropsInput = + | React.ComponentPropsWithRef + | Record; + +/** + * Merges two props objects. Event handlers are chained (both fire), + * `style` is shallow-merged, `className` is concatenated, and + * everything else is overwritten by the second argument. + */ +export function mergeProps( + a: PropsInput, + b: PropsInput, +): Record; +export function mergeProps(a: Record, b: Record): Record { + const merged: Record = { ...a }; + + for (const key of Object.keys(b)) { + const bVal = b[key]; + + if ( + key.charCodeAt(0) === 111 /* o */ && + key.charCodeAt(1) === 110 /* n */ && + key.charCodeAt(2) >= 65 /* A */ && + key.charCodeAt(2) <= 90 /* Z */ && + typeof a[key] === 'function' && + typeof bVal === 'function' + ) { + // Chain event handlers — internal fires first, consumer fires second + const aHandler = a[key] as (...args: unknown[]) => void; + const bHandler = bVal as (...args: unknown[]) => void; + merged[key] = (...args: unknown[]) => { + aHandler(...args); + bHandler(...args); + }; + } else if (key === 'style' && a.style && bVal) { + merged.style = { ...(a.style as object), ...(bVal as object) }; + } else if (key === 'className' && typeof a.className === 'string' && typeof bVal === 'string') { + merged.className = `${a.className} ${bVal}`; + } else { + merged[key] = bVal; + } + } + + return merged; +} + +// --------------------------------------------------------------------------- +// useRender +// --------------------------------------------------------------------------- /** * A `render` prop: a render function, or a React element to clone with the @@ -62,9 +151,8 @@ interface UseRenderParamsAlways< /** * Renders a primitive part with render-prop support, conditional rendering, and - * state-to-data-attribute mapping. Unlike `renderElement`, `render` accepts a - * React element (cloned with merged props) as well as a function, and refs are - * merged internally via `ref`. + * state-to-data-attribute mapping. `render` accepts a React element (cloned with + * merged props) as well as a function, and refs are merged internally via `ref`. * * Hook: call it unconditionally. When `enabled` is omitted the element is always * rendered (returns ReactElement); when passed it may be skipped (returns null). @@ -108,7 +196,7 @@ export function useRender< if (typeof render === 'function') { // SAFETY: computedProps is the tag's props widened with data-* attrs; the render - // function is declared to receive this tag's props. Matches renderElement above. + // function is declared to receive this tag's props. return render({ ...computedProps, ref: mergedRef } as React.ComponentPropsWithRef); } From 4c66397851d1e02f26a35552ea0d004f9a585373 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 19:27:38 -0400 Subject: [PATCH 2/2] add changeset --- .changeset/curvy-worlds-change.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/curvy-worlds-change.md diff --git a/.changeset/curvy-worlds-change.md b/.changeset/curvy-worlds-change.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/curvy-worlds-change.md @@ -0,0 +1,2 @@ +--- +---