Add waitForLayoutUpdate plugin method

This commit is contained in:
alonso.torres 2026-05-13 15:12:15 +02:00
parent 5775e947ad
commit 17915e6717
13 changed files with 390 additions and 24 deletions

View File

@ -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?

View File

@ -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)))))))

View File

@ -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
[]

View File

@ -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 _]

View File

@ -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

View File

@ -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

View File

@ -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))))

View File

@ -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]

View File

@ -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

View File

@ -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)))))))

View File

@ -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)

View File

@ -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<void>;
}
/**
@ -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<void>;
}
/**

View File

@ -388,6 +388,11 @@ export function createApi(
checkPermission('content:write');
return plugin.context.createVariantFromComponents(shapes);
},
waitForLayoutUpdate(timeout?: number): Promise<void> {
checkPermission('content:read');
return plugin.context.waitForLayoutUpdate(timeout);
},
};
return {