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
59 changes: 57 additions & 2 deletions packages/@react-aria/collections/src/Hidden.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@
*/

import {forwardRefType} from '@react-types/shared';
import React, {Context, createContext, forwardRef, JSX, ReactElement, ReactNode, useContext} from 'react';
import React, {Context, createContext, forwardRef, JSX, ReactElement, ReactNode, useContext, useRef} from 'react';
import {useLayoutEffect} from '@react-aria/utils';

// React doesn't understand the <template> element, which doesn't have children like a normal element.
// It will throw an error during hydration when it expects the firstChild to contain content rendered
// on the server, when in reality, the browser will have placed this inside the `content` document fragment.
// This monkey patches the firstChild property for our special hidden template elements to work around this error.
// does the same for appendChild/removeChild/insertBefore as per the issue below
// See https://github.com/facebook/react/issues/19932
if (typeof HTMLTemplateElement !== 'undefined') {
const getFirstChild = Object.getOwnPropertyDescriptor(Node.prototype, 'firstChild')!.get!;
const originalAppendChild = Object.getOwnPropertyDescriptor(Node.prototype, 'appendChild')!.value!;
const originalRemoveChild = Object.getOwnPropertyDescriptor(Node.prototype, 'removeChild')!.value!;
const originalInsertBefore = Object.getOwnPropertyDescriptor(Node.prototype, 'insertBefore')!.value!;

Object.defineProperty(HTMLTemplateElement.prototype, 'firstChild', {
configurable: true,
enumerable: true,
Expand All @@ -31,12 +37,61 @@ if (typeof HTMLTemplateElement !== 'undefined') {
}
}
});

Object.defineProperty(HTMLTemplateElement.prototype, 'appendChild', {
configurable: true,
enumerable: true,
value: function (node) {
if (this.dataset.reactAriaHidden) {
return this.content.appendChild(node);
} else {
return originalAppendChild.call(this, node);
}
}
});

Object.defineProperty(HTMLTemplateElement.prototype, 'removeChild', {
configurable: true,
enumerable: true,
value: function (node) {
if (this.dataset.reactAriaHidden) {
return this.content.removeChild(node);
} else {
return originalRemoveChild.call(this, node);
}
}
});

Object.defineProperty(HTMLTemplateElement.prototype, 'insertBefore', {
configurable: true,
enumerable: true,
value: function (node, child) {
if (this.dataset.reactAriaHidden) {
return this.content.insertBefore(node, child);
} else {
return originalInsertBefore.call(this, node, child);
}
}
});
}

export const HiddenContext: Context<boolean> = createContext<boolean>(false);

export function Hidden(props: {children: ReactNode}): JSX.Element {
let isHidden = useContext(HiddenContext);
let templateRef = useRef<HTMLTemplateElement>(null);
// somehow React might add children to the template and we never hit the reactAriaHidden parts of the above overrides
// so we need to move those children into the content of the template since templates can't have direct children
useLayoutEffect(() => {
let el = templateRef.current;
if (!el?.dataset.reactAriaHidden) {
return;
}
while (el.childNodes.length > 0) {
el.content.appendChild(el.childNodes[0]);
}
}, []);

if (isHidden) {
// Don't hide again if we are already hidden.
return <>{props.children}</>;
Expand All @@ -51,7 +106,7 @@ export function Hidden(props: {children: ReactNode}): JSX.Element {
// In SSR, portals are not supported by React. Instead, always render into a <template>
// element, which the browser will never display to the user. In addition, the
// content is not part of the accessible DOM tree, so it won't affect ids or other accessibility attributes.
return <template data-react-aria-hidden>{children}</template>;
return <template ref={templateRef} data-react-aria-hidden>{children}</template>;
}

/** Creates a component that forwards its ref and returns null if it is in a hidden subtree. */
Expand Down
5 changes: 2 additions & 3 deletions packages/@react-spectrum/s2/test/Combobox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,8 @@ describe('Combobox', () => {
let dialog = tree.getByRole('dialog');
expect(dialog).toBeVisible();

// Because of the fake DOM we'll see this twice
expect(tree.getAllByText('Title here')[1]).toBeVisible();
expect(tree.getAllByText('Contents')[1]).toBeVisible();
expect(tree.getAllByText('Title here')[0]).toBeVisible();
expect(tree.getAllByText('Contents')[0]).toBeVisible();
warn.mockRestore();
});
});
5 changes: 2 additions & 3 deletions packages/@react-spectrum/s2/test/Picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,8 @@ describe('Picker', () => {
let dialog = tree.getByRole('dialog');
expect(dialog).toBeVisible();

// Because of the fake DOM we'll see this twice
expect(tree.getAllByText('Title here')[1]).toBeVisible();
expect(tree.getAllByText('Contents')[1]).toBeVisible();
expect(tree.getAllByText('Title here')[0]).toBeVisible();
expect(tree.getAllByText('Contents')[0]).toBeVisible();
warn.mockRestore();
});
});