From 0639ca53de5a55d4f843e6c060751e196d0f7ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 11 May 2026 13:15:45 +0200 Subject: [PATCH] :sparkles: Implement WebGL context restoring (#9317) * :sparkles: Implement asset re-uploading to wasm * :sparkles: Show toast instead of error screen when webgl context is lost * :tada: Recover context after webgl context restored event * :tada: Set Read-only mode when the context has been lost * :sparkles: Disable scroll & zoom when context loss * :sparkles: Fix stale reload payload * :sparkles: Use existing debounce util to take screenshots * :sparkles: Implement design / ux specs * :sparkles: Fix playwright test by looking for toast, not error page --- .../playwright/ui/specs/render-wasm.spec.js | 9 +- frontend/src/app/main/data/render_wasm.cljs | 31 +- .../app/main/data/workspace/modifiers.cljs | 8 +- .../src/app/main/data/workspace/viewport.cljs | 23 +- .../src/app/main/data/workspace/zoom.cljs | 109 ++--- frontend/src/app/main/errors.cljs | 6 +- frontend/src/app/main/ui/static.cljs | 7 - .../main/ui/workspace/sidebar/options.cljs | 3 +- .../main/ui/workspace/viewport/top_bar.cljs | 22 +- .../main/ui/workspace/viewport/top_bar.scss | 5 +- .../app/main/ui/workspace/viewport_wasm.cljs | 7 +- frontend/src/app/render_wasm/api.cljs | 382 ++++++++++++++---- frontend/src/app/render_wasm/gesture.cljs | 29 ++ frontend/src/app/render_wasm/wasm.cljs | 14 + frontend/translations/en.po | 16 + frontend/translations/es.po | 14 + render-wasm/src/main.rs | 19 +- render-wasm/src/render.rs | 13 +- 18 files changed, 550 insertions(+), 167 deletions(-) create mode 100644 frontend/src/app/render_wasm/gesture.cljs diff --git a/frontend/playwright/ui/specs/render-wasm.spec.js b/frontend/playwright/ui/specs/render-wasm.spec.js index c764df70b1..3dd82ccf17 100644 --- a/frontend/playwright/ui/specs/render-wasm.spec.js +++ b/frontend/playwright/ui/specs/render-wasm.spec.js @@ -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 ({ diff --git a/frontend/src/app/main/data/render_wasm.cljs b/frontend/src/app/main/data/render_wasm.cljs index e55d98754b..c42ba1f943 100644 --- a/frontend/src/app/main/data/render_wasm.cljs +++ b/frontend/src/app/main/data/render_wasm.cljs @@ -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)))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index cf954eee54..62bc42b50a 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -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 diff --git a/frontend/src/app/main/data/workspace/viewport.cljs b/frontend/src/app/main/data/workspace/viewport.cljs index 4383807a32..bf203c10a7 100644 --- a/frontend/src/app/main/data/workspace/viewport.cljs +++ b/frontend/src/app/main/data/workspace/viewport.cljs @@ -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 diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index e1cb6ec716..2984024803 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -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 diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index eaaebedff2..4d75d9677d 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -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 diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index f426ae0874..07fb10ea17 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -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* diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index 7f44c7502e..59c1afa0f1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs index 2fca82347f..8d6f836b16 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs @@ -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]}] diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss index 5ee297d756..7ce47df666 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss @@ -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 { diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 1420030013..dd07e7ca67 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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)"}}])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 737131d243..e086e8963c 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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] diff --git a/frontend/src/app/render_wasm/gesture.cljs b/frontend/src/app/render_wasm/gesture.cljs new file mode 100644 index 0000000000..2e774e511c --- /dev/null +++ b/frontend/src/app/render_wasm/gesture.cljs @@ -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)) diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index 25c2908575..5bcbfc7b2f 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -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 diff --git a/frontend/translations/en.po b/frontend/translations/en.po index d138b77b3c..989ba1c578 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 2107af50d9..82da6d88d3 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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" diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index bd6e26d1fa..f6a1f74f3c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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!(); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 3df32255b5..f25d3f65bc 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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(); } }