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

View File

@ -83,7 +83,12 @@
(mf/defc modal-container* (mf/defc modal-container*
{::mf/props :obj} {::mf/props :obj}
[] []
(when-let [modal (mf/deref ref:modal)] (let [container (mf/use-memo #(dom/create-element "div"))]
(mf/portal (mf/with-effect []
(mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) (let [body (dom/get-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 (deref dropdown-direction*)
dropdown-direction-change* (mf/use-ref 0) dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5) 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/use-effect
(mf/deps is-open?) (mf/deps is-open?)
@ -554,4 +560,4 @@
:on-context-menu prevent-default} :on-context-menu prevent-default}
(when mdata (when mdata
[:& token-context-menu-tree (assoc mdata :width @width :on-delete-token on-delete-token)])]]) [:& 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) icon-button-ref (mf/use-ref nil)
ref (or ref internal-ref) 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) raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type)
filtered-tokens-by-type filtered-tokens-by-type
@ -267,6 +269,11 @@
(mf/with-effect [dropdown-options] (mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref 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] (mf/with-effect [is-open* ref wrapper-ref]
(when is-open (when is-open
(let [handler (fn [event] (let [handler (fn [event]
@ -305,4 +312,4 @@
:empty-to-end empty-to-end :empty-to-end empty-to-end
:wrapper-ref dropdown-ref :wrapper-ref dropdown-ref
:ref set-option-ref}]) :ref set-option-ref}])
(dom/get-body))))])) container)))]))

View File

@ -35,6 +35,7 @@
dropdown-direction-change* (mf/use-ref 0) dropdown-direction-change* (mf/use-ref 0)
top (+ (get-in mdata [:position :y]) 5) 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"))
delete-node (mf/use-fn delete-node (mf/use-fn
(mf/deps mdata) (mf/deps mdata)
@ -44,6 +45,11 @@
(when node (when node
(on-delete-node node type)))))] (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?] (mf/with-effect [is-open?]
(when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?)) (when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?))
(reset! dropdown-direction* "down") (reset! dropdown-direction* "down")
@ -80,4 +86,4 @@
:type "button" :type "button"
:on-click delete-node} :on-click delete-node}
(tr "labels.delete")]]])]]) (tr "labels.delete")]]])]])
(dom/get-body))))) container))))

View File

@ -111,7 +111,14 @@
(let [rect (dom/get-bounding-rect node)] (let [rect (dom/get-bounding-rect node)]
(swap! state* assoc (swap! state* assoc
:is-open? true :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 [:div {:on-click on-open-dropdown
:disabled (not can-edit?) :disabled (not can-edit?)
@ -140,4 +147,4 @@
[:& theme-options {:active-theme-paths active-theme-paths [:& theme-options {:active-theme-paths active-theme-paths
:themes themes :themes themes
:on-close on-close-dropdown}]]]) :on-close on-close-dropdown}]]])
(dom/get-body)))])) container))]))