From b2bd3582aee87d5d6130ae5e21aae7004c1be7e5 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 28 Aug 2026 13:14:30 +0200 Subject: [PATCH] :zap: Scope post-font text relayout to pending font faces Track which shapes use each font face at upload time and only force relayout and stale-selrect sync for shapes tied to not-yet-ready faces instead of every text shape on the page. --- frontend/src/app/render_wasm/api.cljs | 146 +++++++++++++++++------- frontend/src/app/render_wasm/shape.cljs | 4 +- 2 files changed, 109 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 8cdc807c50..04d36045a4 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1604,6 +1604,8 @@ (set-layout-data shape))) (let [is-text? (= type :text) + font-face-keys (when is-text? (text-font-face-keys content)) + pending-font-face-keys (when is-text? (text-pending-font-face-keys content)) text-content-pending (when is-text? (set-shape-text-content id content)) pending-thumbnails (into [] (concat text-content-pending @@ -1616,12 +1618,13 @@ (set-shape-strokes id strokes false write-fills-strokes?)))] {:thumbnails pending-thumbnails :full pending-full - :font-pending-ids (if (some :callback text-content-pending) [id] [])}))) + :font-face-keys (or font-face-keys #{}) + :pending-font-face-keys (or pending-font-face-keys #{})}))) (defn set-object [shape] (if-not (and shape (wasm/live?)) - {:thumbnails [] :full [] :font-pending-ids []} + {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}} (do (perf/begin-measure "set-object") (let [shape (svg-filters/apply-svg-derived shape)] @@ -1629,6 +1632,51 @@ (let [result (set-object-host-attrs shape false)] (perf/end-measure "set-object") result))))) +(def ^:private empty-text-font-state + {:font-index {} :pending-faces #{}}) + +(defn- text-font-face-keys + [content] + (into #{} + (comp (map f/make-font-data) + (map f/font-data-key)) + (f/get-content-fonts content))) + +(defn- text-pending-font-face-keys + "Font faces that were not WASM-ready when the shape was uploaded." + [content] + (into #{} + (comp (map f/make-font-data) + (remove f/font-ready?) + (map f/font-data-key)) + (f/get-content-fonts content))) + +(defn- acc-text-font-state + [{:keys [font-index pending-faces]} id font-face-keys pending-font-face-keys] + {:font-index (reduce (fn [idx face] + (update idx face (fnil conj #{}) id)) + font-index + font-face-keys) + :pending-faces (into pending-faces pending-font-face-keys)}) + +(defn text-font-state-for-shape + "Build the font-face index for a single text shape (incremental updates)." + [shape] + (if (cfh/text-shape? shape) + (let [content (ensure-text-content (:content shape))] + (acc-text-font-state empty-text-font-state + (:id shape) + (text-font-face-keys content) + (text-pending-font-face-keys content))) + empty-text-font-state)) + +(defn- shape-ids-for-pending-fonts + [{:keys [font-index pending-faces]}] + (when (seq pending-faces) + (into #{} + (mapcat #(get font-index % [])) + pending-faces))) + (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates to the worker index." @@ -1697,20 +1745,22 @@ (defn- relayout-after-fonts! "Relayout text shapes once their pending fonts have resolved. Font fetches - are deduped per URL and storing a font does not invalidate cached layouts, - so every text shape (not only the fetch triggers in `font-pending-ids`) - needs a forced relayout; then re-sync selrects that drifted." - [shapes font-pending-ids] - (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + are deduped per URL, so only shapes that use a not-yet-ready face at upload + time need a forced relayout; then re-sync selrects that drifted for those + shapes only." + [shapes text-font-state] + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes) + affected-ids (or (shape-ids-for-pending-fonts text-font-state) #{}) + shapes-by-id (d/index-by :id shapes)] (when (seq text-ids) - (if (seq font-pending-ids) - (do - (force-update-text-layouts text-ids) - (sync-stale-text-selrects! shapes)) + (if (seq affected-ids) + (let [affected-shapes (into [] (keep shapes-by-id) affected-ids)] + (force-update-text-layouts affected-ids) + (sync-stale-text-selrects! affected-shapes)) (update-text-layouts text-ids))))) (defn process-pending - [shapes thumbnails full font-pending-ids on-complete] + [shapes thumbnails full text-font-state on-complete] (let [pending-thumbnails (d/index-by :key :callback thumbnails) @@ -1736,7 +1786,7 @@ noop-fn noop-fn (fn [] - (relayout-after-fonts! shapes font-pending-ids) + (relayout-after-fonts! shapes text-font-state) (request-render "images-loaded") (when (fn? on-complete) (on-complete))))) ;; No pending images — complete immediately. @@ -1744,8 +1794,13 @@ (defn process-object [shape] - (let [{:keys [thumbnails full font-pending-ids]} (set-object shape)] - (process-pending [shape] thumbnails full font-pending-ids noop-fn))) + (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object shape) + text-font-state (acc-text-font-state empty-text-font-state + (:id shape) + font-face-keys + pending-font-face-keys)] + (process-pending [shape] thumbnails full text-font-state noop-fn))) (defn process-objects "Like process-object but for multiple shapes at once. Accumulates all @@ -1754,19 +1809,26 @@ just the first shape that triggered the fetch." [shapes] (let [total-shapes (count shapes) - {:keys [thumbnails full font-pending-ids]} - (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] + {:keys [thumbnails full text-font-state]} + (loop [index 0 + thumbnails-acc (transient []) + full-acc (transient []) + font-state-acc empty-text-font-state] (if (< index total-shapes) (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] + {:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object shape)] (recur (inc index) (reduce conj! thumbnails-acc thumbnails) (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] - (process-pending shapes thumbnails full font-pending-ids noop-fn))) + :text-font-state font-state-acc}))] + (process-pending shapes thumbnails full text-font-state noop-fn))) (def ^:private ^:const BATCH_MAX_SHAPES 512) @@ -1776,8 +1838,8 @@ Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk; host-specific attrs (fills/strokes/text/grid/path) stay per-shape. - Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}" - [shapes start-index thumbnails-acc full-acc font-pending-acc] + Returns {:thumbnails [...] :full [...] :text-font-state {...} :next-index n}" + [shapes start-index thumbnails-acc full-acc text-font-state-acc] (let [total (count shapes) end-index (min total (+ start-index BATCH_MAX_SHAPES)) chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) @@ -1804,17 +1866,20 @@ (loop [xs prepared t-acc (transient thumbnails-acc) f-acc (transient full-acc) - fp-acc (transient font-pending-acc)] + font-state-acc text-font-state-acc] (if-let [shape (first xs)] - (let [{:keys [thumbnails full font-pending-ids]} + (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]} (set-object-host-attrs shape true :skip-fills-strokes? true)] (recur (next xs) (reduce conj! t-acc thumbnails) (reduce conj! f-acc full) - (reduce conj! fp-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! t-acc) :full (persistent! f-acc) - :font-pending-ids (persistent! fp-acc) + :text-font-state font-state-acc :next-index end-index})))) (defn- set-objects-async @@ -1825,16 +1890,16 @@ (let [total-shapes (count shapes)] (p/create (fn [resolve _reject] - (letfn [(process-next-chunk [index thumbnails-acc full-acc font-pending-acc] + (letfn [(process-next-chunk [index thumbnails-acc full-acc text-font-state-acc] (if (< index total-shapes) ;; Process one time-budgeted chunk - (let [{:keys [thumbnails full font-pending-ids next-index]} + (let [{:keys [thumbnails full text-font-state next-index]} (process-shapes-chunk shapes index - thumbnails-acc full-acc font-pending-acc)] + thumbnails-acc full-acc text-font-state-acc)] ;; Yield to browser, then continue with next chunk (-> (yield-to-browser) (p/then (fn [_] - (process-next-chunk next-index thumbnails full font-pending-ids))))) + (process-next-chunk next-index thumbnails full text-font-state))))) ;; All chunks done - finalize (do (perf/end-measure "set-objects") @@ -1888,9 +1953,9 @@ noop-fn noop-fn (fn [] - (relayout-after-fonts! shapes font-pending-acc) + (relayout-after-fonts! shapes text-font-state-acc) (request-render "images-loaded")))))))))))] - (process-next-chunk 0 [] [] [])))))) + (process-next-chunk 0 [] [] empty-text-font-state)))))) ;; This is a version of process-pending that doesn't have sideffects @@ -1957,22 +2022,25 @@ (when (and (contains? #{:path :bool} type) (some? (get shape :content))) (props/set-shape-path-content (get shape :content)))))) (let [total-shapes (count prepared) - {:keys [thumbnails full font-pending-ids]} + {:keys [thumbnails full text-font-state]} (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) - font-acc (transient [])] + font-state-acc empty-text-font-state] (if (< index total-shapes) (let [shape (nth prepared index) - {:keys [thumbnails full font-pending-ids]} + {:keys [thumbnails full font-face-keys pending-font-face-keys]} (set-object-host-attrs shape true :skip-fills-strokes? true)] (recur (inc index) (reduce conj! thumbnails-acc thumbnails) (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] + :text-font-state font-state-acc}))] (perf/end-measure "set-objects") (when on-shapes-ready (on-shapes-ready)) (when (wasm/live?) @@ -1980,7 +2048,7 @@ ;; map to which tiles after a page switch. (h/call wasm/internal-module "_set_view_end") (reset! view-interaction-active? false) - (process-pending shapes thumbnails full font-pending-ids + (process-pending shapes thumbnails full text-font-state (fn [] (if render-callback (render-callback) diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 959d6e35c8..0dd5870675 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -252,12 +252,12 @@ (let [text-content-pending (api/set-shape-text-content id v) pending-thumbnails (vec text-content-pending) pending-full (vec (api/set-shape-text-images id v)) - font-pending-ids (when (some :callback text-content-pending) [id])] + text-font-state (api/text-font-state-for-shape shape)] ;; FIXME: this is a hack to process the pending tasks ;; asynchronously we should probably modify set-wasm-attr! ;; to return a list of callbacks to be executed in a ;; second pass. - (api/process-pending [shape] pending-thumbnails pending-full font-pending-ids api/noop-fn) + (api/process-pending [shape] pending-thumbnails pending-full text-font-state api/noop-fn) nil)) :grow-type