penpot/common/test/common_tests/logic/large_sync_undo_test.cljc
alonso.torres 48fd0400d7 🐛 Keep the undo of a large component sync from overflowing
Undoing a main edit synced into thousands of copies overflowed the
stack, on the JVM and in JS (F84). Each synced shape added the undo of
its parents' update to the end of the undo list with a lazy concat, so
the list nested one lazy sequence per synced shape, and reading it
recursed through all of them. concat-changes and the inverse sync's
marking of local changes (a map over the whole list at every level of
the walk) added more.

- A shape's :reg-objects undo now goes right after the shape's own.
  Each parent is still updated after all its children are undone, and
  a synced parent gets back its stored values instead of recomputed
  ones; the undo round trip of every composable case is unchanged.
- concat-changes builds the joined undo list at once, at a cost that
  grows with the list it adds.
- The inverse sync collects the copy shapes it syncs and marks their
  changes as local once, at the end, instead of at every level.

large-sync-undo-test builds 5,000 copies in one step, syncs a main
edit into them and reads the undo list.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  37.3 / 1.06
  edit-through-a-chain-of-dependents   68.9 /  69.8 / 1.01
  library-update-into-copies           83.1 /  76.7 / 0.92
  main-edit-into-10-copies              1.8 /   1.6 / 0.88
  main-edit-into-100-copies            15.4 /  15.2 / 0.99
  main-edit-into-1000-copies          153.1 / 152.9 / 1.00
  variant-switch-into-copies           32.1 /  27.3 / 0.85
(Measured with the dead :shapes-group reads already removed; run side
by side, this commit and that state give the same ratios.)

AI-assisted-by: claude-opus-5-5
2026-10-30 09:48:15 +01:00

71 lines
3.1 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.large-sync-undo-test
(:require
[app.common.files.changes-builder :as pcb]
[app.common.geom.point :as gpt]
[app.common.logic.libraries :as cll]
[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]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(defn- add-copies
"`file` with `n` copies of the component `component-label` at the root of
its page, added in one step (the copies of a large design system)."
[file component-label n]
(let [page (thf/current-page file)
component (thc/get-component file component-label)
copies (map (fn [_]
(second (ctn/make-component-instance page component (:data file)
(gpt/point 100 100) {})))
(range n))]
(ctf/update-file-data
file
(fn [data]
(ctpl/update-page data (:id page)
(fn [page]
(-> page
(update :objects into (comp cat (map (juxt :id identity))) copies)
(update-in [:objects uuid/zero :shapes] into (map (comp :id first) copies)))))))))
(defn- read-count
"How many changes `changes` holds, or `:stack-overflow` when reading them
overflows the stack."
[changes]
(try
(count changes)
(catch #?(:clj StackOverflowError :cljs js/RangeError) _
:stack-overflow)))
(t/deftest undo-of-a-sync-into-many-copies-can-be-read
;; A large sync builds a long undo list; reading it must not recurse once
;; per synced shape (F84).
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :main-root :main-child)
(add-copies :component1 5000))
page (thf/current-page file)
edit (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :main-child)}
#(assoc % :fills (ths/sample-fills-color :fill-color "#ff0000"))
(:objects page)
{})
file (thf/apply-changes file edit :validate? false)
sync (cll/generate-sync-file-changes (pcb/empty-changes) nil :components (:id file)
(thi/id :component1) (:id file) {(:id file) file} (:id file))]
(t/is (= 10000 (count (:redo-changes sync))))
(t/is (= 10000 (read-count (:undo-changes sync))))))