fix: #8686 prevent useGridCell from overriding child focus restoration loops - #10228
fix: #8686 prevent useGridCell from overriding child focus restoration loops#10228jsmitrah wants to merge 23 commits into
Conversation
snowystinger
left a comment
There was a problem hiding this comment.
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
0a7cf3b to
714723c
Compare
|
Thanks @snowystinger, all tests are passed |
ccb8bbd to
4b28c4a
Compare
71950d3 to
22b6e5f
Compare
| return; | ||
| } | ||
|
|
||
| // If the cell itself is focused, wait a frame so that focus finishes propagatating |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I don't see any update with relatedTarget?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
b06221e to
adbba17
Compare
6ab9b4c to
bce1556
Compare
| // 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. |
There was a problem hiding this comment.
"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 |
There was a problem hiding this comment.
I don't see any update with relatedTarget?
535198e to
244fa42
Compare
244fa42 to
24667bf
Compare
| return; | ||
| } | ||
|
|
||
| // If the cell itself is focused, wait a frame so that focus finishes propagatating |
There was a problem hiding this comment.
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.
| // 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]); |
There was a problem hiding this comment.
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})); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
from the above code changes, why is this still needed?
ac51242 to
217b6bb
Compare
3507110 to
cf9fb80
Compare
…tton' of https://github.com/jsmitrah/react-spectrum into bug-fix/8686/table-cell-incorrectly-returns-focus-to-button
|
@snowystinger, I tested these changes in the browser across several real-world scenarios, including:
I also verified that the changes work correctly for components rendered through portals as well as elements outside the document body. All of these scenarios passed successfully, and I did not encounter any regressions during testing. After completing the browser testing, I also used AI as an additional verification step to help identify whether these changes might impact any other scenarios that I hadn't explicitly tested. This was only used as a secondary check—the browser testing and real-world validation were the primary basis for the changes. Since the changes behaved as expected across all tested scenarios, I proceeded with committing them. I've also attached a video demonstrating the tested scenarios for reference. button-interaction-validation.mp4 |
|
|
||
| let renderTable = props => render(<TestTable {...props} />); | ||
|
|
||
| if (typeof Symbol.dispose === 'undefined') { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| // 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. |
There was a problem hiding this comment.
I'm not sure what the portalled comment is about? the line below nodeContains(ref.current, target) is a guard for portals
There was a problem hiding this comment.
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(() => { |
There was a problem hiding this comment.
I still don't see an explanation for this change
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Closes #8686
✅ Pull Request Checklist:
📝 Test Instructions:
🧢 Your Project: