Skip to content

Djo fix repeating alert toasts - #416

Open
Odin107 wants to merge 3 commits into
mainfrom
djo_fix-repeating-alert-toasts
Open

Djo fix repeating alert toasts#416
Odin107 wants to merge 3 commits into
mainfrom
djo_fix-repeating-alert-toasts

Conversation

@Odin107

@Odin107 Odin107 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes ephemeral alert toasts appearing ~6 times for a single triggered alert. The alerts were never duplicated in alert_store - a rendering regression in Dash ≥ 4.2.0 remounts the toast DOM nodes on every 1-second interval tick, replaying the CSS entry animation each time. Toasts are now rendered into fixed per-toast slots, and only slots whose contents actually changed are written.

Changes Made

src/ssb_dash_framework/utils/alert_handler.py

  • Added _TOAST_POSITIONS, _MAX_TOASTS_PER_POSITION (4), and _toast_slot_id().
  • Added _ephemeral_toast_state(), which assigns each visible ephemeral alert to a fixed slot and returns both the slot contents and a JSON-serializable signature.
  • AlertHandler.layout() now renders _MAX_TOASTS_PER_POSITION slot divs inside each of the three toast containers, plus a dcc.Store(id="alert_toast_signature") holding the previous tick's signature. The three alert-container-* ids are unchanged.
  • display_ephemeral_alerts now outputs one children per slot (12) plus the signature store. It raises PreventUpdate when no slot changed and returns dash.no_update for individual unchanged slots.
  • Drive-by lint/type fixes: removed an unused variant local in show_modal_alerts, annotated current_filter and make_alert parameters, documented current_filter, updated the dcc.Interval comment.

tests/test_alert_handler.py

  • Five new unit tests: signature stability across ticks, per-position slot grouping, slot retention when a sibling expires, dying/expiry transitions, and non-ephemeral/empty input handling.

Why These Changes Were Needed

Since Dash 4.2.0 (plotly/dash#3570, tracked as #3846), any callback write to a container's children bumps an internal freshRenders counter used as the React key of every child in that container. A changed key forces React to unmount and remount the subtree, and a freshly inserted DOM node restarts its CSS animation.

display_ephemeral_alerts fires on a 1-second dcc.Interval and rebuilt all toasts unconditionally - harmless under Dash 3, which reconciled identical children in place. With the default 5-second duration this produced the initial mount plus ~5 remounts, i.e. the ~6 observed "pops".

This is purely client-side: all ~28 callbacks writing to alert_store have disjoint triggers, AlertHandler is instantiated exactly once, and the store provably holds a single copy. The AgGrid copy notification was immune because showCopyNotification() in assets/dashAgGridFunctions.js appends a raw DOM node outside Dash's renderer.

Implementation Details

  • Stable id/key props on the toasts do not help. The remount is driven by the ancestor wrapper's internal counter, not child identity - the only lever is to avoid writing children.
  • Per-container no_update was not sufficient. With two toasts in one container, removing the expired one still rewrote that container and remounted the survivor, replaying its slide-in. Reproduced in the browser as a single toast popping three times (appear, sibling-dying, sibling-expiry). Isolation therefore has to be per toast, not per container.
  • Slot stability is the core invariant. A toast is placed back into the slot it already occupied (matched by created_at), and only genuinely new toasts claim a free slot. If a toast could move slots, both the old and new slot would be written and it would remount.
  • created_at is stringified in the signature because it round-trips through browser JSON, which does not reliably preserve the float/int distinction (a whole-number float returns as an int).
  • Capacity: 4 concurrent toasts per position; extras are dropped rather than stacking off-screen. min-width: 500px toasts mean more than 4 would not fit sensibly anyway.
  • The interval is still required - it is the callback's only Input and drives appearance, the dying flip, and expiry. Idle ticks are now a cheap 204 with no DOM work, so no idle-disable logic was added.
  • Behavior change worth flagging: make_alert previously computed dying with a.get("duration", 6) while removal used 5, so default-duration toasts could never reach the dying state and never played their exit animation. The unified default of 5 means they now do (dying at 4.2 s).

Code Changes

Slot assignment keeps a toast in the slot it already holds (src/ssb_dash_framework/utils/alert_handler.py, ~line 133):

+    unplaced = []
+    for position, key, alert, dying in pending:
+        for index, entry in enumerate(previous.get(position) or []):
+            if (
+                index < _MAX_TOASTS_PER_POSITION
+                and entry
+                and entry[0] == key
+                and slots[position][index] is None
+            ):
+                slots[position][index] = (alert, dying)
+                break
+        else:
+            unplaced.append((position, key, alert, dying))
+
+    for position, _key, alert, dying in unplaced:
+        for index in range(_MAX_TOASTS_PER_POSITION):
+            if slots[position][index] is None:
+                slots[position][index] = (alert, dying)
+                break

Layout: one slot div per toast, container ids preserved (~line 216):

-                html.Div(
-                    id="alert-container-bottom-left",
-                    className="alert-container bottom-left",
-                ),
-                html.Div(
-                    id="alert-container-center", className="alert-container center"
-                ),
-                html.Div(
-                    id="alert-container-top-right",
-                    className="alert-container top-right",
-                ),
+                *[
+                    html.Div(
+                        [
+                            html.Div(id=_toast_slot_id(position, index))
+                            for index in range(_MAX_TOASTS_PER_POSITION)
+                        ],
+                        id=f"alert-container-{position}",
+                        className=f"alert-container {position}",
+                    )
+                    for position in _TOAST_POSITIONS
+                ],

Callback outputs and the skip logic (~line 470):

 @callback(
-    Output("alert-container-bottom-left", "children"),
-    Output("alert-container-center", "children"),
-    Output("alert-container-top-right", "children"),
+    *[
+        Output(_toast_slot_id(position, index), "children")
+        for position in _TOAST_POSITIONS
+        for index in range(_MAX_TOASTS_PER_POSITION)
+    ],
+    Output("alert_toast_signature", "data"),
     Input("alert_ephemeral_interval", "n_intervals"),
     State("alert_store", "data"),
+    State("alert_toast_signature", "data"),
 )
 def display_ephemeral_alerts(...):
-    if not alerts:
-        return [], [], []
-
-    now = time.time()
-    ephemeral_alerts = [
-        a for a in alerts
-        if a.get("ephemeral", False)
-        and (now - a["created_at"] < a.get("duration", 5))
-    ]
+    slots, signature = _ephemeral_toast_state(alerts, time.time(), previous_signature)
+    empty = [None] * _MAX_TOASTS_PER_POSITION
+
+    if all(
+        signature[position] == (previous.get(position) or empty)
+        for position in _TOAST_POSITIONS
+    ):
+        raise PreventUpdate

Per-slot writes (~line 545):

+            children: list[Any] = []
+            for position in _TOAST_POSITIONS:
+                previous_slots = previous.get(position) or empty
+                for index in range(_MAX_TOASTS_PER_POSITION):
+                    was = previous_slots[index] if index < len(previous_slots) else None
+                    if signature[position][index] == was:
+                        children.append(no_update)
+                        continue
+                    entry = slots[position][index]
+                    children.append(make_alert(entry[0], entry[1]) if entry else [])
+
+            return (*children, signature)

The dying flag moved out of make_alert into _ephemeral_toast_state, so make_alert(a, dying) receives it rather than recomputing it from a second time.time() call.

Reviewer Notes

  • _MAX_TOASTS_PER_POSITION = 4 is a hard cap. A 5th concurrent toast at the same position is silently not displayed (it still appears in the modal log). Shout if you'd prefer a higher cap or an explicit overflow indicator.
  • Slot ordering: a toast keeps its slot, so when slot 0 frees up a newer toast can appear above an older one rather than strictly chronologically. Deliberate - chronological repacking would require moving toasts between slots, which reintroduces the remount.
  • assets/dashAgGridFunctions.js still appends into alert-container-bottom-left, which is unchanged; its node now lands after the slot divs. Removing the per-second rewrite makes that foreign node less likely to be disturbed.
  • Pre-existing, unchanged: the 0.8 s dying window vs 1 s tick means some toasts skip the exit animation depending on tick phase.
  • Downstream apps mounting AlertHandler.layout() inside dcc.Tabs would still see the Tabs subtree re-render on writes (Tabs is the only component declaring dashChildrenUpdate); the framework's own main_layout mounts it in the sidebar.

ssb-djo added 2 commits August 14, 2026 09:44
Since Dash 4.2.0 every callback write to a container's children remounts the DOM and replays the toast entry animation, so the 1s interval rewrite made one alert look like ~6. The display callback now skips ticks and containers whose visible toasts are unchanged.
Writing a container's children remounts every toast in it under Dash 4.2+, so a toast replayed its slide-in whenever a neighbour appeared or expired. Each toast now keeps a fixed slot for its whole life and only changed slots are written.
@Odin107 Odin107 added the performance Performance label Aug 14, 2026
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant