adobe / adobe/react-spectrum

React Aria: collapsing a node during a keyboard drag traps the page keyboard

Open
#10,599 4 comments 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

### Provide a general summary of the issue here

During a keyboard drag in a RAC `Tree`, collapsing an ancestor of the dragged item unmounts the drag source. The `DragManager` session survives that unmount with its document-level capture listeners still installed. Every keystroke on the page is then swallowed, no drop indicator is visible, and focus sits on ``. Escape is the only way out, and nothing on screen says so.

The trigger is not collapsing as such. It is unmounting the drag source mid-drag, so any app that removes the dragged row during a keyboard drag (filtering, live data, virtualization) should hit the same state.

### 🤔 Expected Behavior?

A drag session that can no longer be driven should end itself: tear down its listeners, restore focus to a surviving element, and fire `onDragEnd` once. A keyboard-only user should never be left on a page that eats every key while showing no drag.

Separately, there is a design call about what collapsing an ancestor of the dragged item should mean. Two options:

1. **Keep the drag alive.** The item's data still exists and only its row is hidden. RAC already tracks `draggingKeys` independently of the DOM. The drop target would re-derive to the nearest surviving position (`on Photos`, or `after Photos`), and the hidden descendants' indicators disappear. This matches what already happens when collapsing a node that does not contain the dragged item.
2. **Refuse the collapse** while the drag source sits inside that subtree.

Option 1 seems more useful. Collapsing a folder to bring a distant drop target into view is the flow keyboard reordering needs.

### 😯 Current Behavior

Trace from the automated repro below (`swallowed?` reports `event.defaultPrevented` after dispatching a `keydown` on the page):

```
onDragStart keys: ["image-2"]
after drag start target=Insert after Image 2
ArrowUp x4 target=Drop on Photos rows=[documents, budget, photos, image-1, image-2]
ArrowLeft (collapse)
>> onDragEnd op: cancel
target=(none) rows=[documents, budget, photos]
focus=
ArrowUp swallowed? true
Tab swallowed? true
"a" swallowed? true
Escape swallowed? true -> >> onDragEnd op: cancel (second time)
post-Escape ArrowUp swallowed? false
final focus=
```

Four distinct problems:

- The drag ends with no drop, and nothing on screen marks the transition.
- The session keeps eating every keydown on the page via `preventDefault()` and `stopImmediatePropagation()`.
- `onDragEnd` fires twice.
- Focus lands on ``, so a keyboard user restarts tabbing from the top of the document.

**Control case.** Drag `Budget` (a child of `Documents`), then collapse `Photos`. The drag survives, the target stays on `Photos`, and arrow keys keep working. This is what isolates the trigger to the drag source unmounting.

### 💁 Possible Solution

Three pieces are involved, in order. Paths and line numbers are as published in the 1.21.1 sourcemaps.

**1. `react-aria-components/src/Tree.tsx:545`** passes an `onKeyDown` to `useDroppableCollection` that calls `state.toggleKey(target.key)` when the collapse key fires on an `on` target:

```ts
onKeyDown: e => {
let target = dropState?.target;
if (target && target.type === 'item' && target.dropPosition === 'on') {
let item = state.collection.getItem(target.key);
if (e.key === EXPANSION_KEYS['expand'][direction] && item?.hasChildNodes && !state.expandedKeys.has(target.key)) {
state.toggleKey(target.key);
} else if (e.key === EXPANSION_KEYS['collapse'][direction] && item?.hasChildNodes && state.expandedKeys.has(target.key)) {
state.toggleKey(target.key);
}
}
}
```

Collapsing `Photos` unmounts the `Image 2` row, which `DragManager` holds as `dragTarget.element`.

**2. `react-aria/src/dnd/useDrag.ts:283-309`** cleans up on unmount by firing `onDragEnd` and clearing global DnD state, but never tears down the `DragManager` session. That cleanup exists for a [Firefox native-drag bug](https://bugzilla.mozilla.org/show_bug.cgi?id=460801) and predates keyboard DnD. The session's `document.addEventListener('keydown', ..., true)` and `cancelEvent()` stay installed, which is what kills the keyboard. This looks like the main fix: the cleanup should end the `DragManager` session rather than only the global state.

**3. `react-aria/src/dnd/DragManager.ts:634`** `cancel()` calls `this.dragTarget.element.focus()`, which does nothing on a detached node, so even Escape leaves focus on ``. A fallback target is needed.

Two related open issues that may share fix surface:

- [#6649](https://github.com/adobe/react-spectrum/issues/6649): Escape during a keyboard drag does not restore focus in lists. Same weak `cancel()` focus restore, triggered by `aria-hidden` rather than a detached node.
- [#5336](https://github.com/adobe/react-spectrum/issues/5336): cannot drag when the virtualizer removes the item from the DOM. The pointer-DnD counterpart of "drag source unmounts".

### 🔦 Context

We use RAC `Tree` with `useDragAndDrop` for a file-tree style reorder UI where users move items between nested folders. Collapsing a folder to bring a distant drop target into view is a natural part of that flow with a keyboard, and it is the one path that breaks.

The severity comes from the blast radius. The failure is not confined to the tree: the whole page stops responding to the keyboard, with no visible drag and no hint that Escape is the exit. For a keyboard-only or screen-reader user the page looks frozen. Escape does recover input, but focus is still lost to ``.

### 🖥️ Steps to Reproduce

Given a `Tree` with `dragAndDropHooks` and a `Photos` folder containing `Image 1` and `Image 2`:

1. Keyboard-navigate to the `Image 2` row.
2. Move focus to its drag handle and press Enter to start the drag.
3. Press ArrowUp until on `Photos`.
4. Press ArrowLeft to collapse `Photos`.
5. Press any key. Nothing on the page responds until Escape.

### Version

`react-aria-components` 1.21.1 (with `react-aria` 3.52.1, `react-stately` 3.50.0, React 19.2.8).

### What browsers are you seeing the problem on?

Firefox, Chrome

### If other, please specify.

_No response_

### What operating system are you using?

Fedora 44

### 🧢 Your Company/Team

_No response_

### 🕷 Tracking Issue

_No response_

Contributor guide

Open the contributing guide

Research direction

Reproduce the keyboard drag failure in a RAC Tree, then read the unmount cleanup in react-aria/src/dnd/useDrag.ts:283-309 and cancellation logic in react-aria/src/dnd/DragManager.ts:634. Check the collapse handler in react-aria-components/src/Tree.tsx:545 and related issues #6649 and #5336. Done means an unmounted drag source ends one session, releases keyboard listeners, restores focus, and has regression coverage for the reported sequence.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.