Skip to content

fix: #8686 prevent useGridCell from overriding child focus restoration loops - #10228

Open
jsmitrah wants to merge 32 commits into
adobe:mainfrom
jsmitrah:bug-fix/8686/table-cell-incorrectly-returns-focus-to-button
Open

fix: #8686 prevent useGridCell from overriding child focus restoration loops#10228
jsmitrah wants to merge 32 commits into
adobe:mainfrom
jsmitrah:bug-fix/8686/table-cell-incorrectly-returns-focus-to-button

Conversation

@jsmitrah

@jsmitrah jsmitrah commented Jun 18, 2026

Copy link
Copy Markdown

Closes #8686

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

  • Tab into the table row and use the arrow keys to focus on the "Open Dialog" button.
  • Press Enter to open the modal dialog, then press Escape to close it.
  • Verify that focus returns exactly to the "Open Dialog" button instead of jumping to the "Action 1" button.

🧢 Your Project:

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the interest, if you run yarn format it should get rid of all the noisy whitespace changes that I'm guessing your IDE applied

@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch 5 times, most recently from 0a7cf3b to 714723c Compare June 18, 2026 15:09
@jsmitrah
jsmitrah requested a review from snowystinger June 19, 2026 05:35
@jsmitrah

Copy link
Copy Markdown
Author

Thanks @snowystinger, all tests are passed

@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from ccb8bbd to 4b28c4a Compare June 19, 2026 08:22
@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from 71950d3 to 22b6e5f Compare June 24, 2026 10:54
return;
}

// If the cell itself is focused, wait a frame so that focus finishes propagatating

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an important comment. JSDOM and the act environment is not a reliable test against this as it's simulating real browsers.
Did you run this against real browsers? If I recall, document.activeElement can be updated at different times depending on the browser you're in as well.

@jsmitrah jsmitrah Jun 26, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, @snowystinger. To avoid the timing issues of the document.activeElement across different real browsers, I updated the approach to use e.relatedTarget.

By checking e.relatedTarget synchronously, we can instantly determine if focus is moving directly into a nested child element. This provides identical, reliable accuracy in both JSDOM and real browsers without needing to wait for a frame

I've pushed the update.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any update with relatedTarget?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for the confusion, @snowystinger . I initially tried an implementation using e.relatedTarget, but it completely broke the React 16 test suite (test-16) on CircleCI due to synthetic event pooling limitations in older environments.

Because asynchronous timeouts/frames break the synchronous act() testing clock, I reverted to the synchronous approach using getActiveElement(). I have also restored the original comment regarding focus propagation to keep the documentation clear.

@snowystinger snowystinger Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation. I'm going to be level with you. I have low confidence in this PR. It appears to be AI driven with a focus just on the tests and not on the browser. In addition, it doesn't explain how we've moved away from this requestAnimationFrame in a satisfactory way. The comment still says "wait a frame" which isn't what it's doing. Finally, the test seems fairly contrived as I note in my other comment.

This may very well be the correct solution, but we need a lot more due diligence. Help us help you get this PR merged.
Thank you for understanding.

@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from b06221e to adbba17 Compare June 26, 2026 14:56
@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from 6ab9b4c to bce1556 Compare June 26, 2026 15:21
Comment on lines -260 to -265
// useSelectableItem only handles setting the focused key when
// the focused element is the gridcell itself. We also want to
// set the focused key when a child element receives focus.
// If focus is currently visible (e.g. the user is navigating with the keyboard),
// then skip this. We want to restore focus to the previously focused row/cell
// in that case since the table should act like a single tab stop.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this deleted?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"My apologies, @snowystinger! That was completely accidental during a refactor pass in my editor. I have fully restored the original comment block.

return;
}

// If the cell itself is focused, wait a frame so that focus finishes propagatating

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any update with relatedTarget?

@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from 535198e to 244fa42 Compare July 2, 2026 13:32
@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from 244fa42 to 24667bf Compare July 2, 2026 13:39
@jsmitrah
jsmitrah requested a review from snowystinger July 2, 2026 13:50
return;
}

// If the cell itself is focused, wait a frame so that focus finishes propagatating

@snowystinger snowystinger Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation. I'm going to be level with you. I have low confidence in this PR. It appears to be AI driven with a focus just on the tests and not on the browser. In addition, it doesn't explain how we've moved away from this requestAnimationFrame in a satisfactory way. The comment still says "wait a frame" which isn't what it's doing. Finally, the test seems fairly contrived as I note in my other comment.

This may very well be the correct solution, but we need a lot more due diligence. Help us help you get this PR merged.
Thank you for understanding.

Comment on lines +170 to +175
// 2. Simulate focus returning directly to the second target element (Switch 2)
// exactly how the focus-restoration logic inside an overlay does it.
act(() => {
switches[1].focus();
});
expect(document.activeElement).toBe(switches[1]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what overlay? focus wasn't originally on the second switch so why would it be "restored" there?

it might be better to write these tests in a higher level than the hook tests so that we get integration behaviours, somewhere like react-aria-components/test/Table.test.js

// 3. Fire a focus event directly on the gridcell container to trigger your
// onFocus handler in useGridCell.ts and simulate event bubbling
act(() => {
cells[0].dispatchEvent(new FocusEvent('focus', {bubbles: true}));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

focus just happened on the switch, this would be inaccurate

cells[0].dispatchEvent(new FocusEvent('focus', {bubbles: true}));
});

// 4. Force Jest's timer and requestAnimationFrame microtask cycles to execute completely

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from the above code changes, why is this still needed?

@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from ac51242 to 217b6bb Compare July 10, 2026 13:45
@jsmitrah
jsmitrah force-pushed the bug-fix/8686/table-cell-incorrectly-returns-focus-to-button branch from 3507110 to cf9fb80 Compare July 10, 2026 13:57
@jsmitrah
jsmitrah requested a review from snowystinger July 10, 2026 14:12

let renderTable = props => render(<TestTable {...props} />);

if (typeof Symbol.dispose === 'undefined') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this?

@jsmitrah jsmitrah Jul 15, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the fix on different machines that have different Node versions (20.19.3) where this test was failing. I added these lines only to make the test work across those machines. Now I've removed them to keep the PR clean.

// e.g. because an overlay closed and removed the element that had focus. In
// that case, restore the child that was last focused instead of defaulting to
// childFocusStrategy's first/last child.
let ownerDocument = ref.current.ownerDocument;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like there have been some new changes since I last looked at this PR. What prompted these changes? Is it different from the original issue? The comments don't really explain. What's the real world case that caused this? There's a force push, so I can't follow the commits as a train of thought either to see when it was added.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After testing the previous fix in a real browser, I found the issue was still happening after opening the dialog and closing it; focus went back to the first button instead of the button that was previously focused. So I have handled this in a different approach to fix that

Comment on lines +291 to +294
// actual DOM descendants of the cell -- portalled content (e.g. a dialog opened
// from within the cell) can still reach this handler because React dispatches
// events along the component tree rather than the DOM tree for portals, even
// though it isn't really inside this cell in the DOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the portalled comment is about? the line below nodeContains(ref.current, target) is a guard for portals

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the nodeContains(ref.current, target) check prevents that. It ensures we remember only elements that belong to this cell, so focus is restored to the correct button when the dialog closes.


// If the cell itself is focused, wait a frame so that focus finishes propagatating
// up to the tree, and move focus to a focusable child if possible.
requestAnimationFrame(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't see an explanation for this change

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping requestAnimationFrame doesn't really solve the issue. It still depends on timing, which can behave differently across browsers. That was one of the main concerns from the beginning. It also makes the tests more complicated because they have to wait for the delayed code to run, and now, with the new approach, the delay is no longer needed. The code already knows which element should receive focus, so it can restore it immediately instead of waiting; keeping requestAnimationFrame would only add extra complexity without providing any real benefit. That's why I chose the synchronous approach.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connecting comment explaining where the raf may have originally come from that we'll want to verify against.
#10159 (comment)

I'm not sure what verifying it looks like yet.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snowystinger and @LFDanLu, I looked into the focusedKey concern first. The autofocus children behavior depends on focusedKey being updated correctly, so I tracked that flow and confirmed it is the focusMode behavior used for checkbox, drag handle, and menu cells when clicking the "tab" button.

I reran the existing tests after removing requestAnimationFrame, and they all pass, including the test that checks focus moves directly to the next row's checkbox with ArrowDown, confirming focusedKey is still updated correctly.

To verify the behavior in a real browser, I also attached a sample video that reproduces how focusedKey behaves during the same keyboard navigation flow.

@LFDanLu Please check this.

table-cell-focus.mp4

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reran the existing tests after removing requestAnimationFrame, and they all pass, including the test that checks focus moves directly to the next row's checkbox with ArrowDown, confirming focusedKey is still updated correctly.

Yeah, unfortunately in unit tests we have pretty tight control over time, so I wouldn't expect to see this surface there. This will require manual testing. We'll probably have to try building the codebase just before that requestAnimationFrame was added or right around when it was added but specifically remove that delay and see if we can reproduce any issues there so we know what to look out for since there aren't any instructions.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After digging into it a bit more, I realized I was mixing two different things.

RAF was originally added to let the current focus event finish before moving focus to a child. That helps avoid timing issues during focus handling and gives other focus-related logic, like updating focusedKey, a chance to complete first.

I initially removed it because I thought the timing was related to the issue. After tracing the flow, I found the real problem was that focus() didn't remember which child had focus before. When focus was restored, it always defaulted to the first child.

So I've kept RAF as it was originally. The fix is now in the focus restoration logic: focus() remembers the last focused child and restores focus to that element when focus is disrupted. This fixes the original issue without changing the existing RAF behavior.

I have committed my changes without changing the RAF. Please take a look

@jsmitrah
jsmitrah requested a review from snowystinger July 15, 2026 11:54

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Intercept FocusScope restoration since virtualized collections can reuse DOM nodes.

This is the reason that FocusScope can't just restore focus to the original target. Instead, it sets the manager to focused and the useGridCell then kicks in and focuses itself according to the rules for that type of item.

Comment on lines +125 to +129
// Focus counts as "disrupted" (as opposed to a fresh keyboard entry from a
// sibling row/cell) when it's on the cell itself or fell back to body/nothing,
// e.g. because an overlay closed and removed the element that had focus. In
// that case, restore the child that was last focused instead of defaulting to
// childFocusStrategy's first/last child.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't really saying anything

Suggested change
// Focus counts as "disrupted" (as opposed to a fresh keyboard entry from a
// sibling row/cell) when it's on the cell itself or fell back to body/nothing,
// e.g. because an overlay closed and removed the element that had focus. In
// that case, restore the child that was last focused instead of defaulting to
// childFocusStrategy's first/last child.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it.

// that case, restore the child that was last focused instead of defaulting to
// childFocusStrategy's first/last child.
let ownerDocument = getOwnerDocument(ref.current);
let isDisrupted =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let isDisrupted =
let shouldRestoreToLastFocused =

when is it on the cell? wouldn't that mean that Tabbing into a cell would re-focus whatever the last focused element was instead of starting with the first focusable?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed that check and renamed the variable to shouldRestoreToLastFocused, so focus is restored only when it moves to document.body

Comment on lines +343 to +350

// Remember which child was focused so that if something later forces focus
// back to this cell (e.g. a closing overlay), focus() can restore it directly
// instead of falling back to the first/last focusable child. Only do this for
// actual DOM descendants of the cell -- portalled content (e.g. a dialog opened
// from within the cell) can still reach this handler because React dispatches
// events along the component tree rather than the DOM tree for portals, even
// though it isn't really inside this cell in the DOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Remember which child was focused so that if something later forces focus
// back to this cell (e.g. a closing overlay), focus() can restore it directly
// instead of falling back to the first/last focusable child. Only do this for
// actual DOM descendants of the cell -- portalled content (e.g. a dialog opened
// from within the cell) can still reach this handler because React dispatches
// events along the component tree rather than the DOM tree for portals, even
// though it isn't really inside this cell in the DOM.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the comment line.

expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[0]);
});

it('should restore focus to the child that was last focused within a cell, not the first child', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add this test to RAC

    it('should restore focus to the associated dialog trigger inside a cell', async () => {
      let tree = render(
        <>
          <button>Before</button>
          <Table aria-label="Files" selectionMode="multiple">
            <MyTableHeader>
              <MyColumn id="name" isRowHeader>
                Name
              </MyColumn>
              <MyColumn>Type</MyColumn>
              <MyColumn>Date Modified</MyColumn>
            </MyTableHeader>
            <TableBody>
              <MyRow id="1" textValue="Games">
                <Cell>Games</Cell>
                <Cell>File folder</Cell>
                <Cell>
                  <DialogTrigger>
                    <Button>Open 1</Button>
                    <Modal>
                      <Dialog>
                        <Checkbox>
                          <Label>Agree 1</Label>
                        </Checkbox>
                      </Dialog>
                    </Modal>
                  </DialogTrigger>
                  <DialogTrigger>
                    <Button>Open 2</Button>
                    <Modal>
                      <Dialog>
                        <Checkbox>
                          <Label>Agree 2</Label>
                        </Checkbox>
                      </Dialog>
                    </Modal>
                  </DialogTrigger>
                  <DialogTrigger>
                    <Button>Open 3</Button>
                    <Modal>
                      <Dialog>
                        <Checkbox>
                          <Label>Agree 3</Label>
                        </Checkbox>
                      </Dialog>
                    </Modal>
                  </DialogTrigger>
                </Cell>
              </MyRow>
            </TableBody>
          </Table>
          <button>After</button>
        </>
      );
      await user.click(tree.getByRole('button', {name: 'Open 2'}));
      let dialog = tree.getByRole('dialog');
      expect(document.activeElement).toBe(dialog);
      await user.keyboard('{Escape}');
      act(() => jest.runAllTimers());
      expect(dialog).not.toBeInTheDocument();
      expect(document.activeElement).toBe(tree.getByRole('button', {name: 'Open 2'}));
    });

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this in RAC, and the test file passed.

@jsmitrah
jsmitrah requested a review from snowystinger August 11, 2026 06:27

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the "file mode" of packages/react-aria/test/grid/useGrid.test.js was changed, please revert that. Likely it was made executable or something

@jsmitrah

Copy link
Copy Markdown
Author

I’ve reverted the file mode

@jsmitrah
jsmitrah requested a review from snowystinger August 12, 2026 06:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Table cell incorrectly returns focus to button on modal close

2 participants