From 00f5d621b0abcf114868c1e04b75ec81365c54f4 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 23 Oct 2026 20:20:38 +0200 Subject: [PATCH] :bug: Remove invalid swaps when a main detaches a nested head A swapped copy must follow structural changes when its position ceases to be a component head. Walk that shape normally during sync, remove its component metadata and swap group, and replace its former component children with the detached main shape's children. The regression fails three assertions before the fix. The swap suite passes 44 tests and 177 assertions, including exact undo. The shrunk generated detach scenario also passes with its structural model update. Benchmark vs Phase 1e: main 10/100/1000 0.50/0.57/0.64; levels 0.52; chain 0.52; library 0.60; many shapes 0.55; switch 0.65. No flags. AI-assisted-by: GPT-6 --- .serena/memories/common/component-sync-contract.md | 2 +- common/src/app/common/logic/libraries.cljc | 7 +++++-- common/test/common_tests/logic/swap_group_test.cljc | 12 ++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.serena/memories/common/component-sync-contract.md b/.serena/memories/common/component-sync-contract.md index 7a6b9389d0..6e248324b7 100644 --- a/.serena/memories/common/component-sync-contract.md +++ b/.serena/memories/common/component-sync-contract.md @@ -22,7 +22,7 @@ Intended semantics of copies, `:touched` and component sync. This is the spec: w - Edit on a copy shape (authored): sets the attr's group (text content: `:content-group` + the text groups it changes), clears `:remote-synced`. Descendants moved/resized only because of it stay untouched. - Rename: an explicit rename touches `:name-group` on root or child. Automatic renames (text name following its content) are derived: the editors write the name with `:ignore-touched #{:name}`, and sync gives an untouched copy text the name of its own letters when the main's name is automatic. Untouched names sync from the near main; a touched root name on update-main renames the component. - Edit on a main: no touched change on the main; triggers sync. -- Sync main→copy: every untouched group gets the near main's value (geometry: expected geometry); touched groups keep their values; never sets or clears `:touched`. Main child added → added clean to copies. Main child deleted → copy child removed, overrides or not. Main reorder → copies follow. A swapped sub-head pairs with the main child it replaces (its `:shape-ref`) and takes only that child's placement (position, rotation, flips, constraints, `layout-item-*`); everything else syncs from its own component's main. A main child deleted → its swapped copies go with it. A swap made in the main reaches copies without `:swap-group` and spares those with it. +- Sync main→copy: every untouched group gets the near main's value (geometry: expected geometry); touched groups keep their values; never sets or clears `:touched` during attribute sync. If a main detaches a nested head, its images become ordinary shapes and lose their now-invalid `:swap-group`. Main child added → added clean to copies. Main child deleted → copy child removed, overrides or not. Main reorder → copies follow. A swapped sub-head pairs with the main child it replaces (its `:shape-ref`) and takes only that child's placement (position, rotation, flips, constraints, `layout-item-*`); everything else syncs from its own component's main. A main child deleted → its swapped copies go with it. A swap made in the main reaches copies without `:swap-group` and spares those with it. - Sync cascade is eager: one sync runs to a fixpoint in memory (copies of A, including the A copy in B's main → copies of B → …) and commits once in the triggering op's undo group. The file is never left partly synced. Syncing only the components reported as changed equals a full-file sync. Code: `cll/generate-sync-file-cascade` (first pass as `generate-sync-file-changes`, then `generate-sync-components-cascade` over `cll/changed-components` of each pass); the frontend `sync-file` commits it to the synced file, and a library file's sync then syncs the current file's copies of the library components its cascade changed. - Reset: the selected head's subtree becomes equal to its near main (expected geometry); all its touched groups cleared, `:swap-group` included: a swapped head turns back into the near main's component in place, keeping its id. Nothing outside that subtree changes. - Update main (from a top copy head): the copy's differing values go to the main; the copy's touched cleared; other copies then sync and keep their own overrides. A swapped child replaces the main child it stands for, and neither keeps `:swap-group`. The main's authored groups are unioned with the copy's even on a repeated update; `:remote-synced` records an earlier update and must never clear those groups (F114). diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index edad15736b..6fbb120183 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -430,7 +430,8 @@ children-main {:pair? ctk/is-main-of? :on-pair (fn [changes child-inst child-main] - (if (and (not reset?) (ctk/swapped? child-inst)) + (if (and (not reset?) (ctk/swapped? child-inst) + (ctk/instance-head? child-main)) (update-swapped-placement changes ctx child-inst child-main) (sync-copy-recursive changes ctx child-inst child-main))) :only-inst (fn [changes child-inst] @@ -1033,7 +1034,9 @@ (if (and (= (:shape-ref dest-shape) (:id origin-shape)) (ctk/subcopy-head? dest-shape) (not (ctk/instance-head? origin-shape))) - (pcb/update-shapes changes [(:id dest-shape)] ctk/unhead-shape {:ignore-touched true}) + (pcb/update-shapes changes [(:id dest-shape)] + #(-> % ctk/remove-swap-group ctk/unhead-shape) + {:ignore-touched true}) changes)) (defn- check-swapped-main diff --git a/common/test/common_tests/logic/swap_group_test.cljc b/common/test/common_tests/logic/swap_group_test.cljc index 0072848e98..5414bddf4e 100644 --- a/common/test/common_tests/logic/swap_group_test.cljc +++ b/common/test/common_tests/logic/swap_group_test.cljc @@ -785,3 +785,15 @@ (t/is (mth/close? 80 (:height head) 0.001)) (t/is (= (thn/normalize-file (tm/file edited)) (thn/normalize-file (tm/file (r/step updated (n/undo)))))))) + +(t/deftest detaching-a-main-child-removes-the-copys-now-invalid-swap + (let [initial (tm/make-situation (setup)) + detached (-> initial (r/step (n/detach :card-star)) r/settle) + head (tm/resolve-shape detached :copy-star) + main (tm/resolve-shape detached :card-star) + objects (:objects (thf/current-page (tm/file detached)))] + (t/is (not (ctk/instance-head? head))) + (t/is (not (ctk/swapped? head))) + (t/is (= (:shapes main) (mapv #(get-in objects [% :shape-ref]) (:shapes head)))) + (t/is (= (thn/normalize-file (tm/file initial)) + (thn/normalize-file (tm/file (r/step detached (n/undo))))))))