From c39609b99154a5654dacd46d9c5d81fe1142c271 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 10:48:30 +0200 Subject: [PATCH] :recycle: Use shared singleton containers for React portals (#8957) Refactor use-portal-container to allocate one persistent
per logical category (:modal, :popup, :tooltip, :default) instead of creating a new div for every component instance. This keeps the DOM clean with at most four fixed portal containers and eliminates the arbitrary growth of empty
elements on document.body while preserving the removeChild race condition fix. --- .../src/app/main/ui/ds/tooltip/tooltip.cljs | 2 +- frontend/src/app/main/ui/hooks.cljs | 38 ++++++++++++++----- frontend/src/app/main/ui/modal.cljs | 2 +- .../tokens/management/context_menu.cljs | 2 +- .../tokens/management/node_context_menu.cljs | 2 +- .../tokens/themes/theme_selector.cljs | 2 +- 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 4751d81dcf..05246f7f23 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -160,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (hooks/use-portal-container) + container (hooks/use-portal-container :tooltip) id (d/nilv id internal-id) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 42560cd8fe..ae8ebd30d5 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,17 +380,35 @@ state)) +(defn- get-or-create-portal-container + "Returns the singleton container div for the given category, creating + and appending it to document.body on first access." + [category] + (let [body (dom/get-body) + id (str "portal-container-" category)] + (or (dom/query body (str "#" id)) + (let [container (dom/create-element "div")] + (dom/set-attribute! container "id" id) + (dom/append-child! body container) + container)))) + (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)) + "Returns a shared singleton container div for React portals, identified + by a logical category. Available categories: + + :modal — modal dialogs + :popup — popups, dropdowns, context menus + :tooltip — tooltips + :default — general portal use (default) + + All portals in the same category share one
on document.body, + keeping the DOM clean and avoiding removeChild race conditions." + ([] + (use-portal-container :default)) + ([category] + (let [category (name category)] + (mf/with-memo [category] + (get-or-create-portal-container category))))) (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 5df1cc3daa..6e9b1df7d4 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -84,7 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (let [container (hooks/use-portal-container)] + (let [container (hooks/use-portal-container :modal)] (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 ab0dc6326d..c870baf9fb 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 @@ -517,7 +517,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)] + container (hooks/use-portal-container :popup)] (mf/use-effect (mf/deps is-open?) 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 d37e628d02..f150240cf1 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 @@ -36,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 (hooks/use-portal-container) + container (hooks/use-portal-container :popup) delete-node (mf/use-fn (mf/deps mdata) 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 a8687c9719..d688588e2f 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 @@ -114,7 +114,7 @@ :is-open? true :rect rect)))))) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] [:div {:on-click on-open-dropdown :disabled (not can-edit?)