From 54f4ddff4495d059e0202c46381c045a7d7a62e8 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Tue, 2 Dec 2025 20:22:11 -0800 Subject: [PATCH 1/2] refactor the provider --- codepress-swc-plugin/src/lib.rs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/codepress-swc-plugin/src/lib.rs b/codepress-swc-plugin/src/lib.rs index 316fa84..f8efc58 100644 --- a/codepress-swc-plugin/src/lib.rs +++ b/codepress-swc-plugin/src/lib.rs @@ -242,6 +242,10 @@ pub struct CodePressTransform { // Environment variables to inject as window.__CP_ENV_MAP__ (for HMR support) env_vars: HashMap, inserted_env_map: bool, + + // Skip __CPProvider wrapping (for frameworks like Next.js that handle HMR via router) + // When true, only is used for metadata, no React context wrapper + skip_provider_wrap: bool, } impl CodePressTransform { @@ -369,6 +373,21 @@ impl CodePressTransform { }) .unwrap_or_default(); + // Skip __CPProvider wrapping for frameworks that handle HMR differently (e.g., Next.js) + // Can be explicitly set via config, or auto-detected from framework indicators + let skip_provider_wrap = config + .remove("skipProviderWrap") + .and_then(|v| v.as_bool()) + .unwrap_or_else(|| { + // Auto-detect Next.js: check if next.config.js/ts/mjs exists in cwd + // When running under Next.js SWC, these files typically exist + let cwd = std::env::current_dir().unwrap_or_default(); + let next_config_exists = cwd.join("next.config.js").exists() + || cwd.join("next.config.ts").exists() + || cwd.join("next.config.mjs").exists(); + next_config_exists + }); + Self { repo_name, branch_name, @@ -397,6 +416,7 @@ impl CodePressTransform { import_resolver, env_vars, inserted_env_map: false, + skip_provider_wrap, } } @@ -2587,8 +2607,10 @@ impl VisitMut for CodePressTransform { // Inject env vars into entry points (for HMR support) self.ensure_env_map_inline(m); - // Inject inline provider once per module (from main branch) - self.ensure_provider_inline(m); + // Inject inline provider once per module (skip for frameworks that handle HMR differently) + if !self.skip_provider_wrap { + self.ensure_provider_inline(m); + } // Inject guarded stamping helper self.ensure_stamp_helper_inline(m); @@ -3333,9 +3355,10 @@ impl VisitMut for CodePressTransform { .push(JSXElementChild::JSXElement(Box::new(original))); *node = wrapper; - // Wrap with __CPProvider unless the component depends on its direct parent/child - // identity (e.g., Recharts clones its immediate child chart). - if !block_provider { + // Wrap with __CPProvider unless: + // - The component depends on its direct parent/child identity (e.g., Recharts) + // - We're in a framework that handles HMR differently (e.g., Next.js uses router.replace) + if !block_provider && !self.skip_provider_wrap { let cs_enc = if let JSXAttrOrSpread::JSXAttr(a) = self.create_encoded_path_attr(&filename, orig_open_span, Some(orig_full_span)) { From 254e1e0051dfba18747e3ca87972ee8bed9c577e Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Tue, 2 Dec 2025 21:48:29 -0800 Subject: [PATCH 2/2] js map --- codepress-swc-plugin/src/lib.rs | 335 +++++++++++++++++++++++++------- 1 file changed, 262 insertions(+), 73 deletions(-) diff --git a/codepress-swc-plugin/src/lib.rs b/codepress-swc-plugin/src/lib.rs index f8efc58..bb97e64 100644 --- a/codepress-swc-plugin/src/lib.rs +++ b/codepress-swc-plugin/src/lib.rs @@ -246,6 +246,31 @@ pub struct CodePressTransform { // Skip __CPProvider wrapping (for frameworks like Next.js that handle HMR via router) // When true, only is used for metadata, no React context wrapper skip_provider_wrap: bool, + + // Skip DOM wrapper for custom components + // When true, attributes are added directly to components (like Babel plugin behavior) + // This avoids React reconciliation issues with getLayout pattern in Pages Router + skip_marker_wrap: bool, + + // JavaScript-based metadata map (instead of DOM attributes) + // When enabled, heavy metadata is stored in window.__CODEPRESS_MAP__ instead of DOM + // Only codepress-data-fp attribute is added to elements for identification + use_js_metadata_map: bool, + metadata_map: HashMap, + inserted_metadata_map: bool, +} + +/// Metadata entry for the JS-based map (window.__CODEPRESS_MAP__) +#[derive(serde::Serialize, Clone)] +struct MetadataEntry { + #[serde(rename = "cs")] + callsite: String, + #[serde(rename = "c")] + edit_candidates: String, + #[serde(rename = "k")] + source_kinds: String, + #[serde(rename = "s")] + symbol_refs: String, } impl CodePressTransform { @@ -380,14 +405,31 @@ impl CodePressTransform { .and_then(|v| v.as_bool()) .unwrap_or_else(|| { // Auto-detect Next.js: check if next.config.js/ts/mjs exists in cwd - // When running under Next.js SWC, these files typically exist let cwd = std::env::current_dir().unwrap_or_default(); - let next_config_exists = cwd.join("next.config.js").exists() + cwd.join("next.config.js").exists() || cwd.join("next.config.ts").exists() - || cwd.join("next.config.mjs").exists(); - next_config_exists + || cwd.join("next.config.mjs").exists() }); + // Skip wrapper for custom components + // When true, attributes are added directly to components (like Babel plugin) + // This avoids React reconciliation issues with getLayout pattern in Pages Router + // Auto-enabled for Next.js to match Babel plugin behavior + let skip_marker_wrap = config + .remove("skipMarkerWrap") + .and_then(|v| v.as_bool()) + .unwrap_or(skip_provider_wrap); // If skipping provider, also skip marker + + // Use JS-based metadata map instead of DOM attributes + // When true, heavy metadata (edit-candidates, source-kinds, etc.) is stored in + // window.__CODEPRESS_MAP__ instead of DOM attributes. Only codepress-data-fp is on DOM. + // This avoids React reconciliation issues and keeps DOM clean. + // Auto-enabled for Next.js + let use_js_metadata_map = config + .remove("useJsMetadataMap") + .and_then(|v| v.as_bool()) + .unwrap_or(skip_provider_wrap); // Same auto-detection as skip_provider_wrap + Self { repo_name, branch_name, @@ -417,6 +459,10 @@ impl CodePressTransform { env_vars, inserted_env_map: false, skip_provider_wrap, + skip_marker_wrap, + use_js_metadata_map, + metadata_map: HashMap::new(), + inserted_metadata_map: false, } } @@ -563,6 +609,27 @@ impl CodePressTransform { } } + /// Extract element name as a string for use as React key + fn get_element_name_str(name: &JSXElementName) -> Option { + match name { + JSXElementName::Ident(ident) => Some(ident.sym.to_string()), + JSXElementName::JSXMemberExpr(m) => { + // For member expressions like Foo.Bar, build "Foo.Bar" + fn build_member_name(m: &swc_core::ecma::ast::JSXMemberExpr) -> String { + let obj_str = match &m.obj { + swc_core::ecma::ast::JSXObject::Ident(id) => id.sym.to_string(), + swc_core::ecma::ast::JSXObject::JSXMemberExpr(inner) => build_member_name(inner), + }; + format!("{}.{}", obj_str, m.prop.sym) + } + Some(build_member_name(m)) + } + JSXElementName::JSXNamespacedName(ns) => { + Some(format!("{}:{}", ns.ns.sym, ns.name.sym)) + } + } + } + fn is_synthetic_element(&self, name: &JSXElementName) -> bool { match name { // / <__CPProvider> / <__CPX> @@ -1225,12 +1292,14 @@ impl CodePressTransform { kinds.into_iter().collect() } - // Build a wrapper with callsite + // Build a wrapper with callsite + // The key helps React reconcile markers across page navigations (getLayout pattern) fn make_display_contents_wrapper( &self, filename: &str, callsite_open_span: swc_core::common::Span, elem_span: swc_core::common::Span, + component_key: Option<&str>, ) -> JSXElement { let mut opening = JSXOpeningElement { name: JSXElementName::Ident(cp_ident(&self.wrapper_tag).into()), @@ -1239,6 +1308,14 @@ impl CodePressTransform { type_args: None, span: DUMMY_SP, }; + // key={component_name} - helps React reconcile markers across navigations + if let Some(key) = component_key { + opening.attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(cp_ident_name("key".into())), + value: Some(make_jsx_str_attr_value(key.to_string())), + })); + } // style={{display:'contents'}} opening.attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { span: DUMMY_SP, @@ -1970,6 +2047,56 @@ impl CodePressTransform { m.body.insert(insert_at, stmt); self.inserted_env_map = true; } + + /// Injects window.__CODEPRESS_MAP__ entries at the end of module processing. + /// This stores metadata in JS instead of DOM attributes for cleaner React reconciliation. + /// Uses Object.assign to merge with existing map (multiple modules may load). + fn inject_metadata_map(&mut self, m: &mut Module) { + if self.inserted_metadata_map || self.metadata_map.is_empty() || !self.use_js_metadata_map { + return; + } + + // Build JSON for the metadata entries collected in this module + let json = serde_json::to_string(&self.metadata_map).unwrap_or_else(|_| "{}".to_string()); + + // Inject: try{if(typeof window!=='undefined'){window.__CODEPRESS_MAP__=Object.assign(window.__CODEPRESS_MAP__||{},{...});}}catch(_){} + // Using Object.assign to merge with existing map from other modules + let js = format!( + "try{{if(typeof window!=='undefined'){{window.__CODEPRESS_MAP__=Object.assign(window.__CODEPRESS_MAP__||{{}},{});}}}}catch(_){{}}", + json + ); + + let stmt = ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::New(NewExpr { + span: DUMMY_SP, + callee: Box::new(Expr::Ident(cp_ident("Function".into()))), + args: Some(vec![ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: js.into(), + raw: None, + }))), + }]), + type_args: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }))), + args: vec![], + type_args: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + })), + })); + + // Place AFTER directive prologue (e.g., "use client"; "use strict") + let insert_at = self.directive_insert_index(m); + m.body.insert(insert_at, stmt); + self.inserted_metadata_map = true; + } } // ----------------------------------------------------------------------------- @@ -2758,6 +2885,8 @@ impl VisitMut for CodePressTransform { // Continue other transforms and inject graph (from main branch) m.visit_mut_children_with(self); self.inject_graph_stmt(m); + // Inject metadata map (if using JS-based metadata instead of DOM attributes) + self.inject_metadata_map(m); } fn visit_mut_import_decl(&mut self, n: &mut ImportDecl) { let _ = self.file_from_span(n.span); @@ -3326,73 +3455,139 @@ impl VisitMut for CodePressTransform { && !has_slot_prop; let block_provider = self.should_block_provider_wrap(&node.opening.name); - if is_custom_call { - // DOM wrapper (display: contents) carrying callsite; we also duplicate metadata on the invocation - let mut wrapper = - self.make_display_contents_wrapper(&filename, orig_open_span, orig_full_span); + // Generate the fp value (same format as codepress-data-fp attribute) + let normalized = self.normalize_repo_relative(&filename); + let encoded_path = xor_encode(&normalized); + let fp_value = if let Some(line_info) = self.get_line_info(node.opening.span, Some(node.span)) { + format!("{}:{}", encoded_path, line_info) + } else { + encoded_path.clone() + }; - let mut original = std::mem::replace( - node, - JSXElement { - span: DUMMY_SP, - opening: JSXOpeningElement { - name: JSXElementName::Ident(cp_ident("div".into()).into()), - attrs: vec![], - self_closing: false, - type_args: None, - span: DUMMY_SP, - }, - children: vec![], - closing: None, - }, - ); + // Generate callsite value + let callsite_value = format!( + "{}:{}-{}", + xor_encode(&normalized), + self.source_map.as_ref().map(|sm| sm.lookup_char_pos(orig_open_span.lo()).line).unwrap_or(0), + self.source_map.as_ref().map(|sm| sm.lookup_char_pos(orig_full_span.hi()).line).unwrap_or(0) + ); - // Intentionally avoid duplicating metadata onto the custom component invocation - // to prevent interfering with component prop forwarding (e.g., Radix Slot). + // JS-based metadata map mode: store metadata in window.__CODEPRESS_MAP__ instead of DOM + // Only codepress-data-fp attribute is on DOM (already added above at line 3273-3278) + // No wrapper elements, no additional DOM attributes - cleanest possible approach + if self.use_js_metadata_map { + // Store metadata in the map keyed by fp + self.metadata_map.insert(fp_value.clone(), MetadataEntry { + callsite: callsite_value, + edit_candidates: cands_enc.clone(), + source_kinds: kinds_enc.clone(), + symbol_refs: symrefs_enc.clone(), + }); + // No DOM attributes or wrappers needed - extension reads from JS map + return; + } - wrapper - .children - .push(JSXElementChild::JSXElement(Box::new(original))); - *node = wrapper; + // Legacy mode: DOM attributes and wrapper elements + if is_custom_call { + if self.skip_marker_wrap { + // Skip DOM wrapper - add attributes directly to component (like Babel plugin) + // This avoids React reconciliation issues with getLayout pattern in Pages Router + CodePressTransform::attach_attr_string( + &mut node.opening.attrs, + "data-codepress-edit-candidates", + cands_enc.clone(), + ); + CodePressTransform::attach_attr_string( + &mut node.opening.attrs, + "data-codepress-source-kinds", + kinds_enc.clone(), + ); + CodePressTransform::attach_attr_string( + &mut node.opening.attrs, + "data-codepress-symbol-refs", + symrefs_enc.clone(), + ); + CodePressTransform::attach_attr_string( + &mut node.opening.attrs, + "data-codepress-callsite", + callsite_value, + ); + } else { + // DOM wrapper (display: contents) carrying callsite; we also duplicate metadata on the invocation + // Extract component name + line number for use as React key to help reconciliation + // Line number ensures uniqueness when same component appears multiple times + let component_key = Self::get_element_name_str(&node.opening.name).map(|name| { + let line = self.source_map.as_ref() + .map(|sm| sm.lookup_char_pos(orig_open_span.lo()).line) + .unwrap_or(0); + format!("{}:{}", name, line) + }); + let mut wrapper = + self.make_display_contents_wrapper(&filename, orig_open_span, orig_full_span, component_key.as_deref()); - // Wrap with __CPProvider unless: - // - The component depends on its direct parent/child identity (e.g., Recharts) - // - We're in a framework that handles HMR differently (e.g., Next.js uses router.replace) - if !block_provider && !self.skip_provider_wrap { - let cs_enc = if let JSXAttrOrSpread::JSXAttr(a) = - self.create_encoded_path_attr(&filename, orig_open_span, Some(orig_full_span)) - { - jsx_attr_value_to_string(&a.value).unwrap_or_default() - } else { - "".into() - }; - // find fp on this node (or recompute) - let mut fp_enc = String::new(); - for a in &node.opening.attrs { - if let JSXAttrOrSpread::JSXAttr(attr) = a { - if let JSXAttrName::Ident(idn) = &attr.name { - if idn.sym.as_ref() == "codepress-data-fp" { - if let Some(val) = jsx_attr_value_to_string(&attr.value) { - fp_enc = val; + let original = std::mem::replace( + node, + JSXElement { + span: DUMMY_SP, + opening: JSXOpeningElement { + name: JSXElementName::Ident(cp_ident("div".into()).into()), + attrs: vec![], + self_closing: false, + type_args: None, + span: DUMMY_SP, + }, + children: vec![], + closing: None, + }, + ); + + // Intentionally avoid duplicating metadata onto the custom component invocation + // to prevent interfering with component prop forwarding (e.g., Radix Slot). + + wrapper + .children + .push(JSXElementChild::JSXElement(Box::new(original))); + *node = wrapper; + + // Wrap with __CPProvider unless: + // - The component depends on its direct parent/child identity (e.g., Recharts) + // - We're in a framework that handles HMR differently (e.g., Next.js uses router.replace) + if !block_provider && !self.skip_provider_wrap { + let cs_enc = if let JSXAttrOrSpread::JSXAttr(a) = + self.create_encoded_path_attr(&filename, orig_open_span, Some(orig_full_span)) + { + jsx_attr_value_to_string(&a.value).unwrap_or_default() + } else { + "".into() + }; + // find fp on this node (or recompute) + let mut fp_enc = String::new(); + for a in &node.opening.attrs { + if let JSXAttrOrSpread::JSXAttr(attr) = a { + if let JSXAttrName::Ident(idn) = &attr.name { + if idn.sym.as_ref() == "codepress-data-fp" { + if let Some(val) = jsx_attr_value_to_string(&attr.value) { + fp_enc = val; + } } } } } + let meta = ProviderMeta { + cs: cs_enc, + c: cands_enc.clone(), + k: kinds_enc.clone(), + fp: fp_enc, + }; + self.wrap_with_provider(node, meta); } - let meta = ProviderMeta { - cs: cs_enc, - c: cands_enc.clone(), - k: kinds_enc.clone(), - fp: fp_enc, - }; - self.wrap_with_provider(node, meta); - } - let attrs = &mut node.opening.attrs; - // Only annotate the injected wrappers (provider or host wrapper), not the invocation element - CodePressTransform::attach_attr_string(attrs, "data-codepress-edit-candidates", cands_enc.clone()); - CodePressTransform::attach_attr_string(attrs, "data-codepress-source-kinds", kinds_enc.clone()); - CodePressTransform::attach_attr_string(attrs, "data-codepress-symbol-refs", symrefs_enc.clone()); + let attrs = &mut node.opening.attrs; + // Only annotate the injected wrappers (provider or host wrapper), not the invocation element + CodePressTransform::attach_attr_string(attrs, "data-codepress-edit-candidates", cands_enc.clone()); + CodePressTransform::attach_attr_string(attrs, "data-codepress-source-kinds", kinds_enc.clone()); + CodePressTransform::attach_attr_string(attrs, "data-codepress-symbol-refs", symrefs_enc.clone()); + } } else { // Host element → tag directly CodePressTransform::attach_attr_string( @@ -3411,17 +3606,11 @@ impl VisitMut for CodePressTransform { symrefs_enc.clone(), ); if !Self::has_attr_key(&node.opening.attrs, "data-codepress-callsite") { - if let JSXAttrOrSpread::JSXAttr(a) = self.create_encoded_path_attr( - &filename, - node.opening.span, - Some(node.span), - ) { - node.opening.attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { - span: DUMMY_SP, - name: JSXAttrName::Ident(cp_ident_name("data-codepress-callsite".into())), - value: a.value, - })); - } + node.opening.attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(cp_ident_name("data-codepress-callsite".into())), + value: Some(make_jsx_str_attr_value(callsite_value)), + })); } } }