From 5775fa61ba398cbc3569c41a181b9cf50adfdd0c Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 21 Jan 2026 14:42:21 +0100 Subject: [PATCH 01/17] :wrench: Refactor ShapesPool to use index-based storage instead of unsafe lifetime references Replace `HashMap<&'a Uuid, ...>` with `HashMap` for all auxiliary maps (modifiers, structure, scale_content, modified_shape_cache) --- render-wasm/src/main.rs | 2 +- render-wasm/src/state.rs | 21 +- render-wasm/src/state/shapes_pool.rs | 328 ++++++++++----------------- 3 files changed, 127 insertions(+), 224 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 162732d551..11b23309c7 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -23,7 +23,7 @@ use std::collections::HashMap; use utils::uuid_from_u32_quartet; use uuid::Uuid; -pub(crate) static mut STATE: Option>> = None; +pub(crate) static mut STATE: Option> = None; #[macro_export] macro_rules! with_state_mut { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index f178ed4477..b2950e781b 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -18,16 +18,16 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data; /// It is created by [init] and passed to the other exported functions. /// Note that rust-skia data structures are not thread safe, so a state /// must not be shared between different Web Workers. -pub(crate) struct State<'a> { +pub(crate) struct State { pub render_state: RenderState, pub text_editor_state: TextEditorState, pub current_id: Option, pub current_browser: u8, - pub shapes: ShapesPool<'a>, - pub saved_shapes: Option>, + pub shapes: ShapesPool, + pub saved_shapes: Option, } -impl<'a> State<'a> { +impl State { pub fn new(width: i32, height: i32) -> Self { State { render_state: RenderState::new(width, height), @@ -224,16 +224,9 @@ impl<'a> State<'a> { } pub fn rebuild_modifier_tiles(&mut self, ids: Vec) { - // SAFETY: We're extending the lifetime of the mutable borrow to 'a. - // This is safe because: - // 1. shapes has lifetime 'a in the struct - // 2. The reference won't outlive the struct - // 3. No other references to shapes exist during this call - unsafe { - let shapes_ptr = &mut self.shapes as *mut ShapesPool<'a>; - self.render_state - .rebuild_modifier_tiles(&mut *shapes_ptr, ids); - } + // No longer need unsafe lifetime extension - index-based storage is safe + self.render_state + .rebuild_modifier_tiles(&mut self.shapes, ids); } pub fn font_collection(&self) -> &FontCollection { diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index cdbc8c3caa..4edb41069e 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -28,29 +28,44 @@ const SHAPES_POOL_ALLOC_MULTIPLIER: f32 = 1.3; /// Shapes are stored in a `Vec`, which keeps the `Shape` instances /// in a contiguous memory block. /// -pub struct ShapesPoolImpl<'a> { +/// # Index-based Design +/// +/// All auxiliary HashMaps (modifiers, structure, scale_content, modified_shape_cache) +/// use `usize` indices instead of `&'a Uuid` references. This eliminates: +/// - Unsafe lifetime extensions +/// - The need for `rebuild_references()` after Vec reallocation +/// - Complex lifetime annotations +/// +/// The `uuid_to_idx` HashMap maps `Uuid` (owned) to indices, avoiding lifetime issues. +/// +pub struct ShapesPoolImpl { shapes: Vec, counter: usize, - shapes_uuid_to_idx: HashMap<&'a Uuid, usize>, + /// Maps UUID to index in the shapes Vec. Uses owned Uuid, no lifetime needed. + uuid_to_idx: HashMap, - modified_shape_cache: HashMap<&'a Uuid, OnceCell>, - modifiers: HashMap<&'a Uuid, skia::Matrix>, - structure: HashMap<&'a Uuid, Vec>, - scale_content: HashMap<&'a Uuid, f32>, + /// Cache for modified shapes, keyed by index + modified_shape_cache: HashMap>, + /// Transform modifiers, keyed by index + modifiers: HashMap, + /// Structure entries, keyed by index + structure: HashMap>, + /// Scale content values, keyed by index + scale_content: HashMap, } -// Type aliases to avoid writing lifetimes everywhere -pub type ShapesPool<'a> = ShapesPoolImpl<'a>; -pub type ShapesPoolRef<'a> = &'a ShapesPoolImpl<'a>; -pub type ShapesPoolMutRef<'a> = &'a mut ShapesPoolImpl<'a>; +// Type aliases - no longer need lifetimes! +pub type ShapesPool = ShapesPoolImpl; +pub type ShapesPoolRef<'a> = &'a ShapesPoolImpl; +pub type ShapesPoolMutRef<'a> = &'a mut ShapesPoolImpl; -impl<'a> ShapesPoolImpl<'a> { +impl ShapesPoolImpl { pub fn new() -> Self { ShapesPoolImpl { shapes: vec![], counter: 0, - shapes_uuid_to_idx: HashMap::default(), + uuid_to_idx: HashMap::default(), modified_shape_cache: HashMap::default(), modifiers: HashMap::default(), @@ -62,15 +77,14 @@ impl<'a> ShapesPoolImpl<'a> { pub fn initialize(&mut self, capacity: usize) { performance::begin_measure!("shapes_pool_initialize"); self.counter = 0; - self.shapes_uuid_to_idx = HashMap::with_capacity(capacity); + self.uuid_to_idx = HashMap::with_capacity(capacity); let additional = capacity as i32 - self.shapes.len() as i32; if additional <= 0 { return; } - // Reserve exact capacity to avoid any future reallocations - // This is critical because we store &'a Uuid references that would be invalidated + // Reserve extra capacity to avoid future reallocations let target_capacity = (capacity as f32 * SHAPES_POOL_ALLOC_MULTIPLIER) as usize; self.shapes .reserve_exact(target_capacity.saturating_sub(self.shapes.len())); @@ -81,15 +95,13 @@ impl<'a> ShapesPoolImpl<'a> { } pub fn add_shape(&mut self, id: Uuid) -> &mut Shape { - let did_reallocate = if self.counter >= self.shapes.len() { - // We need more space. Check if we'll need to reallocate the Vec. + if self.counter >= self.shapes.len() { + // We need more space let current_capacity = self.shapes.capacity(); let additional = (self.shapes.len() as f32 * SHAPES_POOL_ALLOC_MULTIPLIER) as usize; let needed_capacity = self.shapes.len() + additional; - let will_reallocate = needed_capacity > current_capacity; - - if will_reallocate { + if needed_capacity > current_capacity { // Reserve extra space to minimize future reallocations let extra_reserve = (needed_capacity as f32 * 0.5) as usize; self.shapes @@ -98,165 +110,68 @@ impl<'a> ShapesPoolImpl<'a> { self.shapes .extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional)); - - will_reallocate - } else { - false - }; + } let idx = self.counter; let new_shape = &mut self.shapes[idx]; new_shape.id = id; - // Get a reference to the id field in the shape with lifetime 'a - // SAFETY: This is safe because: - // 1. We pre-allocate enough capacity to avoid Vec reallocation - // 2. The shape and its id field won't move within the Vec - // 3. The reference won't outlive the ShapesPoolImpl - let id_ref: &'a Uuid = unsafe { &*(&self.shapes[idx].id as *const Uuid) }; - - self.shapes_uuid_to_idx.insert(id_ref, idx); + // Simply store the UUID -> index mapping. No unsafe lifetime tricks needed! + self.uuid_to_idx.insert(id, idx); self.counter += 1; - // If the Vec reallocated, we need to rebuild all references in the HashMaps - // because the old references point to deallocated memory - if did_reallocate { - self.rebuild_references(); - } - &mut self.shapes[idx] } - - /// Rebuilds all &'a Uuid references in the HashMaps after a Vec reallocation. - /// This is necessary because Vec reallocation invalidates all existing references. - fn rebuild_references(&mut self) { - // Rebuild shapes_uuid_to_idx with fresh references - let mut new_map = HashMap::with_capacity(self.shapes_uuid_to_idx.len()); - for (_, idx) in self.shapes_uuid_to_idx.drain() { - let id_ref: &'a Uuid = unsafe { &*(&self.shapes[idx].id as *const Uuid) }; - new_map.insert(id_ref, idx); - } - self.shapes_uuid_to_idx = new_map; - - // Rebuild modifiers with fresh references - if !self.modifiers.is_empty() { - let old_modifiers: Vec<(Uuid, skia::Matrix)> = self - .modifiers - .drain() - .map(|(uuid_ref, matrix)| (*uuid_ref, matrix)) - .collect(); - - for (uuid, matrix) in old_modifiers { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modifiers.insert(uuid_ref, matrix); - } - } - } - - // Rebuild structure with fresh references - if !self.structure.is_empty() { - let old_structure: Vec<(Uuid, Vec)> = self - .structure - .drain() - .map(|(uuid_ref, entries)| (*uuid_ref, entries)) - .collect(); - - for (uuid, entries) in old_structure { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.structure.insert(uuid_ref, entries); - } - } - } - - // Rebuild scale_content with fresh references - if !self.scale_content.is_empty() { - let old_scale_content: Vec<(Uuid, f32)> = self - .scale_content - .drain() - .map(|(uuid_ref, scale)| (*uuid_ref, scale)) - .collect(); - - for (uuid, scale) in old_scale_content { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.scale_content.insert(uuid_ref, scale); - } - } - } - // Rebuild modified_shape_cache with fresh references - if !self.modified_shape_cache.is_empty() { - let old_cache: Vec<(Uuid, OnceCell)> = self - .modified_shape_cache - .drain() - .map(|(uuid_ref, cell)| (*uuid_ref, cell)) - .collect(); - - for (uuid, cell) in old_cache { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, cell); - } - } - } - } + // No longer needed! Index-based storage means no references to rebuild. + // The old rebuild_references() function has been removed entirely. pub fn len(&self) -> usize { - self.shapes_uuid_to_idx.len() + self.uuid_to_idx.len() } pub fn has(&self, id: &Uuid) -> bool { - self.shapes_uuid_to_idx.contains_key(&id) + self.uuid_to_idx.contains_key(id) } pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut Shape> { - let idx = *self.shapes_uuid_to_idx.get(&id)?; + let idx = *self.uuid_to_idx.get(id)?; Some(&mut self.shapes[idx]) } - pub fn get(&self, id: &Uuid) -> Option<&'a Shape> { - let idx = *self.shapes_uuid_to_idx.get(&id)?; + /// Get a shape by UUID. Returns the modified shape if modifiers/structure + /// are applied, otherwise returns the base shape. + pub fn get(&self, id: &Uuid) -> Option<&Shape> { + let idx = *self.uuid_to_idx.get(id)?; - // SAFETY: We're extending the lifetimes to 'a. - // This is safe because: - // 1. All internal HashMaps and the shapes Vec have fields with lifetime 'a - // 2. The shape at idx won't be moved or reallocated (pre-allocated Vec) - // 3. The id is stored in shapes[idx].id which has lifetime 'a - // 4. The references won't outlive the ShapesPoolImpl - unsafe { - let shape_ptr = &self.shapes[idx] as *const Shape; - let modifiers_ptr = &self.modifiers as *const HashMap<&'a Uuid, skia::Matrix>; - let structure_ptr = &self.structure as *const HashMap<&'a Uuid, Vec>; - let scale_content_ptr = &self.scale_content as *const HashMap<&'a Uuid, f32>; - let cache_ptr = &self.modified_shape_cache as *const HashMap<&'a Uuid, OnceCell>; + let shape = &self.shapes[idx]; - // Extend the lifetime of id to 'a - safe because it's the same Uuid stored in shapes[idx].id - let id_ref: &'a Uuid = &*(id as *const Uuid); + // Check if this shape needs modification (has modifiers, structure changes, or is a bool) + let needs_modification = shape.is_bool() + || self.modifiers.contains_key(&idx) + || self.structure.contains_key(&idx) + || self.scale_content.contains_key(&idx); - if (*shape_ptr).is_bool() - || (*modifiers_ptr).contains_key(&id_ref) - || (*structure_ptr).contains_key(&id_ref) - || (*scale_content_ptr).contains_key(&id_ref) - { - if let Some(cell) = (*cache_ptr).get(&id_ref) { - Some(cell.get_or_init(|| { - let mut shape = (*shape_ptr).transformed( - (*modifiers_ptr).get(&id_ref), - (*structure_ptr).get(&id_ref), - ); + if needs_modification { + // Check if we have a cached modified version + if let Some(cell) = self.modified_shape_cache.get(&idx) { + Some(cell.get_or_init(|| { + let mut modified_shape = + shape.transformed(self.modifiers.get(&idx), self.structure.get(&idx)); - if self.to_update_bool(&shape) { - math_bools::update_bool_to_path(&mut shape, self); - } + if self.to_update_bool(&modified_shape) { + math_bools::update_bool_to_path(&mut modified_shape, self); + } - if let Some(scale) = (*scale_content_ptr).get(&id_ref) { - shape.scale_content(*scale); - } - shape - })) - } else { - Some(&*shape_ptr) - } + if let Some(scale) = self.scale_content.get(&idx) { + modified_shape.scale_content(*scale); + } + modified_shape + })) } else { - Some(&*shape_ptr) + Some(shape) } + } else { + Some(shape) } } @@ -275,69 +190,68 @@ impl<'a> ShapesPoolImpl<'a> { } pub fn set_modifiers(&mut self, modifiers: HashMap) { - // Convert HashMap to HashMap<&'a Uuid, V> using references from shapes and - // Initialize the cache cells because later we don't want to have the mutable pointer + // Convert HashMap to HashMap using indices + // Initialize the cache cells for affected shapes let mut ids = Vec::::new(); + let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); - let mut modifiers_with_refs = HashMap::with_capacity(modifiers.len()); for (uuid, matrix) in modifiers { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - // self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); - modifiers_with_refs.insert(uuid_ref, matrix); - ids.push(*uuid_ref); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + modifiers_with_idx.insert(idx, matrix); + ids.push(uuid); } } - self.modifiers = modifiers_with_refs; + self.modifiers = modifiers_with_idx; let all_ids = shapes::all_with_ancestors(&ids, self, true); for uuid in all_ids { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } } } pub fn set_structure(&mut self, structure: HashMap>) { - // Convert HashMap to HashMap<&'a Uuid, V> using references from shapes and - // Initialize the cache cells because later we don't want to have the mutable pointer - let mut structure_with_refs = HashMap::with_capacity(structure.len()); + // Convert HashMap to HashMap using indices + // Initialize the cache cells for affected shapes + let mut structure_with_idx = HashMap::with_capacity(structure.len()); let mut ids = Vec::::new(); for (uuid, entries) in structure { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - structure_with_refs.insert(uuid_ref, entries); - ids.push(*uuid_ref); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + structure_with_idx.insert(idx, entries); + ids.push(uuid); } } - self.structure = structure_with_refs; + self.structure = structure_with_idx; let all_ids = shapes::all_with_ancestors(&ids, self, true); for uuid in all_ids { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } } } pub fn set_scale_content(&mut self, scale_content: HashMap) { - // Convert HashMap to HashMap<&'a Uuid, V> using references from shapes and - // Initialize the cache cells because later we don't want to have the mutable pointer - let mut scale_content_with_refs = HashMap::with_capacity(scale_content.len()); + // Convert HashMap to HashMap using indices + // Initialize the cache cells for affected shapes + let mut scale_content_with_idx = HashMap::with_capacity(scale_content.len()); let mut ids = Vec::::new(); for (uuid, value) in scale_content { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - scale_content_with_refs.insert(uuid_ref, value); - ids.push(*uuid_ref); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + scale_content_with_idx.insert(idx, value); + ids.push(uuid); } } - self.scale_content = scale_content_with_refs; + self.scale_content = scale_content_with_idx; let all_ids = shapes::all_with_ancestors(&ids, self, true); for uuid in all_ids { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } } } @@ -349,47 +263,33 @@ impl<'a> ShapesPoolImpl<'a> { self.scale_content = HashMap::default(); } - /// Get a reference to the Uuid stored in a shape, if it exists - pub fn get_uuid_ref(&self, id: &Uuid) -> Option<&'a Uuid> { - let idx = *self.shapes_uuid_to_idx.get(&id)?; - // SAFETY: We're returning a reference with lifetime 'a to a Uuid stored - // in the shapes Vec. This is safe because the Vec is stable (pre-allocated) - // and won't be reallocated. - unsafe { Some(&*(&self.shapes[idx].id as *const Uuid)) } - } - - pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl<'a> { + pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl { let Some(shape) = self.get(id) else { panic!("Subtree not found"); }; let mut shapes = vec![]; - let mut idx = 0; - let mut shapes_uuid_to_idx = HashMap::default(); + let mut new_idx = 0; + let mut uuid_to_idx = HashMap::default(); - for id in shape.all_children_iter(self, true, true) { - let Some(shape) = self.get(&id) else { + for child_id in shape.all_children_iter(self, true, true) { + let Some(child_shape) = self.get(&child_id) else { panic!("Not found"); }; - shapes.push(shape.clone()); - - let id_ref: &'a Uuid = unsafe { &*(&self.shapes[idx].id as *const Uuid) }; - shapes_uuid_to_idx.insert(id_ref, idx); - idx += 1; + shapes.push(child_shape.clone()); + uuid_to_idx.insert(child_id, new_idx); + new_idx += 1; } - let mut result = ShapesPoolImpl { + ShapesPoolImpl { shapes, - counter: idx, - shapes_uuid_to_idx, + counter: new_idx, + uuid_to_idx, modified_shape_cache: HashMap::default(), modifiers: HashMap::default(), structure: HashMap::default(), scale_content: HashMap::default(), - }; - result.rebuild_references(); - - result + } } fn to_update_bool(&self, shape: &Shape) -> bool { @@ -398,11 +298,21 @@ impl<'a> ShapesPoolImpl<'a> { } let default = &Matrix::default(); - let parent_modifier = self.modifiers.get(&shape.id).unwrap_or(default); + + // Get parent modifier by index + let parent_idx = self.uuid_to_idx.get(&shape.id); + let parent_modifier = parent_idx + .and_then(|idx| self.modifiers.get(idx)) + .unwrap_or(default); // Returns true if the transform of any child is different to the parent's - shape.all_children_iter(self, true, false).any(|id| { - !math::is_close_matrix(parent_modifier, self.modifiers.get(&id).unwrap_or(default)) + shape.all_children_iter(self, true, false).any(|child_id| { + let child_modifier = self + .uuid_to_idx + .get(&child_id) + .and_then(|idx| self.modifiers.get(idx)) + .unwrap_or(default); + !math::is_close_matrix(parent_modifier, child_modifier) }) } } From 83387701a03e2ec46b3a417ae4e700cf25f92c25 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 21 Jan 2026 14:45:13 +0100 Subject: [PATCH 02/17] :wrench: Add batched shape base properties serialization for improved WASM performance --- frontend/src/app/render_wasm/api.cljs | 33 +-- frontend/src/app/render_wasm/api/shapes.cljs | 199 ++++++++++++++++++ render-wasm/src/wasm/layouts.rs | 2 +- render-wasm/src/wasm/shapes/base_props.rs | 180 ++++++++++++++++ .../src/wasm/{shapes.rs => shapes/mod.rs} | 2 + 5 files changed, 389 insertions(+), 27 deletions(-) create mode 100644 frontend/src/app/render_wasm/api/shapes.cljs create mode 100644 render-wasm/src/wasm/shapes/base_props.rs rename render-wasm/src/wasm/{shapes.rs => shapes/mod.rs} (98%) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 24e054cfc0..7e33fdc1ca 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -28,6 +28,7 @@ [app.main.ui.shapes.text] [app.main.worker :as mw] [app.render-wasm.api.fonts :as f] + [app.render-wasm.api.shapes :as shapes] [app.render-wasm.api.texts :as t] [app.render-wasm.api.webgl :as webgl] [app.render-wasm.deserializers :as dr] @@ -895,24 +896,12 @@ id (dm/get-prop shape :id) type (dm/get-prop shape :type) - parent-id (get shape :parent-id) masked (get shape :masked-group) - selrect (get shape :selrect) - constraint-h (get shape :constraints-h) - constraint-v (get shape :constraints-v) - clip-content (if (= type :frame) - (not (get shape :show-content)) - false) - rotation (get shape :rotation) - transform (get shape :transform) fills (get shape :fills) strokes (if (= type :group) [] (get shape :strokes)) children (get shape :shapes) - blend-mode (get shape :blend-mode) - opacity (get shape :opacity) - hidden (get shape :hidden) content (let [content (get shape :content)] (if (= type :text) (ensure-text-content content) @@ -921,22 +910,15 @@ grow-type (get shape :grow-type) blur (get shape :blur) svg-attrs (get shape :svg-attrs) - shadows (get shape :shadow) - corners (map #(get shape %) [:r1 :r2 :r3 :r4])] + shadows (get shape :shadow)] - (use-shape id) - (set-parent-id parent-id) - (set-shape-type type) - (set-shape-clip-content clip-content) - (set-shape-constraints constraint-h constraint-v) + ;; Batched call: sets id, parent_id, type, clip_content, hidden, blend_mode, + ;; constraints, opacity, rotation, transform, selrect, and corners in one WASM call. + ;; This replaces 12+ individual WASM calls with a single batched operation. + (shapes/set-shape-base-props shape) - (set-shape-rotation rotation) - (set-shape-transform transform) - (set-shape-blend-mode blend-mode) - (set-shape-opacity opacity) - (set-shape-hidden hidden) + ;; Remaining properties that need separate calls (variable-length or conditional) (set-shape-children children) - (set-shape-corners corners) (set-shape-blur blur) (when (= type :group) (set-masked (boolean masked))) @@ -956,7 +938,6 @@ (set-shape-layout shape) (set-layout-data shape) - (set-shape-selrect selrect) (let [pending_thumbnails (into [] (concat (set-shape-text-content id content) diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/frontend/src/app/render_wasm/api/shapes.cljs new file mode 100644 index 0000000000..3ab680dac4 --- /dev/null +++ b/frontend/src/app/render_wasm/api/shapes.cljs @@ -0,0 +1,199 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.render-wasm.api.shapes + "Batched shape property serialization for improved WASM performance. + + This module provides a single WASM call to set all base shape properties, + replacing multiple individual calls (use_shape, set_parent, set_shape_type, + etc.) with one batched operation." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.uuid :as uuid] + [app.render-wasm.helpers :as h] + [app.render-wasm.mem :as mem] + [app.render-wasm.serializers :as sr] + [app.render-wasm.wasm :as wasm])) + +;; Binary layout constants matching Rust implementation: +;; +;; | Offset | Size | Field | Type | +;; |--------|------|--------------|-----------------------------------| +;; | 0 | 16 | id | UUID (4 × u32 LE) | +;; | 16 | 16 | parent_id | UUID (4 × u32 LE) | +;; | 32 | 1 | shape_type | u8 | +;; | 33 | 1 | flags | u8 (bit0: clip, bit1: hidden) | +;; | 34 | 1 | blend_mode | u8 | +;; | 35 | 1 | constraint_h | u8 (0xFF = None) | +;; | 36 | 1 | constraint_v | u8 (0xFF = None) | +;; | 37 | 3 | padding | - | +;; | 40 | 4 | opacity | f32 LE | +;; | 44 | 4 | rotation | f32 LE | +;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) | +;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) | +;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) | +;; |--------|------|--------------|-----------------------------------| +;; | Total | 104 | | | + +(def ^:const BASE-PROPS-SIZE 104) +(def ^:const FLAG-CLIP-CONTENT 0x01) +(def ^:const FLAG-HIDDEN 0x02) +(def ^:const CONSTRAINT-NONE 0xFF) + +(defn- write-uuid-to-heap + "Write a UUID to the heap at the given byte offset using DataView." + [dview offset id] + (let [buffer (uuid/get-u32 id)] + (.setUint32 dview offset (aget buffer 0) true) + (.setUint32 dview (+ offset 4) (aget buffer 1) true) + (.setUint32 dview (+ offset 8) (aget buffer 2) true) + (.setUint32 dview (+ offset 12) (aget buffer 3) true))) + +(defn- serialize-transform + "Extract transform matrix values, defaulting to identity matrix." + [transform] + (if (some? transform) + [(dm/get-prop transform :a) + (dm/get-prop transform :b) + (dm/get-prop transform :c) + (dm/get-prop transform :d) + (dm/get-prop transform :e) + (dm/get-prop transform :f)] + [1.0 0.0 0.0 1.0 0.0 0.0])) ; identity matrix + +(defn- serialize-selrect + "Extract selrect values." + [selrect] + (if (some? selrect) + [(dm/get-prop selrect :x1) + (dm/get-prop selrect :y1) + (dm/get-prop selrect :x2) + (dm/get-prop selrect :y2)] + [0.0 0.0 0.0 0.0])) + +(defn set-shape-base-props + "Set all base shape properties in a single WASM call. + + This replaces the following individual calls: + - use-shape + - set-parent-id + - set-shape-type + - set-shape-clip-content + - set-shape-rotation + - set-shape-transform + - set-shape-blend-mode + - set-shape-opacity + - set-shape-hidden + - set-shape-selrect + - set-shape-corners + - set-shape-constraints (clear + h + v) + + Returns nil." + [shape] + (when wasm/context-initialized? + (let [;; Extract all properties from shape + id (dm/get-prop shape :id) + parent-id (get shape :parent-id) + shape-type (dm/get-prop shape :type) + + ;; Boolean flags + clip-content (if (= shape-type :frame) + (not (get shape :show-content)) + false) + hidden (get shape :hidden false) + + ;; Compute flags byte + flags (cond-> 0 + clip-content (bit-or FLAG-CLIP-CONTENT) + hidden (bit-or FLAG-HIDDEN)) + + ;; Enum values + blend-mode (sr/translate-blend-mode (get shape :blend-mode)) + constraint-h (let [c (get shape :constraints-h)] + (if (some? c) + (sr/translate-constraint-h c) + CONSTRAINT-NONE)) + constraint-v (let [c (get shape :constraints-v)] + (if (some? c) + (sr/translate-constraint-v c) + CONSTRAINT-NONE)) + + ;; Float values + opacity (d/nilv (get shape :opacity) 1.0) + rotation (d/nilv (get shape :rotation) 0.0) + + ;; Transform matrix + [ta tb tc td te tf] (serialize-transform (get shape :transform)) + + ;; Selrect + selrect (get shape :selrect) + [sx1 sy1 sx2 sy2] (serialize-selrect selrect) + + ;; Corners + r1 (d/nilv (get shape :r1) 0.0) + r2 (d/nilv (get shape :r2) 0.0) + r3 (d/nilv (get shape :r3) 0.0) + r4 (d/nilv (get shape :r4) 0.0) + + ;; Allocate buffer and get DataView + offset (mem/alloc BASE-PROPS-SIZE) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap))] + + ;; Write id (offset 0, 16 bytes) + (write-uuid-to-heap dview offset id) + + ;; Write parent_id (offset 16, 16 bytes) + (write-uuid-to-heap dview (+ offset 16) (d/nilv parent-id uuid/zero)) + + ;; Write shape_type (offset 32, 1 byte) + (.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type)) + + ;; Write flags (offset 33, 1 byte) + (.setUint8 dview (+ offset 33) flags) + + ;; Write blend_mode (offset 34, 1 byte) + (.setUint8 dview (+ offset 34) blend-mode) + + ;; Write constraint_h (offset 35, 1 byte) + (.setUint8 dview (+ offset 35) constraint-h) + + ;; Write constraint_v (offset 36, 1 byte) + (.setUint8 dview (+ offset 36) constraint-v) + + ;; Padding at offset 37-39 (already zero from alloc) + + ;; Write opacity (offset 40, f32) + (.setFloat32 dview (+ offset 40) opacity true) + + ;; Write rotation (offset 44, f32) + (.setFloat32 dview (+ offset 44) rotation true) + + ;; Write transform matrix (offset 48, 6 × f32) + (.setFloat32 dview (+ offset 48) ta true) + (.setFloat32 dview (+ offset 52) tb true) + (.setFloat32 dview (+ offset 56) tc true) + (.setFloat32 dview (+ offset 60) td true) + (.setFloat32 dview (+ offset 64) te true) + (.setFloat32 dview (+ offset 68) tf true) + + ;; Write selrect (offset 72, 4 × f32) + (.setFloat32 dview (+ offset 72) sx1 true) + (.setFloat32 dview (+ offset 76) sy1 true) + (.setFloat32 dview (+ offset 80) sx2 true) + (.setFloat32 dview (+ offset 84) sy2 true) + + ;; Write corners (offset 88, 4 × f32) + (.setFloat32 dview (+ offset 88) r1 true) + (.setFloat32 dview (+ offset 92) r2 true) + (.setFloat32 dview (+ offset 96) r3 true) + (.setFloat32 dview (+ offset 100) r4 true) + + ;; Call WASM function + (h/call wasm/internal-module "_set_shape_base_props") + + nil))) diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index 903edaa0e6..9f77a21429 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -3,7 +3,7 @@ use crate::{with_current_shape_mut, STATE}; use macros::ToJs; mod align; -mod constraints; +pub mod constraints; mod flex; mod grid; diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs new file mode 100644 index 0000000000..4846d9b320 --- /dev/null +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -0,0 +1,180 @@ +use crate::mem; +use crate::shapes::{BlendMode, ConstraintH, ConstraintV}; +use crate::utils::uuid_from_u32_quartet; +use crate::uuid::Uuid; +use crate::wasm::blend::RawBlendMode; +use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV}; +use crate::{with_state_mut, STATE}; + +use super::RawShapeType; + +/// Binary layout for batched shape base properties: +/// +/// | Offset | Size | Field | Type | +/// |--------|------|--------------|-----------------------------------| +/// | 0 | 16 | id | UUID (4 × u32 LE) | +/// | 16 | 16 | parent_id | UUID (4 × u32 LE) | +/// | 32 | 1 | shape_type | u8 | +/// | 33 | 1 | flags | u8 (bit0: clip, bit1: hidden) | +/// | 34 | 1 | blend_mode | u8 | +/// | 35 | 1 | constraint_h | u8 (0xFF = None) | +/// | 36 | 1 | constraint_v | u8 (0xFF = None) | +/// | 37 | 3 | padding | - | +/// | 40 | 4 | opacity | f32 LE | +/// | 44 | 4 | rotation | f32 LE | +/// | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) | +/// | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) | +/// | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) | +/// |--------|------|--------------|-----------------------------------| +/// | Total | 104 | | | + +pub const BASE_PROPS_SIZE: usize = 104; + +const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; +const FLAG_HIDDEN: u8 = 0b0000_0010; +const CONSTRAINT_NONE: u8 = 0xFF; + +/// Reads a f32 from a byte slice at the given offset (little-endian) +#[inline] +fn read_f32_le(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +/// Reads a u32 from a byte slice at the given offset (little-endian) +#[inline] +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +/// Parses UUID from bytes at given offset +#[inline] +fn read_uuid(bytes: &[u8], offset: usize) -> Uuid { + uuid_from_u32_quartet( + read_u32_le(bytes, offset), + read_u32_le(bytes, offset + 4), + read_u32_le(bytes, offset + 8), + read_u32_le(bytes, offset + 12), + ) +} + +/// Sets base shape properties from a pre-allocated buffer in a single WASM call. +/// +/// This replaces multiple individual WASM calls (use_shape, set_parent, set_shape_type, +/// set_shape_clip_content, set_shape_rotation, set_shape_transform, set_shape_blend_mode, +/// set_shape_opacity, set_shape_hidden, set_shape_selrect, set_shape_corners, +/// set_shape_constraints) with a single batched call. +#[no_mangle] +pub extern "C" fn set_shape_base_props() { + let bytes = mem::bytes(); + + if bytes.len() < BASE_PROPS_SIZE { + return; + } + + // Parse all fields from the buffer + let id = read_uuid(&bytes, 0); + let parent_id = read_uuid(&bytes, 16); + let shape_type = bytes[32]; + let flags = bytes[33]; + let blend_mode = bytes[34]; + let constraint_h = bytes[35]; + let constraint_v = bytes[36]; + // bytes[37..40] are padding + + let opacity = read_f32_le(&bytes, 40); + let rotation = read_f32_le(&bytes, 44); + + // Transform matrix (a, b, c, d, e, f) + let transform_a = read_f32_le(&bytes, 48); + let transform_b = read_f32_le(&bytes, 52); + let transform_c = read_f32_le(&bytes, 56); + let transform_d = read_f32_le(&bytes, 60); + let transform_e = read_f32_le(&bytes, 64); + let transform_f = read_f32_le(&bytes, 68); + + // Selrect (x1, y1, x2, y2) + let selrect_x1 = read_f32_le(&bytes, 72); + let selrect_y1 = read_f32_le(&bytes, 76); + let selrect_x2 = read_f32_le(&bytes, 80); + let selrect_y2 = read_f32_le(&bytes, 84); + + // Corners (r1, r2, r3, r4) + let corner_r1 = read_f32_le(&bytes, 88); + let corner_r2 = read_f32_le(&bytes, 92); + let corner_r3 = read_f32_le(&bytes, 96); + let corner_r4 = read_f32_le(&bytes, 100); + + // Decode flags + let clip_content = (flags & FLAG_CLIP_CONTENT) != 0; + let hidden = (flags & FLAG_HIDDEN) != 0; + + // Convert raw enum values + let shape_type_enum = RawShapeType::from(shape_type); + let blend_mode_enum: BlendMode = RawBlendMode::from(blend_mode).into(); + + let constraint_h_opt: Option = if constraint_h == CONSTRAINT_NONE { + None + } else { + Some(RawConstraintH::from(constraint_h).into()) + }; + + let constraint_v_opt: Option = if constraint_v == CONSTRAINT_NONE { + None + } else { + Some(RawConstraintV::from(constraint_v).into()) + }; + + with_state_mut!(state, { + // Select/create the shape + state.use_shape(id); + + // Set parent relationship + state.set_parent_for_current_shape(parent_id); + + // Mark shape as touched + state.touch_current(); + + // Apply all properties to the current shape + if let Some(shape) = state.current_shape_mut() { + // Type + shape.set_shape_type(shape_type_enum.into()); + + // Boolean flags + shape.set_clip(clip_content); + shape.set_hidden(hidden); + + // Blend mode and opacity + shape.set_blend_mode(blend_mode_enum); + shape.set_opacity(opacity); + + // Constraints + shape.set_constraint_h(constraint_h_opt); + shape.set_constraint_v(constraint_v_opt); + + // Transform + shape.set_rotation(rotation); + shape.set_transform( + transform_a, + transform_b, + transform_c, + transform_d, + transform_e, + transform_f, + ); + + // Geometry + shape.set_selrect(selrect_x1, selrect_y1, selrect_x2, selrect_y2); + shape.set_corners((corner_r1, corner_r2, corner_r3, corner_r4)); + } + }); +} diff --git a/render-wasm/src/wasm/shapes.rs b/render-wasm/src/wasm/shapes/mod.rs similarity index 98% rename from render-wasm/src/wasm/shapes.rs rename to render-wasm/src/wasm/shapes/mod.rs index c2ff3b4931..3f32e824c3 100644 --- a/render-wasm/src/wasm/shapes.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -1,3 +1,5 @@ +mod base_props; + use macros::ToJs; use crate::shapes::{Bool, Frame, Group, Path, Rect, SVGRaw, TextContent, Type}; From 962d7839a24136c674b0651e629f9694a704da97 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 21 Jan 2026 14:47:40 +0100 Subject: [PATCH 03/17] :wrench: Add progressive rendering support for improved page load experience When loading large pages with many shapes, the UI now remains responsive by processing shapes in chunks (100 shapes at a time) and yielding to the browser between chunks. Preview renders are triggered at 25%, 50%, and 75% progress to give users visual feedback during loading. --- frontend/src/app/render_wasm/api.cljs | 221 +++++++++++++++++++++++--- render-wasm/src/main.rs | 14 ++ render-wasm/src/render.rs | 35 +++- render-wasm/src/state.rs | 4 + 4 files changed, 245 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 7e33fdc1ca..6df71e7e4f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -69,12 +69,29 @@ (def ^:const DEBOUNCE_DELAY_MS 100) (def ^:const THROTTLE_DELAY_MS 10) +;; Async chunked processing constants +;; SHAPES_CHUNK_SIZE: Number of shapes to process before yielding to browser +;; Lower values = more responsive UI but slower total processing +;; Higher values = faster total processing but may cause UI jank +(def ^:const SHAPES_CHUNK_SIZE 100) + +;; Threshold below which we use synchronous processing (no chunking overhead) +(def ^:const ASYNC_THRESHOLD 100) + (def dpr (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (def noop-fn (constantly nil)) +(defn- yield-to-browser + "Returns a promise that resolves after yielding to the browser's event loop. + Uses requestAnimationFrame for smooth visual updates during loading." + [] + (p/create + (fn [resolve _reject] + (js/requestAnimationFrame (fn [_] (resolve nil)))))) + ;; Based on app.main.render/object-svg (mf/defc object-svg {::mf/props :obj} @@ -121,17 +138,70 @@ (aget buffer 3)) (set! wasm/internal-frame-id nil)))) +(defn render-preview! + "Render a lightweight preview without tile caching. + Used during progressive loading for fast feedback." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_render_preview"))) + (defonce pending-render (atom false)) +(defonce shapes-loading? (atom false)) +(defonce deferred-render? (atom false)) + +(defn- register-deferred-render! + [] + (reset! deferred-render? true)) (defn request-render [_requester] - (when (and wasm/context-initialized? (not @pending-render) (not @wasm/context-lost?)) - (reset! pending-render true) - (js/requestAnimationFrame - (fn [ts] - (reset! pending-render false) - (render ts))))) + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (if @shapes-loading? + (register-deferred-render!) + (when-not @pending-render + (reset! pending-render true) + (let [frame-id + (js/requestAnimationFrame + (fn [ts] + (reset! pending-render false) + (set! wasm/internal-frame-id nil) + (render ts)))] + (set! wasm/internal-frame-id frame-id)))))) + +(defn- begin-shapes-loading! + [] + (reset! shapes-loading? true) + (let [frame-id wasm/internal-frame-id + was-pending @pending-render] + (when frame-id + (js/cancelAnimationFrame frame-id) + (set! wasm/internal-frame-id nil)) + (reset! pending-render false) + (reset! deferred-render? was-pending))) + +(defn- end-shapes-loading! + [] + (let [was-loading (compare-and-set! shapes-loading? true false)] + (reset! deferred-render? false) + ;; Always trigger a render after loading completes + ;; This ensures shapes are displayed even if no deferred render was requested + (when was-loading + (request-render "set-objects:flush")))) + +(defn- request-progressive-render! + ([reason] + (let [was-loading? @shapes-loading?] + (if was-loading? + (do + (reset! shapes-loading? false) + (try + (request-render reason) + (finally + (reset! shapes-loading? was-loading?)))) + (request-render reason)))) + ([] + (request-progressive-render! "set-objects:chunk"))) (declare get-text-dimensions) @@ -993,30 +1063,131 @@ (let [{:keys [thumbnails full]} (set-object shape)] (process-pending [shape] thumbnails full noop-fn))) +(defn- process-shapes-chunk + "Process a chunk of shapes synchronously, returning accumulated pending operations. + Returns {:thumbnails [...] :full [...] :next-index n}" + [shapes start-index chunk-size thumbnails-acc full-acc] + (let [total (count shapes) + end-index (min total (+ start-index chunk-size))] + (loop [index start-index + t-acc thumbnails-acc + f-acc full-acc] + (if (< index end-index) + (let [shape (nth shapes index) + {:keys [thumbnails full]} (set-object shape)] + (recur (inc index) + (into t-acc thumbnails) + (into f-acc full))) + {:thumbnails t-acc + :full f-acc + :next-index end-index})))) + +(defn- set-objects-async + "Asynchronously process shapes in chunks, yielding to the browser between chunks. + Returns a promise that resolves when all shapes are processed. + + Renders a preview only periodically during loading to show progress, + then does a full tile-based render at the end." + [shapes render-callback] + (let [total-shapes (count shapes) + ;; Calculate how many chunks we'll process + total-chunks (js/Math.ceil (/ total-shapes SHAPES_CHUNK_SIZE)) + ;; Render at 25%, 50%, 75% of loading + render-at-chunks (set [(js/Math.floor (* total-chunks 0.25)) + (js/Math.floor (* total-chunks 0.5)) + (js/Math.floor (* total-chunks 0.75))])] + (p/create + (fn [resolve _reject] + (letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count] + (if (< index total-shapes) + ;; Process one chunk + (let [{:keys [thumbnails full next-index]} + (process-shapes-chunk shapes index SHAPES_CHUNK_SIZE + thumbnails-acc full-acc) + new-chunk-count (inc chunk-count)] + ;; Only render at specific progress milestones + (when (contains? render-at-chunks new-chunk-count) + (render-preview!)) + + ;; Yield to browser, then continue with next chunk + (-> (yield-to-browser) + (p/then (fn [_] + (process-next-chunk next-index thumbnails full new-chunk-count))))) + ;; All chunks done - finalize + (do + (perf/end-measure "set-objects") + (process-pending shapes thumbnails-acc full-acc noop-fn + (fn [] + (end-shapes-loading!) + (if render-callback + (render-callback) + (render-finish)) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")) + (resolve nil))))))] + ;; Start processing + (process-next-chunk 0 [] [] 0)))))) + +(defn- set-objects-sync + "Synchronously process all shapes (for small shape counts)." + [shapes render-callback] + (let [total-shapes (count shapes) + {:keys [thumbnails full]} + (loop [index 0 thumbnails-acc [] full-acc []] + (if (< index total-shapes) + (let [shape (nth shapes index) + {:keys [thumbnails full]} (set-object shape)] + (recur (inc index) + (into thumbnails-acc thumbnails) + (into full-acc full))) + {:thumbnails thumbnails-acc :full full-acc}))] + (perf/end-measure "set-objects") + (process-pending shapes thumbnails full noop-fn + (fn [] + (if render-callback + (render-callback) + (render-finish)) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))) + +(defn- shapes-in-tree-order + "Returns shapes sorted in tree order (parents before children). + This ensures parent shapes are processed before their children, + maintaining proper shape reference consistency in WASM." + [objects] + ;; Get IDs in tree order starting from root (uuid/zero) + ;; cfh/get-children-ids-with-self returns [root-id, child1-id, grandchild1-id, ...] + (let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)] + (into [] + (keep #(get objects %)) + ordered-ids))) + (defn set-objects + "Set all shape objects for rendering. + + IMPORTANT: Shapes are processed in tree order (parents before children) + to maintain proper shape reference consistency in WASM. + + NOTE: Async processing uses render gating to avoid race conditions with + requestAnimationFrame renders during loading." ([objects] (set-objects objects nil)) ([objects render-callback] (perf/begin-measure "set-objects") - (let [shapes (into [] (vals objects)) - total-shapes (count shapes) - ;; Collect pending operations - set-object returns {:thumbnails [...] :full [...]} - {:keys [thumbnails full]} - (loop [index 0 thumbnails-acc [] full-acc []] - (if (< index total-shapes) - (let [shape (nth shapes index) - {:keys [thumbnails full]} (set-object shape)] - (recur (inc index) - (into thumbnails-acc thumbnails) - (into full-acc full))) - {:thumbnails thumbnails-acc :full full-acc}))] - (perf/end-measure "set-objects") - (process-pending shapes thumbnails full noop-fn - (fn [] - (if render-callback - (render-callback) - (render-finish)) - (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) + (let [shapes (shapes-in-tree-order objects) + total-shapes (count shapes)] + (if (< total-shapes ASYNC_THRESHOLD) + (set-objects-sync shapes render-callback) + (do + (begin-shapes-loading!) + (try + (-> (set-objects-async shapes render-callback) + (p/catch (fn [error] + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error)))) + (catch :default error + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error) + (throw error))) + nil))))) (defn clear-focus-mode [] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 11b23309c7..c23ce7a07c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -191,6 +191,20 @@ pub extern "C" fn render_from_cache(_: i32) { }); } +#[no_mangle] +pub extern "C" fn set_preview_mode(enabled: bool) { + with_state_mut!(state, { + state.render_state.set_preview_mode(enabled); + }); +} + +#[no_mangle] +pub extern "C" fn render_preview() { + with_state_mut!(state, { + state.render_preview(performance::get_time()); + }); +} + #[no_mangle] pub extern "C" fn process_animation_frame(timestamp: i32) { let result = std::panic::catch_unwind(|| { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f0619a9e37..47fafce5bf 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -294,6 +294,8 @@ pub(crate) struct RenderState { /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. pub ignore_nested_blurs: bool, + /// Preview render mode - when true, uses simplified rendering for progressive loading + pub preview_mode: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -366,6 +368,7 @@ impl RenderState { focus_mode: FocusMode::new(), touched_ids: HashSet::default(), ignore_nested_blurs: false, + preview_mode: false, } } @@ -486,6 +489,10 @@ impl RenderState { self.background_color = color; } + pub fn set_preview_mode(&mut self, enabled: bool) { + self.preview_mode = enabled; + } + pub fn resize(&mut self, width: i32, height: i32) { let dpr_width = (width as f32 * self.options.dpr()).floor() as i32; let dpr_height = (height as f32 * self.options.dpr()).floor() as i32; @@ -1127,6 +1134,25 @@ impl RenderState { performance::end_timed_log!("render_from_cache", _start); } + /// Render a preview of the shapes during loading. + /// This rebuilds tiles for touched shapes and renders synchronously. + pub fn render_preview(&mut self, tree: ShapesPoolRef, timestamp: i32) -> Result<(), String> { + let _start = performance::begin_timed_log!("render_preview"); + performance::begin_measure!("render_preview"); + + // Skip tile rebuilding during preview - we'll do it at the end + // Just rebuild tiles for touched shapes and render synchronously + self.rebuild_touched_tiles(tree); + + // Use the sync render path + self.start_render_loop(None, tree, timestamp, true)?; + + performance::end_measure!("render_preview"); + performance::end_timed_log!("render_preview", _start); + + Ok(()) + } + pub fn start_render_loop( &mut self, base_object: Option<&Uuid>, @@ -1622,10 +1648,11 @@ impl RenderState { is_empty = false; - let element = tree.get(&node_id).ok_or(format!( - "Error: Element with root_id {} not found in the tree.", - node_render_state.id - ))?; + let Some(element) = tree.get(&node_id) else { + // The shape isn't available yet (likely still streaming in from WASM). + // Skip it for this pass; a subsequent render will pick it up once present. + continue; + }; let scale = self.get_scale(); let mut extrect: Option = None; diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index b2950e781b..1f80e4a41f 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -223,6 +223,10 @@ impl State { self.render_state.rebuild_touched_tiles(&self.shapes); } + pub fn render_preview(&mut self, timestamp: i32) { + let _ = self.render_state.render_preview(&self.shapes, timestamp); + } + pub fn rebuild_modifier_tiles(&mut self, ids: Vec) { // No longer need unsafe lifetime extension - index-based storage is safe self.render_state From 499aac31a4796af078b44a399e3c4dcbc5b27501 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 21 Jan 2026 14:49:00 +0100 Subject: [PATCH 04/17] :wrench: Improve tile invalidation to prevent visual flickering When tiles are invalidated (during shape updates or page loading), the old tile content is now kept visible until new content is rendered to replace it. This provides a smoother visual experience during updates. --- render-wasm/src/render.rs | 12 +++++------- render-wasm/src/render/surfaces.rs | 14 +++++++------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 47fafce5bf..d66d8249de 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2168,9 +2168,7 @@ impl RenderState { } pub fn remove_cached_tile(&mut self, tile: tiles::Tile) { - let rect = self.get_aligned_tile_bounds(tile); - self.surfaces - .remove_cached_tile_surface(tile, rect, self.background_color); + self.surfaces.remove_cached_tile_surface(tile); } pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) { @@ -2191,8 +2189,8 @@ impl RenderState { } } - // Update the changed tiles - self.surfaces.remove_cached_tiles(self.background_color); + // Invalidate changed tiles - old content stays visible until new tiles render + self.surfaces.remove_cached_tiles(); for tile in all_tiles { self.remove_cached_tile(tile); } @@ -2238,8 +2236,8 @@ impl RenderState { } } - // Update the changed tiles - self.surfaces.remove_cached_tiles(self.background_color); + // Invalidate changed tiles - old content stays visible until new tiles render + self.surfaces.remove_cached_tiles(); for tile in all_tiles { self.remove_cached_tile(tile); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 00792109d8..aed1c82807 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -401,11 +401,10 @@ impl Surfaces { self.tiles.has(tile) } - pub fn remove_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { - // Clear the specific tile area in the cache surface with color - let mut paint = skia::Paint::default(); - paint.set_color(color); - self.cache.canvas().draw_rect(rect, &paint); + pub fn remove_cached_tile_surface(&mut self, tile: Tile) { + // Mark tile as invalid + // Old content stays visible until new tile overwrites it atomically, + // preventing flickering during tile re-renders. self.tiles.remove(tile); } @@ -422,9 +421,10 @@ impl Surfaces { .draw_image_rect(&image, None, rect, &skia::Paint::default()); } - pub fn remove_cached_tiles(&mut self, color: skia::Color) { + pub fn remove_cached_tiles(&mut self) { + // New tiles will overwrite old content atomically when rendered, + // preventing flickering during bulk invalidation. self.tiles.clear(); - self.cache.canvas().clear(color); } pub fn gc(&mut self) { From aab1d97c4cb27c3d53823d9a3d3e06cdc620925a Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 21 Jan 2026 15:44:41 +0100 Subject: [PATCH 05/17] :wrench: Clean up and use proper imports --- frontend/src/app/render_wasm/api.cljs | 41 ++++---------------- frontend/src/app/render_wasm/api/shapes.cljs | 8 +--- render-wasm/src/state.rs | 2 +- render-wasm/src/wasm/shapes/base_props.rs | 7 ---- 4 files changed, 9 insertions(+), 49 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 6df71e7e4f..9055dea1ec 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -69,12 +69,8 @@ (def ^:const DEBOUNCE_DELAY_MS 100) (def ^:const THROTTLE_DELAY_MS 10) -;; Async chunked processing constants -;; SHAPES_CHUNK_SIZE: Number of shapes to process before yielding to browser -;; Lower values = more responsive UI but slower total processing -;; Higher values = faster total processing but may cause UI jank +;; Number of shapes to process before yielding to browser (def ^:const SHAPES_CHUNK_SIZE 100) - ;; Threshold below which we use synchronous processing (no chunking overhead) (def ^:const ASYNC_THRESHOLD 100) @@ -189,20 +185,6 @@ (when was-loading (request-render "set-objects:flush")))) -(defn- request-progressive-render! - ([reason] - (let [was-loading? @shapes-loading?] - (if was-loading? - (do - (reset! shapes-loading? false) - (try - (request-render reason) - (finally - (reset! shapes-loading? was-loading?)))) - (request-render reason)))) - ([] - (request-progressive-render! "set-objects:chunk"))) - (declare get-text-dimensions) (defn update-text-rect! @@ -982,9 +964,6 @@ svg-attrs (get shape :svg-attrs) shadows (get shape :shadow)] - ;; Batched call: sets id, parent_id, type, clip_content, hidden, blend_mode, - ;; constraints, opacity, rotation, transform, selrect, and corners in one WASM call. - ;; This replaces 12+ individual WASM calls with a single batched operation. (shapes/set-shape-base-props shape) ;; Remaining properties that need separate calls (variable-length or conditional) @@ -1090,12 +1069,11 @@ then does a full tile-based render at the end." [shapes render-callback] (let [total-shapes (count shapes) - ;; Calculate how many chunks we'll process - total-chunks (js/Math.ceil (/ total-shapes SHAPES_CHUNK_SIZE)) + total-chunks (mth/ceil (/ total-shapes SHAPES_CHUNK_SIZE)) ;; Render at 25%, 50%, 75% of loading - render-at-chunks (set [(js/Math.floor (* total-chunks 0.25)) - (js/Math.floor (* total-chunks 0.5)) - (js/Math.floor (* total-chunks 0.75))])] + render-at-chunks (set [(mth/floor (* total-chunks 0.25)) + (mth/floor (* total-chunks 0.5)) + (mth/floor (* total-chunks 0.75))])] (p/create (fn [resolve _reject] (letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count] @@ -1124,7 +1102,6 @@ (render-finish)) (ug/dispatch! (ug/event "penpot:wasm:set-objects")) (resolve nil))))))] - ;; Start processing (process-next-chunk 0 [] [] 0)))))) (defn- set-objects-sync @@ -1154,7 +1131,6 @@ maintaining proper shape reference consistency in WASM." [objects] ;; Get IDs in tree order starting from root (uuid/zero) - ;; cfh/get-children-ids-with-self returns [root-id, child1-id, grandchild1-id, ...] (let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)] (into [] (keep #(get objects %)) @@ -1163,11 +1139,8 @@ (defn set-objects "Set all shape objects for rendering. - IMPORTANT: Shapes are processed in tree order (parents before children) - to maintain proper shape reference consistency in WASM. - - NOTE: Async processing uses render gating to avoid race conditions with - requestAnimationFrame renders during loading." + Shapes are processed in tree order (parents before children) + to maintain proper shape reference consistency in WASM." ([objects] (set-objects objects nil)) ([objects render-callback] diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/frontend/src/app/render_wasm/api/shapes.cljs index 3ab680dac4..c498c5e922 100644 --- a/frontend/src/app/render_wasm/api/shapes.cljs +++ b/frontend/src/app/render_wasm/api/shapes.cljs @@ -95,23 +95,19 @@ Returns nil." [shape] (when wasm/context-initialized? - (let [;; Extract all properties from shape - id (dm/get-prop shape :id) + (let [id (dm/get-prop shape :id) parent-id (get shape :parent-id) shape-type (dm/get-prop shape :type) - ;; Boolean flags clip-content (if (= shape-type :frame) (not (get shape :show-content)) false) hidden (get shape :hidden false) - ;; Compute flags byte flags (cond-> 0 clip-content (bit-or FLAG-CLIP-CONTENT) hidden (bit-or FLAG-HIDDEN)) - ;; Enum values blend-mode (sr/translate-blend-mode (get shape :blend-mode)) constraint-h (let [c (get shape :constraints-h)] (if (some? c) @@ -122,7 +118,6 @@ (sr/translate-constraint-v c) CONSTRAINT-NONE)) - ;; Float values opacity (d/nilv (get shape :opacity) 1.0) rotation (d/nilv (get shape :rotation) 0.0) @@ -193,7 +188,6 @@ (.setFloat32 dview (+ offset 96) r3 true) (.setFloat32 dview (+ offset 100) r4 true) - ;; Call WASM function (h/call wasm/internal-module "_set_shape_base_props") nil))) diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 1f80e4a41f..385408d89f 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -228,7 +228,7 @@ impl State { } pub fn rebuild_modifier_tiles(&mut self, ids: Vec) { - // No longer need unsafe lifetime extension - index-based storage is safe + // Index-based storage is safe self.render_state .rebuild_modifier_tiles(&mut self.shapes, ids); } diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index 4846d9b320..428c5e18fa 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -27,7 +27,6 @@ use super::RawShapeType; /// | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) | /// |--------|------|--------------|-----------------------------------| /// | Total | 104 | | | - pub const BASE_PROPS_SIZE: usize = 104; const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; @@ -67,12 +66,6 @@ fn read_uuid(bytes: &[u8], offset: usize) -> Uuid { ) } -/// Sets base shape properties from a pre-allocated buffer in a single WASM call. -/// -/// This replaces multiple individual WASM calls (use_shape, set_parent, set_shape_type, -/// set_shape_clip_content, set_shape_rotation, set_shape_transform, set_shape_blend_mode, -/// set_shape_opacity, set_shape_hidden, set_shape_selrect, set_shape_corners, -/// set_shape_constraints) with a single batched call. #[no_mangle] pub extern "C" fn set_shape_base_props() { let bytes = mem::bytes(); From 5d7d23a2c71fd48c554f32d88ddd7477b1048f70 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Thu, 22 Jan 2026 08:51:58 +0100 Subject: [PATCH 06/17] :wrench: Keep clear cached canvas --- render-wasm/src/render.rs | 8 +++++--- render-wasm/src/render/surfaces.rs | 5 ++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d66d8249de..f009946a21 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1770,7 +1770,9 @@ impl RenderState { if !matches!(element.shape_type, Type::Bool(_)) { // Nested shapes shadowing - apply black shadow to child shapes too for shadow_shape_id in element.children.iter() { - let shadow_shape = tree.get(shadow_shape_id).unwrap(); + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + continue; + }; if shadow_shape.hidden { continue; } @@ -2190,7 +2192,7 @@ impl RenderState { } // Invalidate changed tiles - old content stays visible until new tiles render - self.surfaces.remove_cached_tiles(); + self.surfaces.remove_cached_tiles(self.background_color); for tile in all_tiles { self.remove_cached_tile(tile); } @@ -2237,7 +2239,7 @@ impl RenderState { } // Invalidate changed tiles - old content stays visible until new tiles render - self.surfaces.remove_cached_tiles(); + self.surfaces.remove_cached_tiles(self.background_color); for tile in all_tiles { self.remove_cached_tile(tile); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index aed1c82807..8719b0373a 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -421,10 +421,9 @@ impl Surfaces { .draw_image_rect(&image, None, rect, &skia::Paint::default()); } - pub fn remove_cached_tiles(&mut self) { - // New tiles will overwrite old content atomically when rendered, - // preventing flickering during bulk invalidation. + pub fn remove_cached_tiles(&mut self, color: skia::Color) { self.tiles.clear(); + self.cache.canvas().clear(color); } pub fn gc(&mut self) { From 8637c46ba1c1b8402e067a0bf23b3bb43884fcaf Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Thu, 22 Jan 2026 08:52:26 +0100 Subject: [PATCH 07/17] :bug: Fix empty pool state --- render-wasm/src/state/shapes_pool.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 4edb41069e..6587a23de2 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -98,7 +98,9 @@ impl ShapesPoolImpl { if self.counter >= self.shapes.len() { // We need more space let current_capacity = self.shapes.capacity(); - let additional = (self.shapes.len() as f32 * SHAPES_POOL_ALLOC_MULTIPLIER) as usize; + // Ensure we add at least 1 shape when the pool is empty + let additional = + ((self.shapes.len() as f32 * SHAPES_POOL_ALLOC_MULTIPLIER) as usize).max(1); let needed_capacity = self.shapes.len() + additional; if needed_capacity > current_capacity { From f94c9cdb02632bebc9a0c13664550c079e174aa7 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Thu, 22 Jan 2026 09:29:33 +0100 Subject: [PATCH 08/17] :bug: Fix objects sorting for thumbnail generation --- frontend/src/app/render_wasm/api.cljs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 9055dea1ec..15ec2b010c 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1131,10 +1131,28 @@ maintaining proper shape reference consistency in WASM." [objects] ;; Get IDs in tree order starting from root (uuid/zero) - (let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)] - (into [] - (keep #(get objects %)) - ordered-ids))) + ;; If root doesn't exist (e.g., filtered thumbnail data), fall back to + ;; finding top-level shapes (those without a parent in objects) and + ;; traversing from there. + (if (contains? objects uuid/zero) + ;; Normal case: traverse from root + (let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)] + (into [] + (keep #(get objects %)) + ordered-ids)) + ;; Fallback for filtered data (thumbnails): find top-level shapes and traverse + (let [;; Find shapes whose parent is not in the objects map (top-level in this subset) + top-level-ids (->> (vals objects) + (filter (fn [shape] + (not (contains? objects (:parent-id shape))))) + (map :id)) + ;; Get all children in order for each top-level shape + all-ordered-ids (into [] + (mapcat #(cfh/get-children-ids-with-self objects %)) + top-level-ids)] + (into [] + (keep #(get objects %)) + all-ordered-ids)))) (defn set-objects "Set all shape objects for rendering. From 4ad528206382201a80f951cc87e12f8ce906c777 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 23 Jan 2026 10:58:54 +0100 Subject: [PATCH 09/17] :bug: Fix blur events for text editor v2 in firefox --- .../src/app/main/ui/workspace/shapes/text/v2_editor.cljs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 410498e13f..0d6fdd3700 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -138,7 +138,6 @@ (st/emit! (dw/set-clipboard-style style))))] (.addEventListener ^js global/document "keyup" on-key-up) - (.addEventListener ^js instance "blur" on-blur) (.addEventListener ^js instance "focus" on-focus) (.addEventListener ^js instance "needslayout" on-needs-layout) (.addEventListener ^js instance "stylechange" on-style-change) @@ -153,8 +152,12 @@ ;; This function is called when the component is unmounted (fn [] + ;; Explicitly call on-blur here instead of relying on browser blur events, + ;; because in Firefox blur is not reliably fired when leaving the text editor + ;; by clicking elsewhere. The component does unmount when the shape is + ;; deselected, so we can safely call the blur handler here to finalize the editor. + (on-blur) (.removeEventListener ^js global/document "keyup" on-key-up) - (.removeEventListener ^js instance "blur" on-blur) (.removeEventListener ^js instance "focus" on-focus) (.removeEventListener ^js instance "needslayout" on-needs-layout) (.removeEventListener ^js instance "stylechange" on-style-change) From 15d369493b0371430eeb360be0ed140edddd8bbe Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Fri, 23 Jan 2026 12:48:01 +0100 Subject: [PATCH 10/17] :bug: Fix problem with z-index modal in dashboard (#8178) --- frontend/src/app/main/ui/dashboard/team.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 90e33f5cca..259fdeb565 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -628,6 +628,7 @@ width: $sz-400; padding: var(--sp-xxxl); background-color: var(--color-background-primary); + z-index: var(--z-index-set); &.hero { top: px2rem(216); From e3148ea20e02bcb4d0764bfb93dc841938325c7b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 23 Jan 2026 13:34:19 +0100 Subject: [PATCH 11/17] :tada: Adding performance logs flag --- common/src/app/common/flags.cljc | 2 + frontend/src/app/main/data/event.cljs | 71 ++++++++++++++------------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 8c26778e90..b7b5e18f7c 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -134,6 +134,8 @@ :subscriptions :subscriptions-old :inspect-styles + ;; Enable performance logs in devconsole (disabled by default) + :perf-logs ;; Security layer middleware that filters request by fetch ;; metadata headers diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index 41daafdda6..7ee1d63225 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -476,23 +476,24 @@ (when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*)) (let [observer (js/PerformanceObserver. (fn [list _] - (doseq [entry (.getEntries list)] - (let [dur (.-duration entry) - start (.-startTime entry) - attrib (.-attribution entry) - attrib-count (when attrib (.-length attrib)) - first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0)) - attrib-name (when first-attrib (.-name first-attrib)) - attrib-ctype (when first-attrib (.-containerType first-attrib)) - attrib-cid (when first-attrib (.-containerId first-attrib)) - attrib-csrc (when first-attrib (.-containerSrc first-attrib))] + (when (contains? cf/flags :perf-logs) + (doseq [entry (.getEntries list)] + (let [dur (.-duration entry) + start (.-startTime entry) + attrib (.-attribution entry) + attrib-count (when attrib (.-length attrib)) + first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0)) + attrib-name (when first-attrib (.-name first-attrib)) + attrib-ctype (when first-attrib (.-containerType first-attrib)) + attrib-cid (when first-attrib (.-containerId first-attrib)) + attrib-csrc (when first-attrib (.-containerSrc first-attrib))] - (.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms" - (when first-attrib - (str " attrib:name=" attrib-name - " ctype=" attrib-ctype - " cid=" attrib-cid - " csrc=" attrib-csrc))))))))] + (.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms" + (when first-attrib + (str " attrib:name=" attrib-name + " ctype=" attrib-ctype + " cid=" attrib-cid + " csrc=" attrib-csrc)))))))))] (.observe observer #js{:entryTypes #js["longtask"]}) (reset! longtask-observer* observer)))) @@ -505,28 +506,30 @@ (let [last (atom (.now js/performance)) id (js/setInterval (fn [] - (let [now (.now js/performance) - expected (+ @last interval-ms) - drift (- now expected) - current-op @current-op* - measures (.getEntriesByType js/performance "measure") - mlen (.-length measures) - last-measure (when (> mlen 0) (aget measures (dec mlen))) - meas-name (when last-measure (.-name last-measure)) - meas-detail (when last-measure (.-detail last-measure)) - meas-count (when meas-detail (unchecked-get meas-detail "count"))] - (reset! last now) - (when (> drift threshold-ms) - (.warn js/console - (str "[perf] event loop stall: " (Math/round drift) "ms" - (when current-op (str " op=" current-op)) - (when meas-name (str " last=" meas-name)) - (when meas-count (str " count=" meas-count))))))) + (when (contains? cf/flags :perf-logs) + (let [now (.now js/performance) + expected (+ @last interval-ms) + drift (- now expected) + current-op @current-op* + measures (.getEntriesByType js/performance "measure") + mlen (.-length measures) + last-measure (when (> mlen 0) (aget measures (dec mlen))) + meas-name (when last-measure (.-name last-measure)) + meas-detail (when last-measure (.-detail last-measure)) + meas-count (when meas-detail (unchecked-get meas-detail "count"))] + (reset! last now) + (when (> drift threshold-ms) + (.warn js/console + (str "[perf] event loop stall: " (Math/round drift) "ms" + (when current-op (str " op=" current-op)) + (when meas-name (str " last=" meas-name)) + (when meas-count (str " count=" meas-count)))))))) interval-ms)] (reset! stall-timer* id)))) (defn init! - "Install perf observers in dev builds. Safe to call multiple times." + "Install perf observers in dev builds. Safe to call multiple times. + Perf logs are disabled by default. Enable them with the :perf-logs flag in config." [] (when ^boolean js/goog.DEBUG (install-long-task-observer!) From 5d7e6afd762bd8847eb201dd80446f52f99cf8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 5 Jan 2026 09:13:14 +0100 Subject: [PATCH 12/17] :wrench: Fix a typo in an interpolation --- .github/workflows/build-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index 80ef7bcaeb..c32e363888 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -33,7 +33,7 @@ jobs: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd TEXT: | - 🐳 *[PENPOT] Docker image available: {{ github.ref_name }}* + 🐳 *[PENPOT] Docker image available: ${{ github.ref_name }}* 🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} @infra From e03ad251187d363e0a97b8bdd96b4db1a4073c8c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 26 Jan 2026 10:10:57 +0100 Subject: [PATCH 13/17] :wrench: Backport CI workflow from develop --- .github/workflows/tests.yml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0d1e008d21..c4e715e55a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ concurrency: jobs: lint: name: "Linter" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest steps: @@ -34,7 +34,7 @@ jobs: test-common: name: "Common Tests" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest steps: @@ -53,7 +53,8 @@ jobs: test-plugins: name: Plugins Runtime Linter & Tests - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 + container: penpotapp/devenv:latest steps: - uses: actions/checkout@v4 @@ -98,7 +99,7 @@ jobs: test-frontend: name: "Frontend Tests" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest steps: @@ -119,7 +120,7 @@ jobs: test-render-wasm: name: "Render WASM Tests" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest steps: @@ -143,7 +144,7 @@ jobs: test-backend: name: "Backend Tests" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest services: @@ -182,7 +183,7 @@ jobs: test-library: name: "Library Tests" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest steps: @@ -196,7 +197,7 @@ jobs: build-integration: name: "Build Integration Bundle" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest steps: @@ -217,7 +218,7 @@ jobs: test-integration-1: name: "Integration Tests 1/4" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -247,7 +248,7 @@ jobs: test-integration-2: name: "Integration Tests 2/4" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -277,7 +278,7 @@ jobs: test-integration-3: name: "Integration Tests 3/4" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -307,7 +308,7 @@ jobs: test-integration-4: name: "Integration Tests 4/4" - runs-on: ubuntu-24.04 + runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration From 33e650242ca2d8d7a9588f25dd9598273c12e3e8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 20 Jan 2026 13:47:09 +0100 Subject: [PATCH 14/17] :sparkles: Add slugify to the filename on assets exportation Fixes https://github.com/penpot/penpot/issues/8017 --- CHANGES.md | 1 + exporter/src/app/handlers/resources.cljs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 10aca874cd..77f4dd262b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,6 +32,7 @@ - Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110) - Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167) - Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171) +- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) ## 2.12.1 diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index 8b0a55ba35..5394e673a8 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -36,7 +36,7 @@ {:path path :mtype (mime/get type) :name name - :filename (str/concat name (mime/get-extension type)) + :filename (str/concat (str/slug name) (mime/get-extension type)) :id task-id})) (defn create-zip From 8632b18eecbfbcf967759c403c7988d6a74b90c2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 20 Jan 2026 15:22:01 +0100 Subject: [PATCH 15/17] :bug: Avoid json decoder liner limit exception by chunking Happens only when we send large binary data serialized with transit (mainly used for upload fonts data). --- CHANGES.md | 1 + backend/src/app/rpc/commands/fonts.clj | 35 +++++++++++++++++++++++--- frontend/src/app/main/data/fonts.cljs | 20 ++++++++++++--- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 77f4dd262b..6754a5045a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -33,6 +33,7 @@ - Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167) - Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171) - Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) +- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) ## 2.12.1 diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 05454d6698..8ca20eac49 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -27,7 +27,17 @@ [app.rpc.helpers :as rph] [app.rpc.quotes :as quotes] [app.storage :as sto] - [app.util.services :as sv])) + [app.storage.tmp :as tmp] + [app.util.services :as sv] + [datoteka.io :as io]) + (:import + java.io.InputStream + java.io.OutputStream + java.io.SequenceInputStream + java.util.Collections)) + +(set! *warn-on-reflection* true) + (def valid-weight #{100 200 300 400 500 600 700 800 900 950}) (def valid-style #{"normal" "italic"}) @@ -105,7 +115,7 @@ (defn create-font-variant [{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}] - (letfn [(generate-missing! [data] + (letfn [(generate-missing [data] (let [data (media/run {:cmd :generate-fonts :input data})] (when (and (not (contains? data "font/otf")) (not (contains? data "font/ttf")) @@ -116,8 +126,26 @@ :hint "invalid font upload, unable to generate missing font assets")) data)) + (process-chunks [chunks] + (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") + streams (map io/input-stream chunks) + streams (Collections/enumeration streams)] + (with-open [^OutputStream output (io/output-stream tmp) + ^InputStream input (SequenceInputStream. streams)] + (io/copy input output)) + tmp)) + + (join-chunks [data] + (reduce-kv (fn [data mtype content] + (if (vector? content) + (assoc data mtype (process-chunks content)) + data)) + data + data)) + (prepare-font [data mtype] (when-let [resource (get data mtype)] + (let [hash (sto/calculate-hash resource) content (-> (sto/content resource) (sto/wrap-with-hash hash))] @@ -156,7 +184,8 @@ :otf-file-id (:id otf) :ttf-file-id (:id ttf)}))] - (let [data (generate-missing! data) + (let [data (join-chunks data) + data (generate-missing data) assets (persist-fonts-files! data) result (insert-font-variant! assets)] (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))) diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 4706e7c5c3..4efa40718f 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -24,6 +24,20 @@ [cuerdas.core :as str] [potok.v2.core :as ptk])) +(def ^:const default-chunk-size + (* 1024 1024 4)) ;; 4MiB + +(defn- chunk-array + [data chunk-size] + (let [total-size (alength data)] + (loop [offset 0 + chunks []] + (if (< offset total-size) + (let [end (min (+ offset chunk-size) total-size) + chunk (.subarray ^js data offset end)] + (recur end (conj chunks chunk))) + chunks)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; General purpose events & IMPL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -116,9 +130,9 @@ (not= hhea-descender win-descent) (and f-selection (or (not= hhea-ascender os2-ascent) - (not= hhea-descender os2-descent))))] - - {:content {:data (js/Uint8Array. data) + (not= hhea-descender os2-descent)))) + data (js/Uint8Array. data)] + {:content {:data (chunk-array data default-chunk-size) :name name :type type} :font-family (or family "") From 23d5fc7408848b7a9974809a5810c8e8432ca0da Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 21 Jan 2026 09:25:22 +0100 Subject: [PATCH 16/17] :bug: Prevent exception on open-new-window when no window is returned Fixes https://github.com/penpot/penpot/issues/7787 --- CHANGES.md | 1 + frontend/src/app/util/dom.cljs | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6754a5045a..c3b176e71e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -34,6 +34,7 @@ - Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171) - Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) - Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) +- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) ## 2.12.1 diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 2faa6cc1c0..e22de0dbba 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -802,9 +802,10 @@ ([uri name] (open-new-window uri name "noopener,noreferrer")) ([uri name features] - (let [new-window (.open js/window (str uri) name features)] + (when-let [new-window (.open js/window (str uri) name features)] (when (not= name "_blank") - (.reload (.-location new-window)))))) + (when-let [location (.-location new-window)] + (.reload location)))))) (defn browser-back [] From f07495ae955d53de07b1025a029dfaf4608cf262 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 21 Jan 2026 09:36:14 +0100 Subject: [PATCH 17/17] :bug: Fix incorrect handling of numeric values layout padding and gap Fixes https://github.com/penpot/penpot/issues/8113 --- CHANGES.md | 2 ++ .../workspace/sidebar/options/menus/layout_container.cljs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c3b176e71e..b175d03cfb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -35,6 +35,8 @@ - Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) - Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) - Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) +- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113) + ## 2.12.1 diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 320abd7d18..e89033964d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -375,7 +375,7 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr event] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (on-change :simple attr value event) (do (let [resolved-value (:resolved-value (first value)) @@ -489,7 +489,7 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr event] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (on-change :multiple attr value event) (do (let [resolved-value (:resolved-value (first value))] @@ -724,7 +724,7 @@ (mf/use-fn (mf/deps on-change wrap-type ids) (fn [value event attr] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (on-change (= "nowrap" wrap-type) attr value event) (do (let [resolved-value (:resolved-value (first value))]