Repository: penpot/penpot
File: frontend/src/app/main/data/workspace/pages.cljs (ClojureScript)

Code before:
    ptk/WatchEvent
    (watch [it state _]
      (let [id                 (uuid/next)
            fdata              (dsh/lookup-file-data state)
            pages              (get fdata :pages-index)
            page               (get pages page-id)

            unames             (cfh/get-used-names pages)
            suffix-fn          (fn [copy-count]
                                 (str/concat " "
                                             (tr "dashboard.copy-suffix")
                                             (when (> copy-count 1)
                                               (str " " copy-count))))
            base-name          (:name page)
            name               (cfh/generate-unique-name base-name unames :suffix-fn suffix-fn)
            objects            (update-vals (:objects page) #(dissoc % :use-for-thumbnail))
            ;; id-remap keeps track of the correspondence between original object ids and their new ids during page duplication.
            ;; This is essential for updating parent-child relationships: when an object is cloned and assigned a new id, any parent referencing it must update its :shapes vector to use the new id.
            ;; id-remap ensures referential integrity in the duplicated page structure.
            id-remap           (atom {})
            ;; Clone a main instance and its descendants, updating id-remap
            add-component-copy
            (fn [objs shape parent-id]
              (let [component (ctkl/get-component fdata (:component-id shape))
                    [new-shape new-shapes]
                    (ctn/make-component-instance page
                                                 component
                                                 fdata
                                                 (gpt/point (:x shape) (:y shape))
                                                 {:keep-ids? true :force-frame-id (:frame-id shape)})
                    ;; Force the parent-id of the cloned mainInstance
                    new-shape (assoc new-shape :parent-id parent-id)
                    children (into {} (map (fn [shape]
                                             (let [s (if (= (:id shape) (:id new-shape))
                                                       new-shape
                                                       shape)]
                                               (swap! id-remap assoc (:id shape) (:id s))
                                               [(:id s) s])) new-shapes))
                    objs (assoc objs (:id new-shape) new-shape)]
                (merge objs children)))
            ;; When duplicating a page, special care is needed for mainInstance shapes nested inside groups or containers.
            ;; If not handled, the parent group and its mainInstance child can end up with inconsistent :shapes and parent-id links.
            ;; The following logic ensures that, after cloning, parent groups and their mainInstance children have correct and
            ;; bidirectional relationships, preserving referential integrity.
            clone-tree
            (fn clone-tree [objs id]
              (let [shape (get objects id)]
                (if (ctc/main-instance? shape)
                  (add-component-copy objs shape (:parent-id shape))
                  (let [objs (reduce (fn [objs cid]
                                       (clone-tree objs cid))

Review comment (bug):
I've just found that `make-component-instance` function has a bug. It always sets the `parent-id` of the root of the new copy the same as the `frame-id`. It works when the copy is inside a frame, but not if it's under a group. I think you cannot cause this in normal cases, because you cannot drag a component into a group, but it happens in the present case when duplicating a page. I'd add a `force-parent-id` parameter to `make-component-instance` instead of setting it after the call. The parameter could be passed into `ctst/clone-shape` function inside.

Code after:
    ptk/WatchEvent
    (watch [it state _]
      (let [id                 (uuid/next)
            fdata              (dsh/lookup-file-data state)
            pages              (get fdata :pages-index)
            page               (get pages page-id)

            unames             (cfh/get-used-names pages)
            suffix-fn          (fn [copy-count]
                                 (str/concat " "
                                             (tr "dashboard.copy-suffix")
                                             (when (> copy-count 1)
                                               (str " " copy-count))))
            base-name          (:name page)
            name               (cfh/generate-unique-name base-name unames :suffix-fn suffix-fn)
            objects            (update-vals (:objects page) #(dissoc % :use-for-thumbnail))

            main-instances-ids (set (keep #(when (ctc/main-instance? (val %)) (key %)) objects))
            ids-to-remove      (set (apply concat (map #(cfh/get-children-ids objects %) main-instances-ids)))

            add-component-copy
            (fn [objs id shape]
              (let [component (ctkl/get-component fdata (:component-id shape))
                    parent-id (when (not= (:parent-id shape) uuid/zero) (:parent-id shape))
                    [new-shape new-shapes]
                    (ctn/make-component-instance page
                                                 component
                                                 fdata
                                                 (gpt/point (:x shape) (:y shape))
                                                 {:keep-ids? true
                                                  :force-frame-id (:frame-id shape)
                                                  :force-parent-id parent-id})
                    children (into {} (map (fn [shape] [(:id shape) shape]) new-shapes))
                    objs (assoc objs id new-shape)]
                (merge objs children)))

            objects
            (reduce
             (fn [objs [id shape]]
               (cond (contains? main-instances-ids id)
                     (add-component-copy objs id shape)
                     (contains? ids-to-remove id)
                     objs
                     :else
                     (assoc objs id shape)))
             {}
             objects)

            page    (-> page
                        (assoc :name name)
                        (assoc :id id)