From 6f35348c7c701b220651f8665de028b2814182cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 27 Aug 2026 10:51:08 +0200 Subject: [PATCH 1/6] :whale: Pin docker images to 2.17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- docker/images/docker-compose.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 59b326c76d..45b2361510 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -78,7 +78,7 @@ services: # - "443:443" penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}" restart: always ports: - 9001:8080 @@ -111,7 +111,7 @@ services: # PENPOT_DISABLE_IPV6_LISTEN: "true" penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/backend:${PENPOT_VERSION:-2.17}" restart: always volumes: @@ -180,13 +180,13 @@ services: PENPOT_SMTP_SSL: "false" penpot-mcp: - image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}" + image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}" restart: always networks: - penpot penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}" + image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}" restart: always depends_on: From b6b1a47a7be2f5f3892193cc396ef793ac1e134e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 2 Sep 2026 13:54:01 +0200 Subject: [PATCH 2/6] :books: Update MCP Quick demo video (#11453) Replace the outdated Quick demo embed on the MCP docs with the new recording. --- docs/mcp/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 466144b3f1..44345bd631 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca title="Quick demo: Penpot MCP server in action" width="100%" height="480" - src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0" + src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" From 88d715938f889cc7a661f9edd2119d052bd65173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 2 Sep 2026 14:06:06 +0200 Subject: [PATCH 3/6] :books: Add migration guide page (#11454) Point First Steps at the enterprise migration PDF with a short summary, without duplicating the Community post. --- docs/user-guide/first-steps/index.njk | 6 ++++ .../first-steps/migration-guide.njk | 31 +++++++++++++++++++ .../first-steps/troubleshooting-webgl.njk | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 docs/user-guide/first-steps/migration-guide.njk diff --git a/docs/user-guide/first-steps/index.njk b/docs/user-guide/first-steps/index.njk index c26777b24f..e707f6d46e 100644 --- a/docs/user-guide/first-steps/index.njk +++ b/docs/user-guide/first-steps/index.njk @@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial

Useful resources to better understand Penpot

+
  • + +

    Migration Guide →

    +

    Move a design system from Figma to Penpot

    +
    +
  • diff --git a/docs/user-guide/first-steps/migration-guide.njk b/docs/user-guide/first-steps/migration-guide.njk new file mode 100644 index 0000000000..cd8600ff68 --- /dev/null +++ b/docs/user-guide/first-steps/migration-guide.njk @@ -0,0 +1,31 @@ +--- +title: Migration Guide +order: 6 +desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF. +--- + +

    Migration Guide

    + +

    If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.

    + + + +

    What the guide covers

    +

    The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.

    + +
      +
    • Before you export: audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.
    • +
    • Static assets: export SVG, PNG, or JPG from Figma and place them in Penpot.
    • +
    • Complex files and libraries: use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.
    • +
    • Tokens: if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.
    • +
    • Validate before you scale: migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.
    • +
    • People and pilots: the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.
    • +
    + +

    The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.

    + +

    Questions and discussion

    +

    If you want to ask about a migration, or share how yours is going, use the Community post The Enterprise Guide to Migrating Design Systems to Penpot.

    diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk index 7188090ac8..c3893f90e8 100644 --- a/docs/user-guide/first-steps/troubleshooting-webgl.njk +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -1,6 +1,6 @@ --- title: Troubleshooting WebGL -order: 5 +order: 7 desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks. --- From df48c834e3cd659683f5ab59ce6d9d8a9a7d6f53 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 4 Sep 2026 12:31:26 +0200 Subject: [PATCH 4/6] :bug: Fix create nil typography token (#11489) * :bug: Fix stroke width token dropdown closing mid-interaction The stroke-row* key included a hash of applied-tokens, forcing a full remount whenever the async token-resolution pipeline updated that state (e.g. after a detach action settles). If the remount landed while the width dropdown was open, it destroyed the dropdown and reset its open state, permanently closing it before the user could pick a token. Drop the hash from the key so the row only remounts on actual structural changes (add/remove/reorder), not on every token resolution tick. AI-assisted-by: claude-sonnet-5 * :bug: Fix crash when a typography token has a nil value A composite typography token saved with no fields filled in stores a nil :value. That value reached StyleDictionary's tokens-studio font-styles preprocessor, which assumes a typography value is never null and threw, crashing token resolution for every other token in the file. Reject the nil value at the source in the typography token form validation, and as defense in depth, filter nil-valued tokens out of every resolve-tokens* entry point before they reach StyleDictionary, tagging them with the existing empty-input error instead. Also remove a stray debug prn left in find-refs. AI-assisted-by: claude-sonnet-5 * :wrench: Gitignore local CLAUDE.md Keep the commit-conventions instructions file local to each contributor's checkout instead of tracking it in the repo. AI-assisted-by: claude-sonnet-5 * :zap: Use a single transducer to tag invalid-value tokens merge-invalid-value-tokens ran three separate passes over the token map (remove, map, into) and then merged the result back in. Combine the remove/map steps into one ns-level transducer, defined once instead of rebuilt on every call, and pass resolved as the seed to into so the trailing merge isn't needed either. AI-assisted-by: claude-sonnet-5 * :recycle: Drop redundant t/testing wrapper in nil-value token test The outer t/testing just repeated the deftest's own name and added nothing the two inner t/testing blocks (each covering one concrete assertion group) don't already say. AI-assisted-by: claude-sonnet-5 * :bug: Fail the nil-value token test on a resolution error rx/sub! only handles the success case, so if token resolution ever errors instead, done is never called and the async test hangs instead of failing. Switch to rx/subs! with an error handler that reports the failure and calls done, matching the pattern already used elsewhere in the tokens test suite. AI-assisted-by: claude-sonnet-5 --- .gitignore | 1 + common/src/app/common/files/tokens.cljc | 1 - .../src/app/main/data/style_dictionary.cljs | 50 +++++++++++++++---- .../sidebar/options/menus/stroke.cljs | 2 +- .../tokens/management/forms/typography.cljs | 10 ++-- .../tokens/style_dictionary_test.cljs | 37 ++++++++++++++ 6 files changed, 85 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index ec067552ff..47840dca52 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,7 @@ opencode.json /.idea *.iml /.claude +/CLAUDE.md /.playwright-mcp /.devenv/mcp/ /opencode.json diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 95ff5da4df..9232037bcf 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -166,7 +166,6 @@ (not (ctob/token-name-path-exists? token-name tokens-tree))) new-tokens))))]]) (defn find-refs [value] - (prn value) (cond (string? value) (cto/find-token-value-references value) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index ba1f2dd67f..320231793f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -584,11 +584,38 @@ (into {}))] (merge resolved dropped))) +(defn- valid-token-value? + [[_ token]] + (some? (:value token))) + +(def ^:private xform-invalid-value-tokens + (comp + (remove valid-token-value?) + (map (fn [[k token]] + [k (assoc token :errors [(wte/get-error-code :error.token/empty-input)])])))) + +(defn- merge-invalid-value-tokens + "Tokens with a `nil` value (e.g. a composite typography token saved with + no fields filled in) must never reach StyleDictionary: some of its + preprocessors (`@tokens-studio/sd-transforms`'s font-styles preprocessor, + in particular) assume a typography token's value is never null and throw + an uncaught exception when it is, taking down token resolution for the + whole file. + + `tokens` is the full, unfiltered token map; `resolved` only contains the + valid subset that was actually sent to StyleDictionary. Tag the invalid + ones with the same \"empty value\" error the token forms already use + instead of ever letting them reach the resolver." + [tokens resolved] + (into resolved xform-invalid-value-tokens tokens)) + (defn resolve-tokens [tokens] - (let [tokens-tree (ctob/tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + tokens-tree (ctob/tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get valid-tokens (sd-token-name %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-interactive "Interactive check of resolving tokens. @@ -610,15 +637,18 @@ computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map." [tokens] - (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + {:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-with-verbose-errors [tokens] - (resolve-tokens-tree - (ctob/tokens-tree tokens) - #(get tokens (sd-token-name %)) - (StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens)] + (resolve-tokens-tree + (ctob/tokens-tree valid-tokens) + #(get valid-tokens (sd-token-name %)) + (StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))) ;; === Hooks diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index d671e7294c..0f8c03b819 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -276,7 +276,7 @@ (seq strokes) [:> h/sortable-container* {} (for [[index value] (d/enumerate (:strokes values []))] - [:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens)) + [:> stroke-row* {:key (dm/str "stroke-" index) :index index :stroke value :title (tr "workspace.options.stroke-color") diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 62b9a63410..b053c130e3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -18,7 +18,6 @@ [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]] [app.util.i18n :refer [tr]] - [beicon.v2.core :as rx] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -43,11 +42,14 @@ (defn- validate-typography-token [{:keys [token-value] :as props}] (cond - ;; Entering form without a value - show no error just resolve nil - (nil? token-value) (rx/of nil) ;; Validate refrence string (cto/composite-token-reference? token-value) (default-validate-token props) - ;; Validate composite token + ;; Validate composite token. `token-value` may be nil when the form is + ;; submitted without any composite field filled in — normalize it to `{}` + ;; so `check-empty-typography-token` catches it and rejects the submit, + ;; instead of silently saving a token with a `nil` value (which later + ;; crashes token resolution: the tokens-studio StyleDictionary + ;; preprocessor assumes a typography token's value is never null). :else (-> props (update :token-value diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index 220dd1f36b..60222f6bae 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -122,6 +122,43 @@ (get-in resolved-tokens ["typography.bad" :errors 0 :error/code]))) (done)))))))) +;; Regression: a token with a `nil` value (e.g. a composite typography +;; token saved via the workspace form with no fields filled in) must never +;; reach StyleDictionary — its `tokens-studio` preprocessor assumes a +;; typography token's value is never null and throws an uncaught exception +;; on it, which used to take down resolution for every other token in the +;; file. It should be tagged with an empty-input error instead. +(t/deftest resolve-tokens-nil-value-test + (t/async + done + (let [tokens (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) + :name "core")) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "typography.empty" + :value nil + :type :typography})) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "borderRadius.sm" + :value "12px" + :type :border-radius})) + (ctob/get-all-tokens-map))] + (->> (sd/resolve-tokens tokens) + (rx/subs! + (fn [resolved-tokens] + (t/testing "the nil-value token is tagged with an error instead of crashing" + (t/is (contains? resolved-tokens "typography.empty")) + (t/is (nil? (get-in resolved-tokens ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input + (get-in resolved-tokens ["typography.empty" :errors 0 :error/code])))) + (t/testing "other tokens still resolve normally" + (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value]))))) + (fn [err] + (t/do-report {:type :error :message "Stream error" :actual err}) + (done)) + (fn [] + (done))))))) + (t/deftest resolve-tokens-interactive-test (t/async done From 5452eb5c8a2ae622e10016d86c91431b55a63e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Fri, 4 Sep 2026 13:36:10 +0200 Subject: [PATCH 5/6] :bug: Fix email sales (#11514) --- frontend/src/app/main/ui/nitrate/nitrate_form.cljs | 4 ++-- frontend/src/app/main/ui/settings/subscription.cljs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index fc962e472c..c9c34bfb73 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -118,8 +118,8 @@ (tr "nitrate.form.contact-upgrade") (tr "nitrate.form.contact-trial"))] [:p {:class (stl/css :modal-text-large)} - [:a {:class (stl/css :link) :href "mailto:sales@penpot.net"} - "sales@penpot.net"]] + [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} + "sales@penpot.app"]] [:div {:class (stl/css :activation-code)} [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index ff38ffb1c6..957ffc6fa0 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -848,8 +848,8 @@ [:div {:class (stl/css :modal-text)} (tr "nitrate.form.enterprise-intro" ".") " " (if nitrate-license (tr "nitrate.form.contact-us-upgrade") (tr "nitrate.form.contact-us-free-trial"))] [:div {:class (stl/css :modal-text)} - [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.net"} - "sales@penpot.net"]]])]])) + [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"} + "sales@penpot.app"]]])]])) (mf/defc nitrate-contact-sales-dialog {::mf/register modal/components @@ -898,7 +898,7 @@ :on-click handle-close-dialog} (tr "ds.confirm-cancel")] [:> button* {:variant "primary" :type "button" - :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.net?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) + :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.app?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) (mf/defc nitrate-cancel-contact-sales-dialog {::mf/register modal/components @@ -908,7 +908,7 @@ (js/encodeURIComponent email) mailto-url - (dm/str "mailto:sales@penpot.net" + (dm/str "mailto:sales@penpot.app" "?subject=Request%20to%20Cancel%20Enterprise%20Subscription" "&body=Hello%2C%0A%0A" "I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%0A" @@ -930,8 +930,8 @@ [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-text-medium)} (tr "nitrate.subscription.settings.manual-contact-us")] - [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"} - "sales@penpot.net"] + [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.app"} + "sales@penpot.app"] [:div {:class (stl/css :action-buttons)} [:> button* {:class (stl/css :button-full-width) :variant "primary" From c8691d0cdebe4d97525c1e886cd8c7f3ffe61120 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 7 Sep 2026 08:34:06 +0200 Subject: [PATCH 6/6] :bug: Fix crash when a nil shape id reaches WASM modifiers (#10896) * :bug: Fix crash when a nil shape id reaches WASM modifiers A modif-tree containing a nil shape id crashed the WASM heap write with "Cannot read properties of null (reading '__u32_buffer')" in propagate-modifiers. Production builds elide the upstream asserts, so a nil id (e.g. update-dimensions called with a missing shape's parent id in the variants flow) reached the uuid heap write. Drop nil-keyed modif-tree entries in apply-wasm-modifiers and set-wasm-modifiers before they reach propagate-modifiers and set-structure-modifiers, filter nil ids at the parse level, and guard the variant reposition/creation events against missing shapes. AI-assisted-by: hy3 * :bug: Fix let scoping bugs in modifiers.cljs Two let forms in set-wasm-modifiers and apply-wasm-modifiers had their bindings vectors closed prematurely, causing undeclared var warnings for snap-pixel?, translation?, ids, update-shape, options, bool-ids, undo-id, and other bindings. In set-wasm-modifiers: removed premature ) after ] on the let bindings so the let body wraps snap-pixel? and translation?. In apply-wasm-modifiers: the cond form was not explicitly closed, so all subsequent bindings (ignore-tree, options, modif-tree, ids, update-shape, bool-ids, undo-id) were parsed as cond clauses instead of let bindings. Added ) to close cond after :else, and moved ] to close the bindings vector after undo-id. AI-assisted-by: mimo-v2.5-pro --- .../app/main/data/workspace/modifiers.cljs | 399 +++++++++--------- .../src/app/main/data/workspace/variants.cljs | 74 ++-- .../logic/wasm_modifiers_nil_id_test.cljs | 109 +++++ frontend/test/frontend_tests/runner.cljs | 2 + 4 files changed, 357 insertions(+), 227 deletions(-) create mode 100644 frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index cb8be131cd..314e8e8224 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -15,6 +15,7 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] + [app.common.logging :as log] [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.container :as ctn] @@ -579,44 +580,57 @@ modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)] (assoc state :workspace-modifiers modifiers)))))) +(defn- without-nil-ids + "Drop nil-keyed entries from a modif-tree. A nil shape id (possible in + production builds, where the upstream asserts are elided) would crash + the WASM heap write with `uuid/get-u32` being called on nil." + [modif-tree] + (if (contains? modif-tree nil) + (do (log/warn :hint "modif-tree contains a nil shape id; ignoring entry") + (dissoc modif-tree nil)) + modif-tree)) + (defn- parse-structure-modifiers [modif-tree] (into [] - (mapcat - (fn [[parent-id data]] - (when (ctm/has-structure? (:modifiers data)) - (->> (concat - (get-in data [:modifiers :structure-parent]) - (get-in data [:modifiers :structure-child])) - (mapcat - (fn [modifier] - (case (:type modifier) - :remove-children - (->> (:value modifier) - (map (fn [child-id] - {:type :remove-children - :parent parent-id - :id child-id - :index 0 - :value 0}))) + (comp + (mapcat + (fn [[parent-id data]] + (when (ctm/has-structure? (:modifiers data)) + (->> (concat + (get-in data [:modifiers :structure-parent]) + (get-in data [:modifiers :structure-child])) + (mapcat + (fn [modifier] + (case (:type modifier) + :remove-children + (->> (:value modifier) + (map (fn [child-id] + {:type :remove-children + :parent parent-id + :id child-id + :index 0 + :value 0}))) - :add-children - (->> (:value modifier) - (map (fn [child-id] - {:type :add-children - :parent parent-id - :id child-id - :index (:index modifier) - :value 0}))) + :add-children + (->> (:value modifier) + (map (fn [child-id] + {:type :add-children + :parent parent-id + :id child-id + :index (:index modifier) + :value 0}))) - :scale-content - [{:type :scale-content - :parent parent-id - :id parent-id - :index 0 - :value (:value modifier)}] - nil))))))) + :scale-content + [{:type :scale-content + :parent parent-id + :id parent-id + :index 0 + :value (:value modifier)}] + nil))))))) + (filter (fn [{:keys [id parent]}] + (and (some? id) (some? parent))))) modif-tree)) @@ -624,7 +638,7 @@ (let [default-transform (gmt/matrix)] (keep (fn [[id data]] (cond - (= id uuid/zero) + (or (nil? id) (= id uuid/zero)) nil (ctm/has-geometry? (:modifiers data)) @@ -693,65 +707,66 @@ subtree-ids-by-id selection-rect-cache] :or {ignore-constraints false ignore-snap-pixel false} :as params}] - (ptk/reify ::set-wasm-modifiers - ptk/UpdateEvent - (update [_ state] - (let [property-changes (extract-property-changes modif-tree)] - (if (d/not-empty? property-changes) - (-> state - (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)) - state))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::set-wasm-modifiers + ptk/UpdateEvent + (update [_ state] + (let [property-changes (extract-property-changes modif-tree)] + (if (d/not-empty? property-changes) + (-> state + (assoc :prev-wasm-props (:wasm-props state)) + (assoc :wasm-props property-changes)) + state))) - ptk/WatchEvent - (watch [_ state _] - ;; Entering an interactive transform (drag/resize/rotate). Flip - ;; the renderer into fast + atlas-backdrop mode so the live - ;; preview is cheap, tiles never appear sequentially and the main - ;; thread is not blocked. The pair is closed in - ;; `clear-local-transform`. - (ensure-interactive-transform-start!) - (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + ptk/WatchEvent + (watch [_ state _] + ;; Entering an interactive transform (drag/resize/rotate). Flip + ;; the renderer into fast + atlas-backdrop mode so the live + ;; preview is cheap, tiles never appear sequentially and the main + ;; thread is not blocked. The pair is closed in + ;; `clear-local-transform`. + (ensure-interactive-transform-start!) + (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (if translation? - ;; Pure translation: no structure changes needed. If structure - ;; modifiers were active from a previous non-translation frame - ;; (e.g. shape hovered over a frame then dragged back out), - ;; clear them now so the shape is not clipped by the old frame. - (when @wasm-structure-modifiers-active? - (wasm.api/clean-modifiers) - (vreset! wasm-structure-modifiers-active? false)) - (let [objects (dsh/lookup-page-objects state)] - (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) - (wasm.api/clean-modifiers) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) - (vreset! wasm-structure-modifiers-active? true))) - (let [geometry-entries (parse-geometry-modifiers modif-tree) - root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) - wasm-ready? (wasm.api/initialized?) - ;; While the GL context is down (lost / mid-reload), keep the - ;; root transforms so SVG selection/preview can still move. - ;; `propagate-modifiers` returns [] when not ready, do not - ;; treat that as "no modifiers". - modifiers - (cond - (or (not wasm-ready?) - (and translation? (not snap-pixel?))) - root-modifiers + (if translation? + ;; Pure translation: no structure changes needed. If structure + ;; modifiers were active from a previous non-translation frame + ;; (e.g. shape hovered over a frame then dragged back out), + ;; clear them now so the shape is not clipped by the old frame. + (when @wasm-structure-modifiers-active? + (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false)) + (let [objects (dsh/lookup-page-objects state)] + (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) + (wasm.api/clean-modifiers) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (vreset! wasm-structure-modifiers-active? true))) + (let [geometry-entries (parse-geometry-modifiers modif-tree) + root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) + wasm-ready? (wasm.api/initialized?) + ;; While the GL context is down (lost / mid-reload), keep the + ;; root transforms so SVG selection/preview can still move. + ;; `propagate-modifiers` returns [] when not ready, do not + ;; treat that as "no modifiers". + modifiers + (cond + (or (not wasm-ready?) + (and translation? (not snap-pixel?))) + root-modifiers - :else - (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] - (if (seq propagated) propagated root-modifiers)))] - (when wasm-ready? - (wasm.api/set-modifiers modifiers)) - (let [ids (into [] xf:map-key geometry-entries) - selrect (when wasm-ready? - (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) - (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) - (wasm.api/get-selection-rect ids)))] - (rx/of (set-temporary-selrect selrect) - (set-temporary-modifiers modifiers)))))))) + :else + (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + (if (seq propagated) propagated root-modifiers)))] + (when wasm-ready? + (wasm.api/set-modifiers modifiers)) + (let [ids (into [] xf:map-key geometry-entries) + selrect (when wasm-ready? + (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids)))] + (rx/of (set-temporary-selrect selrect) + (set-temporary-modifiers modifiers))))))))) (defn propagate-structure-modifiers [modif-tree objects] @@ -782,58 +797,44 @@ subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] - (ptk/reify ::apply-wasm-modifiers - ptk/WatchEvent - (watch [_ state _] - (let [translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (wasm.api/clean-modifiers) - (when-not translation? - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::apply-wasm-modifiers + ptk/WatchEvent + (watch [_ state _] + (let [translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + (wasm.api/clean-modifiers) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) - ;; Apply property changes (e.g. grow-type) to WASM shapes before - ;; propagating geometry, so propagate_modifiers sees the updated state. - (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] - (when (= property :grow-type) - (wasm.api/use-shape id) - (wasm.api/set-shape-grow-type value))) + ;; Apply property changes (e.g. grow-type) to WASM shapes before + ;; propagating geometry, so propagate_modifiers sees the updated state. + (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] + (when (= property :grow-type) + (wasm.api/use-shape id) + (wasm.api/set-shape-grow-type value))) - (let [objects (dsh/lookup-page-objects state) + (let [objects (dsh/lookup-page-objects state) - geometry-entries - (parse-geometry-modifiers modif-tree) + geometry-entries + (parse-geometry-modifiers modif-tree) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - transforms - (cond - (and translation? (not snap-pixel?)) - ;; Mirror WASM `propagate_modifiers` in CLJS: splat the - ;; translation matrix onto every descendant. Without - ;; this step the commit would only touch the dragged - ;; primaries and descendants would snap back to their - ;; pre-drag positions on drop. - ;; - ;; Skipped when `snap-pixel?` is on: WASM applies - ;; per-shape pixel correction (different scale/translate - ;; per descendant) which we can't replicate cheaply on - ;; the CLJS side. - (reduce - (fn [acc [id data]] - (let [t (:transform data) - subtree-ids - (or (get subtree-ids-by-id id) - (cfh/get-children-ids-with-self objects id))] - (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) - {} - geometry-entries) - - ;; Context lost / mid-reload: do not call into WASM. Use - ;; root transforms (and splat translation onto descendants - ;; when we can) so the commit still lands in file data. - (not (wasm.api/initialized?)) - (if translation? + transforms + (cond + (and translation? (not snap-pixel?)) + ;; Mirror WASM `propagate_modifiers` in CLJS: splat the + ;; translation matrix onto every descendant. Without + ;; this step the commit would only touch the dragged + ;; primaries and descendants would snap back to their + ;; pre-drag positions on drop. + ;; + ;; Skipped when `snap-pixel?` is on: WASM applies + ;; per-shape pixel correction (different scale/translate + ;; per descendant) which we can't replicate cheaply on + ;; the CLJS side. (reduce (fn [acc [id data]] (let [t (:transform data) @@ -843,71 +844,87 @@ (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) {} geometry-entries) - (into {} - (map (fn [[id data]] [id (:transform data)])) - geometry-entries)) - :else - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) + ;; Context lost / mid-reload: do not call into WASM. Use + ;; root transforms (and splat translation onto descendants + ;; when we can) so the commit still lands in file data. + (not (wasm.api/initialized?)) + (if translation? + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} + (map (fn [[id data]] [id (:transform data)])) + geometry-entries)) - ignore-tree - (calculate-ignore-tree-wasm transforms objects) + :else + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - (assoc :translation? translation?) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) - modif-tree - (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) - ids - (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + modif-tree + (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) - update-shape - (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + ids + (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) + update-shape + (fn [shape] + (let [shape-id (dm/get-prop shape :id) + transform (get transforms shape-id) + modifiers (dm/get-in modif-tree [shape-id :modifiers])] + (-> shape + (gsh/apply-transform transform) + (ctm/apply-structure-modifiers modifiers)))) - undo-id (js/Symbol)] - (rx/concat - (if undo-transation? - (rx/of (dwu/start-undo-transaction undo-id)) - (rx/empty)) - (rx/of - (clear-local-transform) - (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) - (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) + bool-ids + (into #{} + (comp + (mapcat (partial cfh/get-parents-with-self objects)) + (filter cfh/bool-shape?) + (map :id)) + ids) - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info. - ;; `update-layout? false`: recalculating a bool path can never change - ;; `:hidden`, and the layout check would recompute the whole boolean - ;; path in WASM once per bool shape just to find that out. - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options - :with-objects? true - :update-layout? false))) + undo-id (js/Symbol)] - (if undo-transation? - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/empty)))))))) + (rx/concat + (if undo-transation? + (rx/of (dwu/start-undo-transaction undo-id)) + (rx/empty)) + (rx/of + (clear-local-transform) + (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) + (ptk/event ::dwcm/move-frame-comment-threads transforms) + (dwsh/update-shapes ids update-shape options) + + ;; The update to the bool path needs to be in a different operation because it + ;; needs to have the updated children info. + ;; `update-layout? false`: recalculating a bool path can never change + ;; `:hidden`, and the layout check would recompute the whole boolean + ;; path in WASM once per bool shape just to find that out. + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options + :with-objects? true + :update-layout? false))) + + (if undo-transation? + (rx/of (dwu/commit-undo-transaction undo-id)) + (rx/empty))))))))) (def ^:private xf-rotation-shape diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index a8d013012b..dba4d8b09c 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -335,14 +335,15 @@ (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape (get objects shape-id) - container (get objects (:parent-id shape)) - width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants - x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants - (rx/of - (dwt/update-dimensions [(:parent-id shape)] :width width) - (dwt/update-position shape-id - {:x x} - {:absolute? false})))))) + container (get objects (:parent-id shape))] + (when (and (some? shape) (some? container)) + (let [width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants + x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants + (rx/of + (dwt/update-dimensions [(:parent-id shape)] :width width) + (dwt/update-position shape-id + {:x x} + {:absolute? false})))))))) (defn add-new-variant "Create a new variant and add it to the variant-container" @@ -359,39 +360,40 @@ shape (get objects shape-id) shape (if (ctc/is-variant-container? shape) (get objects (last (:shapes shape))) - shape) - component-id (:component-id shape) - component (ctkl/get-component data component-id) + shape)] + (when (some? shape) + (let [component-id (:component-id shape) + component (ctkl/get-component data component-id) - container-id (:parent-id shape) - variant-container (get objects container-id) - has-layout? (ctsl/any-layout? variant-container) + container-id (:parent-id shape) + variant-container (get objects container-id) + has-layout? (ctsl/any-layout? variant-container) - new-component-id (uuid/next) - new-shape-id (uuid/next) + new-component-id (uuid/next) + new-shape-id (uuid/next) - prop-num (dec (count (:variant-properties component))) + prop-num (dec (count (:variant-properties component))) - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (pcb/with-page-id page-id) - (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) + changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (pcb/with-page-id page-id) + (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) - undo-id (js/Symbol)] - (rx/concat - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (when-not has-layout? - (resposition-and-resize-variant new-shape-id)) - (dwu/commit-undo-transaction undo-id) - (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) - (if multiselect? - (dws/shift-select-shapes new-shape-id) - (dws/select-shape new-shape-id))) - (->> (rx/of (focus-property (:id variant-container))) - (rx/delay 250)))))))) + undo-id (js/Symbol)] + (rx/concat + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (when-not has-layout? + (resposition-and-resize-variant new-shape-id)) + (dwu/commit-undo-transaction undo-id) + (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) + (if multiselect? + (dws/shift-select-shapes new-shape-id) + (dws/select-shape new-shape-id))) + (->> (rx/of (focus-property (:id variant-container))) + (rx/delay 250)))))))))) (defn transform-in-variant "Given the id of a main shape of a component, creates a variant structure for diff --git a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs new file mode 100644 index 0000000000..7e39bf91ee --- /dev/null +++ b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs @@ -0,0 +1,109 @@ +;; 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.logic.wasm-modifiers-nil-id-test + "Reproduces the production crash \"Cannot read properties of null + (reading '__u32_buffer')\". + + A modif-tree containing a nil shape id (production builds elide the + asserts that catch this upstream, e.g. `update-dimensions` called + with `[(:parent-id shape)]` when `shape` is missing) reached + `wasm.api/propagate-modifiers` / `wasm.api/set-structure-modifiers`, + and `mem.h32/write-uuid` crashed calling `uuid/get-u32` on nil while + writing to the WASM heap. + + These tests assert that no nil id ever crosses the WASM boundary and + that valid shapes in the same modif-tree are still processed." + (:require + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] + [app.common.uuid :as uuid] + [app.main.data.workspace.modifiers :as dwm] + [app.render-wasm.api :as wasm.api] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private captured-geometry-entries + "Entries passed to `wasm.api/propagate-modifiers` during a test." + (atom [])) + +(def ^:private captured-structure-entries + "Entries passed to `wasm.api/set-structure-modifiers` during a test." + (atom [])) + +(defn- install-capturing-spies! + "Replace the plain WASM mocks with variants that record their input. + Must run after `thw/setup-wasm-mocks!` so teardown still restores + the real implementations." + [] + (set! wasm.api/propagate-modifiers + (fn [entries _pixel-precision] + (swap! captured-geometry-entries into entries) + (into [] + (map (fn [[id data]] [id (:transform data)])) + entries))) + (set! wasm.api/set-structure-modifiers + (fn [entries] + (swap! captured-structure-entries into entries) + nil))) + +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (reset! captured-geometry-entries []) + (reset! captured-structure-entries []) + (thw/setup-wasm-mocks!) + (install-capturing-spies!)) + :after (fn [] + (thw/teardown-wasm-mocks!))}) + +(t/deftest nil-id-does-not-reach-propagate-modifiers + ;; A nil-keyed entry must be dropped before the WASM heap write while + ;; the valid entry is still resized. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + resize (ctm/change-dimensions-modifiers rect :width 200) + modif-tree {nil {:modifiers resize} + (:id rect) {:modifiers resize}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [new-state] + (let [entry-ids (into #{} (map first) @captured-geometry-entries) + file' (ths/get-file-from-state new-state) + rect' (cths/get-shape file' :rect1) + width (-> rect' :points grc/points->rect :width)] + (t/is (not (contains? entry-ids nil))) + (t/is (contains? entry-ids (:id rect))) + (t/is (mth/close? 200 width)))))))) + +(t/deftest nil-id-does-not-reach-set-structure-modifiers + ;; A nil-keyed entry with structure modifiers must not produce + ;; structure entries with a nil :parent or :id. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + modif-tree {nil {:modifiers (ctm/add-children nil [(uuid/next)] 0)} + (:id rect) {:modifiers (ctm/change-dimensions-modifiers rect :width 200)}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [_new-state] + (t/is (every? #(some? (:parent %)) @captured-structure-entries)) + (t/is (every? #(some? (:id %)) @captured-structure-entries))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 73749f9810..3430ceb41a 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -39,6 +39,7 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.logic.sidebar-transform-coalescing-test] [frontend-tests.logic.update-position-test] + [frontend-tests.logic.wasm-modifiers-nil-id-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] @@ -138,6 +139,7 @@ 'frontend-tests.main-errors-test 'frontend-tests.logic.sidebar-transform-coalescing-test 'frontend-tests.logic.update-position-test + 'frontend-tests.logic.wasm-modifiers-nil-id-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test