From ff60503ce6230aef34d10daf6ecebbf9f371f11a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 12:41:47 +0000 Subject: [PATCH 1/2] :bug: 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
container appended to body on mount and removed on cleanup, giving React an exclusive containerInfo for each portal instance. Signed-off-by: Andrey Antukh --- frontend/src/app/main/ui/ds/tooltip/tooltip.cljs | 9 ++++++++- frontend/src/app/main/ui/modal.cljs | 13 +++++++++---- .../workspace/tokens/management/context_menu.cljs | 10 ++++++++-- .../tokens/management/forms/controls/combobox.cljs | 9 ++++++++- .../tokens/management/node_context_menu.cljs | 8 +++++++- .../ui/workspace/tokens/themes/theme_selector.cljs | 11 +++++++++-- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 968361f865..f1598364d4 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -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))])) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 9d260de69e..ce09cc71da 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -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)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index eb43f4a23b..22e1915e64 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -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)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index d53b8d0d60..2c5b4e25cd 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -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))))])) \ No newline at end of file + container)))])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index 4e272f7bdd..c9b6947316 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -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)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index 3d799e0b59..35b775c9c6 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -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))])) From 2905905a9f95e6fd8e6e17385c3eb5d933628ee3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Mar 2026 13:47:33 +0000 Subject: [PATCH 2/2] :recycle: Extract use-portal-container hook to reduce duplication The dedicated-container portal pattern was repeated across 7 components. Extract it into a reusable use-portal-container hook under app.main.ui.hooks. Signed-off-by: Andrey Antukh --- frontend/src/app/main/ui/components/portal.cljs | 8 ++------ frontend/src/app/main/ui/ds/tooltip/tooltip.cljs | 8 ++------ frontend/src/app/main/ui/hooks.cljs | 12 ++++++++++++ frontend/src/app/main/ui/modal.cljs | 7 ++----- .../ui/workspace/tokens/management/context_menu.cljs | 8 ++------ .../tokens/management/forms/controls/combobox.cljs | 8 ++------ .../tokens/management/node_context_menu.cljs | 8 ++------ .../ui/workspace/tokens/themes/theme_selector.cljs | 8 ++------ 8 files changed, 26 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index ff9f3558d4..381db4b66c 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -6,16 +6,12 @@ (ns app.main.ui.components.portal (:require - [app.util.dom :as dom] + [app.main.ui.hooks :as hooks] [rumext.v2 :as mf])) (mf/defc portal-on-document* [{:keys [children]}] - (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))) + (let [container (hooks/use-portal-container)] (mf/portal (mf/html [:* children]) container))) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index f1598364d4..74764a1a4c 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -9,6 +9,7 @@ [app.main.style :as stl]) (:require [app.common.data :as d] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.timers :as ts] @@ -159,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (mf/use-memo #(dom/create-element "div")) + container (hooks/use-portal-container) id (d/nilv id internal-id) @@ -246,11 +247,6 @@ 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 [] diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index b4ad8fe616..42560cd8fe 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,6 +380,18 @@ state)) +(defn use-portal-container + "Creates a dedicated div container for React portals. The container + is appended to document.body on mount and removed on cleanup, preventing + removeChild race conditions when multiple portals target the same 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))) + container)) + (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) ([itemsize] diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index ce09cc71da..5df1cc3daa 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as k] [goog.events :as events] @@ -83,11 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (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))) + (let [container (hooks/use-portal-container)] (when-let [modal (mf/deref ref:modal)] (mf/portal (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index 22e1915e64..0dd980f73f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -20,6 +20,7 @@ [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.timers :as timers] @@ -516,12 +517,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"))] - - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) + container (hooks/use-portal-container)] (mf/use-effect (mf/deps is-open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index 2c5b4e25cd..3e6dec43b1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -19,6 +19,7 @@ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.forms :as fc] + [app.main.ui.hooks :as hooks] [app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation :refer [use-navigation]] [app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown :refer [use-floating-dropdown]] [app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp] @@ -92,7 +93,7 @@ icon-button-ref (mf/use-ref nil) ref (or ref internal-ref) - container (mf/use-memo #(dom/create-element "div")) + container (hooks/use-portal-container) raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type) @@ -269,11 +270,6 @@ (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] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index c9b6947316..d37e628d02 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -6,6 +6,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [okulary.core :as l] @@ -35,7 +36,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")) + container (hooks/use-portal-container) delete-node (mf/use-fn (mf/deps mdata) @@ -45,11 +46,6 @@ (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") diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index 35b775c9c6..a8687c9719 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -17,6 +17,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cuerdas.core :as str] @@ -113,12 +114,7 @@ :is-open? true :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))) + container (hooks/use-portal-container)] [:div {:on-click on-open-dropdown :disabled (not can-edit?)