From b07f4a1d0a0215c58985be0ec1c00faeb02b68e2 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:06:51 +0530 Subject: [PATCH] fix: allow fragment roots to fan out data attrs --- .../src/components/menu/menu.test.tsx | 24 +++++ .../src/utils/render.test.tsx | 19 ++++ .../@headlessui-react/src/utils/render.ts | 89 ++++++++++++++++++- 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 14a2d3109a..bb68b31a6b 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -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( + + Trigger + + Item A + Item B + Item C + + + ) + + 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 () => { diff --git a/packages/@headlessui-react/src/utils/render.test.tsx b/packages/@headlessui-react/src/utils/render.test.tsx index b6fc152681..4b8c95c981 100644 --- a/packages/@headlessui-react/src/utils/render.test.tsx +++ b/packages/@headlessui-react/src/utils/render.test.tsx @@ -244,6 +244,25 @@ describe('Default functionality', () => { expect(contents()).toMatchSnapshot() }) + it('should forward data attributes to multiple children when rendering a Fragment', () => { + testRender( + + + Contents A + + + Contents B + + + ) + + 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(() => { diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts index dfb47687d0..817b30df61 100644 --- a/packages/@headlessui-react/src/utils/render.ts +++ b/packages/@headlessui-react/src/utils/render.ts @@ -182,20 +182,47 @@ function _render( } 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'), @@ -462,6 +489,62 @@ function omit>(object: T, keysToOmit: string[] = []) return clone } +function pickDataAttributes>(object: T) { + return Object.fromEntries(Object.entries(object).filter(([key]) => key.startsWith('data-'))) +} + +function mergeDataAttributeProps, U extends Record>( + primaryDataAttributes: T, + secondaryDataAttributes: U +) { + return Object.assign({}, primaryDataAttributes, secondaryDataAttributes) +} + +function cloneChildrenWithDataAttributes( + children: ReactElement | ReactElement[], + dataAttributes: Record +): 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 +): 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