Skip to content

fix(Tooltip, FocusTrap, Select): fix show/hide race, focus stealing on trap activation, and phantom onOptionsChange call - #1169

Merged
ayadav-eightfold merged 9 commits into
mainfrom
react18_compatibility_changes
Sep 22, 2026
Merged

ayadav-eightfold merged 9 commits into
mainfrom
react18_compatibility_changes

Conversation

@ayadav-eightfold

@ayadav-eightfold ayadav-eightfold commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

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 visible

toggle() 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 old onBlur/onMouseLeave handlers were gated on the not-yet-updated mergedVisible state, 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:

  • Added intendedVisibleRef, tracking the requested visible state synchronously the instant toggle() runs, instead of relying on the delayed mergedVisible.
  • Converted the internal timeout from a plain per-render local variable to a useRef, 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).
  • Restructured onBlur into an always-attached handler that reads the ref at invocation time, rather than a render-time ternary — necessary because a same-value setHiding call (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: fixed useFocusTrap stealing focus from an already-correctly-focused element on activation

On activation, setUpFocus unconditionally force-focused firstFocusableSelector (or the first focusable element), regardless of where focus currently was. This breaks the common onFocus={() => 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.activeElement is already inside the trap's container. The existing "activate with focus outside the container" behavior (the common case) is unaffected.

3. Select: fixed onOptionsChange firing once with an empty selection before firing again correctly

The internal options state was initialized with every option's selected hardcoded to false, regardless of defaultValue — the correct selection was only applied later via a [defaultValue]-keyed effect. Since the effect that calls onOptionsChange runs 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 the defaultValue effect caught up.

Fix: compute selected from defaultValue directly in the options initializer (mirroring the existing [defaultValue] effect's own logic), and likewise compute the initial displayed text from that already-correct options state. onOptionsChange now 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:

  • Bugfix Pull Request

TEST COVERAGE:

  • I have added unittests for this change

TEST PLAN:

  • Added regression tests for all three fixes:
    • 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: onOptionsChange fires exactly once on mount with the correct defaultValue selection, not [], [] first. Also removed an existing assertion that had encoded the old (buggy) two-call sequence as expected behavior.
  • Full existing suite verified green after each change: 230 test suites / 2703 tests / 441 snapshots, 0 failures.
  • tsc --noEmit clean.

@codesandbox-ci

codesandbox-ci Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

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

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.06173% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.44%. Comparing base (74512bf) to head (8b5d015).

Files with missing lines Patch % Lines
src/components/Tooltip/Tooltip.tsx 89.74% 4 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ayadav-eightfold ayadav-eightfold changed the title make changes to make octuple react 18 compatibility fix(Tooltip, FocusTrap, Select): fix show/hide race, focus stealing on trap activation, and phantom onOptionsChange call Sep 22, 2026
Comment thread src/components/Tooltip/Tooltip.tsx
Comment thread src/components/Select/Select.tsx Outdated
Comment thread src/shared/FocusTrap/hooks/useFocusTrap.ts Outdated
Comment thread src/components/Tooltip/Tooltip.tsx
Comment thread src/components/Tooltip/Tooltip.tsx
Comment thread src/shared/FocusTrap/hooks/useFocusTrap.ts Outdated

@ayadav-eightfold ayadav-eightfold left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Three residual issues from the ref/focus rework; details inline.

Comment thread src/components/Tooltip/Tooltip.tsx Outdated
Comment thread src/shared/FocusTrap/hooks/useFocusTrap.ts
Comment thread src/components/Select/Select.tsx Outdated
…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>
Comment thread src/components/Select/Select.tsx Outdated
Comment thread src/shared/FocusTrap/hooks/useFocusTrap.test.tsx
Comment thread src/components/Tooltip/Tooltip.tsx
Akanksha Yadav and others added 3 commits September 22, 2026 15:11
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>
@ayadav-eightfold
ayadav-eightfold merged commit fa16c4b into main Sep 22, 2026
9 checks passed
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.

2 participants