Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/@headlessui-react/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,30 @@ describe('Safe guards', () => {

describe('Rendering', () => {
describe('Menu', () => {
it(
'should not crash when Menu renders as a Fragment with multiple children and data attributes from router tooling',
suppressConsoleLogs(async () => {
render(
<Menu data-tsd-source="src/routes/index.tsx:1:1">
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="a">Item A</Menu.Item>
<Menu.Item as="a">Item B</Menu.Item>
<Menu.Item as="a">Item C</Menu.Item>
</Menu.Items>
</Menu>
)

assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })

await click(getMenuButton())

assertMenuButton({ state: MenuState.Visible })
assertMenu({ state: MenuState.Visible })
})
)

it(
'should be possible to render a Menu using a render prop',
suppressConsoleLogs(async () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/@headlessui-react/src/utils/render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,25 @@ describe('Default functionality', () => {
expect(contents()).toMatchSnapshot()
})

it('should forward data attributes to multiple children when rendering a Fragment', () => {
testRender(
<Dummy as={Fragment} data-tsd-source="src/routes/index.tsx:1:1">
<span key="a" data-testid="child-a">
Contents A
</span>
<span key="b" data-testid="child-b" data-tsd-source="child-owned">
Contents B
</span>
</Dummy>
)

expect(getByTestId(document.body, 'child-a')).toHaveAttribute(
'data-tsd-source',
'src/routes/index.tsx:1:1'
)
expect(getByTestId(document.body, 'child-b')).toHaveAttribute('data-tsd-source', 'child-owned')
})

it(
'should error when we are applying props to a Fragment when we do not have a dedicated element',
suppressConsoleLogs(() => {
Expand Down
89 changes: 86 additions & 3 deletions packages/@headlessui-react/src/utils/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,20 +182,47 @@ function _render<TTag extends ElementType, TSlot>(
}

if (isFragment(Component)) {
if (Object.keys(compact(rest)).length > 0 || Object.keys(compact(dataAttributes)).length > 0) {
let passthroughProps = compact(rest)
let passthroughDataAttributes = mergeDataAttributeProps(
compact(dataAttributes),
pickDataAttributes(passthroughProps)
)
let passthroughNonDataProps = omit(
passthroughProps,
Object.keys(pickDataAttributes(passthroughProps))
)

if (Object.keys(passthroughDataAttributes).length > 0) {
let childrenWithDataAttributes = cloneChildrenWithDataAttributes(
resolvedChildren,
passthroughDataAttributes
)

if (
childrenWithDataAttributes !== null &&
Object.keys(passthroughNonDataProps).length === 0
) {
return childrenWithDataAttributes
}
}

if (
Object.keys(passthroughProps).length > 0 ||
Object.keys(compact(dataAttributes)).length > 0
) {
if (
!isValidElement(resolvedChildren) ||
(Array.isArray(resolvedChildren) && resolvedChildren.length > 1) ||
isFragmentInstance(resolvedChildren)
) {
if (Object.keys(compact(rest)).length > 0) {
if (Object.keys(passthroughProps).length > 0) {
throw new Error(
[
'Passing props on "Fragment"!',
'',
`The current component <${name} /> is rendering a "Fragment".`,
`However we need to passthrough the following props:`,
Object.keys(compact(rest))
Object.keys(passthroughProps)
.concat(Object.keys(compact(dataAttributes)))
.map((line) => ` - ${line}`)
.join('\n'),
Expand Down Expand Up @@ -462,6 +489,62 @@ function omit<T extends Record<any, any>>(object: T, keysToOmit: string[] = [])
return clone
}

function pickDataAttributes<T extends Record<string, any>>(object: T) {
return Object.fromEntries(Object.entries(object).filter(([key]) => key.startsWith('data-')))
}

function mergeDataAttributeProps<T extends Record<string, any>, U extends Record<string, any>>(
primaryDataAttributes: T,
secondaryDataAttributes: U
) {
return Object.assign({}, primaryDataAttributes, secondaryDataAttributes)
}

function cloneChildrenWithDataAttributes(
children: ReactElement | ReactElement[],
dataAttributes: Record<string, any>
): ReactElement | ReactElement[] | null {
if (Array.isArray(children)) {
let clonedChildren = children.map((child) =>
cloneChildWithDataAttributes(child, dataAttributes)
)

return clonedChildren.every((child) => child !== null)
? (clonedChildren as ReactElement[])
: null
}

return cloneChildWithDataAttributes(children, dataAttributes)
}

function cloneChildWithDataAttributes(
child: ReactElement,
dataAttributes: Record<string, any>
): ReactElement | null {
if (!isValidElement(child)) return null

if (isFragmentInstance(child)) {
let clonedChildren = React.Children.map(child.props.children, (nestedChild) => {
if (!isValidElement(nestedChild)) return null

return cloneChildWithDataAttributes(nestedChild, dataAttributes)
})

if (clonedChildren?.some((nestedChild) => nestedChild === null)) return null

return cloneElement(child, undefined, clonedChildren)
}

let childDataAttributes = {}

for (let key in dataAttributes) {
if (key in child.props) continue
childDataAttributes[key] = dataAttributes[key]
}

return cloneElement(child, childDataAttributes)
}

function getElementRef(element: React.ReactElement) {
// @ts-expect-error
return React.version.split('.')[0] >= '19' ? element.props.ref : element.ref
Expand Down