From 5fca9457cf6b3c08b2c117ce99bd180bb47828dd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 26 Mar 2026 12:45:42 +0100 Subject: [PATCH] :recycle: Extract use-portal-container hook to reduce duplication (#8798) The dedicated-container portal pattern was repeated across 6 components. Extract it into a reusable use-portal-container hook under app.main.ui.hooks. --- frontend/src/app/main/ui/components/portal.cljs | 8 ++------ frontend/src/app/main/ui/ds/tooltip/tooltip.cljs | 5 ++++- frontend/src/app/main/ui/hooks.cljs | 12 ++++++++++++ frontend/src/app/main/ui/modal.cljs | 10 ++++++---- .../ui/workspace/tokens/management/context_menu.cljs | 6 ++++-- .../tokens/management/node_context_menu.cljs | 4 +++- .../ui/workspace/tokens/themes/theme_selector.cljs | 7 +++++-- 7 files changed, 36 insertions(+), 16 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 087649ca63..4751d81dcf 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,6 +160,8 @@ tooltip-ref (mf/use-ref nil) + container (hooks/use-portal-container) + id (d/nilv id internal-id) @@ -283,4 +286,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/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 9d260de69e..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,7 +84,8 @@ (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 (hooks/use-portal-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 bcb44b83c5..ab0dc6326d 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] @@ -515,7 +516,8 @@ 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 (hooks/use-portal-container)] (mf/use-effect (mf/deps is-open?) @@ -554,4 +556,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/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index 4e272f7bdd..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,6 +36,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) + container (hooks/use-portal-container) delete-node (mf/use-fn (mf/deps mdata) @@ -80,4 +82,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..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] @@ -111,7 +112,9 @@ (let [rect (dom/get-bounding-rect node)] (swap! state* assoc :is-open? true - :rect rect))))))] + :rect rect)))))) + + container (hooks/use-portal-container)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) @@ -140,4 +143,4 @@ [:& theme-options {:active-theme-paths active-theme-paths :themes themes :on-close on-close-dropdown}]]]) - (dom/get-body)))])) + container))]))