adobe / adobe/react-spectrum

ariaHideOutside defaults to the module-global document, so modals in a second window hide the opener's <body>

Open
#10,581 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
15.9k
Forks
1.6k
Avg merge
3d 9m
Merged PRs (30d)
59

Description

## Hello, and thank you

First, a genuine thank you. `ariaHideOutside` is one of those pieces that quietly does a lot of hard work, and the fact that it already handles shadow DOM, inert, and ref-counting is why our multi-window workspace got as far as it did before we hit an edge. We're reporting this because we'd like to keep using React Aria for it, not because we're stuck without a workaround.

**What we're building:** a workspace shell where the operator can detach a panel into a secondary browser window (`window.open()`, same origin). The detached panel's React subtree is rendered into the child window's document with `createPortal`, and RAC overlays inside it are pointed at the child window via `UNSTABLE_portalContainer`. That part works beautifully — menus, popovers and modals all open in the window the operator clicked in.

## What we observe

When a RAC `` / `` opens inside that secondary window, `aria-hidden="true"` is set on the **opener window's** ``, and Chrome logs:

```
Blocked aria-hidden on a element because it would hide the entire accessibility
tree from assistive technology users.
```

We measured both documents at the moment the modal opens:

```json
{
"modalIsInChildDocument": true, // the portal works as intended
"childBodyAriaHidden": null, // the child window is untouched
"openerBodyAriaHidden": "true", // the opener's is what got marked
"openerHiddenChildren": 0
}
```

## What we expected

`ariaHideOutside` would hide content in the document that actually contains the modal (the child window), leaving the opener's document alone.

## Where it comes from

We traced it, and we think the mechanism is small and self-contained. In `ariaHideOutside`:

```js
function ariaHideOutside(targets, options) {
let windowObj = getOwnerWindow(targets?.[0]); // ← window derived from the target
let opts = options instanceof windowObj.Element ? { root: options } : options;
let root = opts?.root ?? document.body; // ← but the default root uses the module-global `document`
...
let walker = createShadowTreeWalker(getOwnerDocument(root), root, ...); // ← walker uses root's owner document
}
```

The function **already** derives `windowObj` from the target on the first line, and the tree walker **already** uses `getOwnerDocument(root)`. Only the default `root` falls back to the module-scope `document`.

That global is the opener's document, because a `window.open()`ed same-origin window shares the opener's JavaScript realm: there are two documents but only one `document` global. So when the target lives in the child document, the walker starts from the opener's ``, finds no target inside it, and ends up marking that `` itself.

Call sites that hit this (neither passes `root`):

- `useModalOverlay`: `ariaHideOutside([ref.current], { shouldUseInert: true })`
- `usePopover`: `ariaHideOutside([groupRef?.current ?? popoverRef.current], { shouldUseInert: true })`

## Why the existing escape hatches don't reach it

We looked for a supported way to express this before filing:

- `UNSTABLE_portalContainer` (RAC) / `UNSAFE_PortalProvider` (`@react-aria/overlays`) correctly redirect **where the overlay renders**, but neither is consulted by `ariaHideOutside` — we confirmed there is no portal-context lookup in `ariaHideOutside` or `useModalOverlay`.
- `ariaHideOutside` accepts a `root`, but `ModalOverlay` / `Popover` don't surface a way to pass one through.

So today the behavior isn't reachable from public API, which is why we're bringing it here rather than working around it locally.

## Reproduction

Minimal shape (same origin, no iframe):

```jsx
function DetachedPanel() {
const [win, setWin] = useState(null);
useEffect(() => { setWin(window.open('', '_blank', 'width=800,height=600')); }, []);
if (!win) return null;

return createPortal(


Hello

,
win.document.body
);
}
```

Then inspect from the child window:

```js
window.opener.document.body.getAttribute('aria-hidden'); // "true"
document.body.getAttribute('aria-hidden'); // null
```

(Styles need to be copied into the child document for the modal to be visible, but the `aria-hidden` behavior does not depend on that.)

## Impact

Chrome refuses `aria-hidden` on ``, so the visible symptom is the console warning — but the intended effect is also lost: inside the child window, a screen reader still reaches the content behind the modal, so the dialog isn't modal for assistive technology. In an engine that doesn't block the attribute, the opener window would disappear from the accessibility tree entirely while a dialog is open somewhere else.

## Proposed change

The smallest version we can see is one line, and it's consistent with what the function already does on lines above and below it:

```diff
- let root = opts?.root ?? document.body;
+ let root = opts?.root ?? windowObj.document.body;
```

`windowObj` is already `getOwnerWindow(targets?.[0])`, which resolves to `getOwnerDocument(target)?.defaultView ?? window`. In a single-window app that is the same object as the module-global `window`, so `windowObj.document.body` is byte-for-byte today's value — we'd expect it to be behavior-preserving there. And it introduces no new failure mode when `windowObj` is undefined, since the line just above it (`options instanceof windowObj.Element`) already relies on it being defined.

If you'd rather not change a default, a narrower alternative that would also unblock us is threading an optional `root` (or `UNSTABLE_ariaHideRoot`) through `useModalOverlay` / `usePopover` and out to the RAC `Modal` / `Popover` props, so applications can be explicit.

We'd be glad to open a PR for whichever direction you prefer, including tests for the two-document case — just let us know which shape fits the codebase better.

## Environment

- `react-aria` 3.51.0, `react-aria-components` 1.20.0
- React 19
- Chromium 151, macOS
- No iframe involved; same-origin `window.open()`

Related in spirit (same "secondary document" family, different function): #7743 — *Support root element in `usePreventScroll`*.

Thanks again for reading this far, and for the work on this library.

Contributor guide

Open the contributing guide

Research direction

Start at ariaHideOutside, then trace the useModalOverlay and usePopover entry points described in the issue. Reproduce the two-document window.open case and add tests for it; done means the child document is hidden behind its modal while the opener document remains untouched.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.