From 9cf787d154ab830007ed35803be18ce4b6c65a90 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 17 Apr 2026 12:18:06 +0200 Subject: [PATCH 1/4] :bug: Update atlas when removing shape --- render-wasm/src/render.rs | 18 ++++++++++++++++++ render-wasm/src/render/surfaces.rs | 27 +++++++++++++++++++++++++++ render-wasm/src/state.rs | 26 ++++++++++++++++++-------- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 4394d962cb..400917604e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2817,6 +2817,24 @@ impl RenderState { paint.set_color(self.background_color); s.canvas().draw_rect(tile_rect, &paint); }); + // Keep Cache surface coherent for render_from_cache. + if !self.options.is_fast_mode() { + if !self.cache_cleared_this_render { + self.surfaces.clear_cache(self.background_color); + self.cache_cleared_this_render = true; + } + let aligned_rect = self.get_aligned_tile_bounds(current_tile); + self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| { + let mut paint = skia::Paint::default(); + paint.set_color(self.background_color); + s.canvas().draw_rect(aligned_rect, &paint); + }); + } + + // Clear atlas region to transparent so background shows through. + let _ = self + .surfaces + .clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area); } } } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 3a1d20d900..e3a9512e08 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -289,6 +289,33 @@ impl Surfaces { Ok(()) } + pub fn clear_doc_rect_in_atlas( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + doc_rect.width() * self.atlas_scale, + doc_rect.height() * self.atlas_scale, + ); + + let canvas = self.atlas.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + Ok(()) + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 74b81d6336..c624dad43d 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -160,14 +160,24 @@ impl State { // Only remove the children when is being deleted from the owner if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) { - let tiles::TileRect(rsx, rsy, rex, rey) = - self.render_state.get_tiles_for_shape(shape, &self.shapes); - for x in rsx..=rex { - for y in rsy..=rey { - let tile = tiles::Tile(x, y); - self.render_state.remove_cached_tile(tile); - self.render_state.tiles.remove_shape_at(tile, shape.id); - } + // IMPORTANT: + // Do NOT use `get_tiles_for_shape` here. That method intersects the shape + // tiles with the current interest area, which means we'd only invalidate + // the subset currently near the viewport. When the user later pans/zooms + // to reveal previously cached tiles, stale pixels could reappear. + // + // Instead, remove the shape from *all* tiles where it was indexed, and + // drop cached tiles for those entries. + let indexed_tiles: Vec = self + .render_state + .tiles + .get_tiles_of(shape.id) + .map(|t| t.iter().copied().collect()) + .unwrap_or_default(); + + for tile in indexed_tiles { + self.render_state.remove_cached_tile(tile); + self.render_state.tiles.remove_shape_at(tile, shape.id); } if let Some(shape_to_delete) = self.shapes.get(&id) { From 88dbfe7602a2333209e46ff5e37a5c771683cb67 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 17 Apr 2026 13:58:13 +0200 Subject: [PATCH 2/4] :bug: Fix restore renderer state after thumbnail render_shape_pixels --- render-wasm/src/render.rs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 400917604e..8ad6fa79aa 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -230,6 +230,7 @@ impl NodeRenderState { /// - `enter(...)` / `exit(...)` should be called when entering and leaving shape /// render contexts. /// - `is_active()` returns whether the current shape is being rendered in focus. +#[derive(Clone)] pub struct FocusMode { shapes: Vec, active: bool, @@ -1792,6 +1793,26 @@ impl RenderState { ) -> Result<(Vec, i32, i32)> { let target_surface = SurfaceId::Export; + // `render_shape_pixels` is used by the workspace to render thumbnails using the + // same WASM renderer instance. It must not leak any state into the main + // viewport renderer (tile cache, atlas, focus mode, render context, etc.). + // + // In particular, `update_render_context` clears and reconfigures multiple + // render surfaces, and `render_area` drives atlas blits. If we don't restore + // them, the workspace can temporarily show missing tiles until the next + // interaction (e.g. zoom) forces a full context rebuild. + let saved_focus_mode = self.focus_mode.clone(); + let saved_export_context = self.export_context; + let saved_render_area = self.render_area; + let saved_render_area_with_margins = self.render_area_with_margins; + let saved_current_tile = self.current_tile; + let saved_pending_nodes = std::mem::take(&mut self.pending_nodes); + let saved_nested_fills = std::mem::take(&mut self.nested_fills); + let saved_nested_blurs = std::mem::take(&mut self.nested_blurs); + let saved_nested_shadows = std::mem::take(&mut self.nested_shadows); + let saved_ignore_nested_blurs = self.ignore_nested_blurs; + let saved_preview_mode = self.preview_mode; + // Reset focus mode so all shapes in the export tree are rendered. // Without this, leftover focus_mode state from the workspace could // cause shapes (and their background blur) to be skipped. @@ -1843,6 +1864,30 @@ impl RenderState { .expect("PNG encode failed"); let skia::ISize { width, height } = image.dimensions(); + // Restore the workspace render state. + self.focus_mode = saved_focus_mode; + self.export_context = saved_export_context; + self.render_area = saved_render_area; + self.render_area_with_margins = saved_render_area_with_margins; + self.current_tile = saved_current_tile; + self.pending_nodes = saved_pending_nodes; + self.nested_fills = saved_nested_fills; + self.nested_blurs = saved_nested_blurs; + self.nested_shadows = saved_nested_shadows; + self.ignore_nested_blurs = saved_ignore_nested_blurs; + self.preview_mode = saved_preview_mode; + + // Restore render-surface transforms for the workspace context. + // If we have a current tile, restore its tile render context; otherwise + // fall back to restoring the previous render_area (may be empty). + let workspace_scale = self.get_scale(); + if let Some(tile) = self.current_tile { + self.update_render_context(tile); + } else if !self.render_area.is_empty() { + self.surfaces + .update_render_context(self.render_area, workspace_scale); + } + Ok((data.as_bytes().to_vec(), width, height)) } From bc9496deaafe47f2898843bbca64cb8dd4d46b23 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 20 Apr 2026 07:34:30 +0200 Subject: [PATCH 3/4] :tada: Replace run_script tiles-complete dispatch with wapi extern binding --- render-wasm/src/js/wapi.js | 8 ++++++++ render-wasm/src/render.rs | 10 +++------- render-wasm/src/wapi.rs | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/render-wasm/src/js/wapi.js b/render-wasm/src/js/wapi.js index 4af5c0bf89..13f3fcb698 100644 --- a/render-wasm/src/js/wapi.js +++ b/render-wasm/src/js/wapi.js @@ -12,5 +12,13 @@ addToLibrary({ } else { return window.cancelAnimationFrame(frameId); } + }, + wapi_notifyTilesRenderComplete: function wapi_notifyTilesRenderComplete() { + // The corresponding listener lives on `document` (main thread), so in a + // worker context we simply skip the dispatch instead of crashing. + if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) { + return; + } + document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete')); } }); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8ad6fa79aa..93fd9ce591 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -44,12 +44,6 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; -/// Dispatches `penpot:wasm:tiles-complete` on `document` so the UI can react when a full -/// tile pass has finished (e.g. remove page-transition blur). -fn notify_tiles_render_complete() { - #[cfg(target_arch = "wasm32")] - crate::run_script!("document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete'))"); -} const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -1763,7 +1757,7 @@ impl RenderState { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { - notify_tiles_render_complete(); + wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } } @@ -1781,6 +1775,8 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); + wapi::notify_tiles_render_complete!(); + Ok(()) } diff --git a/render-wasm/src/wapi.rs b/render-wasm/src/wapi.rs index 1947f7e3c6..f9e7e65769 100644 --- a/render-wasm/src/wapi.rs +++ b/render-wasm/src/wapi.rs @@ -35,5 +35,21 @@ macro_rules! cancel_animation_frame { }; } +#[macro_export] +macro_rules! notify_tiles_render_complete { + () => {{ + #[cfg(target_arch = "wasm32")] + unsafe extern "C" { + pub fn wapi_notifyTilesRenderComplete(); + } + + #[cfg(target_arch = "wasm32")] + unsafe { + wapi_notifyTilesRenderComplete() + }; + }}; +} + pub use cancel_animation_frame; +pub use notify_tiles_render_complete; pub use request_animation_frame; From 1d454f379099e27130640b57a24ec375af85131b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 20 Apr 2026 08:50:40 +0200 Subject: [PATCH 4/4] :bug: Fix stale tile cache when flex reflow changes modifier set between frames --- render-wasm/src/main.rs | 7 ++++++- render-wasm/src/render.rs | 2 -- render-wasm/src/state/shapes_pool.rs | 26 +++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 740fae8104..ee3a7815f3 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -866,7 +866,12 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { #[wasm_error] pub extern "C" fn clean_modifiers() -> Result<()> { with_state_mut!(state, { - state.shapes.clean_all(); + let prev_modifier_ids = state.shapes.clean_all(); + if !prev_modifier_ids.is_empty() { + state + .render_state + .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + } }); Ok(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 93fd9ce591..5df0326ace 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1775,8 +1775,6 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); - wapi::notify_tiles_render_complete!(); - Ok(()) } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 436d57f2ea..7e03befa01 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -278,11 +278,35 @@ impl ShapesPoolImpl { } } - pub fn clean_all(&mut self) { + /// Clears transient per-frame state (modifiers, structure, scale_content) + /// and returns the list of UUIDs that had a `modifier` applied at the + /// moment of cleaning. The caller can use that list to re-sync the tile + /// index / tile cache for those shapes: after cleaning their modifier is + /// gone, but if we don't touch their tiles they keep pointing at the + /// previous modified position and the tile texture cache may serve stale + /// pixels. + pub fn clean_all(&mut self) -> Vec { self.clean_shape_cache(); + + let modified_uuids: Vec = if self.modifiers.is_empty() { + Vec::new() + } else { + let mut idx_to_uuid: HashMap = + HashMap::with_capacity(self.uuid_to_idx.len()); + for (uuid, idx) in self.uuid_to_idx.iter() { + idx_to_uuid.insert(*idx, *uuid); + } + self.modifiers + .keys() + .filter_map(|idx| idx_to_uuid.get(idx).copied()) + .collect() + }; + self.modifiers = HashMap::default(); self.structure = HashMap::default(); self.scale_content = HashMap::default(); + + modified_uuids } pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl {