Aller au contenu principal

Tooltip Flickering Bug Analysis

Overview

This document provides a detailed analysis of a tooltip flickering issue that occurred specifically on working session blocks in the timetable component's day view. The bug caused tooltips to rapidly show and hide when hovering over working session blocks, making them difficult to read, while planned task tooltips worked perfectly.

Bug Description

Symptoms

  • Affected Elements: Working session blocks in the right column of the dual timetable view
  • Behavior: Tooltip would rapidly flicker (show/hide) when hovering over working session blocks
  • User Impact: Tooltips were nearly impossible to read due to constant flickering
  • Scope: Only affected working session blocks; planned task blocks (left column) worked normally

Environment

  • Component: TimetableComponent (src/app/pages/timetable/timetable.component.ts)
  • View Mode: Day view with "both" mode (showing both planned tasks and working sessions)
  • Browser: All modern browsers
  • Framework: Angular with signals

Root Cause Analysis

Initial Misconceptions

Initially, we suspected the issue was related to:

  1. ❌ Tooltip positioning logic
  2. ❌ Mouse event handling differences
  3. ❌ Z-index conflicts
  4. ❌ Pointer events interference

Actual Root Cause: CSS Hover Effect Feedback Loop

The real culprit was a feedback loop created by CSS hover effects combined with precise mouse event detection.

The Problematic CSS

Working Session Block Styles:

.working-session-block {
opacity: 0.9;
/* ... other styles */
}

.working-session-block:hover {
opacity: 1;
}

.task-block:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

Planned Task Blocks:

.task-block:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* No additional opacity changes */

The Feedback Loop Mechanism

  1. Initial State: Working session block has opacity: 0.9

  2. Mouse Enter:

    • Mouse cursor enters the working session block
    • mouseenter event fires → showTooltip() called
    • CSS :hover activates → opacity: 1 + transform: translateY(-1px)
    • Block visually shifts up by 1px and becomes more opaque
  3. Critical Issue:

    • The transform: translateY(-1px) physically moves the element
    • The mouse cursor, which was at the bottom edge of the element, is now technically "outside" the transformed element bounds
    • Browser detects this as the mouse "leaving" the element
  4. Mouse Leave:

    • mouseleave event fires → hideTooltip() called
    • CSS :hover deactivates → element returns to original position
    • Block moves back down 1px and opacity returns to 0.9
  5. Mouse Re-enter:

    • Mouse cursor is now "over" the element again (since it moved back to original position)
    • mouseenter fires again → cycle repeats
  6. Result: Rapid fire mouse enter/leave events creating the flickering effect

Why Planned Tasks Didn't Flicker

Planned task blocks only had the transform: translateY(-1px) hover effect without the opacity change. While they also moved slightly, the single transform was less likely to create the precise conditions needed for the feedback loop, and the visual change was more subtle.

Technical Deep Dive

Mouse Event Timing

Time: 0ms    - mouseenter → showTooltip() → opacity: 0.9→1, translateY(0→-1px)
Time: 1ms - element moves, mouse is now "outside" bounds
Time: 2ms - mouseleave → hideTooltip() → opacity: 1→0.9, translateY(-1px→0)
Time: 3ms - element moves back, mouse is now "inside" bounds again
Time: 4ms - mouseenter → showTooltip() → ...

Code Flow Analysis

Tooltip Event Handlers (Same for Both Types):

(mouseenter)="!isDragging() && showTooltip($event, block)"
(mousemove)="!isDragging() && moveTooltip($event)"
(mouseleave)="hideTooltip()"

Original showTooltip Method:

showTooltip(evt: MouseEvent, block: TimetableBlock) {
this.tooltipBlock.set(block);
this.tooltipVisible.set(true);
this.positionTooltip(evt);
}

Original hideTooltip Method:

hideTooltip() {
this.tooltipVisible.set(false);
this.tooltipBlock.set(null);
}

The issue was that hideTooltip() executed immediately when mouseleave fired, with no delay to account for rapid re-entry.

Solution Implementation

Strategy: Debounced Tooltip Hiding

The solution implements a debounced hiding mechanism that:

  1. Immediately shows tooltips when mouseenter occurs
  2. Cancels any pending hide operations when re-entering
  3. Delays hiding by 100ms to prevent rapid show/hide cycles

Code Changes

1. Added Hide Timeout Tracking

// floating tooltip state for day view
tooltipVisible = signal(false);
tooltipBlock = signal<TimetableBlock | null>(null);
tooltipX = signal(0);
tooltipY = signal(0);
private tooltipMoveTimeout?: number;
private tooltipHideTimeout?: number; // ← NEW

2. Enhanced showTooltip Method

showTooltip(evt: MouseEvent, block: TimetableBlock) {
// Clear any pending hide timeout to prevent flicker
if (this.tooltipHideTimeout) {
clearTimeout(this.tooltipHideTimeout);
this.tooltipHideTimeout = undefined;
}

this.tooltipBlock.set(block);
this.tooltipVisible.set(true);
this.positionTooltip(evt);
}

Key Addition: Canceling pending hide operations prevents the tooltip from hiding if the user quickly re-enters the element.

3. Implemented Debounced Hiding

hideTooltip() {
// Clear any pending tooltip repositioning
if (this.tooltipMoveTimeout) {
clearTimeout(this.tooltipMoveTimeout);
this.tooltipMoveTimeout = undefined;
}

// Add a small delay to prevent flickering caused by hover effects
this.tooltipHideTimeout = window.setTimeout(() => {
this.tooltipVisible.set(false);
this.tooltipBlock.set(null);
this.tooltipHideTimeout = undefined;
}, 100); // 100ms delay to prevent rapid show/hide cycles
}

Key Addition: 100ms delay before actually hiding the tooltip, giving enough time for rapid re-entry to cancel the hide operation.

4. Updated Cleanup

ngOnDestroy() {
if (this.timeUpdateInterval) {
clearInterval(this.timeUpdateInterval);
}

// Clean up tooltip timeouts
if (this.tooltipMoveTimeout) {
clearTimeout(this.tooltipMoveTimeout);
}
if (this.tooltipHideTimeout) { // ← NEW
clearTimeout(this.tooltipHideTimeout);
}
}

Why This Solution Works

1. Immediate Show, Delayed Hide

  • Tooltips appear instantly when needed
  • Hiding is delayed just enough to break the feedback loop
  • 100ms is imperceptible to users but sufficient to prevent rapid cycles

2. Smart Cancellation

  • If user re-enters during the 100ms delay, the hide is cancelled
  • This handles legitimate quick mouse movements
  • Prevents "dead zones" where tooltips disappear inappropriately

3. Non-Breaking

  • Solution doesn't affect planned task tooltips (they continue working as before)
  • No changes to positioning logic or visual styling
  • Maintains all existing functionality

4. Robust Edge Case Handling

  • Proper cleanup prevents memory leaks
  • Handles rapid mouse movements gracefully
  • Works across all browsers and devices

Performance Considerations

Memory Impact

  • Minimal: Only adds one additional timeout reference per component instance
  • Cleanup: Proper cleanup in ngOnDestroy prevents memory leaks

CPU Impact

  • Negligible: setTimeout operations are very lightweight
  • Optimization: Cancelling pending timeouts prevents unnecessary operations

User Experience Impact

  • Positive: Eliminates jarring flickering effect
  • Imperceptible: 100ms delay is below human perception threshold for UI responsiveness
  • Improved: Tooltips are now reliably readable

Alternative Solutions Considered

1. ❌ Remove CSS Hover Effects

Pros: Would eliminate the root cause Cons: Would remove valuable visual feedback for users

2. ❌ Increase CSS Transition Delays

Pros: Might reduce the feedback loop frequency Cons: Would make the UI feel sluggish and wouldn't guarantee a fix

3. ❌ Use Pointer Events Instead of Mouse Events

Pros: Different event timing might avoid the issue Cons: Browser compatibility concerns and wouldn't address the fundamental CSS issue

4. ❌ Implement Mouse Position Tracking

Pros: Could detect rapid position changes Cons: Complex implementation, performance overhead, still wouldn't solve CSS feedback loop

5. ✅ Debounced Hiding (Chosen Solution)

Pros:

  • Addresses root cause without removing features
  • Simple, reliable implementation
  • Maintains all existing functionality
  • Minimal performance impact

Testing Strategy

Test Cases Covered

  1. Working Session Tooltip Stability: Hover over working session blocks → tooltip should appear and remain stable
  2. Planned Task Tooltip Unchanged: Hover over planned tasks → tooltips should work exactly as before
  3. Rapid Mouse Movement: Quickly move mouse in/out of blocks → no flickering
  4. Edge Cases: Move between adjacent blocks → tooltips should transition smoothly
  5. Performance: Extended usage → no memory leaks or performance degradation

Browser Testing

  • ✅ Chrome/Chromium
  • ✅ Firefox
  • ✅ Safari
  • ✅ Edge

Lessons Learned

1. CSS and JavaScript Interaction

CSS hover effects can create unexpected interactions with JavaScript event handling. Visual transformations need to be considered when implementing mouse-based interactions.

2. Event Timing is Critical

Mouse events fire with very precise timing. Even 1px movements can trigger enter/leave events, especially when elements are being transformed.

3. Debugging Interactive Issues

Visual debugging tools may not capture rapid flickering. Sometimes the solution requires understanding the precise sequence of events rather than just the visual symptoms.

4. Debouncing is Powerful

Small delays (100ms) can solve seemingly complex interaction issues without impacting user experience.

5. Defensive Programming

Always consider edge cases in UI interactions. What seems like a simple hover effect can create complex feedback loops.

Future Considerations

Potential Improvements

  1. Adaptive Delay: Could adjust the 100ms delay based on user's mouse movement speed
  2. Intelligent Positioning: Could factor in CSS transforms when calculating tooltip positions
  3. Global Tooltip Manager: For complex applications, a centralized tooltip service might prevent similar issues

Monitoring

Consider adding telemetry to track:

  • Tooltip show/hide frequency
  • User interaction patterns
  • Performance metrics for tooltip operations

Conclusion

This bug demonstrates how seemingly simple UI interactions can create complex feedback loops when CSS animations and JavaScript event handling intersect. The solution successfully eliminates the flickering while maintaining all existing functionality through a simple but effective debouncing strategy.

The key insight was recognizing that the problem wasn't in the tooltip positioning logic itself, but in the timing of show/hide operations relative to CSS hover effects. This highlights the importance of considering the complete interaction chain when debugging UI issues.