🐛 Remap library refs inside deleted components on absorb (#12232)

* 🐛 Remap library refs inside deleted components on absorb

Deleted components keep their shapes in :objects, to be restored and
to resolve their remaining copies. When a library was absorbed into a
file (export with libraries merged, unsharing or deleting the
library), absorb-assets only looked for usages in pages and in live
components. The shapes inside deleted components kept pointing to the
library, so they no longer matched their copies in the pages: the file
failed validation with :component-id-mismatch and could not be saved.

Also look for usages in deleted components, so their library
components, colors and typographies are remapped to the local file.

Relates to #10806

AI-assisted-by: claude-opus-5-5

* 🐛 Persist pointer-map fragments when absorbing a library

absorb-library-by-file saved the file without its :features. Without
"fdata/pointer-map" there, encode-file does not persist the changed
fragments, so in files with pointer maps the pages and components
that absorb-assets remapped were never written. After unpublishing or
deleting a library, its consumers failed to load with "fragment not
found".

Pass :features to update-file!. Exporting with shared libraries does
not go through this path, so it was not affected.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-26 17:56:31 +01:00 committed by GitHub
parent 86e01d56a2
commit 01e0b32850
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 113 additions and 4 deletions

View File

@ -961,6 +961,7 @@
:file-id (str file-id))
(bfc/update-file! cfg {:id file-id
:features (:features file)
:migrations (:migrations file)
:revn (inc (:revn file))
:data (:data file)

View File

@ -2261,6 +2261,61 @@
(t/is (nil? (:error out)))
(check-tokens-absorbed prepared)))
(t/deftest unpublishing-a-library-keeps-its-consumers-readable
;; The test helpers enable all supported features, so these files use
;; fdata/pointer-map and the absorbed page is stored as a fragment
(let [profile (th/create-profile* 1 {:is-active true})
library (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared true})
consumer (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
_ (th/link-file-to-library* {:file-id (:id consumer) :library-id (:id library)})
color-id (uuid/random)
page-id (first (get-in consumer [:data :pages]))
shape-id (uuid/random)]
(update-file! :file-id (:id library)
:profile-id (:id profile)
:revn 0
:changes [{:type :add-color
:color {:id color-id :name "Brand" :color "#abcdef" :opacity 1}}])
(update-file!
:file-id (:id consumer)
:profile-id (:id profile)
:revn 0
:changes
[{:type :add-obj
:page-id page-id
:id shape-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape {:id shape-id :name "Rect" :frame-id uuid/zero
:parent-id uuid/zero :type :rect
:fills [{:fill-color "#abcdef"
:fill-opacity 1
:fill-color-ref-id color-id
:fill-color-ref-file (:id library)}]})}])
(let [out (th/command! {::th/type :set-file-shared
::rpc/profile-id (:id profile)
:id (:id library)
:is-shared false})]
(t/is (nil? (:error out))))
;; The absorption changed the page of the shape: the consumer reads
;; it back, with the fill now linked to its own color
(let [out (th/command! {::th/type :get-file
::rpc/profile-id (:id profile)
:id (:id consumer)})
data (-> out :result :data)
fill (get-in data [:pages-index page-id :objects shape-id :fills 0])]
(t/is (nil? (:error out)))
(t/is (contains? (:colors data) color-id))
(t/is (= (:id consumer) (:fill-color-ref-file fill))))))
(t/deftest deleted-files-permanently-delete
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)

View File

@ -683,7 +683,8 @@
(defn find-asset-type-usages
"Find all usages of an asset in a file (may be in pages or in the components
of the local library).
of the local library, deleted ones included, since they keep their shapes
to be restored and to resolve their remaining copies).
Returns a list ((asset ((container shapes) (container shapes)...))...)"
[file-data library-data asset-type]
@ -699,12 +700,17 @@
(when (d/not-empty? instances)
[[container instances]])))
containers
(concat (containers-seq file-data)
(map #(ctn/make-container % :component)
(ctkl/deleted-components-seq file-data)))
find-asset-usages
(fn [file-data asset]
(mapcat #(find-usages-in-container % asset) (containers-seq file-data)))]
(fn [asset]
(mapcat #(find-usages-in-container % asset) containers))]
(mapcat (fn [asset]
(let [instances (find-asset-usages file-data asset)]
(let [instances (find-asset-usages asset)]
(when (d/not-empty? instances)
[[asset instances]])))
assets)))

View File

@ -7,6 +7,9 @@
(ns common-tests.types.absorb-assets-test
(:require
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.files.validate :as cfv]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
@ -57,6 +60,50 @@
(t/is (ctk/is-main-of? main-root' copy-root'))
(t/is (ctk/main-instance-of? (:id main-root') (:id (second pages')) component'))))
(t/deftest absorb-components-used-in-deleted-components
;; A deleted component keeps its shapes in `:objects`, to be able to
;; restore it and to resolve its remaining copies. When those shapes
;; use library components, they must also be remapped to the local
;; file, or they will not match the copies in the pages.
(let [;; Setup
library (-> (thf/sample-file :library :is-shared true)
(tho/add-simple-component :icon :icon-main :icon-rect))
file (-> (thf/sample-file :file)
(tho/add-frame :card-main :name "Card")
(thc/instantiate-component :icon :card-icon
:library library
:parent-label :card-main)
(thc/make-component :card :card-main)
(thc/instantiate-component :card :card-copy))
page (thf/current-page file)
changes (-> (pcb/empty-changes nil (:id page))
(cls/generate-delete-shapes file page (:objects page)
#{(thi/id :card-main)} {})
(second))
file (thf/apply-changes file changes :validate? false)
errors (cfv/validate-file file {(:id library) library})
;; Action
file' (ctf/update-file-data
file
#(ctf/absorb-assets % (:data library)))
;; Get
data' (ctf/file-data file')
card' (ctkl/get-deleted-component data' (thi/id :card))
shapes' (concat (mapcat (comp vals :objects) (ctpl/pages-seq data'))
(mapcat (comp vals :objects) (vals (:components data'))))
library-refs' (filter #(= (thi/id :library) (:component-file %)) shapes')
errors' (cfv/validate-file file' {})]
;; Check
(t/is (nil? errors) "the file is valid before absorbing the library")
(t/is (some? (:objects card')) "the deleted component keeps its shapes")
(t/is (empty? library-refs') "no shape points to the library")
(t/is (nil? errors'))))
(t/deftest absorb-colors
(let [;; Setup
library (-> (thf/sample-file :library :is-shared true)