🐛 Fix pluings API theme.addSet() crash caused by async state race in token-set proxy (#8700)

When `catalog.addSet()` creates a new token set, `st/emit!` is async —
the set is not yet in `@st/state` when the returned proxy is used.
Calling `theme.addSet(proxy)` immediately after reads `.name` from the
proxy, which calls `locate-token-set` on stale state → returns nil →
`enable-set` conjs nil into the theme's `:sets` → backend rejects with
400 (`:sets #{nil}`) → workspace reloads → plugin disconnects.

Fix: store `initial-name` in the proxy at construction time as a
fallback for the `:name` getter during the async propagation window.
Also add nil guards in `addSet`/`removeSet` as defense-in-depth.

Closes #8698

Signed-off-by: rodo <roland@dolltons.com>
This commit is contained in:
Roland 2026-03-23 11:24:29 +01:00 committed by GitHub
parent 72fd637ec2
commit bfb331d230
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -203,123 +203,130 @@
(obj/type-of? p "TokenSetProxy")) (obj/type-of? p "TokenSetProxy"))
(defn token-set-proxy (defn token-set-proxy
[plugin-id file-id id] ([plugin-id file-id id]
(obj/reify {:name "TokenSetProxy" (token-set-proxy plugin-id file-id id nil))
:on-error (u/handle-error plugin-id)} ([plugin-id file-id id initial-name]
:$plugin {:enumerable false :get (constantly plugin-id)} (obj/reify {:name "TokenSetProxy"
:$file-id {:enumerable false :get (constantly file-id)} :on-error (u/handle-error plugin-id)}
:$id {:enumerable false :get (constantly id)} :$plugin {:enumerable false :get (constantly plugin-id)}
:$file-id {:enumerable false :get (constantly file-id)}
:$id {:enumerable false :get (constantly id)}
:id :id
{:get #(dm/str id)} {:get #(dm/str id)}
:name :name
{:this true {:this true
:get :get
(fn [_]
;; Prefer the authoritative state lookup; fall back to initial-name
;; when the async state update from `catalog.addSet()` hasn't
;; propagated yet.
(let [set (u/locate-token-set file-id id)]
(if (some? set)
(ctob/get-name set)
initial-name)))
:schema (cfo/make-token-set-name-schema
(u/locate-tokens-lib file-id)
id)
:set
(fn [_ name]
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/rename-token-set set name))))}
:active
{:this true
:enumerable false
:get
(fn [_]
(let [tokens-lib (u/locate-tokens-lib file-id)
set (u/locate-token-set file-id id)]
(ctob/token-set-active? tokens-lib (ctob/get-name set))))
:schema ::sm/boolean
:set
(fn [_ value]
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))}
:toggleActive
(fn [_] (fn [_]
(let [set (u/locate-token-set file-id id)] (let [set (u/locate-token-set file-id id)]
(ctob/get-name set))) (st/emit! (dwtl/toggle-token-set (ctob/get-name set)))))
:schema (cfo/make-token-set-name-schema
(u/locate-tokens-lib file-id)
id)
:set
(fn [_ name]
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/rename-token-set set name))))}
:active :tokens
{:this true {:this true
:enumerable false :enumerable false
:get :get
(fn [_] (fn [_]
(let [tokens-lib (u/locate-tokens-lib file-id) (let [tokens-lib (u/locate-tokens-lib file-id)]
set (u/locate-token-set file-id id)] (->> (ctob/get-tokens tokens-lib id)
(ctob/token-set-active? tokens-lib (ctob/get-name set)))) (vals)
:schema ::sm/boolean (map #(token-proxy plugin-id file-id id (:id %)))
:set (apply array))))}
(fn [_ value]
(let [set (u/locate-token-set file-id id)]
(st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))}
:toggleActive :tokensByType
(fn [_] {:this true
(let [set (u/locate-token-set file-id id)] :enumerable false
(st/emit! (dwtl/toggle-token-set (ctob/get-name set))))) :get
(fn [_]
(let [tokens-lib (u/locate-tokens-lib file-id)
tokens (ctob/get-tokens tokens-lib id)]
(->> tokens
(vals)
(sort-by :name)
(group-by #(cto/token-type->dtcg-token-type (:type %)))
(into [])
(mapv (fn [[type tokens]]
#js [(name type)
(->> tokens
(map #(token-proxy plugin-id file-id id (:id %)))
(apply array))]))
(apply array))))}
:tokens :getTokenById
{:this true {:enumerable false
:enumerable false :schema [:tuple ::sm/uuid]
:get :fn (fn [token-id]
(fn [_] (let [token (u/locate-token file-id id token-id)]
(let [tokens-lib (u/locate-tokens-lib file-id)] (when (some? token)
(->> (ctob/get-tokens tokens-lib id) (token-proxy plugin-id file-id id token-id))))}
(vals)
(map #(token-proxy plugin-id file-id id (:id %)))
(apply array))))}
:tokensByType :addToken
{:this true {:enumerable false
:enumerable false :schema (fn [args]
:get [:tuple (-> (cfo/make-token-schema
(fn [_] (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id))
(let [tokens-lib (u/locate-tokens-lib file-id) (cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
tokens (ctob/get-tokens tokens-lib id)] ;; Don't allow plugins to set the id
(->> tokens (sm/dissoc-key :id)
(vals) ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below)
(sort-by :name) ;; and set a converter that changes DTCG types to internal types (:decode/json).
(group-by #(cto/token-type->dtcg-token-type (:type %))) ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width
(into []) (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])
(mapv (fn [[type tokens]] :decode/options {:key-fn identity}
#js [(name type) :fn (fn [attrs]
(->> tokens (let [tokens-lib (u/locate-tokens-lib file-id)
(map #(token-proxy plugin-id file-id id (:id %))) token (ctob/make-token attrs)
(apply array))])) tokens-tree (-> (ctob/get-tokens-in-active-sets tokens-lib)
(apply array))))} (assoc (:name token) token))
resolved-tokens (ts/resolve-tokens tokens-tree)
:getTokenById {:keys [errors resolved-value] :as resolved-token}
{:enumerable false (get resolved-tokens (:name token))]
:schema [:tuple ::sm/uuid]
:fn (fn [token-id]
(let [token (u/locate-token file-id id token-id)]
(when (some? token)
(token-proxy plugin-id file-id id token-id))))}
:addToken (if resolved-value
{:enumerable false (do (st/emit! (dwtl/create-token id token))
:schema (fn [args] (token-proxy plugin-id file-id id (:id token)))
[:tuple (-> (cfo/make-token-schema (do (u/not-valid plugin-id :addToken (str errors))
(-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) nil))))}
(cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
;; Don't allow plugins to set the id
(sm/dissoc-key :id)
;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below)
;; and set a converter that changes DTCG types to internal types (:decode/json).
;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])
:decode/options {:key-fn identity}
:fn (fn [attrs]
(let [tokens-lib (u/locate-tokens-lib file-id)
token (ctob/make-token attrs)
tokens-tree (-> (ctob/get-tokens-in-active-sets tokens-lib)
(assoc (:name token) token))
resolved-tokens (ts/resolve-tokens tokens-tree)
{:keys [errors resolved-value] :as resolved-token} :duplicate
(get resolved-tokens (:name token))] (fn []
(st/emit! (dwtl/duplicate-token-set id)))
(if resolved-value :remove
(do (st/emit! (dwtl/create-token id token)) (fn []
(token-proxy plugin-id file-id id (:id token))) (st/emit! (dwtl/delete-token-set id))))))
(do (u/not-valid plugin-id :addToken (str errors))
nil))))}
:duplicate
(fn []
(st/emit! (dwtl/duplicate-token-set id)))
:remove
(fn []
(st/emit! (dwtl/delete-token-set id)))))
(defn token-theme-proxy? [p] (defn token-theme-proxy? [p]
(obj/type-of? p "TokenThemeProxy")) (obj/type-of? p "TokenThemeProxy"))
@ -408,15 +415,26 @@
{:enumerable false {:enumerable false
:schema [:tuple [:fn token-set-proxy?]] :schema [:tuple [:fn token-set-proxy?]]
:fn (fn [token-set] :fn (fn [token-set]
(let [theme (u/locate-token-theme file-id id)] ;; Resolve the set name before the theme lookup. The proxy's :name
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get token-set :name))))))} ;; getter now falls back to `initial-name` when state hasn't
;; propagated, so this is safe even for freshly created sets.
;; Guard against nil to prevent `enable-set` from conj'ing nil
;; into the theme's :sets — which would send `:sets #{nil}` to the
;; backend and crash the workspace.
(let [set-name (obj/get token-set :name)
theme (u/locate-token-theme file-id id)]
(when (and (some? set-name) (some? theme))
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))}
:removeSet :removeSet
{:enumerable false {:enumerable false
:schema [:tuple [:fn token-set-proxy?]] :schema [:tuple [:fn token-set-proxy?]]
:fn (fn [token-set] :fn (fn [token-set]
(let [theme (u/locate-token-theme file-id id)] ;; Same nil guard as addSet — see comment above.
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get token-set :name))))))} (let [set-name (obj/get token-set :name)
theme (u/locate-token-theme file-id id)]
(when (and (some? set-name) (some? theme))
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))}
:duplicate :duplicate
(fn [] (fn []
@ -484,7 +502,10 @@
(let [attrs (update attrs :name ctob/normalize-set-name) (let [attrs (update attrs :name ctob/normalize-set-name)
set (ctob/make-token-set attrs)] set (ctob/make-token-set attrs)]
(st/emit! (dwtl/create-token-set set)) (st/emit! (dwtl/create-token-set set))
(token-set-proxy plugin-id file-id (ctob/get-id set))))} ;; Pass the set name as `initial-name` so the proxy can resolve
;; it immediately, before the async `st/emit!` above propagates
;; the new set into `@st/state`.
(token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set))))}
:getThemeById :getThemeById
{:enumerable false {:enumerable false