Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: SSR Combobox inner ref lost #7663

Merged
merged 10 commits into from
Feb 20, 2025
2 changes: 1 addition & 1 deletion packages/@react-aria/collections/src/CollectionBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function useCollectionDocument<T extends object, C extends BaseCollection<T>>(cr
useLayoutEffect(() => {
document.isMounted = true;
return () => {
// Mark unmounted so we can skip all of the collection updates caused by
// Mark unmounted so we can skip all of the collection updates caused by
// React calling removeChild on every item in the collection.
document.isMounted = false;
};
Expand Down
14 changes: 3 additions & 11 deletions packages/@react-aria/collections/src/Hidden.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@
* governing permissions and limitations under the License.
*/

import {createPortal} from 'react-dom';
import {forwardRefType} from '@react-types/shared';
import React, {createContext, forwardRef, ReactElement, ReactNode, useContext} from 'react';
import {useIsSSR} from '@react-aria/ssr';

// 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
Expand All @@ -37,12 +35,8 @@ if (typeof HTMLTemplateElement !== 'undefined') {

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

// Portal to nowhere
const hiddenFragment = typeof DocumentFragment !== 'undefined' ? new DocumentFragment() : null;

export function Hidden(props: {children: ReactNode}) {
let isHidden = useContext(HiddenContext);
let isSSR = useIsSSR();
if (isHidden) {
// Don't hide again if we are already hidden.
return <>{props.children}</>;
Expand All @@ -54,12 +48,10 @@ export function Hidden(props: {children: ReactNode}) {
</HiddenContext.Provider>
);

// In SSR, portals are not supported by React. Instead, render into a <template>
// 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 DOM tree, so it won't affect ids or other accessibility attributes.
return isSSR
? <template data-react-aria-hidden>{children}</template>
: createPortal(children, hiddenFragment!);
// 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>;
}

/** Creates a component that forwards its ref and returns null if it is in a hidden subtree. */
Expand Down
57 changes: 56 additions & 1 deletion packages/react-aria-components/test/ComboBox.ssr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {screen, testSSR} from '@react-spectrum/test-utils-internal';
import {fireEvent, screen, testSSR} from '@react-spectrum/test-utils-internal';

describe('ComboBox SSR', function () {
it('should render text of default selected key', async function () {
Expand Down Expand Up @@ -40,4 +40,59 @@ describe('ComboBox SSR', function () {
let input = screen.getByRole('combobox');
expect(input.value).toBe('Dog');
});

it('should point ref correctly after hydration', async function () {
await testSSR(__filename, `
import {ComboBox, Label, Input, Popover, ListBox, ListBoxItem} from '../';
import {useState, useRef} from 'react';
import {useLayoutEffect} from '@react-aria/utils';

function App() {
let [triggers, setTriggers] = useState(['null']);
let [otherState, setOtherState] = useState(0);
let ref = useRef(null);

useLayoutEffect(() => {
setTriggers(prev => [...prev, ref.current?.outerHTML]);
}, [otherState]);

return (
<React.StrictMode>
<ComboBox defaultSelectedKey="dog">
<div ref={ref} role="group">
<Label>Favorite Animal</Label>
<Input />
</div>
<Popover>
<ListBox>
<ListBoxItem id="cat">Cat</ListBoxItem>
<ListBoxItem id="dog">Dog</ListBoxItem>
<ListBoxItem id="kangaroo">Kangaroo</ListBoxItem>
</ListBox>
</Popover>
</ComboBox>
<div role="button">{triggers.join(", ")}</div>
<div role="button" onClick={() => setOtherState(1)}>{otherState}</div>
</React.StrictMode>
);
}
<App />
`, () => {
// Assert that server rendered stuff into the HTML.
let input = screen.getByRole('combobox');
expect(input.value).toBe('Dog');
let buttons = screen.getAllByRole('button');
expect(buttons[0].textContent).toBe('null');
});

// Assert that hydrated UI matches what we expect.
let input = screen.getByRole('combobox');
expect(input.value).toBe('Dog');
let buttons = screen.getAllByRole('button');
let [button, button2] = buttons;
fireEvent.click(button2);
expect(button2.textContent).toBe('1');
let [, , second] = button.textContent.split(', ');
expect(second).toBe(screen.getByRole('group').outerHTML);
});
});