penpot/frontend/src/app/main/ui/components/context_menu_a11y.cljs
Andrey Antukh 4b994d20aa
🐛 Fix leaked focus timers in dashboard sidebar navigation (#10715)
* 🐛 Fix leaked deferred DOM ops on dashboard navigation and template clone

The React reconciliation "removeChild" error surfaced during rapid
dashboard navigation because several effects scheduled deferred DOM
operations (focus, CSS positioning) without returning a cleanup that
cancelled them. When the component unmounted before the callback
fired, it ran against stale DOM and desynchronized React fiber tree
from the actual DOM.

- context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle
  window) with tm/schedule (setTimeout 0) and return a rx/dispose!
  cleanup.
- dropdown.cljs: capture the tm/schedule handle and dispose it in
  the effect cleanup.
- tooltip.cljs: capture the ts/raf handle and cancel it on cleanup.

AI-assisted-by: opencode-go/mimo-v2.5-pro

* 🐛 Fix leaked focus timers in dashboard sidebar navigation

Six sidebar navigation handlers scheduled setTimeout callbacks to mutate
tabindex/focus on React-managed title elements without cancelling prior
pending callbacks. During rapid keyboard navigation (Projects→Fonts→Libs→Drafts)
the stale callbacks fired against unmounted DOM, desyncing React fiber tree
and triggering "removeChild" NotFoundError.

- sidebar-project*: cancel prior timer in on-key-down
- sidebar-search*: cancel prior timer in on-key-press
- sidebar-content*: cancel prior timer in go-projects-with-key,
  go-fonts-with-key, go-drafts-with-key, go-libs-with-key

Each handler now stores the timer handle in a component-level ref and
disposes any pending handle before scheduling a new one.

AI-assisted-by: opencode-go/mimo-v2.5-pro

* ♻️ Refactor sidebar focus timer handling into helpers

Extract the repeated dispose-before-schedule focus idiom into
schedule-focus-by-id! (sidebar.cljs) and focus-and-untabbable!
(app.util.dom). Replaces the six duplicated blocks and adds
mf/use-effect unmount cleanup to dispose any pending timer in the
three sidebar components, closing the remaining leak noted in the
original fix.

AI-assisted-by: opencode/hy3-free

* ♻️ Extract use-focus-timer-ref hook for sidebar components

Replace the duplicated mf/use-ref + mf/use-effect cleanup pairs in
sidebar-project*, sidebar-search*, and sidebar-content* with a shared
use-focus-timer-ref hook (app.main.ui.hooks). The hook creates the ref
and disposes any pending timer on unmount via mf/with-effect, reading the
ref with mf/ref-val instead of deref. mf/use-effect is now a body-level
hook call rather than a let binding.

AI-assisted-by: opencode/hy3-free

* 📎 Add pr feedback fix
2026-07-28 11:06:00 +02:00

325 lines
13 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.components.context-menu-a11y
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.main.refs :as refs]
[app.main.ui.components.dropdown :refer [dropdown-content*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.globals :as ug]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[rumext.v2 :as mf]))
(def ^:private xf:options
(comp
(map :id)
(filter some?)))
(defn- generate-ids-group
[options has-parents?]
(let [ids (sequence xf:options options)
ids (if has-parents?
(cons "go-back-sub-option" ids)
ids)]
(vec ids)))
(def ^:private schema:option
[:schema {:registry
{::option
[:or
:nil
[:map [:name [:= :separator]]]
[:and
[:map
[:name :string]
[:id :string]
[:title {:optional true} [:maybe :string]]
[:disabled {:optional true} [:maybe :boolean]]
[:handler {:optional true} fn?]
[:options {:optional true}
[:sequential [:ref ::option]]]]
[::sm/contains-any #{:handler :options}]]]}}
[:ref ::option]])
(def ^:private valid-option?
(sm/lazy-validator schema:option))
(mf/defc context-menu-inner*
[{:keys [on-close options selectable selected
top left fixed min-width origin width]
:as props}]
(assert (every? valid-option? options) "expected valid options")
(assert (fn? on-close) "missing `on-close` prop")
(assert (vector? options) "missing `options` prop")
(let [width (d/nilv width "initial")
min-width (d/nilv min-width false)
left (d/nilv left 0)
top (d/nilv top 0)
route (mf/deref refs/route)
in-dashboard? (= :dashboard-projects (:name (:data route)))
state* (mf/use-state
#(-> {:offset-y 0
:offset-x 0
:levels nil}))
state (deref state*)
offset-x (get state :offset-x)
offset-y (get state :offset-y)
levels (get state :levels)
internal-id (mf/use-id)
on-local-close
(mf/use-fn
(mf/deps on-close)
(fn []
(swap! state* assoc :levels [{:parent nil :options options}])
(when (fn? on-close)
(on-close))))
props
(mf/spread-props props {:on-close on-local-close})
ids
(mf/with-memo [levels]
(let [last-level (last levels)]
(generate-ids-group (:options last-level)
(:parent last-level))))
check-menu-offscreen
(mf/use-fn
(mf/deps top left offset-x offset-y)
(fn [node]
(when (some? node)
(let [bounding-rect (dom/get-bounding-rect node)
window-size (dom/get-window-size)
node-height (dm/get-prop bounding-rect :height)
node-width (dm/get-prop bounding-rect :width)
window-height (get window-size :height)
window-width (get window-size :width)
target-offset-y (if (> (+ top node-height) window-height)
(- node-height)
0)
target-offset-x (if (> (+ left node-width) window-width)
(- node-width)
0)]
(when (or (not= target-offset-y offset-y)
(not= target-offset-x offset-x))
(swap! state* assoc
:offset-y target-offset-y
:offset-x target-offset-x))))))
;; NOTE: this function is used for build navigation callbacks
;; so we don't really need to use the use-fn here. It is not
;; an efficient approach but this manages a reasonable small
;; list of objects, so doing it this way has no real
;; implications on performance but facilitates a lot the
;; implementation
enter-submenu
(fn [name options]
(fn [event]
(dom/stop-propagation event)
(swap! state* update :levels conj {:parent name
:options options})))
on-submenu-exit
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! state* update :levels pop)))
;; NOTE: this function is used for build navigation callbacks
;; so we don't really need to use the use-fn here. It is not
;; an efficient approach but this manages a reasonable small
;; list of objects, so doing it this way has no real
;; implications on performance but facilitates a lot the
;; implementation
on-key-down
(fn [options-original parent-original]
(fn [event]
(let [ids (generate-ids-group options-original
parent-original)
first-id (dom/get-element (first ids))
first-element (dom/get-element first-id)
len (count ids)
parent (dom/get-target event)
parent-id (dom/get-attribute parent "id")
option (d/seek #(= parent-id (:id %)) options-original)
sub-options (not-empty (:options option))
handler (:handler option)
is-back-option? (= "go-back-sub-option" parent-id)]
(when (kbd/home? event)
(when first-element
(dom/focus! first-element)))
(when (kbd/enter? event)
(if is-back-option?
(on-submenu-exit event)
(if sub-options
(do
(dom/stop-propagation event)
(swap! state* update :levels conj {:parent (:name option)
:options sub-options}))
(do
(dom/stop-propagation event)
(handler event)))))
(when (and is-back-option? (kbd/left-arrow? event))
(on-submenu-exit event))
(when (and sub-options (kbd/right-arrow? event))
(dom/stop-propagation event)
(swap! state* update :levels conj {:parent (:name option)
:options sub-options}))
(when (kbd/up-arrow? event)
(let [actual-selected (dom/get-active)
actual-id (dom/get-attribute actual-selected "id")
actual-index (d/index-of ids actual-id)
previous-id (if (= 0 actual-index)
(last ids)
(nth ids (- actual-index 1)))]
(dom/focus! (dom/get-element previous-id))))
(when (kbd/down-arrow? event)
(let [actual-selected (dom/get-active)
actual-id (dom/get-attribute actual-selected "id")
actual-index (d/index-of ids actual-id)
next-id (if (= (- len 1) actual-index)
(first ids)
(nth ids (+ 1 actual-index)))]
(dom/focus! (dom/get-element next-id))))
(when (or (kbd/esc? event) (kbd/tab? event))
(on-close event)
(dom/focus! (dom/get-element origin))))))]
(mf/with-effect [options]
(swap! state* assoc :levels [{:parent nil
:options options}]))
(mf/with-effect [internal-id]
(ug/dispatch! (ug/event "penpot:context-menu:open" #js {:id internal-id})))
(mf/with-effect [internal-id on-local-close]
(letfn [(on-event [event]
(when-let [detail (unchecked-get event "detail")]
(when (not= internal-id (unchecked-get detail "id"))
(on-local-close event))))]
(ug/listen "penpot:context-menu:open" on-event)
(partial ug/unlisten "penpot:context-menu:open" on-event)))
(mf/with-effect [ids]
(let [handle (tm/schedule
(fn []
(some-> (dom/get-element (first ids))
(dom/focus!))))]
#(rx/dispose! handle)))
(when (some? levels)
[:> dropdown-content* props
(let [level (peek levels)
options (:options level)
parent (:parent level)]
[:div {:class (stl/css-case
:is-selectable selectable
:context-menu true
:is-open true
:fixed fixed)
:style {:top (+ top offset-y)
:left (+ left offset-x)}
:on-key-down (on-key-down options parent)}
[:ul {:class (stl/css-case :min-width min-width
:context-menu-items true)
:style {:width width}
:role "menu"
:ref check-menu-offscreen}
(when parent
[:*
[:li {:id "go-back-sub-option"
:class (stl/css :context-menu-item)
:role "menuitem"
:tab-index "0"
:on-key-down dom/prevent-default}
[:button {:class (stl/css :context-menu-action :submenu-back)
:data-no-close true
:on-click on-submenu-exit}
[:span {:class (stl/css :submenu-icon-back)} deprecated-icon/arrow]
parent]]
[:li {:class (stl/css :separator)}]])
(for [[index option] (d/enumerate options)]
(let [name (:name option)
id (:id option)
sub-options (:options option)
handler (:handler option)
title (:title option)
disabled (:disabled option)]
(when name
(if (= name :separator)
[:li {:key (dm/str "context-item-" index)
:class (stl/css :separator)}]
[:li {:id id
:key id
:class (stl/css-case
:is-selected (and selected (= name selected))
:selected (and selected (= id selected))
:context-menu-item true)
:tab-index "0"
:role "menuitem"
:on-key-down dom/prevent-default}
(if-not sub-options
[:a {:class (stl/css-case :context-menu-action true :context-menu-action-disabled disabled)
:title title
:on-click #(do (dom/stop-propagation %)
(when-not disabled
(on-close %)
(handler %)))
:data-testid id}
(if (and in-dashboard? (= name "Default"))
(tr "dashboard.default-team-name")
name)
(when (and selected (= id selected))
[:span {:class (stl/css :selected-icon)} deprecated-icon/tick])]
[:a {:class (stl/css :context-menu-action :submenu)
:data-no-close true
:on-click (enter-submenu name sub-options)
:data-testid id}
name
[:span {:class (stl/css :submenu-icon)} deprecated-icon/arrow]])]))))]])])))
(mf/defc context-menu*
{::mf/private true}
[{:keys [show] :as props}]
(assert (boolean? show) "expected `show` prop to be a boolean")
(when ^boolean show
[:> context-menu-inner* props]))