From fd675e01946918d84d625b5a837204ad886fc75c Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 12:15:01 +0100 Subject: [PATCH 1/4] :wrench: Lookup page objects only when value changes --- frontend/src/app/main/refs.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 76d02544cc..3577842a07 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -305,7 +305,7 @@ (l/derived #(dsh/lookup-shape % page-id shape-id) st/state =)) (def workspace-page-objects - (l/derived dsh/lookup-page-objects st/state)) + (l/derived dsh/lookup-page-objects st/state identical?)) (def workspace-read-only? (l/derived :read-only? workspace-global)) From 92976143bb7f2dc543ffe2b4322400f225fb731a Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 12:20:35 +0100 Subject: [PATCH 2/4] :wrench: Add performance debugging logs --- frontend/src/app/main/data/event.cljs | 74 +++++++++++++++++++++++ frontend/src/app/main/data/workspace.cljs | 6 ++ 2 files changed, 80 insertions(+) diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index d3b1555e6c..41daafdda6 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -61,6 +61,11 @@ ;; Def micro-benchmark iterations (def micro-benchmark-iterations 1e6) +;; Performance logs +(defonce ^:private longtask-observer* (atom nil)) +(defonce ^:private stall-timer* (atom nil)) +(defonce ^:private current-op* (atom nil)) + ;; --- CONTEXT (defn- collect-context @@ -464,3 +469,72 @@ (defn event [props] (ptk/data-event ::event props)) + +;; --- DEVTOOLS PERF LOGGING + +(defn install-long-task-observer! [] + (when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*)) + (let [observer (js/PerformanceObserver. + (fn [list _] + (doseq [entry (.getEntries list)] + (let [dur (.-duration entry) + start (.-startTime entry) + attrib (.-attribution entry) + attrib-count (when attrib (.-length attrib)) + first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0)) + attrib-name (when first-attrib (.-name first-attrib)) + attrib-ctype (when first-attrib (.-containerType first-attrib)) + attrib-cid (when first-attrib (.-containerId first-attrib)) + attrib-csrc (when first-attrib (.-containerSrc first-attrib))] + + (.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms" + (when first-attrib + (str " attrib:name=" attrib-name + " ctype=" attrib-ctype + " cid=" attrib-cid + " csrc=" attrib-csrc))))))))] + (.observe observer #js{:entryTypes #js["longtask"]}) + (reset! longtask-observer* observer)))) + +(defn start-event-loop-stall-logger! + "Log event loop stalls by measuring setInterval drift. + interval-ms: base interval + threshold-ms: drift over which we report" + [interval-ms threshold-ms] + (when (nil? @stall-timer*) + (let [last (atom (.now js/performance)) + id (js/setInterval + (fn [] + (let [now (.now js/performance) + expected (+ @last interval-ms) + drift (- now expected) + current-op @current-op* + measures (.getEntriesByType js/performance "measure") + mlen (.-length measures) + last-measure (when (> mlen 0) (aget measures (dec mlen))) + meas-name (when last-measure (.-name last-measure)) + meas-detail (when last-measure (.-detail last-measure)) + meas-count (when meas-detail (unchecked-get meas-detail "count"))] + (reset! last now) + (when (> drift threshold-ms) + (.warn js/console + (str "[perf] event loop stall: " (Math/round drift) "ms" + (when current-op (str " op=" current-op)) + (when meas-name (str " last=" meas-name)) + (when meas-count (str " count=" meas-count))))))) + interval-ms)] + (reset! stall-timer* id)))) + +(defn init! + "Install perf observers in dev builds. Safe to call multiple times." + [] + (when ^boolean js/goog.DEBUG + (install-long-task-observer!) + (start-event-loop-stall-logger! 50 100) + ;; Expose simple API on window for manual control in devtools + (let [api #js {:reset (fn [] + (try + (.clearMarks js/performance) + (.clearMeasures js/performance) + (catch :default _ nil)))}] + (aset js/window "PenpotPerf" api)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 6e71bcf2a6..4d71f1ec46 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -347,6 +347,12 @@ (with-meta {:team-id team-id :file-id file-id})))))) + ;; Install dev perf observers once the workspace is ready + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/take 1) + (rx/map (fn [_] (ev/init!)))) + (->> stream (rx/filter (ptk/type? ::dps/persistence-notification)) (rx/take 1) From 68f5671eab88da77b1627e086a7037b027e2907b Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 12:26:23 +0100 Subject: [PATCH 3/4] :wrench: Always lookup over a set --- common/src/app/common/files/helpers.cljc | 27 ++++++++++++++---------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/common/src/app/common/files/helpers.cljc b/common/src/app/common/files/helpers.cljc index 2e6b9f688e..f669d34aac 100644 --- a/common/src/app/common/files/helpers.cljc +++ b/common/src/app/common/files/helpers.cljc @@ -526,20 +526,25 @@ ids)) (defn clean-loops - "Clean a list of ids from circular references." + "Clean a list of ids from circular references. Optimized fast-path for single selections." [objects ids] - (let [parent-selected? - (fn [id] - (let [parents (get-parent-ids objects id)] - (some ids parents))) + (if (<= (count ids) 1) + ;; For single selection, there can't be circularity; return as ordered-set. + (into (d/ordered-set) ids) + (let [ids-set (if (set? ids) ids (set ids)) + parent-selected? + (fn [id] + ;; Stop early as soon as we find any selected parent + (let [parents (get-parent-ids objects id)] + (some #(contains? ids-set %) parents))) - add-element - (fn [result id] - (cond-> result - (not (parent-selected? id)) - (conj id)))] + add-element + (fn [result id] + (cond-> result + (not (parent-selected? id)) + (conj id)))] - (reduce add-element (d/ordered-set) ids))) + (reduce add-element (d/ordered-set) ids)))) (defn- indexed-shapes "Retrieves a vector with the indexes for each element in the layer From 5054f6bc38dfd0010f12c4a682e6c0bb66d26d52 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 13:06:09 +0100 Subject: [PATCH 4/4] :wrench: Optimize sidebar performance for deeply nested shapes - Batch hover highlights using RAF to avoid long tasks from rapid events - Run parent expansion asynchronously to not block selection - Lazy-load children in layer items using IntersectionObserver - Clarify expand-all-parents logic with explicit bindings --- CHANGES.md | 1 + .../src/app/main/data/workspace/collapse.cljs | 14 +- .../app/main/data/workspace/selection.cljs | 11 +- .../main/ui/workspace/sidebar/layer_item.cljs | 143 +++++++++++++++--- 4 files changed, 133 insertions(+), 36 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8502083b22..aaac76a83e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,7 @@ - Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202) - Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966) - Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916) +- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017) ### :bug: Bugs fixed diff --git a/frontend/src/app/main/data/workspace/collapse.cljs b/frontend/src/app/main/data/workspace/collapse.cljs index f805c238c8..1143a6f4d8 100644 --- a/frontend/src/app/main/data/workspace/collapse.cljs +++ b/frontend/src/app/main/data/workspace/collapse.cljs @@ -18,13 +18,13 @@ ptk/UpdateEvent (update [_ state] (let [expand-fn (fn [expanded] - (merge expanded - (->> ids - (map #(cfh/get-parent-ids objects %)) - flatten - (remove #(= % uuid/zero)) - (map (fn [id] {id true})) - (into {}))))] + (let [parents-seqs (map (fn [x] (cfh/get-parent-ids objects x)) ids) + flat-parents (apply concat parents-seqs) + non-root-parents (remove #(= % uuid/zero) flat-parents) + distinct-parents (into #{} non-root-parents)] + (merge expanded + (into {} + (map (fn [id] {id true}) distinct-parents)))))] (update-in state [:workspace-local :expanded] expand-fn))))) diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 5424a280d9..45c323a860 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -264,10 +264,13 @@ ptk/WatchEvent (watch [_ state _] - (let [objects (dsh/lookup-page-objects state)] - (rx/of - (dwc/expand-all-parents ids objects) - ::dwsp/interrupt))))) + (let [objects (dsh/lookup-page-objects state) + ;; Schedule expanding parents asynchronously to avoid blocking + ;; the event loop + expand-s (->> (rx/of (dwc/expand-all-parents ids objects)) + (rx/observe-on :async)) + interrupt-s (rx/of ::dwsp/interrupt)] + (rx/merge expand-s interrupt-s))))) (defn select-all [] diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 6ff8e32791..ebb1312369 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -33,9 +33,24 @@ [okulary.core :as l] [rumext.v2 :as mf])) +;; Coalesce sidebar hover highlights to 1 frame to avoid long tasks +(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}})) +(defonce ^:private sidebar-hover-pending? (atom false)) + +(defn- schedule-sidebar-hover-flush [] + (when (compare-and-set! sidebar-hover-pending? false true) + (ts/raf + (fn [] + (let [{:keys [enter leave]} (swap! sidebar-hover-queue (constantly {:enter #{} :leave #{}}))] + (reset! sidebar-hover-pending? false) + (when (seq leave) + (apply st/emit! (map dw/dehighlight-shape leave))) + (when (seq enter) + (apply st/emit! (map dw/highlight-shape enter)))))))) + (mf/defc layer-item-inner {::mf/wrap-props false} - [{:keys [item depth parent-size name-ref children ref + [{:keys [item depth parent-size name-ref children ref style ;; Flags read-only? highlighted? selected? component-tree? filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle? @@ -82,7 +97,8 @@ :dnd-over dnd-over? :dnd-over-top dnd-over-top? :dnd-over-bot dnd-over-bot? - :root-board parent-board?)} + :root-board parent-board?) + :style style} [:span {:class (stl/css-case :tab-indentation true :filtered filtered?) @@ -165,10 +181,12 @@ children])) +;; Memoized for performance (mf/defc layer-item {::mf/props :obj - ::mf/memo true} - [{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted]}] + ::mf/wrap [mf/memo]} + [{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?] + :or {render-children? true}}] (let [id (:id item) blocked? (:blocked item) hidden? (:hidden item) @@ -245,13 +263,21 @@ (mf/use-fn (mf/deps id) (fn [_] - (st/emit! (dw/highlight-shape id)))) + (swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}] + (-> q + (assoc :enter (conj enter id)) + (assoc :leave (disj leave id))))) + (schedule-sidebar-hover-flush))) on-pointer-leave (mf/use-fn (mf/deps id) (fn [_] - (st/emit! (dw/dehighlight-shape id)))) + (swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}] + (-> q + (assoc :enter (disj enter id)) + (assoc :leave (conj leave id))))) + (schedule-sidebar-hover-flush))) on-context-menu (mf/use-fn @@ -337,14 +363,18 @@ component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item)) enable-drag (mf/use-fn #(reset! drag-disabled* false)) - disable-drag (mf/use-fn #(reset! drag-disabled* true))] + disable-drag (mf/use-fn #(reset! drag-disabled* true)) + + ;; Lazy loading of child elements via IntersectionObserver + children-count* (mf/use-state 0) + children-count (deref children-count*) + lazy-ref (mf/use-ref nil) + observer-var (mf/use-var nil) + chunk-size 50] (mf/with-effect [selected? selected] (let [single? (= (count selected) 1) node (mf/ref-val ref) - ;; NOTE: Neither get-parent-at nor get-parent-with-selector - ;; work if the component template changes, so we need to - ;; seek for an alternate solution. Maybe use-context? scroll-node (dom/get-parent-with-data node "scroll-container") parent-node (dom/get-parent-at node 2) first-child-node (dom/get-first-child parent-node) @@ -362,6 +392,61 @@ #(when (some? subid) (rx/dispose! subid)))) + ;; Setup scroll-driven lazy loading when expanded + ;; and ensures selected children are loaded immediately + (mf/with-effect [expanded? (:shapes item) selected] + (let [shapes-vec (:shapes item) + total (count shapes-vec)] + (if expanded? + (let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec + ;; Find if any selected id is a direct child and get its render index + selected-child-render-idx + (when (and (> total chunk-size) (seq selected)) + (let [shapes-reversed (vec (reverse shapes-vec))] + (some (fn [sel-id] + (let [idx (.indexOf shapes-reversed sel-id)] + (when (>= idx 0) idx))) + selected))) + ;; Load at least enough to include the selected child plus extra + ;; for context (so it can be centered in the scroll view) + min-count (if selected-child-render-idx + (+ selected-child-render-idx chunk-size) + chunk-size) + current @children-count* + new-count (min total (max current chunk-size min-count))] + (reset! children-count* new-count)) + (reset! children-count* 0))) + (fn [] + (when-let [obs ^js @observer-var] + (.disconnect obs) + (reset! observer-var nil)))) + + ;; Re-observe sentinel whenever children-count changes (sentinel moves) + (mf/with-effect [children-count expanded?] + (let [total (count (:shapes item)) + node (mf/ref-val ref) + scroll-node (dom/get-parent-with-data node "scroll-container") + lazy-node (mf/ref-val lazy-ref)] + ;; Disconnect previous observer + (when-let [obs ^js @observer-var] + (.disconnect obs) + (reset! observer-var nil)) + ;; Setup new observer if there are more children to load + (when (and expanded? + (< children-count total) + scroll-node + lazy-node) + (let [cb (fn [entries] + (when (and (seq entries) + (.-isIntersecting (first entries))) + ;; Load next chunk when sentinel intersects + (let [current @children-count* + next-count (min total (+ current chunk-size))] + (reset! children-count* next-count)))) + observer (js/IntersectionObserver. cb #js {:root scroll-node})] + (.observe observer lazy-node) + (reset! observer-var observer))))) + [:& layer-item-inner {:ref dref :item item @@ -386,24 +471,32 @@ :on-enable-drag enable-drag :on-disable-drag disable-drag :on-toggle-visibility toggle-visibility - :on-toggle-blocking toggle-blocking} + :on-toggle-blocking toggle-blocking + :style style} - (when (and (:shapes item) expanded?) + (when (and render-children? + (:shapes item) + expanded?) [:div {:class (stl/css-case :element-children true :parent-selected selected? :sticky-children parent-board?) :data-testid (dm/str "children-" id)} - (for [[index id] (reverse (d/enumerate (:shapes item)))] - (when-let [item (get objects id)] - [:& layer-item - {:item item - :highlighted highlighted - :selected selected - :index index - :objects objects - :key (dm/str id) - :sortable? sortable? - :depth depth - :parent-size parent-size - :component-child? component-tree?}]))])])) + (let [all-children (reverse (d/enumerate (:shapes item))) + visible (take children-count all-children)] + (for [[index id] visible] + (when-let [item (get objects id)] + [:& layer-item + {:item item + :highlighted highlighted + :selected selected + :index index + :objects objects + :key (dm/str id) + :sortable? sortable? + :depth depth + :parent-size parent-size + :component-child? component-tree?}]))) + (when (< children-count (count (:shapes item))) + [:div {:ref lazy-ref + :style {:min-height 1}}])])]))