🐛 Fix integrity error on copy-paste swapped component

When copying a component containing a swapped nested component, the
copy operation was not preserving the swap information correctly. This
caused referential integrity errors when pasting the component.

The fix detects when a subinstance head's shape-ref is being advanced
during copy and preserves the original shape-ref as a swap-slot. This
ensures referential integrity is maintained when the copy is pasted in
a context where the parent's near main is reachable.

Closes #9547
This commit is contained in:
Pablo Alba 2026-05-13 13:31:16 +02:00
parent ab284febf7
commit 5649ebc735
4 changed files with 107 additions and 10 deletions

View File

@ -5,6 +5,7 @@
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
@ -64,6 +65,7 @@
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
### :bug: Bugs fixed
- Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361))
- Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513))
- Fix typo in subscription settings success key (by @jack-stormentswe) [#9203](https://github.com/penpot/penpot/issues/9203) (PR: [#9204](https://github.com/penpot/penpot/pull/9204))
@ -125,6 +127,7 @@
- Fix text fill color stops updating in multiselect with texts [#9608](https://github.com/penpot/penpot/issues/9608) (PR: [#9549](https://github.com/penpot/penpot/pull/9549))
- Fix editing a legacy text element silently detaches its color token [Taiga #13958](https://tree.taiga.io/project/penpot/issue/13958)
- Fix token application to grid paddings [Taiga #14136](https://tree.taiga.io/project/penpot/issue/14136)
- Fix copying and pasting a swapped component causes a referential integrity error [Github #9547](https://github.com/penpot/penpot/issues/9547)
## 2.15.4 (Unreleased)
@ -149,7 +152,6 @@
- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
## 2.15.1
### :sparkles: New features & Enhancements
@ -160,7 +162,6 @@
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138))
## 2.15.0
### :sparkles: New features & Enhancements

View File

@ -170,13 +170,28 @@
objects))
(advance-shape [file libraries page level-delta objects shape]
(let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})
container (ctn/make-container page :page)
new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)]
(let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})
container (ctn/make-container page :page)
new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)
old-shape-ref (:shape-ref shape)
ref-changed? (and (some? new-shape-ref) (not= new-shape-ref old-shape-ref))
;; If we advance the shape-ref of a subinstance head, the new
;; shape-ref will no longer match the parent component child
;; at the same position, so the shape becomes "swapped" with
;; respect to the enclosing instance. Preserve the original
;; shape-ref as swap-slot so that referential integrity holds
;; when the copy is pasted in a context where the parent's
;; near main is reachable.
needs-swap-slot? (and ref-changed?
(ctc/subinstance-head? shape)
(nil? (ctc/get-swap-slot shape)))]
(cond-> objects
(and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape)))
ref-changed?
(-> (assoc-in [(:id shape) :shape-ref] new-shape-ref)
(assoc-in [(:id shape) :touched] new-touched)))))
(assoc-in [(:id shape) :touched] new-touched))
needs-swap-slot?
(update (:id shape) ctc/set-swap-slot old-shape-ref))))
(on-copy-error [error]
(js/console.error "clipboard blocked:" error)

View File

@ -247,10 +247,21 @@
objects))
(advance-shape [file libraries page level-delta objects shape]
(let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})]
(let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})
container (ctn/make-container page :page)
new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)
old-shape-ref (:shape-ref shape)
ref-changed? (and (some? new-shape-ref) (not= new-shape-ref old-shape-ref))
needs-swap-slot? (and ref-changed?
(ctk/subinstance-head? shape)
(nil? (ctk/get-swap-slot shape)))]
(cond-> objects
(and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape)))
(assoc-in [(:id shape) :shape-ref] new-shape-ref))))]
ref-changed?
(-> (assoc-in [(:id shape) :shape-ref] new-shape-ref)
(assoc-in [(:id shape) :touched] new-touched))
needs-swap-slot?
(update (:id shape) ctk/set-swap-slot old-shape-ref))))]
(let [file-id (:id file)
frame-id (cfh/common-parent-frame objects selected)

View File

@ -870,3 +870,73 @@
(t/is (some? copied-blue1'))
(t/is (nil? (ctk/get-swap-slot copied-blue1')))))))))
(t/deftest test-set-swap-slot-copy-paste-two-levels-nested-head
;; Test that when copy-pasting a sub-instance head that contains a deeper sub-instance
;; head (with no swap-slot), the advance-shape step correctly sets a swap-slot on the
;; inner head. Without the fix this causes a backend :referential-integrity / :missing-slot
;; validation error.
(t/async
done
(let [;; ==== Setup
;; {:main1-root} [:name Frame1] # [Component :comp1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :comp2]
;; :nested-head1 [:name Frame1] @--> [Component :comp1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :comp3]
;; :nested-head2 [:name Frame2] @--> [Component :comp2] :main2-root
;; :nested-subhead2 [:name Frame1] @--> [Component :comp1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; :copy3-root [:name Frame3] #--> [Component :comp3] :main3-root
;; <copy-head2> [:name Frame2] @--> [Component :comp2] :nested-head2 (no swap-slot)
;; <copy-subhead1> [:name Frame1] @--> [Component :comp1] :nested-subhead2 (no swap-slot)
;; <no-label> [:name Rect1] ---> <no-label>
file (-> (cthf/sample-file :file1)
(ctho/add-two-levels-nested-component-with-copy
:comp1 :main1-root :main1-child
:comp2 :main2-root :nested-head1
:comp3 :main3-root :nested-head2 :nested-subhead2
:copy3-root))
store (ths/setup-store file)
;; ==== Action
page (cthf/current-page file)
copy3-root (cths/get-shape file :copy3-root)
;; The copy of :nested-head2 inside copy3-root (sub-instance head of comp2, no swap-slot,
;; nested 1 level deep — so level-delta=1 during copy-paste)
copy-head2-id (first (:shapes copy3-root))
copy-head2 (get (:objects page) copy-head2-id)
features #{"components/v2"}
version 46
pdata (thp/simulate-copy-shape #{copy-head2-id} (:objects page) {(:id file) file} page file features version)
events
[(dws/select-shape uuid/zero)
(dw/paste-shapes pdata)]]
(ths/run-store
store done events
(fn [new-state]
(let [;; ==== Get
file' (ths/get-file-from-state new-state)
page' (cthf/current-page file')
;; Find the pasted copy-head2 at the page root (parent = uuid/zero)
pasted-head2' (find-copied-shape copy-head2 page' uuid/zero)
;; Its first child is the copy of :nested-subhead2 (sub-instance head of comp1)
pasted-subhead1-id' (some-> pasted-head2' :shapes first)
pasted-subhead1' (get (:objects page') pasted-subhead1-id')]
;; ==== Check
(t/is (some? pasted-head2'))
(t/is (some? pasted-subhead1'))
;; The inner sub-instance head must have a swap-slot set because advance-shape
;; changed its shape-ref (level-delta=1). The swap-slot records the pre-advance
;; shape-ref so the backend can validate referential integrity.
(t/is (some? (ctk/get-swap-slot pasted-subhead1'))
"Pasted inner sub-instance head must have a swap-slot after advance-shape")))))))