From 8ff5f78b729324265030f5062111dfd5a609a121 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 22 Oct 2026 15:26:51 +0200 Subject: [PATCH] :bug: Cancel WASM thumbnails when leaving the workspace Cancel the full thumbnail upload/render stream on page or workspace teardown and on matching thumbnail clearing. Deferred library thumbnails can otherwise query a main missing from the next WASM scene and abort the renderer (F112). Complete the upload observable after its callback so successful requests also release their subscriptions. Add async regressions for pending current and library frames, late upload callbacks, clearing, and successful rendering. They fail before the fix. Document the thumbnail lifecycle in the frontend memory. Frontend CI passes lint, 1499 tests / 7272 assertions, and formatting. Live WASM update-main followed by navigation no longer crashes; an uninterrupted library request refreshes its PNG thumbnail too. AI-assisted-by: GPT-6 --- .../workspace-state-persistence-subtleties.md | 5 + .../main/data/workspace/thumbnails_wasm.cljs | 33 ++-- .../data/workspace_thumbnails_wasm_test.cljs | 156 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 4 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_thumbnails_wasm_test.cljs diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index 551cb5e13d..e7ec393bd9 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -40,6 +40,11 @@ - Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50. - Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path. - After local commits and when render-wasm is active, text shapes get derived `:position-data` recomputed in a separate commit tagged `#{:position-data}`; that tag is excluded from the position-data watcher to avoid loops. +- WASM component thumbnails cancel the entire upload/render stream on + `finalize-page`, `finalize-workspace`, or a matching `clear-thumbnail`. + The upload callback must complete its observable. Without these, deferred + library thumbnails can query a main missing from the next workspace's + WASM scene and abort the renderer while `:exception` remains nil. ## Refs diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 4bcfe857f6..70905e8ef6 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -21,6 +21,8 @@ [app.common.thumbnails :as thc] [app.common.time :as ct] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] + [app.main.data.workspace.pages :as-alias dwpg] [app.main.data.workspace.thumbnails :as dwt] [app.main.repo :as rp] [app.main.store :as st] @@ -118,7 +120,10 @@ (map #(vector (:id %) %)) (cfh/get-children-with-self objects frame-id))] (try - (wasm.api/set-objects subtree #(rx/push! subs %)) + (wasm.api/set-objects subtree + (fn [_] + (rx/push! subs nil) + (rx/end! subs))) (catch :default err (rx/error! subs err))))))) @@ -150,17 +155,23 @@ (rx/catch (fn [err] (js/console.error err) (l/error :hint "error rendering component thumbnail" :frame-id (str frame-id)) - (rx/empty))) + (rx/empty))))))] - (rx/take-until - (->> stream - (rx/filter (ptk/type? ::dwt/clear-thumbnail)) - (rx/filter #(= (deref %) object-id)))))))] - - (if (not= page-id (:current-page-id state)) - (->> (load-objects-stream) - (rx/mapcat do-render-thumbnail)) - (do-render-thumbnail)))))))) + ;; The upload and its deferred frame belong to this workspace + ;; scene. Neither may render after navigation replaces it. + (->> (if (not= page-id (:current-page-id state)) + (->> (load-objects-stream) + (rx/mapcat do-render-thumbnail)) + (do-render-thumbnail)) + (rx/take-until + (->> stream + (rx/filter + (fn [event] + (let [type (ptk/type event)] + (or (= type ::dw/finalize-workspace) + (= type ::dwpg/finalize-page) + (and (= type ::dwt/clear-thumbnail) + (= @event object-id))))))))))))))) (defn persist-thumbnail "Persists the current component thumbnail data-uri to the server. diff --git a/frontend/test/frontend_tests/data/workspace_thumbnails_wasm_test.cljs b/frontend/test/frontend_tests/data/workspace_thumbnails_wasm_test.cljs new file mode 100644 index 0000000000..fefbbed9aa --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_thumbnails_wasm_test.cljs @@ -0,0 +1,156 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.data.workspace-thumbnails-wasm-test + (:require + [app.common.thumbnails :as thc] + [app.common.uuid :as uuid] + [app.main.data.workspace.thumbnails :as thumbnails] + [app.main.data.workspace.thumbnails-wasm :as thumbnails-wasm] + [app.main.store :as st] + [app.render-wasm.api :as wasm.api] + [app.util.timers :as timers] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(defn- thumbnail-state + [file-id page-id frame-id] + {:current-file-id file-id + :current-page-id page-id + :files {file-id {:data {:pages-index + {page-id {:objects {frame-id {:id frame-id + :type :frame + :shapes [] + :selrect {:width 100 :height 100}}}}}}}}}) + +(defn- ^:async run-frames + [frames] + (await (a/settle)) + (let [callbacks (vals @frames)] + (reset! frames {}) + (doseq [callback callbacks] + (callback 0))) + (await (a/settle))) + +(defn- ^:async check-cancellation + [stop-type phase] + (let [file-id (uuid/next) + page-id (uuid/next) + frame-id (uuid/next) + ;; Library thumbnails upload their subtree before requesting a frame. + state (cond-> (thumbnail-state file-id page-id frame-id) + (not= phase :current-frame) + (assoc :current-file-id (uuid/next) :current-page-id (uuid/next))) + stream (rx/subject) + frames (atom {}) + uploaded (atom nil) + scene (atom #{frame-id}) + errors (atom []) + result (atom {:events [] :completed false})] + (await + (mock/with-mocks* + {st/state (atom state) + timers/raf (mock/stub (fn [callback] + (let [id (uuid/next)] + (swap! frames assoc id callback) + id))) + timers/cancel-af! (mock/stub #(swap! frames dissoc %)) + wasm.api/set-objects (mock/stub (fn [_ callback] (reset! uploaded callback))) + wasm.api/get-shape-extrect + (mock/stub (fn [id] + (if (contains? @scene id) + {:width 100 :height 100} + (do + (swap! errors conj id) + (throw (js/Error. "Shape not found"))))))} + (let [subscription + (rx/subs! #(swap! result update :events conj %) + #(swap! result assoc :error %) + #(swap! result assoc :completed true) + (ptk/watch (thumbnails-wasm/render-thumbnail file-id page-id frame-id) + state stream))] + (try + (await (a/settle)) + (when (= phase :library-frame) + (@uploaded nil)) + (rx/push! stream + (if (= stop-type ::thumbnails/clear-thumbnail) + (thumbnails/clear-thumbnail + file-id (thc/fmt-object-id file-id page-id frame-id "component")) + (ptk/data-event stop-type))) + ;; The next workspace can still have the library in :files, but + ;; its WASM scene no longer contains the uploaded library main. + (reset! scene #{}) + (swap! st/state assoc :current-file-id (uuid/next) :current-page-id (uuid/next)) + (when (= phase :library-upload) + (@uploaded nil)) + (await (run-frames frames)) + (t/is (empty? @errors) "old thumbnails never query the new WASM scene") + (t/is (= {:events [] :completed true} @result) + "cancellation ends the entire request, including an outstanding upload") + (finally + (rx/dispose! subscription) + (rx/end! stream)))))))) + +(t/deftest ^:async navigation-cancels-a-pending-thumbnail-frame + (await (check-cancellation :app.main.data.workspace/finalize-workspace :current-frame)) + (await (check-cancellation :app.main.data.workspace.pages/finalize-page :current-frame)) + (await (check-cancellation :app.main.data.workspace/finalize-workspace :library-frame)) + (await (check-cancellation :app.main.data.workspace.pages/finalize-page :library-frame))) + +(t/deftest ^:async navigation-cancels-a-pending-library-thumbnail-upload + (await (check-cancellation :app.main.data.workspace/finalize-workspace :library-upload)) + (await (check-cancellation :app.main.data.workspace.pages/finalize-page :library-upload))) + +(t/deftest ^:async clearing-a-thumbnail-cancels-its-frame-or-upload + (await (check-cancellation ::thumbnails/clear-thumbnail :current-frame)) + (await (check-cancellation ::thumbnails/clear-thumbnail :library-upload))) + +(t/deftest ^:async a-library-thumbnail-renders-and-the-request-completes + (let [file-id (uuid/next) + page-id (uuid/next) + frame-id (uuid/next) + state (assoc (thumbnail-state file-id page-id frame-id) + :current-page-id (uuid/next)) + stream (rx/subject) + frames (atom {}) + uploaded (atom nil) + events (atom []) + uri "data:image/png;base64,AQID"] + (await + (mock/with-mocks* + {st/state (atom state) + timers/raf (mock/stub (fn [callback] (swap! frames assoc :frame callback) :frame)) + timers/cancel-af! (mock/stub #(swap! frames dissoc %)) + wasm.api/set-objects (mock/stub (fn [_ callback] (reset! uploaded callback))) + wasm.api/get-shape-extrect (mock/stub (constantly {:width 100 :height 100})) + wasm.api/render-shape-pixels (mock/stub (fn [_ _ _] (js/Uint8Array. #js [1 2 3]))) + (.-FileReader js/globalThis) + (fn [] + (this-as reader + (set! (.-readAsDataURL reader) + (fn [_] + (.then (js/Promise.resolve nil) + (fn [_] + (set! (.-result reader) uri) + ((.-onload reader)))))) + nil))} + (let [finished (a/observe + (ptk/watch (thumbnails-wasm/render-thumbnail file-id page-id frame-id) + state stream) + :on-next #(swap! events conj %))] + (await (a/settle)) + (rx/push! stream (thumbnails/clear-thumbnail file-id "another-thumbnail")) + (@uploaded nil) + (await (run-frames frames)) + (await finished) + (let [result (reduce #(ptk/update %2 %1) state @events) + object-id (thc/fmt-object-id file-id page-id frame-id "component")] + (t/is (= uri (get-in result [:thumbnails object-id :uri])))) + (rx/end! stream)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 30fa34fb00..db5e809a9c 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -51,6 +51,7 @@ [frontend-tests.data.workspace-text-modifier-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.data.workspace-thumbnails-wasm-test] [frontend-tests.data.workspace-undo-test] [frontend-tests.data.workspace-variant-switch-wasm-test] [frontend-tests.data.workspace-variants-test] @@ -226,6 +227,7 @@ 'frontend-tests.data.workspace-sync-wasm-text-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test + 'frontend-tests.data.workspace-thumbnails-wasm-test 'frontend-tests.data.workspace-undo-test 'frontend-tests.data.workspace-variant-switch-wasm-test 'frontend-tests.data.workspace-variants-test