✨ Sync the components a sync changes in the same commit

Decision (c) of the component contract, after a fix that made the sync of a
library file reach that file at all (Phase 3c).

- Commit the sync of a library file to that file.

  An update of the main of a library component, made from a copy in the
  file, syncs the copies of the component in the file and in the library.
  The changes of a sync carry no file id, so the commit took the current
  file's: the library's copies, among them those its other mains nest, were
  written to pages the current file does not have, and never changed (F19).
  A sync now commits to the file it syncs.

  workspace-remote-cascade-test: an update of the main of a library
  component reaches the copy of it that another library main nests.

- Sync the components a sync changes in the same commit.

  Decision (c) of the component contract: a sync reaches the copies of the
  components it changes, until none is left, in one commit. A sync of a file
  changed the mains of the components that nest the synced copies, and the
  component watcher synced those in a commit of its own, one level per pass,
  so the file stayed partly synced in between; the watcher only watches the
  current file, so a sync of a library file stopped at its first level
  (F19).

  common: generate-sync-file-cascade syncs a file as
  generate-sync-file-changes does, then the copies of each component of the
  file that the sync changed, one per pass, applying each pass to the file
  data, until none is left (generate-sync-components-cascade). The
  composable runner, the benchmark and the golden master use it.

  frontend: sync-file commits the whole cascade at once. When it syncs a
  library file, it then syncs the copies in the current file of the library
  components the cascade changed.

  workspace-remote-cascade-test: an update of the main of a library
  component, made from a copy in the file, reaches the file's copy of a
  library component whose main nests it. The common golden master records
  fewer changes undone in case W: a cascade is one change set.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  17.0 / 0.48
  edit-through-a-chain-of-dependents   68.9 /  33.2 / 0.48
  library-update-into-copies           83.1 /  43.6 / 0.52
  main-edit-among-many-shapes           4.8 /   3.5 / 0.73
  main-edit-into-10-copies              1.8 /   0.9 / 0.49
  main-edit-into-100-copies            15.4 /   8.1 / 0.52
  main-edit-into-1000-copies          153.1 /  83.9 / 0.55
  variant-switch-into-copies           32.1 /  19.6 / 0.61

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-15 15:11:35 +02:00
parent e0bbc67605
commit 52e1ddfd1f
6 changed files with 197 additions and 60 deletions

View File

@ -15,6 +15,7 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes :as cfc]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
@ -1426,6 +1427,85 @@
(maybe-sync sync-typographies? done? #(generate-sync-file % file-id :typographies asset-id library-id libraries current-file-id early-return?))))))
;; A guard against a sync cascade that never settles.
(def ^:private max-sync-passes 100)
(defn changed-components
"Ids of the components whose main `changes` modified, judged against
`old-data`, the file data before `changes` applied."
[old-data changes]
(into #{}
(mapcat (partial cfc/components-changed old-data))
(:redo-changes changes)))
(defn- sync-cascade
"Sync the copies of the components `pending` of the file `file-id` in
that file, one per pass, and queue the components each pass changed,
until none is left. `data` is the file data after `changes`. Returns
`{:changes :data :component-ids :passes}`, with `component-ids` the
components synced."
[changes data file-id pending libraries current-file-id passes]
(loop [changes changes
data data
pending (vec pending)
synced #{}
passes passes]
(cond
(empty? pending)
{:changes changes :data data :component-ids synced :passes passes}
(>= passes max-sync-passes)
(throw (ex-info "Component sync did not settle"
{:type ::sync-not-settled
:pending pending
:passes passes}))
:else
(let [component-id (first pending)
libraries (assoc-in libraries [file-id :data] data)
sync (generate-sync-file-changes (pcb/empty-changes)
nil
:components
file-id
component-id
file-id
libraries
current-file-id)
data' (cfc/process-changes data (:redo-changes sync) false)
more (->> (changed-components data sync)
(remove (set pending)))]
(recur (pcb/concat-changes changes sync)
data'
(into (subvec pending 1) more)
(conj synced component-id)
(inc passes))))))
(defn generate-sync-components-cascade
"Sync the copies of the components `component-ids` of the file `file-id`
in that file, then those of the components each sync changed, until
none is left (`mem:common/component-sync-contract`, decision (c)).
`libraries` holds every file by id, `file-id` included. Returns
`{:changes :data :component-ids :passes}`: the changes of every pass in
order, the file data after them, the components synced and the number
of passes."
[changes file-id component-ids libraries current-file-id]
(sync-cascade changes (get-in libraries [file-id :data]) file-id component-ids
libraries current-file-id 0))
(defn generate-sync-file-cascade
"As `generate-sync-file-changes`, then the cascade of
`generate-sync-components-cascade` from the components of `file-id`
that sync changed. Returns `{:changes :data :component-ids :passes}`."
[changes undo-group asset-type file-id asset-id library-id libraries current-file-id]
(let [data (get-in libraries [file-id :data])
sync (generate-sync-file-changes (pcb/empty-changes) nil asset-type file-id asset-id
library-id libraries current-file-id)
data' (cfc/process-changes data (:redo-changes sync) false)]
(sync-cascade (-> (pcb/set-undo-group changes undo-group)
(pcb/concat-changes sync))
data' file-id (changed-components data sync)
libraries current-file-id 1)))
(defn generate-reset-component
"Reset the copy head to its near main. A swapped head turns back into the
component of the main shape it refers to, keeping its id."

View File

@ -11,7 +11,7 @@
- Sync. After a user operation (kind `:user`, see
`n/IComponentOperation`), it syncs each component that
`ch/components-changed` reports, then the components each sync changed,
`cll/changed-components` reports, then the components each sync changed,
until none is left. The frontend component watcher does the same, one
pass per sync commit.
- Undo and redo. A user operation and the syncs it caused form one undo
@ -31,7 +31,6 @@
and the frontend follow-ups of a swap or switch (token propagation, WASM
text resize, layout update)."
(:require
[app.common.files.changes :as ch]
[app.common.files.changes-builder :as pcb]
[app.common.logic.libraries :as cll]
[app.common.test-helpers.composable.comp.nodes :as n]
@ -42,16 +41,10 @@
(def ^:private default-opts
{:sync? true})
;; A guard against a sync cascade that never settles.
(def ^:private max-sync-passes 100)
(defn changed-components
(def changed-components
"Ids of the components whose main `changes` modified, judged against
`old-data`, the file data before `changes` applied."
[old-data changes]
(into #{}
(mapcat (partial ch/components-changed old-data))
(:redo-changes changes)))
cll/changed-components)
(defn- libraries
"The situation's files by id, with `file` as its current file."
@ -69,44 +62,22 @@
situation))
(defn sync-cascade
"Sync the copies of `component-ids` in `file`, then those of the components
each sync changed, until none is left, as the frontend `sync-file` event
builds the changes for a local component; the frontend watcher runs one
pass per sync commit. `libraries` are the other files by id. Returns
`{:file :syncs :passes}`, with `syncs` the sync changes that changed
something, in order."
"Sync the copies of `component-ids` in `file`, then those of the
components each sync changed, until none is left, as the frontend
`sync-file` event does (`cll/generate-sync-components-cascade`).
`libraries` are the other files by id. Returns `{:file :syncs
:passes}`, with `syncs` the sync changes, if they changed something."
[file libraries component-ids]
(loop [file file
pending (vec component-ids)
syncs []
passes 0]
(cond
(empty? pending)
{:file file :syncs syncs :passes passes}
(>= passes max-sync-passes)
(throw (ex-info "Component sync did not settle"
{:type ::sync-not-settled
:pending pending
:passes passes}))
:else
(let [file-id (:id file)
sync (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
file-id
(first pending)
file-id
(assoc libraries file-id file)
file-id)
file' (thf/apply-changes file sync :validate? false)
more (->> (changed-components (:data file) sync)
(remove (set pending)))]
(recur file'
(into (subvec pending 1) more)
(cond-> syncs (seq (:redo-changes sync)) (conj sync))
(inc passes))))))
(let [file-id (:id file)
{:keys [changes data passes]}
(cll/generate-sync-components-cascade (pcb/empty-changes)
file-id
component-ids
(assoc libraries file-id file)
file-id)]
{:file (assoc file :data data)
:syncs (if (seq (:redo-changes changes)) [changes] [])
:passes passes}))
(defn- sync-file
"Sync `situation`'s file, starting from the components that `changes`

View File

@ -182,8 +182,8 @@
[{:transcript "variant-switch-into-copies", :states ["6cdf94ad6bd691c5"]}]
:w
[{:transcript "create-component {:component \"main\", :id sync-main-component}\n one-of -> skip\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 5}", :states ["f06eb554db2a667d" "906c1b936c948e9c" "8e29f79b473c1a0b" "0072b33980dae904" "bd57e8070d829bdf"]}
{:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 6}", :states ["1de51816cdc0ad11" "ed2117b3a6ae1dd6" "c90876e1b44d00fb" "a5b1e4b00086c891" "f09bed0fa67d711c"]}
{:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 7}", :states ["262f632dd4a0ac41" "af99191229bac7b4" "8ba7cefbe6e101d8" "ab0a68e1af334a44" "987979e4d37001b6"]}]
{:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 5}", :states ["1de51816cdc0ad11" "ed2117b3a6ae1dd6" "c90876e1b44d00fb" "a5b1e4b00086c891" "f09bed0fa67d711c"]}
{:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 5}", :states ["262f632dd4a0ac41" "af99191229bac7b4" "8ba7cefbe6e101d8" "ab0a68e1af334a44" "987979e4d37001b6"]}]
:y
[{:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :text, :value \"copy text\"}\n edit-text {:target :main-instance, :edit :text, :value \"main text\"}", :states ["73a1f9665fb9d817" "c6b6d7c691dfcaa8" "728400277dd671c4"]}
{:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :text, :value \"copy text\"}\n edit-text {:target :main-instance, :edit :font-size, :value \"24\"}", :states ["73a1f9665fb9d817" "85eac5dd891c03b4" "3eaf765ba42c63d1"]}

View File

@ -1093,7 +1093,7 @@
(rx/of (set-updating-library false)
(ntf/hide {:tag :sync-dialog}))
(when (seq (:redo-changes changes))
(rx/of (dch/commit-changes changes)))
(rx/of (dch/commit-changes (assoc changes :file-id file-id))))
(when (seq text-ids)
(rx/of (dwwt/resize-wasm-text-all text-ids {:skip-component-sync? true
:undo-group undo-group})))
@ -1157,16 +1157,19 @@
libraries (dsh/lookup-libraries state)
current-file-id (:current-file-id state)
changes (-> (cll/generate-sync-file-changes
(pcb/empty-changes it)
undo-group
asset-type
file-id
asset-id
library-id
libraries
current-file-id)
(fix-font-variants))
;; the components of the file the sync changes are synced
;; too, until none is left, in one commit
{:keys [changes component-ids]}
(cll/generate-sync-file-cascade (pcb/empty-changes it)
undo-group
asset-type
file-id
asset-id
library-id
libraries
current-file-id)
changes (fix-font-variants changes)
find-frames (fn [change]
(->> (ch/frames-changed ldata change)
@ -1198,6 +1201,13 @@
;; Keep the sync pending until its layout work starts.
(wrf/with-pending :sync-file pending-ids frontend-sync)
;; the copies in the current file of the library components the
;; sync changed follow them
(when (not= file-id current-file-id)
(->> component-ids
(map #(sync-file current-file-id file-id :components % undo-group))
(rx/from)))
(when (not= file-id library-id)
;; When we have just updated the library file, give some time for the
;; update to finish, before marking this file as synced.

View File

@ -0,0 +1,74 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.data.workspace-remote-cascade-test
"An update of the main of a library component, made from a copy in the
file: the library components that nest it change too, and their copies
in the file follow."
(:require
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.composable.core :as tm]
[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.main.data.workspace.libraries :as dwl]
[app.main.repo :as rp]
[app.main.store :as st]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.composable-tests.interpreter :as ftm]
[frontend-tests.helpers.mock :as mock]))
(t/use-fixtures :each ftm/fixture)
(def ^:private old-color "#111111")
(def ^:private new-color "#222222")
(defn- install!
"A library with a component :ca and a component :cb whose main nests a
copy of :ca, and a file with a copy of each."
[]
(let [library (-> (cthf/sample-file :library :is-shared true)
(ctho/add-nested-component :ca :main-a :main-a-child
:cb :main-b :nested-a
:main1-child-params
{:fills (cths/sample-fills-color :fill-color old-color)}))
file (-> (cthf/sample-file :file1)
(cthc/instantiate-component :ca :copy-a
:library library
:children-labels [:copy-a-child])
(cthc/instantiate-component :cb :copy-b
:library library
:children-labels [:copy-b-nested :copy-b-nested-child]))]
(ftm/install! (-> (tm/make-situation file)
(tm/with-aux-files {(:id library) library})))
library))
(defn- fill-of
[file label]
(-> (cths/get-shape file label) :fills first :fill-color))
(defn- nested-child-fill
"The fill of the child of the copy of :ca that the main of :cb nests."
[library]
(let [objects (:objects (cthf/current-page library))
nested (cths/get-shape library :nested-a)]
(-> (get objects (first (:shapes nested))) :fills first :fill-color)))
(t/deftest ^:async an-update-of-a-library-main-reaches-the-copies-of-the-components-that-nest-it
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [& _] (rx/of nil)))
rx/timer mock/timer-mock}
(let [library (install!)]
(await (ftm/await-step [(ftm/fill-commit :copy-a-child new-color {})]))
(await (ftm/await-step [(dwl/update-component-sync (cthi/id :copy-a) (:id library))]))
(let [library' (get-in @st/state [:files (:id library)])]
(t/is (= new-color (fill-of library' :main-a-child)))
(t/is (= new-color (nested-child-fill library'))))
(t/is (= new-color (fill-of (ftm/current-file) :copy-a-child)))
(t/is (= new-color (fill-of (ftm/current-file) :copy-b-nested-child)))))))

View File

@ -44,6 +44,7 @@
[frontend-tests.data.workspace-pages-test]
[frontend-tests.data.workspace-path-edition-test]
[frontend-tests.data.workspace-reflow-test]
[frontend-tests.data.workspace-remote-cascade-test]
[frontend-tests.data.workspace-shortcuts-test]
[frontend-tests.data.workspace-sync-barrier-test]
[frontend-tests.data.workspace-sync-wasm-text-test]
@ -218,6 +219,7 @@
'frontend-tests.data.workspace-pages-test
'frontend-tests.data.workspace-path-edition-test
'frontend-tests.data.workspace-reflow-test
'frontend-tests.data.workspace-remote-cascade-test
'frontend-tests.data.workspace-shortcuts-test
'frontend-tests.data.workspace-sync-barrier-test
'frontend-tests.data.workspace-sync-wasm-text-test