From 17915e67173f0f31fa2160af183c37b18e767aff Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 13 May 2026 15:12:15 +0200 Subject: [PATCH] :sparkles: Add waitForLayoutUpdate plugin method --- .../app/main/data/workspace/modifiers.cljs | 2 +- .../src/app/main/data/workspace/reflow.cljs | 117 +++++++++++++++ .../app/main/data/workspace/shape_layout.cljs | 11 +- .../src/app/main/data/workspace/texts.cljs | 72 +++++++-- .../app/main/data/workspace/wasm_text.cljs | 11 ++ frontend/src/app/main/fonts.cljs | 6 + frontend/src/app/plugins/api.cljs | 8 +- frontend/src/app/plugins/shape.cljs | 6 + frontend/src/app/render_wasm/api/fonts.cljs | 11 +- .../plugins/context_shapes_test.cljs | 141 +++++++++++++++++- plugins/CHANGELOG.md | 2 + plugins/libs/plugin-types/index.d.ts | 22 +++ .../libs/plugins-runtime/src/lib/api/index.ts | 5 + 13 files changed, 390 insertions(+), 24 deletions(-) create mode 100644 frontend/src/app/main/data/workspace/reflow.cljs diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 96a1605704..a2f56e7deb 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -744,7 +744,7 @@ subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] - (ptk/reify ::apply-wasm-modifiesr + (ptk/reify ::apply-wasm-modifiers ptk/WatchEvent (watch [_ state _] (let [translation? diff --git a/frontend/src/app/main/data/workspace/reflow.cljs b/frontend/src/app/main/data/workspace/reflow.cljs new file mode 100644 index 0000000000..5b7c8e7be9 --- /dev/null +++ b/frontend/src/app/main/data/workspace/reflow.cljs @@ -0,0 +1,117 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.data.workspace.reflow + "Tracks the shape ids that have layout/reflow work in flight, broken down by + the kind of work so we can tell which type of reflow is blocking each shape. + + Pending work is stored as a nested refcount map `{shape-id -> {kind -> count}}`: + a shape is pending while it has at least one kind with a count greater than + zero. The count (instead of a plain set) is required because several reflow + operations of the same kind can target the same shape while an earlier one is + still in flight; each `mark-pending!` must be balanced by exactly one + `mark-done!` of the same kind and ids so an operation finishing doesn't resolve + waiters that are still blocked on another operation for the same shape. + + Kinds correspond to the pipelines that schedule the work: + :layout flex/grid layout reflow (shape-layout) + :text-resize wasm text geometry resize (wasm-text) + :font font change measurement (texts)" + (:require + [beicon.v2.core :as rx])) + +;; Feeder subject receiving `{:op .. :kind .. :ids ..}` messages from +;; mark-pending! / mark-done! / reset-pending!; scanned into the +;; `pending-shapes` refcount map. +(defonce ^:private reflow-input (rx/subject)) + +;; Increments the `kind` refcount of each id (adding the shape/kind starting at 1). +(defn- inc-ids + [acc kind ids] + (reduce (fn [m id] (update-in m [id kind] (fnil inc 0))) acc ids)) + +;; Decrements the `kind` refcount of each id, dropping the kind once it reaches +;; zero and the shape once it has no pending kinds left (decrementing an absent +;; id/kind is a no-op). +(defn- dec-ids + [acc kind ids] + (reduce (fn [m id] + (let [n (dec (get-in m [id kind] 0)) + kinds (if (pos? n) + (assoc (get m id) kind n) + (dissoc (get m id) kind))] + (if (seq kinds) + (assoc m id kinds) + (dissoc m id)))) + acc ids)) + +;; Applies one `{:op .. :kind .. :ids ..}` message to the pending map: :add +;; increments, :remove decrements, :reset clears everything. +(defn- reducer + [acc {:keys [op kind ids]}] + (case op + :add (inc-ids acc kind ids) + :remove (dec-ids acc kind ids) + :reset {} + acc)) + +;; Behaviour subject holding the current pending map `{shape-id -> {kind -> count}}`. +;; It replays its current value synchronously to new subscribers, which gives +;; `wait-for-layout-update` a free fast-path when there is nothing pending. +(defonce ^:private pending-shapes + (let [sub (rx/behavior-subject {})] + (rx/sub! (->> reflow-input (rx/scan reducer {})) sub) + sub)) + +;; NOTE: do not dedupe `ids` — multiplicity must be preserved so each +;; `mark-pending!` is balanced by exactly one `mark-done!` of the same kind and ids. +(defn mark-pending! + [kind ids] + (rx/push! reflow-input {:op :add :kind kind :ids ids})) + +(defn mark-done! + [kind ids] + (rx/push! reflow-input {:op :remove :kind kind :ids ids})) + +(defn reset-pending! + [] + (rx/push! reflow-input {:op :reset})) + +(defn wait-for-layout-update + "Returns a JS Promise that resolves when `shape-id` (or, when nil, every + pending shape) has drained from the pending map. When `timeout` (ms) is + provided and elapses first, the promise is rejected. The single-arity form + waits for every pending shape (shape-id nil)." + ([timeout] + (wait-for-layout-update nil timeout)) + ([shape-id timeout] + (js/Promise. + (fn [resolve reject] + (let [done? (fn [pending] + (if shape-id + (not (contains? pending shape-id)) + (empty? pending))) + + settled (->> pending-shapes + (rx/filter done?) + (rx/map (constantly :ok))) + + ;; Race the settle signal against the optional deadline; whichever + ;; reacts first wins (and the loser is unsubscribed). Without a + ;; timeout we just wait on the settle signal. + source (if timeout + (rx/race (->> (rx/of :timeout) + (rx/delay timeout)) + settled) + settled)] + (->> source + (rx/take 1) + (rx/subs! + (fn [value] + (if (= value :timeout) + (reject (js/Error. "waitForLayoutUpdate timeout")) + (resolve))) + reject))))))) diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index 0e84539988..b6038e6858 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -26,6 +26,7 @@ [app.main.data.workspace.colors :as cl] [app.main.data.workspace.grid-layout.editor :as dwge] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dwse] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] @@ -130,17 +131,25 @@ ;; we can just use a keyword for it (rx/filter (ptk/type? :layout/update)) (rx/map deref) + ;; Mark the affected shapes as pending reflow so the plugin API + ;; `waitForLayoutUpdate` can wait until the buffered updates flush. + (rx/tap #(wrf/mark-pending! :layout (:ids %))) ;; We buffer the updates to the layout so if there are many changes at the same time ;; they are process together. It will get a better performance. (rx/buffer-time 100) (rx/filter #(d/not-empty? %)) + ;; Balance the per-event marks: decrement once per buffered event + ;; (multiplicity preserved via mapcat, no deduping). + (rx/tap #(wrf/mark-done! :layout (mapcat :ids %))) (rx/mapcat (fn [data] (->> (group-by :page-id data) (map (fn [[page-id items]] (let [ids (reduce #(into %1 (:ids %2)) #{} items)] (update-layout-positions {:page-id page-id :ids ids}))))))) - (rx/take-until stopper)))))) + (rx/take-until stopper) + ;; On workspace teardown clear everything still pending. + (rx/finalize wrf/reset-pending!)))))) (defn finalize-shape-layout [] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 30b2ecf40b..9f95da7140 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -27,6 +27,7 @@ [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.transforms :as dwt] @@ -59,6 +60,8 @@ (declare v2-update-text-editor-styles) (declare v2-sync-wasm-text-layout) +(def ^:private ^:const stuck-timeout 10000) + ;; -- Content helpers (defn- v2-content-has-text? @@ -391,11 +394,12 @@ (update-content-range start end attrs))] (assoc shape :content new-content))) + (defn update-text-range [id start end attrs] (ptk/reify ::update-text-range ptk/WatchEvent - (watch [_ state _] + (watch [_ state stream] (let [objects (dsh/lookup-page-objects state) shape (get objects id) @@ -412,7 +416,17 @@ (rx/of (dwsh/update-shapes shape-ids update-fn)) (if (features/active-feature? state "render-wasm/v1") (rx/of (dwwt/resize-wasm-text-debounce id)) - (rx/empty))))))) + (when (contains? attrs :font-id) + (wrf/mark-pending! :font [id]) + (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] + (->> stream + (rx/filter (ptk/type? ::commit-position-data)) + (rx/take 1) + ;; Timeout so the shape cannot stay pending forever + (rx/timeout stuck-timeout (rx/of :timeout)) + (rx/take-until stopper) + (rx/finalize #(wrf/mark-done! :font [id])) + (rx/ignore)))))))))) (defn update-root-attrs [{:keys [id attrs]}] @@ -808,13 +822,6 @@ (rx/of (update-position-data id position-data)))) (rx/empty)))))) -(defn font-loaded-event? - [font-id] - (fn [event] - (and - (= :font-loaded (ptk/type event)) - (= (:font-id (deref event)) font-id)))) - (defn update-attrs [id attrs] (ptk/reify ::update-attrs @@ -844,7 +851,7 @@ (not (features/active-feature? state "text-editor-wasm/v1"))) (rx/of (v2-update-text-editor-styles id attrs))) - (when (features/active-feature? state "render-wasm/v1") + (if (features/active-feature? state "render-wasm/v1") (rx/concat ;; Apply style to selected spans and sync content (let [has-selection? (wasm.api/text-editor-has-selection?)] @@ -858,12 +865,45 @@ :update-name? true)))))))) ;; Resize (with delay for font-id changes) (if (contains? attrs :font-id) - (->> stream - (rx/filter (font-loaded-event? (:font-id attrs))) - (rx/take 1) - (rx/observe-on :async) - (rx/map #(dwwt/resize-wasm-text id))) - (rx/of (dwwt/resize-wasm-text id))))))))) + (let [objects (dsh/lookup-page-objects state) + shape (get objects id)] + (if (and (cfh/text-shape? shape) + (not= :fixed (:grow-type shape))) + ;; Auto-height/auto-width: mark the shape pending immediately so + ;; waitForLayoutUpdate detects pending work. Dispatch resize to + ;; trigger font loading (set-shape-text-content → store-fonts), + ;; then re-dispatch after font-loaded for correct final geometry. + ;; This path doesn't go through the resize debounce pipeline, so + ;; it owns its own mark-pending!/mark-done! pair. + (do + (wrf/mark-pending! :font [id]) + (rx/concat + (rx/of (dwwt/resize-wasm-text id)) + (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] + (->> fonts/font-loaded-stream + (rx/filter #(= % (:font-id attrs))) + (rx/take 1) + ;; Timeout if the font load doesn't arrive eventually + (rx/timeout stuck-timeout (rx/of :timeout)) + (rx/take-until stopper) + (rx/observe-on :async) + (rx/map #(dwwt/resize-wasm-text id)) + (rx/finalize #(wrf/mark-done! :font [id])))))) + ;; Fixed: geometry doesn't change with font; no need to wait. + (rx/of (dwwt/resize-wasm-text id)))) + (rx/of (dwwt/resize-wasm-text id)))) + + (when (contains? attrs :font-id) + (wrf/mark-pending! :font [id]) + (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] + (->> stream + (rx/filter (ptk/type? ::commit-position-data)) + (rx/take 1) + ;; Timeout so the shape cannot stay pending forever + (rx/timeout stuck-timeout (rx/of :timeout)) + (rx/take-until stopper) + (rx/finalize #(wrf/mark-done! :font [id])) + (rx/ignore))))))))) ptk/EffectEvent (effect [_ state _] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 6468f487aa..70096f86b8 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -17,6 +17,7 @@ [app.common.types.modifiers :as ctm] [app.main.data.helpers :as dsh] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.render-wasm.api :as wasm.api] @@ -110,6 +111,10 @@ apply-opts (cond-> {} (some? undo-group) (assoc :undo-group undo-group) extend-tx? (assoc :undo-transation? false))] + ;; Balance the per-invocation `mark-pending!` done in + ;; resize-wasm-text-debounce-inner: the batch carries one entry per + ;; conj (duplicates included), so this exactly clears them. + (wrf/mark-done! :text-resize ids) (cond (not (empty? modifiers)) (if extend-tx? @@ -144,6 +149,12 @@ ptk/WatchEvent (watch [_ state stream] + ;; One mark per invocation (1:1 with the conj in UpdateEvent above); + ;; balanced by `mark-done!` of the full batch in + ;; resize-wasm-text-debounce-commit. The stopper below is workspace + ;; teardown, which clears everything via shape-layout's finalize, so no + ;; extra decrement is needed when the wait is cut short. + (wrf/mark-pending! :text-resize [id]) (if (= (::resize-wasm-text-debounce-event state) cur-event) (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] (rx/concat diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 02181c3db8..66ebb07c05 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -211,6 +211,11 @@ ;; LOAD API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Emits the font-id of every font that finishes loading (browser side). Lets +;; consumers (e.g. app.main.data.workspace.texts) react to font loads without +;; this namespace having to depend on the store/potok/ui. +(defonce font-loaded-stream (rx/subject)) + (defn ensure-loaded! ([font-id] (ensure-loaded! font-id nil)) ([font-id variant-id] @@ -240,6 +245,7 @@ (let [on-load (fn [resolve] (swap! loaded conj font-id) (swap! loading dissoc font-id) + (rx/push! font-loaded-stream font-id) (resolve font-id)) load-p (-> (p/create diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index a9b1e4d323..e0d968abe7 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -28,6 +28,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.pages :as dwpg] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] @@ -699,4 +700,9 @@ (se/add-event plugin-id))) (shape/shape-proxy plugin-id variant-id)) - (u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant"))))))) + (u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant"))))) + + :waitForLayoutUpdate + (fn [timeout] + ;; Resolve once every shape with reflow work in flight has settled. + (wrf/wait-for-layout-update timeout)))) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 882f8eca05..5c275a7dc7 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -42,6 +42,7 @@ [app.main.data.workspace.guides :as dwgu] [app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] @@ -1008,6 +1009,11 @@ :else (st/emit! (dwsh/delete-shapes #{id})))) + :waitForLayoutUpdate + (fn [timeout] + ;; Resolve once this shape's pending reflow work has settled. + (wrf/wait-for-layout-update id timeout)) + ;; Plugin data :getPluginData (fn [key] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 676a00cf78..e4153e2e07 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -17,12 +17,12 @@ [app.render-wasm.helpers :as h] [app.render-wasm.wasm :as wasm] [app.util.http :as http] + [app.util.timers :as tm] [beicon.v2.core :as rx] [cuerdas.core :as str] [goog.object :as gobj] [lambdaisland.uri :as u] - [okulary.core :as l] - [potok.v2.core :as ptk])) + [okulary.core :as l])) (def ^:private fonts (l/derived :fonts st/state)) @@ -130,7 +130,7 @@ mem (js/Uint8Array. (.-buffer heap) ptr size)] (.set mem (js/Uint8Array. font-array-buffer)) - (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) + (rx/push! fonts/font-loaded-stream (:font-id font-data)) (h/call wasm/internal-module "_store_font" (aget font-id-buffer 0) (aget font-id-buffer 1) @@ -213,7 +213,10 @@ font-data (assoc font-data :family-id-buffer id-buffer) font-stored? (font-stored? font-data emoji?)] (if font-stored? - (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) + ;; Defer so the consumer (which subscribes to font-loaded-stream after + ;; dispatching the resize that triggers this store) is listening when + ;; the already-stored font is reported as loaded. + (tm/schedule #(rx/push! fonts/font-loaded-stream (:font-id font-data))) (fetch-font font-data uri emoji? fallback?))))) (defn serialize-font-style diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index dd1a7e3634..c52b71c67d 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -9,12 +9,15 @@ [app.common.math :as m] [app.common.test-helpers.files :as cthf] [app.common.uuid :as uuid] + [app.main.data.workspace.reflow :as wrf] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.shape :as shape] [app.util.object :as obj] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths] - [frontend-tests.helpers.wasm :as thw])) + [frontend-tests.helpers.wasm :as thw] + [potok.v2.core :as ptk])) (t/deftest test-common-shape-properties (thw/with-wasm-mocks* @@ -402,3 +405,139 @@ (let [shadows (.-shadows shape)] (t/is (array? shadows)) (t/is (= 0 (.-length shadows))))))))) + +;; ---- waitForLayoutUpdate tests ------------------------------------------ +;; +;; `waitForLayoutUpdate` resolves once the shapes with reflow work in flight +;; have drained from the `app.main.data.workspace.reflow` pending refcount map. +;; The tests drive that map directly with `mark-pending!` / `mark-done!` instead +;; of replaying internal pipeline events. A minimal store is still installed so +;; `create-context` / `shape-proxy` can resolve the global st/state. + +(def ^:private original-st-state st/state) +(def ^:private original-st-stream st/stream) +(def ^:private zero-id "00000000-0000-0000-0000-000000000000") + +(t/use-fixtures :each + {:before wrf/reset-pending! + :after (fn [] + (wrf/reset-pending!) + (set! st/state original-st-state) + (set! st/stream original-st-stream))}) + +(defn- make-test-store + "Creates a minimal potok store with an empty state map and installs it as the + global st/state and st/stream for the duration of the calling test." + [] + (let [test-store (ptk/store {:state {} :on-error (fn [e] (js/console.error e))})] + (set! st/state test-store) + (set! st/stream (ptk/input-stream test-store)) + test-store)) + +(t/deftest test-wait-for-layout-update-no-pending + ;; When nothing is pending the promise resolves immediately via the fast path + ;; (the behavior-subject replays the empty map on subscribe). + (t/async done + (make-test-store) + (let [^js ctx (api/create-context zero-id)] + (-> (.waitForLayoutUpdate ctx) + (.then (fn [] + (t/is true "resolved with no pending work") + (done))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err)) + (done))))))) + +(t/deftest test-wait-for-layout-update-pending + ;; While a shape is pending the context promise stays unresolved; it resolves + ;; once that shape is marked done. + (t/async done + (make-test-store) + (wrf/mark-pending! :layout [(uuid/next)]) + (let [^js ctx (api/create-context zero-id) + resolved (atom false)] + (-> (.waitForLayoutUpdate ctx) + (.then (fn [] (reset! resolved true))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err))))) + (js/setTimeout + (fn [] + (t/is (false? @resolved) "must not resolve while a shape is pending") + ;; Draining everything resolves the context wait. + (wrf/reset-pending!) + (js/setTimeout + (fn [] + (t/is (true? @resolved) "resolves once nothing is pending") + (done)) + 20)) + 20)))) + +(t/deftest test-wait-for-layout-update-per-shape + ;; `shape.waitForLayoutUpdate()` only waits for that shape: draining another + ;; shape must not resolve it; draining its own id does. + (t/async done + (make-test-store) + (let [id-a (uuid/next) + id-b (uuid/next) + ^js shape-a (shape/shape-proxy zero-id uuid/zero uuid/zero id-a) + resolved (atom false)] + (wrf/mark-pending! :layout [id-a id-b]) + (-> (.waitForLayoutUpdate shape-a) + (.then (fn [] (reset! resolved true))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err))))) + ;; Draining the other shape leaves A pending. + (wrf/mark-done! :layout [id-b]) + (js/setTimeout + (fn [] + (t/is (false? @resolved) "shape A wait must stay pending while A is pending") + (wrf/mark-done! :layout [id-a]) + (js/setTimeout + (fn [] + (t/is (true? @resolved) "resolves once shape A drains") + (done)) + 20)) + 20)))) + +(t/deftest test-wait-for-layout-update-refcount + ;; Two overlapping operations on the same shape: a single mark-done! must not + ;; resolve the wait while the second operation is still in flight. + (t/async done + (make-test-store) + (let [id (uuid/next) + ^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero id) + resolved (atom false)] + (wrf/mark-pending! :layout [id]) + (wrf/mark-pending! :layout [id]) + (-> (.waitForLayoutUpdate shape) + (.then (fn [] (reset! resolved true))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err))))) + ;; First operation finishes; second is still pending. + (wrf/mark-done! :layout [id]) + (js/setTimeout + (fn [] + (t/is (false? @resolved) "must not resolve while a second op is in flight") + (wrf/mark-done! :layout [id]) + (js/setTimeout + (fn [] + (t/is (true? @resolved) "resolves once both operations drain") + (done)) + 20)) + 20)))) + +(t/deftest test-wait-for-layout-update-timeout + ;; When the optional timeout fires before the shape drains the promise should + ;; reject with an Error whose message mentions "timeout". + (t/async done + (make-test-store) + (wrf/mark-pending! :layout [(uuid/next)]) + (let [^js ctx (api/create-context zero-id)] + (-> (.waitForLayoutUpdate ctx 20) + (.then (fn [] + (t/is false "expected rejection but promise resolved") + (done))) + (.catch (fn [^js err] + (t/is (instance? js/Error err) "rejection value should be an Error") + (t/is (some? (re-find #"timeout" (.-message err))) "error message should mention timeout") + (done))))))) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 64163dbd42..e109282307 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -17,6 +17,8 @@ - **plugin-types**: Added `fixedWhenScrolling` property for shapes - **plugin-runtime:** `addToken` now resolves references against all token sets, allowing references to tokens in inactive sets - **plugin-types:** `TokenCatalog.addSet` now accepts an optional `active` flag to create an already-active set (sets are inactive by default) +- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished +- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until a single shape's pending layout updates have finished ## 1.4.2 (2026-01-21) diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index fc53fd1b33..229ae8c9e1 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1343,6 +1343,17 @@ export interface Context { * @return The variant container created */ createVariantFromComponents(shapes: Board[]): VariantContainer; + + /** + * This method returns a promise that will be resolved when all the + * pending layout updates have finished. If no layout work is pending + * the promise resolves immediately. + * @param timeout Maximum time to wait, in milliseconds. If the timeout + * elapses before the layout settles, the promise is rejected. Without a + * timeout the promise waits indefinitely. + * @return The promise to be resolved when the layout is updated + */ + waitForLayoutUpdate(timeout?: number): Promise; } /** @@ -4019,6 +4030,17 @@ export interface ShapeBase extends PluginData { * Removes the shape from its parent. */ remove(): void; + + /** + * This method returns a promise that will be resolved when the pending + * layout updates for this shape have finished. If no layout work is pending + * for the shape the promise resolves immediately. + * @param timeout Maximum time to wait, in milliseconds. If the timeout + * elapses before the shape's layout settles, the promise is rejected. + * Without a timeout the promise waits indefinitely. + * @return The promise to be resolved when the shape's layout is updated + */ + waitForLayoutUpdate(timeout?: number): Promise; } /** diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 6faa7b1925..8956a5124e 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -388,6 +388,11 @@ export function createApi( checkPermission('content:write'); return plugin.context.createVariantFromComponents(shapes); }, + + waitForLayoutUpdate(timeout?: number): Promise { + checkPermission('content:read'); + return plugin.context.waitForLayoutUpdate(timeout); + }, }; return {