From cb21f6401a54403cf2c8f07e874271e2a8836bfd Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 28 Jul 2026 14:44:30 +0200 Subject: [PATCH] :sparkles: Add waitForLayoutUpdate plugin method (#9898) * :sparkles: Add waitForLayoutUpdate plugin method * :sparkles: Refactor internal wait for tasks --- frontend/src/app/main/data/workspace.cljs | 3 + .../app/main/data/workspace/modifiers.cljs | 2 +- .../src/app/main/data/workspace/reflow.cljs | 194 ++++++ .../app/main/data/workspace/shape_layout.cljs | 83 ++- .../src/app/main/data/workspace/shapes.cljs | 72 +- .../src/app/main/data/workspace/texts.cljs | 148 ++++- .../app/main/data/workspace/wasm_text.cljs | 72 +- .../shapes/text/viewport_texts_html.cljs | 22 +- frontend/src/app/plugins/api.cljs | 13 +- frontend/src/app/plugins/shape.cljs | 13 + frontend/src/app/plugins/utils.cljs | 8 + frontend/src/app/render_wasm/api/fonts.cljs | 22 +- .../data/workspace_reflow_test.cljs | 159 +++++ .../test/frontend_tests/helpers/wasm.cljs | 28 +- .../plugins/context_shapes_test.cljs | 272 ++++++++ .../frontend_tests/plugins/tokens_test.cljs | 2 +- frontend/test/frontend_tests/runner.cljs | 2 + plugins/CHANGELOG.md | 2 + plugins/apps/plugin-api-test-suite/README.md | 4 + .../apps/plugin-api-test-suite/ci/run-ci.ts | 7 + .../src/generated/api-surface.json | 82 ++- .../src/tests/wait-layout.test.ts | 613 ++++++++++++++++++ .../apps/plugin-api-test-suite/vite.config.ts | 4 + plugins/libs/plugin-types/index.d.ts | 22 + .../libs/plugins-runtime/src/lib/api/index.ts | 5 + 25 files changed, 1752 insertions(+), 102 deletions(-) create mode 100644 frontend/src/app/main/data/workspace/reflow.cljs create mode 100644 frontend/test/frontend_tests/data/workspace_reflow_test.cljs create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index fc2404f39b..689ce07ecd 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -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) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 91268a8550..7886881f5b 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -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? diff --git a/frontend/src/app/main/data/workspace/reflow.cljs b/frontend/src/app/main/data/workspace/reflow.cljs new file mode 100644 index 0000000000..3932aa7ca7 --- /dev/null +++ b/frontend/src/app/main/data/workspace/reflow.cljs @@ -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))))))) diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index 0e84539988..fade07bd2d 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -26,6 +26,7 @@ [app.main.data.workspace.colors :as cl] [app.main.data.workspace.grid-layout.editor :as dwge] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dwse] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] @@ -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 [] diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index ac3ccae82b..0b3cfb3f94 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -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) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 2a96cee2d2..c3a907dde1 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -27,6 +27,7 @@ [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.transforms :as dwt] @@ -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 _] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index fc97dba96e..04b81381bd 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -17,6 +17,7 @@ [app.common.types.modifiers :as ctm] [app.main.data.helpers :as dsh] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.render-wasm.api :as wasm.api] @@ -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))))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index ae6f49f688..77d659c17b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -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] diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index fc7d67968a..bea36ad027 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -28,6 +28,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.pages :as dwpg] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] @@ -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))))))) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 4a1ea9bdf9..8375c561e5 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -42,6 +42,7 @@ [app.main.data.workspace.guides :as dwgu] [app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] @@ -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] diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 49622d9710..21afd5cdde 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -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)] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 5937ebc014..d0b6c39f0f 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -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 diff --git a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs new file mode 100644 index 0000000000..a1a50bf985 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs @@ -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))))))) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index aa632ed52d..1b05e203ac 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -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)) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index 0bba39cd40..c417b43dab 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -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)))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index f064f234e0..de94a08f21 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -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))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 9cccfcca16..e26c36ade9 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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 diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index eaae151372..9cd186baf0 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -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 diff --git a/plugins/apps/plugin-api-test-suite/README.md b/plugins/apps/plugin-api-test-suite/README.md index c9b0b8cdf5..4de9a3adfb 100644 --- a/plugins/apps/plugin-api-test-suite/README.md +++ b/plugins/apps/plugin-api-test-suite/README.md @@ -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.) diff --git a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts index 41a544d4f3..37f865ac0d 100644 --- a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts +++ b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts @@ -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'], }); diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index 0cbb2f8e1d..5116a2b1ec 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -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", diff --git a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts new file mode 100644 index 0000000000..79c0fb330d --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts @@ -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(); + +/** + * 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`: + // 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); + }, + ); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/vite.config.ts b/plugins/apps/plugin-api-test-suite/vite.config.ts index cc031bca49..25c811f49e 100644 --- a/plugins/apps/plugin-api-test-suite/vite.config.ts +++ b/plugins/apps/plugin-api-test-suite/vite.config.ts @@ -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', diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index cb53532dd6..483cfecca8 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -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; } /** @@ -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; } /** diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 6faa7b1925..8956a5124e 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -388,6 +388,11 @@ export function createApi( checkPermission('content:write'); return plugin.context.createVariantFromComponents(shapes); }, + + waitForLayoutUpdate(timeout?: number): Promise { + checkPermission('content:read'); + return plugin.context.waitForLayoutUpdate(timeout); + }, }; return {