Reduce WASM text selrect churn after batch page load (#11471)

* 🐛 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.

*  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.
This commit is contained in:
Alejandro Alonso 2026-09-04 12:24:37 +02:00 committed by GitHub
parent 7f46c8c80e
commit 86aebd3429
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 166 additions and 55 deletions

View File

@ -233,5 +233,6 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => {
name: "Width", name: "Width",
exact: true, exact: true,
}); });
await expect(widthInput).toHaveValue("23"); // WASM auto-width includes the HTML paragraph-set 1px right margin.
await expect(widthInput).toHaveValue("24");
}); });

View File

@ -1523,6 +1523,60 @@
[content] [content]
(or content (tc/v2-default-text-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 (defn- set-object-host-attrs
"Host-specific attrs after structural upload (text/svg-raw/grid; optionally "Host-specific attrs after structural upload (text/svg-raw/grid; optionally
fills/strokes). When `skip-layout?` is true, flex+layout-item were already in fills/strokes). When `skip-layout?` is true, flex+layout-item were already in
@ -1562,6 +1616,8 @@
(set-layout-data shape))) (set-layout-data shape)))
(let [is-text? (= type :text) (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)) text-content-pending (when is-text? (set-shape-text-content id content))
pending-thumbnails (into [] (concat pending-thumbnails (into [] (concat
text-content-pending text-content-pending
@ -1574,12 +1630,13 @@
(set-shape-strokes id strokes false write-fills-strokes?)))] (set-shape-strokes id strokes false write-fills-strokes?)))]
{:thumbnails pending-thumbnails {:thumbnails pending-thumbnails
:full pending-full :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 (defn set-object
[shape] [shape]
(if-not (and shape (wasm/live?)) (if-not (and shape (wasm/live?))
{:thumbnails [] :full [] :font-pending-ids []} {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}}
(do (do
(perf/begin-measure "set-object") (perf/begin-measure "set-object")
(let [shape (svg-filters/apply-svg-derived shape)] (let [shape (svg-filters/apply-svg-derived shape)]
@ -1587,6 +1644,7 @@
(let [result (set-object-host-attrs shape false)] (let [result (set-object-host-attrs shape false)]
(perf/end-measure "set-object") (perf/end-measure "set-object")
result))))) result)))))
(defn- update-text-layouts (defn- update-text-layouts
"Synchronously update text layouts for all shapes and send rect updates "Synchronously update text layouts for all shapes and send rect updates
to the worker index." to the worker index."
@ -1611,6 +1669,29 @@
:auto-height (not (mth/close? height (:height selrect) 0.1)) :auto-height (not (mth/close? height (:height selrect) 0.1))
false))) 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! (defn- sync-stale-text-selrects!
"Emit the ids of auto-grow text shapes whose selrect no longer matches the "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 measured layout, so the workspace resizes them (data-event instead of a
@ -1624,24 +1705,26 @@
(map :id)) (map :id))
shapes)] shapes)]
(when (seq stale-ids) (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! (defn- relayout-after-fonts!
"Relayout text shapes once their pending fonts have resolved. Font fetches "Relayout text shapes once their pending fonts have resolved. Font fetches
are deduped per URL and storing a font does not invalidate cached layouts, are deduped per URL, so only shapes that use a not-yet-ready face at upload
so every text shape (not only the fetch triggers in `font-pending-ids`) time need a forced relayout; then re-sync selrects that drifted for those
needs a forced relayout; then re-sync selrects that drifted." shapes only."
[shapes font-pending-ids] [shapes text-font-state]
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] (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) (when (seq text-ids)
(if (seq font-pending-ids) (if (seq affected-ids)
(do (let [affected-shapes (into [] (keep shapes-by-id) affected-ids)]
(force-update-text-layouts text-ids) (force-update-text-layouts affected-ids)
(sync-stale-text-selrects! shapes)) (sync-stale-text-selrects! affected-shapes))
(update-text-layouts text-ids))))) (update-text-layouts text-ids)))))
(defn process-pending (defn process-pending
[shapes thumbnails full font-pending-ids on-complete] [shapes thumbnails full text-font-state on-complete]
(let [pending-thumbnails (let [pending-thumbnails
(d/index-by :key :callback thumbnails) (d/index-by :key :callback thumbnails)
@ -1664,18 +1747,24 @@
(rx/reduce conj []) (rx/reduce conj [])
(rx/catch #(rx/empty)))) (rx/catch #(rx/empty))))
(rx/subs! (rx/subs!
(fn [_]
(relayout-after-fonts! shapes font-pending-ids)
(request-render "images-loaded"))
noop-fn 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. ;; No pending images — complete immediately.
(when on-complete (on-complete))))) (when on-complete (on-complete)))))
(defn process-object (defn process-object
[shape] [shape]
(let [{:keys [thumbnails full font-pending-ids]} (set-object shape)] (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]}
(process-pending [shape] thumbnails full font-pending-ids noop-fn))) (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 (defn process-objects
"Like process-object but for multiple shapes at once. Accumulates all "Like process-object but for multiple shapes at once. Accumulates all
@ -1684,19 +1773,26 @@
just the first shape that triggered the fetch." just the first shape that triggered the fetch."
[shapes] [shapes]
(let [total-shapes (count shapes) (let [total-shapes (count shapes)
{:keys [thumbnails full font-pending-ids]} {:keys [thumbnails full text-font-state]}
(loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] (loop [index 0
thumbnails-acc (transient [])
full-acc (transient [])
font-state-acc empty-text-font-state]
(if (< index total-shapes) (if (< index total-shapes)
(let [shape (nth shapes index) (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) (recur (inc index)
(reduce conj! thumbnails-acc thumbnails) (reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full) (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) {:thumbnails (persistent! thumbnails-acc)
:full (persistent! full-acc) :full (persistent! full-acc)
:font-pending-ids (persistent! font-acc)}))] :text-font-state font-state-acc}))]
(process-pending shapes thumbnails full font-pending-ids noop-fn))) (process-pending shapes thumbnails full text-font-state noop-fn)))
(def ^:private ^:const BATCH_MAX_SHAPES 512) (def ^:private ^:const BATCH_MAX_SHAPES 512)
@ -1706,8 +1802,8 @@
Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk; Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk;
host-specific attrs (fills/strokes/text/grid/path) stay per-shape. host-specific attrs (fills/strokes/text/grid/path) stay per-shape.
Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}" Returns {:thumbnails [...] :full [...] :text-font-state {...} :next-index n}"
[shapes start-index thumbnails-acc full-acc font-pending-acc] [shapes start-index thumbnails-acc full-acc text-font-state-acc]
(let [total (count shapes) (let [total (count shapes)
end-index (min total (+ start-index BATCH_MAX_SHAPES)) end-index (min total (+ start-index BATCH_MAX_SHAPES))
chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes))
@ -1734,17 +1830,20 @@
(loop [xs prepared (loop [xs prepared
t-acc (transient thumbnails-acc) t-acc (transient thumbnails-acc)
f-acc (transient full-acc) f-acc (transient full-acc)
fp-acc (transient font-pending-acc)] font-state-acc text-font-state-acc]
(if-let [shape (first xs)] (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)] (set-object-host-attrs shape true :skip-fills-strokes? true)]
(recur (next xs) (recur (next xs)
(reduce conj! t-acc thumbnails) (reduce conj! t-acc thumbnails)
(reduce conj! f-acc full) (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) {:thumbnails (persistent! t-acc)
:full (persistent! f-acc) :full (persistent! f-acc)
:font-pending-ids (persistent! fp-acc) :text-font-state font-state-acc
:next-index end-index})))) :next-index end-index}))))
(defn- set-objects-async (defn- set-objects-async
@ -1755,16 +1854,16 @@
(let [total-shapes (count shapes)] (let [total-shapes (count shapes)]
(p/create (p/create
(fn [resolve _reject] (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) (if (< index total-shapes)
;; Process one time-budgeted chunk ;; 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 (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, then continue with next chunk
(-> (yield-to-browser) (-> (yield-to-browser)
(p/then (fn [_] (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 ;; All chunks done - finalize
(do (do
(perf/end-measure "set-objects") (perf/end-measure "set-objects")
@ -1815,12 +1914,12 @@
(if (fn? callback) (callback) (rx/empty)))) (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj []))) (rx/reduce conj [])))
(rx/subs! (rx/subs!
(fn [_]
(relayout-after-fonts! shapes font-pending-acc)
(request-render "images-loaded"))
noop-fn noop-fn
noop-fn)))))))))] noop-fn
(process-next-chunk 0 [] [] [])))))) (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 ;; 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))) (when (and (contains? #{:path :bool} type) (some? (get shape :content)))
(props/set-shape-path-content (get shape :content)))))) (props/set-shape-path-content (get shape :content))))))
(let [total-shapes (count prepared) (let [total-shapes (count prepared)
{:keys [thumbnails full font-pending-ids]} {:keys [thumbnails full text-font-state]}
(loop [index 0 (loop [index 0
thumbnails-acc (transient []) thumbnails-acc (transient [])
full-acc (transient []) full-acc (transient [])
font-acc (transient [])] font-state-acc empty-text-font-state]
(if (< index total-shapes) (if (< index total-shapes)
(let [shape (nth prepared index) (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)] (set-object-host-attrs shape true :skip-fills-strokes? true)]
(recur (inc index) (recur (inc index)
(reduce conj! thumbnails-acc thumbnails) (reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full) (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) {:thumbnails (persistent! thumbnails-acc)
:full (persistent! full-acc) :full (persistent! full-acc)
:font-pending-ids (persistent! font-acc)}))] :text-font-state font-state-acc}))]
(perf/end-measure "set-objects") (perf/end-measure "set-objects")
(when on-shapes-ready (on-shapes-ready)) (when on-shapes-ready (on-shapes-ready))
(when (wasm/live?) (when (wasm/live?)
@ -1910,7 +2012,7 @@
;; map to which tiles after a page switch. ;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false) (reset! view-interaction-active? false)
(process-pending shapes thumbnails full font-pending-ids (process-pending shapes thumbnails full text-font-state
(fn [] (fn []
(if render-callback (if render-callback
(render-callback) (render-callback)

View File

@ -252,12 +252,12 @@
(let [text-content-pending (api/set-shape-text-content id v) (let [text-content-pending (api/set-shape-text-content id v)
pending-thumbnails (vec text-content-pending) pending-thumbnails (vec text-content-pending)
pending-full (vec (api/set-shape-text-images id v)) 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 ;; FIXME: this is a hack to process the pending tasks
;; asynchronously we should probably modify set-wasm-attr! ;; asynchronously we should probably modify set-wasm-attr!
;; to return a list of callbacks to be executed in a ;; to return a list of callbacks to be executed in a
;; second pass. ;; 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)) nil))
:grow-type :grow-type
@ -341,7 +341,8 @@
(when (d/not-empty? shape-changes) (when (d/not-empty? shape-changes)
(->> (rx/from shape-changes) (->> (rx/from shape-changes)
(rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) (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 ;; `conj` empty set initialization
(def conj* (fnil conj (d/ordered-set))) (def conj* (fnil conj (d/ordered-set)))

View File

@ -90,6 +90,11 @@ pub struct TextContentSize {
const DEFAULT_TEXT_CONTENT_SIZE: f32 = 0.01; 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 { impl TextContentSize {
pub fn default() -> Self { pub fn default() -> Self {
Self { Self {
@ -863,7 +868,7 @@ impl TextContent {
let measure_paragraphs = let measure_paragraphs =
build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX); build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX);
let width = measure_paragraphs let content_width = measure_paragraphs
.iter() .iter()
.flatten() .flatten()
.fold(0.0_f32, |auto_width, paragraph| { .fold(0.0_f32, |auto_width, paragraph| {
@ -871,9 +876,10 @@ impl TextContent {
}) })
.ceil(); .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 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 let height = paragraphs
.iter() .iter()
.flatten() .flatten()
@ -881,10 +887,11 @@ impl TextContent {
auto_height + paragraph.height() auto_height + paragraph.height()
}); });
let reported_width = content_width + PARAGRAPH_SET_MARGIN_RIGHT;
let size = TextContentSize::new_with_normalized_line_height( let size = TextContentSize::new_with_normalized_line_height(
width, reported_width,
height.ceil(), height.ceil(),
width, reported_width,
normalized_line_height, normalized_line_height,
); );
TextContentLayoutResult(paragraph_builders, paragraphs, size) TextContentLayoutResult(paragraph_builders, paragraphs, size)