Skip to content

react-aria-components ListBox can lose focus during virtualized keyboard navigation when options remount #9851

@vladimir-tikhonov-nutrient

Description

Provide a general summary of the issue here

When using react-aria-components ListBox inside a Virtualizer, focus can be lost during keyboard navigation once virtualization starts recycling or remounting option DOM nodes.

I can reproduce this with a grid-style ListBox: navigate far enough with ArrowRight to trigger remounts, then reverse direction with ArrowLeft. I can also reproduce it with rapid discrete arrow presses, not only with key repeat.

🤔 Expected Behavior?

Keyboard navigation should keep DOM focus on the logical option being navigated to, even if virtualization remounts that option’s DOM node.

😯 Current Behavior

After navigating far enough to trigger virtualization/remounts, focus can fall away from the option entirely. In a regular DOM context this may surface as focus landing on document.body. In Shadow DOM contexts it can surface as focus falling back to the shadow host from the outer document’s perspective.

Once that happens, keyboard navigation can break unless the consumer adds local focus-restoration logic.

💁 Possible Solution

There already seems to be virtualization/remount focus-repair logic on adjacent paths:

  • packages/@react-aria/gridlist/src/useGridListItem.ts
  • packages/@react-aria/grid/src/useGridCell.ts

Both track the focused key across remounts with this pattern:

// We need to track the key of the item at the time it was last focused so that we force
// focus to go to the item when the DOM node is reused for a different item in a virtualizer.
let keyWhenFocused = useRef<Key | null>(null);

ListBox appears to go through:

  • packages/react-aria-components/src/ListBox.tsx
  • packages/@react-aria/listbox/src/useOption.ts

and useOption does not appear to have an equivalent remount-focus recovery path.

My guess is that the ListBox / useOption path needs similar handling so wrappers do not need to patch this locally.

🔦 Context

This surfaced for us in a downstream wrapper built on top of react-aria-components ListBox. It looks like an upstream gap rather than something wrapper-specific.

If helpful, I can extract a smaller standalone reproduction from the wrapper into a minimal sandbox.

🖥️ Steps to Reproduce

  1. Render a ListBox inside a Virtualizer with enough items to force DOM recycling/remounting.
  2. Use a grid layout so ArrowRight / ArrowLeft move focus horizontally.
  3. Focus the first option.
  4. Navigate with ArrowRight far enough that virtualization remounts or recycles option DOM nodes.
  5. Reverse direction with ArrowLeft, or rapidly press arrows back and forth.
  6. Observe that focus can be lost when the currently focused option is remounted.

Minimal shape:

import {GridLayout, ListBox, ListBoxItem, Virtualizer} from 'react-aria-components';
import {Size} from '@react-stately/virtualizer';

<div style={{height: 400, width: 400, overflow: 'hidden'}}>
  <Virtualizer
    layout={GridLayout}
    layoutOptions={{
      minItemSize: new Size(80, 80),
      maxItemSize: new Size(100, 100)
    }}>
    <ListBox
      aria-label="virtualized listbox"
      layout="grid"
      items={items}
      style={{width: '100%', height: '100%'}}>
      {item => <ListBoxItem>{item.name}</ListBoxItem>}
    </ListBox>
  </Virtualizer>
</div>

🌍 Your Environment

Version

  • react-aria-components: 1.16.0
  • @react-aria/listbox: 3.15.3

What browsers are you seeing the problem on?

  • Chrome

What operating system are you using?

  • macOS

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