🐛 Fix removeChild crash on all portal components

The previous fix (80b64c440c) only addressed portal-on-document* but
there were 6 additional components that portaled directly to
document.body, causing the same race condition when React attempted
to remove a node that had already been detached during concurrent
state updates (e.g. navigating away while a context menu is open).

Apply the dedicated-container pattern consistently to all portal
sites: modal, context menus, combobox dropdown, theme selector, and
tooltip. Each component now creates a dedicated <div> container
appended to body on mount and removed on cleanup, giving React an
exclusive containerInfo for each portal instance.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Andrey Antukh 2026-03-23 12:41:47 +00:00
parent 43cdb91063
commit ff60503ce6
6 changed files with 49 additions and 11 deletions

View File

@ -159,6 +159,8 @@
tooltip-ref (mf/use-ref nil)
container (mf/use-memo #(dom/create-element "div"))
id
(d/nilv id internal-id)
@ -244,6 +246,11 @@
content
aria-label)})]
(mf/with-effect []
(let [body (dom/get-body)]
(dom/append-child! body container)
#(dom/remove-child! body container)))
(mf/use-effect
(mf/deps tooltip-id)
(fn []
@ -295,4 +302,4 @@
[:div {:class (stl/css :tooltip-content)} content]
[:div {:class (stl/css :tooltip-arrow)
:id "tooltip-arrow"}]]])
(.-body js/document)))]))
container))]))

View File

@ -83,7 +83,12 @@
(mf/defc modal-container*
{::mf/props :obj}
[]
(when-let [modal (mf/deref ref:modal)]
(mf/portal
(mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}])
(dom/get-body))))
(let [container (mf/use-memo #(dom/create-element "div"))]
(mf/with-effect []
(let [body (dom/get-body)]
(dom/append-child! body container)
#(dom/remove-child! body container)))
(when-let [modal (mf/deref ref:modal)]
(mf/portal
(mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}])
container))))

View File

@ -515,7 +515,13 @@
dropdown-direction (deref dropdown-direction*)
dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)]
left (+ (get-in mdata [:position :x]) 5)
container (mf/use-memo #(dom/create-element "div"))]
(mf/with-effect []
(let [body (dom/get-body)]
(dom/append-child! body container)
#(dom/remove-child! body container)))
(mf/use-effect
(mf/deps is-open?)
@ -554,4 +560,4 @@
:on-context-menu prevent-default}
(when mdata
[:& token-context-menu-tree (assoc mdata :width @width :on-delete-token on-delete-token)])]])
(dom/get-body)))))
container))))

View File

@ -92,6 +92,8 @@
icon-button-ref (mf/use-ref nil)
ref (or ref internal-ref)
container (mf/use-memo #(dom/create-element "div"))
raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type)
filtered-tokens-by-type
@ -267,6 +269,11 @@
(mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref dropdown-options))
(mf/with-effect []
(let [body (dom/get-body)]
(dom/append-child! body container)
#(dom/remove-child! body container)))
(mf/with-effect [is-open* ref wrapper-ref]
(when is-open
(let [handler (fn [event]
@ -305,4 +312,4 @@
:empty-to-end empty-to-end
:wrapper-ref dropdown-ref
:ref set-option-ref}])
(dom/get-body))))]))
container)))]))

View File

@ -35,6 +35,7 @@
dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5)
left (+ (get-in mdata [:position :x]) 5)
container (mf/use-memo #(dom/create-element "div"))
delete-node (mf/use-fn
(mf/deps mdata)
@ -44,6 +45,11 @@
(when node
(on-delete-node node type)))))]
(mf/with-effect []
(let [body (dom/get-body)]
(dom/append-child! body container)
#(dom/remove-child! body container)))
(mf/with-effect [is-open?]
(when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?))
(reset! dropdown-direction* "down")
@ -80,4 +86,4 @@
:type "button"
:on-click delete-node}
(tr "labels.delete")]]])]])
(dom/get-body)))))
container))))

View File

@ -111,7 +111,14 @@
(let [rect (dom/get-bounding-rect node)]
(swap! state* assoc
:is-open? true
:rect rect))))))]
:rect rect))))))
container (mf/use-memo #(dom/create-element "div"))]
(mf/with-effect []
(let [body (dom/get-body)]
(dom/append-child! body container)
#(dom/remove-child! body container)))
[:div {:on-click on-open-dropdown
:disabled (not can-edit?)
@ -140,4 +147,4 @@
[:& theme-options {:active-theme-paths active-theme-paths
:themes themes
:on-close on-close-dropdown}]]])
(dom/get-body)))]))
container))]))