🐛 Fix text editor crash when switching from svg to wasm renderer (#9926)

* 🐛 Fix crash when switching renderers with text editor open

* ♻️ Use new initialized? helper in wasm api
This commit is contained in:
Belén Albeza 2026-05-29 14:00:49 +02:00 committed by GitHub
parent b5108ca1ad
commit a5c8bcaf9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 129 additions and 64 deletions

View File

@ -35,6 +35,7 @@
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.router :as rt]
[app.main.store :as st]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as wasm.text-editor]
[app.util.text-editor :as ted]
@ -83,17 +84,23 @@
[]
(ptk/reify ::focus-editor
ptk/EffectEvent
(effect [_ state _]
(let [editor (:workspace-editor state)
element (when editor (.-element editor))]
(cond
;; V1 (DraftEditor)
(.-focus editor)
(ts/schedule #(.focus ^js editor))
(effect [_ _ _]
;; The focus is deferred, so we re-read the current editor at fire
;; time: the editor present now can be unmounted before the timeout
;; runs (e.g. switching renderer while editing a text), and focusing a
;; stale instance throws.
(ts/schedule
(fn []
(let [editor (:workspace-editor @st/state)
element (when editor (.-element editor))]
(cond
;; V1 (DraftEditor)
(and (some? editor) (.-focus editor))
(.focus ^js editor)
;; V2
(and element (.-focus element))
(ts/schedule #(.focus ^js element)))))))
;; V2
(and element (.-focus element))
(.focus ^js element))))))))
(defn gen-name
[editor]

View File

@ -33,7 +33,10 @@
(get-wasm-text-new-size shape (:content shape)))
([{:keys [id selrect grow-type] :as shape} content]
(when id
;; Skip when the WASM context is not ready (e.g. switching renderer while a
;; text shape is being edited): there is no design state to query, and
;; returning nil makes callers skip the WASM resize/modifier path.
(when (and id (wasm.api/initialized?))
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)

View File

@ -85,28 +85,34 @@
parents
(mf/deref parents-by-ids-ref)
;; Deref every editor-state ref unconditionally so the hook order
;; stays stable when feature flags change at runtime (e.g. switching
;; renderer while editing a text). The selection happens afterwards.
wasm-editor-styles-map (mf/deref refs/workspace-wasm-editor-styles)
v2-editor-state-map (mf/deref refs/workspace-v2-editor-state)
v1-editor-state-map (mf/deref refs/workspace-editor-state)
editor (mf/deref refs/workspace-editor)
text-editor-wasm? (features/active-feature? @st/state "text-editor-wasm/v1")
text-editor-v2? (features/active-feature? @st/state "text-editor/v2")
state-map
(cond
(features/active-feature? @st/state "text-editor-wasm/v1")
(mf/deref refs/workspace-wasm-editor-styles)
(features/active-feature? @st/state "text-editor/v2")
(mf/deref refs/workspace-v2-editor-state)
:else
(mf/deref refs/workspace-editor-state))
text-editor-wasm? wasm-editor-styles-map
text-editor-v2? v2-editor-state-map
:else v1-editor-state-map)
editor-styles
(when (features/active-feature? @st/state "text-editor-wasm/v1")
(when text-editor-wasm?
(get state-map id))
editor-state
(when (not (features/active-feature? @st/state "text-editor/v2"))
(when (not text-editor-v2?)
(get state-map id))
editor-instance
(when (features/active-feature? @st/state "text-editor/v2")
(mf/deref refs/workspace-editor))
(when text-editor-v2?
editor)
fill-values
(dwt/current-text-values

View File

@ -424,9 +424,9 @@
(js/clearTimeout timeout-id))
(wasm.api/clear-canvas)))))
(mf/with-effect [show-text-editor? workspace-editor-state edition]
(mf/with-effect [show-text-editor? workspace-editor-state edition @canvas-init? @initialized?]
(let [active-editor-state (get workspace-editor-state edition)]
(when (and show-text-editor? active-editor-state)
(when (and show-text-editor? active-editor-state @canvas-init? @initialized?)
(let [content (-> active-editor-state
(ted/get-editor-current-content)
(ted/export-content))]

View File

@ -85,6 +85,15 @@
(def ^:private snapshot-capture-debounce-ms 250)
(defn initialized?
"True when the WASM render context is ready to receive design-state
operations. Use it to skip WASM work during transient states (e.g. while
switching renderer with a text shape being edited)."
[]
(and wasm/context-initialized? (not @wasm/context-lost?)))
(defn set-transition-image-from-background!
"Sets `transition-image-url*` to a data URL representing a solid background color."
[background]
@ -161,8 +170,7 @@
(defonce ^:private schedule-canvas-snapshot-capture!
(fns/debounce
(fn []
(when (and wasm/context-initialized?
(not @wasm/context-lost?)
(when (and (initialized?)
(some? wasm/canvas))
(-> (webgl/capture-canvas-snapshot-url)
(p/catch (fn [_] nil)))))
@ -323,14 +331,13 @@
[]
;; check if the context has not been lost already or we will get warnings about
;; removing objects from a non-current context
(when (and wasm/context-initialized?
(not @wasm/context-lost?))
(when (initialized?)
(h/call wasm/internal-module "_free_gpu_resources")))
;; This should never be called from the outside.
(defn- render
[timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(when (initialized?)
(h/call wasm/internal-module "_render" timestamp)
;; Update text editor blink (so cursor toggles) using the same timestamp
@ -361,13 +368,13 @@
(defn render-sync
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(when (initialized?)
(h/call wasm/internal-module "_render_sync")
(set! wasm/internal-frame-id nil)))
(defn render-sync-shape
[id]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(when (initialized?)
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_render_sync_shape"
(aget buffer 0)
@ -380,7 +387,7 @@
"Render a lightweight preview without tile caching.
Used during progressive loading for fast feedback."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(when (initialized?)
(h/call wasm/internal-module "_render_preview")))
@ -394,7 +401,7 @@
(defn request-render
[_requester]
(when (and wasm/context-initialized? (not @wasm/context-lost?) (not @wasm/disable-request-render?))
(when (and (initialized?) (not @wasm/disable-request-render?))
(if @shapes-loading?
(register-deferred-render!)
(when-not @pending-render
@ -431,7 +438,7 @@
(defn use-shape
[id]
(when wasm/context-initialized?
(when (initialized?)
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_use_shape"
(aget buffer 0)
@ -441,7 +448,7 @@
(defn has-shape
[id]
(when wasm/context-initialized?
(when (initialized?)
(let [buffer (uuid/get-u32 id)
result
@ -459,17 +466,20 @@
;; Cache content for text editor sync
(text-editor/cache-shape-text-content! shape-id content)
(h/call wasm/internal-module "_clear_shape_text")
;; The WASM design state may not be ready (e.g. while switching renderer
;; with a text shape being edited). Skip the WASM layout calls in that case.
(when (initialized?)
(h/call wasm/internal-module "_clear_shape_text")
(set-shape-vertical-align (get content :vertical-align))
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result)))
(defn apply-styles-to-selection
"Apply style attrs to the currently selected text spans.
@ -1130,21 +1140,23 @@
(use-shape id)
(get-text-dimensions))
([]
(let [offset (-> (h/call wasm/internal-module "_get_text_dimensions")
(mem/->offset-32))
heapf32 (mem/get-heap-f32)
width (aget heapf32 (+ offset 0))
height (aget heapf32 (+ offset 1))
max-width (aget heapf32 (+ offset 2))
(if-not (initialized?)
{:x 0 :y 0 :width 0 :height 0 :max-width 0}
(let [offset (-> (h/call wasm/internal-module "_get_text_dimensions")
(mem/->offset-32))
heapf32 (mem/get-heap-f32)
width (aget heapf32 (+ offset 0))
height (aget heapf32 (+ offset 1))
max-width (aget heapf32 (+ offset 2))
x (aget heapf32 (+ offset 3))
y (aget heapf32 (+ offset 4))]
(mem/free)
{:x x :y y :width width :height height :max-width max-width})))
x (aget heapf32 (+ offset 3))
y (aget heapf32 (+ offset 4))]
(mem/free)
{:x x :y y :width width :height height :max-width max-width}))))
(defn intersect-position-in-shape
[id position]
(if (and wasm/context-initialized? (not @wasm/context-lost?))
(if (initialized?)
(let [buffer (uuid/get-u32 id)
result
(h/call wasm/internal-module "_intersect_position_in_shape"
@ -1175,7 +1187,7 @@
(letfn [(do-render []
;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(when (initialized?)
(view-interaction-end!)
;; Use async _render: visible tiles render synchronously
;; (no yield), interest-area tiles render progressively
@ -1202,8 +1214,7 @@
(defn sync-workspace-local-viewport!
"Pushes `[:workspace-local :zoom]` and `:vbox` into WASM."
[state]
(when (and wasm/context-initialized?
(not @wasm/context-lost?))
(when (initialized?)
(let [zoom (get-in state [:workspace-local :zoom])
vbox (get-in state [:workspace-local :vbox])]
(when (and zoom vbox)
@ -1661,7 +1672,8 @@
(defn clean-modifiers
[]
(h/call wasm/internal-module "_clean_modifiers"))
(when (initialized?)
(h/call wasm/internal-module "_clean_modifiers")))
(defn set-modifiers-start
"Enter interactive transform mode (drag / resize / rotate). Enables
@ -1669,7 +1681,7 @@
backdrop so tiles do not appear sequentially or flicker while the
gesture is in progress."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(when (initialized?)
(h/call wasm/internal-module "_set_modifiers_start")))
(defn set-modifiers-end
@ -1677,7 +1689,7 @@
scheduled under it; the caller is expected to trigger a full-quality
render (via `request-render`) once the gesture is committed."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(when (initialized?)
(h/call wasm/internal-module "_set_modifiers_end")))
(defn set-modifiers
@ -2164,7 +2176,7 @@
(defn calculate-position-data
[shape]
(when wasm/context-initialized?
(when (initialized?)
(use-shape (:id shape))
(let [heapf32 (mem/get-heap-f32)
heapu32 (mem/get-heap-u32)

View File

@ -85,6 +85,31 @@
(track! :set-shape-grow-type)
nil)
(defn- mock-initialized?
[]
(track! :initialized?)
true)
;; The functions below used to short-circuit in tests because they guarded on
;; `wasm/context-initialized?` (always false without a real WASM binary). They
;; now guard on `initialized?`, which the mock forces to `true`, so they would
;; reach the real WASM heap. Stub them as no-ops to preserve that behavior.
(defn- mock-use-shape
[_id]
(track! :use-shape)
nil)
(defn- mock-calculate-position-data
[_shape]
(track! :calculate-position-data)
nil)
(defn- mock-request-render
[_requester]
(track! :request-render)
nil)
(defn- mock-set-shape-text-content
[_shape-id _content]
(track! :set-shape-text-content)
@ -143,7 +168,11 @@
(reset-call-counts!)
;; Save originals
(reset! originals
{:clean-modifiers wasm.api/clean-modifiers
{:initialized? wasm.api/initialized?
:use-shape wasm.api/use-shape
:calculate-position-data wasm.api/calculate-position-data
:request-render wasm.api/request-render
:clean-modifiers wasm.api/clean-modifiers
:set-structure-modifiers wasm.api/set-structure-modifiers
:propagate-modifiers wasm.api/propagate-modifiers
:set-modifiers wasm.api/set-modifiers
@ -155,6 +184,10 @@
:make-font-data wasm.fonts/make-font-data
:get-content-fonts wasm.fonts/get-content-fonts})
;; Install mocks
(set! wasm.api/initialized? mock-initialized?)
(set! wasm.api/use-shape mock-use-shape)
(set! wasm.api/calculate-position-data mock-calculate-position-data)
(set! wasm.api/request-render mock-request-render)
(set! wasm.api/clean-modifiers mock-clean-modifiers)
(set! wasm.api/set-structure-modifiers mock-set-structure-modifiers)
(set! wasm.api/propagate-modifiers mock-propagate-modifiers)
@ -171,6 +204,10 @@
"Restore the original WASM functions saved by `setup-wasm-mocks!`."
[]
(let [orig @originals]
(set! wasm.api/initialized? (:initialized? orig))
(set! wasm.api/use-shape (:use-shape orig))
(set! wasm.api/calculate-position-data (:calculate-position-data orig))
(set! wasm.api/request-render (:request-render orig))
(set! wasm.api/clean-modifiers (:clean-modifiers orig))
(set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig))
(set! wasm.api/propagate-modifiers (:propagate-modifiers orig))