From 7ab5f241dadea37665df89be186b5e739621e478 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 12:21:13 +0000 Subject: [PATCH 01/19] :bug: Fix TypeError when path content is nil in get-points calls Use nil-safe path/get-points wrapper (some-> based) instead of direct path.segment/get-points calls in edition.cljs to prevent 'Cannot read properties of undefined (reading get)' crash. Add nil-safety test to verify path/get-points returns nil without throwing when content is nil. Signed-off-by: Andrey Antukh --- common/test/common_tests/types/path_data_test.cljc | 9 +++++++++ frontend/src/app/main/data/workspace/path/edition.cljs | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 7a37438252..5e6298191e 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -270,6 +270,15 @@ (t/is (= result1 result2)) (t/is (= result2 result3)))) +(t/deftest path-get-points-nil-safe + (t/testing "path/get-points returns nil for nil content without throwing" + (t/is (nil? (path/get-points nil)))) + (t/testing "path/get-points returns correct points for valid content" + (let [content (path/content sample-content) + points (path/get-points content)] + (t/is (some? points)) + (t/is (= 3 (count points)))))) + (defn calculate-extremities "Calculate extremities for the provided content. A legacy implementation used mainly as reference for testing" diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index a97947ea05..f5118f16ab 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -59,8 +59,8 @@ content (get shape :content) new-content (path/apply-content-modifiers content content-modifiers) - old-points (path.segment/get-points content) - new-points (path.segment/get-points new-content) + old-points (path/get-points content) + new-points (path/get-points new-content) point-change (->> (map hash-map old-points new-points) (reduce merge))] (when (and (some? new-content) (some? shape)) @@ -162,7 +162,7 @@ start-position (apply min-key #(gpt/distance start-position %) selected-points) content (st/get-path state :content) - points (path.segment/get-points content)] + points (path/get-points content)] (rx/concat ;; This stream checks the consecutive mouse positions to do the dragging @@ -255,7 +255,7 @@ start-delta-y (dm/get-in modifiers [index cy] 0) content (st/get-path state :content) - points (path.segment/get-points content) + points (path/get-points content) point (-> content (nth (if (= prefix :c1) (dec index) index)) (path.helpers/segment->point)) handler (-> content (nth index) (path.segment/get-handler prefix)) From 80b64c440c3459ddddd48e56e0984f4a7d4403d9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 14:22:34 +0000 Subject: [PATCH 02/19] :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 03/19] :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] From fed01fba73f2d69754b55243889fe4bfa9cbd916 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 14:58:19 +0000 Subject: [PATCH 04/19] :bug: Wrap fetch TypeError into proper ex-info with :unable-to-fetch code Signed-off-by: Andrey Antukh --- frontend/src/app/util/http.cljs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index bf35ce96fd..0fd9be60b7 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -104,10 +104,17 @@ (.next ^js subscriber response) (.complete ^js subscriber))) (p/catch - (fn [err] + (fn [cause] (vreset! abortable? false) (when-not @unsubscribed? - (.error ^js subscriber err)))) + (let [error (ex-info (ex-message cause) + {:type :internal + :code :unable-to-fetch + :hint "unable to perform fetch operation" + :uri uri + :headers headers} + cause)] + (.error ^js subscriber error))))) (p/finally (fn [] (let [{:keys [count average] :or {count 0 average 0}} (get @network-averages (:path uri)) From f566a2adfd4955430824a5cf8e38dfd8b425df71 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 18:06:44 +0000 Subject: [PATCH 05/19] :bug: Fix ITransformable error when path content is a plain vector Coerce content to PathData in transform-content before dispatching the ITransformable protocol, so shapes carrying a plain vector in their :content field (legacy data, bool shapes, SVG imports) no longer crash with 'No protocol method ITransformable.-transform defined for type object'. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/segment.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index fbe90b7c92..8ff37a8e81 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -777,7 +777,7 @@ content as PathData instance." [content transform] (if (some? transform) - (impl/-transform content transform) + (impl/-transform (impl/path-data content) transform) content)) (defn move-content From 7939cb045b696ae742bab2b9902f9b3c04637ba3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 18:24:13 +0000 Subject: [PATCH 06/19] :bug: Fix plain vector leaking into shape :content from shape-to-path conversions group-to-path was storing a raw concatenated vector into :content after flattening children's PathData instances via (map vec). bool-to-path was storing the plain-vector result of bool/calculate-content directly. Both now wrap through path.impl/path-data at the assignment site so the :content invariant (always a PathData instance) is upheld. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/shape_to_path.cljc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/path/shape_to_path.cljc b/common/src/app/common/types/path/shape_to_path.cljc index fc7a07f859..8641ee556e 100644 --- a/common/src/app/common/types/path/shape_to_path.cljc +++ b/common/src/app/common/types/path/shape_to_path.cljc @@ -168,7 +168,7 @@ child-as-paths)] (-> group (assoc :type :path) - (assoc :content content) + (assoc :content (path.impl/path-data content)) (merge head-data) (d/without-keys dissoc-attrs)))) @@ -184,7 +184,8 @@ (:bool-type shape) content - (bool/calculate-content bool-type (map :content children))] + (-> (bool/calculate-content bool-type (map :content children)) + (path.impl/path-data))] (-> shape (assoc :type :path) From db9e9f483215fa756182371fd3819b231bdd5d49 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 18:22:55 +0000 Subject: [PATCH 07/19] :bug: Ignore browser extension errors in unhandled exception handler Signed-off-by: Andrey Antukh --- frontend/src/app/main/errors.cljs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 222b86fdac..205452e8d8 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -331,9 +331,16 @@ (st/async-emit! (rt/assign-exception error))) (defonce uncaught-error-handler - (letfn [(is-ignorable-exception? [cause] + (letfn [(from-extension? [cause] + (let [stack (.-stack cause)] + (and (string? stack) + (or (str/includes? stack "chrome-extension://") + (str/includes? stack "moz-extension://"))))) + + (is-ignorable-exception? [cause] (let [message (ex-message cause)] - (or (= message "Possible side-effect in debug-evaluate") + (or (from-extension? cause) + (= message "Possible side-effect in debug-evaluate") (= message "Unexpected end of input") (str/starts-with? message "invalid props on component") (str/starts-with? message "Unexpected token ")))) @@ -341,17 +348,18 @@ (on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] - (set! last-exception cause) (when-not (is-ignorable-exception? cause) + (set! last-exception cause) (ex/print-throwable cause :prefix "Uncaught Exception") (ts/schedule #(flash :cause cause :type :unhandled))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] - (set! last-exception cause) - (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/schedule #(flash :cause cause :type :unhandled))))] + (when-not (is-ignorable-exception? cause) + (set! last-exception cause) + (ex/print-throwable cause :prefix "Uncaught Rejection") + (ts/schedule #(flash :cause cause :type :unhandled)))))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) From e6d15a5ac290449e1bb15ea7032ff4ef08906cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 10 Mar 2026 18:42:28 +0100 Subject: [PATCH 08/19] :wrench: Disable search indexing of plugin docs for non-production envs --- .github/workflows/plugins-deploy-api-doc.yml | 17 +++++++++++++++++ .github/workflows/plugins-deploy-styles-doc.yml | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index aaa1339c9e..1842a61b16 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -104,6 +104,23 @@ jobs: run: | sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml + - name: Add noindex header and robots.txt files for non-production environments + if: ${{ steps.vars.outputs.gh_ref != 'main' }} + working-directory: plugins + shell: bash + run: | + ASSETS_DIR="dist/doc" + + cat > "${ASSETS_DIR}/_headers" << 'EOF' + /* + X-Robots-Tag: noindex, nofollow + EOF + + cat > "${ASSETS_DIR}/robots.txt" << 'EOF' + User-agent: * + Disallow: / + EOF + - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 1e2b39e74d..f8e43899b8 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -102,6 +102,23 @@ jobs: run: | sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml + - name: Add noindex header and robots.txt files for non-production environments + if: ${{ steps.vars.outputs.gh_ref != 'main' }} + working-directory: plugins + shell: bash + run: | + ASSETS_DIR="dist/apps/example-styles" + + cat > "${ASSETS_DIR}/_headers" << 'EOF' + /* + X-Robots-Tag: noindex, nofollow + EOF + + cat > "${ASSETS_DIR}/robots.txt" << 'EOF' + User-agent: * + Disallow: / + EOF + - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: From 11a1ac2a09f31a52dff85e61898c8c667a0db937 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 11 Mar 2026 13:16:57 +0000 Subject: [PATCH 09/19] :bug: Fix RangeError (stack overflow) in find-component-main Refactor find-component-main to use an iterative loop/recur pattern instead of direct recursion and added cycle detection for malformed data structures. Signed-off-by: Andrey Antukh --- common/src/app/common/types/container.cljc | 46 ++++-- common/test/common_tests/runner.cljc | 2 + .../common_tests/types/container_test.cljc | 156 ++++++++++++++++++ 3 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 common/test/common_tests/types/container_test.cljc diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index fa27ce5f07..324528854b 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -212,25 +212,39 @@ (get-instance-root objects (get objects (:parent-id shape))))) (defn find-component-main - "If the shape is a component main instance or is inside one, return that instance" + "If the shape is a component main instance or is inside one, return that instance. + Uses an iterative loop with cycle detection to prevent stack overflow on circular + parent references or malformed data structures." ([objects shape] (find-component-main objects shape true)) ([objects shape only-direct-child?] - (cond - (or (nil? shape) (cfh/root? shape)) - nil - (nil? (:parent-id shape)) ; This occurs in the root of components v1 - shape - (ctk/main-instance? shape) - shape - (and only-direct-child? ;; If we are asking only for direct childs of a component-main, - (ctk/instance-head? shape)) ;; stop when we found a instance-head that isn't main-instance - nil - (and (not only-direct-child?) - (ctk/instance-root? shape)) - nil - :else - (find-component-main objects (get objects (:parent-id shape)))))) + (loop [shape shape + visited #{}] + (cond + (or (nil? shape) (cfh/root? shape)) + nil + + ;; Cycle detected: we have already visited this shape id + (contains? visited (:id shape)) + nil + + (nil? (:parent-id shape)) ; This occurs in the root of components v1 + shape + + (ctk/main-instance? shape) + shape + + (and only-direct-child? ;; If we are asking only for direct childs of a component-main, + (ctk/instance-head? shape)) ;; stop when we found a instance-head that isn't main-instance + nil + + (and (not only-direct-child?) + (ctk/instance-root? shape)) + nil + + :else + (recur (get objects (:parent-id shape)) + (conj visited (:id shape))))))) (defn inside-component-main? "Check if the shape is a component main instance or is inside one." diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 1fa9d63c14..58419240f3 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -39,6 +39,7 @@ [common-tests.time-test] [common-tests.types.absorb-assets-test] [common-tests.types.components-test] + [common-tests.types.container-test] [common-tests.types.fill-test] [common-tests.types.modifiers-test] [common-tests.types.objects-map-test] @@ -92,6 +93,7 @@ 'common-tests.time-test 'common-tests.types.absorb-assets-test 'common-tests.types.components-test + 'common-tests.types.container-test 'common-tests.types.fill-test 'common-tests.types.modifiers-test 'common-tests.types.objects-map-test diff --git a/common/test/common_tests/types/container_test.cljc b/common/test/common_tests/types/container_test.cljc new file mode 100644 index 0000000000..e4498db9b7 --- /dev/null +++ b/common/test/common_tests/types/container_test.cljc @@ -0,0 +1,156 @@ +;; 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 + +(ns common-tests.types.container-test + (:require + [app.common.types.container :as ctc] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- make-shape + "Build a realistic shape using setup-shape, so it has proper geometric + data (selrect, points, transform, …) and follows project data standards." + [id & {:as attrs}] + (cts/setup-shape (merge {:type :rect + :x 0 + :y 0 + :width 100 + :height 100} + attrs + {:id id}))) + +(defn- objects-map + "Build an objects map from a seq of shapes." + [& shapes] + (into {} (map (juxt :id identity) shapes))) + +;; The sentinel root shape (uuid/zero) recognised by cfh/root? +(def root-id uuid/zero) + +(defn- root-shape + "Create the page-root frame shape (id = uuid/zero, type :frame)." + [] + (cts/setup-shape {:id root-id + :type :frame + :x 0 + :y 0 + :width 100 + :height 100})) + +;; --------------------------------------------------------------------------- +;; Tests – base cases +;; --------------------------------------------------------------------------- + +(t/deftest find-component-main-nil-shape + (t/testing "returns nil when shape is nil" + (t/is (nil? (ctc/find-component-main {} nil))))) + +(t/deftest find-component-main-root-shape + (t/testing "returns nil when shape is the page root (uuid/zero)" + (let [root (root-shape) + objects (objects-map root)] + (t/is (nil? (ctc/find-component-main objects root)))))) + +(t/deftest find-component-main-no-parent-id + (t/testing "returns the shape itself when parent-id is nil (v1 component root)" + (let [id (uuid/next) + ;; Simulate a v1 component root: setup-shape produces a full shape, + ;; then we explicitly clear :parent-id to nil, which is how legacy + ;; component roots appear in deserialized data. + shape (assoc (make-shape id) :parent-id nil) + objects (objects-map shape)] + (t/is (= shape (ctc/find-component-main objects shape)))))) + +(t/deftest find-component-main-main-instance + (t/testing "returns the shape when it is a main-instance" + (let [parent-id (uuid/next) + id (uuid/next) + parent (make-shape parent-id) + shape (make-shape id :parent-id parent-id :main-instance true) + objects (objects-map parent shape)] + (t/is (= shape (ctc/find-component-main objects shape)))))) + +(t/deftest find-component-main-instance-head-stops-when-only-direct-child + (t/testing "returns nil when hitting an instance-head that is not main (only-direct-child? true)" + (let [parent-id (uuid/next) + id (uuid/next) + ;; instance-head? ← has :component-id but NOT :main-instance + shape (make-shape id + :parent-id parent-id + :component-id (uuid/next)) + parent (make-shape parent-id) + objects (objects-map parent shape)] + (t/is (nil? (ctc/find-component-main objects shape true)))))) + +(t/deftest find-component-main-instance-root-stops-when-not-only-direct-child + (t/testing "returns nil when hitting an instance-root and only-direct-child? is false" + (let [parent-id (uuid/next) + id (uuid/next) + ;; instance-root? ← has :component-root true + shape (make-shape id + :parent-id parent-id + :component-id (uuid/next) + :component-root true) + parent (make-shape parent-id) + objects (objects-map parent shape)] + (t/is (nil? (ctc/find-component-main objects shape false)))))) + +(t/deftest find-component-main-walks-to-main-ancestor + (t/testing "traverses ancestors and returns the first main-instance found" + (let [gp-id (uuid/next) + p-id (uuid/next) + child-id (uuid/next) + grandparent (make-shape gp-id :parent-id nil :main-instance true) + parent (make-shape p-id :parent-id gp-id) + child (make-shape child-id :parent-id p-id) + objects (objects-map grandparent parent child)] + (t/is (= grandparent (ctc/find-component-main objects child)))))) + +;; --------------------------------------------------------------------------- +;; Tests – cycle detection (the bug fix) +;; --------------------------------------------------------------------------- + +(t/deftest find-component-main-direct-self-loop + (t/testing "returns nil (no crash) when a shape's parent-id points to itself" + (let [id (uuid/next) + ;; deliberately malformed: parent-id == id (self-loop) + shape (make-shape id :parent-id id) + objects (objects-map shape)] + (t/is (nil? (ctc/find-component-main objects shape)))))) + +(t/deftest find-component-main-two-node-cycle + (t/testing "returns nil (no crash) for a two-node circular reference A→B→A" + (let [id-a (uuid/next) + id-b (uuid/next) + shape-a (make-shape id-a :parent-id id-b) + shape-b (make-shape id-b :parent-id id-a) + objects (objects-map shape-a shape-b)] + (t/is (nil? (ctc/find-component-main objects shape-a)))))) + +(t/deftest find-component-main-multi-node-cycle + (t/testing "returns nil (no crash) for a longer cycle A→B→C→A" + (let [id-a (uuid/next) + id-b (uuid/next) + id-c (uuid/next) + shape-a (make-shape id-a :parent-id id-b) + shape-b (make-shape id-b :parent-id id-c) + shape-c (make-shape id-c :parent-id id-a) + objects (objects-map shape-a shape-b shape-c)] + (t/is (nil? (ctc/find-component-main objects shape-a)))))) + +(t/deftest find-component-main-only-direct-child-with-cycle + (t/testing "cycle detection works correctly with only-direct-child? false as well" + (let [id-a (uuid/next) + id-b (uuid/next) + shape-a (make-shape id-a :parent-id id-b) + shape-b (make-shape id-b :parent-id id-a) + objects (objects-map shape-a shape-b)] + (t/is (nil? (ctc/find-component-main objects shape-a false)))))) From 6ee8184821778755a81c1a7db47adafc071629e1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 12 Mar 2026 12:23:09 +0100 Subject: [PATCH 10/19] :bug: Fix error when creating guides without frame (#8598) * :bug: Fix error when creating guides without frame The error 'Cannot read properties of undefined (reading $cljs$core$IFn$_invoke$arity$0$)' occurred when creating a new guide. It is probably a race condition because it is not reproducible from the user point of view. The cause is mainly because of use incorrect jsx handler :& where :> should be used. This caused that some props pased with incorrect casing and the relevant callback props received as nil on the component and on the use-guide hook. The fix is simple: use correct jsx handler Signed-off-by: Andrey Antukh * :lipstick: Add cosmetic changes to viewport guides components --------- Signed-off-by: Andrey Antukh --- .../main/ui/workspace/viewport/guides.cljs | 157 ++++++++++-------- 1 file changed, 89 insertions(+), 68 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index af21882f21..d542d983c9 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -25,60 +25,68 @@ [app.util.dom :as dom] [rumext.v2 :as mf])) -(def guide-width 1) -(def guide-opacity 0.7) -(def guide-opacity-hover 1) -(def guide-color colors/new-danger) -(def guide-pill-width 34) -(def guide-pill-height 20) -(def guide-pill-corner-radius 4) -(def guide-active-area 16) +(def ^:const guide-width 1) +(def ^:const guide-opacity 0.7) +(def ^:const guide-opacity-hover 1) +(def ^:const guide-color colors/new-danger) +(def ^:const guide-pill-width 34) +(def ^:const guide-pill-height 20) +(def ^:const guide-pill-corner-radius 4) +(def ^:const guide-active-area 16) -(def guide-creation-margin-left 8) -(def guide-creation-margin-top 28) -(def guide-creation-width 16) -(def guide-creation-height 24) +(def ^:const guide-creation-margin-left 8) +(def ^:const guide-creation-margin-top 28) +(def ^:const guide-creation-width 16) +(def ^:const guide-creation-height 24) (defn use-guide "Hooks to support drag/drop for existing guides and new guides" [on-guide-change get-hover-frame zoom {:keys [id position axis frame-id]}] - (let [dragging-ref (mf/use-ref false) - start-ref (mf/use-ref nil) + (let [dragging-ref (mf/use-ref false) + start-ref (mf/use-ref nil) start-pos-ref (mf/use-ref nil) - state (mf/use-state {:hover false + state (mf/use-state + #(do {:hover false :new-position nil - :new-frame-id frame-id}) + :new-frame-id frame-id})) - frame-id (:new-frame-id @state) + frame-id + (:new-frame-id @state) - frame-ref (mf/use-memo (mf/deps frame-id) #(refs/object-by-id frame-id)) - frame (mf/deref frame-ref) + frame-ref + (mf/with-memo [frame-id] + (refs/object-by-id frame-id)) - snap-pixel? (mf/deref refs/snap-pixel?) + frame + (mf/deref frame-ref) - workspace-read-only? (mf/use-ctx ctx/workspace-read-only?) + snap-pixel? + (mf/deref refs/snap-pixel?) + + read-only? + (mf/use-ctx ctx/workspace-read-only?) on-pointer-enter - (mf/use-callback - (mf/deps workspace-read-only?) + (mf/use-fn + (mf/deps read-only?) (fn [] - (when-not workspace-read-only? + (when-not read-only? (st/emit! (dw/set-hover-guide id true)) (swap! state assoc :hover true)))) on-pointer-leave - (mf/use-callback - (mf/deps workspace-read-only?) + (mf/use-fn + (mf/deps read-only?) (fn [] - (when-not workspace-read-only? + (when-not read-only? (st/emit! (dw/set-hover-guide id false)) (swap! state assoc :hover false)))) on-pointer-down - (mf/use-callback - (mf/deps workspace-read-only?) + (mf/use-fn + (mf/deps read-only?) (fn [event] - (when-not workspace-read-only? + (when-not read-only? (when (= 0 (.-button event)) (dom/capture-pointer event) (mf/set-ref-val! dragging-ref true) @@ -86,20 +94,20 @@ (mf/set-ref-val! start-pos-ref (get @ms/mouse-position axis)))))) on-pointer-up - (mf/use-callback - (mf/deps (select-keys @state [:new-position :new-frame-id]) on-guide-change workspace-read-only?) + (mf/use-fn + (mf/deps (select-keys @state [:new-position :new-frame-id]) on-guide-change read-only?) (fn [] - (when-not workspace-read-only? + (when-not read-only? (when (some? on-guide-change) (when (some? (:new-position @state)) (on-guide-change {:position (:new-position @state) :frame-id (:new-frame-id @state)})))))) on-lost-pointer-capture - (mf/use-callback - (mf/deps workspace-read-only?) + (mf/use-fn + (mf/deps read-only?) (fn [event] - (when-not workspace-read-only? + (when-not read-only? (dom/release-pointer event) (mf/set-ref-val! dragging-ref false) (mf/set-ref-val! start-ref nil) @@ -107,27 +115,29 @@ (swap! state assoc :new-position nil)))) on-pointer-move - (mf/use-callback - (mf/deps position zoom snap-pixel? workspace-read-only?) + (mf/use-fn + (mf/deps position zoom snap-pixel? read-only? get-hover-frame) (fn [event] - (when-not workspace-read-only? - (when-let [_ (mf/ref-val dragging-ref)] - (let [start-pt (mf/ref-val start-ref) - start-pos (mf/ref-val start-pos-ref) - current-pt (dom/get-client-position event) - delta (/ (- (get current-pt axis) (get start-pt axis)) zoom) + (when-not read-only? + (when (mf/ref-val dragging-ref) + (let [start-pt (mf/ref-val start-ref) + start-pos (mf/ref-val start-pos-ref) + current-pt (dom/get-client-position event) + delta (/ (- (get current-pt axis) (get start-pt axis)) zoom) new-position (if (some? position) (+ position delta) (+ start-pos delta)) - new-position (if snap-pixel? (mth/round new-position) new-position) - new-frame-id (:id (get-hover-frame))] + new-frame-id (-> (get-hover-frame) + (get :id))] + (swap! state assoc :new-position new-position :new-frame-id new-frame-id))))))] + {:on-pointer-enter on-pointer-enter :on-pointer-leave on-pointer-leave :on-pointer-down on-pointer-down @@ -137,8 +147,8 @@ :state state :frame frame})) -;; This functions are auxiliary to get the coords of components depending on the axis -;; we're handling +;; This functions are auxiliary to get the coords of components +;; depending on the axis we're handling (defn guide-area-axis [pos vbox zoom frame axis] @@ -270,11 +280,11 @@ (<= (:position guide) (+ (:y frame) (:height frame)))))) (mf/defc guide* - {::mf/wrap [mf/memo] - ::mf/props :obj} + {::mf/wrap [mf/memo]} [{:keys [guide is-hover on-guide-change get-hover-frame vbox zoom hover-frame disabled-guides frame-modifier frame-transform]}] - (let [axis (:axis guide) + (let [axis + (get guide :axis) handle-change-position (mf/use-fn @@ -290,9 +300,11 @@ on-lost-pointer-capture on-pointer-move state - frame]} (use-guide handle-change-position get-hover-frame zoom guide) + frame]} + (use-guide handle-change-position get-hover-frame zoom guide) - base-frame (or frame hover-frame) + base-frame + (or frame hover-frame) frame (cond-> base-frame @@ -302,12 +314,18 @@ (some? frame-transform) (gsh/apply-transform frame-transform)) - move-vec (gpt/to-vec (gpt/point (:x base-frame) (:y base-frame)) - (gpt/point (:x frame) (:y frame))) + move-vec + (gpt/to-vec (gpt/point (:x base-frame) (:y base-frame)) + (gpt/point (:x frame) (:y frame))) - pos (+ (or (:new-position @state) (:position guide)) (get move-vec axis)) - guide-width (/ guide-width zoom) - guide-pill-corner-radius (/ guide-pill-corner-radius zoom) + pos + (+ (or (:new-position @state) (:position guide)) (get move-vec axis)) + + guide-width + (/ guide-width zoom) + + guide-pill-corner-radius + (/ guide-pill-corner-radius zoom) frame-guide-outside? (and (some? frame) @@ -404,9 +422,7 @@ (fmt/format-number (- pos (if (= axis :x) (:x frame) (:y frame))))]]))]))) (mf/defc new-guide-area* - {::mf/props :obj} [{:keys [vbox zoom axis get-hover-frame disabled-guides]}] - (let [on-guide-change (mf/use-fn (mf/deps vbox) @@ -426,7 +442,9 @@ state frame]} (use-guide on-guide-change get-hover-frame zoom {:axis axis}) - workspace-read-only? (mf/use-ctx ctx/workspace-read-only?)] + + read-only? + (mf/use-ctx ctx/workspace-read-only?)] [:g.new-guides (when-not disabled-guides @@ -441,13 +459,15 @@ :on-pointer-up on-pointer-up :on-lost-pointer-capture on-lost-pointer-capture :on-pointer-move on-pointer-move - :class (when-not workspace-read-only? - (if (= axis :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ns" 0))) + :class (when-not read-only? + (if (= axis :x) + (cur/get-dynamic "resize-ew" 0) + (cur/get-dynamic "resize-ns" 0))) :style {:fill "none" :pointer-events "fill"}}])) (when (:new-position @state) - [:& guide* {:guide {:axis axis :position (:new-position @state)} + [:> guide* {:guide {:axis axis :position (:new-position @state)} :get-hover-frame get-hover-frame :vbox vbox :zoom zoom @@ -455,17 +475,18 @@ :hover-frame frame}])])) (mf/defc viewport-guides* - {::mf/wrap [mf/memo] - ::mf/props :obj} + {::mf/wrap [mf/memo]} [{:keys [zoom vbox hover-frame disabled-guides modifiers guides]}] (let [guides (mf/with-memo [guides vbox] (->> (vals guides) (filter (partial guide-inside-vbox? zoom vbox)))) - focus (mf/deref refs/workspace-focus-selected) + focus + (mf/deref refs/workspace-focus-selected) - hover-frame-ref (mf/use-ref nil) + hover-frame-ref + (mf/use-ref nil) ;; We use the ref to not redraw every guide everytime the hovering frame change ;; we're only interested to get the frame in the guide we're moving From 82e3a5fa53ee2a241c29ac8e7799a896103405c0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 12:33:02 +0000 Subject: [PATCH 11/19] :bug: Fix 'not ISeqable' error when entering float values in layout/opacity inputs Replace int? with number? in on-change handlers for layout item margins, min/max sizes, and layer opacity. Using int? caused float values like 8.5 to fall into the design token branch, calling (first 8.5) and crashing. Signed-off-by: Andrey Antukh --- CHANGES.md | 1 + .../app/main/ui/workspace/sidebar/options/menus/layer.cljs | 2 +- .../ui/workspace/sidebar/options/menus/layout_item.cljs | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f223538233..d4fae89f5e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -37,6 +37,7 @@ - Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) - Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463) - Fix warning when clicking on number token pills [Taiga #13661](https://tree.taiga.io/project/penpot/issue/13661) +- Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569) ## 2.13.3 diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs index f0b549dcac..3e5cfe9921 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs @@ -140,7 +140,7 @@ (mf/use-fn (mf/deps on-change handle-opacity-change) (fn [value] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (handle-opacity-change value) (do (st/emit! (dwta/toggle-token {:token (first value) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index dd6c661030..ca3e53ee1a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -117,7 +117,7 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (on-change :simple attr value) (do (st/emit! @@ -247,7 +247,7 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (on-change :multiple attr value) (do (st/emit! @@ -577,7 +577,7 @@ (mf/use-fn (mf/deps ids) (fn [value attr] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (st/emit! (dwsl/update-layout-child ids {attr value})) (do (st/emit! From b68e400cc17ff6859da5b993b442e9b41a73562d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 12 Mar 2026 13:06:25 +0100 Subject: [PATCH 12/19] :bug: Fix crash in select* when options vector is empty (#8578) Guard get-option fallback with (when (seq options) ...) to avoid "No item 0 in vector of length 0" when options is an empty vector. Also guard the selected-option memo in select* to mirror the same pattern already present in combobox*. Signed-off-by: Andrey Antukh --- CHANGES.md | 1 + frontend/src/app/main/ui/ds/controls/select.cljs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d4fae89f5e..efbcd5b866 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -38,6 +38,7 @@ - Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463) - Fix warning when clicking on number token pills [Taiga #13661](https://tree.taiga.io/project/penpot/issue/13661) - Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569) +- Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578) ## 2.13.3 diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index 32676bcd36..d40d7275b8 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -22,7 +22,8 @@ [options id] (let [options (if (delay? options) @options options)] (or (d/seek #(= id (get % :id)) options) - (nth options 0)))) + (when (seq options) + (nth options 0))))) (defn- get-selected-option-id [options default] @@ -178,7 +179,8 @@ selected-option (mf/with-memo [options selected-id] - (get-option options selected-id)) + (when (d/not-empty? options) + (get-option options selected-id))) label (get selected-option :label) From 80d165ed5b5efe80d993992432f3f51cd8f493bf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 11 Mar 2026 12:08:11 +0000 Subject: [PATCH 13/19] :bug: Fix unhandled AbortError in HTTP fetch requests Identify and silence "signal is aborted without reason" errors by: - Providing an explicit reason to AbortController when subscriptions are disposed. - Updating the global error handler to ignore AbortError exceptions. - Ensuring unhandled rejections use the ignorable exception filter. The root cause was RxJS disposal calling .abort() without a reason, combined with the on-unhandled-rejection handler missing the ignorable error filter. Signed-off-by: Andrey Antukh --- frontend/src/app/main/errors.cljs | 7 ++++++- frontend/src/app/util/http.cljs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 205452e8d8..fe5a82518e 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -343,7 +343,12 @@ (= message "Possible side-effect in debug-evaluate") (= message "Unexpected end of input") (str/starts-with? message "invalid props on component") - (str/starts-with? message "Unexpected token ")))) + (str/starts-with? message "Unexpected token ") + ;; Abort errors are expected when an in-flight HTTP request is + ;; cancelled (e.g. via RxJS unsubscription / take-until). They + ;; are handled gracefully inside app.util.http/fetch and must + ;; NOT be surfaced as application errors. + (= (.-name ^js cause) "AbortError")))) (on-unhandled-error [event] (.preventDefault ^js event) diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 0fd9be60b7..a25f030d3f 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -123,10 +123,15 @@ (/ current-time (inc count))) count (inc count)] (swap! network-averages assoc (:path uri) {:count count :average average}))))) + (fn [] (vreset! unsubscribed? true) (when @abortable? - (.abort ^js controller))))))) + ;; Provide an explicit reason so that the resulting AbortError carries + ;; a meaningful message instead of the browser default + ;; "signal is aborted without reason". + (.abort ^js controller (ex-info (str "fetch to '" uri "' is aborted") + {:uri uri})))))))) (defn response->map [response] From 8f5c38d47661c95af2d79278a7f01acc1e7fb65d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 12 Mar 2026 13:36:38 +0100 Subject: [PATCH 14/19] :bug: Fix scroll on colorpicker (#8595) --- CHANGES.md | 1 + frontend/src/app/main/ui/ds/layout/tab_switcher.cljs | 8 +++++--- frontend/src/app/main/ui/ds/layout/tab_switcher.scss | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index efbcd5b866..90ac9c8962 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -39,6 +39,7 @@ - Fix warning when clicking on number token pills [Taiga #13661](https://tree.taiga.io/project/penpot/issue/13661) - Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569) - Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578) +- Fix scroll on colorpicker [Taiga #13623](https://tree.taiga.io/project/penpot/issue/13623) ## 2.13.3 diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs index 9034d27e79..0d1e427c4f 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs @@ -103,6 +103,7 @@ [:map [:tabs [:vector {:min 1} schema:tab]] [:class {:optional true} :string] + [:scrollable-panel {:optional true} :boolean] [:on-change fn?] [:selected :string] [:action-button {:optional true} some?] @@ -111,15 +112,15 @@ (mf/defc tab-switcher* {::mf/schema schema:tab-switcher} - [{:keys [tabs class on-change selected action-button-position action-button children] :rest props}] + [{:keys [tabs class on-change selected action-button-position action-button children scrollable-panel] :rest props}] (let [nodes-ref (mf/use-ref nil) + scrollable-panel (d/nilv scrollable-panel false) tabs (if (array? tabs) (mfu/bean tabs) tabs) - on-click (mf/use-fn (mf/deps on-change) @@ -186,7 +187,8 @@ :on-key-down on-key-down :on-click on-click}]] - [:section {:class (stl/css :tab-panel) + [:section {:class (stl/css-case :tab-panel true + :scrollable-panel scrollable-panel) :tab-index 0 :role "tabpanel" :aria-labelledby selected} diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index 56ecfe27f0..90af8cf778 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -114,5 +114,8 @@ width: 100%; height: 100%; outline: $b-1 solid var(--tab-panel-outline-color); +} + +.scrollable-panel { overflow-y: auto; } From 8d5450391e39d1b02b09f397b7d9e6c55d0215ab Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 17:28:10 +0000 Subject: [PATCH 15/19] :bug: Fix crash when pasting non-map transit clipboard data Guard against transit-decoded clipboard content that is not a map before calling assoc, which caused a runtime crash ('No protocol method IAssociative.-assoc defined for type number'). Also route :copied-props paste data to paste-transit-props instead of incorrectly sending it to paste-transit-shapes. Signed-off-by: Andrey Antukh --- CHANGES.md | 1 + .../app/main/data/workspace/clipboard.cljs | 48 +++++++++++-------- frontend/src/app/util/clipboard.js | 14 +++++- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 90ac9c8962..a55bcfed02 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -40,6 +40,7 @@ - Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569) - Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578) - Fix scroll on colorpicker [Taiga #13623](https://tree.taiga.io/project/penpot/issue/13623) +- Fix crash when pasting non-map transit clipboard data [Github #8580](https://github.com/penpot/penpot/pull/8580) ## 2.13.3 diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 2692eef103..4bd8190895 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -258,33 +258,39 @@ #js {:decodeTransit t/decode-str :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")}) -(defn create-paste-from-blob +(defn- create-paste-from-blob [in-viewport?] (fn [blob] - (let [type (.-type blob) - result (cond - (= type "image/svg+xml") - (->> (rx/from (.text blob)) - (rx/map paste-svg-text)) + (let [type (.-type blob)] + (cond + (= type "image/svg+xml") + (->> (rx/from (.text blob)) + (rx/map paste-svg-text)) - (some #(= type %) clipboard/image-types) - (rx/of (paste-image blob)) + (some #(= type %) clipboard/image-types) + (rx/of (paste-image blob)) - (= type "text/html") - (->> (rx/from (.text blob)) - (rx/map paste-html-text)) + (= type "text/html") + (->> (rx/from (.text blob)) + (rx/map paste-html-text)) - (= type "application/transit+json") - (->> (rx/from (.text blob)) - (rx/map (fn [text] - (let [transit-data (t/decode-str text)] - (assoc transit-data :in-viewport in-viewport?)))) - (rx/map paste-transit-shapes)) + (= type "application/transit+json") + (->> (rx/from (.text blob)) + (rx/map t/decode-str) + (rx/filter map?) + (rx/map + (fn [pdata] + (assoc pdata :in-viewport in-viewport?))) + (rx/mapcat + (fn [pdata] + (case (:type pdata) + :copied-props (rx/of (paste-transit-props pdata)) + :copied-shapes (rx/of (paste-transit-shapes pdata)) + (rx/empty))))) - :else - (->> (rx/from (.text blob)) - (rx/map paste-text)))] - result))) + :else + (->> (rx/from (.text blob)) + (rx/map paste-text)))))) (def default-paste-from-blob (create-paste-from-blob false)) diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js index 0322829e41..92d8a0c2f5 100644 --- a/frontend/src/app/util/clipboard.js +++ b/frontend/src/app/util/clipboard.js @@ -30,6 +30,15 @@ const exclusiveTypes = [ * @property {boolean} [allowHTMLPaste] */ +const looksLikeJSON = (str) => { + if (typeof str !== 'string') return false; + const trimmed = str.trim(); + return ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ); +}; + /** * * @param {string} text @@ -39,13 +48,14 @@ const exclusiveTypes = [ */ function parseText(text, options) { options = options || {}; + const decodeTransit = options["decodeTransit"]; - if (decodeTransit) { + if (decodeTransit && looksLikeJSON(text)) { try { decodeTransit(text); return new Blob([text], { type: "application/transit+json" }); } catch (_error) { - // NOOP + return new Blob([text], { type: "text/plain" }); } } From 25df9f2f83eed10fea49c146e7e2cbef91c47e45 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 12 Mar 2026 13:43:21 +0000 Subject: [PATCH 16/19] :bug: Fix DataCloneError in plugin postMessage communication Fixes a crash where plugins sending messages via 'penpot.ui.sendMessage()' could fail if their message payload contained non-serializable values like functions or closures. The fix adds validation using 'structuredClone()' to catch these messages early with a helpful error message, and adds a defensive try/catch in the modal's message handler as a safety net. Fixes the error: 'Failed to execute postMessage on Window: ... could not be cloned.' Signed-off-by: Andrey Antukh --- plugins/libs/plugins-runtime/src/lib/api/index.ts | 15 ++++++++++++++- .../plugins-runtime/src/lib/modal/plugin-modal.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 6d452d2ddc..b24c2328f3 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -68,8 +68,21 @@ export function createApi( }, sendMessage(message: unknown) { + let cloneableMessage: unknown; + + try { + cloneableMessage = structuredClone(message); + } catch (err) { + console.error( + 'plugin sendMessage: the message could not be cloned. ' + + 'Ensure the message does not contain functions, DOM nodes, or other non-serializable values.', + err, + ); + return; + } + const event = new CustomEvent('message', { - detail: message, + detail: cloneableMessage, }); plugin.getModal()?.dispatchEvent(event); diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index b9a9183c8b..3346175212 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -129,7 +129,14 @@ export class PluginModalElement extends HTMLElement { return; } - iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); + try { + iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); + } catch (err) { + console.error( + 'plugin modal: failed to send message to iframe via postMessage.', + err, + ); + } }); this.shadowRoot.appendChild(this.wrapper); From e7e6303184c9b13cbb8850908f66e28ef7ed91de Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 12 Mar 2026 19:55:51 +0100 Subject: [PATCH 17/19] :bug: Make ct/format-inst nil safe (#8612) Prevent JS TypeError when date is nil in date formatting. Signed-off-by: Andrey Antukh --- common/src/app/common/time.cljc | 53 ++++++++++--------- .../src/app/main/data/workspace/versions.cljs | 18 +++---- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index fb5a5d1d1b..755151bcfc 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -204,40 +204,41 @@ (defn format-inst ([v] (format-inst v :iso)) ([v fmt] - (case fmt - (:iso :iso8601) - #?(:clj (.format DateTimeFormatter/ISO_INSTANT ^Instant v) - :cljs (dfn-format-iso v)) + (when (some? v) + (case fmt + (:iso :iso8601) + #?(:clj (.format DateTimeFormatter/ISO_INSTANT ^Instant v) + :cljs (dfn-format-iso v)) - :iso-date - #?(:clj (.format DateTimeFormatter/ISO_LOCAL_DATE - ^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC"))) - :cljs (dfn-format-iso v #js {:representation "date"})) + :iso-date + #?(:clj (.format DateTimeFormatter/ISO_LOCAL_DATE + ^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC"))) + :cljs (dfn-format-iso v #js {:representation "date"})) - (:rfc1123 :http) - #?(:clj (.format DateTimeFormatter/RFC_1123_DATE_TIME - ^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC"))) - :cljs (dfn-format v "EEE, dd LLL yyyy HH:mm:ss 'GMT'")) + (:rfc1123 :http) + #?(:clj (.format DateTimeFormatter/RFC_1123_DATE_TIME + ^ZonedDateTime (ZonedDateTime/ofInstant v (ZoneId/of "UTC"))) + :cljs (dfn-format v "EEE, dd LLL yyyy HH:mm:ss 'GMT'")) - #?@(:cljs [:time-24-simple - (dfn-format v "HH:mm") + #?@(:cljs [:time-24-simple + (dfn-format v "HH:mm") - ;; DEPRECATED - :date-full - (dfn-format v "PPP") + ;; DEPRECATED + :date-full + (dfn-format v "PPP") - :localized-date - (dfn-format v "PPP") + :localized-date + (dfn-format v "PPP") - :localized-time - (dfn-format v "p") + :localized-time + (dfn-format v "p") - :localized-date-time - (dfn-format v "PPP . p") + :localized-date-time + (dfn-format v "PPP . p") - (if (string? fmt) - (dfn-format v fmt) - (throw (js/Error. "unpexted format")))])))) + (if (string? fmt) + (dfn-format v fmt) + (throw (js/Error. "unpexted format")))]))))) #?(:cljs (def locales diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index dd46c3cf16..58e1e52f62 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -137,16 +137,16 @@ (ptk/reify ::pin-version ptk/WatchEvent (watch [_ state _] - (let [version (->> (dm/get-in state [:workspace-versions :data]) - (d/seek #(= (:id %) id))) - params {:id id - :label (ct/format-inst (:created-at version) :localized-date)}] + (when-let [version (->> (dm/get-in state [:workspace-versions :data]) + (d/seek #(= (:id %) id)))] + (let [params {:id id + :label (ct/format-inst (:created-at version) :localized-date)}] - (->> (rp/cmd! :update-file-snapshot params) - (rx/mapcat (fn [_] - (rx/of (update-versions-state {:editing id}) - (fetch-versions) - (ptk/event ::ev/event {::ev/name "pin-version"}))))))))) + (->> (rp/cmd! :update-file-snapshot params) + (rx/mapcat (fn [_] + (rx/of (update-versions-state {:editing id}) + (fetch-versions) + (ptk/event ::ev/event {::ev/name "pin-version"})))))))))) (defn lock-version [id] From eecb51ecc121f2a487c5dd3b9b7a036dee5b2090 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 13 Mar 2026 08:24:36 +0100 Subject: [PATCH 18/19] :bug: Fix clipboard getType error when no allowed types found (#8609) When clipboard items have types that don't match the allowed types list, the filtering results in an empty array. Calling getType with undefined throws a NotFoundError. This change adds a check for null/undefined types and filters them from the result. Signed-off-by: Andrey Antukh --- frontend/src/app/util/clipboard.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js index 0322829e41..742183d0cb 100644 --- a/frontend/src/app/util/clipboard.js +++ b/frontend/src/app/util/clipboard.js @@ -135,7 +135,7 @@ function sortItems(a, b) { export async function fromNavigator(options) { options = options || {}; const items = await navigator.clipboard.read(); - return Promise.all( + const result = await Promise.all( Array.from(items).map(async (item) => { const itemAllowedTypes = Array.from(item.types) .filter(filterAllowedTypes(options)) @@ -155,9 +155,15 @@ export async function fromNavigator(options) { } const type = itemAllowedTypes.at(0); + + if (type == null) { + return null; + } + return item.getType(type); }), ); + return result.filter((item) => !!item); } /** From 33c5f82c43fbe9da901490d1aeda6182e189b09a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 11:56:22 +0000 Subject: [PATCH 19/19] :bug: Fix penpot.openPage() to navigate in same tab by default - Change the default for the newWindow param from true to false, so openPage() navigates in the same tab instead of opening a new one - Accept a UUID string as the page argument in addition to a Page object, avoiding the need to call penpot.getPage(uuid) first - Add validation error when an invalid page argument is passed Signed-off-by: Andrey Antukh --- CHANGES.md | 1 + frontend/src/app/plugins/api.cljs | 11 ++++++++--- frontend/src/app/plugins/page.cljs | 2 +- plugins/libs/plugin-types/index.d.ts | 6 +++--- plugins/libs/plugins-runtime/src/lib/api/index.ts | 4 ++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a55bcfed02..6d52b2b01f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -41,6 +41,7 @@ - Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578) - Fix scroll on colorpicker [Taiga #13623](https://tree.taiga.io/project/penpot/issue/13623) - Fix crash when pasting non-map transit clipboard data [Github #8580](https://github.com/penpot/penpot/pull/8580) +- Fix `penpot.openPage()` plugin API not navigating in the same tab; change default to same-tab navigation and allow passing a UUID string instead of a Page object [Github #8520](https://github.com/penpot/penpot/issues/8520) ## 2.13.3 diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 14effac100..febb24e4ba 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -540,9 +540,14 @@ :openPage (fn [page new-window] - (let [id (obj/get page "$id") - new-window (if (boolean? new-window) new-window true)] - (st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window)))) + (let [id (cond + (page/page-proxy? page) (obj/get page "$id") + (string? page) (uuid/parse* page) + :else nil) + new-window (if (boolean? new-window) new-window false)] + (if (nil? id) + (u/display-not-valid :openPage "Expected a Page object or a page UUID string") + (st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window))))) :alignHorizontal (fn [shapes direction] diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index e6c9f95bab..b0302a1939 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -269,7 +269,7 @@ (u/display-not-valid :openPage "Plugin doesn't have 'content:read' permission") :else - (let [new-window (if (boolean? new-window) new-window true)] + (let [new-window (if (boolean? new-window) new-window false)] (st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window))))) :createFlow diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 75861797bf..f1b1595aec 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1256,15 +1256,15 @@ export interface Context { /** * Changes the current open page to given page. Requires `content:read` permission. - * @param page the page to open - * @param newWindow if true opens the page in a new window + * @param page the page to open (a Page object or a page UUID string) + * @param newWindow if true opens the page in a new window, defaults to false * * @example * ```js * context.openPage(page); * ``` */ - openPage(page: Page, newWindow?: boolean): void; + openPage(page: Page | string, newWindow?: boolean): void; /** * Aligning will move all the selected layers to a position relative to one diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index b24c2328f3..1ecbbafb59 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -336,9 +336,9 @@ export function createApi( return plugin.context.createPage(); }, - openPage(page: Page, newWindow?: boolean): void { + openPage(page: Page | string, newWindow?: boolean): void { checkPermission('content:read'); - plugin.context.openPage(page, newWindow ?? true); + plugin.context.openPage(page, newWindow ?? false); }, alignHorizontal(