diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index f7f96bac1c..ed1a96444b 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -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." diff --git a/common/src/app/common/test_helpers/composable/comp/runner.cljc b/common/src/app/common/test_helpers/composable/comp/runner.cljc index 35ca94ca55..73bc074b17 100644 --- a/common/src/app/common/test_helpers/composable/comp/runner.cljc +++ b/common/src/app/common/test_helpers/composable/comp/runner.cljc @@ -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` diff --git a/common/test/common_tests/logic/golden_master.edn b/common/test/common_tests/logic/golden_master.edn index ae0d47f1e8..9ac75f3973 100644 --- a/common/test/common_tests/logic/golden_master.edn +++ b/common/test/common_tests/logic/golden_master.edn @@ -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"]} diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index ef579e7de8..6f7cde8c23 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -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. diff --git a/frontend/test/frontend_tests/data/workspace_remote_cascade_test.cljs b/frontend/test/frontend_tests/data/workspace_remote_cascade_test.cljs new file mode 100644 index 0000000000..90f6687c18 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_remote_cascade_test.cljs @@ -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))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 8d476fd499..e608e983ec 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -45,6 +45,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] @@ -229,6 +230,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