From 53ebf1f39c51361282ca55e18c83fbffed2f9a34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:40:54 -0500 Subject: [PATCH 01/41] fix(pathops): sanitize a brush's width profile once before sampling (#1948) --- crates/pathops/src/brush.rs | 4 +++- crates/pathops/src/brush_tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/pathops/src/brush.rs b/crates/pathops/src/brush.rs index bfd7f9212..b5875d4a2 100644 --- a/crates/pathops/src/brush.rs +++ b/crates/pathops/src/brush.rs @@ -131,7 +131,9 @@ pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) - let p = preset(&brush.preset)?; let unit = shape(p.id)?; let lines = polylines(path)?; - let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t)); + // Sanitized once: at most 64 points, so each sample's symmetry scan stays cheap. + let profile = props.profile().map(|pr| pr.sanitized()); + let scale = |t: f64| width * profile.as_ref().map_or(1.0, |pr| pr.scale_at(t)); let mut out = BezPath::new(); match p.kind { BrushKind::Stretch => { diff --git a/crates/pathops/src/brush_tests.rs b/crates/pathops/src/brush_tests.rs index 403935ddd..81cc55e10 100644 --- a/crates/pathops/src/brush_tests.rs +++ b/crates/pathops/src/brush_tests.rs @@ -72,3 +72,14 @@ fn a_width_profile_scales_the_brush() { let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box()); assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}"); } + +#[test] +fn an_unsanitized_profile_is_bounded_before_sampling() { + // 100k points, all past the 64-point cap and out of range: the brush reads the clamped profile. + let raw = WidthProfile { points: (0..100_000).map(|i| figcraft_doc::WidthPoint::new(f64::from(i), 1e300)).collect() }; + let b = brush("taper"); + let mut p = props(1.0, &b); + p.width_profile = Some(raw); + let out = outline(&line(50.0), 1.0, &p, &b).unwrap(); + assert!(out.bounding_box().height() <= 2.0 * WidthProfile::MAX_SCALE); +} From cfb29a4a9617b75f9072188c15ad1e24307850f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:09 -0500 Subject: [PATCH 02/41] perf(text): compute each glyph's cluster end once per run (#1963) --- crates/text/src/layout.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f2c587206..dd728916b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1698,11 +1698,22 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe } let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript }); let n = raw.len(); + // Cluster end: the next larger cluster value, else the segment end. A glyph in the same + // cluster as the next one shares its end, so many glyphs of one cluster aren't rescanned. + let mut cends = vec![0usize; n]; + for gi in (0..n).rev() { + let Some(&(_, cl, ..)) = raw.get(gi) else { continue }; + let cl = cl as usize; + let shared = raw.get(gi + 1).filter(|r| r.1 as usize == cl).and_then(|_| cends.get(gi + 1).copied()); + let cend = shared.unwrap_or_else(|| raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1)); + if let Some(slot) = cends.get_mut(gi) { + *slot = cend; + } + } for gi in 0..n { let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue }; let cl = cl as usize; - // Cluster end: the next larger cluster value, else the segment end. - let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1); + let cend = cends.get(gi).copied().unwrap_or(cl + 1); let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl); let ch = chars.get(cl).copied().unwrap_or(' '); let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale; From fb4d0b50352e97debc98b212ba6e2795a6c3d4b6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:27 -0500 Subject: [PATCH 03/41] perf(doc,engine): rebuild a widget by removing its children in one pass (#1967) --- crates/doc/src/lib.rs | 13 +++++++++++++ crates/doc/src/tests.rs | 14 ++++++++++++++ crates/engine/src/extensions/widgets.rs | 4 +--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 89eb97269..0181ffd21 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -574,6 +574,19 @@ impl Document { Ok(()) } + /// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing + /// them one by one would search that list once per child). + pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> { + let kids = std::mem::take(&mut self.get_mut(id)?.children); + for c in kids { + for d in self.subtree(c) { + self.nodes.remove(&d); + self.dirty.touch(d); + } + } + Ok(()) + } + /// Whether sections may go in `parent`: a page or another section (never a frame or a group). pub fn takes_sections(&self, parent: NodeId) -> bool { self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a0e3e17b0..a5c8e8e7a 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -846,3 +846,17 @@ fn the_colour_walker_visits_calculation_literals() { let ColorOperand::Literal { literal } = from else { panic!("literal operand") }; assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour"); } + +#[test] +fn remove_children_removes_every_subtree_and_keeps_the_parent() { + let mut doc = Document::new("T"); + let page = doc.first_page().unwrap(); + let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap(); + let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap(); + let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap(); + doc.remove_children(frame).unwrap(); + assert!(doc.contains(frame) && doc.contains(other)); + assert!(!doc.contains(inner) && !doc.contains(leaf)); + assert!(doc.get(frame).unwrap().children.is_empty()); +} diff --git a/crates/engine/src/extensions/widgets.rs b/crates/engine/src/extensions/widgets.rs index 6386ceab4..498fbeb8a 100644 --- a/crates/engine/src/extensions/widgets.rs +++ b/crates/engine/src/extensions/widgets.rs @@ -243,9 +243,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) { return Err(bad(cmd, "the layer is no longer this plugin's widget")); } - for c in n.children.clone() { - doc.remove(c)?; - } + doc.remove_children(w)?; crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?; let n = doc.get_mut(w)?; n.locked = false; From 3265cbca404644c70fb08035536882960d9f15c3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:39 -0500 Subject: [PATCH 04/41] perf(engine): look up a component's asset root once per usage report, and skip mode migration without instances (#1966) --- crates/engine/src/assets/components.rs | 4 ++++ crates/engine/src/assets/library_server.rs | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index cee9b0bb2..7ddb22427 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { /// main layer's, for files from before `modes` was an override group. Copies follow their main /// layer's modes, so a layer on other modes chose them itself. pub fn mark_layer_modes(doc: &mut Document) { + // Without an instance nothing can be inside one: skip the per-layer ancestry lookups. + if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) { + return; + } let chose: Vec = doc .nodes() .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index 47d15a954..81889be5d 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage { let records: std::collections::BTreeMap = doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect(); if !records.is_empty() { + // One component's asset root is looked up once, however many instances use it. + let mut roots = std::collections::BTreeMap::>::new(); for n in doc.nodes() { if let NodeKind::Instance { component, .. } = &n.kind && store.is_none_or(|st| doc.page_of(n.id) != Some(st)) - && let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r)) + && let Some(key) = + roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r)) { let c = counts.entry(*key).or_default(); *c = c.saturating_add(1); From 4bb35d5266aae4dfb156cd85561c4d81198cddd9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:51 -0500 Subject: [PATCH 05/41] perf(collab): check a replaced list's entries against sets, not a scan per entry (#1962) --- crates/collab/src/change.rs | 11 +++++++++++ crates/collab/src/server.rs | 14 ++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index bcc09ad86..442376f37 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -333,6 +333,17 @@ impl Change { } impl Entry { + /// The synced list this entry belongs to (`None` for entries kept in maps). + pub(crate) fn family(&self) -> Option { + match self { + Entry::Style(_) => Some(Family::Styles), + Entry::Comment(_) => Some(Family::Comments), + Entry::Collection(_) => Some(Family::Collections), + Entry::Variable(_) => Some(Family::Variables), + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, + } + } + /// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live /// session carry their creator's client id, ids from before and ids of other forms are in /// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index b94cb5726..d5af681b2 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; -use crate::change::{Change, Entry, Prop, Target}; +use crate::change::{Change, Entry, Family, Prop, Target}; use crate::message::{ Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals, }; @@ -557,6 +557,7 @@ impl Server { fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result, Error> { let before = self.state.document(); let mut touched: BTreeSet = BTreeSet::new(); + let mut listed: BTreeMap, BTreeSet)> = BTreeMap::new(); for c in changes { match c { Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => { @@ -565,8 +566,10 @@ impl Server { // A whole list replaced (the fallback for lists whose ids aren't unique). Change::SetDoc { prop, .. } => { if let Some(Ok(family)) = prop.list() { - touched.extend(docprops::entries(before, family)); - touched.extend(docprops::entries(after, family)); + let (b, a) = (docprops::entries(before, family), docprops::entries(after, family)); + touched.extend(b.iter().chain(&a).cloned()); + // Looked up in sets below: a linear search per entry would be quadratic. + listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect())); } } _ => {} @@ -574,7 +577,10 @@ impl Server { } let mut out = Vec::new(); for entry in touched { - let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry)); + let (was, now) = match entry.family().and_then(|f| listed.get(&f)) { + Some((b, a)) => (b.contains(&entry), a.contains(&entry)), + None => (docprops::has(before, &entry), docprops::has(after, &entry)), + }; if was == now { continue; } From 88851996a0932c2bc436550b2416524289044c40 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:22:03 -0500 Subject: [PATCH 06/41] fix(format): a saved file replaces its predecessor with the same permissions (#1957) --- crates/format/src/lib.rs | 5 +++++ crates/format/src/staging_tests.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 3a69ca848..e2880a3ca 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1028,6 +1028,11 @@ pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Resul let tmp = std::path::PathBuf::from(name); match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { Ok(mut file) => { + // The replacement keeps the destination's permissions from the start, so a private + // file's content is never readable under default ones (the open handle still writes). + if let Ok(old) = std::fs::metadata(path) { + let _ = file.set_permissions(old.permissions()); + } if let Err(e) = file.write_all(bytes) { let _ = std::fs::remove_file(&tmp); return Err(e); diff --git a/crates/format/src/staging_tests.rs b/crates/format/src/staging_tests.rs index 1bc199e20..abf8eacdc 100644 --- a/crates/format/src/staging_tests.rs +++ b/crates/format/src/staging_tests.rs @@ -123,3 +123,17 @@ fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { } let _ = std::fs::remove_dir_all(&d); } + +#[cfg(unix)] +#[test] +fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt; + let d = dir("mode"); + let path = d.join("a.figcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_file_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&d); +} From df07ac2d09f0e29d40170a58f70e4bf88a659790 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:23:03 -0500 Subject: [PATCH 07/41] fix(engine): never let document text close the generated HTML style element (#1955) --- crates/engine/src/dev/codegen/html.rs | 5 ++++- crates/engine/src/html_codegen_tests.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/dev/codegen/html.rs b/crates/engine/src/dev/codegen/html.rs index d1b144cae..ac23e6a5b 100644 --- a/crates/engine/src/dev/codegen/html.rs +++ b/crates/engine/src/dev/codegen/html.rs @@ -90,7 +90,10 @@ pub(super) fn html(doc: &Document, id: NodeId) -> String { let ctx = Ctx::new(doc, id); let mut out = Markup::new(&ctx, CodeLang::Html); out.node(id, None, 0, ""); - format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles) + // Document text can reach the stylesheet through any property: `<\/` reads back as `\n{css}\n", out.markup) } /// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index cd1318ee0..ab7115554 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -163,3 +163,16 @@ fn rem_preserves_text_property_default() { } } } + +#[test] +fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap()); + s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a"}})).unwrap(); + s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap(); + let code = code_for(s.doc().unwrap(), f, CodeLang::Html); + let (_, css) = code.split_once("").count(), 1, "{code}"); + assert!(!code.contains("