🐛 Relayout text whose font is fetched for another shape

Font fetches are shared per URL, so a text that asks for a face already
being fetched gets no fetch callback. process-pending only relaid out
text after its own callbacks, so such a text kept its fallback-font
layout. Auto-grow text recovered through the reflow resize, but fixed
text stayed wrong until another change.

Wait for the text's pending faces to be stored or to fail before the
relayout, whoever started their fetch.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-28 11:24:45 +00:00
parent e97b85cf1b
commit 4a25c0bf94
2 changed files with 50 additions and 13 deletions

View File

@ -1788,29 +1788,48 @@
(sync-stale-text-selrects! affected-shapes))
(update-text-layouts text-ids)))))
(defn- await-pending-faces
"Completes once every face in `pending-faces` is stored or has failed.
Waits on the faces, not on fetch callbacks: a face whose URL is already
being fetched for another shape yields no callback for this one."
[pending-faces]
(if (empty? pending-faces)
(rx/empty)
(->> (rx/merge f/font-stored-stream f/font-storage-failed-stream)
(rx/filter pending-faces)
(rx/scan disj pending-faces)
(rx/filter empty?)
(rx/take 1)
(rx/ignore))))
(defn process-pending
[shapes thumbnails full text-font-state on-complete]
(let [pending-thumbnails
(d/index-by :key :callback thumbnails)
pending-full
(d/index-by :key :callback full)]
(d/index-by :key :callback full)
pending-faces
(:pending-faces text-font-state)]
;; Run text layouts synchronously so shapes are immediately correct.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(if (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])
(rx/catch #(rx/empty)))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])
(rx/catch #(rx/empty))))
(if (or (seq pending-thumbnails) (seq pending-full) (seq pending-faces))
(->> (rx/merge
(rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])
(rx/catch #(rx/empty)))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])
(rx/catch #(rx/empty))))
(await-pending-faces pending-faces))
(rx/subs!
noop-fn
noop-fn
@ -1818,7 +1837,7 @@
(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 or faces: complete immediately.
(when on-complete (on-complete)))))
(defn process-objects

View File

@ -18,8 +18,10 @@
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.api.fonts :as wasm.fonts]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]))
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]))
;; ---------------------------------------------------------------------------
;; Helpers
@ -111,6 +113,22 @@
(t/is (= 2 (count (:shapes @captured)))
"Both shapes are in process-pending so font-load covers all of them")))
(t/deftest ^:async process-pending-waits-for-faces-fetched-for-other-shapes
"A text whose face is already being fetched for another shape gets no fetch
callback. process-pending must still wait for that face, so the relayout
that follows uses the real font instead of the fallback."
(let [face "face-key"
shape (assoc (make-shape :text) :grow-type :fixed)
completed (atom false)]
(wasm.api/process-pending [shape] [] []
{:font-index {face #{(:id shape)}}
:pending-faces #{face}}
#(reset! completed true))
(await (async/settle))
(t/is (not @completed) "the shape waits for its pending face")
(rx/push! wasm.fonts/font-stored-stream face)
(await (async/wait-for #(deref completed) "storing the face completes the wait"))))
(t/deftest empty-grid-tracks-do-not-allocate-zero-bytes
(let [calls (atom [])
;; `h/call` is a macro that resolves the wasm function off the module