From 4a25c0bf943548af2e3240a9ee8124164c0b63b3 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 28 Oct 2026 11:24:45 +0000 Subject: [PATCH] :bug: 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 --- frontend/src/app/render_wasm/api.cljs | 43 +++++++++++++------ .../render_wasm/process_objects_test.cljs | 20 ++++++++- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 11f94a7037..46bca2a7d5 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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 diff --git a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs index 32ea3a8756..6f05b0966a 100644 --- a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs @@ -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