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