From 51cbae6ea5b265bbfde7ad249e8a09dc85bdd359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 3 Nov 2026 17:41:36 +0100 Subject: [PATCH] :zap: Improve memory consumption on shape decoding (#12790) * :zap: Avoid decoding the opened page twice for its frame index The frame index was built over a with-meta copy, so pages got decoded again. Read it from the page objects themselves and fix the with-meta identity check. * :zap: Intern keywords when decoding transit on the frontend Each shape is decoded on its own, so every shape had its own key keywords. Cache them in the read handler so all decoded data shares one instance. * :zap: Use an array map as the wasm shape proxy delegate A hash map with ~35 keys costs ~3 KB of nodes per shape on large pages. An array map holds the same entries in one array, at a small lookup cost. --- common/src/app/common/transit.cljc | 15 +++++++ common/src/app/common/types/objects_map.cljc | 2 +- common/src/app/common/types/shape_tree.cljc | 10 ++++- frontend/src/app/render_wasm/shape.cljs | 45 +++++++++++++++----- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/common/src/app/common/transit.cljc b/common/src/app/common/transit.cljc index dc3e37b380..41b503316f 100644 --- a/common/src/app/common/transit.cljc +++ b/common/src/app/common/transit.cljc @@ -99,6 +99,17 @@ ;; --- HANDLERS +#?(:cljs + (defn- keyword-reader + "Return a reader that gives the same keyword instance for each name." + [] + (let [cache (js/Map.)] + (fn [value] + (or (.get cache value) + (let [kw (keyword value)] + (.set cache value kw) + kw)))))) + (add-handlers! #?@(:clj [{:id "file" @@ -119,6 +130,10 @@ {:id "u" :rfn parse-uuid}) + #?(:cljs + {:id ":" + :rfn (keyword-reader)}) + {:id "ordered-map" :class #?(:clj LinkedMap :cljs lkm/LinkedMap) :wfn vec diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index 77289a2b1e..1b4e17ce4c 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -206,7 +206,7 @@ c/IWithMeta (-with-meta [this new-meta] - (if (identical? new-meta meta) + (if (identical? new-meta metadata) this (ObjectsMap. new-meta cache diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index 0080aae00d..05cb0fc40f 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -368,11 +368,17 @@ (defn start-page-index [objects] - (with-meta objects {::index-frames (get-frames (with-meta objects nil))})) + (with-meta objects {::index-frames (into [] (get-frames objects {:ignore-index? true}))})) (defn update-page-index + "Lazy, so pages that are not open are not decoded. The index reads + from the returned objects, so decoded shapes are shared with them." [objects] - (with-meta objects {::index-frames (get-frames (with-meta objects nil))})) + (let [self (volatile! nil) + objects (with-meta objects + {::index-frames (lazy-seq (get-frames @self {:ignore-index? true}))})] + (vreset! self objects) + objects)) (defn update-object-indices [file page-id] diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index d94bd8d070..1d2980d6c9 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -79,6 +79,16 @@ (-count [_] (+ 1 (count delegate))) + c/IKVReduce + (-kv-reduce [_ f init] + (let [acc (f init :id id)] + (if (reduced? acc) + @acc + (let [acc (f acc :type type)] + (if (reduced? acc) + @acc + (reduce-kv f acc delegate)))))) + c/ILookup (-lookup [coll k] (-lookup coll k nil)) @@ -354,6 +364,18 @@ (let [id (:id self)] (.set changes id (conj* (.get changes id) k))))) +(defn- assoc-delegate + "Keeps an array map delegate as an array map when adding a new key." + [delegate k v] + (if (and (instance? PersistentArrayMap delegate) + (not (contains? delegate k))) + (PersistentArrayMap. (meta delegate) + (inc (count delegate)) + (doto (aclone (.-arr ^PersistentArrayMap delegate)) + (.push k v)) + nil) + (assoc delegate k v))) + (defn- impl-assoc [self k v] (track-change! self k) @@ -369,7 +391,7 @@ (.-delegate ^ShapeProxy self)) (let [delegate (.-delegate ^ShapeProxy self) - delegate' (assoc delegate k v)] + delegate' (assoc-delegate delegate k v)] (if (identical? delegate' delegate) self (ShapeProxy. (.-id ^ShapeProxy self) @@ -418,18 +440,19 @@ (next es)) (throw (js/Error. "conj on a map takes map entries or seqables of map entries")))))))) -(def ^:private xf:without-id-and-type - (remove (fn [kvpair] - (let [k (key kvpair)] - (or (= k :id) - (= k :type)))))) - (defn create-shape - "Instantiate a shape from a map" + "Instantiate a shape from a map. The delegate is an array map because + it takes far less memory than a hash map for shape-sized maps." [attrs] - (ShapeProxy. (:id attrs) - (:type attrs) - (into {} xf:without-id-and-type attrs))) + (let [arr (reduce-kv (fn [arr k v] + (when-not (or (= k :id) (= k :type)) + (.push arr k v)) + arr) + #js [] + attrs)] + (ShapeProxy. (:id attrs) + (:type attrs) + (PersistentArrayMap. nil (/ (alength arr) 2) arr nil)))) (t/add-handlers! ;; We only add a write handler, read handler uses the dynamic dispatch