From 85425e2ccdacb7221afef0adafed9bc25dad62dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 25 Mar 2026 17:28:24 +0100 Subject: [PATCH] :bug: Fix repeateable keys triggering an infinite React loop in text editor v2 --- .../src/app/main/data/workspace/texts.cljs | 38 ++++++++++++++++++- .../ui/workspace/shapes/text/v2_editor.cljs | 33 ++++++++++++---- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 1690398f8a..6b54b59d0e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -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] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 29e6ec6759..30f5dd100e 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -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 []