mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ 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:
parent
e0bbc67605
commit
52e1ddfd1f
@ -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."
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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"]}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)))))))
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user