diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ab0319..0584d60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - key: ${{ runner.os }}-cargo-${{ matrix.band }}-${{ hashFiles('codepress-swc-plugin/src/**') }} + key: ${{ runner.os }}-cargo-${{ matrix.band }}-${{ hashFiles('codepress-swc-plugin/src/**', 'scripts/build-swc.mjs') }} restore-keys: | ${{ runner.os }}-cargo-${{ matrix.band }}- ${{ runner.os }}-cargo- @@ -142,6 +142,15 @@ jobs: env: BAND: ${{ matrix.band }} + - name: Verify WASM build + run: | + echo "Built WASM files:" + ls -la swc/*.wasm + for f in swc/*.wasm; do + echo "MD5 of $f:" + md5sum "$f" || md5 "$f" + done + - name: Upload WASM artifact uses: actions/upload-artifact@v4 with: diff --git a/codepress-swc-plugin/src/lib.rs b/codepress-swc-plugin/src/lib.rs index 01ee0d0..f090520 100644 --- a/codepress-swc-plugin/src/lib.rs +++ b/codepress-swc-plugin/src/lib.rs @@ -242,6 +242,39 @@ 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, + + // Auto-inject refresh provider at entry points (when use_js_metadata_map is true) + auto_inject_refresh_provider: bool, + inserted_refresh_provider: 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 { @@ -369,6 +402,46 @@ 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 + let cwd = std::env::current_dir().unwrap_or_default(); + cwd.join("next.config.js").exists() + || cwd.join("next.config.ts").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. + // Defaults to true - it's the better approach and works everywhere + let use_js_metadata_map = config + .remove("useJsMetadataMap") + .and_then(|v| v.as_bool()) + .unwrap_or(true); // Default to true - cleaner DOM, no reconciliation issues + + // Auto-inject refresh provider at detected app entry points + // Set to false for monorepos or custom entry points where you want manual control + // Defaults to true when use_js_metadata_map is true + let auto_inject_refresh_provider = config + .remove("autoInjectRefreshProvider") + .and_then(|v| v.as_bool()) + .unwrap_or(use_js_metadata_map); // Default to true when using JS metadata map + Self { repo_name, branch_name, @@ -397,6 +470,13 @@ impl CodePressTransform { import_resolver, env_vars, inserted_env_map: false, + auto_inject_refresh_provider, + inserted_refresh_provider: false, + skip_provider_wrap, + skip_marker_wrap, + use_js_metadata_map, + metadata_map: HashMap::new(), + inserted_metadata_map: false, } } @@ -530,6 +610,32 @@ impl CodePressTransform { false } + /// Detect if this file is an app entry point where we should inject the refresh provider. + /// This enables automatic HMR support without users needing to manually add CPRefreshProvider. + fn is_app_entry_point(p: &str) -> bool { + let s = p.replace('\\', "/"); + + // Next.js Pages Router: _app.tsx/_app.js (the main app wrapper) + if s.contains("/_app.") && (s.ends_with(".tsx") || s.ends_with(".jsx") || s.ends_with(".ts") || s.ends_with(".js")) { + return true; + } + + // Next.js App Router: root layout.tsx (app/layout.tsx or src/app/layout.tsx) + // Only match the ROOT layout, not nested layouts + if (s.ends_with("/app/layout.tsx") || s.ends_with("/app/layout.jsx") || + s.ends_with("/app/layout.ts") || s.ends_with("/app/layout.js")) { + return true; + } + + // Vite / Create React App: main.tsx, index.tsx in src folder + if s.contains("/src/") && (s.ends_with("/main.tsx") || s.ends_with("/main.jsx") || + s.ends_with("/index.tsx") || s.ends_with("/index.jsx")) { + return true; + } + + false + } + fn is_custom_component_name(name: &JSXElementName) -> bool { match name { JSXElementName::Ident(ident) => ident @@ -543,6 +649,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> @@ -1205,12 +1332,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()), @@ -1219,6 +1348,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, @@ -1950,168 +2087,615 @@ impl CodePressTransform { m.body.insert(insert_at, stmt); self.inserted_env_map = true; } -} - -// ----------------------------------------------------------------------------- -// Module Graph info -// ----------------------------------------------------------------------------- - -#[derive(serde::Serialize)] -struct ImportRow { - local: String, // local alias in this module - imported: String, // 'default' | named | '*' (namespace) - source: String, // "…/module" - span: String, // "file:start-end" -} - -#[derive(serde::Serialize)] -struct ExportRow { - exported: String, // name visible to other modules ('default' is ok) - local: String, // local symbol bound in this module - span: String, -} - -#[derive(serde::Serialize)] -struct ReexportRow { - exported: String, // name re-exported by this module - imported: String, // name imported from source - source: String, // "…/module" - span: String, -} -#[derive(serde::Serialize)] -struct DefRow { - local: String, // local binding in this module - kind: &'static str, // var|let|const|func|class - span: String, -} + /// 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; + } -#[derive(serde::Serialize)] -struct MutationRow { - root: String, // root local ident being mutated (teams) - path: String, // dotted/index path if static: ".new_key" or '["k"]' or "[2]" - kind: &'static str, // assign|update|call:Object.assign|call:push|call:set|spread-merge - span: String, -} + // Build JSON for the metadata entries collected in this module + let json = serde_json::to_string(&self.metadata_map).unwrap_or_else(|_| "{}".to_string()); -#[derive(serde::Serialize)] -struct LiteralIxRow { - export_name: String, // e.g. PRINCIPALS - path: String, // e.g. [1].specialty - text: String, - span: 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 + ); -#[derive(serde::Serialize)] -struct ModuleGraph { - imports: Vec, - exports: Vec, - reexports: Vec, - defs: Vec, - mutations: Vec, - literal_index: Vec, -} + 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(), + })), + })); -// ----------------------------------------------------------------------------- -// Tracing types & binding collector -// ----------------------------------------------------------------------------- + // 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; + } + + /// Injects the CPRefreshProvider at app entry points (when use_js_metadata_map is true). + /// This enables automatic HMR without users needing to manually add the provider. + /// + /// At entry points (_app.tsx, layout.tsx, main.tsx), injects: + /// 1. A module-level version counter + /// 2. The __CP_triggerRefresh function + /// 3. The __CPRefreshProvider component using useSyncExternalStore + /// + /// The default export is then wrapped with __CPRefreshProvider. + fn ensure_refresh_provider_at_entry(&mut self, m: &mut Module) { + if self.inserted_refresh_provider || !self.use_js_metadata_map { + return; + } -#[derive(Clone)] -struct Binding { - def_span: swc_core::common::Span, - init: Option>, - import: Option, - fn_body_span: Option, -} + // Get current file path + let _ = self.file_from_span(m.span); + let file = self.current_file(); -#[derive(Clone)] -struct ImportInfo { - source: String, - imported: String, -} + // Only inject at entry points + if !Self::is_app_entry_point(&file) { + return; + } -#[derive(serde::Serialize)] -#[serde(tag = "kind")] -enum ProvNode { - Literal { - span: String, - value_kind: &'static str, - }, - Ident { - name: String, - span: String, - }, - Init { - span: String, - }, - Import { - source: String, - imported: String, - span: String, - }, - Member { - span: String, - }, - ObjectProp { - key: String, - span: String, - }, - ArrayElem { - index: usize, - span: String, - }, - Call { - callee: String, - callsite: String, - callee_span: String, - fn_def_span: Option, - }, - Ctor { - callee: String, - span: String, - }, - Op { - op: String, - span: String, - }, - Env { - key: String, - span: String, - }, - Fetch { - url: Option, - span: String, - }, - Context { - name: String, - span: String, - }, - Hook { - name: String, - span: String, - }, - Unknown { - span: String, - }, -} + // NOTE: We intentionally do NOT skip files with Next.js fonts here. + // Unlike the legacy per-component __CPProvider wrapping which could + // interfere with font optimization, __CPRefreshProvider only uses + // useSyncExternalStore and simply returns children - no context wrapping. + // This allows HMR to work even in files that use next/font. -#[derive(serde::Serialize)] -struct Candidate { - target: String, - reason: String, -} + // Check if file is TSX/JSX (has JSX content) + if !file.ends_with(".tsx") && !file.ends_with(".jsx") { + return; + } -// ----------------------------------------------------------------------------- -// Import resolution (tsconfig/jsconfig-aware) to determine local vs external imports -// ----------------------------------------------------------------------------- + // import { useSyncExternalStore, createElement } from "react"; + let import_decl = ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: cp_ident("useSyncExternalStore".into()), + imported: None, + is_type_only: false, + }), + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: cp_ident("createElement".into()), + imported: None, + is_type_only: false, + }), + ], + src: Box::new(Str { + span: DUMMY_SP, + value: "react".into(), + raw: None, + }), + type_only: false, + with: None, + #[cfg(not(feature = "compat_0_87"))] + phase: ImportPhase::Evaluation, + })); -#[derive(Clone)] -struct PathAlias { - prefix: String, - suffix: String, - targets: Vec, // already joined with baseUrl/config dir - has_wildcard: bool, -} + // Inject runtime setup via new Function() for global trigger setup + // This sets up window.__CP_triggerRefresh and the version counter + let runtime_js = r#"try{if(typeof window!=='undefined'){window.__cpRefreshVersion=window.__cpRefreshVersion||0;if(!window.__CP_triggerRefresh){window.__CP_triggerRefresh=function(){window.__cpRefreshVersion++;window.dispatchEvent(new CustomEvent('CP_PREVIEW_REFRESH'));};window.__CP_triggerRefresh.__cp_dispatches_preview=true;}}}catch(_){}"#; + + let runtime_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: runtime_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(), + })), + })); + + // let __cpRefreshVersion = 0; (module-level version for useSyncExternalStore) + let version_decl = ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Let, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(BindingIdent { + id: cp_ident("__cpRefreshVersion".into()), + type_ann: None, + }), + init: Some(Box::new(Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: 0.0, + raw: None, + })))), + definite: false, + }], + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + })))); + + // function __CPRefreshProvider({ children }) { + // const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + // return children; + // } + // The version change triggers React to re-render all children + let provider_fn = { + // Build subscribe function: (cb) => { const h = () => { __cpRefreshVersion++; cb(); }; window.addEventListener("CP_PREVIEW_REFRESH", h); return () => window.removeEventListener("CP_PREVIEW_REFRESH", h); } + let subscribe_arrow = Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + params: vec![Pat::Ident(BindingIdent { id: cp_ident("cb".into()), type_ann: None })], + body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt { + span: DUMMY_SP, + stmts: vec![ + // const h = () => { __cpRefreshVersion++; cb(); } + Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(BindingIdent { id: cp_ident("h".into()), type_ann: None }), + init: Some(Box::new(Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + params: vec![], + body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt { + span: DUMMY_SP, + stmts: vec![ + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Update(UpdateExpr { + span: DUMMY_SP, + op: UpdateOp::PlusPlus, + prefix: false, + arg: Box::new(Expr::Ident(cp_ident("__cpRefreshVersion".into()))), + })), + }), + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident(cp_ident("cb".into())))), + args: vec![], + type_args: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + })), + }), + ], + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + })), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }))), + definite: false, + }], + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }))), + // window.addEventListener("CP_PREVIEW_REFRESH", h); + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(cp_ident("window".into()))), + prop: MemberProp::Ident(cp_ident_name("addEventListener".into())), + }))), + args: vec![ + ExprOrSpread { spread: None, expr: Box::new(Expr::Lit(Lit::Str(Str { span: DUMMY_SP, value: "CP_PREVIEW_REFRESH".into(), raw: None }))) }, + ExprOrSpread { spread: None, expr: Box::new(Expr::Ident(cp_ident("h".into()))) }, + ], + type_args: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + })), + }), + // return () => window.removeEventListener("CP_PREVIEW_REFRESH", h); + Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(Box::new(Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + params: vec![], + body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(cp_ident("window".into()))), + prop: MemberProp::Ident(cp_ident_name("removeEventListener".into())), + }))), + args: vec![ + ExprOrSpread { spread: None, expr: Box::new(Expr::Lit(Lit::Str(Str { span: DUMMY_SP, value: "CP_PREVIEW_REFRESH".into(), raw: None }))) }, + ExprOrSpread { spread: None, expr: Box::new(Expr::Ident(cp_ident("h".into()))) }, + ], + type_args: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + })))), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }))), + }), + ], + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + })), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }); + + // () => __cpRefreshVersion + let get_snapshot = Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + params: vec![], + body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Ident(cp_ident("__cpRefreshVersion".into()))))), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }); + + // () => 0 (server snapshot) + let get_server_snapshot = Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + params: vec![], + body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: 0.0, + raw: None, + }))))), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }); + + // const __cpV = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + let use_sync_stmt = Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(BindingIdent { id: cp_ident("__cpV".into()), type_ann: None }), + init: Some(Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident(cp_ident("useSyncExternalStore".into())))), + args: vec![ + ExprOrSpread { spread: None, expr: Box::new(subscribe_arrow) }, + ExprOrSpread { spread: None, expr: Box::new(get_snapshot) }, + ExprOrSpread { spread: None, expr: Box::new(get_server_snapshot) }, + ], + type_args: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }))), + definite: false, + }], + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }))); + + // return createElement("div", { key: __cpV, style: { display: "contents" } }, children); + // Using a div with display:contents so it's layout-neutral but has a key to force remount + let return_children = Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident(cp_ident("createElement".into())))), + args: vec![ + // "div" + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: "div".into(), + raw: None, + }))), + }, + // { key: __cpV, style: { display: "contents" } } + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![ + PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(cp_ident_name("key".into())), + value: Box::new(Expr::Ident(cp_ident("__cpV".into()))), + }))), + PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(cp_ident_name("style".into())), + value: Box::new(Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![ + PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(cp_ident_name("display".into())), + value: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: "contents".into(), + raw: None, + }))), + }))), + ], + })), + }))), + ], + })), + }, + // children + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(cp_ident("children".into()))), + }, + ], + type_args: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }))), + }); + + // function __CPRefreshProvider({ children }) { ... } + ModuleItem::Stmt(Stmt::Decl(Decl::Fn(FnDecl { + ident: cp_ident("__CPRefreshProvider".into()), + declare: false, + function: Box::new(Function { + params: vec![Param { + span: DUMMY_SP, + decorators: vec![], + pat: Pat::Object(ObjectPat { + span: DUMMY_SP, + optional: false, + type_ann: None, + props: vec![ObjectPatProp::Assign(AssignPatProp { + span: DUMMY_SP, + key: cp_ident("children".into()).into(), + value: None, + })], + }), + }], + decorators: vec![], + span: DUMMY_SP, + body: Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![use_sync_stmt, return_children], + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }), + is_generator: false, + is_async: false, + type_params: None, + return_type: None, + #[cfg(not(feature = "compat_0_87"))] + ctxt: SyntaxContext::empty(), + }), + }))) + }; + + // Insert declarations at the top of the module (after directives) + let insert_at = self.directive_insert_index(m); + m.body.insert(insert_at, provider_fn); + m.body.insert(insert_at, version_decl); + m.body.insert(insert_at, runtime_stmt); + m.body.insert(insert_at, import_decl); + self.inserted_refresh_provider = true; + } +} + +// ----------------------------------------------------------------------------- +// Module Graph info +// ----------------------------------------------------------------------------- + +#[derive(serde::Serialize)] +struct ImportRow { + local: String, // local alias in this module + imported: String, // 'default' | named | '*' (namespace) + source: String, // "…/module" + span: String, // "file:start-end" +} + +#[derive(serde::Serialize)] +struct ExportRow { + exported: String, // name visible to other modules ('default' is ok) + local: String, // local symbol bound in this module + span: String, +} + +#[derive(serde::Serialize)] +struct ReexportRow { + exported: String, // name re-exported by this module + imported: String, // name imported from source + source: String, // "…/module" + span: String, +} + +#[derive(serde::Serialize)] +struct DefRow { + local: String, // local binding in this module + kind: &'static str, // var|let|const|func|class + span: String, +} + +#[derive(serde::Serialize)] +struct MutationRow { + root: String, // root local ident being mutated (teams) + path: String, // dotted/index path if static: ".new_key" or '["k"]' or "[2]" + kind: &'static str, // assign|update|call:Object.assign|call:push|call:set|spread-merge + span: String, +} + +#[derive(serde::Serialize)] +struct LiteralIxRow { + export_name: String, // e.g. PRINCIPALS + path: String, // e.g. [1].specialty + text: String, + span: String, +} + +#[derive(serde::Serialize)] +struct ModuleGraph { + imports: Vec, + exports: Vec, + reexports: Vec, + defs: Vec, + mutations: Vec, + literal_index: Vec, +} + +// ----------------------------------------------------------------------------- +// Tracing types & binding collector +// ----------------------------------------------------------------------------- + +#[derive(Clone)] +struct Binding { + def_span: swc_core::common::Span, + init: Option>, + import: Option, + fn_body_span: Option, +} + +#[derive(Clone)] +struct ImportInfo { + source: String, + imported: String, +} + +#[derive(serde::Serialize)] +#[serde(tag = "kind")] +enum ProvNode { + Literal { + span: String, + value_kind: &'static str, + }, + Ident { + name: String, + span: String, + }, + Init { + span: String, + }, + Import { + source: String, + imported: String, + span: String, + }, + Member { + span: String, + }, + ObjectProp { + key: String, + span: String, + }, + ArrayElem { + index: usize, + span: String, + }, + Call { + callee: String, + callsite: String, + callee_span: String, + fn_def_span: Option, + }, + Ctor { + callee: String, + span: String, + }, + Op { + op: String, + span: String, + }, + Env { + key: String, + span: String, + }, + Fetch { + url: Option, + span: String, + }, + Context { + name: String, + span: String, + }, + Hook { + name: String, + span: String, + }, + Unknown { + span: String, + }, +} + +#[derive(serde::Serialize)] +struct Candidate { + target: String, + reason: String, +} + +// ----------------------------------------------------------------------------- +// Import resolution (tsconfig/jsconfig-aware) to determine local vs external imports +// ----------------------------------------------------------------------------- + +#[derive(Clone)] +struct PathAlias { + prefix: String, + suffix: String, + targets: Vec, // already joined with baseUrl/config dir + has_wildcard: bool, +} struct ImportResolver { root: PathBuf, @@ -2481,100 +3065,8 @@ fn detect_fetch_like(c: &CallExpr) -> Option { } // ----------------------------------------------------------------------------- -// Pass 1: main transform (add attributes; DOM wrapper; non-DOM provider) +// Pass 1: main transform // ----------------------------------------------------------------------------- -impl CodePressTransform { - // Build provider wrapper: <__CPProvider value={{cs,c,k,fp}}>{node} - fn wrap_with_provider(&self, node: &mut JSXElement, meta: ProviderMeta) { - let provider_name: JSXElementName = - JSXElementName::Ident(cp_ident(&self.provider_ident).into()); - - let mut opening = JSXOpeningElement { - name: provider_name.clone(), - attrs: vec![], - self_closing: false, - type_args: None, - span: DUMMY_SP, - }; - - let obj = Expr::Object(ObjectLit { - span: DUMMY_SP, - props: vec![ - PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { - key: PropName::Ident(cp_ident_name("cs".into())), - value: Box::new(Expr::Lit(Lit::Str(Str { - span: DUMMY_SP, - value: meta.cs.into(), - raw: None, - }))), - }))), - PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { - key: PropName::Ident(cp_ident_name("c".into())), - value: Box::new(Expr::Lit(Lit::Str(Str { - span: DUMMY_SP, - value: meta.c.into(), - raw: None, - }))), - }))), - PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { - key: PropName::Ident(cp_ident_name("k".into())), - value: Box::new(Expr::Lit(Lit::Str(Str { - span: DUMMY_SP, - value: meta.k.into(), - raw: None, - }))), - }))), - PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { - key: PropName::Ident(cp_ident_name("fp".into())), - value: Box::new(Expr::Lit(Lit::Str(Str { - span: DUMMY_SP, - value: meta.fp.into(), - raw: None, - }))), - }))), - ], - }); - - opening.attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { - span: DUMMY_SP, - name: JSXAttrName::Ident(cp_ident_name("value".into())), - value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { - span: DUMMY_SP, - expr: JSXExpr::Expr(Box::new(obj)), - })), - })); - - let mut provider = JSXElement { - span: DUMMY_SP, - opening, - children: vec![], - closing: Some(JSXClosingElement { - span: DUMMY_SP, - name: provider_name, - }), - }; - - 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, - }, - ); - provider - .children - .push(JSXElementChild::JSXElement(Box::new(original))); - *node = provider; - } -} impl VisitMut for CodePressTransform { fn visit_mut_module(&mut self, m: &mut Module) { @@ -2587,8 +3079,18 @@ 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) + // When using JS metadata map, we skip per-component provider wrapping entirely. + // HMR is handled by a single root-level provider injected at app entry points. + if !self.skip_provider_wrap && !self.use_js_metadata_map { + self.ensure_provider_inline(m); + } + // When using JS metadata map and auto-inject is enabled, inject refresh provider at app entry points + // This provides HMR support without wrapping every component + // Set autoInjectRefreshProvider: false to disable and manually add CPRefreshProvider + if self.use_js_metadata_map && self.auto_inject_refresh_provider { + self.ensure_refresh_provider_at_entry(m); + } // Inject guarded stamping helper self.ensure_stamp_helper_inline(m); @@ -2736,6 +3238,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); @@ -3292,188 +3796,44 @@ impl VisitMut for CodePressTransform { let symrefs_enc = xor_encode(&symrefs_json); // Always-on behavior for custom component callsites (excluding skip list): + // Skip wrapping for components with props that indicate slot/polymorphic patterns: + // - asChild: Radix UI, Ark UI slot composition + // - forwardedAs: styled-components polymorphic pattern + // These patterns rely on direct parent-child relationships that wrappers would break + let has_slot_prop = Self::has_attr_key(&node.opening.attrs, "asChild") + || Self::has_attr_key(&node.opening.attrs, "forwardedAs"); let is_custom_call = !is_host && Self::is_custom_component_name(&node.opening.name) - && !self.is_skip_component(&node.opening.name); + && !self.is_skip_component(&node.opening.name) + && !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); - - 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, - }, - ); - - // 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 clones its immediate child chart). - if !block_provider { - 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 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()); + // 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 { - // Host element → tag directly - 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(), - ); - 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, - })); - } - } - } - } -} - -// ----------------------------------------------------------------------------- -// Pass 2: hoist wrapper attrs to child & remove wrapper -// ----------------------------------------------------------------------------- - -struct HoistAndElide { - wrapper_tag: String, - keys: Vec, -} - -impl HoistAndElide { - fn is_wrapper(&self, name: &JSXElementName) -> bool { - match name { - JSXElementName::Ident(id) => id.sym.as_ref() == self.wrapper_tag, - _ => false, - } - } - fn has_attr(attrs: &[JSXAttrOrSpread], key: &str) -> bool { - attrs.iter().any(|a| { - if let JSXAttrOrSpread::JSXAttr(attr) = a { - if let JSXAttrName::Ident(id) = &attr.name { - return id.sym.as_ref() == key; - } - } - false - }) - } - fn get_attr_string(attrs: &[JSXAttrOrSpread], key: &str) -> Option { - for a in attrs { - if let JSXAttrOrSpread::JSXAttr(attr) = a { - if let JSXAttrName::Ident(id) = &attr.name { - if id.sym.as_ref() == key { - return jsx_attr_value_to_string(&attr.value); - } - } - } - } - None - } - fn push_attr(attrs: &mut Vec, key: &str, val: String) { - attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { - span: DUMMY_SP, - name: JSXAttrName::Ident(cp_ident_name(key.into())), - value: Some(make_jsx_str_attr_value(val)), - })); - } -} - -impl VisitMut for HoistAndElide { - fn visit_mut_jsx_element(&mut self, node: &mut JSXElement) { - // Recurse first - node.visit_mut_children_with(self); - - // Only wrappers with exactly one JSXElement child - if !self.is_wrapper(&node.opening.name) || node.children.len() != 1 { - return; - } - - let child_el = match node.children.remove(0) { - JSXElementChild::JSXElement(boxed) => *boxed, - other => { - node.children.push(other); - return; - } + encoded_path.clone() }; - let mut child = child_el; - // Hoist keys if missing on child - for key in &self.keys { - if !Self::has_attr(&child.opening.attrs, key) { - if let Some(val) = Self::get_attr_string(&node.opening.attrs, key) { - Self::push_attr(&mut child.opening.attrs, key, val); - } - } - } + // 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) + ); - // Replace wrapper with child - *node = child; + // Store metadata in window.__CODEPRESS_MAP__ instead of DOM attributes + // Only codepress-data-fp attribute is on DOM (already added above) + // Extension reads from JS map, HMR handled by top-level CPRefreshProvider + self.metadata_map.insert(fp_value.clone(), MetadataEntry { + callsite: callsite_value.clone(), + edit_candidates: cands_enc.clone(), + source_kinds: kinds_enc.clone(), + symbol_refs: symrefs_enc.clone(), + }); } } @@ -3503,32 +3863,170 @@ pub fn process_transform( // Pass 1: main transform program.visit_mut_with(&mut transform); - // Pass 2: always hoist & elide (remove wrappers, keep data on child callsite) - let mut elider = HoistAndElide { - wrapper_tag: transform.wrapper_tag.clone(), - keys: vec![ - "data-codepress-edit-candidates".to_string(), - "data-codepress-source-kinds".to_string(), - "data-codepress-callsite".to_string(), - "data-codepress-symbol-refs".to_string(), - ], - }; - program.visit_mut_with(&mut elider); + // Pass 2: Wrap JSX returns in default exports at entry points with __CPRefreshProvider + // This provides automatic HMR support + if transform.inserted_refresh_provider { + let mut wrapper = RefreshProviderWrapper; + program.visit_mut_with(&mut wrapper); + } program } // ----------------------------------------------------------------------------- -// (Optional) tests could go here +// Pass 3: Wrap default export JSX with __CPRefreshProvider at entry points // ----------------------------------------------------------------------------- -// Payload carried by the non-DOM provider -struct ProviderMeta { - cs: String, - c: String, - k: String, - fp: String, +struct RefreshProviderWrapper; + +impl RefreshProviderWrapper { + /// Wrap a JSX element with <__CPRefreshProvider>... + fn wrap_with_refresh_provider(jsx: JSXElement) -> JSXElement { + let provider_name = JSXElementName::Ident(cp_ident("__CPRefreshProvider".into()).into()); + JSXElement { + span: DUMMY_SP, + opening: JSXOpeningElement { + name: provider_name.clone(), + attrs: vec![], + self_closing: false, + type_args: None, + span: DUMMY_SP, + }, + children: vec![JSXElementChild::JSXElement(Box::new(jsx))], + closing: Some(JSXClosingElement { + span: DUMMY_SP, + name: provider_name, + }), + } + } + + /// Check if this JSX element is already wrapped with __CPRefreshProvider + fn is_already_wrapped(jsx: &JSXElement) -> bool { + match &jsx.opening.name { + JSXElementName::Ident(id) => id.sym.as_ref() == "__CPRefreshProvider", + _ => false, + } + } +} + +impl VisitMut for RefreshProviderWrapper { + fn visit_mut_module(&mut self, m: &mut Module) { + // Find the default export and wrap its JSX return + for item in &mut m.body { + match item { + // export default function X() { ... } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(ExportDefaultDecl { + decl: DefaultDecl::Fn(fn_expr), + .. + })) => { + if let Some(body) = &mut fn_expr.function.body { + self.wrap_jsx_returns(body); + } + } + // export default () => ... + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(ExportDefaultExpr { + expr, + .. + })) => { + match &mut **expr { + Expr::Arrow(arrow) => { + match &mut *arrow.body { + BlockStmtOrExpr::BlockStmt(body) => { + self.wrap_jsx_returns(body); + } + BlockStmtOrExpr::Expr(expr) => { + // Arrow with implicit return: () => + if let Expr::JSXElement(jsx) = &**expr { + if !Self::is_already_wrapped(jsx) { + let wrapped = Self::wrap_with_refresh_provider(*jsx.clone()); + **expr = Expr::JSXElement(Box::new(wrapped)); + } + } + if let Expr::Paren(paren) = &**expr { + if let Expr::JSXElement(jsx) = &*paren.expr { + if !Self::is_already_wrapped(jsx) { + let wrapped = Self::wrap_with_refresh_provider(*jsx.clone()); + **expr = Expr::Paren(ParenExpr { + span: paren.span, + expr: Box::new(Expr::JSXElement(Box::new(wrapped))), + }); + } + } + } + } + } + } + Expr::Fn(fn_expr) => { + if let Some(body) = &mut fn_expr.function.body { + self.wrap_jsx_returns(body); + } + } + _ => {} + } + } + _ => {} + } + } + } +} + +impl RefreshProviderWrapper { + /// Find return statements with JSX and wrap them + fn wrap_jsx_returns(&mut self, body: &mut BlockStmt) { + for stmt in &mut body.stmts { + self.visit_mut_stmt(stmt); + } + } + + fn visit_mut_stmt(&mut self, stmt: &mut Stmt) { + match stmt { + Stmt::Return(ret) => { + if let Some(arg) = &mut ret.arg { + self.wrap_jsx_expr(arg); + } + } + Stmt::If(if_stmt) => { + self.visit_mut_stmt(&mut if_stmt.cons); + if let Some(alt) = &mut if_stmt.alt { + self.visit_mut_stmt(alt); + } + } + Stmt::Block(block) => { + for s in &mut block.stmts { + self.visit_mut_stmt(s); + } + } + _ => {} + } + } + + fn wrap_jsx_expr(&mut self, expr: &mut Box) { + match &mut **expr { + Expr::JSXElement(jsx) => { + if !Self::is_already_wrapped(jsx) { + let wrapped = Self::wrap_with_refresh_provider(*jsx.clone()); + **expr = Expr::JSXElement(Box::new(wrapped)); + } + } + Expr::Paren(paren) => { + if let Expr::JSXElement(jsx) = &*paren.expr { + if !Self::is_already_wrapped(jsx) { + let wrapped = Self::wrap_with_refresh_provider(*jsx.clone()); + paren.expr = Box::new(Expr::JSXElement(Box::new(wrapped))); + } + } + } + Expr::Cond(cond) => { + self.wrap_jsx_expr(&mut cond.cons); + self.wrap_jsx_expr(&mut cond.alt); + } + _ => {} + } + } } + +// ----------------------------------------------------------------------------- +// (Optional) tests could go here // ----------------------------------------------------------------------------- // Extra types/helpers for symbol-refs & literal index // ----------------------------------------------------------------------------- diff --git a/package.json b/package.json index 7239ad7..175172a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codepress/codepress-engine", - "version": "0.7.11", + "version": "0.8.0", "packageManager": "pnpm@10.22.0", "description": "CodePress engine - Babel and SWC plug-ins", "main": "./dist/index.js", @@ -41,6 +41,11 @@ "require": "./dist/webpack-plugin.js", "default": "./dist/webpack-plugin.js" }, + "./refresh-provider": { + "types": "./dist/CPRefreshProvider.d.ts", + "require": "./dist/CPRefreshProvider.js", + "default": "./dist/CPRefreshProvider.js" + }, "./swc/wasm": "./swc/codepress_engine.v42.wasm", "./swc/wasm-v42": "./swc/codepress_engine.v42.wasm", "./swc/wasm-v26": "./swc/codepress_engine.v26.wasm", @@ -89,7 +94,16 @@ "access": "public" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0", + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "react": { + "optional": true + } }, "devDependencies": { "@babel/cli": "^7.26.0", @@ -105,6 +119,7 @@ "@types/babel__core": "^7.20.5", "@types/jest": "^30.0.0", "@types/node": "^22.9.0", + "@types/react": "^19.0.0", "@types/node-fetch": "^2.6.11", "@types/webpack": "^5.28.5", "babel-jest": "^30.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b26744..4017f23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: prettier: specifier: ^3.1.0 version: 3.6.2 + react: + specifier: ^18.0.0 || ^19.0.0 + version: 19.2.1 tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -69,6 +72,9 @@ importers: '@types/node-fetch': specifier: ^2.6.11 version: 2.6.13 + '@types/react': + specifier: ^19.0.0 + version: 19.2.7 '@types/webpack': specifier: ^5.28.5 version: 5.28.5(@swc/core@1.15.3)(esbuild@0.24.2) @@ -1309,6 +1315,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1840,6 +1849,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3063,6 +3075,10 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -5076,6 +5092,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + '@types/stack-utils@2.0.3': {} '@types/webpack@5.28.5(@swc/core@1.15.3)(esbuild@0.24.2)': @@ -5675,6 +5695,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7180,6 +7202,8 @@ snapshots: react-is@18.3.1: {} + react@19.2.1: {} + readdirp@3.6.0: dependencies: picomatch: 2.3.1 diff --git a/scripts/build-swc.mjs b/scripts/build-swc.mjs index 71ab414..6eb83e5 100644 --- a/scripts/build-swc.mjs +++ b/scripts/build-swc.mjs @@ -40,8 +40,8 @@ const BANDS = [ id: "v42", swc_core: "=42.0.3", extra: { - serde: "^1.0.225", - serde_json: "^1.0.140", + serde: "=1.0.225", + serde_json: "=1.0.140", }, }, @@ -50,8 +50,8 @@ const BANDS = [ id: "v48", swc_core: "=48.0.2", extra: { - serde: "^1.0.225", - serde_json: "^1.0.140", + serde: "=1.0.225", + serde_json: "=1.0.140", compat_feature: "compat_v48", swc_atoms: "=9.0.0", }, diff --git a/src/CPRefreshProvider.tsx b/src/CPRefreshProvider.tsx new file mode 100644 index 0000000..0d0c27c --- /dev/null +++ b/src/CPRefreshProvider.tsx @@ -0,0 +1,91 @@ +/** + * CPRefreshProvider - Root-level refresh provider for CodePress HMR + * + * NOTE: By default, you don't need to manually add this provider. + * The CodePress SWC/Babel plugin automatically injects it at app entry points: + * - Next.js Pages Router: pages/_app.tsx + * - Next.js App Router: app/layout.tsx + * - Vite/CRA: src/main.tsx or src/index.tsx + * + * When `window.__CP_triggerRefresh()` is called, all components under this provider will re-render. + * + * To disable auto-injection, set `autoInjectRefreshProvider: false` in the plugin config: + * ```js + * // next.config.js + * experimental: { + * swcPlugins: [['@codepress/codepress-engine/swc', { autoInjectRefreshProvider: false }]] + * } + * ``` + * + * Then manually add the provider: + * ```tsx + * import { CPRefreshProvider } from '@codepress/codepress-engine/refresh-provider'; + * + * export default function App({ children }) { + * return {children}; + * } + * ``` + */ + +import React, { createContext, useSyncExternalStore, ReactNode } from 'react'; + +// Module-level version counter that persists across renders +let __cpVersion = 0; + +// Context for components that want to subscribe to refresh events +export const CPRefreshContext = createContext(0); + +interface CPRefreshProviderProps { + children: ReactNode; +} + +/** + * Root-level provider that triggers re-renders when CP_PREVIEW_REFRESH event fires. + * Only one instance of this provider is needed at the app root. + */ +export function CPRefreshProvider({ children }: CPRefreshProviderProps) { + const version = useSyncExternalStore( + (callback: () => void) => { + if (typeof window === 'undefined') { + return () => {}; + } + + const handler = () => { + __cpVersion = __cpVersion + 1; + callback(); + }; + + window.addEventListener('CP_PREVIEW_REFRESH', handler); + + // Also expose the trigger function globally + if (!window.__CP_triggerRefresh) { + window.__CP_triggerRefresh = () => { + window.dispatchEvent(new CustomEvent('CP_PREVIEW_REFRESH')); + }; + // Mark that this function dispatches the preview event + (window.__CP_triggerRefresh as any).__cp_dispatches_preview = true; + } + + return () => { + window.removeEventListener('CP_PREVIEW_REFRESH', handler); + }; + }, + () => __cpVersion, + () => 0 // Server snapshot + ); + + return ( + + {children} + + ); +} + +// Extend Window interface for TypeScript +declare global { + interface Window { + __CP_triggerRefresh?: () => void; + } +} + +export default CPRefreshProvider; diff --git a/src/esbuild-plugin.ts b/src/esbuild-plugin.ts index 7d73377..e354a97 100644 --- a/src/esbuild-plugin.ts +++ b/src/esbuild-plugin.ts @@ -1,6 +1,9 @@ /** - * CodePress esbuild plugin - Injects tracking attributes and provider wrappers into JSX - * Replaces the Rust SWC plugin with a pure JavaScript implementation + * CodePress esbuild plugin - Injects tracking attributes into JSX + * + * This plugin adds codepress-data-fp attributes to JSX elements for element identification. + * HMR is handled separately by a single root-level provider (CPRefreshProvider) that users + * add to their app entry point, rather than wrapping every component. */ import * as fs from 'fs'; @@ -81,65 +84,6 @@ function injectJSXAttributes(source: string, encoded: string, repoName?: string, return output.join('\n'); } -/** - * Wrap exported components with __CPProvider - */ -function wrapWithProvider(source: string): string { - // Find default export component - const defaultExportMatch = source.match(/export\s+default\s+function\s+(\w+)/); - if (!defaultExportMatch) { - // Try: export default ComponentName; - const namedMatch = source.match(/export\s+default\s+(\w+);/); - if (!namedMatch) return source; - } - - const componentName = defaultExportMatch?.[1] || source.match(/export\s+default\s+(\w+);/)?.[1]; - if (!componentName) return source; - - // Inject provider wrapper code at the top - const providerCode = ` -import { useSyncExternalStore } from 'react'; - -// Module-level version counter for HMR -let __cpvVersion = 0; - -// Provider component that wraps the default export -function __CPProvider({ value, children }: { value?: any; children: React.ReactNode }) { - const __cpv = useSyncExternalStore( - (cb) => { - const h = () => { - __cpvVersion = __cpvVersion + 1; - cb(); - }; - if (typeof window !== 'undefined') { - window.addEventListener("CP_PREVIEW_REFRESH", h); - return () => { window.removeEventListener("CP_PREVIEW_REFRESH", h); }; - } - return () => {}; - }, - () => __cpvVersion, - () => 0 - ); - - return {children}; -} - -// Context for passing data through provider -const CPX = { Provider: ({ value, children }: any) => children }; -`; - - // Wrap the default export - const wrappedSource = source.replace( - new RegExp(`export\\s+default\\s+${componentName}`), - `const __Original${componentName} = ${componentName}; -export default function ${componentName}(props: any) { - return <__CPProvider><__Original${componentName} {...props} />; -}` - ); - - return providerCode + '\n' + wrappedSource; -} - export function createCodePressPlugin(options: CodePressPluginOptions = {}): Plugin { const { repo_name = '', @@ -166,13 +110,9 @@ export function createCodePressPlugin(options: CodePressPluginOptions = {}): Plu return { contents: source, loader: 'tsx' }; } - // Step 1: Inject JSX attributes - let transformed = injectJSXAttributes(source, encoded, repo_name, branch_name); - - // Step 2: Wrap with provider (for default exports) - if (transformed.includes('export default')) { - transformed = wrapWithProvider(transformed); - } + // Inject JSX attributes (codepress-data-fp) + // HMR is handled by a root-level CPRefreshProvider, not per-component wrapping + const transformed = injectJSXAttributes(source, encoded, repo_name, branch_name); return { contents: transformed, diff --git a/src/types.ts b/src/types.ts index 92ec644..4405db6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,4 +14,48 @@ export interface CodePressPluginOptions { * Used by CodePress HMR to substitute env vars in dynamically built modules. */ env_vars?: Record; + /** + * Store metadata in window.__CODEPRESS_MAP__ instead of DOM attributes. + * When true, only codepress-data-fp attribute is added to DOM. + * This avoids React reconciliation issues and keeps DOM clean. + * Defaults to true. + */ + useJsMetadataMap?: boolean; + /** + * Automatically inject the refresh provider at detected app entry points. + * Defaults to true. + * + * When enabled, the plugin detects and wraps these entry points: + * - Next.js Pages Router: pages/_app.tsx + * - Next.js App Router: app/layout.tsx (root layout) + * - Vite/CRA: src/main.tsx or src/index.tsx + * + * Set to false to disable auto-injection. You'll need to manually add + * the refresh provider: + * + * ```tsx + * import { CPRefreshProvider } from '@codepress/codepress-engine/refresh-provider'; + * + * export default function App({ children }) { + * return {children}; + * } + * ``` + * + * Reasons to disable: + * - Monorepos with library packages that match entry point patterns + * - Custom entry points not detected automatically + * - Full control over where the provider is placed + */ + autoInjectRefreshProvider?: boolean; + /** + * Skip wrapping custom components with __CPProvider. + * When useJsMetadataMap is true (the default), this is automatically set to true. + * @deprecated This is now automatically determined by useJsMetadataMap + */ + skipProviderWrap?: boolean; + /** + * Skip wrapping custom components with . + * Only used when useJsMetadataMap is false. + */ + skipMarkerWrap?: boolean; } diff --git a/test/esbuild-plugin.test.ts b/test/esbuild-plugin.test.ts index 9c976e1..da08aef 100644 --- a/test/esbuild-plugin.test.ts +++ b/test/esbuild-plugin.test.ts @@ -310,7 +310,7 @@ export default function Image() { }); describe('Provider Wrapping', () => { - test('should wrap default export functions with __CPProvider', async () => { + test('should NOT include per-component provider wrapping (HMR handled by root provider)', async () => { const testFile = path.join(tmpDir, 'App.tsx'); const source = ` export default function App() { @@ -337,10 +337,15 @@ export default function App() { const output = result.outputFiles[0].text; - // Should contain provider wrapper code - expect(output).toContain('__CPProvider'); - expect(output).toContain('useSyncExternalStore'); - expect(output).toContain('CP_PREVIEW_REFRESH'); + // Should NOT contain provider wrapper code + // HMR is now handled by CPRefreshProvider at app root + expect(output).not.toContain('__CPProvider'); + expect(output).not.toContain('useSyncExternalStore'); + expect(output).not.toContain('CP_PREVIEW_REFRESH'); + + // Note: The esbuild plugin uses regex-based attribute injection which is + // a simplified version. The main SWC/Babel plugins handle this more robustly. + // The key assertion here is that provider wrapping is removed. }); }); diff --git a/tsconfig.json b/tsconfig.json index 9106c75..1d93d39 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "skipLibCheck": true, "strict": true, "useUnknownInCatchVariables": false, - "lib": ["es2020", "dom"] + "lib": ["es2020", "dom"], + "jsx": "react-jsx" }, "include": ["src/**/*"], "exclude": ["dist", "node_modules"]