fix(Tooltip, FocusTrap, Select): fix show/hide race, focus stealing on trap activation, and phantom onOptionsChange call - #1169
Merged
Conversation
|
This pull request is automatically built and testable in CodeSandbox. To see build info of the built libraries, click here or the icon next to each commit SHA. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1169 +/- ##
==========================================
+ Coverage 85.41% 85.44% +0.02%
==========================================
Files 1231 1231
Lines 21727 21775 +48
Branches 8268 8293 +25
==========================================
+ Hits 18559 18606 +47
- Misses 3080 3081 +1
Partials 88 88 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ayadav-eightfold
left a comment
Contributor
Author
There was a problem hiding this comment.
Three residual issues from the ref/focus rework; details inline.
…review - Tooltip: restructure onMouseEnter/onFocus to check intendedVisibleRef inside the handler (like onBlur) instead of at render time, so an already-open controlled tooltip doesn't fire a duplicate onVisibleChange(true) on hover/focus. - Select: keep aria-selected in sync with the computed `selected` value at every point options are (re)built, including the click-time toggle handler -- it was still reading the raw, often-stale option.selected. - useFocusTrap: clear the restore-focus target when the trap activates with focus already inside, and only register the effect's cleanup for an instance that actually activated the trap -- otherwise a deactivate -> reactivate-from-inside -> deactivate sequence could yank focus back to a stale target from an earlier activation. Added a regression test for this sequence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
agupta-eightfold
approved these changes
Sep 22, 2026
Follow-up to 561efd2 per re-review: - The [_options] and [isLoading] effects, and the initial useState, still computed `selected`/`aria-selected` before the `...option` spread, so a raw option.selected field would override `selected` but not `aria-selected`, reintroducing the exact desync being fixed. Moved both keys after the spread (matching the pattern the mount case already used for `selected`), and folded `option.selected` into the computed value so a freshly-supplied selection on an incoming `_options` update isn't silently dropped. - Added the same aria-selected mirroring to three sibling spots that set `selected` without it: the [defaultValue] effect, and both clearing branches in onInputClear/onInputChange. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g a revert Per agupta-eightfold's review: - useFocusTrap.test.tsx: "does not steal focus when the trap activates via focus already inside it" asserted immediately via `waitFor`, which resolves on its first sync poll -- before the buggy version's FOCUS_DELAY_INTERVAL-based steal would even fire. Added a second assertion after waiting past that interval, so a revert of the early-return in setUpFocus now actually fails the test (verified by reverting it locally). - Tooltip.test.tsx: the unmount cleanup that clears a pending `toggle` timeout had no test exercising unmount-while-pending, so deleting it wouldn't have failed anything. Added a test that triggers a pending toggle via hover, unmounts, and asserts onVisibleChange never fires (verified by removing the cleanup locally). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SUMMARY:
Three independent bug fixes found while investigating a component stuck in an incorrect visual state under fast, back-to-back interactions:
1.
Tooltip: fixed a race that could leave a hover/focus-triggered tooltip permanently visibletoggle()schedules both showing and hiding through a single delayed timer (to allow the fade animation to play before content is removed). If a blur/mouseleave arrives before a pending show commits, the oldonBlur/onMouseLeavehandlers were gated on the not-yet-updatedmergedVisiblestate, so the close handler never even attached — the blur event was silently dropped, and the pending show committed a moment later with nothing left to reverse it, leaving the tooltip stuck open indefinitely.Fix:
intendedVisibleRef, tracking the requested visible state synchronously the instanttoggle()runs, instead of relying on the delayedmergedVisible.timeoutfrom a plain per-render local variable to auseRef, so a close handler firing on a later render can actually cancel a timer scheduled by an earlier render's closure (a per-render variable can never reach back and do that).onBlurinto an always-attached handler that reads the ref at invocation time, rather than a render-time ternary — necessary because a same-valuesetHidingcall (e.g. the very first show) causes React to skip re-rendering, which would otherwise leave a stale handler attached regardless of the ref's value.2.
FocusTrap: fixeduseFocusTrapstealing focus from an already-correctly-focused element on activationOn activation,
setUpFocusunconditionally force-focusedfirstFocusableSelector(or the first focusable element), regardless of where focus currently was. This breaks the commononFocus={() => setTrapActive(true)}activation pattern: the trap turns on because something inside it was just explicitly focused, but the activation effect then ignored that and redirected focus elsewhere.Fix: skip the force-focus step when
document.activeElementis already inside the trap's container. The existing "activate with focus outside the container" behavior (the common case) is unaffected.3.
Select: fixedonOptionsChangefiring once with an empty selection before firing again correctlyThe internal
optionsstate was initialized with every option'sselectedhardcoded tofalse, regardless ofdefaultValue— the correct selection was only applied later via a[defaultValue]-keyed effect. Since the effect that callsonOptionsChangeruns on every render where the selection changes (including the first), consumers saw one call with an empty selection immediately on mount, followed by a second, corrective call once thedefaultValueeffect caught up.Fix: compute
selectedfromdefaultValuedirectly in theoptionsinitializer (mirroring the existing[defaultValue]effect's own logic), and likewise compute the initial displayed text from that already-correctoptionsstate.onOptionsChangenow fires once, correctly, from the first render.GITHUB ISSUE (Open Source Contributors)
JIRA TASK (Eightfold Employees Only):
https://eightfoldai.atlassian.net/browse/ENG-207605
CHANGE TYPE:
TEST COVERAGE:
TEST PLAN:
Tooltip.test.tsx: blur arriving before a pending show commits no longer leaves the tooltip stuck visible.useFocusTrap.test.tsx: activating the trap via an explicit focus already inside it no longer redirects focus to the first focusable element.Select.test.tsx:onOptionsChangefires exactly once on mount with the correctdefaultValueselection, not[], []first. Also removed an existing assertion that had encoded the old (buggy) two-call sequence as expected behavior.tsc --noEmitclean.