🐛 Fix repeateable keys triggering an infinite React loop in text editor v2

This commit is contained in:
Belén Albeza
2026-03-25 17:28:24 +01:00
parent 4e3dc6532a
commit 85425e2ccd
2 changed files with 61 additions and 10 deletions

View File

@@ -55,6 +55,7 @@
(declare v2-update-text-shape-content)
(declare v2-update-text-editor-styles)
(declare v2-sync-wasm-text-layout)
;; -- Content helpers
@@ -907,10 +908,43 @@
(ptk/reify ::v2-update-text-editor-styles
ptk/UpdateEvent
(update [_ state]
;; `stylechange` can fire on every `selectionchange` while typing.
;; Avoid swapping the global store when the computed styles are unchanged,
;; otherwise we can end up in store->rerender->selectionchange loops.
(let [merged-styles (merge (txt/get-default-text-attrs)
(get-in state [:workspace-global :default-font])
new-styles)]
(update-in state [:workspace-v2-editor-state id] (fnil merge {}) merged-styles)))))
new-styles)
prev (get-in state [:workspace-v2-editor-state id])]
(if (= merged-styles prev)
state
(assoc-in state [:workspace-v2-editor-state id] merged-styles))))))
(defn v2-sync-wasm-text-layout
"Live-sync WASM text layout from the DOM editor without writing shape :content.
Intended to be called from Text Editor v2 `needslayout` events (coalesced)."
[id content]
(ptk/reify ::v2-sync-wasm-text-layout
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(if-not (and (some? shape) (cfh/text-shape? shape))
(rx/empty)
(let [new-size (dwwt/get-wasm-text-new-size shape content)
modifiers (when (and (some? new-size)
(not= :fixed (:grow-type shape)))
(dwwt/resize-wasm-text-modifiers shape content))]
;; `get-wasm-text-new-size` has the side effect of syncing WASM's internal text
;; content/layout. Only non-fixed grow-types need geometry modifiers updates.
(if (some? modifiers)
(rx/of (dwm/set-wasm-modifiers modifiers))
(rx/empty))))))
ptk/EffectEvent
(effect [_ _ _]
;; While typing, v2 only commits shape :content on debounced `change`.
;; We still need to repaint the WASM canvas for live preview.
(wasm.api/request-render "text-editor-v2-needslayout"))))
(defn v2-update-text-shape-position-data
[shape-id position-data]

View File

@@ -33,6 +33,7 @@
[app.util.object :as obj]
[app.util.text.content :as content]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -48,6 +49,20 @@
result (.-textContent editor-root)]
(when (not= result "") result))))
(defn coalesce-per-tick
"Return a function that runs `f` at most once per JS tick.
Rationale: `needslayout` can fire on every input (including key repeat). We want
to break nested store update loops."
[f]
(let [scheduled?* (atom false)]
(fn []
(when-not @scheduled?*
(reset! scheduled?* true)
(ts/asap
(fn []
(reset! scheduled?* false)
(f)))))))
(defn- get-fonts
[content]
(let [extract-fn (juxt :font-id :font-variant-id)
@@ -136,14 +151,16 @@
(st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))
on-needs-layout
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? true
:save-undo? false)))
;; FIXME: We need to find a better way to trigger layout changes.
#_(st/emit!
(dwt/v2-update-text-shape-position-data shape-id [])))
(coalesce-per-tick
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
;; For WASM renderer, use the dedicated layout sync to avoid touching shape content
;; during `needslayout` bursts. For non-wasm, keep existing behavior.
(if (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwt/v2-sync-wasm-text-layout shape-id content))
(st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? true
:save-undo? false))))))
on-change
(fn []