Skip to content

bug: ActionMenu tooltip reappears after modal close (React 18 timing issue) #30

@takaokouji

Description

@takaokouji

Problem

After opening a modal from the ActionMenu (sprite selector) and closing it, the tooltip "Choose a Sprite" reappears when it should remain hidden.

Environment:

  • Affected: packages/scratch-gui (React 18)
  • Working: gui/smalruby3-gui (React 16)
  • Component: packages/scratch-gui/src/components/action-menu/action-menu.jsx

Steps to Reproduce

  1. Start dev server and navigate to sprite selector
  2. Hover mouse over ActionMenu button → tooltip "Choose a Sprite" appears
  3. Move mouse up to expand action menu
  4. Click any action item (e.g., "Surprise", "Paint", "Choose a Sprite") to open modal
  5. Close the modal
  6. Bug: Tooltip "Choose a Sprite" reappears (should stay hidden)

Investigation Results

Initial Hypothesis (Partially Correct)

Race condition between React 18 event handling and multiple setTimeout operations:

  • Zero-delay setTimeout resets forceHide: false immediately
  • Phantom onMouseLeave events during modal display trigger 300ms timeout
  • React 18's root-level event attachment causes different timing than React 16

Actual Root Causes (Discovered Through Testing)

  1. File input buttons bypass clickDelayer (line 186):

    onClick={hasFileInput ? handleClick : this.clickDelayer(handleClick)}
    • "Upload Sprite" button has fileInput, so it bypassed tooltip hiding logic
    • Only "Upload Sprite" worked correctly (file dialog steals focus)
    • Other buttons ("Surprise", "Paint", "Choose a Sprite") showed the bug
  2. Automatic forceHide reset after 300ms:

    setTimeout(() => this.setState({forceHide: false}), CLOSE_DELAY);
    • forceHide: false resets while modal is still opening/closing
    • If mouse is still over ActionMenu area, ReactTooltip re-activates
    • 300ms is too short - modal takes longer to open and capture focus
  3. ReactTooltip Portal rendering:

    • ReactTooltip renders in Portal outside ActionMenu DOM
    • CSS .force-hidden .tooltip doesn't affect Portal-rendered tooltips
    • Need to control ReactTooltip state programmatically

Why It Works in React 16 but Not React 18

Aspect React 16 React 18
Event attachment Document level React root level
Event timing After React synthetic events May fire before synthetic events
Mouse event behavior Predictable sequence Can trigger during reconciliation
Tooltip management Natural timing works Requires explicit control

Final Solution

1. Ensure All Buttons Use clickDelayer (line 206)

Before:

onClick={hasFileInput ? handleClick : this.clickDelayer(handleClick)}

After:

onClick={this.clickDelayer(handleClick)}

All sub-menu buttons (including file input) now go through clickDelayer.

2. Add ignoreMouseLeave Flag (line 29)

this.ignoreMouseLeave = false;

Prevents phantom onMouseLeave events during modal display from scheduling close timeout.

3. Ignore Mouse Leave During Click (lines 55-61)

handleClosePopover () {
    if (this.ignoreMouseLeave) {
        return; // Ignore phantom events
    }
    this.closeTimeoutId = setTimeout(() => {
        ReactTooltip.hide();
        this.setState({isOpen: false});
        this.closeTimeoutId = null;
    }, CLOSE_DELAY);
}

4. Set Flag in clickDelayer (lines 99, 115)

this.ignoreMouseLeave = true;

Prevents subsequent handleClosePopover calls from scheduling timeouts.

5. Remove Automatic forceHide Reset (lines 122-124)

Before:

this.setState({forceHide: true, isOpen: false}, () => {
    setTimeout(() => this.setState({forceHide: false}), CLOSE_DELAY);
});

After:

this.setState({forceHide: true, isOpen: false});
// Don't reset forceHide automatically - wait for user to move mouse away and back

forceHide now stays true until user explicitly hovers again.

6. Reset Flags on Re-Hover (line 82)

handleToggleOpenState () {
    if (!this.state.isOpen) {
        this.ignoreMouseLeave = false;
        this.setState({
            isOpen: true,
            forceHide: false
        });
    }
}

When user moves mouse away and hovers again, flags are reset and tooltip can show normally.

Implementation Details

Modified Files

  • packages/scratch-gui/src/components/action-menu/action-menu.jsx

Key Changes

  1. Added ignoreMouseLeave flag to prevent phantom mouse leave events
  2. All buttons use clickDelayer (removed file input bypass)
  3. Removed automatic forceHide reset (manual reset on re-hover only)
  4. Added flag reset in handleToggleOpenState and componentWillUnmount
  5. Added debug logging (to be removed before final commit)

Behavior After Fix

Expected sequence:

1. User hovers → tooltip appears
2. User clicks sub-menu item
3. clickDelayer sets: ignoreMouseLeave=true, forceHide=true
4. ReactTooltip.hide() called
5. Modal opens (DOM changes trigger phantom onMouseLeave)
6. handleClosePopover called but IGNORES (ignoreMouseLeave=true)
7. Modal closes
8. Tooltip stays hidden ✓
9. User moves mouse away and hovers again
10. handleToggleOpenState resets flags
11. Tooltip works normally again ✓

Verification Steps

After fix:

  1. Start dev server: docker compose up app
  2. Hover ActionMenu → tooltip appears ✓
  3. Expand menu → menu shows ✓
  4. Click each sub-menu item:
    • Upload Sprite → modal opens → close → tooltip stays hidden
    • Surprise → modal opens → close → tooltip stays hidden
    • Paint → modal opens → close → tooltip stays hidden
    • Choose a Sprite → modal opens → close → tooltip stays hidden
  5. Move mouse away and hover again → tooltip works normally ✓

Related Fix

Commit 7d2a8c7 fixed similar MenuBar issue using setTimeout(..., 0) pattern. This ActionMenu fix follows similar React 18 timing principles but with different implementation due to tooltip-specific requirements.

Status

FIXED - All sub-menu buttons now correctly hide tooltip after modal closes. Tooltip only reappears when user explicitly re-hovers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions