Implement WebGL context restoring (#9317)

*  Implement asset re-uploading to wasm

*  Show toast instead of error screen when webgl context is lost

* 🎉 Recover context after webgl context restored event

* 🎉 Set Read-only mode when the context has been lost

*  Disable scroll & zoom when context loss

*  Fix stale reload payload

*  Use existing debounce util to take screenshots

*  Implement design / ux specs

*  Fix playwright test by looking for toast, not error page
This commit is contained in:
Belén Albeza 2026-05-11 13:15:45 +02:00 committed by GitHub
parent cd4a4da0f2
commit 0639ca53de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 550 additions and 167 deletions

View File

@ -16,7 +16,7 @@ test.skip("BUG 10867 - Crash when loading comments", async ({ page }) => {
).toBeVisible();
});
test("BUG 13541 - Shows error page when WebGL context is lost", async ({
test("Shows toast when WebGL context is lost", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
@ -31,12 +31,9 @@ test("BUG 13541 - Shows error page when WebGL context is lost", async ({
});
await expect(
page.getByText("Oops! The canvas context was lost"),
page.getByText("WebGL context was lost"),
).toBeVisible();
await expect(
page.getByText("WebGL has stopped working"),
).toBeVisible();
await expect(page.getByText("Reload page")).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh" })).toBeVisible();
});
test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({

View File

@ -1,5 +1,6 @@
(ns app.main.data.render-wasm
(:require
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn context-lost
@ -7,11 +8,37 @@
(ptk/reify ::context-lost
ptk/UpdateEvent
(update [_ state]
(update state :render-state #(assoc % :lost true)))))
(let [already-lost? (get-in state [:render-state :lost])
prev-read-only? (get-in state [:workspace-global :read-only?])
prev-options-mode (get-in state [:workspace-global :options-mode])]
(-> state
(update :render-state
(fn [render-state]
(cond-> (assoc render-state :lost true)
(not already-lost?)
(assoc :pre-context-lost-read-only? prev-read-only?
:pre-context-lost-options-mode prev-options-mode))))
(assoc-in [:workspace-global :options-mode] :inspect)
(assoc-in [:workspace-global :read-only?] true))))
ptk/WatchEvent
(watch [_ _ _]
(rx/of :interrupt))))
(defn context-restored
[]
(ptk/reify ::context-restored
ptk/UpdateEvent
(update [_ state]
(update state :render-state #(dissoc % :lost)))))
(let [restored-read-only? (get-in state [:render-state :pre-context-lost-read-only?]
(get-in state [:workspace-global :read-only?]))
restored-options-mode (get-in state [:render-state :pre-context-lost-options-mode]
(get-in state [:workspace-global :options-mode]))]
(-> state
(update :render-state #(dissoc % :lost
:pre-context-lost-read-only?
:pre-context-lost-options-mode))
(assoc-in [:workspace-global :options-mode] restored-options-mode)
(assoc-in [:workspace-global :read-only?] restored-read-only?))))
ptk/WatchEvent
(watch [_ _ _]
(rx/of :interrupt))))

View File

@ -32,6 +32,7 @@
[app.main.features :as features]
[app.main.streams :as ms]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.gesture :as wasm-gesture]
[app.render-wasm.shape :as wasm.shape]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -44,16 +45,17 @@
;; Paired with `set-modifiers-start` / `set-modifiers-end` so the
;; native side only toggles once per gesture, regardless of how many
;; `set-wasm-modifiers` calls fire in between.
(defonce ^:private interactive-transform-active? (atom false))
;; State lives in `app.render-wasm.gesture` so `reload-renderer!` can reset it after
;; `_clean_up` without an api ↔ modifiers circular dependency.
(defn- ensure-interactive-transform-start!
[]
(when (compare-and-set! interactive-transform-active? false true)
(when (wasm-gesture/try-begin-interactive-transform!)
(wasm.api/set-modifiers-start)))
(defn- ensure-interactive-transform-end!
[]
(when (compare-and-set! interactive-transform-active? true false)
(when (wasm-gesture/try-end-interactive-transform!)
(wasm.api/set-modifiers-end)))
(def ^:private transform-attrs

View File

@ -20,6 +20,10 @@
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn- render-context-lost?
[state]
(true? (get-in state [:render-state :lost])))
(defn initialize-viewport
[{:keys [width height] :as size}]
@ -101,7 +105,9 @@
(ptk/reify ::update-viewport-position-center
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local calculate-centered-viewbox position))))
(if (render-context-lost? state)
state
(update state :workspace-local calculate-centered-viewbox position)))))
(defn update-viewport-position
[{:keys [x y] :or {x identity y identity}}]
@ -118,11 +124,13 @@
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :vbox]
(fn [vbox]
(-> vbox
(update :x x)
(update :y y)))))))
(if (render-context-lost? state)
state
(update-in state [:workspace-local :vbox]
(fn [vbox]
(-> vbox
(update :x x)
(update :y y))))))))
(defn update-viewport-size
[resize-type {:keys [width height] :as size}]
@ -172,7 +180,8 @@
(watch [_ state stream]
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-panning)))
zoom (get-in state [:workspace-local :zoom])]
(when-not (get-in state [:workspace-local :panning])
(when (and (not (render-context-lost? state))
(not (get-in state [:workspace-local :panning])))
(rx/concat
(rx/of #(-> % (assoc-in [:workspace-local :panning] true)))
(->> stream

View File

@ -21,6 +21,10 @@
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn- render-context-lost?
[state]
(true? (get-in state [:render-state :lost])))
(defn impl-update-zoom
[{:keys [vbox] :as local} center zoom]
(let [new-zoom (if (fn? zoom) (zoom (:zoom local)) zoom)
@ -43,9 +47,11 @@
ptk/UpdateEvent
(update [_ state]
(let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (min (* z 1.3) 200)))))))))
(if (render-context-lost? state)
state
(let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (min (* z 1.3) 200))))))))))
(defn decrease-zoom
([]
@ -56,9 +62,11 @@
ptk/UpdateEvent
(update [_ state]
(let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01)))))))))
(if (render-context-lost? state)
state
(let [center (if (= center ::auto) @ms/mouse-position center)]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01))))))))))
(defn set-zoom
([scale]
@ -69,68 +77,76 @@
ptk/UpdateEvent
(update [_ state]
(let [vp (dm/get-in state [:workspace-local :vbox])
x (+ (:x vp) (/ (:width vp) 2))
y (+ (:y vp) (/ (:height vp) 2))
center (d/nilv center (gpt/point x y))]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (-> (* z scale)
(max 0.01)
(min 200))))))))))
(if (render-context-lost? state)
state
(let [vp (dm/get-in state [:workspace-local :vbox])
x (+ (:x vp) (/ (:width vp) 2))
y (+ (:y vp) (/ (:height vp) 2))
center (d/nilv center (gpt/point x y))]
(update state :workspace-local
#(impl-update-zoom % center (fn [z] (-> (* z scale)
(max 0.01)
(min 200)))))))))))
(def reset-zoom
(ptk/reify ::reset-zoom
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local
#(impl-update-zoom % nil 1)))))
(if (render-context-lost? state)
state
(update state :workspace-local
#(impl-update-zoom % nil 1))))))
(def zoom-to-fit-all
(ptk/reify ::zoom-to-fit-all
ptk/UpdateEvent
(update [_ state]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shapes (cfh/get-immediate-children objects)
srect (gsh/shapes->rect shapes)]
(if (empty? shapes)
state
(update state :workspace-local
(fn [{:keys [vport] :as local}]
(let [srect (gal/adjust-to-viewport vport srect {:padding 160 :min-zoom 0.01})
zoom (/ (:width vport) (:width srect))]
(-> local
(assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect))))))))))
(def zoom-to-selected-shape
(ptk/reify ::zoom-to-selected-shape
ptk/UpdateEvent
(update [_ state]
(let [selected (dsh/lookup-selected state)]
(if (empty? selected)
state
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
srect (->> selected
(map #(get objects %))
(gsh/shapes->rect))]
(if (render-context-lost? state)
state
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shapes (cfh/get-immediate-children objects)
srect (gsh/shapes->rect shapes)]
(if (empty? shapes)
state
(update state :workspace-local
(fn [{:keys [vport] :as local}]
(let [srect (gal/adjust-to-viewport vport srect {:padding 40 :min-zoom 0.01})
(let [srect (gal/adjust-to-viewport vport srect {:padding 160 :min-zoom 0.01})
zoom (/ (:width vport) (:width srect))]
(-> local
(assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect)))))))))))
(def zoom-to-selected-shape
(ptk/reify ::zoom-to-selected-shape
ptk/UpdateEvent
(update [_ state]
(if (render-context-lost? state)
state
(let [selected (dsh/lookup-selected state)]
(if (empty? selected)
state
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
srect (->> selected
(map #(get objects %))
(gsh/shapes->rect))]
(update state :workspace-local
(fn [{:keys [vport] :as local}]
(let [srect (gal/adjust-to-viewport vport srect {:padding 40 :min-zoom 0.01})
zoom (/ (:width vport) (:width srect))]
(-> local
(assoc :zoom zoom)
(assoc :zoom-inverse (/ 1 zoom))
(update :vbox merge srect))))))))))))
(defn fit-to-shapes
[ids]
(ptk/reify ::fit-to-shapes
ptk/UpdateEvent
(update [_ state]
(if (empty? ids)
(if (or (render-context-lost? state) (empty? ids))
state
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
@ -155,7 +171,8 @@
ptk/WatchEvent
(watch [_ state stream]
(let [stopper (->> stream (rx/filter (ptk/type? ::finish-zooming)))]
(when-not (get-in state [:workspace-local :zooming])
(when (and (not (render-context-lost? state))
(not (get-in state [:workspace-local :zooming])))
(rx/concat
(rx/of #(-> % (assoc-in [:workspace-local :zooming] true)))
(->> stream

View File

@ -221,9 +221,11 @@
(when-let [cause (::instance error)]
(ex/print-throwable cause)
(let [code (get error :code)]
(if (or (= code :panic)
(= code :webgl-context-lost))
(cond
(= code :panic)
(st/emit! (rt/assign-exception error))
:else
(flash :type :handled :cause cause)))))
;; We receive a explicit authentication error; If the uri is for

View File

@ -491,13 +491,6 @@
:service-unavailable
[:> service-unavailable*]
:wasm-error
(case (get data :code)
:webgl-context-lost
[:> webgl-context-lost*]
[:> internal-error* props])
[:> internal-error* props])))
(mf/defc context-wrapper*

View File

@ -219,6 +219,7 @@
[{:keys [objects selected page-id file-id on-change-section on-expand]}]
(let [permissions
(mf/use-ctx ctx/permissions)
render-context-lost? (mf/deref refs/render-context-lost?)
options-mode
(mf/deref refs/options-mode-global)
@ -228,7 +229,7 @@
(sequence (keep (d/getf objects)) selected))]
[:div {:class (stl/css :tool-window)}
(if (:can-edit permissions)
(if (and (:can-edit permissions) (not render-context-lost?))
[:> tab-switcher* {:tabs options-tabs
:on-change on-option-tab-change
:selected (name options-mode)

View File

@ -9,7 +9,9 @@
(:require
[app.main.data.workspace :as dw]
[app.main.data.workspace.common :as dwc]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.workspace.viewport.grid-layout-editor :refer [grid-edition-actions]]
[app.main.ui.workspace.viewport.path-actions :refer [path-actions*]]
[app.util.i18n :as i18n :refer [tr]]
@ -23,18 +25,24 @@
[]
(let [on-close
(mf/use-fn
#(st/emit! :interrupt
(dw/set-options-mode :design)
(dwc/set-workspace-read-only false)))]
(fn []
(st/emit! :interrupt
(dw/set-options-mode :design)
(dwc/set-workspace-read-only false))))
render-context-lost? (mf/deref refs/render-context-lost?)]
[:div {:class (stl/css :viewport-actions)}
[:div {:class (stl/css :viewport-actions-container)}
[:div {:class (stl/css :viewport-actions-title)}
[:> i18n/tr-html*
{:tag-name "span"
:content (tr "workspace.top-bar.view-only")}]]
[:button {:class (stl/css :done-btn)
:on-click on-close}
(tr "workspace.top-bar.read-only.done")]]]))
:content (tr (if render-context-lost?
"workspace.top-bar.webgl-context-lost"
"workspace.top-bar.view-only"))}]]
(if render-context-lost?
[:> button* {:variant "primary" :on-click (fn [] (js/location.reload))}
(tr "workspace.top-bar.webgl-context-lost.reload")]
[:> button* {:on-click on-close}
(tr "workspace.top-bar.read-only.done")])]]))
(mf/defc path-edition-bar*
[{:keys [layout edit-path-state shape]}]

View File

@ -16,6 +16,7 @@
top: calc(var(--actions-toolbar-position-y) + var(--actions-toolbar-offset-y));
left: 50%;
transform: translateX(-50%);
z-index: deprecated.$z-index-20;
}
@ -31,11 +32,10 @@
box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color);
gap: deprecated.$s-8;
height: deprecated.$s-48;
margin-left: -50%;
padding: deprecated.$s-8;
cursor: initial;
pointer-events: initial;
width: deprecated.$s-400;
min-width: deprecated.$s-400;
border: deprecated.$s-2 solid var(--panel-border-color);
}
@ -44,6 +44,7 @@
font-size: deprecated.$fs-12;
color: var(--color-foreground-secondary);
padding-left: deprecated.$s-8;
width: max-content;
}
.done-btn {

View File

@ -254,6 +254,7 @@
;; True when we are opening a new file or switching to a new page
page-transition? (mf/deref wasm.api/page-transition?)
context-loss-overlay? (mf/deref wasm.api/context-loss-overlay?)
on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids read-only?)
@ -544,8 +545,9 @@
:style {:background-color background
:pointer-events "none"}}]
;; Show the transition image when we are opening a new file or switching to a new page
(when (and page-transition? (some? transition-image-url))
;; Show the transition image when switching pages or recovering from WebGL context loss.
(when (and (or page-transition? context-loss-overlay?)
(some? transition-image-url))
(let [src transition-image-url]
[:img {:data-testid "canvas-wasm-transition"
:src src
@ -556,6 +558,7 @@
:height "100%"
:object-fit "cover"
:pointer-events "none"
;; use (when page-transition? "blur(4px)") if we don't want the blur on context loss
:filter "blur(4px)"}}]))

View File

@ -11,6 +11,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.focus :as cpf]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.math :as mth]
@ -22,6 +23,9 @@
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf]
[app.main.data.render-wasm :as drw]
[app.main.data.workspace.texts-v3 :as texts]
[app.main.refs :as refs]
[app.main.router :as rt]
@ -32,6 +36,7 @@
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
[app.render-wasm.gesture :as wasm-gesture]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
@ -45,6 +50,7 @@
[app.util.dom :as dom]
[app.util.functions :as fns]
[app.util.globals :as ug]
[app.util.i18n :refer [tr]]
[app.util.modules :as mod]
[app.util.text.content :as tc]
[beicon.v2.core :as rx]
@ -69,11 +75,14 @@
;; - `transition-tiles-handler*`: the currently installed DOM event handler for
;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely.
(defonce page-transition? (atom false))
(defonce context-loss-overlay? (atom false))
(defonce transition-image-url* (atom nil))
(defonce transition-epoch* (atom 0))
(defonce transition-tiles-handler* (atom nil))
(defonce snapshot-tiles-handler* (atom nil))
(def ^:private transition-blur-css "blur(4px)")
(def ^:private snapshot-capture-debounce-ms 250)
(defn- set-transition-blur!
[]
@ -113,9 +122,7 @@
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(reset! transition-tiles-handler* nil)
(reset! transition-image-url* nil)
(clear-transition-blur!)
;; Clear captured pixels so future transitions must explicitly capture again.
(set! wasm/canvas-snapshot-url nil))
(clear-transition-blur!))
(defn- set-transition-tiles-complete-handler!
"Installs a tiles-complete handler bound to the current transition epoch.
@ -147,6 +154,16 @@
(let [epoch (begin-page-transition!)]
(set-transition-tiles-complete-handler! epoch end-page-transition!))))
(defn- start-context-loss-overlay!
[]
(reset! context-loss-overlay? true))
(defn- end-context-loss-overlay!
[]
(reset! context-loss-overlay? false)
(when-not @page-transition?
(reset! transition-image-url* nil)))
(defn listen-tiles-render-complete-once!
"Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM
when a full tile pass finishes."
@ -157,6 +174,32 @@
(f))
#js {:once true}))
(defonce ^:private schedule-canvas-snapshot-capture!
(fns/debounce
(fn []
(when (and wasm/context-initialized?
(not @wasm/context-lost?)
(some? wasm/canvas))
(-> (webgl/capture-canvas-snapshot-url)
(p/catch (fn [_] nil)))))
snapshot-capture-debounce-ms))
(defn- start-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(let [handler (fn [_] (schedule-canvas-snapshot-capture!))]
(reset! snapshot-tiles-handler* handler)
(.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler)))
(defn- stop-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(reset! snapshot-tiles-handler* nil)
(when-let [cancel (unchecked-get schedule-canvas-snapshot-capture! "cancel")]
(cancel)))
(defn text-editor-wasm?
[]
(or (contains? cf/flags :feature-text-editor-wasm)
@ -267,6 +310,36 @@
;; forward declare helpers so render can call them
(declare request-render)
(declare set-shape-vertical-align fonts-from-text-content)
(declare reload-renderer!)
(defn- build-reload-payload
"Builds renderer reload payload from current application state.
Avoids keeping heavyweight object snapshots in memory."
[]
(let [state @st/state
file-id (:current-file-id state)
page-id (:current-page-id state)
page (dsh/lookup-page state file-id page-id)
objects (dsh/lookup-page-objects state file-id page-id)
focus (:workspace-focus-selected state)
local (:workspace-local state)
zoom (:zoom local)
vbox (:vbox local)
canvas wasm/canvas
background (get page :background)]
{:canvas canvas
:base-objects (cpf/focus-objects objects focus)
:zoom zoom
:vbox vbox
:background background}))
(defn free-gpu-resources
[]
;; 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?))
(h/call wasm/internal-module "_free_gpu_resources")))
;; This should never be called from the outside.
(defn- render
@ -1578,6 +1651,57 @@
(h/call wasm/internal-module "_init_shapes_pool" total-shapes)
(set-objects base-objects on-render on-shapes-ready force-sync)))
(defn- run-resource-callbacks!
[entries]
(if (seq entries)
(p/create
(fn [resolve _reject]
(->> (rx/from (vals (d/index-by :key :callback entries)))
(rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty))))
(rx/reduce conj [])
(rx/subs! (fn [_] (resolve nil))
(fn [_cause] (resolve nil))
(fn [] (resolve nil))))))
(p/resolved nil)))
(defn- replay-font-resources!
[fonts]
(let [pending (into [] (f/store-fonts fonts))]
(run-resource-callbacks! pending)))
(defn- derive-font-resources
[base-objects payload-fonts]
(let [object-fonts
(->> (vals base-objects)
(filter cfh/text-shape?)
(mapcat (fn [shape]
(let [content (ensure-text-content (:content shape))
direct-fonts (f/get-content-fonts content)
;; `true` would call `write-shape-text`, which requires
;; an active current shape in WASM and can panic during
;; reload pre-processing. We only need fallback font
;; discovery here, so use side-effect free mode.
fallback-fonts (fonts-from-text-content content false)]
(concat direct-fonts fallback-fonts))))
(into #{}))]
(into [] (set (concat payload-fonts object-fonts)))))
(defn- replay-image-resources!
[image-resources]
(let [pending
(into []
(keep (fn [{:keys [shape-id image-id thumbnail?]}]
(when (and (uuid? image-id) (or (nil? shape-id) (uuid? shape-id)))
(fetch-image (or shape-id uuid/zero) image-id (boolean thumbnail?)))))
image-resources)]
(run-resource-callbacks! pending)))
(defn- wait-next-frame!
[]
(p/create
(fn [resolve _reject]
(js/requestAnimationFrame (fn [] (resolve nil))))))
(def ^:private default-context-options
#js {:antialias false
:depth true
@ -1654,96 +1778,202 @@
(defn- on-webgl-context-lost
[event]
(dom/prevent-default event)
;; Keep the last rendered pixels visible while context is lost/recovering.
(start-context-loss-overlay!)
(when-let [url wasm/canvas-snapshot-url]
(when (string? url)
(reset! transition-image-url* url)))
(reset! wasm/context-lost? true)
(ex/raise :type :wasm-error
:code :webgl-context-lost
:hint "WASM Error: WebGL context lost"))
(st/async-emit!
(ntf/show {:content (tr "webgl.webgl-context-lost.toast")
:type :toast
:level :warning
:timeout 5000}))
(st/emit! (drw/context-lost)))
(defn- on-webgl-context-restored
[event]
(dom/prevent-default event)
(reset! wasm/context-lost? false)
(st/emit! (drw/context-restored))
(let [payload (build-reload-payload)]
(-> (reload-renderer! payload)
(p/then (fn [_]
(listen-tiles-render-complete-once! end-context-loss-overlay!)
(st/async-emit!
(ntf/show {:content (tr "webgl.webgl-context-recovered.toast")
:type :toast
:level :success
:timeout 3000}))))
(p/catch (fn [cause]
(end-context-loss-overlay!)
(log/error :hint "wasm reload after context restore failed"
:cause cause)
nil)))))
(defn init-canvas-context
[canvas]
(let [gl (unchecked-get wasm/internal-module "GL")
flags (debug-flags)
context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2")
context (.getContext ^js canvas context-id default-context-options)
context-init? (not (nil? context))
browser (sr/translate-browser cf/browser)]
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)
(set! wasm/gl-context-handle handle)
(set! wasm/gl-context context)
(if-not (wasm/module-ready?)
false
(let [gl (unchecked-get wasm/internal-module "GL")
flags (debug-flags)
context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2")
context (.getContext ^js canvas context-id default-context-options)
context-init? (not (nil? context))
browser (sr/translate-browser cf/browser)]
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)
(set! wasm/gl-context-handle handle)
(set! wasm/gl-context context)
;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it
(.getExtension context "WEBGL_debug_renderer_info")
;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it
(.getExtension context "WEBGL_debug_renderer_info")
;; Initialize Wasm Render Engine
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr)
(when-let [t (wasm-aa-threshold-from-route-params)]
(h/call wasm/internal-module "_set_antialias_threshold" t))
(when-let [t (wasm-viewport-interest-area-threshold-from-route-params)]
(h/call wasm/internal-module "_set_viewport_interest_area_threshold" t))
(when-let [t (wasm-max-blocking-time-ms-from-route-params)]
(h/call wasm/internal-module "_set_max_blocking_time_ms" t))
(when-let [t (wasm-node-batch-threshold-from-route-params)]
(h/call wasm/internal-module "_set_node_batch_threshold" t))
(when-let [t (wasm-blur-downscale-threshold-from-route-params)]
(h/call wasm/internal-module "_set_blur_downscale_threshold" t))
(when-let [max-tex (webgl/max-texture-size context)]
(h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex))
;; Initialize Wasm Render Engine
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr)
(when-let [t (wasm-aa-threshold-from-route-params)]
(h/call wasm/internal-module "_set_antialias_threshold" t))
(when-let [t (wasm-viewport-interest-area-threshold-from-route-params)]
(h/call wasm/internal-module "_set_viewport_interest_area_threshold" t))
(when-let [t (wasm-max-blocking-time-ms-from-route-params)]
(h/call wasm/internal-module "_set_max_blocking_time_ms" t))
(when-let [t (wasm-node-batch-threshold-from-route-params)]
(h/call wasm/internal-module "_set_node_batch_threshold" t))
(when-let [t (wasm-blur-downscale-threshold-from-route-params)]
(h/call wasm/internal-module "_set_blur_downscale_threshold" t))
(when-let [max-tex (webgl/max-texture-size context)]
(h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex))
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
(set-canvas-size canvas)
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
(set-canvas-size canvas)
;; Add event listeners for WebGL context lost
(set! wasm/canvas canvas)
(.addEventListener canvas "webglcontextlost" on-webgl-context-lost)
(set! wasm/context-initialized? true)))
;; Add event listeners for WebGL context lost
(set! wasm/canvas canvas)
(.addEventListener canvas "webglcontextlost" on-webgl-context-lost)
(.addEventListener canvas "webglcontextrestored" on-webgl-context-restored)
(start-canvas-snapshot-listener!)
(reset! wasm/context-lost? false)
(set! wasm/context-initialized? true)))
context-init?))
context-init?)))
(defn clear-canvas
[]
(when wasm/context-initialized?
(try
(set! wasm/context-initialized? false)
([]
(clear-canvas {}))
([{:keys [lose-browser-context?]
:or {lose-browser-context? true}}]
(try
(set! wasm/context-initialized? false)
;; Cancel any pending animation frame to prevent race conditions
(when wasm/internal-frame-id
(js/cancelAnimationFrame wasm/internal-frame-id)
(set! wasm/internal-frame-id nil))
;; Cancel any pending animation frame to prevent race conditions.
(when wasm/internal-frame-id
(js/cancelAnimationFrame wasm/internal-frame-id))
;; Reset render flags to prevent new renders from being scheduled
(reset! pending-render false)
(reset! shapes-loading? false)
(reset! deferred-render? false)
;; Reset render flags to prevent new renders from being scheduled.
(reset! pending-render false)
(reset! shapes-loading? false)
(reset! deferred-render? false)
(h/call wasm/internal-module "_clean_up")
;; Remove listener before losing/deleting context.
(when wasm/canvas
(.removeEventListener wasm/canvas "webglcontextlost" on-webgl-context-lost)
(.removeEventListener wasm/canvas "webglcontextrestored" on-webgl-context-restored))
(stop-canvas-snapshot-listener!)
;; Remove event listener for WebGL context lost
(when wasm/canvas
(.removeEventListener wasm/canvas "webglcontextlost" on-webgl-context-lost)
(set! wasm/canvas nil))
(when (wasm/module-ready?)
(free-gpu-resources)
(h/call wasm/internal-module "_clean_up"))
;; Ensure the WebGL context is properly disposed so browsers do not keep
;; accumulating active contexts between page switches.
(when-let [gl (unchecked-get wasm/internal-module "GL")]
(when-let [handle wasm/gl-context-handle]
(try
;; Ask the browser to release resources explicitly if available.
(when-let [ctx wasm/gl-context]
(when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")]
(.loseContext ^js lose-ext)))
(.deleteContext ^js gl handle)
(finally
(set! wasm/gl-context-handle nil)
(set! wasm/gl-context nil)))))
;; Ensure the WebGL context is properly disposed so browsers do not keep
;; accumulating active contexts between page switches.
(when-let [gl (unchecked-get wasm/internal-module "GL")]
(when-let [handle wasm/gl-context-handle]
(try
;; For hard teardown we can explicitly lose browser context.
;; For reload->reinit flows we skip this because immediate context
;; recreation may fail on some browsers/GPUs while context is lost.
(when lose-browser-context?
(when-let [ctx wasm/gl-context]
(when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")]
(.loseContext ^js lose-ext))))
(.deleteContext ^js gl handle)
(catch :default dispose-error
(.error js/console dispose-error)))))
;; If this calls panics we don't want to crash. This happens sometimes
;; with hot-reload in develop
(catch :default error
(.error js/console error)))))
(wasm-gesture/reset-after-wasm-reload!)
(wasm/reset-context-state!)
true
;; If this panics we don't want to crash. This happens sometimes with
;; hot-reload in development.
(catch :default error
(.error js/console error)
(wasm-gesture/reset-after-wasm-reload!)
(wasm/reset-context-state!)
false))))
(defn reload-renderer!
[{:keys [canvas
base-objects
zoom
vbox
fonts
image-resources
background
background-opacity
on-render
on-shapes-ready
force-sync]
:or {fonts []
image-resources []
background-opacity 1
force-sync false}
:as payload}]
(ug/dispatch! (ug/event "penpot:wasm:reload-start"))
(let [fonts (derive-font-resources base-objects fonts)]
(-> (p/resolved nil)
;; Keep teardown strict (`_clean_up` + deleteContext) but do not
;; force `loseContext` because we immediately create a new context.
(p/then (fn [_]
(let [was-cleared? (clear-canvas {:lose-browser-context? false})]
(when-not was-cleared?
(ex/raise :type :wasm-error
:code :wasm-reload-context-failure
:hint "WASM renderer cleanup failed")))))
;; Give browser a frame to settle context deletion before init.
(p/then (fn [_] (wait-next-frame!)))
(p/then (fn [_]
(let [context-ready? (init-canvas-context canvas)]
(when-not context-ready?
(ex/raise :type :wasm-error
:code :wasm-reload-context-failure
:hint "WASM renderer could not create a new WebGL context"))
;; Gesture bookkeeping (`modifiers.cljs`) uses compare-and-set on an atom
;; that survives WASM teardown; reset so it matches fresh `_init` state.
(wasm-gesture/reset-after-wasm-reload!))))
;; Ensure render surfaces are blank before replay to avoid overpainting.
(p/then (fn [_] (h/call wasm/internal-module "_reset_canvas")))
(p/then (fn [_] (replay-font-resources! fonts)))
(p/then (fn [_] (replay-image-resources! image-resources)))
(p/then
(fn []
(initialize-viewport base-objects zoom vbox
:background background
:background-opacity background-opacity
:on-render on-render
:on-shapes-ready on-shapes-ready
:force-sync force-sync)
(request-render "reload-renderer")
(ug/dispatch! (ug/event "penpot:wasm:reload-complete"))
payload))
(p/catch
(fn [cause]
(ug/dispatch! (ug/event "penpot:wasm:reload-failed"))
(clear-canvas)
(p/rejected cause))))))
(defn show-grid
[id]

View File

@ -0,0 +1,29 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.gesture
"WASM-linked pointer gestures (interactive transforms, like D&D)")
(defonce ^:private interactive-transform-active? (atom false))
(defn reset-after-wasm-reload!
"Call after `_clean_up` + `_init` (new GL context). WASM interactive_transform /
fast_mode are reset to defaults; this atom must match or compare-and-set helpers in
modifiers.cljs will skip `_set_modifiers_start` / `_set_modifiers_end` incorrectly."
[]
(reset! interactive-transform-active? false))
(defn try-begin-interactive-transform!
"Returns true iff we transitioned inactive → active and native `_set_modifiers_start`
must run."
[]
(compare-and-set! interactive-transform-active? false true))
(defn try-end-interactive-transform!
"Returns true iff we transitioned active → inactive and native `_set_modifiers_end`
must run."
[]
(compare-and-set! interactive-transform-active? true false))

View File

@ -29,6 +29,20 @@
;; When we're rendering in a sync way we want to stop the asynchrous `request-render`
(defonce disable-request-render? (atom false))
(defn module-ready?
[]
(and internal-module (fn? (unchecked-get internal-module "_init"))))
(defn reset-context-state!
[]
(set! internal-frame-id nil)
(set! canvas nil)
(set! canvas-snapshot-url nil)
(set! gl-context-handle nil)
(set! gl-context nil)
(set! context-initialized? false)
(reset! context-lost? false))
(defonce serializers
#js {:blur-type shared/RawBlurType

View File

@ -1715,6 +1715,8 @@ msgstr "WebGL has stopped working. Please reload the page to reset it"
msgid "errors.webgl-context-lost.main-message"
msgstr "Oops! The canvas context was lost"
#: src/app/main/ui/dashboard/team.cljs:1051
msgid "errors.webhooks.connection"
msgstr "Connection error, URL not reacheable"
@ -8973,6 +8975,12 @@ msgstr "Done"
msgid "workspace.top-bar.view-only"
msgstr "**Inspecting code** (View Only)"
msgid "workspace.top-bar.webgl-context-lost"
msgstr "Rendering unavailable. Refresh to restore editing."
msgid "workspace.top-bar.webgl-context-lost.reload"
msgstr "Refresh"
#: src/app/main/ui/workspace/sidebar/history.cljs:333
msgid "workspace.undo.empty"
msgstr "There are no history changes so far"
@ -9234,6 +9242,14 @@ msgstr ""
msgid "workspace.versions.warning.text"
msgstr "Autosaved versions will be kept for %s days."
#: src/app/render_wasm/api.cljs
msgid "webgl.webgl-context-lost.toast"
msgstr "WebGL context was lost"
#: src/app/render_wasm/api.cljs
msgid "webgl.webgl-context-recovered.toast"
msgstr "WebGL context was recovered"
msgid "webgl.modals.webgl-unavailable.title"
msgstr "Oops! WebGL is not available"

View File

@ -8701,6 +8701,12 @@ msgstr "Hecho"
msgid "workspace.top-bar.view-only"
msgstr "**Inspeccionando código** (View only)"
msgid "workspace.top-bar.webgl-context-lost"
msgstr "Renderizado no disponible. Recarga la página para restaurar la edición."
msgid "workspace.top-bar.webgl-context-lost.reload"
msgstr "Recargar"
#: src/app/main/ui/workspace/sidebar/history.cljs:333
msgid "workspace.undo.empty"
msgstr "Todavía no hay cambios en el histórico"
@ -8942,6 +8948,14 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](%
msgid "workspace.versions.warning.text"
msgstr "Los autoguardados duran %s días."
#: src/app/render_wasm/api.cljs
msgid "webgl.webgl-context-lost.toast"
msgstr "Se perdió el contexto WebGL"
#: src/app/render_wasm/api.cljs
msgid "webgl.webgl-context-recovered.toast"
msgstr "Se recuperó el contexto WebGL"
msgid "webgl.modals.webgl-unavailable.title"
msgstr "Vaya, WebGL no está disponible"

View File

@ -178,9 +178,17 @@ pub extern "C" fn set_browser(browser: u8) -> Result<()> {
pub extern "C" fn clean_up() -> Result<()> {
// Cancel the current animation frame if it exists so
// it won't try to render without context
let render_state = get_render_state();
render_state.cancel_animation_frame();
unsafe { STATE = None }
unsafe {
#[allow(static_mut_refs)]
if STATE.is_some() {
// Cancel the current animation frame if it exists so
// it won't try to render without context.
let render_state = get_render_state();
render_state.cancel_animation_frame();
render_state.prepare_context_loss_cleanup();
}
STATE = None;
}
mem::free_bytes()?;
Ok(())
}
@ -1072,6 +1080,11 @@ pub extern "C" fn render_stats() {
get_render_state().print_stats();
}
#[no_mangle]
pub fn free_gpu_resources() {
get_render_state().free_gpu_resources();
}
fn main() {
#[cfg(target_arch = "wasm32")]
init_gl!();

View File

@ -3775,10 +3775,17 @@ impl RenderState {
pub fn print_stats(&self) {
self.stats.print();
}
}
impl Drop for RenderState {
fn drop(&mut self) {
pub fn prepare_context_loss_cleanup(&mut self) {
// Drop cached GPU-backed snapshots before dropping the render state.
self.backbuffer_crop_cache.clear();
self.surfaces.invalidate_tile_cache();
// Mark context as abandoned so resource destructors avoid issuing
// GL commands when the browser has already lost/restored the context.
get_gpu_state().context.abandon();
}
pub fn free_gpu_resources(&mut self) {
get_gpu_state().context.free_gpu_resources();
}
}