From 60f063a775775a4700aa48b4b82705ccea4cd1cc Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Sat, 24 Oct 2026 14:51:22 +0200 Subject: [PATCH] :bug: Sync content from the restored position after a swap back A swap back relinks to its near position and clears the swap marker. Sync through the enclosing copy that owns those refs so its content overrides reach the restored head and children. Keep carried overrides and unrelated sibling overrides intact. Portable regression: three failures before the fix, then all assertions pass, including exact undo. Focused JVM and golden checks: 183 tests, 2143 assertions. The original and newly shrunk library programs pass. Live WASM verifies plain and variant swaps, later propagation, carried opacity and an unrelated sibling override. Common/frontend lint and fmt pass. Component benchmark ratios: mains 0.61/0.80/0.85; levels 0.71; chain 0.73; library 0.52; many shapes 0.53; variant switch 0.62. All worlds pass the unchanged baseline gate. AI-assisted-by: GPT-6 --- .../common/component-sync-contract.md | 2 +- common/src/app/common/logic/variants.cljc | 19 +++++++++++++++++++ .../common_tests/logic/swap_group_test.cljc | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.serena/memories/common/component-sync-contract.md b/.serena/memories/common/component-sync-contract.md index cf494b3239..0b805c2232 100644 --- a/.serena/memories/common/component-sync-contract.md +++ b/.serena/memories/common/component-sync-contract.md @@ -30,7 +30,7 @@ Intended semantics of copies, `:touched` and component sync. This is the spec: w - Instantiate / nest a copy: the new subtree has no touched groups. - Duplicate a copy: the new subtree keeps the source's values and touched; if its nesting level changes, touched is inherited as in detach. Paste and move out of a copy follow the same rule. - Swap without keep-touched: the new subtree keeps `swap-keep-attrs` values; the new head keeps the old shape's id. A retained rotation or flip that differs from the new component's expected placement becomes a `:geometry-group` override so later sync preserves it, including orientation inherited from the old component. The swap authors that choice. Size-only overrides clear. Orientation inherited from the position source or - the parent stays untouched, including references into deleted mains (F154). Other content overrides clear. Inside a copy it keeps the old `:shape-ref` and gets `:swap-group` (none when the new component is the near main's); inside a main it is a plain nested copy. + the parent stays untouched, including references into deleted mains (F154). Other content overrides clear. Inside a copy it keeps the old `:shape-ref` and gets `:swap-group` (none when the new component is the near main's); inside a main it is a plain nested copy. Swapping back produces a clean copy of the near position, including its content overrides, without authoring new groups (F176). Sync through the enclosing copy that owns the new refs; direct sync from the chosen component skips those indirect refs. - Variant switch with keep-touched: an attr is carried from the old copy only when its group is touched and the old and new mains hold the same value for it. Resulting touched = groups of carried attrs (+ `:swap-group` when the head is inside a copy). The root also keeps a retained rotation or flip as a geometry override when it differs from the new expected placement, including orientation supplied by the old component (F146, F175). Placement still supplied by an enclosing copy remains inherited when no root geometry override was carried. A value equal to the new main is not carried and not touched. Texts: the same per text group (letters carried only when both mains have the same letters; a style group when both mains agree on it once placed on each other's letters). - Delete component: copies keep their data. The deleted main cannot be edited, so it starts no sync, but changes from components nested in it still pass through: sync updates its stored shapes (`:objects` of the deleted component), its copies, and the components that nest it. Restore brings back an up-to-date main. - Undo/redo: restores the exact prior state, `:touched` and `:remote-synced` included, of the op and the sync it caused together (same undo group, one step). Undo never needs a re-sync. diff --git a/common/src/app/common/logic/variants.cljc b/common/src/app/common/logic/variants.cljc index 8efd402dd5..8f76471bd2 100644 --- a/common/src/app/common/logic/variants.cljc +++ b/common/src/app/common/logic/variants.cljc @@ -453,6 +453,23 @@ (when-let [parent (get objects parent-id)] (or (d/index-of (:shapes parent) shape-id) 0))) +(defn- sync-restored-position + "A swap back is an ordinary copy of its position, including that + position's content overrides. Sync through the head that owns its ref." + [changes page libraries shape-id] + (let [objects (pcb/get-objects changes) + shape (get objects shape-id) + parent (get objects (:parent-id shape)) + container (ctn/make-container (assoc page :objects objects) :page)] + (if (and (:shape-ref parent) (not (ctk/swapped? shape))) + (let [component (ctf/find-ref-component nil container libraries shape) + head (d/seek #(= (:id component) (:component-id %)) + (ctn/get-parent-copy-heads objects parent))] + (if head + (cll/generate-sync-shape-direct changes nil libraries container (:id head) false) + changes)) + changes))) + (defn generate-component-swap-in-place "Swap the copy head `shape` of `page` for a copy of the component `component-id` of library data `ldata`. The new head takes the old one's @@ -482,6 +499,8 @@ (ctf/find-ref-shape nil page libraries shape :include-deleted? true :with-context? true))) []]) + changes (sync-restored-position changes page libraries (:id new-shape)) + ;; Replacement keeps the head id and its cell. Cleanup during the ;; temporary deletion must not change the parent's placement data. changes (cond-> changes diff --git a/common/test/common_tests/logic/swap_group_test.cljc b/common/test/common_tests/logic/swap_group_test.cljc index e31be06860..3ee293298f 100644 --- a/common/test/common_tests/logic/swap_group_test.cljc +++ b/common/test/common_tests/logic/swap_group_test.cljc @@ -966,3 +966,22 @@ (t/is (= (:fills main-child) (:fills (ths/get-shape-by-id full (:id main-child))))) (t/is (= (thn/normalize-file (tm/file edited)) (thn/normalize-file (tm/file (r/step updated (n/undo)))))))) + +(t/deftest swapping-back-inherits-the-new-positions-content-overrides + (let [initial (tm/make-situation (setup)) + child-id (first (:shapes (tm/resolve-shape initial :card-star))) + edited (-> initial + (r/step (n/change-property :card-star :fills "#333333")) r/settle + (r/step (n/change-property (constantly child-id) :fills "#444444")) r/settle) + swapped (-> edited (r/step (n/swap-shape :copy-star :star)) r/settle) + head (tm/resolve-shape swapped :copy-star) + child (tm/shape-by-id swapped (first (:shapes head))) + file (tm/file swapped) + full (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))] + (t/is (= "#333333" (get-in head [:fills 0 :fill-color]))) + (t/is (= "#444444" (get-in child [:fills 0 :fill-color]))) + (t/is (empty? (:touched head))) + (t/is (empty? (:touched child))) + (t/is (= (:fills head) (:fills (ths/get-shape-by-id full (:id head))))) + (t/is (= (thn/normalize-file (tm/file edited)) + (thn/normalize-file (tm/file (r/step swapped (n/undo))))))))