Skip to content

fix(CDropdown): prevent window listener and popper leaks on unmount#464

Merged
mrholek merged 1 commit into
mainfrom
fix/dropdown-listener-cleanup
Jun 13, 2026
Merged

fix(CDropdown): prevent window listener and popper leaks on unmount#464
mrholek merged 1 commit into
mainfrom
fix/dropdown-listener-cleanup

Conversation

@mrholek

@mrholek mrholek commented Jun 13, 2026

Copy link
Copy Markdown
Member

Problem

  1. Window listener leak on unmount. The unmount cleanup effect captured the initial _visible value in a stale closure (useEffect(() => () => { if (_visible) handleHide() }, [])), so it never ran for a dropdown that was opened after mount. Unmounting an open dropdown left click/keyup listeners on window and a live Popper instance.
  2. Listener reference mismatch. Listeners were added imperatively in handleShow and removed in handleHide. When any useCallback dependency changed while the dropdown was open (e.g. a new onHide prop or a replaced toggler element), removeEventListener received a different function reference and the original listener stayed attached forever.
  3. Spurious onHide on mount. The [visible] sync effect ran handleHide() on first render with the default visible={false}, firing the onHide callback for a dropdown that was never opened.
  4. Popper instance leak on re-init. initPopper overwrote _popper.current without destroying the previous instance, leaking its scroll/resize listeners whenever the toggler element was replaced while open.

Changes

  • Move keydown/click/keyup listener management to a declarative effect keyed on _visible (the same pattern CModal already uses). React guarantees the cleanup removes exactly the references that were added, including on unmount and when the toggler element changes mid-open.
  • Replace the broken unmount effect with one that destroys the Popper instance.
  • Guard the [visible] sync effect so handleHide only runs when the dropdown is actually open.
  • Memoize usePopper callbacks (useCallback) — they only touch refs, so stable identities are safe; this stops handleHide/context value churn on every parent render.
  • Destroy the previous Popper instance inside initPopper before creating a new one.

Tests

  • New regression test: replaces the toggler element (via key) while the dropdown is open and asserts the menu keeps position: absolute + data-popper-placement, Popper is re-created against the new toggler node, and the old instance is destroyed. Verified it fails against the previous usePopper.
  • Full suite: 127 suites / 349 tests green, no snapshot changes (markup untouched).

Note: the react-hooks/refs lint error on usePopper's popper: _popper.current return pre-exists on main and is left out of scope — fixing it changes the hook's public API.

- manage keydown/click/keyup listeners declaratively in an effect keyed on
  visibility, so cleanup always removes the exact references it added (also
  when handlers or the toggle element change while open)
- destroy the popper instance on unmount; the previous cleanup effect
  captured the initial visibility in a stale closure and never ran
- skip handleHide on mount so onHide is not called for a never-opened dropdown
- memoize usePopper callbacks and destroy the previous popper instance on
  re-init to keep stable identities and avoid leaking scroll/resize listeners
- add a regression test covering popper re-initialization when the toggler
  element is replaced while the dropdown is open
@mrholek mrholek merged commit 6cc1db7 into main Jun 13, 2026
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.

1 participant