Cut React reconciliation cost during drag gestures

This commit is contained in:
Elena Torro 2026-04-30 12:28:02 +02:00
parent acb3997ed7
commit 95b21fcf5e
4 changed files with 104 additions and 29 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.refs :as refs]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.shape :as wasm.shape]
[beicon.v2.core :as rx]
@ -55,6 +56,52 @@
(when (compare-and-set! interactive-transform-active? true false)
(wasm.api/set-modifiers-end)))
;; ──────────────────────────────────────────────────────────────────────
;; Live drag-gesture state.
;;
;; `set-wasm-modifiers` runs on every drag pointermove and writes:
;; 1. WASM canvas state (synchronous, cheap)
;; 2. Live drag values (selrect + per-shape transforms) consumed by
;; viewport overlays, the sidebar, and on-canvas widgets.
;;
;; Step 2 lives in dedicated atoms (refs/workspace-selrect,
;; refs/workspace-wasm-modifiers) — NOT in Redux state. Atom writes are
;; coalesced to one per animation frame so React subscribers reconcile
;; at most once per frame regardless of pointermove arrival rate, and we
;; skip the ptk event-pipeline overhead for these high-frequency updates.
(defonce ^:private pending-temp-payload (atom nil))
(defonce ^:private pending-temp-frame-id (volatile! nil))
(defn- flush-pending-temp!
[]
(vreset! pending-temp-frame-id nil)
(when-some [payload @pending-temp-payload]
(reset! pending-temp-payload nil)
(reset! refs/workspace-selrect (:selrect payload))
(reset! refs/workspace-wasm-modifiers (:modifiers payload))))
(defn- schedule-temp-flush!
[selrect modifiers]
(reset! pending-temp-payload {:selrect selrect :modifiers modifiers})
(when (nil? @pending-temp-frame-id)
(vreset! pending-temp-frame-id
(js/requestAnimationFrame flush-pending-temp!))))
(defn- cancel-pending-temp-flush!
[]
(when-some [frame-id @pending-temp-frame-id]
(js/cancelAnimationFrame frame-id)
(vreset! pending-temp-frame-id nil))
(reset! pending-temp-payload nil))
(defn clear-temp-state!
"Reset the live drag-gesture state. Cancels any rAF-queued write so it
cannot resurrect the values after they are cleared."
[]
(cancel-pending-temp-flush!)
(reset! refs/workspace-selrect nil)
(reset! refs/workspace-wasm-modifiers nil))
(def ^:private transform-attrs
#{:selrect
:points
@ -296,6 +343,10 @@
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "render-wasm/v1")
;; Drop any rAF-queued temp dispatch and reset the live drag-gesture
;; state — its values are about to be replaced by the committed
;; update.
(clear-temp-state!)
;; End interactive transform mode BEFORE cleaning modifiers so
;; the final full-quality render triggered by subsequent shape
;; updates is not still classified as "interactive" (which would
@ -614,20 +665,6 @@
(filter (fn [[_ {:keys [type]}]]
(= type :change-property)))))
(defn set-temporary-selrect
[selrect]
(ptk/reify ::set-temporary-selrect
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-selrect selrect))))
(defn set-temporary-modifiers
[modifiers]
(ptk/reify ::set-temporary-modifiers
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-wasm-modifiers modifiers))))
(def ^:private xf:map-key (map key))
#_:clj-kondo/ignore
@ -666,8 +703,13 @@
(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)))))))))
;; Coalesce the Redux dispatch to one per animation frame so
;; React subscribers (sidebar, viewport overlays, flex
;; controls) reconcile at most once per frame regardless of
;; pointermove arrival rate. The WASM canvas update above
;; already happened synchronously.
(schedule-temp-flush! selrect modifiers)
(rx/empty))))))))
(defn propagate-structure-modifiers
[modif-tree objects]

View File

@ -135,11 +135,16 @@
(defn finish-transform []
(ptk/reify ::finish-transform
ptk/EffectEvent
(effect [_ _ _]
;; Live drag-gesture state lives in dedicated atoms (see
;; app.main.data.workspace.modifiers); reset them here so the
;; viewport overlays and sidebar drop the in-flight values.
(dwm/clear-temp-state!))
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?))))
;; -- Resize --------------------------------------------------------

View File

@ -160,8 +160,17 @@
"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))
(l/atom nil))
(def workspace-wasm-modifiers
(l/atom nil))
;; WARNING: Don't use directly from components, this is a proxy to
;; improve performance of selected-shapes and
@ -381,8 +390,9 @@
(def workspace-wasm-editor-styles
(l/derived :workspace-wasm-editor-styles st/state))
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))
;; `workspace-wasm-modifiers` is defined alongside `workspace-selrect`
;; near the top of this file as a plain atom (not derived from app
;; state). Keep it in mind when grepping.
(def ^:private workspace-modifiers-with-objects
(l/derived

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