Skip to content

<Focusable>/<Pressable> dev warning "child must be focusable" false-positives for content mounted in hidden subtrees #10546

Description

@SimenB

Provide a general summary of the issue here

The dev-only mount check in Focusable and Pressable warns "child must be focusable. Please ensure the tabIndex prop is passed through." for children that do receive tabIndex correctly, whenever they mount inside a hidden subtree. Anything rendered inside a collapsed DisclosurePanel hits this, because a collapsed panel keeps its content mounted with the hidden attribute.

PR incoming - I have a potential fix and tests ready, filing this first per CONTRIBUTING.md.

🤔 Expected Behavior?

No warning when the child forwards its props and the tabIndex reaches the DOM element. Whether that element happens to be visible at mount time is not what the warning is about.

😯 Current Behavior

A console.warn on every mounted instance:

<Focusable> child must be focusable. Please ensure the tabIndex prop is passed through.

The child is fine — it has tabindex="0" in the DOM. The check is in the dev-only effect in Focusable and Pressable:

if (!props.isDisabled && !isFocusable(el)) {
  console.warn('<Focusable> child must be focusable. Please ensure the tabIndex prop is passed through.');
}

isFocusable is element.matches(FOCUSABLE_ELEMENT_SELECTOR) && !isInert(element) && isElementVisible(element). isElementVisible walks up the ancestor chain and returns false on any ancestor with hidden or display: none. So the visibility half fails while the part the warning is actually about — did tabIndex reach the element — passes.

useDisclosure sets hidden="until-found" on the collapsed panel and keeps the content mounted, so every Focusable/Pressable inside a collapsed Disclosure warns. The effect's deps are [ref, props.isDisabled], so it fires once at mount; expanding the disclosure later does not help, the warning has already been logged.

It fires once per mounted instance. A page with a list of avatars that each have a tooltip inside a collapsed section produces one warning per avatar, in dev and in test output.

💁 Possible Solution

isFocusable already takes the escape hatch this needs. Pass {skipVisibilityCheck: true} at both call sites:

if (!props.isDisabled && !isFocusable(el)) {

if (!props.isDisabled && !isFocusable(el)) {

element.matches(FOCUSABLE_ELEMENT_SELECTOR) still catches the real mistake: a child that drops the props it is given has no tabindex attribute, so the warning still fires for it. The same option is already used this way by the tree walk in interactions/utils.ts:

while (target && !isFocusable(target, {skipVisibilityCheck: true})) {

Note that hidden on the child element itself would still warn, since the selector is [tabindex]:not([disabled]):not([hidden]). Only hidden ancestors stop warning, which seems like the right line to draw, but happy to be told otherwise.

🔦 Context

Found in a real app's CI: avatars with tooltips inside collapsed disclosures on one page log the warning several times per test run and in every dev build. It is noise that hides real warnings, and it points developers at code that is already correct.

🖥️ Steps to Reproduce

Render this with the disclosure collapsed (its default) and look at the console:

import {Button, Disclosure, DisclosurePanel, Focusable, Heading, Tooltip, TooltipTrigger} from 'react-aria-components';

<Disclosure>
  <Heading level={3}>
    <Button slot="trigger">Toggle</Button>
  </Heading>
  <DisclosurePanel>
    <TooltipTrigger>
      <Focusable>
        <span role="img" aria-label="Avatar">A</span>
      </Focusable>
      <Tooltip>Avatar</Tooltip>
    </TooltipTrigger>
  </DisclosurePanel>
</Disclosure>

The warning is logged on mount. Expanding the disclosure afterwards does not change anything, since the check only runs once.

The same thing reproduces without RAC components at all:

<div hidden>
  <Focusable>
    <span role="button">Button</span>
  </Focusable>
</div>

Version

react-aria@3.52.0

What browsers are you seeing the problem on?

Other

If other, please specify.

not browser-specific — it is a dev-mode console.warn

What operating system are you using?

macOS

🧢 Your Company/Team

No response

🕷 Tracking Issue

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions