Add waitForLayoutUpdate plugin method (#9898)

*  Add waitForLayoutUpdate plugin method

*  Refactor internal wait for tasks
This commit is contained in:
Alonso Torres 2026-07-28 14:44:30 +02:00 committed by GitHub
parent 0999bc31b2
commit cb21f6401a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1752 additions and 102 deletions

View File

@ -60,6 +60,7 @@
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwtxt]
[app.main.data.workspace.thumbnails :as dwth]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
@ -402,6 +403,7 @@
(rx/of (dpj/initialize-project (:project-id file))
(dwn/initialize team-id file-id)
(dwsl/initialize-shape-layout)
(dwtxt/initialize-text-reflow)
(fetch-libraries file-id features)
(-> (workspace-initialized file-id)
(with-meta {:team-id team-id
@ -553,6 +555,7 @@
(rx/of (dwn/finalize file-id)
(dpj/finalize-project project-id)
(dwsl/finalize-shape-layout)
(dwtxt/finalize-text-reflow)
(dwcl/stop-picker)
(dwc/set-workspace-visited)
(modal/hide)

View File

@ -770,7 +770,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,194 @@
;; 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 `{shape-id -> {kind -> #{task-id}}}`. Every producer
opens an exact task with `start!` and closes that same task with `finish!`.
Tasks belong to a workspace generation, so a delayed completion from a
finalized workspace cannot drain work opened after the workspace reloads.
Observable producers should use `with-pending`; imperative renderer work
should use `run-pending!`. Direct task lifecycle calls are reserved for Potok
pipelines whose operation has to cross event or batching boundaries.
Kinds correspond to the pipelines that schedule the work:
:layout flex/grid layout reflow (shape-layout)
:text-resize text geometry resize (wasm-text, texts)
:text-measure DOM text measurement (texts)
:text-bridge change awaiting its pipeline (texts)
:font font change measurement (texts)"
(:require
[beicon.v2.core :as rx]
[promesa.core :as p]))
;; Feeder subject receiving task lifecycle messages, scanned into the
;; `pending-shapes` map.
(defonce ^:private reflow-input (rx/subject))
(defonce ^:private workspace-generation (atom 0))
(defonce ^:private next-task-id (atom 0))
(defn- add-task
[acc {:keys [id kind ids]}]
(reduce (fn [m shape-id]
(update-in m [shape-id kind] (fnil conj #{}) id))
acc
ids))
(defn- remove-task
[acc {:keys [id kind ids]}]
(let [task-id id]
(reduce (fn [m shape-id]
(let [tasks (disj (get-in m [shape-id kind] #{}) task-id)
kinds (if (seq tasks)
(assoc (get m shape-id) kind tasks)
(dissoc (get m shape-id) kind))]
(if (seq kinds)
(assoc m shape-id kinds)
(dissoc m shape-id))))
acc
ids)))
(defn- reducer
[acc {:keys [op task ids]}]
(case op
:add (add-task acc task)
:remove (remove-task acc task)
:cancel (apply dissoc acc ids)
:reset {}
acc))
;; Behaviour subject holding `{shape-id -> {kind -> #{task-id}}}`.
;; 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))
(defn task
"Creates an opaque task token without opening it."
[kind ids]
{:id (swap! next-task-id inc)
:generation @workspace-generation
:kind kind
:ids (into #{} ids)})
(defn start!
"Opens and returns a task. The one-argument form opens a token created with
`task`; the two-argument form creates and opens it in one step."
([task]
(when (and (seq (:ids task))
(= (:generation task) @workspace-generation))
(rx/push! reflow-input {:op :add :task task}))
task)
([kind ids]
(start! (task kind ids))))
(defn finish!
"Closes `task` if it belongs to the active workspace generation. Repeated or
stale completion is a no-op."
[{:keys [generation ids] :as task}]
(when (and (seq ids)
(= generation @workspace-generation))
(rx/push! reflow-input {:op :remove :task task})))
(defn reset-pending!
"Starts a new workspace generation and forgets every task from the old one."
[]
(swap! workspace-generation inc)
(rx/push! reflow-input {:op :reset}))
(defn cancel-shapes!
"Forgets pending work attached to shapes that no longer exist."
[ids]
(when (seq ids)
(rx/push! reflow-input {:op :cancel :ids ids})))
(defn with-pending
"Runs observable `ob` as tracked layout work. Prefer this entry point for
observable producers: it owns task activation and finalization, including
errors and unsubscription. An `ob` that is built but never subscribed marks
nothing."
[kind ids ob]
(->> (rx/of ::subscribe)
(rx/mapcat (fn [_]
(let [task (start! kind ids)]
(rx/finalize #(finish! task) ob))))))
(defn run-pending!
"Runs zero-argument promise operation `f` as tracked layout work. The task is
opened before `f` runs and is finalized when its returned promise settles.
Synchronous failures also finalize the task before being rethrown.
Prefer this entry point for imperative or renderer-driven producers."
[kind ids f]
(let [task (start! kind ids)]
(try
(p/finally (f) #(finish! task))
(catch :default cause
(finish! task)
(throw cause)))))
(defn pending-signal
"Emits once any of `kinds` is pending for any of `ids`, then completes.
Emits right away when that work is already in flight."
[ids kinds]
(letfn [(id-pending? [pending id]
(some (partial contains? (get pending id)) kinds))
(any-pending? [pending]
(some (partial id-pending? pending) ids))]
(->> pending-shapes
(rx/filter any-pending?)
(rx/take 1))))
;; Ceiling for callers that pass no timeout, so a pipeline that never drains
;; its marks rejects the promise rather than leaving it unsettled.
(def ^:private default-timeout 30000)
(defn wait-for-layout-update
"Returns a JS Promise that resolves when every id in `shape-ids` has drained
from the pending map. A nil `shape-ids` waits for every pending shape; an
empty one has nothing to wait for and resolves right away. The promise is
rejected when `timeout` (ms) elapses first; a nil `timeout` uses
`default-timeout`.
Callers waiting on one shape pass its whole subtree: reflow work lands either
on the shape (a board laying out its children) or on its descendants (a group
whose texts are re-measured)."
([timeout]
(wait-for-layout-update nil timeout))
([shape-ids timeout]
(js/Promise.
(fn [resolve reject]
(let [timeout (or timeout default-timeout)
done? (if (some? shape-ids)
(fn [pending] (not-any? #(contains? pending %) shape-ids))
empty?)
settled (->> pending-shapes
(rx/filter done?)
(rx/map (constantly :ok)))
;; Race the settle signal against the deadline; the loser is
;; unsubscribed. `settled` replays on subscribe, so an already
;; drained map wins even against a 1ms deadline.
source (rx/race (->> (rx/of :timeout)
(rx/delay timeout))
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]
@ -98,26 +99,50 @@
;; Never call this directly but through the data-event `:layout/update`
;; Otherwise a lot of cycle dependencies could be generated
(defn- update-layout-positions
[{:keys [page-id ids undo-group]}]
[{:keys [page-id ids undo-group reflow-tasks]}]
(ptk/reify ::update-layout-positions
ptk/WatchEvent
(watch [_ state _]
(watch [_ state stream]
(let [page-id (or page-id (:current-page-id state))
objects (dsh/lookup-page-objects state page-id)
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))]
(if (d/not-empty? ids)
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))
update-positions-stream
(if (d/not-empty? ids)
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree
:stack-undo? true
:undo-group undo-group
:ignore-touched true))
(rx/of (dwm/apply-modifiers {:page-id page-id
:modifiers modif-tree
:stack-undo? true
:undo-group undo-group
:ignore-touched true))
(rx/of (dwm/apply-modifiers {:page-id page-id
:modifiers modif-tree
:stack-undo? true
:ignore-touched true
:undo-group undo-group}))))
(rx/empty))))))
:ignore-touched true
:undo-group undo-group}))))
(rx/empty))
;; The apply events above are dispatched and applied synchronously,
;; except while a shape-update buffer is open (token propagation),
;; where the commit lands on `update-shapes-buffer-commit`.
drain-stream
(if (and (::dwsh/update-shapes-buffer state)
(d/not-empty? ids))
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/take 1)
(rx/take-until (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream))
;; No events are derived from this
(rx/ignore))
(rx/empty))]
(cond->> (rx/concat update-positions-stream drain-stream)
(d/not-empty? reflow-tasks)
(rx/finalize #(run! wrf/finish! reflow-tasks)))))))
(defn- without-root-board
[ids]
(into [] (remove uuid/zero?) ids))
(defn initialize-shape-layout
[]
@ -126,21 +151,27 @@
(watch [_ _ stream]
(let [stopper (rx/filter (ptk/type? ::finalize-shape-layout) stream)]
(->> stream
;; FIXME: we don't need use types for simple signaling,
;; we can just use a keyword for it
(rx/filter (ptk/type? :layout/update))
(rx/map deref)
;; 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.
;; Keeps each update's page and the shapes that can be laid out;
;; the root lays out nothing, so an update with only it is dropped.
(rx/map (fn [event]
(-> (deref event)
(update :ids without-root-board))))
(rx/filter #(d/not-empty? (:ids %)))
(rx/map #(assoc % ::reflow-task (wrf/start! :layout (:ids %))))
(rx/buffer-time 100)
(rx/filter #(d/not-empty? %))
(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))))))
(fn [updates]
(->> (group-by :page-id updates)
(map (fn [[page-id updates]]
(let [ids (into #{} (mapcat :ids) updates)]
(update-layout-positions {:page-id page-id
:ids ids
:reflow-tasks (mapv ::reflow-task updates)})))))))
(rx/take-until stopper)
;; On workspace teardown clear everything still pending.
(rx/finalize wrf/reset-pending!))))))
(defn finalize-shape-layout
[]

View File

@ -23,8 +23,10 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace.collapse :as dwco]
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -32,6 +34,24 @@
;; `(when-not translation? …)` guard in `update-shapes` below.
(def ^:private update-layout-attr? #{:hidden})
;; Text attrs whose change makes the DOM text pipeline re-measure the shape.
(def ^:private text-reflow-attr? #{:content :grow-type})
(defn- reflow-attr?
[attr]
(or (update-layout-attr? attr) (text-reflow-attr? attr)))
(defn- async-text-reflow?
"Whether `shape` enters an asynchronous text geometry pipeline. The HTML
renderer measures every changed text; WASM only resizes auto-sized texts.
A grow-type transition is included because `shape` is the value before the
update and may still be fixed."
[state shape changed]
(and (cfh/text-shape? shape)
(or (not (features/active-feature? state "render-wasm/v1"))
(not= :fixed (:grow-type shape))
(contains? changed :grow-type))))
(defn- add-undo-group
[changes state]
(let [undo (:workspace-undo state)
@ -177,25 +197,48 @@
objects (dsh/lookup-page-objects state page-id)
ids (into [] (filter some?) ids)
xf-update-layout
;; Pairs of [shape changed-attrs] for the shapes whose change
;; matters to a reflow, feeding both id sets below.
xf-reflow
(comp
(map (d/getf objects))
(filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?})))
(map :id))
(keep (fn [shape]
(let [changed (pcb/changed-attrs shape objects update-fn
{:attrs attrs :with-objects? with-objects?})]
(when (some reflow-attr? changed)
[shape changed])))))
;; `changed-attrs` runs `update-fn` in full for every shape, which
;; can be expensive (e.g. `update-bool-shape` recalculates the whole
;; boolean path in WASM). Skip the pass entirely when we can prove it
;; cannot match: when the caller declares `attrs`, `changed-attrs`
;; filters its result to that set, so if no layout attr is present
;; filters its result to that set, so if no reflow attr is present
;; the check is always empty.
update-layout-ids
reflow-changes
(when-not (or translation?
(not update-layout?)
(and (some? attrs)
(not (some update-layout-attr? attrs))))
(->> (into [] xf-update-layout ids)
(not-empty)))
(not (some reflow-attr? attrs))))
(into [] xf-reflow ids))
update-layout-ids
(->> reflow-changes
(into [] (comp (filter (fn [[_ changed]] (some update-layout-attr? changed)))
(map (comp :id first))))
(not-empty))
;; Text shapes the DOM pipeline has to re-measure, narrowed to what
;; it actually measures: the active page, never the edited shape.
text-reflow-ids
(when (= page-id (get state :current-page-id))
(let [edition (dm/get-in state [:workspace-local :edition])]
(->> reflow-changes
(into [] (comp (filter (fn [[shape changed]]
(and (async-text-reflow? state shape changed)
(some text-reflow-attr? changed))))
(map (comp :id first))
(remove #(= % edition))))
(not-empty))))
changes
(-> (pcb/empty-changes it page-id)
@ -218,6 +261,13 @@
(add-undo-group changes state)]
(rx/concat
;; Announces the texts still to be re-measured, so a reflow wait
;; covers the render that measures them. Goes before the commit,
;; which is what triggers that render.
(if text-reflow-ids
(rx/of (ptk/data-event :text/reflow {:ids text-reflow-ids :page-id page-id}))
(rx/empty))
(if (seq (:redo-changes changes))
(let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))]
(rx/of (dch/commit-changes changes)))
@ -269,6 +319,10 @@
(rx/concat
(rx/of (dwu/start-undo-transaction undo-id)
;; A new text has no geometry until the pipeline measures it,
;; so it raises the same signal an edit does.
(when (async-text-reflow? state shape nil)
(ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id}))
(dch/commit-changes changes)
(when-not no-update-layout?
(ptk/data-event :layout/update {:ids [(:parent-id shape)]}))
@ -327,6 +381,7 @@
fdata (dsh/lookup-file-data state file-id)
page (dsh/get-page fdata page-id)
objects (:objects page)
deleted-ids (into #{} (mapcat #(cfh/get-children-ids-with-self objects %)) ids)
undo-id (or (:undo-id options) (js/Symbol))
[all-parents changes]
@ -336,6 +391,7 @@
:undo-group (:undo-group options)
:undo-id undo-id}))]
(wrf/cancel-shapes! deleted-ids)
(rx/of (dwu/start-undo-transaction undo-id)
(dc/detach-comment-thread ids)
(dch/commit-changes changes)

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]
@ -37,6 +38,7 @@
[app.main.router :as rt]
[app.main.store :as st]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.api.fonts :as wasm.fonts]
[app.render-wasm.text-editor :as wasm.text-editor]
[app.util.text-editor :as ted]
[app.util.text.content :as tc]
@ -60,6 +62,87 @@
(declare v2-update-text-editor-styles)
(declare v2-sync-wasm-text-layout)
(defn- bridge-to-measurement
"Marks `ids` pending until the text pipeline marks its own work:
`:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing."
[ids]
(->> (rx/from ids)
;; Each id owns its bridge. Starting work for one text must not release
;; siblings that the renderer has not picked up yet.
(rx/mapcat
(fn [id]
(->> (wrf/pending-signal [id] #{:text-measure :text-resize})
(rx/ignore)
(wrf/with-pending :text-bridge [id]))))
(rx/ignore)))
(defn initialize-text-reflow
"Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait
covers the measurement that resizes them."
[]
(ptk/reify ::initialize-text-reflow
ptk/WatchEvent
(watch [_ _ stream]
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)]
(->> stream
(rx/filter (ptk/type? :text/reflow))
(rx/map deref)
(rx/mapcat (fn [{:keys [ids]}] (bridge-to-measurement ids)))
(rx/take-until stopper))))))
(defn finalize-text-reflow
[]
(ptk/data-event ::finalize-text-reflow))
(defn- resolve-text-ids
"Returns the text shapes an attribute change on `id` applies to: `id` itself
when it is a text, its text descendants when it is a group. These are the
shapes that get re-measured, and so the ones the reflow waits track."
[objects id]
(let [shape (get objects id)]
(cond
(cfh/text-shape? shape)
[id]
(cfh/group-shape? shape)
(into [] (filter #(cfh/text-shape? (get objects %))) (cfh/get-children-ids objects id))
:else
[])))
(defn- await-font-resize
"Marks `ids` as pending font work and dispatches their wasm resize once wasm
can measure with `font-id`, draining the marks afterwards. The fetch of that
font is started by the wasm shape sync of the content change these shapes
receive, so measuring before it lands would use the fallback font."
[stream font-id ids]
(if (empty? ids)
(rx/empty)
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
(->> wasm.fonts/font-stored-stream
(rx/filter #(= % font-id))
(rx/take 1)
(rx/take-until stopper)
(rx/observe-on :async)
(rx/mapcat (fn [_] (rx/from (mapv dwwt/resize-wasm-text ids))))
(wrf/with-pending :font ids)))))
(defn- await-html-font
"Keeps legacy DOM text pending while its new font is loading. The DOM
measurement also awaits this promise, so the font task bridges the state
update to the renderer commit without relying on a fixed settle delay."
[font-id font-variant-id ids]
(if (or (nil? font-id) (empty? ids))
(rx/empty)
(->> (rx/of ::load-font)
;; `ensure-loaded!` is intentionally invoked after `with-pending`
;; subscribes, so even an immediately settled promise cannot create a
;; gap before the task is visible to waiters.
(rx/mapcat (fn [_]
(rx/from (fonts/ensure-loaded! font-id font-variant-id))))
(rx/ignore)
(wrf/with-pending :font ids))))
;; -- Content helpers
(defn ensure-valid-text-content
@ -425,12 +508,20 @@
(update-text-range-attrs start end attrs)))
shape-ids (cond (cfh/text-shape? shape) [id]
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
text-ids (resolve-text-ids objects id)]
(rx/concat
(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))
(cond
(features/active-feature? state "render-wasm/v1")
(->> (rx/from text-ids)
(rx/map dwwt/resize-wasm-text-debounce))
(contains? attrs :font-id)
(await-html-font (:font-id attrs) (:font-variant-id attrs) text-ids)
:else
(rx/empty)))))))
(defn update-root-attrs
@ -664,17 +755,20 @@
(defn resize-text
[id new-width new-height]
(let [cur-event (js/Symbol)]
(let [cur-event (js/Symbol)
reflow-task (wrf/task :text-resize [id])]
(ptk/reify ::resize-text
ptk/UpdateEvent
(update [_ state]
(-> state
(update ::resize-text-debounce-props (fnil assoc {}) id [new-width new-height])
(update ::resize-text-reflow-tasks (fnil conj []) reflow-task)
(cond-> (nil? (::resize-text-debounce-event state))
(assoc ::resize-text-debounce-event cur-event))))
ptk/WatchEvent
(watch [_ state stream]
(wrf/start! reflow-task)
(if (= (::resize-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(rx/concat
@ -686,7 +780,12 @@
(rx/map #(commit-resize-text))
(rx/take-until stopper))
(rx/of (resize-text id new-width new-height)))
(rx/of #(dissoc % ::resize-text-debounce-props ::resize-text-debounce-event))))
(rx/of (fn [state]
(run! wrf/finish! (::resize-text-reflow-tasks state))
(dissoc state
::resize-text-debounce-props
::resize-text-reflow-tasks
::resize-text-debounce-event)))))
(rx/empty))))))
(defn save-font
@ -834,19 +933,14 @@
(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
ptk/WatchEvent
(watch [_ state stream]
(let [text-editor-instance (:workspace-editor state)]
(let [text-editor-instance (:workspace-editor state)
objects (dsh/lookup-page-objects state)
text-ids (resolve-text-ids objects id)]
(if (and (features/active-feature? state "text-editor/v2")
(some? text-editor-instance))
(rx/empty)
@ -870,7 +964,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?)]
@ -882,14 +976,24 @@
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
: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)))))))))
;; Resize (with delay for font-id changes). Only auto-height and
;; auto-width shapes have geometry to recompute.
(let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)]
(if (contains? attrs :font-id)
;; The geometry depends on the font, so wait until wasm has it.
(await-font-resize stream (:font-id attrs) auto-ids)
;; No font change: measurable right away.
(->> (rx/from auto-ids)
(rx/map dwwt/resize-wasm-text)))))
;; The legacy renderer re-measures these in the DOM on its own,
;; but font loading starts before that render commits.
(if (contains? attrs :font-id)
(await-html-font
(:font-id attrs)
(:font-variant-id attrs)
text-ids)
(rx/empty)))))))
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]
@ -83,12 +84,14 @@
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(if (and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
(rx/empty))))))
shape (get objects id)
resize-stream
(if (and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
(rx/empty))]
(wrf/with-pending :text-resize [id] resize-stream)))))
(defn resize-wasm-text-debounce-commit
([]
@ -141,17 +144,20 @@
([id]
(resize-wasm-text-debounce-inner id nil))
([id {:keys [undo-group undo-id]}]
(let [cur-event (js/Symbol)]
(let [cur-event (js/Symbol)
reflow-task (wrf/task :text-resize [id])]
(ptk/reify ::resize-wasm-text-debounce-inner
ptk/UpdateEvent
(update [_ state]
(-> state
(update ::resize-wasm-text-debounce-ids (fnil conj []) id)
(update ::resize-wasm-text-reflow-tasks (fnil conj []) reflow-task)
(cond-> (nil? (::resize-wasm-text-debounce-event state))
(assoc ::resize-wasm-text-debounce-event cur-event))))
ptk/WatchEvent
(watch [_ state stream]
(wrf/start! reflow-task)
(if (= (::resize-wasm-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(rx/concat
@ -168,9 +174,16 @@
(rx/of (with-meta
(resize-wasm-text-debounce-inner id)
{:undo-group undo-group :undo-id undo-id})))
(rx/of #(dissoc %
::resize-wasm-text-debounce-ids
::resize-wasm-text-debounce-event))))
;; Cleanup, reached both after the commit and when the stopper
;; cancels the debounce, so the batch always drains and stays
;; pending until the resize is applied. All exact tasks in the
;; batch are retained in state and finished by the cleanup.
(rx/of (fn [state]
(run! wrf/finish! (::resize-wasm-text-reflow-tasks state))
(dissoc state
::resize-wasm-text-debounce-ids
::resize-wasm-text-reflow-tasks
::resize-wasm-text-debounce-event)))))
(rx/empty)))))))
(defn resize-wasm-text-debounce
@ -190,18 +203,24 @@
(every?
(fn [font]
(let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))]
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))
(if fonts-loaded?
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
(cond-> {}
(some? undo-group) (assoc :undo-group undo-group)
(some? undo-id) (assoc :undo-id undo-id)))]
(rx/of (resize-wasm-text-debounce-inner id pass-opts)))
resize-wasm-stream
(if fonts-loaded?
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
(cond-> {}
(some? undo-group) (assoc :undo-group undo-group)
(some? undo-id) (assoc :undo-id undo-id)))]
(rx/of (resize-wasm-text-debounce-inner id pass-opts)))
;; Fonts not loaded; retry after 20 msecs
(->> (rx/of (resize-wasm-text-debounce id opts))
(rx/delay 20))))))))
;; Fonts not loaded; retry after 20 msecs
(->> (rx/of (resize-wasm-text-debounce id opts))
(rx/delay 20)))]
;; Holds the shape pending across the font-retry loop: the retried
;; event opens its task before this one drains, so the task set never
;; becomes empty in between.
(wrf/with-pending :text-resize [id] resize-wasm-stream))))))
(defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
@ -214,9 +233,12 @@
(rx/map resize-wasm-text-debounce))]
(if (::dwsh/update-shapes-buffer state)
;; If we're in the middle of a token propagation we wait until is finished to
;; recalculate the text sizes
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/take 1)
(rx/mapcat (constantly resize-stream)))
;; recalculate the text sizes. The shapes stay pending for that whole wait,
;; since the per-shape debounce only marks them once dispatched.
(wrf/with-pending
:text-resize ids
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/take 1)
(rx/mapcat (constantly resize-stream))))
resize-stream)))))

View File

@ -16,6 +16,7 @@
[app.common.types.modifiers :as ctm]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.texts :as dwt]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
@ -94,7 +95,10 @@
(not migrate))
(st/emit! (dwt/resize-text id width height)))))
(st/emit! (dwt/clean-text-modifier id))))))
(st/emit! (dwt/clean-text-modifier id))))
;; Swallowed so a text whose position data cannot be computed still
;; settles and still reports its measurement as finished.
(p/catch (fn [_] nil))))
(defn- update-text-modifier
[{:keys [grow-type id] :as shape} node]
@ -183,11 +187,17 @@
(mf/use-fn
(fn [shape node]
;; Unique to indentify the pending state
(let [uid (uuid/next)]
(swap! pending-update* assoc uid (:id shape))
(p/then
(update-text-shape shape node)
#(swap! pending-update* dissoc uid)))))]
(let [uid (uuid/next)
id (:id shape)]
(swap! pending-update* assoc uid id)
;; Callback refs run at the DOM commit boundary. Track the exact
;; measurement promise from that acknowledgement; any resize it
;; schedules owns the following task in the chain.
(wrf/run-pending!
:text-measure
[id]
#(-> (update-text-shape shape node)
(p/finally (fn [] (swap! pending-update* dissoc uid))))))))]
[:.text-changes-renderer
(for [{:keys [id] :as shape} changed-texts]

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]
@ -729,4 +730,14 @@
(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]
;; Always a promise, so a bad argument travels as a rejection.
(if (u/valid-timeout? timeout)
;; Resolves once every shape with reflow work in flight has settled.
(wrf/wait-for-layout-update timeout)
(js/Promise.
(fn [_ reject]
(u/reject-not-valid reject :waitForLayoutUpdate 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]
@ -1053,6 +1054,18 @@
:else
(st/emit! (dwsh/delete-shapes #{id}))))
:waitForLayoutUpdate
(fn [timeout]
;; Always a promise, so a bad argument travels as a rejection.
(if (u/valid-timeout? timeout)
;; Resolves once the reflow work of this shape's subtree has
;; settled: it can be marked on the shape or on its descendants.
(let [objects (u/locate-objects file-id page-id)]
(wrf/wait-for-layout-update (cfh/get-children-ids-with-self objects id) timeout))
(js/Promise.
(fn [_ reject]
(u/reject-not-valid reject :waitForLayoutUpdate timeout)))))
;; Plugin data
:getPluginData
(fn [key]

View File

@ -291,6 +291,14 @@
(throw-not-valid code value)
(display-not-valid code value)))
(defn valid-timeout?
"A plugin timeout argument: omitted, or a finite positive number of msecs."
[value]
(or (nil? value)
(and (number? value)
(pos? value)
(js/Number.isFinite value))))
(defn reject-not-valid
[reject code value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)]

View File

@ -18,16 +18,23 @@
[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
;; Custom fonts uploaded to the current team, keyed by id (`fonts` is taken by
;; the `app.main.fonts` alias).
(def ^:private custom-fonts
(l/derived :fonts st/state))
;; Emits the font-id of every font whose glyphs wasm can already shape and
;; measure with. The browser-side loading of `app.main.fonts` is a separate
;; signal: it only says the DOM can render the font.
(defonce font-stored-stream (rx/subject))
(def ^:private default-font-size 14)
(def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0)
@ -103,7 +110,7 @@
(= (str (:font-weight font)) (str font-weight))
(= (:font-style font) font-style)
font))
(seq @fonts))]
(seq @custom-fonts))]
(when matching-font
(:ttf-file-id matching-font)))
:builtin
@ -141,7 +148,6 @@
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)}))
(h/call wasm/internal-module "_store_font"
(aget font-id-buffer 0)
(aget font-id-buffer 1)
@ -151,6 +157,8 @@
(:style font-data)
emoji?
fallback?)
;; Reported after the store call: subscribers react by measuring text.
(rx/push! font-stored-stream (:font-id font-data))
true)))
;; Tracks fonts currently being fetched: {url -> fallback?}
@ -224,7 +232,9 @@
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)}))
;; Deferred so consumers, which subscribe after dispatching the sync
;; that lands here, are listening when an already-stored font reports.
(tm/schedule #(rx/push! font-stored-stream (:font-id font-data)))
(fetch-font font-data uri emoji? fallback?)))))
(defn serialize-font-style

View File

@ -0,0 +1,159 @@
;; 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 Sucursal en España SL
(ns frontend-tests.data.workspace-reflow-test
"Tests the reflow tasks the layout and text pipelines feed to
`app.main.data.workspace.reflow`, which is what plugin waits observe."
(:require
[app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.texts :as dwtxt]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(t/use-fixtures :each {:before wrf/reset-pending!
:after wrf/reset-pending!})
;; Starts the `:layout/update` pipeline on a throwaway store; its empty state
;; resolves every buffered update to no shapes and applies no modifiers.
(defn- start-pipeline!
[]
(doto (ptk/store {:state {} :on-error #(js/console.error %)})
(ptk/emit! (dwsl/initialize-shape-layout))))
(defn- stop-pipeline!
[store]
(ptk/emit! store (dwsl/finalize-shape-layout)))
(defn- start-text-pipeline!
[]
(doto (ptk/store {:state {} :on-error #(js/console.error %)})
(ptk/emit! (dwtxt/initialize-text-reflow))))
(defn- stop-text-pipeline!
[store]
(ptk/emit! store (dwtxt/finalize-text-reflow)))
(t/deftest root-only-layout-update-is-not-pending-work
;; The root lays out nothing, so an update with only its id holds no wait.
(t/async done
(let [store (start-pipeline!)]
(ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]}))
(-> (wrf/wait-for-layout-update nil 20)
(.then #(t/is true "resolved with no pending work"))
(.catch #(t/is false "a root-only update was marked as pending work"))
(.then (fn []
(stop-pipeline! store)
(done)))))))
(t/deftest stale-task-cannot-finish-current-work
;; Delayed callbacks from a finalized workspace carry the old generation and
;; must not drain a new workspace task for the same shape id.
(t/async done
(let [id (uuid/next)
stale-task (wrf/start! :text-measure [id])
_ (wrf/reset-pending!)
current-task (wrf/start! :text-measure [id])]
(wrf/finish! stale-task)
(-> (wrf/wait-for-layout-update [id] 20)
(.then #(t/is false "a stale completion drained current work"))
(.catch #(t/is true "current work stayed pending"))
(.then (fn []
(wrf/finish! current-task)
(wrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "the exact current task drained normally"))
(.catch #(t/is false "the current task did not drain"))
(.then (fn [] (done)))))))
(t/deftest pending-promise-finishes-at-the-operation-boundary
;; Imperative render work is pending from before its thunk starts until the
;; exact promise returned by that thunk settles; no timer is involved.
(t/async done
(let [id (uuid/next)
resolve* (atom nil)
started? (atom false)]
(wrf/run-pending!
:text-measure
[id]
(fn []
(reset! started? true)
(js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
(-> (wrf/wait-for-layout-update [id] 20)
(.then #(t/is false "resolved while the render operation was pending"))
(.catch #(t/is @started? "the task was opened before running the operation"))
(.then (fn []
(@resolve*)
(wrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "resolved as soon as the render operation settled"))
(.catch #(t/is false "the settled render operation stayed pending"))
(.then (fn [] (done)))))))
(t/deftest pending-promise-finishes-after-a-synchronous-error
(let [id (uuid/next)]
(try
(wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
(catch :default _))
(t/async done
(-> (wrf/wait-for-layout-update [id] 100)
(.then #(t/is true "a synchronous failure drained its exact task"))
(.catch #(t/is false "a synchronous failure leaked pending work"))
(.then (fn [] (done)))))))
(t/deftest deleted-shapes-cancel-only-their-work
(t/async done
(let [id-a (uuid/next)
id-b (uuid/next)
task-a (wrf/start! :text-bridge [id-a])
task-b (wrf/start! :text-bridge [id-b])]
(wrf/cancel-shapes! [id-a])
(-> (wrf/wait-for-layout-update [id-a] 100)
(.then #(t/is true "deleted shape work was cancelled"))
(.catch #(t/is false "deleted shape work stayed pending"))
(.then #(wrf/wait-for-layout-update [id-b] 20))
(.then #(t/is false "cancelling one shape drained its sibling"))
(.catch #(t/is true "sibling work stayed pending"))
(.then (fn []
(wrf/finish! task-a)
(wrf/finish! task-b)
(done)))))))
(t/deftest text-bridge-waits-for-each-shape
;; Starting measurement for one id must not release sibling bridge tasks.
(t/async done
(let [store (start-text-pipeline!)
id-a (uuid/next)
id-b (uuid/next)]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
(let [task-a (wrf/start! :text-measure [id-a])]
(wrf/finish! task-a))
(-> (wrf/wait-for-layout-update [id-b] 20)
(.then #(t/is false "the first text released its sibling bridge"))
(.catch #(t/is true "the sibling bridge stayed pending"))
(.then (fn []
(let [task-b (wrf/start! :text-measure [id-b])]
(wrf/finish! task-b))
(wrf/wait-for-layout-update [id-b] 100)))
(.then #(t/is true "the sibling drained after its own measurement"))
(.catch #(t/is false "the sibling never drained"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest layout-update-is-pending-until-the-buffer-flushes
;; A shape id is marked on arrival and drained when the update is processed.
(t/async done
(let [store (start-pipeline!)]
(ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]}))
(-> (wrf/wait-for-layout-update nil 20)
(.then #(t/is false "resolved while the update was still buffered"))
(.catch #(t/is true "stayed pending until the flush"))
(.then #(wrf/wait-for-layout-update nil 5000))
(.then #(t/is true "resolved once the update was processed"))
(.catch #(t/is false "the pipeline never drained its mark"))
(.then (fn []
(stop-pipeline! store)
(done)))))))

View File

@ -175,12 +175,26 @@
"Stores the original WASM function values so they can be restored."
(atom {}))
;; Nesting depth of setup/teardown pairs. Only the outermost setup saves the
;; originals and installs the mocks, and only the outermost teardown restores
;; them, so a `with-wasm-mocks*` inside a fixture that already installed the
;; mocks does not capture mocks as "originals" and leak them.
(def ^:private install-depth (atom 0))
(declare install-wasm-mocks!)
(defn setup-wasm-mocks!
"Install WASM mocks via `set!` that persist across async boundaries.
Also resets call counts. Call `teardown-wasm-mocks!` to restore."
Also resets call counts. Call `teardown-wasm-mocks!` to restore.
Nested calls are no-ops apart from resetting call counts."
[]
;; Reset call tracking
(reset-call-counts!)
(when (= 1 (swap! install-depth inc))
(install-wasm-mocks!)))
(defn- install-wasm-mocks!
[]
;; Save originals
(reset! originals
{:initialized? wasm.api/initialized?
@ -221,12 +235,16 @@
(set! wasm.fonts/make-font-data mock-make-font-data)
(set! wasm.fonts/get-content-fonts mock-get-content-fonts))
(declare restore-wasm-mocks!)
(defn teardown-wasm-mocks!
"Restore the original WASM functions saved by `setup-wasm-mocks!`.
No-op when there is nothing to restore (`originals` empty): restoring from an
empty snapshot would `set!` every WASM function to nil, breaking any later
code that calls them (e.g. a leaked debounced event firing during a
subsequent test namespace)."
Nested calls are no-ops; only the outermost teardown restores."
[]
(when (zero? (swap! install-depth #(max 0 (dec %))))
(restore-wasm-mocks!)))
(defn- restore-wasm-mocks!
[]
(when-let [orig (not-empty @originals)]
(set! wasm.api/initialized? (:initialized? orig))

View File

@ -9,9 +9,14 @@
[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.data.workspace.shapes :as dwsh]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.store :as st]
[app.plugins.api :as api]
[app.plugins.shape :as shape]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]
@ -404,3 +409,270 @@
(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 task map.
;; The tests drive that map directly with `start!` / `finish!` 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 (fn []
;; Keep the WASM mocks installed across the async test bodies:
;; debounced text-resize timers leaked by earlier test namespaces
;; can fire inside this namespace's async waits, and in Node they
;; would otherwise reach the real (unavailable) WASM API.
(thw/setup-wasm-mocks!)
(wrf/reset-pending!))
:after (fn []
(thw/teardown-wasm-mocks!)
(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/start! :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)
task-a (wrf/start! :layout [id-a])
task-b (wrf/start! :layout [id-b])
^js shape-a (shape/shape-proxy zero-id uuid/zero uuid/zero id-a)
resolved (atom false)]
(-> (.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/finish! task-b)
(js/setTimeout
(fn []
(t/is (false? @resolved) "shape A wait must stay pending while A is pending")
(wrf/finish! task-a)
(js/setTimeout
(fn []
(t/is (true? @resolved) "resolves once shape A drains")
(done))
20))
20))))
(t/deftest test-wait-for-layout-update-overlapping-tasks
;; Two overlapping operations on the same shape: finishing one task must not
;; resolve the wait while the second operation is still in flight.
(t/async done
(make-test-store)
(let [id (uuid/next)
task-a (wrf/start! :layout [id])
task-b (wrf/start! :layout [id])
^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero id)
resolved (atom false)]
(-> (.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/finish! task-a)
(js/setTimeout
(fn []
(t/is (false? @resolved) "must not resolve while a second op is in flight")
(wrf/finish! task-b)
(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/start! :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)))))))
(t/deftest test-wait-for-layout-update-subtree
;; A shape wait covers its whole subtree: reflow work is often marked on the
;; children rather than on the shape the caller holds (a group is re-measured
;; through its texts), so a pending child must block the parent.
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(let [^js ctx (api/create-context zero-id)
^js board (.createBoard ctx)
^js rect (.createRectangle ctx)]
(.appendChild board rect)
(let [child-id (obj/get rect "$id")
task (wrf/start! :layout [child-id])
resolved (atom false)]
(-> (.waitForLayoutUpdate board)
(.then (fn [] (reset! resolved true)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err)))))
(js/setTimeout
(fn []
(t/is (false? @resolved) "parent wait must block on a pending child")
(wrf/finish! task)
(js/setTimeout
(fn []
(t/is (true? @resolved) "resolves once the child drains")
(done))
20))
20))))))
(t/deftest test-wait-for-layout-update-invalid-timeout
;; A non-numeric or non-positive timeout is an invalid argument. The method
;; always hands back a promise and rejects it, whatever the plugin's
;; throwValidationErrors flag says.
(t/async done
(make-test-store)
(let [^js ctx (api/create-context zero-id)
^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero (uuid/next))
rejected? (fn [^js promise]
(t/is (instance? js/Promise promise) "must return a promise")
(-> promise
(.then (fn [] false))
(.catch (fn [] true))))]
(-> (js/Promise.all
#js [(rejected? (.waitForLayoutUpdate ctx "soon"))
(rejected? (.waitForLayoutUpdate ctx 0))
(rejected? (.waitForLayoutUpdate ctx -5))
(rejected? (.waitForLayoutUpdate ctx js/NaN))
(rejected? (.waitForLayoutUpdate ctx js/Infinity))
(rejected? (.waitForLayoutUpdate shape "soon"))])
(.then (fn [results]
(t/is (every? true? (array-seq results))
"every invalid timeout must reject")
(done)))))))
(t/deftest test-with-pending-marks-on-subscribe
;; `with-pending` marks on subscription, not while the observable is being
;; built, so an observable that is never subscribed leaves nothing pending.
(t/async done
(make-test-store)
(let [id (uuid/next)
;; Built but never subscribed.
_ (wrf/with-pending :layout [id] (rx/of :ignored))
^js ctx (api/create-context zero-id)]
(-> (.waitForLayoutUpdate ctx 50)
(.then (fn []
(t/is true "an unsubscribed with-pending must not mark anything")
(done)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err))
(done)))))))
(t/deftest test-with-pending-drains-on-terminate
;; Once subscribed, the shape is pending for the lifetime of the wrapped
;; observable and drains when it completes.
(t/async done
(make-test-store)
(let [id (uuid/next)
subject (rx/subject)
^js shape (shape/shape-proxy zero-id uuid/zero uuid/zero id)
resolved (atom false)]
(rx/sub! (wrf/with-pending :layout [id] subject) (fn [_] nil))
(-> (.waitForLayoutUpdate shape)
(.then (fn [] (reset! resolved true)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err)))))
(js/setTimeout
(fn []
(t/is (false? @resolved) "must stay pending while the wrapped stream is alive")
(rx/end! subject)
(js/setTimeout
(fn []
(t/is (true? @resolved) "resolves once the wrapped stream completes")
(done))
20))
20))))
(t/deftest test-resize-wasm-text-all-pending-across-buffer-commit
;; While a shape-update buffer is open (token propagation) `resize-wasm-text-all`
;; holds the resizes back until the commit lands, and the shapes stay pending
;; for that whole wait.
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))
id (uuid/next)
resolved (atom false)]
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
(-> (wrf/wait-for-layout-update [id] nil)
(.then (fn [] (reset! resolved true)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err)))))
(js/setTimeout
(fn []
(t/is (false? @resolved) "must stay pending while the buffer has not committed")
(ptk/emit! store (dwsh/update-shapes-buffer-commit))
;; Covers the commit plus the per-shape debounce the resizes go through.
(js/setTimeout
(fn []
(t/is (true? @resolved) "resolves once the commit releases the resizes")
(done))
300))
50))))

View File

@ -262,7 +262,7 @@
(fn [id theme]
(swap! captured conj {:id id :theme theme})
:update-token-theme)
st/emit! identity]
st/emit! mock/noop]
(.addSet theme set)
(.removeSet theme set)
(t/is (= [theme-id theme-id] (mapv :id @captured)))

View File

@ -18,6 +18,7 @@
[frontend-tests.data.workspace-interactions-test]
[frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test]
[frontend-tests.data.workspace-reflow-test]
[frontend-tests.data.workspace-shortcuts-test]
[frontend-tests.data.workspace-texts-test]
[frontend-tests.data.workspace-thumbnails-test]
@ -104,6 +105,7 @@
'frontend-tests.data.workspace-interactions-test
'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test
'frontend-tests.data.workspace-reflow-test
'frontend-tests.data.workspace-shortcuts-test
'frontend-tests.data.workspace-texts-test
'frontend-tests.data.workspace-thumbnails-test

View File

@ -3,6 +3,8 @@
### 🚀 Features
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
### 🩹 Fixes

View File

@ -32,6 +32,10 @@ From `plugins/`:
- Dev server: `pnpm run start:plugin:api-test-suite` (serves on port 4202).
- In Penpot: open the Plugin Manager (Ctrl+Alt+P) and install
`http://localhost:4202/manifest.json`.
- Alternatively, a one-shot `pnpm --filter plugin-api-test-suite run build` output
is served by the devenv alongside the other bundled plugins, at
`https://localhost:3449/plugins/plugin-api-test-suite/manifest.json` (no hot
reload of the UI there, but the **Reload** button still picks up rebuilt tests).
- **Hot-reloading tests:** after editing a `*.test.ts`, click **Reload** in the
plugin UI. It fetches the freshly built test bundle and swaps in your changes —
no need to close/reopen the plugin. (The dev server rebuilds the bundle on save.)

View File

@ -17,6 +17,7 @@ import type { CoverageReport, TestResult } from '../src/framework/types';
// Optional env: PENPOT_BASE_URL (default https://localhost:3449).
// Optional env: TEST_FILTER — run only tests whose group or name contains
// the given substring (case-insensitive).
// Optional env: RENDER_WASM — force the workspace renderer (`true`/`false`).
//
// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle via the e2e
// static server and intercepts every backend RPC with Playwright `page.route`,
@ -351,6 +352,12 @@ async function main() {
fileUrl = getFileUrl(file);
}
const renderWasm = process.env['RENDER_WASM'];
if (renderWasm === 'true' || renderWasm === 'false') {
const separator = fileUrl.includes('?') ? '&' : '?';
fileUrl += `${separator}wasm=${renderWasm}`;
}
const browser = await chromium.launch({
args: ['--ignore-certificate-errors'],
});

View File

@ -114,7 +114,8 @@
"uploadMediaData",
"uploadMediaUrl",
"version",
"viewport"
"viewport",
"waitForLayoutUpdate"
],
"ContextGeometryUtils": ["center"],
"ContextTypesUtils": [
@ -454,6 +455,7 @@
"switchVariant",
"tokens",
"visible",
"waitForLayoutUpdate",
"width",
"x",
"y"
@ -1213,6 +1215,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -1713,6 +1721,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -2312,6 +2326,12 @@
"kind": "method",
"type": "VariantContainer",
"array": false
},
"waitForLayoutUpdate": {
"decl": "Context",
"kind": "method",
"type": null,
"array": false
}
},
"ContextGeometryUtils": {
@ -2845,6 +2865,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -4087,6 +4113,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -4671,6 +4703,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -6299,6 +6337,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -6620,6 +6664,12 @@
"kind": "method",
"type": "VariantContainer",
"array": false
},
"waitForLayoutUpdate": {
"decl": "Context",
"kind": "method",
"type": null,
"array": false
}
},
"PluginData": {
@ -7129,6 +7179,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -7657,6 +7713,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -8221,6 +8283,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -8787,6 +8855,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",
@ -11085,6 +11159,12 @@
"type": null,
"array": false
},
"waitForLayoutUpdate": {
"decl": "ShapeBase",
"kind": "method",
"type": null,
"array": false
},
"getPluginData": {
"decl": "PluginData",
"kind": "method",

View File

@ -0,0 +1,613 @@
import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, Font, Group, Shape, Text } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// waitForLayoutUpdate (context-level and per-shape).
// The promise must resolve only once the pending reflow work has been applied,
// so every test asserts the *observable geometry* after awaiting: if the
// promise settles too early the read-back is stale and the test goes red.
// Reflow work is scheduled by three pipelines: flex/grid layout updates
// (buffered ~100ms), wasm text resizes (debounced), and font-change
// re-measurement. The probes below cover each one, including cascaded reflows
// (a child resize that must also re-hug its ancestors) and waits racing the
// font-loading retry loop.
function flexBoard(ctx: TestContext): Board {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
return b;
}
/** Creates a horizontal flex board with two 50x50 rects and a 10px gap. */
function flexRow(ctx: TestContext): {
board: Board;
rects: [Shape, Shape];
} {
const b = flexBoard(ctx);
const flex = b.addFlexLayout();
flex.dir = 'row';
flex.columnGap = 10;
const r1 = ctx.penpot.createRectangle();
r1.resize(50, 50);
flex.appendChild(r1);
const r2 = ctx.penpot.createRectangle();
r2.resize(50, 50);
flex.appendChild(r2);
return { board: b, rects: [r1, r2] };
}
/** Orders the two rects of a flex row by their current x position. */
function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } {
const [a, b] = rects;
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
}
/** Font ids already handed out by `unloadedFont`. */
const claimedFonts = new Set<string>();
/**
* Picks an unclaimed font differing from the text's current one, so assigning
* it triggers a real (not-yet-loaded) fetch. A font stays cached for the whole
* run, so reusing one would drain the pending mark near-instantly.
*/
function unloadedFont(ctx: TestContext, t: Text): Font {
const all = ctx.penpot.fonts.all;
for (let i = all.length - 1; i >= 0; i--) {
const f = all[i];
if (f.fontId === t.fontId || f.variants.length === 0) continue;
if (claimedFonts.has(f.fontId)) continue;
claimedFonts.add(f.fontId);
return f;
}
throw new Error('no alternative font available');
}
/** Picks any font other than the text's current one; may already be loaded. */
function otherFont(ctx: TestContext, t: Text): Font {
const font = ctx.penpot.fonts.all.find(
(f) => f.fontId !== t.fontId && f.variants.length > 0,
);
if (!font) throw new Error('no alternative font available');
return font;
}
function autoWidthText(ctx: TestContext, value: string): Text {
const t = ctx.penpot.createText(value);
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
t.growType = 'auto-width';
t.fontSize = '36';
return t;
}
describe('WaitForLayoutUpdate', () => {
describe('Basics', () => {
test('context method returns a promise that resolves', async (ctx) => {
const p = ctx.penpot.waitForLayoutUpdate();
expect(typeof p.then).toBe('function');
const value = await p;
expect(value).toBeUndefined();
});
// For the next two, resolving (not rejecting, not hitting the runner
// timeout) is itself the behaviour under test, so they have no expects.
test('a timeout wait resolves via the fast path when nothing is pending', async (ctx) => {
// On an already drained pending map the settle signal replays
// synchronously, so it beats the deadline and even a 1ms timeout
// resolves. Draining is what has to be retried: a pipeline can schedule
// fresh work in the instant between the drain and the timed call, and
// that is a pending map, not a broken fast path.
let resolved = false;
for (let i = 0; i < 5 && !resolved; i++) {
await ctx.penpot.waitForLayoutUpdate();
resolved = await ctx.penpot
.waitForLayoutUpdate(1)
.then(() => true)
.catch(() => false);
}
expect(resolved).toBe(true);
});
test('shape method resolves for a shape with no pending work', async (ctx) => {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
await r.waitForLayoutUpdate();
});
test('consecutive waits each track their own pending work', async (ctx) => {
// Each call opens a fresh subscription to the pending map, so a wait must
// settle on the work scheduled right before it and never carry state from
// an earlier, already-resolved wait. Two successive resizes to different
// sizes prove it: the second wait has to reflect the second reflow.
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
left.resize(70, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(80, 0);
});
});
describe('Flex reflow', () => {
test('sibling is repositioned after awaiting a child resize', async (ctx) => {
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('board-level wait settles the reflow of its layout', async (ctx) => {
const { board: b, rects } = flexRow(ctx);
await b.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
// The layout pipeline marks the laid-out board (the parent) as pending,
// so the board proxy is the shape-level handle for this reflow.
await b.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('back-to-back resizes settle to the last value', async (ctx) => {
// Overlapping operations on the same shapes: the wait must not resolve
// after the first one drains while the second is still in flight.
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(80, 50);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('grid layout reflow settles child positions', async (ctx) => {
const b = flexBoard(ctx);
const grid = b.addGridLayout();
grid.addRow('fixed', 60);
grid.addColumn('fixed', 60);
grid.addColumn('fixed', 60);
const r1 = ctx.penpot.createRectangle();
r1.resize(50, 50);
grid.appendChild(r1, 1, 1);
const r2 = ctx.penpot.createRectangle();
r2.resize(50, 50);
grid.appendChild(r2, 1, 2);
await ctx.penpot.waitForLayoutUpdate();
// Cells are 60px wide, so the second child sits one track further right.
expect(Math.abs(r2.x - r1.x)).toBeCloseTo(60, 0);
});
});
describe('Hug sizing', () => {
test('hug board fits its content after awaiting the initial layout', async (ctx) => {
const { board: b } = flexRow(ctx);
const flex = b.flex;
if (!flex) throw new Error('expected a flex layout on the board');
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
await ctx.penpot.waitForLayoutUpdate();
// 50 + 10 gap + 50, no padding.
expect(b.width).toBeCloseTo(110, 0);
});
test('hug board grows after awaiting a child resize', async (ctx) => {
const { board: b, rects } = flexRow(ctx);
const flex = b.flex;
if (!flex) throw new Error('expected a flex layout on the board');
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
await ctx.penpot.waitForLayoutUpdate();
const { left } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(b.width).toBeCloseTo(180, 0);
});
test('nested hug boards both grow after awaiting a child resize', async (ctx) => {
// Cascade probe: the child resize reflows the inner board, whose new
// size must then reflow the outer board. The wait has to cover the whole
// chain, not just the first reflow round.
const outer = flexBoard(ctx);
const outerFlex = outer.addFlexLayout();
outerFlex.dir = 'row';
outerFlex.horizontalSizing = 'auto';
outerFlex.verticalSizing = 'auto';
const inner = ctx.penpot.createBoard();
outerFlex.appendChild(inner);
const innerFlex = inner.addFlexLayout();
innerFlex.dir = 'row';
innerFlex.horizontalSizing = 'auto';
innerFlex.verticalSizing = 'auto';
const rect = ctx.penpot.createRectangle();
rect.resize(50, 50);
innerFlex.appendChild(rect);
await ctx.penpot.waitForLayoutUpdate();
expect(inner.width).toBeCloseTo(50, 0);
expect(outer.width).toBeCloseTo(50, 0);
rect.resize(200, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(inner.width).toBeCloseTo(200, 0);
expect(outer.width).toBeCloseTo(200, 0);
});
test.skipIfMocked(
'hug board hugs an auto-width text after a characters change',
async (ctx) => {
// Cascade probe across pipelines: the text resize is scheduled by the
// text pipeline and only then does the board reflow to hug it. Skipped
// under a mocked backend: hugging a text depends on real glyph
// measurement (no fonts are served), so the text width never grows.
const b = flexBoard(ctx);
const flex = b.addFlexLayout();
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
const t = ctx.penpot.createText('hi');
if (!t) throw new Error('createText returned null');
flex.appendChild(t);
t.growType = 'auto-width';
await ctx.penpot.waitForLayoutUpdate();
const w0 = b.width;
t.characters = 'a much much much longer text content than before';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
expect(b.width).toBeCloseTo(t.width, 0);
},
);
});
// Skipped under a mocked backend: every case here asserts that a text is
// re-measured (width changes) after a mutation, which depends on real glyph
// measurement against fonts the backend serves. Mocked mode serves no fonts,
// so the measured width stays put and the assertions can't hold.
describe.skipIfMocked('Text', () => {
test('auto-width text grows after awaiting a fontSize bump', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
t.fontSize = '72';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('auto-width text grows after awaiting a characters change', async (ctx) => {
const t = autoWidthText(ctx, 'short');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
t.characters = 'short plus a considerably longer tail 0123456789';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('shape-level wait covers the text own resize', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await t.waitForLayoutUpdate();
const w0 = t.width;
t.fontSize = '72';
await t.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('auto-width text is re-measured after awaiting a font change', async (ctx) => {
// Probe for the font-loading window: right after a font change the new
// font is typically not loaded yet, and the wait must cover the load +
// re-measure, not resolve while the loader is still retrying.
const t = autoWidthText(
ctx,
'The quick brown fox jumps over the lazy dog 0123456789',
);
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const font = otherFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await ctx.penpot.waitForLayoutUpdate();
// A different family practically never yields the same measured width
// for a 54-char string at 36px.
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
// The wait covers the *final* geometry: a second wait must find the
// text where the first one left it.
const settled = t.width;
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeCloseTo(settled, 0);
});
test('range font change is re-measured after awaiting', async (ctx) => {
// Same probe through the text-range pipeline instead of the shape-level
// font setter.
const t = autoWidthText(
ctx,
'The quick brown fox jumps over the lazy dog 0123456789',
);
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const font = otherFont(ctx, t);
const range = t.getRange(0, t.characters.length);
range.fontFamily = font.fontFamily;
range.fontId = font.fontId;
await ctx.penpot.waitForLayoutUpdate();
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
});
});
// A font applied to a group reaches its text descendants, so the work is
// pending on the children and never on the group itself. Skipped under a
// mocked backend for the same reason as the Text group: no fonts are served,
// so nothing is ever re-measured.
describe.skipIfMocked('Groups', () => {
/** Groups two auto-width texts and returns the group with its children. */
function textGroup(ctx: TestContext): {
group: Group;
texts: [Text, Text];
} {
const a = autoWidthText(ctx, 'The quick brown fox');
const b = autoWidthText(ctx, 'jumps over the lazy dog');
const group = ctx.penpot.group([a, b]);
if (!group) throw new Error('group returned null');
return { group, texts: [a, b] };
}
/** Groups an auto-width text with a fixed-size one. */
function mixedGrowTypeGroup(ctx: TestContext): {
group: Group;
auto: Text;
fixed: Text;
} {
const auto = autoWidthText(ctx, 'The quick brown fox');
const fixed = autoWidthText(ctx, 'jumps over the lazy dog');
fixed.resize(400, 80);
fixed.growType = 'fixed';
const group = ctx.penpot.group([auto, fixed]);
if (!group) throw new Error('group returned null');
return { group, auto, fixed };
}
/**
* The typed signature of `applyToText` takes a `Text`, but the setter also
* accepts a container and forwards the change to its text descendants, as
* it does for a group selected in the UI.
*/
function applyFontToGroup(font: Font, group: Group): void {
font.applyToText(group as unknown as Text);
}
test('context wait covers a font applied to a group', async (ctx) => {
const { group, texts } = textGroup(ctx);
await ctx.penpot.waitForLayoutUpdate();
const [w0, w1] = [texts[0].width, texts[1].width];
const font = otherFont(ctx, texts[0]);
applyFontToGroup(font, group);
const started = Date.now();
await ctx.penpot.waitForLayoutUpdate();
expect(Math.abs(texts[0].width - w0)).toBeGreaterThan(0.5);
expect(Math.abs(texts[1].width - w1)).toBeGreaterThan(0.5);
// The wait tracks the text children. Tracking the group id would only
// unblock on the internal stuck-timeout, orders of magnitude slower.
expect(Date.now() - started).toBeLessThan(5000);
});
test('group wait covers the reflow of its text children', async (ctx) => {
const { group, texts } = textGroup(ctx);
await group.waitForLayoutUpdate();
const w0 = texts[0].width;
const font = otherFont(ctx, texts[0]);
applyFontToGroup(font, group);
const started = Date.now();
await group.waitForLayoutUpdate();
expect(Math.abs(texts[0].width - w0)).toBeGreaterThan(0.5);
expect(Date.now() - started).toBeLessThan(5000);
});
test('a font on a mixed group re-measures only the auto-width child', async (ctx) => {
// A group can hold both grow types, and only the auto ones have geometry
// to recompute. A fixed text keeps its box whatever the new glyphs
// measure, so the wait settles on the auto child alone.
const { group, auto, fixed } = mixedGrowTypeGroup(ctx);
await ctx.penpot.waitForLayoutUpdate();
const [autoW0, fixedW0] = [auto.width, fixed.width];
const font = otherFont(ctx, auto);
applyFontToGroup(font, group);
const started = Date.now();
await ctx.penpot.waitForLayoutUpdate();
expect(Math.abs(auto.width - autoW0)).toBeGreaterThan(0.5);
expect(fixed.width).toBeCloseTo(fixedW0, 0);
expect(Date.now() - started).toBeLessThan(5000);
});
test('board wait covers a pending descendant', async (ctx) => {
// The shape-level wait spans the subtree, so a board settles only once
// the text nested inside it has been re-measured too.
const b = flexBoard(ctx);
const t = ctx.penpot.createText('The quick brown fox');
if (!t) throw new Error('createText returned null');
b.appendChild(t);
t.growType = 'auto-width';
t.fontSize = '36';
await b.waitForLayoutUpdate();
const w0 = t.width;
const font = otherFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await b.waitForLayoutUpdate();
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
});
});
describe('Arguments', () => {
test('rejects a non-numeric timeout', async (ctx) => {
await expectReject(() =>
ctx.penpot.waitForLayoutUpdate('soon' as unknown as number),
);
});
test('rejects a non-positive timeout', async (ctx) => {
// 0 would mean "reject immediately"; omitting the argument is how a
// caller asks for the default.
await expectReject(() => ctx.penpot.waitForLayoutUpdate(0));
await expectReject(() => ctx.penpot.waitForLayoutUpdate(-1));
});
test('shape method rejects an invalid timeout too', async (ctx) => {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
await expectReject(() =>
r.waitForLayoutUpdate('soon' as unknown as number),
);
});
test('an invalid timeout comes back as a rejected promise', async (ctx) => {
// `expectReject` accepts a synchronous throw as readily as a rejection,
// so a thunk cannot tell the two apart. The signature is `Promise<void>`:
// the rejection has to travel on a promise, so call it bare and check
// what came back before awaiting it.
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
const fromContext = ctx.penpot.waitForLayoutUpdate(-1);
expect(fromContext instanceof Promise).toBe(true);
await expectReject(fromContext);
const fromShape = r.waitForLayoutUpdate(0);
expect(fromShape instanceof Promise).toBe(true);
await expectReject(fromShape);
});
});
describe('Timeout', () => {
// Pure geometry mutations (resize & co.) are settled synchronously by the
// renderer, so right after them nothing is pending and a short-timeout
// wait legitimately resolves. The deterministic pending source is a font
// change: it marks the text pending until the font is fetched and the
// shape re-measured, which can never complete within 1ms. These tests hit
// the real backend for the font asset, hence skipIfMocked.
test.skipIfMocked(
'rejects with a timeout error while a font is loading',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
// A fresh font change leaves work pending, so a 1ms deadline loses and
// the wait rejects. Retried because that work can already have drained
// by the time the deadline is set.
let rejected = false;
for (let i = 0; i < 3 && !rejected; i++) {
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
rejected = await ctx.penpot
.waitForLayoutUpdate(1)
.then(() => false)
.catch((e: Error) => /timeout/i.test(e.message));
}
expect(rejected).toBe(true);
},
);
test.skipIfMocked(
'shape-level wait also rejects on timeout',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await expectReject(t.waitForLayoutUpdate(1), /timeout/i);
},
);
test('resolves normally when work settles before the timeout', async (ctx) => {
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate(5000);
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test.skipIfMocked(
'a rejected wait leaves later waits working',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const loaded = { fontId: t.fontId, fontFamily: t.fontFamily };
// A fresh font change leaves work pending, so a 1ms deadline loses and
// the wait rejects. Retried because that work can already have drained
// by the time the deadline is set.
let rejected = false;
for (let i = 0; i < 3 && !rejected; i++) {
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
rejected = await ctx.penpot
.waitForLayoutUpdate(1)
.then(() => false)
.catch((e: Error) => /timeout/i.test(e.message));
}
expect(rejected).toBe(true);
// A later wait still tracks work and still reports settled geometry.
// Measured on the loaded font the text started with: one inside its
// block period renders no box, so that text can never grow.
t.fontId = loaded.fontId;
t.fontFamily = loaded.fontFamily;
await ctx.penpot.waitForLayoutUpdate(8000);
t.characters =
'The quick brown fox jumps over the lazy dog 0123456789 and then some';
await ctx.penpot.waitForLayoutUpdate(8000);
expect(t.width).toBeGreaterThan(w0);
},
);
test.skipIfMocked(
'per-shape wait is not blocked by another shape pending work',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
const bystander = ctx.penpot.createRectangle();
ctx.board.appendChild(bystander);
await ctx.penpot.waitForLayoutUpdate();
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
// The text has pending font work but the bystander does not; its wait
// must resolve on the fast path instead of waiting for the font.
const started = Date.now();
await bystander.waitForLayoutUpdate();
expect(Date.now() - started).toBeLessThan(90);
},
);
});
});

View File

@ -3,6 +3,10 @@ import { defineConfig } from 'vite';
export default defineConfig({
root: __dirname,
// Emit relative asset URLs in index.html so the built plugin works when served
// from a subdirectory (Penpot serves the bundled plugins under `/plugins/...`).
// Vite resolves `./` to `/` for the dev server, so `pnpm run dev` is unaffected.
base: './',
server: {
port: 4202,
host: '0.0.0.0',

View File

@ -1350,6 +1350,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. Defaults to
* 30000; the promise never waits indefinitely.
* @return The promise to be resolved when the layout is updated
*/
waitForLayoutUpdate(timeout?: number): Promise<void>;
}
/**
@ -4096,6 +4107,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 and its children have finished. If no layout
* work is pending for them 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.
* Defaults to 30000; the promise never 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 {