From 448390f8c905da3da139bd496428d18f33051685 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 20 Apr 2026 12:28:42 +0200 Subject: [PATCH] :sparkles: Add lazy async rendering for component thumbnails --- .../app/main/data/workspace/libraries.cljs | 75 ++++++++----------- .../main/data/workspace/thumbnails_wasm.cljs | 25 +++++-- .../ui/workspace/sidebar/assets/common.cljs | 20 ++++- render-wasm/src/render/images.rs | 6 +- 4 files changed, 74 insertions(+), 52 deletions(-) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index ca4362ef5b..28057cfd09 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -979,10 +979,7 @@ ;; These calls are necessary for properly sync thumbnails ;; when a main component does not live in the same page. - ;; When WASM is active, skip the "frame" tag (SVG-based) since - ;; component previews are rendered locally via WASM. - (when-not (features/active-feature? state "render-wasm/v1") - (update-component-thumbnail-sync state component-id file-id "frame")) + (update-component-thumbnail-sync state component-id file-id "frame") (update-component-thumbnail-sync state component-id file-id "component") (sync-file current-file-id file-id :components component-id undo-group) @@ -1007,10 +1004,10 @@ (dwu/commit-undo-transaction undo-id))))))) (defn update-component-thumbnail - "Persist the thumbnail of the component to the server. - For WASM, the UI is already up-to-date from the immediate render in - update-component-thumbnail-sync, so this only persists. - For SVG, this does the full render + persist." + "Update the thumbnail of the component with the given id, in the + current file and in the imported libraries. + For WASM, re-renders and persists to the server in one step. + For SVG, update-thumbnail already handles both render + persist." [component-id file-id] (ptk/reify ::update-component-thumbnail ptk/WatchEvent @@ -1020,7 +1017,7 @@ component (ctkl/get-component data component-id) page-id (:main-instance-page component) root-id (:main-instance-id component)] - (rx/of (dwt.wasm/persist-thumbnail file-id page-id root-id))) + (rx/of (dwt.wasm/render-thumbnail file-id page-id root-id :persist? true))) (rx/of (update-component-thumbnail-sync state component-id file-id "component")))))) (defn- find-shape-index @@ -1379,7 +1376,8 @@ check-changes (fn [[event [old-data _mid_data _new-data]]] - (when old-data + (if (nil? old-data) + (rx/empty) (let [{:keys [file-id changes save-undo? undo-group]} event changed-components @@ -1397,18 +1395,9 @@ (->> (rx/from changed-components) (rx/map #(component-changed % (:id old-data) undo-group)))) ;; even if save-undo? is false, we need to update the :modified-date of the component - ;; (for example, for undos). When WASM is active, also re-render the thumbnail - ;; so undo/redo visually updates component previews. - (->> (mapcat (fn [component-id] - (if (features/active-feature? @st/state "render-wasm/v1") - (let [component (ctkl/get-component old-data component-id)] - [(touch-component component-id) - (dwt.wasm/render-thumbnail (:id old-data) - (:main-instance-page component) - (:main-instance-id component))]) - [(touch-component component-id)])) - changed-components) - (rx/from))) + ;; (for example, for undos) + (->> (rx/from changed-components) + (rx/map touch-component))) (rx/empty))))) @@ -1425,30 +1414,30 @@ (when (or (contains? cf/flags :component-thumbnails) (features/active-feature? @st/state "render-wasm/v1")) - (let [wasm? (features/active-feature? @st/state "render-wasm/v1")] - (->> (rx/merge - changes-s + (->> (rx/merge + changes-s - ;; WASM: render thumbnails immediately for instant UI feedback - (if wasm? - (->> changes-s - (rx/filter (ptk/type? ::component-changed)) - (rx/map deref) - (rx/map (fn [[component-id file-id]] - (update-component-thumbnail-sync @st/state component-id file-id "component")))) - (rx/empty)) + ;; Persist thumbnails to the server in batches after user + ;; becomes inactive for 5 seconds. + (->> changes-s + (rx/filter (ptk/type? ::component-changed)) + (rx/map deref) + (rx/buffer-until notifier-s) + (rx/mapcat #(into #{} %)) + (rx/map (fn [[component-id file-id]] + (update-component-thumbnail component-id file-id)))) - ;; Persist thumbnails to the server in batches after user - ;; becomes inactive for 5 seconds. - (->> changes-s - (rx/filter (ptk/type? ::component-changed)) - (rx/map deref) - (rx/buffer-until notifier-s) - (rx/mapcat #(into #{} %)) - (rx/map (fn [[component-id file-id]] - (update-component-thumbnail component-id file-id))))) + ;; Immediately update the component thumbnail on undos, + ;; which emit touch-component instead of component-changed. + (->> changes-s + (rx/filter (ptk/type? ::touch-component)) + (rx/map deref) + (rx/map (fn [[component-id file-id]] + (let [file-id (or file-id (:current-file-id @st/state))] + (update-component-thumbnail-sync + @st/state component-id file-id "component")))))) - (rx/take-until stopper-s)))))))) + (rx/take-until stopper-s))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Backend interactions diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 3695205985..82bf85cc2c 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -84,9 +84,9 @@ (defn render-thumbnail "Renders a component thumbnail via WASM and updates the UI immediately. - Does NOT persist to the server — persistence is handled separately - by `persist-thumbnail` on a debounced schedule." - [file-id page-id frame-id] + When `persist?` is true, also persists the rendered thumbnail to the + server in the same observable chain (guaranteeing correct ordering)." + [file-id page-id frame-id & {:keys [persist?] :or {persist? false}}] (let [object-id (thc/fmt-object-id file-id page-id frame-id "component")] (ptk/reify ::render-thumbnail @@ -115,15 +115,30 @@ (catch :default err (rx/error! subs err))))))) + (persist-to-server + [data-uri] + (let [blob (wapi/data-uri->blob data-uri)] + (->> (rp/cmd! :create-file-object-thumbnail + {:file-id file-id + :object-id object-id + :media blob + :tag "component"}) + (rx/catch rx/empty) + (rx/ignore)))) + (do-render-thumbnail [] (let [tp (ct/tpoint-ms)] (->> (render-component-pixels file-id page-id frame-id) - (rx/map + (rx/mapcat (fn [data-uri] (l/dbg :hint "component thumbnail rendered (wasm)" :elapsed (dm/str (tp) "ms")) - (dwt/assoc-thumbnail object-id data-uri))) + (if persist? + (rx/merge + (rx/of (dwt/assoc-thumbnail object-id data-uri)) + (persist-to-server data-uri)) + (rx/of (dwt/assoc-thumbnail object-id data-uri))))) (rx/catch (fn [err] (js/console.error err) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index df0fe8c14c..41fd1c4088 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -22,6 +22,7 @@ [app.main.data.modal :as modal] [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.thumbnails-wasm :as dwt.wasm] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] [app.main.features :as features] @@ -283,6 +284,9 @@ (let [page-id (:main-instance-page component) root-id (:main-instance-id component) retry (mf/use-state 0) + wasm? (features/active-feature? @st/state "render-wasm/v1") + current-page-id (mf/deref refs/current-page-id) + thumbnail-requested? (mf/use-ref false) thumbnail-uri* (mf/with-memo [file-id page-id root-id] @@ -299,9 +303,23 @@ (when (< @retry 3) (inc retry))))] + ;; Lazy WASM thumbnail rendering: when the component becomes + ;; visible, has no cached thumbnail, and lives on the current page + ;; trigger a render. Ref is used to avoid triggering multiple renders + ;; while the component is still not rendered and the thumbnail URI + ;; is not available. + (mf/use-effect + (mf/deps is-hidden thumbnail-uri wasm? current-page-id) + (fn [] + (if (some? thumbnail-uri) + (mf/set-ref-val! thumbnail-requested? false) + (when (and wasm? (not is-hidden) (not (mf/ref-val thumbnail-requested?)) (= page-id current-page-id)) + (mf/set-ref-val! thumbnail-requested? true) + (st/emit! (dwt.wasm/render-thumbnail file-id page-id root-id)))))) + (if (and (some? thumbnail-uri) (or (contains? cf/flags :component-thumbnails) - (features/active-feature? @st/state "render-wasm/v1"))) + wasm?)) [:& component-svg-thumbnail {:thumbnail-uri thumbnail-uri :class class diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 51bf9dbbe0..e1c66b2a51 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -2,7 +2,7 @@ use crate::math::Rect as MathRect; use crate::shapes::ImageFill; use crate::uuid::Uuid; -use crate::error::{Error, Result}; +use crate::error::Result; use skia_safe::gpu::{surfaces, Budgeted, DirectContext}; use skia_safe::{self as skia, Codec, ISize}; use std::collections::HashMap; @@ -159,7 +159,7 @@ impl ImageStore { let key = (id, is_thumbnail); if self.images.contains_key(&key) { - return Err(Error::RecoverableError("Image already exists".to_string())); + return Ok(()); } let raw_data = image_data.to_vec(); @@ -186,7 +186,7 @@ impl ImageStore { let key = (id, is_thumbnail); if self.images.contains_key(&key) { - return Err(Error::RecoverableError("Image already exists".to_string())); + return Ok(()); } // Create a Skia image from the existing GL texture