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

View File

@ -33,7 +33,10 @@
(get-wasm-text-new-size shape (:content shape))) (get-wasm-text-new-size shape (:content shape)))
([{:keys [id selrect grow-type] :as shape} content] ([{: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/use-shape id)
(wasm.api/set-shape-text-content id content) (wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content) (wasm.api/set-shape-text-images id content)

View File

@ -85,28 +85,34 @@
parents parents
(mf/deref parents-by-ids-ref) (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 state-map
(cond (cond
(features/active-feature? @st/state "text-editor-wasm/v1") text-editor-wasm? wasm-editor-styles-map
(mf/deref refs/workspace-wasm-editor-styles) text-editor-v2? v2-editor-state-map
:else v1-editor-state-map)
(features/active-feature? @st/state "text-editor/v2")
(mf/deref refs/workspace-v2-editor-state)
:else
(mf/deref refs/workspace-editor-state))
editor-styles editor-styles
(when (features/active-feature? @st/state "text-editor-wasm/v1") (when text-editor-wasm?
(get state-map id)) (get state-map id))
editor-state editor-state
(when (not (features/active-feature? @st/state "text-editor/v2")) (when (not text-editor-v2?)
(get state-map id)) (get state-map id))
editor-instance editor-instance
(when (features/active-feature? @st/state "text-editor/v2") (when text-editor-v2?
(mf/deref refs/workspace-editor)) editor)
fill-values fill-values
(dwt/current-text-values (dwt/current-text-values

View File

@ -424,9 +424,9 @@
(js/clearTimeout timeout-id)) (js/clearTimeout timeout-id))
(wasm.api/clear-canvas))))) (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)] (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 (let [content (-> active-editor-state
(ted/get-editor-current-content) (ted/get-editor-current-content)
(ted/export-content))] (ted/export-content))]

View File

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

View File

@ -85,6 +85,31 @@
(track! :set-shape-grow-type) (track! :set-shape-grow-type)
nil) 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 (defn- mock-set-shape-text-content
[_shape-id _content] [_shape-id _content]
(track! :set-shape-text-content) (track! :set-shape-text-content)
@ -143,7 +168,11 @@
(reset-call-counts!) (reset-call-counts!)
;; Save originals ;; Save originals
(reset! 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 :set-structure-modifiers wasm.api/set-structure-modifiers
:propagate-modifiers wasm.api/propagate-modifiers :propagate-modifiers wasm.api/propagate-modifiers
:set-modifiers wasm.api/set-modifiers :set-modifiers wasm.api/set-modifiers
@ -155,6 +184,10 @@
:make-font-data wasm.fonts/make-font-data :make-font-data wasm.fonts/make-font-data
:get-content-fonts wasm.fonts/get-content-fonts}) :get-content-fonts wasm.fonts/get-content-fonts})
;; Install mocks ;; 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/clean-modifiers mock-clean-modifiers)
(set! wasm.api/set-structure-modifiers mock-set-structure-modifiers) (set! wasm.api/set-structure-modifiers mock-set-structure-modifiers)
(set! wasm.api/propagate-modifiers mock-propagate-modifiers) (set! wasm.api/propagate-modifiers mock-propagate-modifiers)
@ -171,6 +204,10 @@
"Restore the original WASM functions saved by `setup-wasm-mocks!`." "Restore the original WASM functions saved by `setup-wasm-mocks!`."
[] []
(let [orig @originals] (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/clean-modifiers (:clean-modifiers orig))
(set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig)) (set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig))
(set! wasm.api/propagate-modifiers (:propagate-modifiers orig)) (set! wasm.api/propagate-modifiers (:propagate-modifiers orig))