Coalesce live drag-preview state via RAF atoms

This commit is contained in:
Elena Torro 2026-05-04 17:47:48 +02:00
parent ca1e1dd806
commit 2d32946c06
5 changed files with 138 additions and 26 deletions

View File

@ -30,6 +30,7 @@
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.streams :as ms]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.shape :as wasm.shape]
[beicon.v2.core :as rx]
@ -614,19 +615,27 @@
(filter (fn [[_ {:keys [type]}]]
(= type :change-property)))))
;; The live preview state for a drag / resize / rotate gesture lives in
;; behavior-subjects in `app.main.streams` (`wasm-modifiers` and
;; `workspace-selrect`). These were previously the Redux keys
;; `:workspace-wasm-modifiers` / `:workspace-selrect`, but at one
;; React commit per pointermove they were the dominant per-event cost
;; during a drag. Reads now go through RAF-coalesced atoms in
;; `app.main.refs`, which update at most once per animation frame.
(defn set-temporary-selrect
[selrect]
(ptk/reify ::set-temporary-selrect
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-selrect selrect))))
ptk/EffectEvent
(effect [_ _ _]
(rx/push! ms/workspace-selrect selrect))))
(defn set-temporary-modifiers
[modifiers]
(ptk/reify ::set-temporary-modifiers
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-wasm-modifiers modifiers))))
ptk/EffectEvent
(effect [_ _ _]
(rx/push! ms/wasm-modifiers modifiers))))
(def ^:private xf:map-key (map key))
@ -658,15 +667,40 @@
objects (dsh/lookup-page-objects state)
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))]
;; --- DRAG PERF INSTRUMENTATION (remove after investigation) ---
;; Spans appear in DevTools Performance > Timings track as `swm:*`.
;; `props=N`/`geom=N` count tags are logged once per call so you can
;; correlate per-event work to subtree size.
(js/performance.mark "swm:total:start")
(js/console.timeStamp (str "swm props=" (count wasm-props)))
(js/performance.mark "swm:set-props:start")
(set-wasm-props! objects prev-wasm-props wasm-props)
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
(let [geometry-entries (parse-geometry-modifiers modif-tree)
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(wasm.api/set-modifiers modifiers)
(let [ids (into [] xf:map-key geometry-entries)
selrect (wasm.api/get-selection-rect ids)]
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers))))))))
(js/performance.measure "swm:set-props" "swm:set-props:start")
(js/performance.clearMarks "swm:set-props:start")
(let [structure-entries (parse-structure-modifiers modif-tree)]
(js/performance.mark "swm:struct-mods:start")
(wasm.api/set-structure-modifiers structure-entries)
(js/performance.measure "swm:struct-mods" "swm:struct-mods:start")
(js/performance.clearMarks "swm:struct-mods:start")
(let [geometry-entries (parse-geometry-modifiers modif-tree)
_ (js/console.timeStamp (str "swm geom=" (count geometry-entries)))
_ (js/performance.mark "swm:propagate:start")
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)
_ (js/performance.measure "swm:propagate" "swm:propagate:start")
_ (js/performance.clearMarks "swm:propagate:start")]
(js/performance.mark "swm:set-mods:start")
(wasm.api/set-modifiers modifiers)
(js/performance.measure "swm:set-mods" "swm:set-mods:start")
(js/performance.clearMarks "swm:set-mods:start")
(let [ids (into [] xf:map-key geometry-entries)
_ (js/performance.mark "swm:selrect:start")
selrect (wasm.api/get-selection-rect ids)
_ (js/performance.measure "swm:selrect" "swm:selrect:start")
_ (js/performance.clearMarks "swm:selrect:start")]
(js/performance.measure "swm:total" "swm:total:start")
(js/performance.clearMarks "swm:total:start")
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers)))))))))
(defn propagate-structure-modifiers
[modif-tree objects]

View File

@ -137,9 +137,15 @@
(ptk/reify ::finish-transform
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-local dissoc :transform :duplicate-move-started?)
(dissoc :workspace-selrect :workspace-wasm-modifiers)))))
(update state :workspace-local dissoc :transform :duplicate-move-started?))
ptk/EffectEvent
(effect [_ _ _]
;; Clear the live-preview streams so React overlays unmount when
;; the gesture ends. (These streams replaced the redux keys
;; `:workspace-wasm-modifiers` / `:workspace-selrect`.)
(rx/push! ms/wasm-modifiers nil)
(rx/push! ms/workspace-selrect nil))))
;; -- Resize --------------------------------------------------------

View File

@ -17,8 +17,41 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace.tokens.selected-set :as dwts]
[app.main.store :as st]
[app.main.streams :as ms]
[beicon.v2.core :as rx]
[okulary.core :as l]))
(defn- raf-coalesced-atom
"Returns a CLJS atom mirroring the latest value pushed to source-stream,
but updated at most once per animation frame for non-nil pushes.
Components that `mf/deref` the atom re-render aligned to RAF instead
of per push, which matters for streams that fire at pointer-event
rate (drag / resize / rotate live preview).
nil pushes apply synchronously and cancel any pending RAF: a
`finish-transform` clear must take effect in the same reducer pass
that commits the new shape state, otherwise the previous modifier
would double-apply to the already-committed shapes for one frame."
[source-stream]
(let [a (atom nil)
latest (volatile! nil)
raf-id (volatile! nil)
flush! (fn []
(vreset! raf-id nil)
(reset! a @latest))]
(rx/sub! source-stream
(fn [v]
(vreset! latest v)
(if (nil? v)
(do
(when-some [id @raf-id]
(js/cancelAnimationFrame id)
(vreset! raf-id nil))
(reset! a nil))
(when (nil? @raf-id)
(vreset! raf-id (js/requestAnimationFrame flush!))))))
a))
;; ---- Global refs
(def route
@ -160,8 +193,14 @@
"All tokens related ephimeral state"
(l/derived :workspace-tokens st/state))
;; Live drag-gesture state. These are intentionally NOT in the Redux
;; state tree — they are short-lived UI values updated on every gesture
;; tick (drag/resize/rotate) and consumed by viewport overlays, the
;; sidebar, and on-canvas widgets. Writing to atoms instead of dispatching
;; ptk events skips the event-pipeline overhead and keeps temp state out
;; of app state. See `app.main.data.workspace.modifiers` for the writers.
(def workspace-selrect
(l/derived :workspace-selrect st/state))
(raf-coalesced-atom ms/workspace-selrect))
;; WARNING: Don't use directly from components, this is a proxy to
;; improve performance of selected-shapes and
@ -382,7 +421,7 @@
(l/derived :workspace-wasm-editor-styles st/state))
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))
(raf-coalesced-atom ms/wasm-modifiers))
(def ^:private workspace-modifiers-with-objects
(l/derived

View File

@ -22,6 +22,21 @@
(or ^boolean (kbd/keyboard-event? event)
^boolean (mse/mouse-event? event)))
;; --- Live preview streams (drag / resize / rotate)
;;
;; Carry the in-flight modifier tree and selrect during an interactive
;; transform. Replaces the previous Redux keys
;; `:workspace-wasm-modifiers` and `:workspace-selrect`, which churned
;; React state on every pointermove. Writes happen from
;; `app.main.data.workspace.modifiers/set-temporary-*`; reads happen
;; via the RAF-coalesced atoms in `app.main.refs`.
(defonce wasm-modifiers
(rx/behavior-subject nil))
(defonce workspace-selrect
(rx/behavior-subject nil))
;; --- Derived streams
(defonce ^:private pointer

View File

@ -46,13 +46,10 @@
(mf/defc single-shape-options*
{::mf/private true}
[{:keys [shape page-id file-id libraries] :rest props}]
[{:keys [shape page-id file-id libraries wasm-modifiers modifiers] :rest props}]
(let [shape-type (dm/get-prop shape :type)
shape-id (dm/get-prop shape :id)
wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
modifiers (mf/deref refs/workspace-modifiers)
shape
(if (features/active-feature? @st/state "render-wasm/v1")
(let [wasm-modifiers (into {} wasm-modifiers)]
@ -72,17 +69,23 @@
:bool [:> bool/options* {:shape shape :file-id file-id :page-id page-id}]
nil)))
(mf/defc shape-options*
;; Throttled inner: re-renders at most every 100ms when its props change.
;; The parent (shape-options*) feeds wasm-modifiers / modifiers as props so
;; this throttle actually applies to drag-time modifier updates.
(mf/defc shape-options-throttled*
{::mf/wrap [#(mf/throttle % 100)]
::mf/private true}
[{:keys [shapes shapes-with-children selected page-id file-id libraries]}]
[{:keys [shapes shapes-with-children selected page-id file-id libraries
wasm-modifiers modifiers]}]
(if (= 1 (count selected))
[:> single-shape-options*
{:page-id page-id
:file-id file-id
:libraries libraries
:shape (first shapes)
:shapes-with-children shapes-with-children}]
:shapes-with-children shapes-with-children
:wasm-modifiers wasm-modifiers
:modifiers modifiers}]
[:> multiple/options*
{:shapes-with-children shapes-with-children
:shapes shapes
@ -90,6 +93,21 @@
:file-id file-id
:libraries libraries}]))
(mf/defc shape-options*
{::mf/private true}
[{:keys [shapes shapes-with-children selected page-id file-id libraries]}]
(let [wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
modifiers (mf/deref refs/workspace-modifiers)]
[:> shape-options-throttled*
{:shapes shapes
:shapes-with-children shapes-with-children
:selected selected
:page-id page-id
:file-id file-id
:libraries libraries
:wasm-modifiers wasm-modifiers
:modifiers modifiers}]))
(mf/defc specialized-panel*
{::mf/private true}
[{:keys [panel]}]