From 86aebd3429ca890f0ed061ada0a7f7b6e2fc9d3c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 4 Sep 2026 12:24:37 +0200 Subject: [PATCH] :zap: Reduce WASM text selrect churn after batch page load (#11471) * :bug: Align WASM auto-width text size with HTML measurement Include the HTML paragraph-set 1px right margin in Skia auto-width so WASM and DOM measurements match. * :zap: Scope and defer post-font WASM text relayout Track pending font faces per shape, force-relayout only affected texts, defer stale-selrect sync until the first tiles paint, and batch request-render after attr updates. Tolerate missing font-face keys so callers that omit them do not blow up. --- .../ui/specs/workspace-modifers.spec.js | 3 +- frontend/src/app/render_wasm/api.cljs | 194 +++++++++++++----- frontend/src/app/render_wasm/shape.cljs | 7 +- render-wasm/src/shapes/text.rs | 17 +- 4 files changed, 166 insertions(+), 55 deletions(-) diff --git a/frontend/playwright/ui/specs/workspace-modifers.spec.js b/frontend/playwright/ui/specs/workspace-modifers.spec.js index bbea6199f8..1fc688fbeb 100644 --- a/frontend/playwright/ui/specs/workspace-modifers.spec.js +++ b/frontend/playwright/ui/specs/workspace-modifers.spec.js @@ -233,5 +233,6 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { name: "Width", exact: true, }); - await expect(widthInput).toHaveValue("23"); + // WASM auto-width includes the HTML paragraph-set 1px right margin. + await expect(widthInput).toHaveValue("24"); }); diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index ee37ae995b..07fb91e98b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1523,6 +1523,60 @@ [content] (or content (tc/v2-default-text-content))) +(def ^:private empty-text-font-state + {:font-index {} :pending-faces #{}}) + +(defn- text-layout-fonts + "Content and fallback faces (emoji, Noto, ...) that `set-shape-text-content` + uploads, must match that path for pending-face tracking." + [content] + (into #{} (concat (f/get-content-fonts content) + (fonts-from-text-content content false)))) + +(defn- text-font-face-keys-state + "All font-face keys for a text content, and the subset not WASM-ready yet." + [content] + (reduce + (fn [acc font] + (let [font-data (f/make-font-data font) + key (f/font-data-key font-data) + pending? (not (f/font-ready? font-data))] + (-> acc + (update :font-face-keys conj key) + (cond-> pending? + (update :pending-font-face-keys conj key))))) + {:font-face-keys #{} :pending-font-face-keys #{}} + (text-layout-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 + (or font-face-keys #{})) + :pending-faces (into (or pending-faces #{}) + (or 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)) + {:keys [font-face-keys pending-font-face-keys]} + (text-font-face-keys-state content)] + (acc-text-font-state empty-text-font-state + (:id shape) + font-face-keys + pending-font-face-keys)) + 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- set-object-host-attrs "Host-specific attrs after structural upload (text/svg-raw/grid; optionally fills/strokes). When `skip-layout?` is true, flex+layout-item were already in @@ -1562,6 +1616,8 @@ (set-layout-data shape))) (let [is-text? (= type :text) + {:keys [font-face-keys pending-font-face-keys]} + (when is-text? (text-font-face-keys-state content)) text-content-pending (when is-text? (set-shape-text-content id content)) pending-thumbnails (into [] (concat text-content-pending @@ -1574,12 +1630,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)] @@ -1587,6 +1644,7 @@ (let [result (set-object-host-attrs shape false)] (perf/end-measure "set-object") result))))) + (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates to the worker index." @@ -1611,6 +1669,29 @@ :auto-height (not (mth/close? height (:height selrect) 0.1)) false))) +(defonce ^:private pending-stale-selrect-ids (atom #{})) +(defonce ^:private stale-selrect-sync-token (atom 0)) + +(defn- flush-stale-selrect-sync! + [] + (when-let [ids (seq (first (reset-vals! pending-stale-selrect-ids #{})))] + (st/emit! (ptk/data-event ::stale-text-selrects {:ids (vec ids)})))) + +(defn- schedule-stale-selrect-sync! + "Coalesce stale-selrect emissions and defer until the first viewport tile + pass completes, then run on idle so page load can paint first." + [stale-ids] + (swap! pending-stale-selrect-ids into stale-ids) + (let [token (swap! stale-selrect-sync-token inc) + flush-on-idle! + (fn [] + (when (= token @stale-selrect-sync-token) + (timers/schedule-on-idle + (fn [] + (when (= token @stale-selrect-sync-token) + (flush-stale-selrect-sync!))))))] + (listen-tiles-render-complete-once! flush-on-idle!))) + (defn- sync-stale-text-selrects! "Emit the ids of auto-grow text shapes whose selrect no longer matches the measured layout, so the workspace resizes them (data-event instead of a @@ -1624,24 +1705,26 @@ (map :id)) shapes)] (when (seq stale-ids) - (st/emit! (ptk/data-event ::stale-text-selrects {:ids stale-ids}))))) + (schedule-stale-selrect-sync! stale-ids)))) (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) @@ -1664,18 +1747,24 @@ (rx/reduce conj []) (rx/catch #(rx/empty)))) (rx/subs! - (fn [_] - (relayout-after-fonts! shapes font-pending-ids) - (request-render "images-loaded")) noop-fn - (fn [] (when (fn? on-complete) (on-complete))))) + noop-fn + (fn [] + (relayout-after-fonts! shapes text-font-state) + (request-render "images-loaded") + (when (fn? on-complete) (on-complete))))) ;; No pending images — complete immediately. (when on-complete (on-complete))))) (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 @@ -1684,19 +1773,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) @@ -1706,8 +1802,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)) @@ -1734,17 +1830,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 @@ -1755,16 +1854,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") @@ -1815,12 +1914,12 @@ (if (fn? callback) (callback) (rx/empty)))) (rx/reduce conj []))) (rx/subs! - (fn [_] - (relayout-after-fonts! shapes font-pending-acc) - (request-render "images-loaded")) noop-fn - noop-fn)))))))))] - (process-next-chunk 0 [] [] [])))))) + noop-fn + (fn [] + (relayout-after-fonts! shapes text-font-state-acc) + (request-render "images-loaded")))))))))))] + (process-next-chunk 0 [] [] empty-text-font-state)))))) ;; This is a version of process-pending that doesn't have sideffects @@ -1887,22 +1986,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?) @@ -1910,7 +2012,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 d351710eaa..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 @@ -341,7 +341,8 @@ (when (d/not-empty? shape-changes) (->> (rx/from shape-changes) (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) - (rx/subs! #(api/request-render "set-wasm-attrs"))))))) + (rx/reduce conj []) + (rx/subs! (fn [_] (api/request-render "set-wasm-attrs")))))))) ;; `conj` empty set initialization (def conj* (fnil conj (d/ordered-set))) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1e9ae16aee..7cbf859ab4 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -90,6 +90,11 @@ pub struct TextContentSize { const DEFAULT_TEXT_CONTENT_SIZE: f32 = 0.01; +/// Matches `marginRight: "1px"` on `.paragraph-set` in the HTML text renderer +/// (`frontend/src/app/main/ui/shapes/text/styles.cljs`). DOM `getBoundingClientRect` +/// includes that margin in auto-width measurements; Skia `longest_line()` does not. +const PARAGRAPH_SET_MARGIN_RIGHT: f32 = 1.0; + impl TextContentSize { pub fn default() -> Self { Self { @@ -863,7 +868,7 @@ impl TextContent { let measure_paragraphs = build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX); - let width = measure_paragraphs + let content_width = measure_paragraphs .iter() .flatten() .fold(0.0_f32, |auto_width, paragraph| { @@ -871,9 +876,10 @@ impl TextContent { }) .ceil(); - // Re-layout at that width with the real alignment. + // Re-layout at the intrinsic width (without the HTML margin slack). let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, content_width); let height = paragraphs .iter() .flatten() @@ -881,10 +887,11 @@ impl TextContent { auto_height + paragraph.height() }); + let reported_width = content_width + PARAGRAPH_SET_MARGIN_RIGHT; let size = TextContentSize::new_with_normalized_line_height( - width, + reported_width, height.ceil(), - width, + reported_width, normalized_line_height, ); TextContentLayoutResult(paragraph_builders, paragraphs, size)