From 80b64c440c3459ddddd48e56e0984f4a7d4403d9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 14:22:34 +0000 Subject: [PATCH 1/2] :bug: Fix removeChild crash on portal-on-document* unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation passed document.body directly as the React portal containerInfo. During unmount, React's commit phase (commitUnmountFiberChildrenRecursively, case 4) sets the current container to containerInfo and then calls container.removeChild() for every DOM node inside the portal tree. When two concurrent state updates are processed — e.g. navigating away from a dashboard section while a file-menu portal is open — React could attempt document.body.removeChild(node) twice for the same node, the second time throwing: NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node. The fix allocates a dedicated
container per portal instance via mf/use-memo. The container is appended to body on mount and removed in the effect cleanup. React then owns an exclusive containerInfo and its unmount path never races with another portal or the modal container (which also targets document.body). Signed-off-by: Andrey Antukh --- frontend/src/app/main/ui/components/portal.cljs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index 27ebaa205a..ff9f3558d4 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -11,6 +11,11 @@ (mf/defc portal-on-document* [{:keys [children]}] - (mf/portal - (mf/html [:* children]) - (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))) + (mf/portal + (mf/html [:* children]) + container))) From 4f0bceddae98ea073cb2f9e28e8755d9d1645b3e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 14:22:45 +0000 Subject: [PATCH 2/2] :bug: Fix stale deferred DOM ops in dashboard navigation Two related issues that could cause crashes during fast navigation in the dashboard: 1. grid.cljs: On drag-start, a temporary counter element is appended to the file card node for the drag ghost image, then scheduled for removal via requestAnimationFrame. If the user navigates away before the RAF fires, React unmounts the section and removes the card node from the DOM. When the RAF fires, item-el.removeChild(counter-el) throws because counter-el is no longer a child. Fixed by guarding the removal with dom/child?. 2. sidebar.cljs: Keyboard navigation handlers used ts/schedule-on-idle (requestIdleCallback with a 30s timeout) to focus the newly rendered section title after navigation. This left a very wide window for the callback to fire against a stale DOM after a subsequent navigation. Additionally, the idle callbacks were incorrectly passed as arguments to st/emit! (which ignores non-event values), making the scheduling an accidental side effect. Fixed by replacing all occurrences with ts/schedule (setTimeout 0), which is sufficient to defer past the current render cycle, and moving the calls outside st/emit!. Signed-off-by: Andrey Antukh --- frontend/src/app/main/ui/dashboard/grid.cljs | 6 +- .../src/app/main/ui/dashboard/sidebar.cljs | 64 +++++++++---------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index ba3304305e..a24490af59 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -328,7 +328,11 @@ ;; it right afterwards, in the next render cycle. (dom/append-child! item-el counter-el) (dnd/set-drag-image! event item-el (:x offset) (:y offset)) - (ts/raf #(dom/remove-child! item-el counter-el)))))) + ;; Guard against race condition: if the user navigates away + ;; before the RAF fires, item-el may have been unmounted and + ;; counter-el is no longer a child — removeChild would throw. + (ts/raf #(when (dom/child? counter-el item-el) + (dom/remove-child! item-el counter-el))))))) on-menu-click (mf/use-fn diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 0ca0a3514d..420cb162ac 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -104,13 +104,13 @@ (fn [event] (when (kbd/enter? event) (st/emit! - (dcm/go-to-dashboard-files :project-id project-id) - (ts/schedule-on-idle - (fn [] - (when-let [title (dom/get-element (str project-id))] - (dom/set-attribute! title "tabindex" "0") - (dom/focus! title) - (dom/set-attribute! title "tabindex" "-1")))))))) + (dcm/go-to-dashboard-files :project-id project-id)) + (ts/schedule + (fn [] + (when-let [title (dom/get-element (str project-id))] + (dom/set-attribute! title "tabindex" "0") + (dom/focus! title) + (dom/set-attribute! title "tabindex" "-1"))))))) on-menu-click (mf/use-fn @@ -234,7 +234,7 @@ (mf/use-fn (fn [e] (when (kbd/enter? e) - (ts/schedule-on-idle + (ts/schedule (fn [] (let [search-title (dom/get-element (str "dashboard-search-title"))] (when search-title @@ -713,13 +713,13 @@ (mf/deps team-id) (fn [] (st/emit! - (dcm/go-to-dashboard-recent :team-id team-id) - (ts/schedule-on-idle - (fn [] - (when-let [projects-title (dom/get-element "dashboard-projects-title")] - (dom/set-attribute! projects-title "tabindex" "0") - (dom/focus! projects-title) - (dom/set-attribute! projects-title "tabindex" "-1"))))))) + (dcm/go-to-dashboard-recent :team-id team-id)) + (ts/schedule + (fn [] + (when-let [projects-title (dom/get-element "dashboard-projects-title")] + (dom/set-attribute! projects-title "tabindex" "0") + (dom/focus! projects-title) + (dom/set-attribute! projects-title "tabindex" "-1")))))) go-fonts (mf/use-fn @@ -731,14 +731,14 @@ (mf/deps team) (fn [] (st/emit! - (dcm/go-to-dashboard-fonts :team-id team-id) - (ts/schedule-on-idle - (fn [] - (let [font-title (dom/get-element "dashboard-fonts-title")] - (when font-title - (dom/set-attribute! font-title "tabindex" "0") - (dom/focus! font-title) - (dom/set-attribute! font-title "tabindex" "-1")))))))) + (dcm/go-to-dashboard-fonts :team-id team-id)) + (ts/schedule + (fn [] + (let [font-title (dom/get-element "dashboard-fonts-title")] + (when font-title + (dom/set-attribute! font-title "tabindex" "0") + (dom/focus! font-title) + (dom/set-attribute! font-title "tabindex" "-1"))))))) go-drafts (mf/use-fn @@ -751,7 +751,7 @@ (mf/deps team-id default-project-id) (fn [] (st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id)) - (ts/schedule-on-idle + (ts/schedule (fn [] (when-let [title (dom/get-element "dashboard-drafts-title")] (dom/set-attribute! title "tabindex" "0") @@ -768,14 +768,14 @@ (mf/deps team-id) (fn [] (st/emit! - (dcm/go-to-dashboard-libraries :team-id team-id) - (ts/schedule-on-idle - (fn [] - (let [libs-title (dom/get-element "dashboard-libraries-title")] - (when libs-title - (dom/set-attribute! libs-title "tabindex" "0") - (dom/focus! libs-title) - (dom/set-attribute! libs-title "tabindex" "-1")))))))) + (dcm/go-to-dashboard-libraries :team-id team-id)) + (ts/schedule + (fn [] + (let [libs-title (dom/get-element "dashboard-libraries-title")] + (when libs-title + (dom/set-attribute! libs-title "tabindex" "0") + (dom/focus! libs-title) + (dom/set-attribute! libs-title "tabindex" "-1"))))))) pinned-projects (mf/with-memo [projects]