From b38912f3cb1afa1fbd9e97924c9367c5c22350c7 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Thu, 16 Apr 2026 18:20:44 +0200 Subject: [PATCH 01/14] :wrench: Add short tag to DocherHub release (#8864) --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21c0eb6de2..053dd3ff0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,13 +64,14 @@ jobs: echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io IMAGES=("frontend" "backend" "exporter" "storybook") + SHORT_TAG=${TAG%.*} for image in "${IMAGES[@]}"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$TAG - for alias in main latest; do + for alias in main latest "$SHORT_TAG"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$alias From eeeb698d91ad0791cb3bc519c8d3caffbe98ff4e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 20 Apr 2026 18:05:51 +0000 Subject: [PATCH 02/14] :arrow_up: Bump opencode-ai dev dependency 1.4.3 -> 1.14.19 Signed-off-by: Andrey Antukh --- package.json | 2 +- pnpm-lock.yaml | 106 ++++++++++++++++++++++++------------------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 26baa0d989..fbb4c5d92f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,6 @@ "@github/copilot": "^1.0.21", "@types/node": "^25.5.2", "esbuild": "^0.28.0", - "opencode-ai": "^1.4.3" + "opencode-ai": "^1.14.19" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 276b2891e2..32b4fa3382 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.28.0 version: 0.28.0 opencode-ai: - specifier: ^1.4.3 - version: 1.4.3 + specifier: ^1.14.19 + version: 1.14.19 packages: @@ -227,67 +227,67 @@ packages: engines: {node: '>=18'} hasBin: true - opencode-ai@1.4.3: - resolution: {integrity: sha512-WwCSrLgJiS+sLIWoi9pa62vAw3l6VI3a+ShhjDDMUJBBG2FxU18xEhk8xhEedLMKyHo1p0nwD41+iKZ1y+rdAw==} + opencode-ai@1.14.19: + resolution: {integrity: sha512-67h56qYcJivd2U9VK8LJvyMBCc3ZE3HcJ/qL4YtaidSnjEumy4SxO+HHlDISsLase7TUQ0w1nULOAibdZxGzbQ==} hasBin: true - opencode-darwin-arm64@1.4.3: - resolution: {integrity: sha512-d/MT28Is5yhdFY+36AqKc5r31zx8lXTQIYblfn5R8kdhamXijZVGdD0pHl3eJc1ZolUHNwzg2B+IqV22uyU9GQ==} + opencode-darwin-arm64@1.14.19: + resolution: {integrity: sha512-gjJ97dTiBbCas3Y7K6KQMQm5/KNIBOM9+10KZHqNr2bQfn0N09O977ZkXoX6IVBBE2632Ahaa71d4pzLKN9ULw==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.4.3: - resolution: {integrity: sha512-WTqf7WBNRZcv6pClqnN4F7X/T/osgcPGikNHkHUSLszKWg9flqz7Z68kHR4i9ae8Bn3ke9MQRgzRdOt2PgLL0w==} + opencode-darwin-x64-baseline@1.14.19: + resolution: {integrity: sha512-UuwLpa511h7qQ+rTmOUmsHch/N4NBQT64dzg+iLWnR5yoR/81inENpbwxiS7hXpVdzCUGo/YnxI1u6SIBoMlTQ==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.4.3: - resolution: {integrity: sha512-8FUHeybVmaCYt4S2YmWcf32o/xa/ahCfI258bpWssrzs7Xg51JgUB/Csoble0I1mH7RpW39SKy/hHUtHGuJfJg==} + opencode-darwin-x64@1.14.19: + resolution: {integrity: sha512-uksrjOtWI7Ob5JvjZBSsrKuy3JVF9d89oYZYfWS5m8ordNyv1Nob39MXJXizv85ozsXjSb0rqjpJurnJw8K+tQ==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.4.3: - resolution: {integrity: sha512-3Ej2klaep8+fxcc44UyEuRpb/UFiNkwfzIDLIST83hFUtjzprjpTRqg6zHmOfzyfjNAaNpB4VZw6e9y3mGBpiQ==} + opencode-linux-arm64-musl@1.14.19: + resolution: {integrity: sha512-R1BJuBGWHfBxfKvIA/Hb4nhYaJgCKl1B+mAGNydu+z0CLtGtwU8r+kQWF/G2N0y8Vx6Y6DRfJiv1X0eZEfD1BQ==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.4.3: - resolution: {integrity: sha512-9jpVSOEF7TX3gPPAHVAsBT9XEO3LgYafI+IUmOzbBB9CDiVVNJw6JmEffmSpSxY4nkAh322xnMbNjVGEyXQBRA==} + opencode-linux-arm64@1.14.19: + resolution: {integrity: sha512-Bo+aZOppLF366mgGfK0CnIcAVy1EmsrBv93eot1CmPSN1oeud07GpGdn3Bjl5f6KuBx1io6JsvjQyHno+MH5AA==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.4.3: - resolution: {integrity: sha512-aned/3FQTHXXQv2PPKDprJwQaQkoadriQ6AByGhRl6/bHhSkhkiVl6cHHvYMKxYEwN4bVOydWhasfgm/xru/xw==} + opencode-linux-x64-baseline-musl@1.14.19: + resolution: {integrity: sha512-+K8MuGoHugtUec4P/nKcTwZFUipHfW7oPpwlIoPiAQou3bNFTzzP6rslbzzNwjXlQRsUw9GAtuIPDOCL6CkgDg==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.4.3: - resolution: {integrity: sha512-HpzdgYaI90qqt0WokcyBhadgFQ0EYMhq4TZ4EcaSPuZTssS2Drb6kp70Si54uOJL/MUAdc9+E0BYYIAdOJ6h1g==} + opencode-linux-x64-baseline@1.14.19: + resolution: {integrity: sha512-KuvITzg4iK0hdIjpNZepwu3bLZ/dUZDI6BwCoV4w//VEP1j3UfDyeS3vWghKcQLd2T1+yybuEMM/3RXcwm/lGQ==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.4.3: - resolution: {integrity: sha512-ibUevyDxVrwkp6FWu8UBCBsrzlKDT/uEug2NHCKaHIwo9uwVf5zsL/0ueHYqmH14SHK+M6wzWewYk6WuW9f0zQ==} + opencode-linux-x64-musl@1.14.19: + resolution: {integrity: sha512-0qe2+X76UJdrCdhdlJyfubMC4tveHAVxjSmPq7g9Zm95heBeJdcQDCLeyQk/lGgeXgsZzVPfLmyTWNtBvCZYFQ==} cpu: [x64] os: [linux] - opencode-linux-x64@1.4.3: - resolution: {integrity: sha512-RS6TsDqTUrW5sefxD1KD9Xy9mSYGXAlr2DlGrdi8vNm9e/Bt4r4u557VB7f/Uj2CxTt2Gf7OWl08ZoPlxMJ5Gg==} + opencode-linux-x64@1.14.19: + resolution: {integrity: sha512-2GljfL7BeG4xALBJVRwaAGCM/dzYF5aQf6bfLTKsQIl6QpLUguYSF+fkStBHLeehyqbDP5MtiEEuXjC0+mecjA==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.4.3: - resolution: {integrity: sha512-2ViH17WpIQbRVfQaOBMi49pu73gqTQYT/4/WxFjShmRagX40/KkG18fhvyDAZrBKfkhPtdwgFsFxMSYP9F6QCQ==} + opencode-windows-arm64@1.14.19: + resolution: {integrity: sha512-8/vRHe5tHexikfPceLmpjsQiEhuDTOSCSlEmP4s0Yq3UAkVaDAxpiWq7Bx4g8hjr1gzfXD9vbjV+WHq/BtMC/w==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.4.3: - resolution: {integrity: sha512-SWYDli9SAKQd/pS/hVfuq1KEsc+gnAJdv+YtBmxaHOw57y0euqLwbGFUYFq78GAMGt/RnTYWZIEUbRK/ZiX3UA==} + opencode-windows-x64-baseline@1.14.19: + resolution: {integrity: sha512-Z8imEJK/srE/r1fr7oNLvpLTeRJQyuL7vsbXvCt3T7j2Ew9BOZ7RuYa8EE0R6bNqQ+MLhBGPiAG7NWc++MgK8Q==} cpu: [x64] os: [win32] - opencode-windows-x64@1.4.3: - resolution: {integrity: sha512-UxmKDIw3t4XHST6JSUWHmSrCGIEK1LRTAOpO82HBC3XkIjH78gVIeauRR6RULjWAApmy9I1C3TukO2sDUi7Gvw==} + opencode-windows-x64@1.14.19: + resolution: {integrity: sha512-/TqGN91WiUzx7IPMPwmpMIzRixi5TMjaBcC9FH1TgD7DCqKnP6pokvu+ak0C9xwA4wKorE9PoZzeRt/+c3rDCQ==} cpu: [x64] os: [win32] @@ -434,55 +434,55 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 - opencode-ai@1.4.3: + opencode-ai@1.14.19: optionalDependencies: - opencode-darwin-arm64: 1.4.3 - opencode-darwin-x64: 1.4.3 - opencode-darwin-x64-baseline: 1.4.3 - opencode-linux-arm64: 1.4.3 - opencode-linux-arm64-musl: 1.4.3 - opencode-linux-x64: 1.4.3 - opencode-linux-x64-baseline: 1.4.3 - opencode-linux-x64-baseline-musl: 1.4.3 - opencode-linux-x64-musl: 1.4.3 - opencode-windows-arm64: 1.4.3 - opencode-windows-x64: 1.4.3 - opencode-windows-x64-baseline: 1.4.3 + opencode-darwin-arm64: 1.14.19 + opencode-darwin-x64: 1.14.19 + opencode-darwin-x64-baseline: 1.14.19 + opencode-linux-arm64: 1.14.19 + opencode-linux-arm64-musl: 1.14.19 + opencode-linux-x64: 1.14.19 + opencode-linux-x64-baseline: 1.14.19 + opencode-linux-x64-baseline-musl: 1.14.19 + opencode-linux-x64-musl: 1.14.19 + opencode-windows-arm64: 1.14.19 + opencode-windows-x64: 1.14.19 + opencode-windows-x64-baseline: 1.14.19 - opencode-darwin-arm64@1.4.3: + opencode-darwin-arm64@1.14.19: optional: true - opencode-darwin-x64-baseline@1.4.3: + opencode-darwin-x64-baseline@1.14.19: optional: true - opencode-darwin-x64@1.4.3: + opencode-darwin-x64@1.14.19: optional: true - opencode-linux-arm64-musl@1.4.3: + opencode-linux-arm64-musl@1.14.19: optional: true - opencode-linux-arm64@1.4.3: + opencode-linux-arm64@1.14.19: optional: true - opencode-linux-x64-baseline-musl@1.4.3: + opencode-linux-x64-baseline-musl@1.14.19: optional: true - opencode-linux-x64-baseline@1.4.3: + opencode-linux-x64-baseline@1.14.19: optional: true - opencode-linux-x64-musl@1.4.3: + opencode-linux-x64-musl@1.14.19: optional: true - opencode-linux-x64@1.4.3: + opencode-linux-x64@1.14.19: optional: true - opencode-windows-arm64@1.4.3: + opencode-windows-arm64@1.14.19: optional: true - opencode-windows-x64-baseline@1.4.3: + opencode-windows-x64-baseline@1.14.19: optional: true - opencode-windows-x64@1.4.3: + opencode-windows-x64@1.14.19: optional: true undici-types@7.18.2: {} From 98c8bb1746cd34b695ba9c0df9befc5a49abdfdf Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 21 Apr 2026 07:45:27 +0200 Subject: [PATCH 03/14] :bug: Avoid sequential tile draws and flicker during shape transforms --- .../app/main/data/workspace/modifiers.cljs | 29 ++++++++++ frontend/src/app/render_wasm/api.cljs | 17 ++++++ render-wasm/src/main.rs | 36 ++++++++++++ render-wasm/src/render.rs | 56 ++++++++++++++++--- render-wasm/src/render/options.rs | 26 +++++++++ 5 files changed, 156 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index c7181abbaf..ed8e44e3ca 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -38,6 +38,23 @@ (def ^:private xf:without-uuid-zero (remove #(= % uuid/zero))) +;; Tracks whether the WASM renderer is currently in "interactive +;; transform" mode (a drag / resize / rotate gesture in progress). +;; 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)) + +(defn- ensure-interactive-transform-start! + [] + (when (compare-and-set! interactive-transform-active? false true) + (wasm.api/set-modifiers-start))) + +(defn- ensure-interactive-transform-end! + [] + (when (compare-and-set! interactive-transform-active? true false) + (wasm.api/set-modifiers-end))) + (def ^:private transform-attrs #{:selrect :points @@ -279,6 +296,11 @@ ptk/EffectEvent (effect [_ state _] (when (features/active-feature? state "render-wasm/v1") + ;; End interactive transform mode BEFORE cleaning modifiers so + ;; the final full-quality render triggered by subsequent shape + ;; updates is not still classified as "interactive" (which would + ;; skip shadows / blur). + (ensure-interactive-transform-end!) (wasm.api/clean-modifiers) (set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) []))) @@ -624,6 +646,12 @@ ptk/WatchEvent (watch [_ state _] + ;; Entering an interactive transform (drag/resize/rotate). Flip + ;; the renderer into fast + atlas-backdrop mode so the live + ;; preview is cheap, tiles never appear sequentially and the main + ;; thread is not blocked. The pair is closed in + ;; `clear-local-transform`. + (ensure-interactive-transform-start!) (wasm.api/clean-modifiers) (let [prev-wasm-props (:prev-wasm-props state) wasm-props (:wasm-props state) @@ -764,6 +792,7 @@ (ptk/reify ::set-wasm-rotation-modifiers ptk/EffectEvent (effect [_ state _] + (ensure-interactive-transform-start!) (let [objects (dsh/lookup-page-objects state) ids (sequence xf-rotation-shape shapes) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index ec064493e3..0be80c8c8a 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1517,6 +1517,23 @@ [] (h/call wasm/internal-module "_clean_modifiers")) +(defn set-modifiers-start + "Enter interactive transform mode (drag / resize / rotate). Enables + fast-mode effect skipping in the renderer and activates an atlas + backdrop so tiles do not appear sequentially or flicker while the + gesture is in progress." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_set_modifiers_start"))) + +(defn set-modifiers-end + "Leave interactive transform mode. Cancels any pending async render + scheduled under it; the caller is expected to trigger a full-quality + render (via `request-render`) once the gesture is committed." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_set_modifiers_end"))) + (defn set-modifiers [modifiers] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ee3a7815f3..7a030e114d 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -401,6 +401,42 @@ pub extern "C" fn set_view_end() -> Result<()> { Ok(()) } +/// Enter interactive transform mode (drag / resize / rotate of a +/// shape). Activates the same expensive-effect skipping as pan/zoom +/// (`fast_mode`) but keeps per-frame flushing enabled so the Target is +/// presented every rAF, and triggers atlas-backed backdrops so +/// invalidated tiles do not appear sequentially or flicker. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_modifiers_start() -> Result<()> { + with_state_mut!(state, { + performance::begin_measure!("set_modifiers_start"); + let opts = &mut state.render_state.options; + opts.set_fast_mode(true); + opts.set_interactive_transform(true); + performance::end_measure!("set_modifiers_start"); + }); + Ok(()) +} + +/// Leave interactive transform mode and cancel any pending async +/// render scheduled under it. The caller is responsible for triggering +/// a final full-quality render (typically via `_render`) once the +/// modifiers have been committed. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_modifiers_end() -> Result<()> { + with_state_mut!(state, { + performance::begin_measure!("set_modifiers_end"); + let opts = &mut state.render_state.options; + opts.set_fast_mode(false); + opts.set_interactive_transform(false); + state.render_state.cancel_animation_frame(); + performance::end_measure!("set_modifiers_end"); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn clear_focus_mode() -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5df0326ace..8684e0f112 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1666,6 +1666,24 @@ impl RenderState { self.cache_cleared_this_render = false; self.reset_canvas(); + + // During an interactive shape transform (drag/resize/rotate) the + // Target is repainted tile-by-tile. If only a subset of the + // invalidated tiles finishes in this rAF the remaining area + // would either show stale content from the previous frame or, + // on buffer swaps, show blank pixels — either way the user + // perceives tiles appearing sequentially. Paint the persistent + // 1:1 atlas as a stable backdrop so every flush presents a + // coherent picture: unchanged tiles come from the atlas and + // invalidated tiles are overwritten on top as they finish. + if self.options.is_interactive_transform() && self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + } + let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 @@ -1744,12 +1762,16 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; } - // In fast mode (pan/zoom in progress), render_from_cache owns - // the Target surface — skip flush so we don't present stale - // tile positions. The rAF still populates the Cache surface - // and tile HashMap so render_from_cache progressively shows - // more complete content. - if !self.options.is_fast_mode() { + // In a pure viewport interaction (pan/zoom), render_from_cache + // owns the Target surface — skip flush so we don't present + // stale tile positions. The rAF still populates the Cache + // surface and tile HashMap so render_from_cache progressively + // shows more complete content. + // + // During interactive shape transforms (drag/resize/rotate) we + // still need to flush every rAF so the user sees the updated + // shape position — render_from_cache is not in the loop here. + if !self.options.is_viewport_interaction() { self.flush_and_submit(); } @@ -1887,8 +1909,26 @@ impl RenderState { #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { - iteration % NODE_BATCH_THRESHOLD == 0 - && performance::get_time() - timestamp > MAX_BLOCKING_TIME_MS + if iteration % NODE_BATCH_THRESHOLD != 0 { + return false; + } + if performance::get_time() - timestamp <= MAX_BLOCKING_TIME_MS { + return false; + } + + // During interactive shape transforms we must complete every + // visible tile in a single rAF so the user never sees tiles + // popping in sequentially. Only yield once all visible work is + // done and we are processing the interest-area pre-render. + if self.options.is_interactive_transform() { + if let Some(tile) = self.current_tile { + if self.tile_viewbox.is_visible(&tile) { + return false; + } + } + } + + true } #[inline] diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 27454ec90f..f6072f964f 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -9,6 +9,11 @@ pub struct RenderOptions { pub flags: u32, pub dpr: Option, fast_mode: bool, + /// Active while the user is interacting with a shape (drag, resize, + /// rotate). Implies `fast_mode` semantics for expensive effects but + /// keeps per-frame flushing enabled (unlike pan/zoom, where + /// `render_from_cache` drives target presentation). + interactive_transform: bool, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, } @@ -19,6 +24,7 @@ impl Default for RenderOptions { flags: 0, dpr: None, fast_mode: false, + interactive_transform: false, antialias_threshold: 7.0, } } @@ -42,6 +48,26 @@ impl RenderOptions { self.fast_mode = enabled; } + /// Interactive transform is ON while the user is dragging, resizing + /// or rotating a shape. Callers use it to keep per-frame flushing + /// enabled and to render visible tiles in a single frame so tiles + /// never appear sequentially or flicker during the gesture. + pub fn is_interactive_transform(&self) -> bool { + self.interactive_transform + } + + pub fn set_interactive_transform(&mut self, enabled: bool) { + self.interactive_transform = enabled; + } + + /// True only when the viewport is the one being moved (pan/zoom) + /// and the dedicated `render_from_cache` path owns Target + /// presentation. In this mode `process_animation_frame` must not + /// flush to avoid presenting stale tile positions. + pub fn is_viewport_interaction(&self) -> bool { + self.fast_mode && !self.interactive_transform + } + pub fn dpr(&self) -> f32 { self.dpr.unwrap_or(1.0) } From 7efc4d6d53a7fd2306b619e89c6b26b018618a90 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 20 Apr 2026 12:14:43 +0200 Subject: [PATCH 04/14] :bug: Fix caret dimensions --- render-wasm/src/render/text_editor.rs | 26 ++++++++++++++++++++++---- render-wasm/src/state/text_editor.rs | 3 --- render-wasm/src/wasm/text_editor.rs | 23 +++++++++-------------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index a9acc7bd57..9478e8a0eb 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,9 +1,17 @@ +use crate::render::options::RenderOptions; use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; use crate::state::{TextEditorState, TextSelection}; +use crate::view::Viewbox; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; use skia_safe::{BlendMode, Canvas, Paint, Rect}; -pub fn render_overlay(canvas: &Canvas, editor_state: &TextEditorState, shape: &Shape) { +pub fn render_overlay( + canvas: &Canvas, + viewbox: &Viewbox, + options: &RenderOptions, + editor_state: &TextEditorState, + shape: &Shape, +) { if !editor_state.has_focus { return; } @@ -13,17 +21,24 @@ pub fn render_overlay(canvas: &Canvas, editor_state: &TextEditorState, shape: &S }; canvas.save(); + let zoom = viewbox.zoom * options.dpr(); + canvas.scale((zoom, zoom)); + canvas.translate((-viewbox.area.left, -viewbox.area.top)); + if editor_state.selection.is_selection() { render_selection(canvas, editor_state, text_content, shape); } + if editor_state.cursor_visible { - render_cursor(canvas, editor_state, text_content, shape); + render_cursor(canvas, zoom, editor_state, text_content, shape); } + canvas.restore(); } fn render_cursor( canvas: &Canvas, + zoom: f32, editor_state: &TextEditorState, text_content: &TextContent, shape: &Shape, @@ -32,6 +47,9 @@ fn render_cursor( return; }; + let mut cursor_rect = Rect::new_empty(); + cursor_rect.set_xywh(rect.x(), rect.y(), 1.5 / zoom, rect.height()); + let mut paint = Paint::default(); paint.set_color(editor_state.theme.cursor_color); paint.set_anti_alias(true); @@ -39,7 +57,7 @@ fn render_cursor( let shape_matrix = shape.get_matrix(); canvas.save(); canvas.concat(&shape_matrix); - canvas.draw_rect(rect, &paint); + canvas.draw_rect(cursor_rect, &paint); canvas.restore(); } @@ -160,7 +178,7 @@ fn calculate_cursor_rect( return Some(Rect::from_xywh( cursor_x, y_offset + cursor_y, - editor_state.theme.cursor_width, + 1.0, // cursor_width cursor_height, )); } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 0f89a25d41..6bba86b3a3 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -100,7 +100,6 @@ pub enum TextEditorEvent { /// FIXME: It should be better to get these constants from the frontend through the API. const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184); -const CURSOR_WIDTH: f32 = 1.5; const CURSOR_COLOR: Color = Color::BLACK; const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; @@ -257,7 +256,6 @@ impl TextEditorStyles { pub struct TextEditorTheme { pub selection_color: Color, - pub cursor_width: f32, pub cursor_color: Color, } @@ -340,7 +338,6 @@ impl TextEditorState { Self { theme: TextEditorTheme { selection_color: SELECTION_COLOR, - cursor_width: CURSOR_WIDTH, cursor_color: CURSOR_COLOR, }, selection: TextSelection::new(), diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 21c8566ec1..9db9910ee2 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -32,16 +32,11 @@ pub enum CursorDirection { // ============================================================================ #[no_mangle] -pub extern "C" fn text_editor_apply_theme( - selection_color: u32, - cursor_width: f32, - cursor_color: u32, -) { +pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32) { with_state_mut!(state, { // NOTE: In the future could be interesting to fill al this data from // a structure pointer. state.text_editor_state.theme.selection_color = Color::new(selection_color); - state.text_editor_state.theme.cursor_width = cursor_width; state.text_editor_state.theme.cursor_color = Color::new(cursor_color); }) } @@ -912,13 +907,14 @@ pub extern "C" fn text_editor_render_overlay() { }; let canvas = state.render_state.surfaces.canvas(SurfaceId::Target); - canvas.save(); let viewbox = state.render_state.viewbox; - let zoom = viewbox.zoom * state.render_state.options.dpr(); - canvas.scale((zoom, zoom)); - canvas.translate((-viewbox.area.left, -viewbox.area.top)); - text_editor_render::render_overlay(canvas, &state.text_editor_state, shape); - canvas.restore(); + text_editor_render::render_overlay( + canvas, + &viewbox, + &state.render_state.options, + &state.text_editor_state, + shape, + ); state.render_state.flush_and_submit(); }); } @@ -1103,12 +1099,11 @@ fn get_cursor_rect( (pos.position as f32, height) }; - let cursor_width = 2.0; let selrect = shape.selrect(); let base_x = selrect.x(); let base_y = selrect.y() + y_offset; - return Some(Rect::from_xywh(base_x + x, base_y, cursor_width, height)); + return Some(Rect::from_xywh(base_x + x, base_y, 1.0, height)); } y_offset += laid_out_para.height(); } From 88ec9e4ff172166c065893ab862890fb84bdde95 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 16 Apr 2026 13:55:28 +0200 Subject: [PATCH 05/14] :bug: Fix problem with store font after cleanup --- frontend/src/app/render_wasm/api/fonts.cljs | 35 +++++++++++---------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 3c011cd3db..b46bf6cafc 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -121,24 +121,25 @@ ;; IMPORTANT: Only TTF fonts can be stored. (defn- store-font-buffer [font-data font-array-buffer emoji? fallback?] - (let [font-id-buffer (:family-id-buffer font-data) - size (.-byteLength font-array-buffer) - ptr (h/call wasm/internal-module "_alloc_bytes" size) - heap (gobj/get ^js wasm/internal-module "HEAPU8") - mem (js/Uint8Array. (.-buffer heap) ptr size)] + (when wasm/context-initialized? + (let [font-id-buffer (:family-id-buffer font-data) + size (.-byteLength font-array-buffer) + ptr (h/call wasm/internal-module "_alloc_bytes" size) + heap (gobj/get ^js wasm/internal-module "HEAPU8") + mem (js/Uint8Array. (.-buffer heap) ptr size)] - (.set mem (js/Uint8Array. font-array-buffer)) - (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) - (h/call wasm/internal-module "_store_font" - (aget font-id-buffer 0) - (aget font-id-buffer 1) - (aget font-id-buffer 2) - (aget font-id-buffer 3) - (:weight font-data) - (:style font-data) - emoji? - fallback?) - true)) + (.set mem (js/Uint8Array. font-array-buffer)) + (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) + (h/call wasm/internal-module "_store_font" + (aget font-id-buffer 0) + (aget font-id-buffer 1) + (aget font-id-buffer 2) + (aget font-id-buffer 3) + (:weight font-data) + (:style font-data) + emoji? + fallback?) + true))) ;; Tracks fonts currently being fetched: {url -> fallback?} ;; When the same font is requested as both primary and fallback, From 62ec66b97469b24bb99b73e083312a5ae78503f6 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 20 Apr 2026 12:28:42 +0200 Subject: [PATCH 06/14] :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 From 612855452a40d764a13aa7c7f02828792d0e46d0 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 20 Apr 2026 12:51:38 +0200 Subject: [PATCH 07/14] :tada: Add render perf options --- frontend/src/app/render_wasm/api.cljs | 44 ++++++++++++++++++ render-wasm/src/main.rs | 42 ++++++++++++++++++ render-wasm/src/render.rs | 64 +++++++++++++++++---------- render-wasm/src/render/options.rs | 42 +++++++++++++++++- 4 files changed, 168 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0be80c8c8a..a68d3d1b24 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1599,6 +1599,42 @@ (when (and (number? n) (not (js/isNaN n)) (pos? n)) n)))) +(defn- wasm-blur-downscale-threshold-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:blur_downscale_threshold p))] + (let [n (if (string? raw) (js/parseFloat raw) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + +(defn- wasm-max-blocking-time-ms-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:max_blocking_time_ms p))] + (let [n (if (string? raw) (js/parseInt raw 10) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + +(defn- wasm-node-batch-threshold-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:node_batch_threshold p))] + (let [n (if (string? raw) (js/parseInt raw 10) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + +(defn- wasm-viewport-interest-area-threshold-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:viewport_interest_area_threshold p))] + (let [n (if (string? raw) (js/parseInt raw 10) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + (defn set-canvas-size [canvas] (let [width (or (.-clientWidth ^js canvas) (.-width ^js canvas)) @@ -1636,6 +1672,14 @@ (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)) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 7a030e114d..957955d3e0 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -145,6 +145,48 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_viewport_interest_area_threshold( + viewport_interest_area_threshold: i32, +) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_max_blocking_time_ms(max_blocking_time_ms); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_node_batch_threshold(node_batch_threshold); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_blur_downscale_threshold(blur_downscale_threshold); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8684e0f112..6272d8d9a3 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -37,15 +37,6 @@ use crate::wapi; pub use fonts::*; pub use images::*; -// This is the extra area used for tile rendering (tiles beyond viewport). -// Higher values pre-render more tiles, reducing empty squares during pan but using more memory. -const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; - -const MAX_BLOCKING_TIME_MS: i32 = 32; -const NODE_BATCH_THRESHOLD: i32 = 3; - -const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; - type ClipStack = Vec<(Rect, Option, Matrix)>; #[derive(Debug)] @@ -345,9 +336,8 @@ pub(crate) struct RenderState { pub cache_cleared_this_render: bool, } -pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { +pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. - let interest = VIEWPORT_INTEREST_AREA_THRESHOLD; let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest(viewbox, interest, scale); @@ -382,10 +372,11 @@ impl RenderState { let viewbox = Viewbox::new(width as f32, height as f32); let tiles = tiles::TileHashMap::new(); + let options = RenderOptions::default(); Ok(RenderState { gpu_state: gpu_state.clone(), - options: RenderOptions::default(), + options, surfaces, fonts, viewbox, @@ -402,7 +393,7 @@ impl RenderState { tiles, tile_viewbox: tiles::TileViewbox::new_with_interest( viewbox, - VIEWPORT_INTEREST_AREA_THRESHOLD, + options.viewport_interest_area_threshold, 1.0, ), pending_tiles: PendingTiles::new_empty(), @@ -631,6 +622,22 @@ impl RenderState { self.options.set_antialias_threshold(value); } + pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { + self.options.set_viewport_interest_area_threshold(value); + } + + pub fn set_node_batch_threshold(&mut self, value: i32) { + self.options.set_node_batch_threshold(value); + } + + pub fn set_max_blocking_time_ms(&mut self, value: i32) { + self.options.set_max_blocking_time_ms(value); + } + + pub fn set_blur_downscale_threshold(&mut self, value: f32) { + self.options.set_blur_downscale_threshold(value); + } + pub fn set_background_color(&mut self, color: skia::Color) { self.background_color = color; } @@ -1495,7 +1502,7 @@ impl RenderState { // Scale and translate the target according to the cached data let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; - let interest = VIEWPORT_INTEREST_AREA_THRESHOLD; + let interest = self.options.viewport_interest_area_threshold; let TileRect(start_tile_x, start_tile_y, _, _) = tiles::get_tiles_for_viewbox_with_interest( self.cached_viewbox, @@ -1692,15 +1699,25 @@ impl RenderState { s.canvas().scale((scale, scale)); }); - let viewbox_cache_size = get_cache_size(self.viewbox, scale); - let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale); + let viewbox_cache_size = get_cache_size( + self.viewbox, + scale, + self.options.viewport_interest_area_threshold, + ); + let cached_viewbox_cache_size = get_cache_size( + self.cached_viewbox, + scale, + self.options.viewport_interest_area_threshold, + ); // Only resize cache if the new size is larger than the cached size // This avoids unnecessary surface recreations when the cache size decreases if viewbox_cache_size.width > cached_viewbox_cache_size.width || viewbox_cache_size.height > cached_viewbox_cache_size.height { - self.surfaces - .resize_cache(viewbox_cache_size, VIEWPORT_INTEREST_AREA_THRESHOLD)?; + self.surfaces.resize_cache( + viewbox_cache_size, + self.options.viewport_interest_area_threshold, + )?; } // FIXME - review debug @@ -1909,10 +1926,10 @@ impl RenderState { #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { - if iteration % NODE_BATCH_THRESHOLD != 0 { + if iteration % self.options.node_batch_threshold != 0 { return false; } - if performance::get_time() - timestamp <= MAX_BLOCKING_TIME_MS { + if performance::get_time() - timestamp <= self.options.max_blocking_time_ms { return false; } @@ -2303,9 +2320,10 @@ impl RenderState { // Bounds above were computed from the original sigma so filter surface coverage is correct. // Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the // filter surface becomes too small and quality degrades noticeably. - const MIN_BLUR_DOWNSCALE: f32 = 1.0 / BLUR_DOWNSCALE_THRESHOLD; - let blur_downscale = if shadow.blur > BLUR_DOWNSCALE_THRESHOLD { - (BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE) + let blur_downscale_threshold: f32 = self.options.blur_downscale_threshold; + let min_blur_downscale: f32 = 1.0 / blur_downscale_threshold; + let blur_downscale = if shadow.blur > blur_downscale_threshold { + (blur_downscale_threshold / shadow.blur).max(min_blur_downscale) } else { 1.0 }; diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index f6072f964f..40a3125ccd 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -4,6 +4,14 @@ const PROFILE_REBUILD_TILES: u32 = 0x02; const TEXT_EDITOR_V3: u32 = 0x04; const SHOW_WASM_INFO: u32 = 0x08; +// Render performance options +// This is the extra area used for tile rendering (tiles beyond viewport). +// Higher values pre-render more tiles, reducing empty squares during pan but using more memory. +const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; +const MAX_BLOCKING_TIME_MS: i32 = 32; +const NODE_BATCH_THRESHOLD: i32 = 3; +const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; +const ANTIALIAS_THRESHOLD: f32 = 7.0; #[derive(Debug, Copy, Clone, PartialEq)] pub struct RenderOptions { pub flags: u32, @@ -16,6 +24,10 @@ pub struct RenderOptions { interactive_transform: bool, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, + pub viewport_interest_area_threshold: i32, + pub max_blocking_time_ms: i32, + pub node_batch_threshold: i32, + pub blur_downscale_threshold: f32, } impl Default for RenderOptions { @@ -25,7 +37,11 @@ impl Default for RenderOptions { dpr: None, fast_mode: false, interactive_transform: false, - antialias_threshold: 7.0, + antialias_threshold: ANTIALIAS_THRESHOLD, + viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, + max_blocking_time_ms: MAX_BLOCKING_TIME_MS, + node_batch_threshold: NODE_BATCH_THRESHOLD, + blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD, } } } @@ -85,4 +101,28 @@ impl RenderOptions { self.antialias_threshold = value; } } + + pub fn set_blur_downscale_threshold(&mut self, value: f32) { + if value.is_finite() && value > 0.0 { + self.blur_downscale_threshold = value; + } + } + + pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { + if value >= 0 { + self.viewport_interest_area_threshold = value; + } + } + + pub fn set_node_batch_threshold(&mut self, value: i32) { + if value > 0 { + self.node_batch_threshold = value; + } + } + + pub fn set_max_blocking_time_ms(&mut self, value: i32) { + if value > 0 { + self.max_blocking_time_ms = value; + } + } } From 3da74ed864a9c56a7da586753ea8ff852c6df401 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 21 Apr 2026 12:50:38 +0200 Subject: [PATCH 08/14] :bug: Fix problem with component thumbnails in swap component panel --- .../main/data/workspace/thumbnails_wasm.cljs | 36 ++++++++------- .../ui/workspace/sidebar/assets/common.cljs | 2 +- .../sidebar/options/menus/component.cljs | 2 +- render-wasm/src/main.rs | 4 ++ render-wasm/src/render/surfaces.rs | 45 ++++++++++++------- 5 files changed, 54 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 82bf85cc2c..ff82e15972 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -61,23 +61,25 @@ (js/requestAnimationFrame (fn [_] (try - (let [objects (dsh/lookup-page-objects @st/state file-id page-id) - frame (get objects frame-id) - {:keys [width height]} (:selrect frame) - max-size (mth/max width height) - scale (mth/max 1 (/ target-size max-size)) - png-bytes (wasm.api/render-shape-pixels frame-id scale)] - (if (or (nil? png-bytes) (zero? (.-length png-bytes))) - (do - (l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id)) - (rx/end! subs)) - (.then - (png-bytes->data-uri png-bytes) - (fn [data-uri] - (rx/push! subs data-uri) - (rx/end! subs)) - (fn [err] - (rx/error! subs err))))) + (let [objects (dsh/lookup-page-objects @st/state file-id page-id)] + (if-let [frame (get objects frame-id)] + (let [{:keys [width height]} (:selrect frame) + max-size (mth/max width height) + scale (mth/max 1 (/ target-size max-size)) + png-bytes (wasm.api/render-shape-pixels frame-id scale)] + (if (or (nil? png-bytes) (zero? (.-length png-bytes))) + (do + (l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id)) + (rx/end! subs)) + (.then + (png-bytes->data-uri png-bytes) + (fn [data-uri] + (rx/push! subs data-uri) + (rx/end! subs)) + (fn [err] + (rx/error! subs err))))) + + (rx/error! subs "Frame not found"))) (catch :default err (rx/error! subs err)))))] #(js/cancelAnimationFrame req-id))))) 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 41fd1c4088..227cbf77b4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -309,7 +309,7 @@ ;; 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) + (mf/deps is-hidden thumbnail-uri wasm? current-page-id file-id page-id) (fn [] (if (some? thumbnail-uri) (mf/set-ref-val! thumbnail-requested? false) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 401b01d67b..3a8f07a136 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -589,7 +589,7 @@ :on-click on-select :disabled loop} (when visible? - [:> cmm/component-item-thumbnail* {:file-id (:file-id item) + [:> cmm/component-item-thumbnail* {:file-id file-id :class (stl/css :swap-item-thumbnail) :root-shape root-shape :component item diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 957955d3e0..9f298c3900 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1017,6 +1017,10 @@ pub extern "C" fn render_shape_pixels( ) -> Result<*mut u8> { let id = uuid_from_u32_quartet(a, b, c, d); + if !scale.is_finite() { + return Err(Error::CriticalError("Scale is not finite".to_string())); + } + with_state_mut!(state, { let (data, width, height) = state.render_shape_pixels(&id, scale, performance::get_time())?; diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index e3a9512e08..ca7f2a3ef2 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -938,36 +938,49 @@ impl Surfaces { if max_w > self.extra_tile_dims.width || max_h > self.extra_tile_dims.height { self.extra_tile_dims = skia::ISize::new(max_w, max_h); - self.drop_shadows = self + + if let Some(surface) = self .drop_shadows .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.inner_shadows = self + { + self.drop_shadows = surface; + } + + if let Some(surface) = self .inner_shadows .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.text_drop_shadows = self + { + self.inner_shadows = surface; + } + + if let Some(surface) = self .text_drop_shadows .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.text_drop_shadows = self - .text_drop_shadows - .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.shape_strokes = self + { + self.text_drop_shadows = surface; + } + + if let Some(surface) = self .shape_strokes .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.shape_fills = self + { + self.shape_strokes = surface; + } + + if let Some(surface) = self .shape_strokes .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); + { + self.shape_fills = surface; + } } - self.export = self + if let Some(surface) = self .export .new_surface_with_dimensions((target_w, target_h)) - .unwrap(); + { + self.export = surface; + } } } From f89f4e0047366084ebf3cea9e29f023ca359049c Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 21 Apr 2026 13:09:21 +0200 Subject: [PATCH 09/14] :bug: Fix problem on component swap --- render-wasm/src/wasm/text.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index f34aa10cf2..88726de91e 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -314,9 +314,9 @@ pub extern "C" fn set_shape_grow_type(grow_type: u8) { with_current_shape_mut!(state, |shape: &mut Shape| { if let Type::Text(text_content) = &mut shape.shape_type { text_content.set_grow_type(GrowType::from(grow_type)); - } else { - panic!("Trying to update grow type in a shape that it's not a text shape"); } + // Don't throw error if the object is not text. + // On swap component opperations is convenient. }); } From bd82829cb7848ccc4c403937595916b9e73ff10e Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Tue, 21 Apr 2026 15:39:35 +0200 Subject: [PATCH 10/14] :wrench: Add main-staging workflow --- .github/workflows/build-main-staging.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/build-main-staging.yml diff --git a/.github/workflows/build-main-staging.yml b/.github/workflows/build-main-staging.yml new file mode 100644 index 0000000000..33ee46947c --- /dev/null +++ b/.github/workflows/build-main-staging.yml @@ -0,0 +1,22 @@ +name: _MAIN-STAGING + +on: + workflow_dispatch: + schedule: + - cron: '26 5-20 * * 1-5' + +jobs: + build-bundle: + uses: ./.github/workflows/build-bundle.yml + secrets: inherit + with: + gh_ref: "main-staging" + build_wasm: "yes" + build_storybook: "yes" + + build-docker: + needs: build-bundle + uses: ./.github/workflows/build-docker.yml + secrets: inherit + with: + gh_ref: "main-staging" From d5cf7dcf9d106833f1f2303305b0aaec559e0c92 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Tue, 21 Apr 2026 15:39:35 +0200 Subject: [PATCH 11/14] :wrench: Add main-staging workflow --- .github/workflows/build-main-staging.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/build-main-staging.yml diff --git a/.github/workflows/build-main-staging.yml b/.github/workflows/build-main-staging.yml new file mode 100644 index 0000000000..33ee46947c --- /dev/null +++ b/.github/workflows/build-main-staging.yml @@ -0,0 +1,22 @@ +name: _MAIN-STAGING + +on: + workflow_dispatch: + schedule: + - cron: '26 5-20 * * 1-5' + +jobs: + build-bundle: + uses: ./.github/workflows/build-bundle.yml + secrets: inherit + with: + gh_ref: "main-staging" + build_wasm: "yes" + build_storybook: "yes" + + build-docker: + needs: build-bundle + uses: ./.github/workflows/build-docker.yml + secrets: inherit + with: + gh_ref: "main-staging" From 77560b93052dcf8d1f369e8d4fdfff12e6790e3a Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Tue, 21 Apr 2026 17:23:58 +0200 Subject: [PATCH 12/14] :sparkles: Encourage use of layouts and proper naming #9081 (#9084) Improve MCP instructions on design creation: * Agents should make use of layouts when appropriate * Agents should name all elements appropriately --- mcp/packages/server/data/initial_instructions.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 33ba407d23..3f6c19bd10 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -386,6 +386,9 @@ For many tasks, it can be critical to visually inspect the design. Remember to u * When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to non-creative defaults such as white/black if you are lacking information). +* When creating new designs, + - ensure a clean internal structure by applying flex and grid layouts when appropriate + - ensure proper semantic naming of elements. # Revising Designs From aed2f8a8f801268bf1ed227e025c3a107cb54945 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 21 Apr 2026 17:31:05 +0200 Subject: [PATCH 13/14] :bug: Fix removeChild errors from unmount race conditions (#8927) Guard imperative DOM operations (removeChild, RAF callbacks) against race conditions where React has already unmounted the target nodes. - assets/common.cljs: add dom/child? guard before removeChild in RAF - dynamic_modifiers.cljs: capture RAF IDs and cancel them on cleanup; add null guards for DOM nodes that may no longer exist - hooks.cljs: guard portal container removal with dom/child? check - errors.cljs: extract is-ignorable-exception? to a top-level defn and add NotFoundError/removeChild to ignorable exceptions, since these are caused by browser extensions modifying React-managed DOM - Add unit tests for is-ignorable-exception? predicate Signed-off-by: Andrey Antukh --- frontend/src/app/main/errors.cljs | 90 +++++++++++------- .../shapes/frame/dynamic_modifiers.cljs | 86 ++++++++++------- .../ui/workspace/sidebar/assets/common.cljs | 6 +- frontend/test/frontend_tests/errors_test.cljs | 95 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 5 files changed, 206 insertions(+), 73 deletions(-) create mode 100644 frontend/test/frontend_tests/errors_test.cljs diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 37177aec7d..0af0d8714c 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -408,43 +408,61 @@ (ex/print-throwable instance :prefix "Server Error")) (st/async-emit! (rt/assign-exception error))) +(defn- from-extension? + "True when the error stack trace originates from a browser extension." + [cause] + (let [stack (.-stack cause)] + (and (string? stack) + (or (str/includes? stack "chrome-extension://") + (str/includes? stack "moz-extension://"))))) + +(defn- from-posthog? + "True when the error stack trace originates from PostHog analytics." + [cause] + (let [stack (.-stack cause)] + (and (string? stack) + (str/includes? stack "posthog")))) + +(defn is-ignorable-exception? + "True when the error is known to be harmless (browser extensions, analytics, + React/extension DOM conflicts, etc.) and should NOT be surfaced to the user." + [cause] + (let [message (ex-message cause)] + (or (from-extension? cause) + (from-posthog? cause) + (= message "Possible side-effect in debug-evaluate") + (= message "Unexpected end of input") + (str/starts-with? message "invalid props on component") + (str/starts-with? message "Unexpected token ") + ;; Native AbortError DOMException: raised when an in-flight + ;; HTTP fetch is cancelled via AbortController (e.g. by an + ;; RxJS unsubscription / take-until chain). These are + ;; handled gracefully inside app.util.http/fetch and must NOT + ;; be surfaced as application errors. + (= (.-name ^js cause) "AbortError") + ;; Zone.js (injected by browser extensions such as Angular + ;; DevTools) wraps event listeners and assigns a custom + ;; .toString to its wrapper functions using + ;; Object.defineProperty. When the wrapper was previously + ;; defined with {writable: false}, a subsequent plain assignment + ;; in strict mode (our libs.js uses "use strict") throws this + ;; TypeError. This is a known Zone.js / browser-extension + ;; incompatibility and is NOT a Penpot bug. + (str/starts-with? message "Cannot assign to read only property 'toString'") + ;; NotFoundError DOMException: "Failed to execute + ;; 'removeChild' on 'Node'" — Thrown by React's commit + ;; phase when the DOM tree has been modified externally + ;; (typically by browser extensions like Grammarly, + ;; LastPass, translation tools, or ad blockers that + ;; inject/remove nodes). The entire stack trace is inside + ;; React internals (libs.js) with no application code, + ;; so there is nothing actionable on our side. React's + ;; error boundary already handles recovery. + (and (= (.-name ^js cause) "NotFoundError") + (str/includes? message "removeChild"))))) + (defonce uncaught-error-handler - (letfn [(from-extension? [cause] - (let [stack (.-stack cause)] - (and (string? stack) - (or (str/includes? stack "chrome-extension://") - (str/includes? stack "moz-extension://"))))) - - (from-posthog? [cause] - (let [stack (.-stack cause)] - (and (string? stack) - (str/includes? stack "posthog")))) - - (is-ignorable-exception? [cause] - (let [message (ex-message cause)] - (or (from-extension? cause) - (from-posthog? cause) - (= message "Possible side-effect in debug-evaluate") - (= message "Unexpected end of input") - (str/starts-with? message "invalid props on component") - (str/starts-with? message "Unexpected token ") - ;; Native AbortError DOMException: raised when an in-flight - ;; HTTP fetch is cancelled via AbortController (e.g. by an - ;; RxJS unsubscription / take-until chain). These are - ;; handled gracefully inside app.util.http/fetch and must NOT - ;; be surfaced as application errors. - (= (.-name ^js cause) "AbortError") - ;; Zone.js (injected by browser extensions such as Angular - ;; DevTools) wraps event listeners and assigns a custom - ;; .toString to its wrapper functions using - ;; Object.defineProperty. When the wrapper was previously - ;; defined with {writable: false}, a subsequent plain assignment - ;; in strict mode (our libs.js uses "use strict") throws this - ;; TypeError. This is a known Zone.js / browser-extension - ;; incompatibility and is NOT a Penpot bug. - (str/starts-with? message "Cannot assign to read only property 'toString'")))) - - (on-unhandled-error [event] + (letfn [(on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] (when-not (is-ignorable-exception? cause) diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index 71533852e8..90b27f6ee2 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -265,54 +265,68 @@ prev-transforms (mf/use-var nil)] (mf/with-effect [add-children] - (ts/raf - #(doseq [{:keys [shape]} add-children-prev] - (let [shape-node (get-shape-node shape) - mirror-node (dom/query (dm/fmt ".mirror-shape[href='#shape-%'" shape))] - (when mirror-node (.remove mirror-node)) - (dom/remove-attribute! (dom/get-parent shape-node) "display")))) + (let [raf-id1 + (ts/raf + #(doseq [{:keys [shape]} add-children-prev] + (let [shape-node (get-shape-node shape) + mirror-node (dom/query (dm/fmt ".mirror-shape[href='#shape-%'" shape))] + (when mirror-node (.remove mirror-node)) + (when-let [parent (some-> shape-node dom/get-parent)] + (dom/remove-attribute! parent "display"))))) - (ts/raf - #(doseq [{:keys [frame shape]} add-children] - (let [frame-node (get-shape-node frame) - shape-node (get-shape-node shape) + raf-id2 + (ts/raf + #(doseq [{:keys [frame shape]} add-children] + (let [frame-node (get-shape-node frame) + shape-node (get-shape-node shape)] + (when (and (some? frame-node) (some? shape-node)) + (let [clip-id + (-> (dom/query frame-node ":scope > defs > .frame-clip-def") + (dom/get-attribute "id")) - clip-id - (-> (dom/query frame-node ":scope > defs > .frame-clip-def") - (dom/get-attribute "id")) + use-node + (dom/create-element "http://www.w3.org/2000/svg" "use") - use-node - (dom/create-element "http://www.w3.org/2000/svg" "use") + contents-node + (or (dom/query frame-node ".frame-children") frame-node)] - contents-node - (or (dom/query frame-node ".frame-children") frame-node)] - - (dom/set-attribute! use-node "href" (dm/fmt "#shape-%" shape)) - (dom/set-attribute! use-node "clip-path" (dm/fmt "url(#%)" clip-id)) - (dom/add-class! use-node "mirror-shape") - (dom/append-child! contents-node use-node) - (dom/set-attribute! (dom/get-parent shape-node) "display" "none"))))) + (dom/set-attribute! use-node "href" (dm/fmt "#shape-%" shape)) + (dom/set-attribute! use-node "clip-path" (dm/fmt "url(#%)" clip-id)) + (dom/add-class! use-node "mirror-shape") + (dom/append-child! contents-node use-node) + (dom/set-attribute! (dom/get-parent shape-node) "display" "none"))))))] + (fn [] + (js/cancelAnimationFrame raf-id1) + (js/cancelAnimationFrame raf-id2)))) (mf/with-effect [transforms] (let [curr-shapes-set (into #{} (map :id) shapes) prev-shapes-set (into #{} (map :id) @prev-shapes) new-shapes (->> shapes (remove #(contains? prev-shapes-set (:id %)))) - removed-shapes (->> @prev-shapes (remove #(contains? curr-shapes-set (:id %))))] + removed-shapes (->> @prev-shapes (remove #(contains? curr-shapes-set (:id %)))) - ;; NOTE: we schedule the dom modifications to be executed - ;; asynchronously for avoid component flickering when react18 - ;; is used. + ;; NOTE: we schedule the dom modifications to be executed + ;; asynchronously for avoid component flickering when react18 + ;; is used. - (when (d/not-empty? new-shapes) - (ts/raf #(start-transform! node new-shapes))) + raf-id1 + (when (d/not-empty? new-shapes) + (ts/raf #(start-transform! node new-shapes))) - (when (d/not-empty? shapes) - (ts/raf #(update-transform! node shapes transforms modifiers))) + raf-id2 + (when (d/not-empty? shapes) + (ts/raf #(update-transform! node shapes transforms modifiers))) - (when (d/not-empty? removed-shapes) - (ts/raf #(remove-transform! node removed-shapes)))) + raf-id3 + (when (d/not-empty? removed-shapes) + (ts/raf #(remove-transform! node removed-shapes)))] - (reset! prev-modifiers modifiers) - (reset! prev-transforms transforms) - (reset! prev-shapes shapes)))) + (reset! prev-modifiers modifiers) + (reset! prev-transforms transforms) + (reset! prev-shapes shapes) + + (fn [] + (when raf-id1 (js/cancelAnimationFrame raf-id1)) + (when raf-id2 (js/cancelAnimationFrame raf-id2)) + (when raf-id3 (js/cancelAnimationFrame raf-id3))))))) 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 9f7762b861..cae198ad9a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -242,7 +242,11 @@ ;; afterwards, in the next render cycle. (dom/append-child! item-el counter-el) (dnd/set-drag-image! event item-el (:x offset) (:y offset)) - (ts/raf #(.removeChild ^js item-el counter-el)))) + ;; Guard against race condition: if the user navigates away + ;; before the RAF fires, item-el may have been unmounted and + ;; counter-el is no longer a child — removeChild would throw. + (ts/raf #(when (dom/child? counter-el item-el) + (dom/remove-child! item-el counter-el))))) (defn on-asset-drag-start [event file-id asset selected item-ref asset-type on-drag-start] diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs new file mode 100644 index 0000000000..8d217fca04 --- /dev/null +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -0,0 +1,95 @@ +;; 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 frontend-tests.errors-test + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true])) + +(defn- make-error + "Create a JS Error-like object with the given name, message, and optional stack." + [error-name message & {:keys [stack] :or {stack ""}}] + (let [err (js/Error. message)] + (set! (.-name err) error-name) + (when (some? stack) + (set! (.-stack err) stack)) + err)) + +;; --------------------------------------------------------------------------- +;; is-ignorable-exception? tests +;; --------------------------------------------------------------------------- + +(t/deftest test-ignorable-chrome-extension + (t/testing "Errors from Chrome extensions are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at chrome-extension://abc123/content.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-moz-extension + (t/testing "Errors from Firefox extensions are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at moz-extension://abc123/content.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-posthog + (t/testing "Errors from PostHog are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at https://app.posthog.com/static/array.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-debug-evaluate + (t/testing "Debug-evaluate side-effect errors are ignorable" + (let [cause (make-error "Error" "Possible side-effect in debug-evaluate")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-unexpected-end-of-input + (t/testing "Unexpected end of input errors are ignorable" + (let [cause (make-error "SyntaxError" "Unexpected end of input")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-invalid-props + (t/testing "Invalid React props errors are ignorable" + (let [cause (make-error "Error" "invalid props on component Foo")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-unexpected-token + (t/testing "Unexpected token errors are ignorable" + (let [cause (make-error "SyntaxError" "Unexpected token <")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-abort-error + (t/testing "AbortError DOMException is ignorable" + (let [cause (make-error "AbortError" "The operation was aborted")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-zone-js-tostring + (t/testing "Zone.js toString read-only property error is ignorable" + (let [cause (make-error "TypeError" + "Cannot assign to read only property 'toString' of function 'function () { [native code] }'")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-not-found-error-remove-child + (t/testing "NotFoundError with removeChild message is ignorable" + (let [cause (make-error "NotFoundError" + "Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node." + :stack "NotFoundError: Failed to execute 'removeChild'\n at zLe (libs.js:1:1)")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-not-found-error-other + (t/testing "NotFoundError without removeChild is NOT ignorable" + (let [cause (make-error "NotFoundError" + "Failed to execute 'insertBefore' on 'Node': something else")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-regular-error + (t/testing "Regular application errors are NOT ignorable" + (let [cause (make-error "Error" "Cannot read property 'x' of undefined")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-type-error + (t/testing "Regular TypeError is NOT ignorable" + (let [cause (make-error "TypeError" "undefined is not a function")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 003e68264c..13e2796391 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -7,6 +7,7 @@ [frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.errors-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -42,6 +43,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test + 'frontend-tests.errors-test 'frontend-tests.main-errors-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test From 6eccffb8bb184514f2a9581760a67652a22164c1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 21 Apr 2026 19:19:51 +0200 Subject: [PATCH 14/14] :bug: Fix incorrect handlig of version restore operation (#9041) - Add session ID tracking to RPC layer (backend and frontend) - Send session ID header with RPC requests for request correlation - Rename file-restore to file-restored for consistency - Extract initialize-file function from initialize-workspace flow - Improve file restoration initialization with wait-for-persistence - Extract initialize-version event handler for version restoration - Fix viewport key generation with file version numbers for proper re-renders - Update layout item schema and constraints to use internal sizing state - Add v-sizing state retrieval in layout-size-constraints component - Refactor file-change notifications stream handling with rx/map - Fix team-id lookup in restore-version-from-plugins Improves request traceability across frontend/backend sessions and streamlines the workspace initialization flow for file restoration scenarios. Signed-off-by: Andrey Antukh --- backend/src/app/rpc.clj | 2 + .../src/app/rpc/commands/files_snapshot.clj | 5 +- frontend/src/app/config.cljs | 3 + frontend/src/app/main.cljs | 6 +- frontend/src/app/main/data/event.cljs | 1 + frontend/src/app/main/data/workspace.cljs | 370 +++++++++--------- .../app/main/data/workspace/libraries.cljs | 7 +- .../main/data/workspace/notifications.cljs | 18 +- .../src/app/main/data/workspace/versions.cljs | 76 ++-- frontend/src/app/main/refs.cljs | 3 - frontend/src/app/main/repo.cljs | 1 + frontend/src/app/main/ui/workspace.cljs | 12 +- .../sidebar/options/menus/layout_item.cljs | 11 +- .../src/app/main/ui/workspace/viewport.cljs | 26 +- .../app/main/ui/workspace/viewport_wasm.cljs | 20 +- 15 files changed, 299 insertions(+), 262 deletions(-) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 4b47c19451..20cb0c150b 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -96,6 +96,7 @@ (fn [{:keys [params path-params method] :as request}] (let [handler-name (:method-name path-params) etag (yreq/get-header request "if-none-match") + session-id (yreq/get-header request "x-session-id") key-id (get request ::http/auth-key-id) profile-id (or (::session/profile-id request) @@ -108,6 +109,7 @@ (assoc ::handler-name handler-name) (assoc ::ip-addr ip-addr) (assoc ::request-at (ct/now)) + (assoc ::session-id (some-> session-id uuid/parse*)) (assoc ::cond/key etag) (cond-> (uuid? profile-id) (assoc ::profile-id profile-id))) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index cd3cbcdf0f..8325772361 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -71,7 +71,7 @@ {::doc/added "1.20" ::sm/params schema:restore-file-snapshot ::db/transaction true} - [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] + [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}] (files/check-edition-permissions! conn profile-id file-id) (let [file (bfc/get-file cfg file-id) team (teams/get-team conn @@ -88,7 +88,8 @@ ;; Send to the clients a notification to reload the file (mbus/pub! msgbus :topic (:id file) - :message {:type :file-restore + :message {:type :file-restored + :session-id session-id :file-id (:id file) :vern vern}) nil))) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index efda9a9356..11c59e5a65 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -12,6 +12,7 @@ [app.common.logging :as log] [app.common.time :as ct] [app.common.uri :as u] + [app.common.uuid :as uuid] [app.common.version :as v] [app.util.avatars :as avatars] [app.util.extends] @@ -112,10 +113,12 @@ (def target (parse-target global)) (def browser (parse-browser)) (def platform (parse-platform)) +(def session-id (uuid/next)) (def version (parse-version global)) (def version-tag (obj/get global "penpotVersionTag")) + (defn stale-build? "Returns true when the compiled JS was built with a different version tag than the one present in the current index.html. This indicates diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 9583b86686..20830e78c7 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -11,7 +11,6 @@ [app.common.time :as ct] [app.common.transit :as t] [app.common.types.objects-map] - [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.auth :as da] [app.main.data.event :as ev] @@ -45,7 +44,8 @@ (log/inf :version (:full cf/version) :asserts *assert* :build-date cf/build-date - :public-uri (dm/str cf/public-uri)) + :public-uri (dm/str cf/public-uri) + :session-id (str cf/session-id)) (log/inf :hint "enabled flags" :flags (str/join " " (map name cf/flags)))) (declare reinit) @@ -71,7 +71,7 @@ (ptk/reify ::initialize ptk/UpdateEvent (update [_ state] - (assoc state :session-id (uuid/next))) + (assoc state :session-id cf/session-id)) ptk/WatchEvent (watch [_ _ stream] diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index cfd2cc841c..dfc925cce8 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -431,6 +431,7 @@ context (-> @context (merge (:context event)) (assoc :session session*) + (assoc :session-id cf/session-id) (assoc :external-session-id (cf/external-session-id)) (add-external-context-info) (d/without-nils))] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 32dd2ae5e9..e49bc026b6 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -224,18 +224,12 @@ IDeref (-deref [_] bundle) + ptk/UpdateEvent (update [_ state] - (let [pending-version-id (:workspace-pending-file-version-id state) - state (-> state - (assoc :thumbnails thumbnails) - (update :files assoc file-id file) - (dissoc :workspace-pending-file-version-id))] - (cond-> state - (some? pending-version-id) - (assoc :workspace-file-version-id pending-version-id) - (nil? pending-version-id) - (dissoc :workspace-file-version-id)))))) + (-> state + (assoc :thumbnails thumbnails) + (update :files assoc file-id file))))) (defn zoom-to-frame [] @@ -253,6 +247,7 @@ (rx/of (dws/select-shapes frames-id) dwz/zoom-to-selected-shape))))) +;; FIXME: rename to `fetch-file` (defn- fetch-bundle "Multi-stage file bundle fetch coordinator" [file-id features] @@ -289,204 +284,215 @@ ;; This prevents errors when processing changes from other pages (when shape (wasm.api/process-object shape)))))) + +(defn initialize-file + [team-id file-id] + (assert (uuid? team-id) "expected valud uuid for `team-id`") + (assert (uuid? file-id) "expected valud uuid for `file-id`") + + (ptk/reify ::initialize-file + ptk/WatchEvent + (watch [_ state _] + (let [features (features/get-enabled-features state team-id)] + (log/dbg :hint "initialize-file" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) + (rx/of (fetch-bundle file-id features)))))) + (defn initialize-workspace - ([team-id file-id] - (initialize-workspace team-id file-id nil)) - ([team-id file-id version-id] - (assert (uuid? team-id) "expected valud uuid for `team-id`") - (assert (uuid? file-id) "expected valud uuid for `file-id`") + [team-id file-id] + (assert (uuid? team-id) "expected valud uuid for `team-id`") + (assert (uuid? file-id) "expected valud uuid for `file-id`") - (ptk/reify ::initialize-workspace - ptk/UpdateEvent - (update [_ state] - (-> state - (assoc :recent-colors (:recent-colors storage/user)) - (assoc :recent-fonts (:recent-fonts storage/user)) - (assoc :current-file-id file-id) - (assoc :workspace-presence {}) - ;; Store pending version-id; bundle-fetched will set workspace-file-version-id - ;; when the new bundle is applied so the viewport re-inits with new data - (assoc :workspace-pending-file-version-id version-id))) + (ptk/reify ::initialize-workspace + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc :recent-colors (:recent-colors storage/user)) + (assoc :recent-fonts (:recent-fonts storage/user)) + (assoc :current-file-id file-id) + (assoc :workspace-presence {}))) - ptk/WatchEvent - (watch [_ state stream] - (let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream) - rparams (rt/get-params state) - features (features/get-enabled-features state team-id) - render-wasm? (contains? features "render-wasm/v1")] + ptk/WatchEvent + (watch [_ state stream] + (let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream) + rparams (rt/get-params state) + features (features/get-enabled-features state team-id) + render-wasm? (contains? features "render-wasm/v1")] - (log/debug :hint "initialize-workspace" - :team-id (dm/str team-id) - :file-id (dm/str file-id)) + (log/debug :hint "initialize-workspace" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) - (rx/concat - (->> (rx/merge - (rx/concat - ;; Fetch all essential data that should be loaded before the file - (rx/merge - (if ^boolean render-wasm? - (->> (rx/from @wasm/module) - (rx/filter true?) - (rx/tap (fn [_] - (let [event (ug/event "penpot:wasm:loaded")] - (ug/dispatch! event)))) - (rx/ignore)) - (rx/empty)) + (rx/concat + (->> (rx/merge + (rx/concat + ;; Fetch all essential data that should be loaded before the file + (rx/merge + (if ^boolean render-wasm? + (->> (rx/from @wasm/module) + (rx/filter true?) + (rx/tap (fn [_] + (let [event (ug/event "penpot:wasm:loaded")] + (ug/dispatch! event)))) + (rx/ignore)) + (rx/empty)) - (->> stream - (rx/filter (ptk/type? ::df/fonts-loaded)) - (rx/take 1) - (rx/ignore)) + (->> stream + (rx/filter (ptk/type? ::df/fonts-loaded)) + (rx/take 1) + (rx/ignore)) - (rx/of (ntf/hide) - (dcmt/retrieve-comment-threads file-id) - (dcmt/fetch-profiles) - (df/fetch-fonts team-id)) + (rx/of (ntf/hide) + (dcmt/retrieve-comment-threads file-id) + (dcmt/fetch-profiles) + (df/fetch-fonts team-id)) - (when (contains? cf/flags :mcp) - (rx/of (du/fetch-access-tokens)))) + (when (contains? cf/flags :mcp) + (rx/of (du/fetch-access-tokens)))) - ;; Once the essential data is fetched, lets proceed to - ;; fetch teh file bunldle - (rx/of (fetch-bundle file-id features))) + ;; Once the essential data is fetched, lets proceed to + ;; fetch teh file bunldle + (rx/of (initialize-file team-id file-id))) - (->> stream - (rx/filter (ptk/type? ::bundle-fetched)) - (rx/take 1) - (rx/map deref) - (rx/mapcat - (fn [{:keys [file]}] - (log/debug :hint "bundle fetched" - :team-id (dm/str team-id) - :file-id (dm/str file-id)) + (->> stream + (rx/filter (ptk/type? ::bundle-fetched)) + (rx/take 1) + (rx/map deref) + (rx/mapcat + (fn [{:keys [file]}] + (log/debug :hint "bundle fetched" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) - (rx/of (dpj/initialize-project (:project-id file)) - (dwn/initialize team-id file-id) - (dwsl/initialize-shape-layout) - (fetch-libraries file-id features) - (-> (workspace-initialized file-id) - (with-meta {:team-id team-id - :file-id file-id})))))) + (rx/of (dpj/initialize-project (:project-id file)) + (dwn/initialize team-id file-id) + (dwsl/initialize-shape-layout) + (fetch-libraries file-id features) + (-> (workspace-initialized file-id) + (with-meta {:team-id team-id + :file-id file-id})))))) - ;; Install dev perf observers once the workspace is ready - (when (contains? cf/flags :perf-logs) - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/take 1) - (rx/tap (fn [_] (perf/setup))))) + ;; Install dev perf observers once the workspace is ready + (when (contains? cf/flags :perf-logs) + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/take 1) + (rx/tap (fn [_] (perf/setup))))) - (->> stream - (rx/filter (ptk/type? ::dps/persistence-notification)) - (rx/take 1) - (rx/map dwc/set-workspace-visited)) - (when-let [component-id (some-> rparams :component-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) + (->> stream + (rx/filter (ptk/type? ::dps/persistence-notification)) + (rx/take 1) + (rx/map dwc/set-workspace-visited)) - (when (:board-id rparams) - (->> stream - (rx/filter (ptk/type? ::dwv/initialize-viewport)) - (rx/take 1) - (rx/map zoom-to-frame))) + (when-let [component-id (some-> rparams :component-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) - (when-let [comment-id (some-> rparams :comment-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwcm/navigate-to-comment-id comment-id)))) + (when (:board-id rparams) + (->> stream + (rx/filter (ptk/type? ::dwv/initialize-viewport)) + (rx/take 1) + (rx/map zoom-to-frame))) - (when render-wasm? - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [redo-changes]}] - (let [added (->> redo-changes - (filter #(= (:type %) :add-obj)) - (map :id))] - (->> (rx/from added) - (rx/map process-wasm-object))))))) + (when-let [comment-id (some-> rparams :comment-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwcm/navigate-to-comment-id comment-id)))) - (when render-wasm? - (let [local-commits-s - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/filter #(and (= :local (:source %)) - (not (contains? (:tags %) :position-data)))) - (rx/filter (complement empty?))) + (when render-wasm? + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [redo-changes]}] + (let [added (->> redo-changes + (filter #(= (:type %) :add-obj)) + (map :id))] + (->> (rx/from added) + (rx/map process-wasm-object))))))) - notifier-s - (rx/merge - (->> local-commits-s (rx/debounce 1000)) - (->> stream (rx/filter dps/force-persist?))) + (when render-wasm? + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) - objects-s - (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) - current-page-id-s - (rx/from-atom refs/current-page-id {:emit-current-value? true})] + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) - (->> local-commits-s - (rx/buffer-until notifier-s) - (rx/with-latest-from objects-s) - (rx/map - (fn [[commits objects]] - (->> commits - (mapcat :redo-changes) - (filter #(contains? #{:mod-obj :add-obj} (:type %))) - (filter #(cfh/text-shape? objects (:id %))) - (map #(vector - (:id %) - (wasm.api/calculate-position-data (get objects (:id %)))))))) + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] - (rx/with-latest-from current-page-id-s) - (rx/map - (fn [[text-position-data page-id]] - (let [changes - (->> text-position-data - (mapv (fn [[id position-data]] - {:type :mod-obj - :id id - :page-id page-id - :operations - [{:type :set - :attr :position-data - :val position-data - :ignore-touched true - :ignore-geometry true}]})))] - (when (d/not-empty? changes) - (dch/commit-changes - {:redo-changes changes :undo-changes [] - :save-undo? false - :tags #{:position-data}}))))) - (rx/take-until stoper-s)))) + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] - (if (and save-undo? (seq undo-changes)) - (let [entry {:undo-changes undo-changes - :redo-changes redo-changes - :undo-group undo-group - :tags tags}] - (rx/of (dwu/append-undo entry stack-undo?))) - (rx/empty)))))) - (rx/take-until stoper-s)) + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (when (d/not-empty? changes) + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))) + (rx/take-until stoper-s)))) - (rx/of (mcp/notify-other-tabs-disconnect))))) + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] + (if (and save-undo? (seq undo-changes)) + (let [entry {:undo-changes undo-changes + :redo-changes redo-changes + :undo-group undo-group + :tags tags}] + (rx/of (dwu/append-undo entry stack-undo?))) + (rx/empty)))))) + (rx/take-until stoper-s)) - ptk/EffectEvent - (effect [_ _ _] - (let [name (dm/str "workspace-" file-id)] - (unchecked-set ug/global "name" name)))))) + (rx/of (mcp/notify-other-tabs-disconnect))))) + + ptk/EffectEvent + (effect [_ _ _] + (let [name (dm/str "workspace-" file-id)] + (unchecked-set ug/global "name" name))))) (defn finalize-workspace [_team-id file-id] diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 28057cfd09..be0b8b6dc6 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1350,9 +1350,10 @@ (watch [_ _ stream] (let [stopper-s (->> stream - (rx/filter #(or (= ::dwpg/finalize-page (ptk/type %)) - (= ::watch-component-changes (ptk/type %))))) - + (rx/map ptk/type) + (rx/filter (fn [event-type] + (or (= ::dwpg/finalize-page event-type) + (= ::watch-component-changes event-type))))) workspace-data-s (->> (rx/from-atom refs/workspace-data {:emit-current-value? true}) (rx/share)) diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 5e01fd4486..2dc6450abc 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -40,7 +40,7 @@ (declare handle-pointer-update) (declare handle-file-change) (declare handle-file-deleted) -(declare handle-file-restore) +(declare handle-file-restored) (declare handle-library-change) (declare handle-pointer-send) (declare handle-export-update) @@ -132,7 +132,7 @@ :pointer-update (handle-pointer-update msg) :file-change (handle-file-change msg) :file-deleted (handle-file-deleted msg) - :file-restore (handle-file-restore msg) + :file-restored (handle-file-restored msg) :library-change (handle-library-change msg) :notification (dc/handle-notification msg) :team-role-change (handle-change-team-role msg) @@ -283,22 +283,22 @@ (rt/nav :dashboard-recent {:team-id team-id}))))))) (def ^:private - schema:handle-file-restore - [:map {:title "handle-file-restore"} + schema:handle-file-restored + [:map {:title "handle-file-restored"} [:type :keyword] [:file-id ::sm/uuid] [:vern :int]]) -(def ^:private check-file-restore-params - (sm/check-fn schema:handle-file-restore)) +(def ^:private check-file-restored-params + (sm/check-fn schema:handle-file-restored)) -(defn handle-file-restore +(defn handle-file-restored [{:keys [file-id vern] :as msg}] - (assert (check-file-restore-params msg) + (assert (check-file-restored-params msg) "expected valid parameters") - (ptk/reify ::handle-file-restore + (ptk/reify ::handle-file-restored ptk/WatchEvent (watch [_ state _] (let [curr-file-id (:current-file-id state) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 11ed461366..85630cfccb 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -11,9 +11,10 @@ [app.common.schema :as sm] [app.common.time :as ct] [app.main.data.event :as ev] - [app.main.data.helpers :as dsh] + [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] [app.main.data.workspace :as dw] + [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.thumbnails :as th] [app.main.refs :as refs] [app.main.repo :as rp] @@ -92,33 +93,59 @@ (->> (rp/cmd! :update-file-snapshot {:id id :label label}) (rx/map fetch-versions))))))) +(defn- initialize-version + [] + (ptk/reify ::initialize-version + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + file-id (:current-file-id state) + team-id (:current-team-id state)] + + (rx/merge + (->> stream + (rx/filter (ptk/type? ::dw/bundle-fetched)) + (rx/take 1) + (rx/map #(dwpg/initialize-page file-id page-id))) + + (rx/of (ntf/hide :tag :restore-dialog) + (dw/initialize-file team-id file-id))))) + + ptk/EffectEvent + (effect [_ _ _] + (th/clear-queue!)))) + +(defn- wait-for-persistence + [file-id snapshot-id] + (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) + (rx/filter #(or (nil? %) (= :saved %))) + (rx/take 1) + (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id})))) + (defn restore-version [id origin] (assert (uuid? id) "expected valid uuid for `id`") (ptk/reify ::restore-version ptk/WatchEvent (watch [_ state _] - (let [file-id (:current-file-id state) - team-id (:current-team-id state)] + (let [file-id (:current-file-id state) + team-id (:current-team-id state) + event-name (case origin + :version "restore-pin-version" + :snapshot "restore-autosave" + :plugin "restore-version-plugin")] + (rx/concat (rx/of ::dwp/force-persist (dw/remove-layout-flag :document-history)) - (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) - (rx/filter #(or (nil? %) (= :saved %))) - (rx/take 1) - (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) - (rx/tap #(th/clear-queue!)) - (rx/map #(dw/initialize-workspace team-id file-id id))) - (case origin - :version - (rx/of (ptk/event ::ev/event {::ev/name "restore-pin-version"})) - :snapshot - (rx/of (ptk/event ::ev/event {::ev/name "restore-autosave"})) - - :plugin - (rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"})) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) + (if event-name + (rx/of (ev/event {::ev/name event-name + :file-id file-id + :team-id team-id})) (rx/empty))))))) (defn delete-version @@ -220,18 +247,15 @@ (ptk/reify ::restore-version-from-plugins ptk/WatchEvent (watch [_ state _] - (let [file (dsh/lookup-file state file-id) - team-id (or (:team-id file) (:current-file-id state))] + (let [team-id (:current-team-id state)] (rx/concat - (rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"}) + (rx/of (ev/event {::ev/name "restore-version-plugin" + :file-id file-id + :team-id team-id}) ::dwp/force-persist) - ;; FIXME: we should abstract this - (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) - (rx/filter #(or (nil? %) (= :saved %))) - (rx/take 1) - (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) - (rx/map #(dw/initialize-workspace team-id file-id id))) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) (->> (rx/of 1) (rx/tap resolve) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index e84f92954b..48dc9529cd 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -259,9 +259,6 @@ (def workspace-layout (l/derived :workspace-layout st/state)) -(def workspace-file-version-id - (l/derived :workspace-file-version-id st/state)) - (def snap-pixel? (l/derived #(contains? % :snap-pixel-grid) workspace-layout)) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 1e6dd417a6..9b5b9872ab 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -182,6 +182,7 @@ :credentials "include" :headers {"accept" "application/transit+json,text/event-stream,*/*" "x-external-session-id" (cf/external-session-id) + "x-session-id" (str cf/session-id) "x-event-origin" (::ev/origin (meta params))} :body (when (= method :post) (if form-data? diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index c0258ccb16..c0e600b835 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -51,7 +51,7 @@ (mf/defc workspace-content* {::mf/private true} - [{:keys [file layout page wglobal file-version-id]}] + [{:keys [file layout page wglobal]}] (let [palete-size (mf/use-state nil) selected (mf/deref refs/selected-shapes) @@ -109,7 +109,6 @@ :wglobal wglobal :selected selected :layout layout - :file-version-id file-version-id :palete-size (when (and (or colorpalette? textpalette?) (not hide-ui?)) @palete-size)}]]] @@ -169,7 +168,7 @@ (mf/defc workspace-inner* {::mf/private true} - [{:keys [page-id file-id file layout wglobal file-version-id]}] + [{:keys [page-id file-id file layout wglobal]}] (let [page-ref (mf/with-memo [file-id page-id] (make-page-ref file-id page-id)) page (mf/deref page-ref)] @@ -188,8 +187,7 @@ [:> workspace-content* {:file file :page page :wglobal wglobal - :layout layout - :file-version-id file-version-id}] + :layout layout}] [:> workspace-loader*]))) (mf/defc workspace* @@ -201,7 +199,6 @@ layout (mf/deref refs/workspace-layout) wglobal (mf/deref refs/workspace-global) - file-version-id (mf/deref refs/workspace-file-version-id) team-ref (mf/with-memo [team-id] (make-team-ref team-id)) @@ -277,8 +274,7 @@ :file-id file-id :file file :wglobal wglobal - :layout layout - :file-version-id file-version-id}]) + :layout layout}]) (when (or (not (and file-loaded? page-id)) ;; in wasm renderer, extend the pixel loader until the first frame is rendered ;; but do not apply it when switching pages diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index db8e378e19..d239dbd1e3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -539,16 +539,18 @@ [:map [:values schema:layout-item-props-schema] [:applied-tokens [:maybe [:map-of :keyword :string]]] - [:ids [::sm/vec ::sm/uuid]] - [:v-sizing {:optional true} [:maybe [:enum :fill :fix :auto]]]]) + [:ids [::sm/vec ::sm/uuid]]]) (mf/defc layout-size-constraints* {::mf/private true ::mf/schema (sm/schema schema:layout-size-constraints)} - [{:keys [values v-sizing ids applied-tokens] :as props}] + [{:keys [values ids applied-tokens] :as props}] (let [token-numeric-inputs (features/use-feature "tokens/numeric-input") + v-sizing + (:layout-item-v-sizing values) + min-w (get values :layout-item-min-w) max-w (get values :layout-item-max-w) @@ -914,5 +916,4 @@ (= v-sizing :fill)) [:> layout-size-constraints* {:ids ids :values values - :applied-tokens applied-tokens - :v-sizing v-sizing}])])])) + :applied-tokens applied-tokens}])])])) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 7d1b778466..98b1178b75 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -81,6 +81,7 @@ selected)) (mf/defc viewport-classic* + {::mf/private true} [{:keys [selected wglobal layout file page palete-size]}] (let [{:keys [edit-path panning @@ -108,8 +109,8 @@ ;; DEREFS drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) - file-id (get file :id) + vern (get file :vern) page-id (get page :id) objects (get page :objects) background (get page :background clr/canvas) @@ -340,7 +341,7 @@ :opacity 0.6}} (when (and (:can-edit permissions) (not read-only?)) [:& stvh/viewport-texts - {:key (dm/str "texts-" page-id) + {:key (dm/str "viewport-texts-" page-id "-" vern) :page-id page-id :objects objects :modifiers modifiers @@ -366,7 +367,7 @@ :xmlnsXlink "http://www.w3.org/1999/xlink" :xmlns:penpot "https://penpot.app/xmlns" :preserveAspectRatio "xMidYMid meet" - :key (str "render" page-id) + :key (dm/str "viewport-svg-" page-id "-" vern) :width (:width vport 0) :height (:height vport 0) :view-box (utils/format-viewbox vbox) @@ -400,7 +401,7 @@ [:& (mf/provider ctx/current-vbox) {:value vbox'} [:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)} ;; Render root shape - [:& shapes/root-shape {:key page-id + [:& shapes/root-shape {:key (str page-id) :objects base-objects :active-frames @active-frames}]]]] @@ -408,7 +409,7 @@ {:xmlns "http://www.w3.org/2000/svg" :xmlnsXlink "http://www.w3.org/1999/xlink" :preserveAspectRatio "xMidYMid meet" - :key (str "viewport" page-id) + :key (dm/str "viewport-controls-" page-id "-" vern) :view-box (utils/format-viewbox vbox) :ref on-viewport-ref :class (dm/str @cursor (when drawing-tool " drawing") " " (stl/css :viewport-controls)) @@ -719,7 +720,7 @@ (not= @hover-top-frame-id (:id frame))) [:& grid-layout/editor {:zoom zoom - :key (dm/str (:id frame)) + :key (dm/str "viewport-frame-" (:id frame)) :objects base-objects :modifiers modifiers :shape frame @@ -733,8 +734,11 @@ :bottom-padding (when palete-size (+ palete-size 8))}]]]]])) (mf/defc viewport* - [props] - (let [wasm-renderer-enabled? (features/use-feature "render-wasm/v1")] - (if ^boolean wasm-renderer-enabled? - [:> viewport.wasm/viewport* props] - [:> viewport-classic* props]))) + [{:keys [file page] :as props}] + (let [vern (get file :vern) + page-id (get page :id) + render-wasm? (features/use-feature "render-wasm/v1")] + [:* {:key (dm/str "viewport-" page-id "-" vern)} + (if ^boolean render-wasm? + [:> viewport.wasm/viewport* props] + [:> viewport-classic* props])])) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 8cb0910f1f..c49034de3a 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -79,7 +79,7 @@ (apply-modifiers-to-objects objects (select-keys (into {} modifiers) selected))) (mf/defc viewport* - [{:keys [selected wglobal layout file page palete-size file-version-id]}] + [{:keys [selected wglobal layout file page palete-size]}] (let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check ;; that the new parameter is sent @@ -111,6 +111,7 @@ workspace-editor-state (mf/deref refs/workspace-editor-state) file-id (get file :id) + vern (get file :vern) objects (get page :objects) page-id (get page :id) background (get page :background clr/canvas) @@ -154,7 +155,7 @@ canvas-ref (mf/use-ref nil) text-editor-ref (mf/use-ref nil) - last-file-version-id-ref (mf/use-ref nil) + last-vern-ref (mf/use-ref nil) ;; STATE REFS disable-paste-ref (mf/use-ref false) @@ -391,10 +392,11 @@ (when (and @canvas-init? preview-blend) (wasm.api/request-render "with-effect"))) - (mf/with-effect [@canvas-init? file-version-id zoom vbox background] + (mf/with-effect [@canvas-init? vern zoom vbox background] (when @canvas-init? (if (not @initialized?) (do + (mf/set-ref-val! last-vern-ref vern) ;; Initial file open uses the same transition workflow as page switches, ;; but with a solid background-color blurred placeholder. (wasm.api/start-initial-load-transition! background) @@ -402,14 +404,12 @@ ;; blank canvas (first load) visible while shapes load. ;; The loading overlay is suppressed because on-shapes-ready ;; is set. - (wasm.api/initialize-viewport - base-objects zoom vbox :background background) - (reset! initialized? true) - (mf/set-ref-val! last-file-version-id-ref file-version-id)) - (when (and (some? file-version-id) - (not= file-version-id (mf/ref-val last-file-version-id-ref))) (wasm.api/initialize-viewport base-objects zoom vbox :background background) - (mf/set-ref-val! last-file-version-id-ref file-version-id))))) + (reset! initialized? true)) + + (when (and (some? vern) (not= vern (mf/ref-val last-vern-ref))) + (wasm.api/initialize-viewport base-objects zoom vbox :background background) + (mf/set-ref-val! last-vern-ref vern))))) (mf/with-effect [focus] (when (and @canvas-init? @initialized?)