🐛 Fix wasm context restore guards (#10824)

* 🐛 Prevent WASM panics during WebGL context restore

Delay context-restored until reload finishes and no-op app
mutations while reloading so resize/modifiers cannot panic
mid-teardown.

* 🐛 Fall back to CLJS bool content when WASM is not ready

Returning nil from calculate-bool would persist empty path content
during context loss/reload; use path/calc-bool-content instead.
This commit is contained in:
Alejandro Alonso 2026-07-29 10:06:52 +02:00 committed by GitHub
parent e14e7bb616
commit 4cd34b22e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 540 additions and 413 deletions

View File

@ -20,7 +20,6 @@
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.shape :as wasm.shape]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -90,8 +89,7 @@
(ptk/reify ::sync-wasm-structural-changes
ptk/EffectEvent
(effect [_ state _]
(when (and wasm/context-initialized?
(not @wasm/context-lost?))
(when (wasm.api/initialized?)
(let [objects (dsh/lookup-page-objects state)
shapes
(into []

View File

@ -355,8 +355,7 @@
features (features/get-enabled-features state team-id)
render-wasm-enabled? (features/active-feature? state "render-wasm/v1")
render-wasm-ready? #(and render-wasm-enabled?
wasm-state/context-initialized?
(not @wasm-state/context-lost?))]
(wasm-state/ready?))]
(log/debug :hint "initialize-workspace"
:team-id (dm/str team-id)

View File

@ -683,7 +683,7 @@
ty (.-f first-matrix)]
(if-let [base @cache]
(translate-selrect base tx ty)
(let [computed (wasm.api/get-selection-rect ids)]
(when-let [computed (wasm.api/get-selection-rect ids)]
(vreset! cache (translate-selrect computed (- tx) (- ty)))
computed))))
@ -729,15 +729,27 @@
(vreset! wasm-structure-modifiers-active? true)))
(let [geometry-entries (parse-geometry-modifiers modif-tree)
root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries)
wasm-ready? (wasm.api/initialized?)
;; While the GL context is down (lost / mid-reload), keep the
;; root transforms so SVG selection/preview can still move.
;; `propagate-modifiers` returns [] when not ready, do not
;; treat that as "no modifiers".
modifiers
(if (and translation? (not snap-pixel?))
(cond
(or (not wasm-ready?)
(and translation? (not snap-pixel?)))
root-modifiers
(wasm.api/propagate-modifiers geometry-entries snap-pixel?))]
(wasm.api/set-modifiers modifiers)
:else
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(if (seq propagated) propagated root-modifiers)))]
(when wasm-ready?
(wasm.api/set-modifiers modifiers))
(let [ids (into [] xf:map-key geometry-entries)
selrect (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
(wasm.api/get-selection-rect ids))]
selrect (when wasm-ready?
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
(wasm.api/get-selection-rect ids)))]
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers))))))))
@ -795,7 +807,8 @@
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
transforms
(if (and translation? (not snap-pixel?))
(cond
(and translation? (not snap-pixel?))
;; Mirror WASM `propagate_modifiers` in CLJS: splat the
;; translation matrix onto every descendant. Without
;; this step the commit would only touch the dragged
@ -815,6 +828,26 @@
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
{}
geometry-entries)
;; Context lost / mid-reload: do not call into WASM. Use
;; root transforms (and splat translation onto descendants
;; when we can) so the commit still lands in file data.
(not (wasm.api/initialized?))
(if translation?
(reduce
(fn [acc [id data]]
(let [t (:transform data)
subtree-ids
(or (get subtree-ids-by-id id)
(cfh/get-children-ids-with-self objects id))]
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
{}
geometry-entries)
(into {}
(map (fn [[id data]] [id (:transform data)]))
geometry-entries))
:else
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
ignore-tree

File diff suppressed because it is too large Load Diff

View File

@ -119,7 +119,7 @@
(defn update-text-layout
[id]
(when wasm/context-initialized?
(when (wasm/live?)
(let [shape-id-buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_update_shape_text_layout_for"
(aget shape-id-buffer 0)
@ -129,7 +129,7 @@
(defn force-update-text-layout
[id]
(when wasm/context-initialized?
(when (wasm/live?)
(let [shape-id-buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_force_update_shape_text_layout_for"
(aget shape-id-buffer 0)
@ -140,7 +140,7 @@
;; IMPORTANT: Only TTF fonts can be stored.
(defn- store-font-buffer
[font-data font-array-buffer emoji? fallback?]
(when wasm/context-initialized?
(when (wasm/live?)
(let [font-id-buffer (:family-id-buffer font-data)
size (.-byteLength font-array-buffer)
ptr (h/call wasm/internal-module "_alloc_bytes" size)

View File

@ -94,7 +94,7 @@
Returns nil."
[shape]
(when wasm/context-initialized?
(when (wasm/live?)
(let [id (dm/get-prop shape :id)
parent-id (get shape :parent-id)
shape-type (dm/get-prop shape :type)

View File

@ -14,7 +14,7 @@
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
[]
(when wasm/context-initialized?
(when (wasm/live?)
(let [gl-obj (unchecked-get wasm/internal-module "GL")]
(when gl-obj
;; Get the current WebGL context from Emscripten

View File

@ -15,7 +15,6 @@
[app.main.refs :as refs]
[app.render-wasm.api :as api]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[cljs.core :as c]
[cuerdas.core :as str]))
@ -129,7 +128,7 @@
;; The `set-wasm-attr!` can return a list of callbacks to be executed in a second pass.
(defn- set-wasm-attr!
[shape k]
(when wasm/context-initialized?
(when (api/initialized?)
(let [shape (case k
:svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs)))
(:fills :blur :shadow) (svg-filters/apply-svg-derived shape)
@ -334,14 +333,15 @@
(defn process-shape-changes!
[objects shape-changes]
(let [shape-changes
(->> shape-changes
;; We don't need to update the model for shapes not in the current page
(filter (fn [[shape-id _]] (shape-in-current-page? shape-id))))]
(when (d/not-empty? shape-changes)
(->> (rx/from shape-changes)
(rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props)))
(rx/subs! #(api/request-render "set-wasm-attrs"))))))
(when (api/initialized?)
(let [shape-changes
(->> shape-changes
;; We don't need to update the model for shapes not in the current page
(filter (fn [[shape-id _]] (shape-in-current-page? shape-id))))]
(when (d/not-empty? shape-changes)
(->> (rx/from shape-changes)
(rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props)))
(rx/subs! #(api/request-render "set-wasm-attrs")))))))
;; `conj` empty set initialization
(def conj* (fnil conj (d/ordered-set)))

View File

@ -190,7 +190,7 @@
(defn text-editor-focus
[id]
(when wasm/context-initialized?
(when (wasm/ready?)
(let [buffer (uuid/get-u32 id)]
(when-not (h/call wasm/internal-module "_text_editor_focus"
(aget buffer 0)
@ -202,44 +202,44 @@
(defn text-editor-set-cursor-from-offset
"Sets caret position from shape relative coordinates"
[{:keys [x y]}]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y)))
(defn text-editor-set-cursor-from-point
"Sets caret position from screen (canvas) coordinates"
[{:keys [x y]}]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-toggle-overtype-mode
"Toggles overtype mode"
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_toggle_overtype_mode")))
(defn text-editor-pointer-down
[{:keys [x y]}]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-move
[{:keys [x y]}]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_move" x y)))
(defn text-editor-pointer-up
[{:keys [x y]}]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_up" x y)))
(defn text-editor-update-blink
[timestamp-ms]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
(defn text-editor-render-overlay
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_render_overlay")))
(defn text-editor-render-caret
@ -252,7 +252,7 @@
(defn text-editor-poll-event
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
res)))
@ -323,7 +323,7 @@
(defn text-editor-get-current-styles
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(let [ptr (h/call wasm/internal-module "_text_editor_get_current_styles")]
(when (and ptr (not (zero? ptr)))
(let [heap-u8 (mem/get-heap-u8)
@ -411,7 +411,7 @@
(defn text-editor-encode-text-pre
[text]
(when (and (not (empty? text))
wasm/context-initialized?)
(wasm/ready?))
(let [encoder (js/TextEncoder.)
buf (.encode encoder text)
heapu8 (mem/get-heap-u8)
@ -422,31 +422,31 @@
(defn text-editor-encode-text-post
[text]
(when (and (not (empty? text))
wasm/context-initialized?)
(wasm/ready?))
(mem/free)))
(defn text-editor-composition-start
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_composition_start")))
(defn text-editor-composition-update
[text]
(when wasm/context-initialized?
(when (wasm/ready?)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_composition_update")
(text-editor-encode-text-post text)))
(defn text-editor-composition-end
[text]
(when wasm/context-initialized?
(when (wasm/ready?)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_composition_end")
(text-editor-encode-text-post text)))
(defn text-editor-insert-text
[text]
(when wasm/context-initialized?
(when (wasm/ready?)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_insert_text")
(text-editor-encode-text-post text)))
@ -455,62 +455,62 @@
([]
(text-editor-delete-backward false))
([word-boundary]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_delete_backward" word-boundary))))
(defn text-editor-delete-forward
([]
(text-editor-delete-forward false))
([word-boundary]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_delete_forward" word-boundary))))
(defn text-editor-insert-paragraph []
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
[direction word-boundary extend-selection]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_move_cursor" direction word-boundary (if extend-selection 1 0))))
(defn text-editor-select-all
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_select_all")))
(defn text-editor-select-word-boundary
[{:keys [x y]}]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_select_word_boundary" x y)))
(defn text-editor-blur
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(when-not (h/call wasm/internal-module "_text_editor_blur")
(throw (js/Error. "TextEditor blur failed")))))
(defn text-editor-dispose
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_dispose")))
(defn text-editor-has-focus?
([id]
(when wasm/context-initialized?
(when (wasm/ready?)
(not (zero? (h/call wasm/internal-module "_text_editor_has_focus_with_id" id)))))
([]
(when wasm/context-initialized?
(when (wasm/ready?)
(not (zero? (h/call wasm/internal-module "_text_editor_has_focus"))))))
(defn text-editor-has-selection?
([]
(when wasm/context-initialized?
(when (wasm/ready?)
(not (zero? (h/call wasm/internal-module "_text_editor_has_selection"))))))
(defn text-editor-export-content
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(let [ptr (h/call wasm/internal-module "_text_editor_export_content")]
(when (and ptr (not (zero? ptr)))
(let [json-str (mem/read-string ptr)]
@ -520,7 +520,7 @@
(defn text-editor-export-selection
"Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)."
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(let [ptr (h/call wasm/internal-module "_text_editor_export_selection")]
(when (and ptr (not (zero? ptr)))
(let [text (mem/read-string ptr)]
@ -529,7 +529,7 @@
(defn text-editor-get-active-shape-id
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(try
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
@ -549,7 +549,7 @@
(defn text-editor-get-selection
[]
(when wasm/context-initialized?
(when (wasm/ready?)
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)
@ -643,7 +643,7 @@
shape-id and the fully merged content map ready for
v2-update-text-shape-content."
[]
(when (and wasm/context-initialized? (text-editor-has-focus?))
(when (and (wasm/ready?) (text-editor-has-focus?))
(let [shape-id (text-editor-get-active-shape-id)
new-texts (text-editor-export-content)]
(when (and shape-id new-texts)
@ -707,7 +707,7 @@
(defn apply-styles-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
(when wasm/context-initialized?
(when (wasm/ready?)
(let [shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]

View File

@ -28,6 +28,11 @@
(defonce context-initialized? false)
(defonce context-lost? (atom false))
;; True while `reload-renderer!` is reconstructing the GL/WASM pipeline after a
;; WebGL context restore (or an explicit reload). External app callers must not
;; touch WASM until this clears (`ready?`); reload-internal ops use `live?`.
(defonce reloading? (atom false))
;; When we're rendering in a sync way we want to stop the asynchrous `request-render`
(defonce disable-request-render? (atom false))
@ -35,15 +40,30 @@
[]
(and internal-module (fn? (unchecked-get internal-module "_init"))))
(defn live?
"GL/WASM context exists and is not marked lost. True during reload after re-init."
[]
(and context-initialized? (not @context-lost?)))
(defn ready?
"Safe for normal application WASM calls (not lost, not mid-reload)."
[]
(and (live?) (not @reloading?)))
(defn reset-context-state!
"Clears canvas/GL handles and marks the context uninitialized.
Intentionally does **not** clear `context-lost?` or `reloading?`. During a
context-restore reload, `clear-canvas` runs while recovery is still in
progress; clearing those flags here would reopen a window where callers think
WebGL is ready before re-init finishes."
[]
(set! internal-frame-id nil)
(set! canvas nil)
(set! canvas-snapshot nil)
(set! gl-context-handle nil)
(set! gl-context nil)
(set! context-initialized? false)
(reset! context-lost? false))
(set! context-initialized? false))
(defonce serializers
#js {:raster-format shared/RasterFormat