Skip to content

Component | Tooltip: Don't hide or move externally controlled tooltips from delegated handlers - #898

Draft
rokotyan wants to merge 2 commits into
f5:mainfrom
rokotyan:fix/tooltip-external-control
Draft

rokotyan wants to merge 2 commits into
f5:mainfrom
rokotyan:fix/tooltip-external-control

Conversation

@rokotyan

@rokotyan rokotyan commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Crosshair's forceShowAt is broken: the pinned tooltip disappears as soon as the mouse moves anywhere on the page (repro: the Force Show At Position example in packages/dev).

The regression was introduced in #859. That PR added the tooltip container to the list of elements the Tooltip's delegated mousemove handler is attached to (to fix follow-cursor freezing over trigger gaps). But that handler has a "no trigger matched → hide()" fallback, and when the tooltip container is document.body, it now runs on every mouse movement on the page:

  • Crosshair shows its tooltip imperatively via tooltip.show() and never registers any triggers, so no trigger ever matches.
  • While the pointer is over the chart SVG, Crosshair's own mousemove handler re-shows the tooltip in the same frame, so the conflict stays invisible (as it always has — this hide/re-show race predates Component | Tooltip: Fix fade-in animation and follow-cursor freeze over trigger gaps #859).
  • Once the pointer moves outside the SVG, Crosshair never gets a chance to re-show, and the container-level handler hides the pinned tooltip for good.

The new follow-cursor fallback from #859 also calls place() with the raw cursor position on non-trigger mousemoves, yanking a forceShowAt-pinned tooltip toward the pointer (Crosshair force-sets followCursor: true).

The underlying gap: the delegated handler assumes it exclusively owns show/hide based on triggers, with no notion that a component like Crosshair is managing the tooltip imperatively.

Fix

Tooltip — make ownership explicit. show() (the imperative API — Crosshair is its only caller in the codebase) marks the tooltip as externally controlled; hide() releases it. While the flag is set, the delegated mousemove/mouseleave handlers neither hide the tooltip nor re-place it — the owning component decides.

No Crosshair changes needed for this part: it already calls tooltip.hide() whenever the crosshair should disappear (out of range, mouseout, wheel), which releases the flag. Trigger-driven tooltips never call show(), so their behavior is untouched. As a side benefit, mouse-driven crosshair tooltips no longer get hidden and re-shown by the delegated handler on every mousemove.

Crosshair — a pre-existing flicker in the same scenario: with forceShowAt set, shouldShow still required the pointer to be within the Y range, so moving the mouse over the axis area (inside the SVG but outside the plot) hid the pinned crosshair and tooltip, and leaving the SVG re-showed them. A pinned crosshair's visibility now depends only on the forced position being within the X range, not on where the pointer is.

Verified

  • Force Show At Position dev example: tooltip stays pinned while the mouse moves anywhere on the page, including over the axis area and across the container edge (no flicker).
  • Simple Crosshair dev example (regression check): tooltip still follows the pointer over the chart and hides when the pointer leaves the SVG (via Crosshair's own mouseouthide() path).
  • Trigger-based tooltips unaffected (show() is never called on that path).

🤖 Generated with Claude Code

…s from delegated handlers

The delegated mousemove handler hides the tooltip whenever no trigger
matches, and since f5#859 it is also attached to the tooltip container.
When the container is document.body, any mouse movement on the page
hides a tooltip that Crosshair pinned via `forceShowAt`, and the
follow-cursor fallback drags it to the pointer.

Track ownership instead: `show()` marks the tooltip as externally
controlled and `hide()` releases it, so the delegated handlers leave
imperatively shown tooltips alone.

Co-Authored-By: Claude Fable 5 <[email protected]>
@rokotyan

rokotyan commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

This PR fixes the issue but there's some flickering that I don't like. I'll see if we can fix it

Screen.Recording.2026-08-27.at.5.08.25.PM.mov

Update: Fixed in the second commit

…ointer position

With `forceShowAt` set, `shouldShow` still required the pointer to be
within the Y range, so moving the mouse over the axis area (inside the
SVG but outside the plot) hid the pinned crosshair and tooltip, and
leaving the SVG re-showed them — visible as flickering. A pinned
crosshair's visibility should only depend on the forced position being
within the X range.

Co-Authored-By: Claude Fable 5 <[email protected]>
@rokotyan
rokotyan force-pushed the fix/tooltip-external-control branch 2 times, most recently from 44326a9 to 5353b85 Compare August 28, 2026 00:17
@lee00678

lee00678 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

I still see the flickering issue

Screen.Recording.2026-09-01.at.12.28.13.PM.mov


/** Hides the tooltip after `hideDelay` */
public hide (): void {
this._isControlledExternally = false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This clears _isControlledExternally immediately, even though the actual _hide() DOM update can be deferred by config.hideDelay. If a user passed a Tooltip with hideDelay set into config.tooltip for Crosshair (default is undefined, so this doesn't affect the default/dev-example case), a mousemove during that delay window could very briefly re-trigger the followCursor fallback before the fade-out completes.

@Vincentdevreede

Copy link
Copy Markdown
Contributor

@lee00678 @rokotyan Sorry for the late response, my second son was born two weeks ago, so things have been hectic 🙂 I think I've got the fix: #923. Details in the PR, would appreciate a look whenever you have time.

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.

3 participants