Bug: Notification popup X-button click does not always dismiss the bubble
Summary
Clicking the dismiss ("X") button on a notification popup does not consistently
remove the bubble from screen. The bubble can stay visible indefinitely — both
on click and (in the worst case) on timeout — until another popup replaces it
or the user manually closes the shell session.
The same race also affects:
- Action button clicks (modelData.invoke() runs, but the popup stays)
- Card body clicks that fall through to the
else branch (no actions)
Reproduction
Setup
- DMS installed via standard method
- Default notification popup position (top-right or center)
- Any notification sender (e.g.
notify-send "test" "hello")
Steps
- Receive a notification popup. Do not move the mouse (hovering the popup
pauses the dismiss timer — that path is already fixed upstream).
- Click the X (close) button in the top-right of the popup.
- Observed: in some cases the bubble stays on screen and never animates
out. Pressing X again does nothing.
- Expected: the popup runs the exit animation (
exitAnim.restart()) and
disappears immediately.
The likelihood of triggering the bug increases when the click happens within
the first ~200 ms of the popup appearing — i.e. while the 160 ms enterDelay
Timer is still armed. It can also happen when the user clicks during a
context-menu open/close cycle (which restarts the dismiss Timer) or during
the _sync() reordering performed by NotificationPopupManager.
Root cause
NotificationPopup.qml's closeButton onClicked (and two adjacent handlers)
set notificationData.popup = false directly and rely on wrapperConn's
onPopupChanged signal to call startExit(). There are at least four
documented paths where this signal chain is interrupted:
-
Race with enterDelay (160 ms timer that arms notificationData.timer).
If the user clicks X before enterDelay fires, popup = false is set,
the service removes the wrapper from visibleNotifications, but
enterDelay then fires notificationData.timer.start() on the now-orphan
wrapper. That timer is unbound from any UI state and may flip popup
later, re-entering a desync state.
-
Race with the dismiss Timer itself. When notificationData.popup = false is set, the existing dismiss Timer keeps running. If it fires after
the click (e.g. because its interval is bound to a state that flips
during _sync()), it can set popup = false redundantly. The handler
guards on !win.exiting, so the second invocation is silent — but the
exit animation may never start.
-
Stale wrapperConn.target. Line 293 sets
wrapperConn.target = win.notificationData || null imperatively in
onNotificationDataChanged. If the notification wrapper is swapped
(e.g. by _sync() in NotificationPopupManager) just as the user clicks,
wrapperConn.onPopupChanged may not fire at all.
-
win._isDestroying or win.exiting stuck true. wrapperConn is
gated by enabled: !win._isDestroying. If a prior teardown path
set these flags without resetting them, subsequent clicks are silently
dropped.
Upstream master has already added notificationData.timer.stop() in three
places to mitigate similar timer races (hover at line 733, contextMenu at
line 1389, and Component.onDestruction at line 307). The close-button
handler was missed.
Suggested fix
Introduce a single helper dismissPopupReliably() in NotificationPopup.qml
that:
- Returns early if
exiting or _isDestroying is set.
- Calls
notificationData.timer.stop() to disarm any pending Timer.
- Sets
notificationData.popup = false to drive the normal signal chain.
- Uses
Qt.callLater(() => startExit()) as a belt-and-suspenders fallback
for cases where the binding chain does not fire (covers the four races
above).
Apply the helper to the three vulnerable handlers:
closeButton.onClicked
- The action button
onClicked (after modelData.invoke())
- The cardClick
LeftButton else branch
This mirrors the upstream pattern of stopping the timer before mutating
popup state (hover / contextMenu / destruction paths), and keeps the public
behavior identical — clicks still dismiss the popup, just reliably.
Environment
Reproduced on:
- DMS CLI: v1.4.6 (
/usr/bin/dms)
- DMS Shell: v1.5-beta (
~/.config/quickshell/dms/)
- Quickshell: 0.3.0 (Debian package)
- Compositor: Niri (
niri --session)
- Distribution: Debian GNU/Linux forky/sid, kernel 6.19.14+deb14-amd64, amd64
- Wayland display:
wayland-1
The bug is reproducible across compositors — the relevant signal handlers
(wrapperConn, enterDelay, dismiss Timer, _sync() reordering) live in
NotificationPopup.qml and NotificationService.qml, neither of which is
compositor-specific. If maintainers need to test on Hyprland / Sway to
confirm, those should reproduce identically because the close-button code
path is independent of compositor IPC.
Related
Bug: Notification popup X-button click does not always dismiss the bubble
Summary
Clicking the dismiss ("X") button on a notification popup does not consistently
remove the bubble from screen. The bubble can stay visible indefinitely — both
on click and (in the worst case) on timeout — until another popup replaces it
or the user manually closes the shell session.
The same race also affects:
elsebranch (no actions)Reproduction
Setup
notify-send "test" "hello")Steps
pauses the dismiss timer — that path is already fixed upstream).
out. Pressing X again does nothing.
exitAnim.restart()) anddisappears immediately.
The likelihood of triggering the bug increases when the click happens within
the first ~200 ms of the popup appearing — i.e. while the 160 ms
enterDelayTimer is still armed. It can also happen when the user clicks during a
context-menu open/close cycle (which restarts the dismiss Timer) or during
the
_sync()reordering performed byNotificationPopupManager.Root cause
NotificationPopup.qml's closeButtononClicked(and two adjacent handlers)set
notificationData.popup = falsedirectly and rely onwrapperConn'sonPopupChangedsignal to callstartExit(). There are at least fourdocumented paths where this signal chain is interrupted:
Race with
enterDelay(160 ms timer that armsnotificationData.timer).If the user clicks X before
enterDelayfires,popup = falseis set,the service removes the wrapper from
visibleNotifications, butenterDelaythen firesnotificationData.timer.start()on the now-orphanwrapper. That timer is unbound from any UI state and may flip
popuplater, re-entering a desync state.
Race with the dismiss Timer itself. When
notificationData.popup = falseis set, the existing dismiss Timer keeps running. If it fires afterthe click (e.g. because its
intervalis bound to a state that flipsduring
_sync()), it can setpopup = falseredundantly. The handlerguards on
!win.exiting, so the second invocation is silent — but theexit animation may never start.
Stale
wrapperConn.target. Line 293 setswrapperConn.target = win.notificationData || nullimperatively inonNotificationDataChanged. If the notification wrapper is swapped(e.g. by
_sync()inNotificationPopupManager) just as the user clicks,wrapperConn.onPopupChangedmay not fire at all.win._isDestroyingorwin.exitingstucktrue.wrapperConnisgated by
enabled: !win._isDestroying. If a prior teardown pathset these flags without resetting them, subsequent clicks are silently
dropped.
Upstream master has already added
notificationData.timer.stop()in threeplaces to mitigate similar timer races (hover at line 733, contextMenu at
line 1389, and
Component.onDestructionat line 307). The close-buttonhandler was missed.
Suggested fix
Introduce a single helper
dismissPopupReliably()inNotificationPopup.qmlthat:
exitingor_isDestroyingis set.notificationData.timer.stop()to disarm any pending Timer.notificationData.popup = falseto drive the normal signal chain.Qt.callLater(() => startExit())as a belt-and-suspenders fallbackfor cases where the binding chain does not fire (covers the four races
above).
Apply the helper to the three vulnerable handlers:
closeButton.onClickedonClicked(aftermodelData.invoke())LeftButtonelsebranchThis mirrors the upstream pattern of stopping the timer before mutating
popup state (hover / contextMenu / destruction paths), and keeps the public
behavior identical — clicks still dismiss the popup, just reliably.
Environment
Reproduced on:
/usr/bin/dms)~/.config/quickshell/dms/)niri --session)wayland-1The bug is reproducible across compositors — the relevant signal handlers
(
wrapperConn,enterDelay, dismiss Timer,_sync()reordering) live inNotificationPopup.qmlandNotificationService.qml, neither of which iscompositor-specific. If maintainers need to test on Hyprland / Sway to
confirm, those should reproduce identically because the close-button code
path is independent of compositor IPC.
Related
symptom, hover path)
action (different bug, click routing)
clicking action buttons" — same pattern of fixing user-initiated close
paths