react-spectrum: SSRProvider causes an error when conditionally rendering an Overlay (Next.js)

🐛 Bug Report

When wrapping the whole application with SSRProvider and then conditionally rendering an Overlay or OverlayContainer component with the useOverlayTriggerState state hook, an Unhandled Runtime Error is thrown.

Unhandled Runtime Error
TypeError: Cannot read properties of null (reading 'contains')

It doesn’t happen when the SSRProvider is removed in _app.tsx.

I’ve tried all sorts of different things like reimplementing a Modal in different ways with react-aria. I’ve followed the official documentation etc. It all seems to be tied to the SSRProvider component somehow. I don’t understand the code well enough to find a solution myself.

🤔 Expected Behavior

I expect the SSRProvider not to cause unhandled runtime errors when conditionally rendering react-aria components.

😯 Current Behavior

Conditionally rendering the Overlay component from react-aria causes an Unhandled Runtime Error.

Unhandled Runtime Error
TypeError: Cannot read properties of null (reading 'contains')

Call Stack
eval
node_modules/@react-aria/overlays/dist/module.js (1102:0)
Array.some
<anonymous>
MutationObserver.eval
node_modules/@react-aria/overlays/dist/module.js (1102:0)

💻 Code Sample

The code sample is a standard Nextjs setup. Relevant files are components/modal.tsx and pages/index.tsx. https://codesandbox.io/p/sandbox/react-aria-conditional-overlay-ssrprovider-7rvohm?file=%2Fpages%2Findex.tsx

🌍 Your Environment

Software Version(s)
react-aria 3.21.0
react-stately 3.19.0
Browser Chrome
Operating System Macbook Pro, Ventura 13.0.1

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 7
  • Comments: 17 (2 by maintainers)

Most upvoted comments

I confirmed this in our Next.js test app build too. You can see it if you open the ComboBox and check the console. Not seeing it in any other React Spectrum overlays interestingly.

We can fix this with a change to node?.contains here:

https://github.com/adobe/react-spectrum/blob/168ca5a5cd53589bae6620fba1602e18240efc46/packages/%40react-aria/overlays/src/ariaHideOutside.ts#L97

but we probably need to figure out why that ref is null for a render in the first place.

I got to the bottom of this in #4297. This is actually the same issue as #3293. Due to the useIsSSR check here: https://github.com/adobe/react-spectrum/blob/a1efafd9e8bf893a6b433a3ccab60ff66295c302/packages/%40react-aria/overlays/src/Overlay.tsx#L37

The popover is not rendered for an extra render cycle after the combobox state’s isOpen property becomes true. That causes the popover ref to be null and the crash here.

The fix is to only perform an extra re-render during initial hydration, and not whenever any component mounts inside an SSR environment later on.

this is working fine for me:

<Overlay portalContainer={document.body}>
      <div
        css={css.underlay}
        {...underlayProps}
        onContextMenu={preventContextMenu}
      >
     ...
  </div>
</Overlay>

I’m able to replicate the same error as well

Hello there,

I have also encountered this issue, I think that it might be interesting to avoid crash when a reference is null or undefined in ariaHideOutside helper. This error is currently caused by the Overlay component and the portalContainer, as you can see here : https://github.com/adobe/react-spectrum/blob/86b9d3d0564244215a73c3bd18edad53078c4f3b/packages/%40react-aria/overlays/src/Overlay.tsx#L37 by default portalContainer is set to null when isSSR is true. When we are wrapping our application in SSRProvider, the hook useIsSSR always return true at first, then false after passing the useLayoutEffect inside the if condition instead of being false since the beginning. That switch between true and false is for me the main issue behind all of this.

I’m not enough aware about SSR constraints and the system used behind SSRProvider here, but can’t we avoid the state change in this hook due to rerender after setting the state ? I would have suggest to replace this line : https://github.com/adobe/react-spectrum/blob/86b9d3d0564244215a73c3bd18edad53078c4f3b/packages/%40react-aria/ssr/src/SSRProvider.tsx#L94 by something like :let [isSSR, setIsSSR] = useState(typeof window === 'undefined' && isInSSRContext); but i’m pretty sure that this is not as easy as that 😛

I currently have found a workaround since i’m using react-aria hooks, this is to set a portalContainer ref on Overlay component, by doing that, you avoid the null set by isSSR.

Seeing this issue as well when running a useComboBox example in Next.

In this case my workaround was to give the listBoxRef in there as popoverRef. Because I wanted it to use without a popover or something similiar.

`… let inputRef = React.useRef(null); let listBoxRef = React.useRef(null); let popoverRef = listBoxRef;

let { inputProps, listBoxProps, labelProps } = useComboBox(
    {
        ...props,
        inputRef,
        listBoxRef,
        popoverRef,
    },
    state
);
...`

Here’s a dirty workaround that seems to work:

const Modal = (props) => {
  // Callback ref instead of `useRef`.
  const [ref, setRef] = React.useState<null | HTMLDivElement>(document.createElement("div"));
  // `useModalOverlay` expects it to be in the shape of a normal ref.
  const { modalProps, underlayProps } = useModalOverlay(props, state, {current: ref});

  // /* ... */

  return (
    <Overlay>
      <div className={"overlay"} {...underlayProps}>
        <div
          {...modalProps}
          className="modal"
          ref={setRef}
        >
          {children}
        </div>
      </div>
    </Overlay>
  )
}

When the component first renders, the value of ref is set to a div element unattached to the main document with no children, so ariaHideOutside does essentially nothing. When the modal is mounted, it calls setRef with its ref value, which causes the component to re-render, calling useModalOverlay a second time with the correct ref, so it works as expected.

I get the same error in Remix

ariaHideOutside.ts:97 Uncaught TypeError: Cannot read properties of null (reading 'contains')
    at ariaHideOutside.ts:97:64
    at Array.some (<anonymous>)
    at MutationObserver.<anonymous> (ariaHideOutside.ts:97:46)

Seeing this issue as well when running a useComboBox example in Next.