🐛 Fix leaked deferred DOM ops on dashboard navigation and template clone

The React reconciliation "removeChild" error surfaced during rapid
dashboard navigation because several effects scheduled deferred DOM
operations (focus, CSS positioning) without returning a cleanup that
cancelled them. When the component unmounted before the callback
fired, it ran against stale DOM and desynchronized React fiber tree
from the actual DOM.

- context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle
  window) with tm/schedule (setTimeout 0) and return a rx/dispose!
  cleanup.
- dropdown.cljs: capture the tm/schedule handle and dispose it in
  the effect cleanup.
- tooltip.cljs: capture the ts/raf handle and cancel it on cleanup.

AI-assisted-by: opencode-go/mimo-v2.5-pro
This commit is contained in:
Andrey Antukh 2026-07-10 07:22:11 +00:00
parent 85dbf14344
commit b372e89282
3 changed files with 30 additions and 23 deletions

View File

@ -18,6 +18,7 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[rumext.v2 :as mf]))
(def ^:private xf:options
@ -229,8 +230,11 @@
(partial ug/unlisten "penpot:context-menu:open" on-event)))
(mf/with-effect [ids]
(tm/schedule-on-idle
#(dom/focus! (dom/get-element (first ids)))))
(let [handle (tm/schedule
(fn []
(some-> (dom/get-element (first ids))
(dom/focus!))))]
#(rx/dispose! handle)))
(when (some? levels)
[:> dropdown-content* props

View File

@ -11,6 +11,7 @@
[app.util.globals :as globals]
[app.util.keyboard :as kbd]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
@ -45,9 +46,10 @@
(fn []
(let [keys [(events/listen globals/document EventType.CLICK on-click)
(events/listen globals/document EventType.CONTEXTMENU on-click)
(events/listen globals/document EventType.KEYUP on-keyup)]]
(tm/schedule #(mf/set-ref-val! listening-ref true))
#(run! events/unlistenByKey keys)))]
(events/listen globals/document EventType.KEYUP on-keyup)]
timer (tm/schedule #(mf/set-ref-val! listening-ref true))]
#(do (rx/dispose! timer)
(run! events/unlistenByKey keys))))]
(mf/use-effect on-mount)
children))

View File

@ -322,25 +322,26 @@
(let [trigger-el (mf/ref-val trigger-ref)
tooltip-el (mf/ref-val tooltip-ref)]
(when (and trigger-el tooltip-el)
(ts/raf
(fn []
(let [origin-brect (dom/get-bounding-rect trigger-el)
tooltip-brect (dom/get-bounding-rect tooltip-el)
window-size (dom/get-window-size)]
(when-let [[new-placement placement-rect]
(find-matching-placement
placement
tooltip-brect
origin-brect
window-size
offset)]
(dom/set-css-property! tooltip-el "inset-block-start"
(str (:top placement-rect) "px"))
(dom/set-css-property! tooltip-el "inset-inline-start"
(str (:left placement-rect) "px"))
(let [raf-id (ts/raf
(fn []
(let [origin-brect (dom/get-bounding-rect trigger-el)
tooltip-brect (dom/get-bounding-rect tooltip-el)
window-size (dom/get-window-size)]
(when-let [[new-placement placement-rect]
(find-matching-placement
placement
tooltip-brect
origin-brect
window-size
offset)]
(dom/set-css-property! tooltip-el "inset-block-start"
(str (:top placement-rect) "px"))
(dom/set-css-property! tooltip-el "inset-inline-start"
(str (:left placement-rect) "px"))
(when (not= new-placement placement)
(reset! placement* new-placement)))))))))))
(when (not= new-placement placement)
(reset! placement* new-placement))))))]
#(ts/cancel-af! raf-id)))))))
[:> :div props
children