🐛 Fix the component watcher and library updates

Fixes in when the workspace syncs copies and propagates tokens, and in what
a layout wait covers (Phase 3c).

- Sync the copies when a shape is dragged inside a main.

  The component watcher dropped every commit flagged :translation? (the WASM
  drag commits only moves) so dragging whole mains around the canvas stays
  fast. A drag of a shape inside a main is flagged the same, so its copies
  never followed until some other edit of that main made them jump (F70).

  The watcher now drops a translation commit only when it moves shapes
  outside mains or whole mains: every shape it moves inside a main moves
  with the main's root (decision (q)). Otherwise the commit goes through
  component sync like any edit.

  workspace-sync-barrier-test: a translation of a whole main is dropped; one
  of a main's child syncs the copy. fill-commit in the interpreter takes
  several labels.

- Propagate the file's tokens after any library update.

  Updating a library from the notification synced the file and then
  propagated its tokens, so the copies take the token values of the file's
  token status, not the library's. The update button of the libraries
  dialog's Updates tab only synced the file (F83).

  dwl/update-libraries syncs the file from the given libraries and then
  propagates its tokens; the notification and the Updates tab both use it.

  workspace-library-updates-test: updating a library syncs it and then
  propagates the tokens.

- Sync copies whether or not component thumbnails are on.

  The component watcher's whole pipeline, the sync of the copies included,
  ran only when the :component-thumbnails flag was on or the WASM renderer
  was active. The flag is on by default, but an install that turned it off
  never synced copies on the SVG renderer: editing a main left its copies as
  they were (F8). The flag now gates only the thumbnail streams.

  workspace-sync-barrier-test: with the flag off, a main edit reaches the
  copy.

- Hold a token's shapes pending until the token is applied.

  A plugin's waitForLayoutUpdate waits until no shape has layout work
  pending. Applying a token resolves the tokens first, asynchronously, and
  opened no pending work meanwhile, so the wait found nothing pending and
  returned before the token reached the shapes or their copies (F54). The
  shapes are now pending, as a :token task, from the start of the resolution
  until the update is emitted, and the layout, text and sync work it starts
  takes over from there.

  The composable interpreter no longer waits on the token itself: case AY
  applies a token and checks its copies after the same wait a plugin uses,
  and fails without this change.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-15 15:11:31 +02:00
parent 1e61322dc5
commit 10f8dc9770
7 changed files with 132 additions and 69 deletions

View File

@ -1215,6 +1215,18 @@
(when (> (:modified-at library) sync-date)
(ctf/used-assets-changed-since file-data library sync-date))))))
(defn update-libraries
"Sync the file `file-id` from each library of `library-ids`, then
propagate the file's tokens, so that its copies take the token values
of the file's token status, not the library's."
[file-id library-ids]
(ptk/reify ::update-libraries
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(rx/from (map #(sync-file file-id %) library-ids))
(rx/of (dwtp/propagate-workspace-tokens))))))
(defn notify-sync-file
"Notify the user that there are updates in the libraries used by the
current file, and ask if he wants to update them now."
@ -1264,13 +1276,8 @@
#(modal/show! :libraries-dialog {:starting-tab "updates" :file-id file-id})
do-update
#(do (apply st/emit! (map (fn [library]
(sync-file file-id (:id library)))
libraries-with-changes))
;; Launch a local tokens propagation, so that the copies have the token values
;; with the local tokens status, not the one coming from the external library.
(st/emit! (dwtp/propagate-workspace-tokens))
(st/emit! (ntf/hide)))
#(st/emit! (update-libraries file-id (map :id libraries-with-changes))
(ntf/hide))
do-dismiss
#(st/emit! ignore-sync (ntf/hide))]
@ -1326,6 +1333,21 @@
(component-sync-events state component-id file-id undo-group)
[(dwu/commit-undo-transaction undo-id)]))))))
(defn- moves-whole-mains?
"Whether the `changes` of a translation commit, applied on the file
`data`, move only shapes outside mains, or whole mains: every shape they
move inside a main moves with the main's root."
[data changes]
(every? (fn [[page-id changes]]
(let [objects (dm/get-in data [:pages-index page-id :objects])
ids (into #{} (map :id) changes)]
(every? (fn [id]
(let [root (ctn/get-component-shape objects (get objects id) {:allow-main? true})]
(or (not (ctk/main-instance? root))
(contains? ids (:id root)))))
ids)))
(group-by :page-id (filter #(= :mod-obj (:type %)) changes))))
(defn watch-component-changes
"Watch the state for changes that affect to any main instance. If a change is detected will call
component-changed, so changes are immediately propagated to the component and copies."
@ -1380,12 +1402,15 @@
(rx/map start-sync-barrier)
(rx/share))
;; Translation commits never propagate component changes. Derived /
;; A translation commit that moves whole mains changes no
;; component; one that moves a shape inside a main does. Derived /
;; corrective commits (font-load selrect fix, position-data regen)
;; are not user component edits.
dropped-commit?
(fn [[{:keys [translation? skip-component-sync?]} _]]
(or translation? skip-component-sync?))
(fn [[{:keys [translation? skip-component-sync? changes]} _]]
(or skip-component-sync?
(and translation?
(moves-whole-mains? @refs/workspace-data changes))))
dropped-commits-s
(->> local-commits-s
@ -1456,40 +1481,45 @@
(rx/debounce 5000)
(rx/tap #(log/trc :hint "buffer initialized")))]
(when (or (contains? cf/flags :component-thumbnails)
(features/active-feature? state "render-wasm/v1"))
(->> (rx/merge
dropped-commits-s
component-events-s
(->> (rx/merge
dropped-commits-s
component-events-s
;; WASM only: render the thumbnail on every component
;; change so single edits (fill, etc.) update instantly.
;; Non-WASM persists on every render, so it stays on the
;; debounced path below to avoid per-edit backend posts.
(if (features/active-feature? state "render-wasm/v1")
(->> component-events-s
(rx/filter (ptk/type? ::component-changed))
(rx/map deref)
(rx/map render-component-thumbnail-event))
(rx/empty))
;; the flag (or WASM) turns on the thumbnails of components,
;; not their sync
(if (or (contains? cf/flags :component-thumbnails)
(features/active-feature? state "render-wasm/v1"))
(rx/merge
;; Persist to the server in batches, 5s after the user
;; goes idle.
(->> component-events-s
(rx/filter (ptk/type? ::component-changed))
(rx/map deref)
(rx/buffer-until notifier-s)
(rx/mapcat #(into #{} %))
(rx/map (fn [[component-id file-id]]
(update-component-thumbnail component-id file-id))))
;; WASM only: render the thumbnail on every component
;; change so single edits (fill, etc.) update instantly.
;; Non-WASM persists on every render, so it stays on the
;; debounced path below to avoid per-edit backend posts.
(if (features/active-feature? state "render-wasm/v1")
(->> component-events-s
(rx/filter (ptk/type? ::component-changed))
(rx/map deref)
(rx/map render-component-thumbnail-event))
(rx/empty))
;; Undo/redo emit touch-component instead.
(->> component-events-s
(rx/filter (ptk/type? ::touch-component))
(rx/map deref)
(rx/map render-component-thumbnail-event)))
;; Persist to the server in batches, 5s after the user
;; goes idle.
(->> component-events-s
(rx/filter (ptk/type? ::component-changed))
(rx/map deref)
(rx/buffer-until notifier-s)
(rx/mapcat #(into #{} %))
(rx/map (fn [[component-id file-id]]
(update-component-thumbnail component-id file-id))))
(rx/take-until stopper-s)))))))
;; Undo/redo emit touch-component instead.
(->> component-events-s
(rx/filter (ptk/type? ::touch-component))
(rx/map deref)
(rx/map render-component-thumbnail-event)))
(rx/empty)))
(rx/take-until stopper-s))))))
(defn sync-tokens-status-with-lib
[]

View File

@ -26,6 +26,7 @@
:text-position DOM text fragment geometry (texts)
:text-bridge change awaiting its pipeline (texts)
:font font change measurement (texts)
:token token resolution (tokens application)
:sync-file component/library propagation (libraries)"
(:require
[beicon.v2.core :as rx]

View File

@ -26,6 +26,7 @@
[app.main.data.tinycolor :as tinycolor]
[app.main.data.tokenscript :as ts]
[app.main.data.workspace.colors :as wdc]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.transforms :as dwtr]
@ -715,6 +716,8 @@
tokens-status (dsh/lookup-tokens-status state)]
(when (and tokens-lib tokens-status)
(cfo/get-tokens-in-active-sets tokens-status tokens-lib)))]
;; the shapes are pending while the tokens resolve, so a wait
;; for the layout covers the update that follows
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
@ -761,7 +764,8 @@
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))
(rx/of (dwu/commit-undo-transaction undo-id))))))
(wrf/with-pending :token shape-ids))))
(when text-editing?
(rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")

View File

@ -994,7 +994,7 @@
(when library-id
(st/emit!
(dwl/set-updating-library true)
(dwl/sync-file file-id library-id)))))))]
(dwl/update-libraries file-id [library-id])))))))]
[:div {:class (stl/css-case :updates-content (not is-legacy)
:updates-content-legacy is-legacy)}

View File

@ -364,13 +364,14 @@
(-> (cths/get-shape (current-file) label) :fills first :fill-color))
(defn fill-commit
"A commit that fills the shape `label` of the live file with `color`, with
`opts` merged into the commit params."
"A commit that fills the shape `label` (or each of the labels in a
collection) of the live file with `color`, with `opts` merged into the
commit params."
[label color opts]
(let [page (cthf/current-page (current-file))
changes (cls/generate-update-shapes
(pcb/empty-changes nil (:id page))
#{(cthi/id label)}
(into #{} (map cthi/id) (if (keyword? label) [label] label))
#(assoc % :fills (cths/sample-fills-color :fill-color color))
(:objects page)
{})]
@ -395,11 +396,11 @@
(defn await-step
"Emit `events` and return a promise of `:settled` once the pending work of
`app.main.data.workspace.reflow` drains (component sync, layout and text
reflow), as the plugin `waitForLayoutUpdate` waits; or of
`{:timeout pending}` after `timeout-ms` (default `step-timeout-ms`). Work
must be opened while the events are processed; for events that commit
only after asynchronous work that opens none (token resolution), pass
`ready?`, which must hold before the pending work is awaited."
reflow, token resolution), as the plugin `waitForLayoutUpdate` waits; or
of `{:timeout pending}` after `timeout-ms` (default `step-timeout-ms`).
Work must be opened while the events are processed; for events whose
effect waits on something that opens none (a dialog shown to the user),
pass `ready?`, which must hold before the pending work is awaited."
([events] (await-step events step-timeout-ms))
([events timeout-ms] (await-step events timeout-ms nil))
([events timeout-ms ready?]
@ -452,20 +453,6 @@
[op situation]
(op->events (unwrap op) situation))
(defn- live-shape
"The shape `id` of the current page of the live file."
[id]
(get-in (current-file) [:data :pages-index (:current-page-id @st/state) :objects id]))
(defn- op-ready
"For the op unit `op` whose events commit only after asynchronous work that
opens no pending work, a predicate of when they have committed; else nil."
[op situation]
(let [op (unwrap op)]
(when (instance? n/ApplyToken op)
(let [id (tm/target-shape-id situation (:target op))]
#(= (:token-name op) (get-in (live-shape id) [:applied-tokens :r1]))))))
(defn- install-file-event
"An event that makes `file` the current file in the store, the result of a
`sync-op?` operation."
@ -695,7 +682,7 @@
(run-ops situation (rest ops)))
(do
(reset! step-errors [])
(.then (await-step (op-events op situation) step-timeout-ms (op-ready op situation))
(.then (await-step (op-events op situation) step-timeout-ms)
(fn [result]
(let [[errors] (reset-vals! step-errors [])]
(cond

View File

@ -18,6 +18,7 @@
[app.common.test-helpers.shapes :as cths]
[app.common.types.file :as ctf]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.repo :as rp]
[app.main.store :as st]
[beicon.v2.core :as rx]
@ -145,6 +146,24 @@
(t/is (not= synced-at (:synced-at (live-library))))
(t/is (= :settled (await (await-sync-status-sent)))))))
(t/deftest ^:async updating-a-library-propagates-the-file-tokens
;; the update of the sync dialog and of the libraries dialog's updates tab
(await
(mock/with-mocks*
{rp/cmd! rpc-on-subscribe
rx/timer mock/timer-mock}
(install-with-library! true)
(let [events (atom [])
sub (rx/sub! (->> st/stream
(rx/map ptk/type)
(rx/filter #{::dwl/sync-file ::dwtp/propagate-workspace-tokens}))
#(swap! events conj %))]
(t/is (= :settled (await (ftm/await-step [(dwl/update-libraries (:id (ftm/current-file))
[(:id (live-library))])]))))
(await (await-until #(= new-color (copy-fill))))
(rx/dispose! sub)
(t/is (= [::dwl/sync-file ::dwtp/propagate-workspace-tokens] @events))))))
(t/deftest ^:async sync-dialog-dismiss-ignores-the-changes
(await
(mock/with-mocks*

View File

@ -12,6 +12,7 @@
(:require
[app.common.test-helpers.composable.comp.setups :as setup]
[app.common.test-helpers.ids-map :as thi]
[app.config :as cf]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.reflow :as wrf]
[app.main.store :as st]
@ -32,7 +33,8 @@
(defn- run-commit
"Install a component with a copy and emit the main child fill commit with
`commit-opts`. Once the wait settles, call `check` (which may return a
`commit-opts` (with `:labels`, the fill commit of those shapes). Once the
wait settles, call `check` (which may return a
promise) with whether the sync wait was open right after the commit and an
atom with the ids of the components the watcher touched. Fails the test
when the wait does not settle."
@ -42,7 +44,7 @@
touched (atom #{})
sub (rx/sub! (rx/filter (ptk/type? ::dwl/touch-component) st/stream)
#(swap! touched into @%))]
(st/emit! (ftm/fill-commit :main-child red commit-opts))
(st/emit! (ftm/fill-commit (:labels commit-opts :main-child) red (dissoc commit-opts :labels)))
(let [open? (sync-pending? file-id)]
(-> (pwrf/wait-for-layout-update nil 2000)
(.then #(check open? touched)
@ -80,10 +82,30 @@
(t/is (= setup/labeled-fill (ftm/fill-of :copy-child)))
(t/is (empty? @touched))))))
(t/deftest translation-commit-leaves-nothing-pending
(t/deftest translation-of-a-whole-main-leaves-nothing-pending
(t/async done
(run-commit done {:translation? true} dropped)))
(run-commit done {:translation? true :labels [:main-root :main-child]} dropped)))
(t/deftest translation-of-a-child-inside-a-main-syncs-the-copies
;; dragging a shape inside a main is an edit its copies follow
(t/async done
(run-commit done {:translation? true}
(fn [open? _]
(t/is open?)
(t/is (= red (ftm/fill-of :copy-child)))))))
(t/deftest derived-commit-leaves-nothing-pending
(t/async done
(run-commit done {:skip-component-sync? true} dropped)))
(t/deftest copies-sync-without-component-thumbnails
;; the flag only turns the thumbnails of components off
(t/async done
(with-redefs [cf/flags (disj cf/flags :component-thumbnails)]
(ftm/install! (setup/simple-component-with-labeled-copy)))
(st/emit! (ftm/fill-commit :main-child red {}))
(-> (pwrf/wait-for-layout-update nil 2000)
(.then #(t/is (= red (ftm/fill-of :copy-child))))
(.catch #(t/is false (str "the wait threw: " %)))
(.finally done))))