From e7395dff21055c3ed937b038cc9f77311139054c Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 2 Jan 2026 14:35:57 -0800 Subject: [PATCH 1/3] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d479cc8..f1590cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swc-plugin-component-annotate", - "version": "1.12.0", + "version": "1.13.0", "description": "Use SWC to automatically annotate React components with data attributes for component tracking", "author": "scttcper ", "license": "MIT", From 52951f9da7f173f3276e307323936390efdfdaff Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Sat, 14 Mar 2026 23:22:41 -0700 Subject: [PATCH 2/3] perf: reduce JSX annotation string churn --- src/constants.rs | 245 ++++++++++++++++++++++++----------------------- src/jsx_utils.rs | 78 ++++++++++----- src/lib.rs | 144 ++++++++++++++++++---------- 3 files changed, 271 insertions(+), 196 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index b420942..ee57fc3 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,125 +1,130 @@ use rustc_hash::FxHashSet; +use std::sync::OnceLock; -pub fn default_ignored_elements() -> FxHashSet<&'static str> { - let mut set = FxHashSet::default(); - let elements = [ - "a", - "abbr", - "address", - "area", - "article", - "aside", - "audio", - "b", - "base", - "bdi", - "bdo", - "blockquote", - "body", - "br", - "button", - "canvas", - "caption", - "cite", - "code", - "col", - "colgroup", - "data", - "datalist", - "dd", - "del", - "details", - "dfn", - "dialog", - "div", - "dl", - "dt", - "em", - "embed", - "fieldset", - "figure", - "footer", - "form", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "head", - "header", - "hgroup", - "hr", - "html", - "i", - "iframe", - "img", - "input", - "ins", - "kbd", - "keygen", - "label", - "legend", - "li", - "link", - "main", - "map", - "mark", - "menu", - "menuitem", - "meter", - "nav", - "noscript", - "object", - "ol", - "optgroup", - "option", - "output", - "p", - "param", - "pre", - "progress", - "q", - "rb", - "rp", - "rt", - "rtc", - "ruby", - "s", - "samp", - "script", - "section", - "select", - "small", - "source", - "span", - "strong", - "style", - "sub", - "summary", - "sup", - "table", - "tbody", - "td", - "template", - "textarea", - "tfoot", - "th", - "thead", - "time", - "title", - "tr", - "track", - "u", - "ul", - "var", - "video", - "wbr", - ]; +pub fn default_ignored_elements() -> &'static FxHashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); - for element in elements { - set.insert(element); - } + SET.get_or_init(|| { + let mut set = FxHashSet::default(); + let elements = [ + "a", + "abbr", + "address", + "area", + "article", + "aside", + "audio", + "b", + "base", + "bdi", + "bdo", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "cite", + "code", + "col", + "colgroup", + "data", + "datalist", + "dd", + "del", + "details", + "dfn", + "dialog", + "div", + "dl", + "dt", + "em", + "embed", + "fieldset", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hgroup", + "hr", + "html", + "i", + "iframe", + "img", + "input", + "ins", + "kbd", + "keygen", + "label", + "legend", + "li", + "link", + "main", + "map", + "mark", + "menu", + "menuitem", + "meter", + "nav", + "noscript", + "object", + "ol", + "optgroup", + "option", + "output", + "p", + "param", + "pre", + "progress", + "q", + "rb", + "rp", + "rt", + "rtc", + "ruby", + "s", + "samp", + "script", + "section", + "select", + "small", + "source", + "span", + "strong", + "style", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "template", + "textarea", + "tfoot", + "th", + "thead", + "time", + "title", + "tr", + "track", + "u", + "ul", + "var", + "video", + "wbr", + ]; - set + for element in elements { + set.insert(element); + } + + set + }) } diff --git a/src/jsx_utils.rs b/src/jsx_utils.rs index c7411fe..6368aac 100644 --- a/src/jsx_utils.rs +++ b/src/jsx_utils.rs @@ -6,15 +6,11 @@ use swc_core::ecma::ast::*; pub fn is_react_fragment(element: &JSXElementName) -> bool { match element { JSXElementName::Ident(ident) => ident.sym.as_ref() == "Fragment", - JSXElementName::JSXMemberExpr(member_expr) => { - // Check for React.Fragment - if let JSXObject::Ident(obj) = &member_expr.obj { - if obj.sym.as_ref() == "React" { - return member_expr.prop.sym.as_ref() == "Fragment"; - } - } - false - } + JSXElementName::JSXMemberExpr(member_expr) => matches!( + &member_expr.obj, + JSXObject::Ident(obj) + if obj.sym.as_ref() == "React" && member_expr.prop.sym.as_ref() == "Fragment" + ), JSXElementName::JSXNamespacedName(_) => false, #[cfg(swc_ast_unknown)] _ => panic!("unknown jsx element name"), @@ -39,28 +35,42 @@ pub fn get_element_name(element: &JSXElementName) -> Cow { /// Recursively build the name for member expressions (e.g., "Components.UI.Button") fn get_member_expression_name(member_expr: &JSXMemberExpr) -> String { - let obj_name = match &member_expr.obj { - JSXObject::Ident(ident) => ident.sym.as_ref(), - JSXObject::JSXMemberExpr(nested_member) => { - return format!( - "{}.{}", - get_member_expression_name(nested_member), - member_expr.prop.sym - ); + fn member_expression_name_len(member_expr: &JSXMemberExpr) -> usize { + let obj_len = match &member_expr.obj { + JSXObject::Ident(ident) => ident.sym.len(), + JSXObject::JSXMemberExpr(nested_member) => member_expression_name_len(nested_member), + #[cfg(swc_ast_unknown)] + _ => panic!("unknown jsx object"), + }; + + obj_len + 1 + member_expr.prop.sym.len() + } + + fn push_member_expression_name(target: &mut String, member_expr: &JSXMemberExpr) { + match &member_expr.obj { + JSXObject::Ident(ident) => target.push_str(ident.sym.as_ref()), + JSXObject::JSXMemberExpr(nested_member) => { + push_member_expression_name(target, nested_member); + } + #[cfg(swc_ast_unknown)] + _ => panic!("unknown jsx object"), } - #[cfg(swc_ast_unknown)] - _ => panic!("unknown jsx object"), - }; - format!("{}.{}", obj_name, member_expr.prop.sym) + target.push('.'); + target.push_str(member_expr.prop.sym.as_ref()); + } + + let mut output = String::with_capacity(member_expression_name_len(member_expr)); + push_member_expression_name(&mut output, member_expr); + output } /// Check if a JSX element already has an attribute with the given name #[inline] pub fn has_attribute(element: &JSXOpeningElement, attr_name: &str) -> bool { element.attrs.iter().any(|attr| { - matches!(attr, JSXAttrOrSpread::JSXAttr(jsx_attr) - if matches!(&jsx_attr.name, JSXAttrName::Ident(ident) + matches!(attr, JSXAttrOrSpread::JSXAttr(jsx_attr) + if matches!(&jsx_attr.name, JSXAttrName::Ident(ident) if ident.sym.as_ref() == attr_name)) }) } @@ -78,3 +88,25 @@ pub fn create_jsx_attr(name: &str, value: &str) -> JSXAttrOrSpread { })), }) } + +#[inline] +pub fn create_jsx_attr_with_ident(name: &IdentName, value: &str) -> JSXAttrOrSpread { + JSXAttrOrSpread::JSXAttr(JSXAttr { + span: Default::default(), + name: JSXAttrName::Ident(name.clone()), + value: Some(JSXAttrValue::Str(Str { + span: Default::default(), + value: value.into(), + raw: None, + })), + }) +} + +#[inline] +pub fn create_jsx_attr_with_ident_and_str(name: &IdentName, value: &Str) -> JSXAttrOrSpread { + JSXAttrOrSpread::JSXAttr(JSXAttr { + span: Default::default(), + name: JSXAttrName::Ident(name.clone()), + value: Some(JSXAttrValue::Str(value.clone())), + }) +} diff --git a/src/lib.rs b/src/lib.rs index 3995489..cfc33ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ use jsx_utils::*; use path_utils::{extract_absolute_path, extract_filename}; use rustc_hash::FxHashSet; use swc_core::{ - common::FileName, + common::{FileName, DUMMY_SP}, ecma::{ ast::*, visit::{noop_visit_mut_type, VisitMut, VisitMutWith}, @@ -21,31 +21,55 @@ use swc_core::{ pub struct ReactComponentAnnotateVisitor { config: PluginConfig, - source_file_name: Option, - source_file_path: Option, + source_file_name: Option, + source_file_path: Option, current_component_name: Option, - ignored_elements: FxHashSet<&'static str>, + ignored_elements: &'static FxHashSet<&'static str>, ignored_components_set: FxHashSet, + component_attr_ident: IdentName, + element_attr_ident: IdentName, + source_file_attr_ident: IdentName, + source_path_attr_ident: Option, /// Track the local identifier name for `styled` from @emotion/styled styled_import: Option, } impl ReactComponentAnnotateVisitor { pub fn new(config: PluginConfig, filename: &FileName) -> Self { - let source_file_name = extract_filename(filename); - let source_file_path = extract_absolute_path(filename); + let source_file_name = extract_filename(filename).map(|value| Str { + span: DUMMY_SP, + value: value.into(), + raw: None, + }); + let source_file_path = extract_absolute_path(filename).map(|value| Str { + span: DUMMY_SP, + value: value.into(), + raw: None, + }); // Pre-compute ignored components set for O(1) lookups let ignored_components_set: FxHashSet = config.ignored_components.iter().cloned().collect(); + let component_attr_ident = IdentName::new(config.component_attr_name().into(), DUMMY_SP); + let element_attr_ident = IdentName::new(config.element_attr_name().into(), DUMMY_SP); + let source_file_attr_ident = + IdentName::new(config.source_file_attr_name().into(), DUMMY_SP); + let source_path_attr_ident = config + .source_path_attr + .as_ref() + .map(|_| IdentName::new(config.source_path_attr_name().into(), DUMMY_SP)); Self { + component_attr_ident, config, + element_attr_ident, + ignored_elements: constants::default_ignored_elements(), + ignored_components_set, source_file_name, + source_file_attr_ident, source_file_path, + source_path_attr_ident, current_component_name: None, - ignored_elements: constants::default_ignored_elements(), - ignored_components_set, styled_import: None, } } @@ -113,11 +137,6 @@ impl ReactComponentAnnotateVisitor { fn add_attributes_to_element(&self, opening_element: &mut JSXOpeningElement) { let element_name = get_element_name(&opening_element.name); - // Skip React fragments - if is_react_fragment(&opening_element.name) { - return; - } - // Check if component should be ignored if let Some(ref component_name) = self.current_component_name { if self.should_ignore_component(component_name) { @@ -125,58 +144,71 @@ impl ReactComponentAnnotateVisitor { } } - // Check if element should be ignored if self.should_ignore_component(&element_name) { return; } let is_ignored_html = self.should_ignore_element(&element_name); - - // Add element attribute (for non-HTML elements or when component name differs) - if !is_ignored_html + let add_element_attr = !is_ignored_html && !has_attribute(opening_element, self.config.element_attr_name()) && (self.config.component_attr_name() != self.config.element_attr_name() - || self.current_component_name.is_none()) - { - opening_element.attrs.push(create_jsx_attr( - self.config.element_attr_name(), + || self.current_component_name.is_none()); + let add_component_attr = self.current_component_name.is_some() + && !has_attribute(opening_element, self.config.component_attr_name()); + let add_source_file_attr = self.source_file_name.is_some() + && (self.current_component_name.is_some() || !is_ignored_html) + && !has_attribute(opening_element, self.config.source_file_attr_name()); + let add_source_path_attr = self.source_file_path.is_some() + && self.source_path_attr_ident.is_some() + && (self.current_component_name.is_some() || !is_ignored_html) + && !has_attribute(opening_element, self.config.source_path_attr_name()); + + let attr_count = usize::from(add_element_attr) + + usize::from(add_component_attr) + + usize::from(add_source_file_attr) + + usize::from(add_source_path_attr); + + if attr_count > 0 { + opening_element.attrs.reserve(attr_count); + } + + if add_element_attr { + opening_element.attrs.push(create_jsx_attr_with_ident( + &self.element_attr_ident, &element_name, )); } - // Add component attribute (only for root elements) - if let Some(ref component_name) = self.current_component_name { - if !has_attribute(opening_element, self.config.component_attr_name()) { - opening_element.attrs.push(create_jsx_attr( - self.config.component_attr_name(), + if add_component_attr { + if let Some(ref component_name) = self.current_component_name { + opening_element.attrs.push(create_jsx_attr_with_ident( + &self.component_attr_ident, component_name, )); } } - // Add source file attribute - if let Some(ref source_file) = self.source_file_name { - if (self.current_component_name.is_some() || !is_ignored_html) - && !has_attribute(opening_element, self.config.source_file_attr_name()) - { - opening_element.attrs.push(create_jsx_attr( - self.config.source_file_attr_name(), - source_file, - )); + if add_source_file_attr { + if let Some(ref source_file) = self.source_file_name { + opening_element + .attrs + .push(create_jsx_attr_with_ident_and_str( + &self.source_file_attr_ident, + source_file, + )); } } - // Add source path attribute (only if explicitly configured) - if self.config.source_path_attr.is_some() { - if let Some(ref source_path) = self.source_file_path { - if (self.current_component_name.is_some() || !is_ignored_html) - && !has_attribute(opening_element, self.config.source_path_attr_name()) - { - opening_element.attrs.push(create_jsx_attr( - self.config.source_path_attr_name(), + if add_source_path_attr { + if let (Some(ref source_path), Some(ref source_path_attr_ident)) = + (&self.source_file_path, &self.source_path_attr_ident) + { + opening_element + .attrs + .push(create_jsx_attr_with_ident_and_str( + source_path_attr_ident, source_path, )); - } } } } @@ -267,6 +299,12 @@ impl ReactComponentAnnotateVisitor { // Build attributes in order: data attributes first, then spread let mut attrs = vec![]; + attrs.reserve( + 2 + usize::from(self.source_file_name.is_some()) + + usize::from( + self.source_path_attr_ident.is_some() && self.source_file_path.is_some(), + ), + ); // Add data-element attribute using the styled component variable name attrs.push(create_jsx_attr( @@ -276,20 +314,20 @@ impl ReactComponentAnnotateVisitor { // Add data-source-file attribute if let Some(ref source_file) = self.source_file_name { - attrs.push(create_jsx_attr( - self.config.source_file_attr_name(), + attrs.push(create_jsx_attr_with_ident_and_str( + &self.source_file_attr_ident, source_file, )); } // Add data-source-path attribute (only if explicitly configured) - if self.config.source_path_attr.is_some() { - if let Some(ref source_path) = self.source_file_path { - attrs.push(create_jsx_attr( - self.config.source_path_attr_name(), - source_path, - )); - } + if let (Some(ref source_path), Some(ref source_path_attr_ident)) = + (&self.source_file_path, &self.source_path_attr_ident) + { + attrs.push(create_jsx_attr_with_ident_and_str( + source_path_attr_ident, + source_path, + )); } // Add spread attribute AFTER data attributes: {...props} From 4f0dd0a218ea9a93e4540422b08310f5795dd638 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Sun, 15 Mar 2026 13:19:59 -0700 Subject: [PATCH 3/3] perf: satisfy clippy on styled attr allocation --- src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cfc33ab..47f1128 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -298,8 +298,7 @@ impl ReactComponentAnnotateVisitor { }); // Build attributes in order: data attributes first, then spread - let mut attrs = vec![]; - attrs.reserve( + let mut attrs = Vec::with_capacity( 2 + usize::from(self.source_file_name.is_some()) + usize::from( self.source_path_attr_ident.is_some() && self.source_file_path.is_some(),