penpot/frontend/src/app/plugins/reflow.cljs
Alonso Torres 4ac14cfd08
Add component synchronization to waitForLayoutUpdate (#10964)
*  Add component synchronization to waitForLayoutUpdate

* 🐛 Fix async mock leak in workspace-reflow-test

Use mock/with-mocks instead of with-redefs for http/send! mock in
failed-google-font-css-does-not-abort-shared-consumers test.

with-redefs restores bindings when the block exits synchronously,
but the RxJS subscription fires asynchronously. This caused the mock
to leak into subsequent tests (workspace-media-test), producing 3
spurious failures.

AI-assisted-by: mimo-v2.5-pro

* ♻️ Replace async with-redefs with mock/with-mocks in frontend tests

with-redefs restores bindings when the block exits synchronously,
which is too early for async code (t/async, rx/subs!, promises).
mock/with-mocks uses set! and restores in the done callback, keeping
mocks alive across async boundaries.

Converted 15 with-redefs usages across 4 test files:
- workspace_reflow_test.cljs: 2 genuinely async tests (P1)
- routes_test.cljs: 3 SSO caching tests (P2)
- main_errors_test.cljs: 8 expired-org SSO tests (P2)
- comments_test.cljs: 2 comment thread tests (P2)

36 purely sync with-redefs usages left unchanged — with-redefs
is correct for synchronous code.

AI-assisted-by: mimo-v2.5-pro

* 📎 Fix fmt issues

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-18 17:45:42 +02:00

78 lines
2.8 KiB
Clojure

;; 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.plugins.reflow
"Promise adapter for the plugin `waitForLayoutUpdate` methods. Owns the
argument validation, the default deadline and the rejection shape; the
workspace only reports when its pending work has drained."
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf]
[beicon.v2.core :as rx]))
;; 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)
;; Largest value a signed 32-bit timer accepts.
(def ^:private max-timeout 2147483647)
(defn- valid-timeout?
"Checks that a plugin timeout fits a signed 32-bit timer."
[value]
(or (nil? value)
(and (number? value)
(pos? value)
(<= value max-timeout)
(js/Number.isFinite value))))
(defn- reject-invalid!
[reject value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value
". Code: " :waitForLayoutUpdate)]
(.error js/console msg)
(reject (js/Error. msg))))
(defn shape-wait-ids
"Ids a per-shape wait covers: the shape subtree, its ancestors, and the file
its components sync from."
[objects file-id id]
(-> (into #{} (cfh/get-children-ids-with-self objects id))
(into (cfh/get-parent-ids objects id))
(conj file-id)
(disj uuid/zero)))
(defn wait-for-layout-update
"Returns a JS Promise that resolves once every id in `ids` has drained from
the workspace pending map. A nil `ids` waits for every pending id; an empty
one has nothing to wait for and resolves right away.
The promise is rejected when `timeout` (ms) is not a valid timer value, or
when it elapses first; a nil `timeout` uses `default-timeout`."
([timeout]
(wait-for-layout-update nil timeout))
([ids timeout]
(js/Promise.
(fn [resolve reject]
(if-not (valid-timeout? timeout)
(reject-invalid! reject timeout)
;; 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.
(->> (rx/race (->> (rx/of :timeout)
(rx/delay (or timeout default-timeout)))
(->> (wrf/settled ids)
(rx/map (constantly :ok))))
(rx/take 1)
(rx/subs!
(fn [value]
(if (= value :timeout)
(reject (js/Error. "waitForLayoutUpdate timeout"))
(resolve)))
reject)))))))