From 455c7f5cae1a0d9e0b47a4bb23b5de62881c52a1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 08:48:00 +0200 Subject: [PATCH 01/11] :books: Update serena doc related to creating issues --- .serena/memories/workflow/creating-issues.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 9f3831b165..9aff0b50c1 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`. | Field | Rule | |-------|------| -| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) | +| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) | | **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. | | **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. | | **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. | @@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/` | Docs | `IT_kwDOAcyBPM4B_IQz` | **Map:** -- `bug` label → Bug -- `enhancement` label → Enhancement +- Bug report (steps to reproduce, expected vs. actual) → Bug +- Enhancement / new feature → Enhancement - Feature/epic → Feature - Docs → Docs - None of the above → Task From 8fcd8a63b46fe67ccd31466799fa5c1225a59241 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 09:25:19 +0200 Subject: [PATCH 02/11] :arrow_up: Update opencode dependency on devenv --- docker/devenv/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 1b854a48cb..1fd9a7b382 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.17.13 +ENV OPENCODE_VERSION=1.17.16 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ From 8e9df0c5153a8cd9fa83192d87cd72fcfb6b72dc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 09:25:59 +0200 Subject: [PATCH 03/11] :sparkles: Add insert-multi chunked variant to app.db --- backend/src/app/db.clj | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index c4e432b225..f10b0089a0 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -331,7 +331,10 @@ This expands to a single SQL statement with placeholders for every value being inserted. For large data sets, this may exceed the limit - of sql string size and/or number of parameters." + of sql string size and/or number of parameters. + + See `insert-many-chunked!` for a safe alternative that automatically + partitions rows to stay within the parameter limit." [ds table cols rows & {:as opts}] (let [conn (get-connectable ds) sql (sql/insert-many table cols rows opts) @@ -341,6 +344,24 @@ opts (update opts :return-keys boolean)] (jdbc/execute! conn sql opts))) +(def ^:private default-max-params + "PostgreSQL PreparedStatement parameter limit." + 65535) + +(defn insert-many-chunked! + "Like `insert-many!` but partitions rows into chunks that stay within + PostgreSQL's 65,535 PreparedStatement parameter limit. + + The chunk size is computed as `floor(max-params / num-columns)`, + so callers do not need to calculate it. All chunks execute within + the same transaction when called inside `tx-run!`." + [ds table cols rows & {:keys [max-params] :as opts + :or {max-params default-max-params}}] + (let [chunk-size (quot max-params (count cols)) + opts (dissoc opts :max-params)] + (doseq [chunk (partition-all chunk-size rows)] + (apply insert-many! ds table cols chunk (mapcat identity opts))))) + (defn update! "A helper that build an UPDATE SQL statement and executes it. From f2460eee2957383d527fbed684cc890714dba6b7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 10:59:40 +0200 Subject: [PATCH 04/11] :bug: Fix Draft.js selection offset DOMException on text editor re-render (#10608) Clamp selection offset to node length in setDraftEditorSelection to prevent DOMException when Draft.js SelectionState offset exceeds the actual DOM text node length. This can happen during spellcheck, IME composition, or race conditions during React re-renders. Updates @penpot/draft-js to commit 09a33e0a which includes the fix in addPointToSelection and addFocusToSelection. Fixes #10607 AI-assisted-by: mimo-v2.5-pro --- frontend/packages/draft-js/package.json | 2 +- frontend/packages/draft-js/pnpm-lock.yaml | 449 ---------------------- frontend/pnpm-lock.yaml | 10 +- package.json | 2 +- 4 files changed, 7 insertions(+), 456 deletions(-) delete mode 100644 frontend/packages/draft-js/pnpm-lock.yaml diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 8ad22449d7..ec153b2eac 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -8,7 +8,7 @@ "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { - "draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0", + "draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35", "immutable": "^5.1.9" }, "peerDependencies": { diff --git a/frontend/packages/draft-js/pnpm-lock.yaml b/frontend/packages/draft-js/pnpm-lock.yaml deleted file mode 100644 index 6efab568fd..0000000000 --- a/frontend/packages/draft-js/pnpm-lock.yaml +++ /dev/null @@ -1,449 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - draft-js: - specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 - version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - immutable: - specifier: ^5.1.4 - version: 5.1.4 - react: - specifier: '>=0.17.0' - version: 19.2.3 - react-dom: - specifier: '>=0.17.0' - version: 19.2.3(react@19.2.3) - devDependencies: - esbuild: - specifier: ^0.27.2 - version: 0.27.2 - -packages: - - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - cross-fetch@3.2.0: - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} - - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: - resolution: {tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} - version: 0.11.7 - peerDependencies: - react: '>=0.14.0' - react-dom: '>=0.14.0' - - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} - hasBin: true - - fbjs-css-vars@1.0.2: - resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} - - fbjs@3.0.5: - resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} - - immutable@3.7.6: - resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} - engines: {node: '>=0.8.0'} - - immutable@5.1.4: - resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} - peerDependencies: - react: ^19.2.3 - - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} - engines: {node: '>=0.10.0'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - ua-parser-js@1.0.41: - resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} - hasBin: true - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - -snapshots: - - '@esbuild/aix-ppc64@0.27.2': - optional: true - - '@esbuild/android-arm64@0.27.2': - optional: true - - '@esbuild/android-arm@0.27.2': - optional: true - - '@esbuild/android-x64@0.27.2': - optional: true - - '@esbuild/darwin-arm64@0.27.2': - optional: true - - '@esbuild/darwin-x64@0.27.2': - optional: true - - '@esbuild/freebsd-arm64@0.27.2': - optional: true - - '@esbuild/freebsd-x64@0.27.2': - optional: true - - '@esbuild/linux-arm64@0.27.2': - optional: true - - '@esbuild/linux-arm@0.27.2': - optional: true - - '@esbuild/linux-ia32@0.27.2': - optional: true - - '@esbuild/linux-loong64@0.27.2': - optional: true - - '@esbuild/linux-mips64el@0.27.2': - optional: true - - '@esbuild/linux-ppc64@0.27.2': - optional: true - - '@esbuild/linux-riscv64@0.27.2': - optional: true - - '@esbuild/linux-s390x@0.27.2': - optional: true - - '@esbuild/linux-x64@0.27.2': - optional: true - - '@esbuild/netbsd-arm64@0.27.2': - optional: true - - '@esbuild/netbsd-x64@0.27.2': - optional: true - - '@esbuild/openbsd-arm64@0.27.2': - optional: true - - '@esbuild/openbsd-x64@0.27.2': - optional: true - - '@esbuild/openharmony-arm64@0.27.2': - optional: true - - '@esbuild/sunos-x64@0.27.2': - optional: true - - '@esbuild/win32-arm64@0.27.2': - optional: true - - '@esbuild/win32-ia32@0.27.2': - optional: true - - '@esbuild/win32-x64@0.27.2': - optional: true - - asap@2.0.6: {} - - cross-fetch@3.2.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - fbjs: 3.0.5 - immutable: 3.7.6 - object-assign: 4.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - transitivePeerDependencies: - - encoding - - esbuild@0.27.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - - fbjs-css-vars@1.0.2: {} - - fbjs@3.0.5: - dependencies: - cross-fetch: 3.2.0 - fbjs-css-vars: 1.0.2 - loose-envify: 1.4.0 - object-assign: 4.1.1 - promise: 7.3.1 - setimmediate: 1.0.5 - ua-parser-js: 1.0.41 - transitivePeerDependencies: - - encoding - - immutable@3.7.6: {} - - immutable@5.1.4: {} - - js-tokens@4.0.0: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - - object-assign@4.1.1: {} - - promise@7.3.1: - dependencies: - asap: 2.0.6 - - react-dom@19.2.3(react@19.2.3): - dependencies: - react: 19.2.3 - scheduler: 0.27.0 - - react@19.2.3: {} - - scheduler@0.27.0: {} - - setimmediate@1.0.5: {} - - tr46@0.0.3: {} - - ua-parser-js@1.0.41: {} - - webidl-conversions@3.0.1: {} - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c11f5736a6..e7ee6eda1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -263,8 +263,8 @@ importers: packages/draft-js: dependencies: draft-js: - specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 - version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35 + version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) immutable: specifier: ^5.1.9 version: 5.1.9 @@ -2886,8 +2886,8 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: - resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35: + resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35} version: 0.11.7 peerDependencies: react: '>=0.14.0' @@ -8117,7 +8117,7 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: fbjs: 3.0.5(encoding@0.1.13) immutable: 3.8.3 diff --git a/package.json b/package.json index 77800eaca6..1454dbd2bc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" From 41ebb8e80b0d1f608da01d9c8ffe2351634771f8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 11:00:18 +0200 Subject: [PATCH 05/11] :bug: Fix crash when converting SVG-raw shape to path (#10613) Guard the content-to-PathData coercion on whether stp/convert-to-path actually produced a new value, so SVG-raw shapes (whose :content is a hiccup map) pass through unchanged instead of crashing. Closes #10612 AI-assisted-by: deepseek-v4-pro --- common/src/app/common/types/path.cljc | 6 ++++-- common/test/common_tests/types/path_data_test.cljc | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 60e736914b..2b1188682f 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -392,7 +392,9 @@ ([shape] (convert-to-path shape {})) ([shape objects] - (-> (stp/convert-to-path shape objects) - (update :content impl/path-data)))) + (let [shape' (stp/convert-to-path shape objects)] + (if (identical? shape shape') + shape' + (update shape' :content impl/path-data))))) (dm/export impl/decode-segments) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index f56d61a539..714270cb62 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1370,6 +1370,15 @@ ;; A path shape stays a path shape unchanged (t/is (= :path (:type result))))) +(t/deftest shape-to-path-svg-raw-does-not-throw + (let [shape {:type :svg-raw :x 0.0 :y 0.0 :width 100.0 :height 50.0 + :selrect (make-selrect 0.0 0.0 100.0 50.0) + :content {:tag :text :attrs {:style {}} + :content [{:tag :tspan :attrs {} :content ["x"]}]}} + result (path/convert-to-path shape {})] + (t/is (= :svg-raw (:type result))) + (t/is (some? (:content result))))) + (t/deftest shape-to-path-rect-with-radius (let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0 :r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0 From 47a3158602099c55f84bb820984d93084c00fefe Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 11:01:08 +0200 Subject: [PATCH 06/11] :bug: Fix component variant panel crash with mismatched property counts (#10616) Use `get` instead of `nth` to avoid index-out-of-bounds when a selected component copy has fewer variant properties than the first component in the selection. Closes #10615 AI-assisted-by: deepseek-v4-pro --- .../app/main/ui/workspace/sidebar/options/menus/component.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 536c087b36..2851124260 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -522,7 +522,7 @@ [:* [:div {:class (stl/css :variant-property-list)} (for [[pos prop] (map-indexed vector props-first)] - (let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties) + (let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties) options (get-options (:name prop)) boolean-pair (ctv/find-boolean-pair (mapv :id options)) options (cond-> options From 28fd798c5238a26310f2e0325d4725084c2c5685 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 11:03:06 +0200 Subject: [PATCH 07/11] :bug: Fix crash with degenerate selrect in text pipeline (#10618) Guard remaining text pipeline locations that accessed :selrect directly against nil/zero-dimension selrects by using safe-size-rect, which provides a 4-level fallback chain (selrect -> points -> shape fields -> empty 0.01x0.01 rect). Fixes: - fix-position in viewport_texts_html.cljs: replaced dm/get-prop :selrect with ctm/safe-size-rect for both old and new shape - assoc-position-data in modifiers.cljs: replaced (:selrect ...) with ctm/safe-size-rect for delta computation - change-orientation-modifiers in modifiers.cljc: replaced raw :selrect access with safe-size-rect for scale and origin computation Closes #10617 AI-assisted-by: mimo-v2.5-pro --- common/src/app/common/types/modifiers.cljc | 4 +-- .../common_tests/types/modifiers_test.cljc | 32 ++++++++++++++++++ .../app/main/data/workspace/modifiers.cljs | 4 +-- .../shapes/text/viewport_texts_html.cljs | 4 +-- .../data/workspace_texts_test.cljs | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index dbc0b52296..34a81365f1 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -500,9 +500,9 @@ shape-transform (:transform shape) shape-transform-inv (:transform-inverse shape) shape-center (gco/shape->center shape) - {sr-width :width sr-height :height} (:selrect shape) + {sr-width :width sr-height :height} (safe-size-rect shape) - origin (cond-> (gpt/point (:selrect shape)) + origin (cond-> (gpt/point (safe-size-rect shape)) (some? shape-transform) (gmt/transform-point-center shape-center shape-transform)) diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 2cfb196fa8..405da89935 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -657,3 +657,35 @@ mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45) result (ctm/apply-structure-modifiers shape mods)] (t/is (mth/close? 45.0 (:rotation result)))))) + +;; ─── change-orientation-modifiers — degenerate selrect ──────────────────────── + +(defn- make-degenerate-shape + "Build a shape whose selrect has zero width/height, simulating a shape + decoded from the server via map->Rect (bypasses make-rect's 0.01 floor)." + [x y selrect-width selrect-height] + (let [shape (make-shape x y 100 50)] + (assoc shape :selrect (grc/map->Rect {:x x :y y + :width selrect-width + :height selrect-height + :x1 x :y1 y + :x2 (+ x selrect-width) + :y2 (+ y selrect-height)})))) + +(t/deftest change-orientation-zero-width-selrect-does-not-throw + (t/testing "orientation change on a shape with zero selrect width does not throw" + (let [shape (make-degenerate-shape 0 0 0 50) + mods (ctm/change-orientation-modifiers shape :horiz)] + (t/is (some? mods))))) + +(t/deftest change-orientation-zero-height-selrect-does-not-throw + (t/testing "orientation change on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-shape 0 0 100 0) + mods (ctm/change-orientation-modifiers shape :vert)] + (t/is (some? mods))))) + +(t/deftest change-orientation-zero-width-and-height-selrect-does-not-throw + (t/testing "orientation change on a fully degenerate selrect does not throw" + (let [shape (make-degenerate-shape 0 0 0 0) + mods (ctm/change-orientation-modifiers shape :horiz)] + (t/is (some? mods))))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 96a1605704..b5696414c8 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -256,8 +256,8 @@ (defn assoc-position-data [shape position-data old-shape] - (let [deltav (gpt/to-vec (gpt/point (:selrect old-shape)) - (gpt/point (:selrect shape))) + (let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape)) + (gpt/point (ctm/safe-size-rect shape))) position-data (-> position-data (gsh/move-position-data deltav))] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index a22b82f46a..ae6f49f688 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -35,8 +35,8 @@ (if-let [modifiers (:modifiers shape)] (let [shape' (gsh/transform-shape shape modifiers) - old-sr (dm/get-prop shape :selrect) - new-sr (dm/get-prop shape' :selrect) + old-sr (ctm/safe-size-rect shape) + new-sr (ctm/safe-size-rect shape') ;; We need to remove the movement because the dynamic modifiers will have move it deltav (gpt/to-vec (gpt/point new-sr) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 15db703ef8..ee438672c5 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -9,9 +9,11 @@ [app.common.geom.rect :as grc] [app.common.test-helpers.files :as cthf] [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] [app.common.types.shape :as cts] [app.common.types.text :as txt] [app.main.data.workspace.texts :as dwt] + [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths])) @@ -374,3 +376,34 @@ "exactly one typography was added") (t/is (= "0.1" (:letter-spacing (first typographies))) "float letter-spacing is normalised to 2-decimal string"))))))) + +;; --------------------------------------------------------------------------- +;; Tests: fix-position with degenerate selrect +;; --------------------------------------------------------------------------- + +(t/deftest fix-position-zero-width-selrect-does-not-throw + (t/testing "fix-position on a shape with zero selrect width does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50) + modifiers (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest fix-position-zero-height-selrect-does-not-throw + (t/testing "fix-position on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0) + modifiers (ctm/change-dimensions-modifiers shape :height 80 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest fix-position-zero-width-and-height-selrect-does-not-throw + (t/testing "fix-position on a fully degenerate selrect does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + modifiers (ctm/change-dimensions-modifiers shape :width 150 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) From 64b0bff7bd1590c6c56ec786fe16ccdd1e61bf85 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 13:12:41 +0200 Subject: [PATCH 08/11] :books: Update serena creating-prs workflow documentation --- .serena/memories/workflow/creating-prs.md | 35 +++++++++++ tools/detect-target-branch | 74 +++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100755 tools/detect-target-branch diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 4c5e75c212..204fd49c76 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -2,6 +2,20 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `/` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`). +## Target Branch + +Auto-detect the base branch with `tools/detect-target-branch`: + +```bash +TARGET=$(tools/detect-target-branch) +``` + +This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails. + +## Metadata + +Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project. + ## Title Format PR titles follow commit title conventions: @@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. - Follow `mem:workflow/creating-commits` for commits - Run the focused tests/lints appropriate to touched modules. - Do not force-push during review unless the maintainer workflow explicitly asks for it. +- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`. + +## Creating the PR + +```bash +cat > /tmp/pr-body.md << 'PR_BODY' + +PR_BODY + +TARGET=$(tools/detect-target-branch) + +gh pr create \ + --repo penpot/penpot \ + --base "$TARGET" \ + --head \ + --title "" \ + --project "Main" \ + --body-file /tmp/pr-body.md + +rm -f /tmp/pr-body.md +``` diff --git a/tools/detect-target-branch b/tools/detect-target-branch new file mode 100755 index 0000000000..5d3d584552 --- /dev/null +++ b/tools/detect-target-branch @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +# detect-target-branch +# +# Determines the target branch for a PR of the current branch. +# +# Uses git name-rev to find the nearest ref (branch/tag) that is an ancestor of +# HEAD, falling back to a graph walk if name-rev returns the current branch. +# +# Pure local — no remote, no SSH, no network. Works even when the current +# branch shares its tip commit with the target (not yet diverged). +# +# Usage: +# tools/detect-target-branch # print branch name +# tools/detect-target-branch --verbose # print "branch~N" +# +# Exit status: +# 0 — target found +# 1 — no target found (orphan commit, no other branches) + +verbose=false + +for arg do + case "$arg" in + --verbose|-v) verbose=true ;; + --name-only) ;; + --help|-h) + sed -n '/^#/,/^$/p' "$0" | sed 's/^# //; s/^#$//' + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + echo "Usage: $(basename "$0") [--verbose|-v] [--name-only] [--help|-h]" >&2 + exit 1 + ;; + esac +done + +current=$(git rev-parse --abbrev-ref HEAD) + +# Use name-rev to find the nearest ref — it has smart tie-breaking +raw=$(git name-rev --name-only HEAD 2>/dev/null) +target=${raw%%~[0-9]*} # strip ~N distance suffix + +# If name-rev returned the current branch itself, walk backwards +if [ "$target" = "$current" ] || [ -z "$target" ]; then + target=$(git rev-list HEAD | while read commit; do + branch=$(git branch --points-at="$commit" --format='%(refname:short)' 2>/dev/null \ + | grep -v "^$current$" \ + | head -1 || true) + if [ -n "$branch" ]; then + echo "$branch" + break + fi + done || true) + + if [ -z "$target" ]; then + echo "error: unable to determine target branch" >&2 + exit 1 + fi +fi + +if [ "$verbose" = true ]; then + target_commit=$(git rev-parse "$target" 2>/dev/null || true) + if [ -n "$target_commit" ]; then + distance=$(git rev-list --count HEAD ^"$target_commit" 2>/dev/null || echo "0") + echo "${target}~${distance}" + else + echo "${target}~?" + fi +else + echo "$target" +fi From d6c50cc40b0439b4d0ed8f1abf1991e985feabde Mon Sep 17 00:00:00 2001 From: Alonso Torres <alonso.torres@kaleidos.net> Date: Thu, 9 Jul 2026 18:00:26 +0200 Subject: [PATCH 09/11] :bug: Fix problems with plugins api setPluginData (#10632) --- frontend/src/app/plugins/library.cljs | 6 ++ frontend/src/app/plugins/page.cljs | 4 +- plugins/CHANGELOG.md | 2 + .../src/tests/library.test.ts | 41 ++++++++++ .../src/tests/plugin-data.test.ts | 76 +++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index b97343ec20..9fb646f205 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1059,6 +1059,9 @@ :setPluginData (fn [key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) + (not (string? key)) (u/not-valid plugin-id :setPluginData-key key) @@ -1092,6 +1095,9 @@ :setSharedPluginData (fn [namespace key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) + (not (string? namespace)) (u/not-valid plugin-id :setSharedPluginData-namespace namespace) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 76bc45a832..e668bc8756 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -272,13 +272,13 @@ (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) :getSharedPluginDataKeys - (fn [self namespace] + (fn [namespace] (cond (not (string? namespace)) (u/not-valid plugin-id :page-plugin-data-namespace namespace) :else - (let [page (u/proxy->page self)] + (let [page (u/locate-page file-id id)] (apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)])))))) :openPage diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 00095c38b5..80dee3f1c9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -52,6 +52,8 @@ - **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires. - **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`). - **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error). +- **plugins-runtime**: `Page.getSharedPluginDataKeys(namespace)` now works instead of always raising a namespace validation error: the implementation expected a spurious leading argument, so the caller's `namespace` was read as a missing second argument. +- **plugins-runtime**: Storing plugin data on a connected (non-local) shared library is now consistently rejected with a `setPluginData-non-local-library` error on the `Library` object as well as its assets (colors, typographies, components). Previously the `Library` object accepted the write and applied it optimistically, but plugin data is not part of library synchronization and the change only persists when the caller can edit the library file — on a read-only shared library it failed silently and was lost on reload. Plugin data can only be stored on the file currently being edited. ## 1.4.2 (2026-01-21) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 5f16958282..7d3a8ccf0a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -34,6 +34,24 @@ describe('Library', () => { expect(Array.isArray(ctx.penpot.library.connected)).toBe(true); }); + // The Library object itself carries plugin data (it extends PluginData), + // stored on the underlying file. Exercised on the local library; the + // connected-library case (writing to a shared library's file/assets) shares + // the same code path but can't be fixtured here (connecting a library hangs). + test('local library stores plugin data', (ctx) => { + const lib = ctx.penpot.library.local; + lib.setPluginData('k', 'v'); + expect(lib.getPluginData('k')).toBe('v'); + expect(lib.getPluginDataKeys()).toContain('k'); + }); + + test('local library stores shared plugin data', (ctx) => { + const lib = ctx.penpot.library.local; + lib.setSharedPluginData('ns', 'k', 'v'); + expect(lib.getSharedPluginData('ns', 'k')).toBe('v'); + expect(lib.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('library elements expose a libraryId', (ctx) => { const color = ctx.penpot.library.local.createColor(); expect(typeof color.libraryId).toBe('string'); @@ -136,6 +154,13 @@ describe('Library', () => { expect(color.getPluginData('k')).toBe('v'); expect(color.getPluginDataKeys()).toContain('k'); }); + + test('color shared plugin data round-trips', (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.setSharedPluginData('ns', 'k', 'v'); + expect(color.getSharedPluginData('ns', 'k')).toBe('v'); + expect(color.getSharedPluginDataKeys('ns')).toContain('k'); + }); }); describe('Typographies', () => { @@ -170,6 +195,13 @@ describe('Library', () => { expect(typo.getPluginDataKeys()).toContain('k'); }); + test('typography shared plugin data round-trips', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.setSharedPluginData('ns', 'k', 'v'); + expect(typo.getSharedPluginData('ns', 'k')).toBe('v'); + expect(typo.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('typography fontFamily and fontId round-trip', (ctx) => { const typo = ctx.penpot.library.local.createTypography(); expect(typeof typo.fontFamily).toBe('string'); @@ -256,6 +288,15 @@ describe('Library', () => { expect(comp.getPluginDataKeys()).toContain('k'); }); + test('component shared plugin data round-trips', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + comp.setSharedPluginData('ns', 'k', 'v'); + expect(comp.getSharedPluginData('ns', 'k')).toBe('v'); + expect(comp.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('component instance and mainInstance return shapes', (ctx) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts index 254717e63e..5564b2e3f1 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts @@ -26,6 +26,41 @@ describe('Plugin data', () => { if (file) { file.setPluginData('fileKey', 'fileValue'); expect(file.getPluginData('fileKey')).toBe('fileValue'); + expect(file.getPluginDataKeys()).toContain('fileKey'); + } + }); + + test('shared plugin data round-trips on the file', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + file.setSharedPluginData('ns', 'fileShared', 'fileSharedValue'); + expect(file.getSharedPluginData('ns', 'fileShared')).toBe( + 'fileSharedValue', + ); + expect(file.getSharedPluginDataKeys('ns')).toContain('fileShared'); + } + }); + + test('plugin data round-trips on a page', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + page.setPluginData('pageKey', 'pageValue'); + expect(page.getPluginData('pageKey')).toBe('pageValue'); + expect(page.getPluginDataKeys()).toContain('pageKey'); + } + }); + + test('shared plugin data round-trips on a page', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + page.setSharedPluginData('ns', 'pageShared', 'pageSharedValue'); + expect(page.getSharedPluginData('ns', 'pageShared')).toBe( + 'pageSharedValue', + ); + expect(page.getSharedPluginDataKeys('ns')).toContain('pageShared'); } }); @@ -45,6 +80,47 @@ describe('Plugin data', () => { expect(rect.getPluginDataKeys()).toContain(''); }); + // Keys are opaque strings: no case normalization is applied. A camelCase key + // and its kebab-case spelling are distinct entries that each round-trip + // independently. Pins that behaviour so a future key-normalization regression + // (reported as camelCase keys "behaving incorrectly") would be caught here. + test('camelCase and kebab-case keys are distinct and both round-trip', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setPluginData('myKey', 'camel'); + rect.setPluginData('my-key', 'kebab'); + expect(rect.getPluginData('myKey')).toBe('camel'); + expect(rect.getPluginData('my-key')).toBe('kebab'); + const keys = rect.getPluginDataKeys(); + expect(keys).toContain('myKey'); + expect(keys).toContain('my-key'); + }); + + // Same guarantee at the file-data storage location (a distinct code path from + // shape/page objects), covering a camelCase key on the file itself. + test('a camelCase key round-trips on the file', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + file.setPluginData('camelCaseFileKey', 'value'); + expect(file.getPluginData('camelCaseFileKey')).toBe('value'); + expect(file.getPluginDataKeys()).toContain('camelCaseFileKey'); + } + }); + + // camelCase is likewise preserved in the shared namespace and the shared key. + test('camelCase shared namespace and key round-trip', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setSharedPluginData('myNamespace', 'myKey', 'camel'); + rect.setSharedPluginData('myNamespace', 'my-key', 'kebab'); + expect(rect.getSharedPluginData('myNamespace', 'myKey')).toBe('camel'); + expect(rect.getSharedPluginData('myNamespace', 'my-key')).toBe('kebab'); + const keys = rect.getSharedPluginDataKeys('myNamespace'); + expect(keys).toContain('myKey'); + expect(keys).toContain('my-key'); + }); + test('setPluginData with a non-string value throws', (ctx) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); From 3400d6afbb612ae01863fc28fbe9b990b80de4ee Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 18:04:05 +0200 Subject: [PATCH 10/11] :bug: Fix too much recursion when clicking shape in comments mode (#10622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix too much recursion when clicking shape in comments mode Remove `deselect-all` from `handle-interrupt` in comments mode. The `select-shape` event emits `:interrupt` which the comments stream watcher routes to `handle-interrupt`. In comments mode, calling `deselect-all` cleared the selection and emitted a competing `rt/nav`, creating a synchronous recursion cycle in the potok store that overflowed the JS call stack. Fixes #10620 AI-assisted-by: opencode * :bug: Add unit tests for comments handle-interrupt Make `handle-interrupt` public (defn- → defn) and add 4 tests covering each branch: draft thread, open thread, comments mode, and noop. AI-assisted-by: opencode --- .../src/app/main/data/workspace/comments.cljs | 6 +- .../data/workspace_comments_test.cljs | 84 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_comments_test.cljs diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 968cc8194e..fc9911feca 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -24,7 +24,6 @@ [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.layout :as dwlo] - [app.main.data.workspace.selection :as dws] [app.main.data.workspace.zoom :as dwz] [app.main.repo :as rp] [app.main.router :as rt] @@ -71,7 +70,7 @@ (rx/take-until stopper-s)))))) -(defn- handle-interrupt +(defn handle-interrupt [] (ptk/reify ::handle-interrupt ptk/WatchEvent @@ -85,8 +84,7 @@ ;; tool is active. When comments are merely visible during design, ;; `select-shape` emits `:interrupt` and this would otherwise wipe ;; the freshly selected shape, breaking click selection. - comments-mode? (rx/of (dwe/clear-edition-mode) - (dws/deselect-all true)) + comments-mode? (rx/of (dwe/clear-edition-mode)) :else (rx/empty)))))) ;; Event responsible of the what should be executed when user clicked diff --git a/frontend/test/frontend_tests/data/workspace_comments_test.cljs b/frontend/test/frontend_tests/data/workspace_comments_test.cljs new file mode 100644 index 0000000000..8e653875c1 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_comments_test.cljs @@ -0,0 +1,84 @@ +;; 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 frontend-tests.data.workspace-comments-test + (:require + [app.main.data.comments :as dcmt] + [app.main.data.workspace.comments :as dwcm] + [app.main.data.workspace.edition :as dwe] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +(t/deftest test-handle-interrupt-draft + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:comments-local {:draft {:id "draft-id"}}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dcmt/close-comment-thread (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-open + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:comments-local {:open {:id "thread-id"}}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dcmt/close-comment-thread (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-comments-mode + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:workspace-drawing {:tool :comments}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dwe/clear-edition-mode (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-noop + (t/async + done + (let [event (dwcm/handle-interrupt) + state {} + result (ptk/watch event state (rx/empty)) + emitted? (atom false)] + (->> result + (rx/subs! + (fn [_] + (reset! emitted? true)) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (t/is (false? @emitted?) "should not emit any events") + (done))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 0e92dd804a..f21b124277 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -12,6 +12,7 @@ [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-comments-test] [frontend-tests.data.workspace-interactions-test] [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] @@ -85,6 +86,7 @@ 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-comments-test 'frontend-tests.data.workspace-interactions-test 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test From 23a9b4bdd9a80deeb5d6256d035378c818173be5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 18:53:49 +0200 Subject: [PATCH 11/11] :bug: Fix backend util shell tests --- backend/src/app/util/shell.clj | 4 +++- backend/test/backend_tests/shell_test.clj | 27 ++++++++++++++--------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/backend/src/app/util/shell.clj b/backend/src/app/util/shell.clj index 0d7349d632..61dd08e682 100644 --- a/backend/src/app/util/shell.clj +++ b/backend/src/app/util/shell.clj @@ -65,7 +65,6 @@ (assert (every? string? cmd) "the command should be a vector of strings") (let [executor (::wrk/executor system) - _ (assert (some? executor) "executor is required, check ::wrk/executor") full-cmd (cond->> cmd (seq prlimit) (into (prlimit-cmd prlimit))) @@ -74,6 +73,9 @@ _ (reduce-kv set-env env-map env) process (.start builder)] + (when-not executor + (throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor"))) + (if in (px/run! executor (fn [] diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj index c77c585568..c9d1932c44 100644 --- a/backend/test/backend_tests/shell_test.clj +++ b/backend/test/backend_tests/shell_test.clj @@ -8,12 +8,17 @@ (:require [app.common.exceptions :as ex] [app.util.shell :as shell] + [app.worker :as-alias wrk] [clojure.string :as str] - [clojure.test :as t])) + [clojure.test :as t] + [promesa.exec :as px])) + +(def ^:private system + {::wrk/executor (px/cached-executor)}) (t/deftest exec-normal-completes (t/testing "normal process completes within timeout" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -21,7 +26,7 @@ (t/deftest exec-captures-stderr (t/testing "stderr is captured separately" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo out; echo err >&2"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -30,14 +35,14 @@ (t/deftest exec-non-zero-exit (t/testing "non-zero exit code is captured" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "exit 42"] :timeout 10)] (t/is (= 42 (:exit result)))))) (t/deftest exec-with-env (t/testing "environment variables are passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo $MY_VAR"] :env {"MY_VAR" "test-value"} :timeout 10)] @@ -46,7 +51,7 @@ (t/deftest exec-with-input (t/testing "stdin input is passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["cat"] :in "hello from stdin" :timeout 10)] @@ -57,7 +62,7 @@ (t/testing "process that exceeds timeout is killed and raises exception" (let [start (System/currentTimeMillis)] (try - (shell/exec! {} + (shell/exec! system :cmd ["sleep" "60"] :timeout 1) (t/is false "should have thrown") @@ -72,14 +77,14 @@ (t/deftest exec-no-timeout-waits (t/testing "without timeout, process runs to completion" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["sleep" "0.1"] :timeout nil)] (t/is (= 0 (:exit result)))))) (t/deftest exec-prlimit-normal (t/testing "normal process completes within prlimit" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :prlimit {:mem 256 :cpu 10} :timeout 10)] @@ -88,7 +93,7 @@ (t/deftest exec-prlimit-cpu (t/testing "process exceeding CPU limit is killed" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "while true; do :; done"] :prlimit {:cpu 2} :timeout 10)] @@ -98,7 +103,7 @@ (t/testing "process exceeding memory limit is killed" ;; Use python3 to allocate more memory than the limit allows. ;; This test requires python3 to be available in the environment. - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["python3" "-c" "import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"] :prlimit {:mem 256}