From 0b5a0cb0d8a6817b7e5eccaa570a38def7ab0ab7 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:59:39 +0800 Subject: [PATCH 1/3] feat(et): add element template transform --- .changeset/social-nails-retire.md | 3 + Cargo.lock | 59 ++ packages/react/transform/Cargo.toml | 3 +- .../react/transform/__test__/fixture.spec.js | 90 +++ .../swc_plugin_element_template/Cargo.toml | 29 + .../swc_plugin_element_template/asset.rs | 64 ++ .../swc_plugin_element_template/attr_name.rs | 83 +++ .../swc_plugin_element_template/extractor.rs | 487 +++++++++++++ .../crates/swc_plugin_element_template/lib.rs | 434 +++++++++++ .../swc_plugin_element_template/lowering.rs | 170 +++++ .../swc_plugin_element_template/napi.rs | 223 ++++++ .../swc_plugin_element_template/slot.rs | 99 +++ .../template_attribute.rs | 31 + .../template_definition.rs | 373 ++++++++++ .../template_slot.rs | 81 +++ ...ttribute_slots_for_dynamic_attributes.snap | 87 +++ ...dle_background_conditional_attributes.snap | 47 ++ ..._handle_boolean_and_number_attributes.snap | 67 ++ .../should_handle_complex_text_structure.snap | 127 ++++ .../should_handle_dataset_attributes.snap | 67 ++ ..._handle_deeply_nested_user_components.snap | 56 ++ ...hould_handle_dynamic_class_attributes.snap | 61 ++ .../should_handle_events_js.snap | 61 ++ .../should_handle_events_lepus.snap | 61 ++ .../should_handle_id_attributes.snap | 61 ++ .../should_handle_inline_styles.snap | 88 +++ ...le_interpolated_text_with_siblings_js.snap | 88 +++ ...interpolated_text_with_siblings_lepus.snap | 88 +++ .../should_handle_mixed_content.snap | 86 +++ ..._nested_structure_and_dynamic_content.snap | 151 ++++ .../should_handle_refs_js.snap | 55 ++ .../should_handle_refs_lepus.snap | 55 ++ ...should_handle_sibling_user_components.snap | 72 ++ .../should_handle_spread_attributes.snap | 60 ++ .../should_handle_user_component.snap | 56 ++ ..._arrays_with_element_slot_placeholder.snap | 58 ++ ...te_attribute_slots_in_sync_for_spread.snap | 72 ++ ..._output_element_template_simple_lepus.snap | 55 ++ ...utput_template_with_static_attributes.snap | 67 ++ ...uld_verify_template_structure_complex.snap | 74 ++ ...y_text_attribute_and_child_text_slots.snap | 73 ++ .../tests/element_template.rs | 684 ++++++++++++++++++ .../tests/element_template_contract.rs | 365 ++++++++++ packages/react/transform/index.d.ts | 32 +- packages/react/transform/src/lib.rs | 274 +++++-- .../transform/swc-plugin-reactlynx/Cargo.toml | 1 + .../transform/swc-plugin-reactlynx/index.d.ts | 19 + .../transform/swc-plugin-reactlynx/src/lib.rs | 115 ++- 48 files changed, 5543 insertions(+), 69 deletions(-) create mode 100644 .changeset/social-nails-retire.md create mode 100644 packages/react/transform/crates/swc_plugin_element_template/Cargo.toml create mode 100644 packages/react/transform/crates/swc_plugin_element_template/asset.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/attr_name.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/extractor.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/lib.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/lowering.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/napi.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/slot.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/template_attribute.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/template_definition.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/template_slot.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_deeply_nested_user_components.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_element_slot_placeholder.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs diff --git a/.changeset/social-nails-retire.md b/.changeset/social-nails-retire.md new file mode 100644 index 0000000000..853d812bb3 --- /dev/null +++ b/.changeset/social-nails-retire.md @@ -0,0 +1,3 @@ +--- + +--- diff --git a/Cargo.lock b/Cargo.lock index b67c641c44..7d8b26c4af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,17 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -628,6 +639,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" @@ -1091,6 +1108,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + [[package]] name = "is-macro" version = "0.3.5" @@ -1302,6 +1332,8 @@ dependencies = [ "ctor", "napi-sys", "once_cell", + "serde", + "serde_json", "thread_local", ] @@ -1746,6 +1778,7 @@ dependencies = [ "swc_plugin_define_dce", "swc_plugin_directive_dce", "swc_plugin_dynamic_import", + "swc_plugin_element_template", "swc_plugin_inject", "swc_plugin_list", "swc_plugin_shake", @@ -2065,6 +2098,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.10" @@ -2341,6 +2380,7 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_codegen", + "swc_ecma_minifier", "swc_ecma_parser", "swc_ecma_quote_macros", "swc_ecma_transforms_base", @@ -3187,6 +3227,24 @@ dependencies = [ "swc_plugins_shared", ] +[[package]] +name = "swc_plugin_element_template" +version = "0.1.0" +dependencies = [ + "convert_case 0.8.0", + "hex", + "insta", + "napi", + "napi-derive", + "once_cell", + "regex", + "serde", + "serde_json", + "sha-1", + "swc_core", + "swc_plugins_shared", +] + [[package]] name = "swc_plugin_inject" version = "0.1.0" @@ -3247,6 +3305,7 @@ dependencies = [ "swc_plugin_define_dce", "swc_plugin_directive_dce", "swc_plugin_dynamic_import", + "swc_plugin_element_template", "swc_plugin_inject", "swc_plugin_list", "swc_plugin_shake", diff --git a/packages/react/transform/Cargo.toml b/packages/react/transform/Cargo.toml index 7317a0f6d2..055904c42e 100644 --- a/packages/react/transform/Cargo.toml +++ b/packages/react/transform/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] convert_case = { workspace = true } hex = { workspace = true } indexmap = { workspace = true } -napi = { workspace = true } +napi = { workspace = true, features = ["serde-json"] } napi-derive = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } @@ -25,6 +25,7 @@ swc_plugin_css_scope = { path = "./crates/swc_plugin_css_scope", features = ["na swc_plugin_define_dce = { path = "./crates/swc_plugin_define_dce", features = ["napi"] } swc_plugin_directive_dce = { path = "./crates/swc_plugin_directive_dce", features = ["napi"] } swc_plugin_dynamic_import = { path = './crates/swc_plugin_dynamic_import', features = ["napi"] } +swc_plugin_element_template = { path = "./crates/swc_plugin_element_template", features = ["napi"] } swc_plugin_inject = { path = "./crates/swc_plugin_inject", features = ["napi"] } swc_plugin_list = { path = "./crates/swc_plugin_list", features = ["napi"] } swc_plugin_shake = { path = './crates/swc_plugin_shake', features = ["napi"] } diff --git a/packages/react/transform/__test__/fixture.spec.js b/packages/react/transform/__test__/fixture.spec.js index d2deb4c94e..52b08d5b96 100644 --- a/packages/react/transform/__test__/fixture.spec.js +++ b/packages/react/transform/__test__/fixture.spec.js @@ -164,6 +164,96 @@ describe('ui source map', () => { }); }); +describe('element template', () => { + it('should export compiled element templates when enabled', async () => { + const result = await transformReactLynx('const node = ;', { + mode: 'test', + pluginName: '', + filename: 'test.js', + sourcemap: false, + cssScope: false, + elementTemplate: { + preserveJsx: false, + runtimePkg: '@lynx-js/react', + filename: 'test.js', + target: 'LEPUS', + }, + jsx: true, + directiveDCE: false, + defineDCE: false, + shake: false, + compat: true, + worklet: false, + refresh: false, + }); + + expect(Array.isArray(result.elementTemplates)).toBe(true); + const template = result.elementTemplates?.[0]; + expect(template?.templateId).toEqual(expect.any(String)); + expect(template?.templateId.length).toBeGreaterThan(0); + expect(template?.compiledTemplate).toEqual(expect.any(Object)); + expect(Array.isArray(template?.compiledTemplate)).toBe(false); + expect(template?.sourceFile).toEqual(expect.any(String)); + }); + + it('should export template ids for element template uiSourceMapRecords', async () => { + const result = await transformReactLynx('const node = ;', { + mode: 'test', + pluginName: '', + filename: 'test.js', + sourcemap: false, + cssScope: false, + elementTemplate: { + preserveJsx: false, + runtimePkg: '@lynx-js/react', + filename: 'test.js', + target: 'LEPUS', + enableUiSourceMap: true, + }, + jsx: true, + directiveDCE: false, + defineDCE: false, + shake: false, + compat: true, + worklet: false, + refresh: false, + }); + + expect(result.uiSourceMapRecords).toHaveLength(1); + expect(result.uiSourceMapRecords[0]).toMatchObject({ + filename: 'test.js', + templateId: expect.stringMatching(/^_et_/), + }); + expect(result.uiSourceMapRecords[0].snapshotId).toBeUndefined(); + }); + + it('should not bridge legacy snapshot ET flags into the new ET plugin path', async () => { + const result = await transformReactLynx('const node = ;', { + mode: 'test', + pluginName: '', + filename: 'test.js', + sourcemap: false, + cssScope: false, + snapshot: { + preserveJsx: false, + runtimePkg: '@lynx-js/react', + filename: 'test.js', + target: 'LEPUS', + enableElementTemplate: true, + }, + jsx: true, + directiveDCE: false, + defineDCE: false, + shake: false, + compat: true, + worklet: false, + refresh: false, + }); + + expect(result.elementTemplates).toBeUndefined(); + }); +}); + describe('jsx', () => { it('should allow JSXNamespace', async () => { const result = await transformReactLynx('const jsx = ', { diff --git a/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml b/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml new file mode 100644 index 0000000000..12500f66c0 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "swc_plugin_element_template" +version = "0.1.0" +edition = "2021" + +[lib] +path = "lib.rs" + +[features] +napi = ["dep:napi", "dep:napi-derive", "swc_plugins_shared/napi"] + +[dependencies] +convert_case = { workspace = true } +hex = { workspace = true } +napi = { workspace = true, optional = true, features = ["serde-json"] } +napi-derive = { workspace = true, optional = true } +once_cell = { workspace = true } +regex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["preserve_order"] } +sha-1 = { workspace = true } +swc_core = { workspace = true, features = ["base", "ecma_codegen", "ecma_parser", "ecma_minifier", "ecma_transforms_typescript", "ecma_utils", "ecma_quote", "ecma_transforms_react", "ecma_transforms_optimization", "__visit", "__testing_transform"] } +swc_plugins_shared = { path = "../swc_plugins_shared" } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(swc_ast_unknown)'] } + +[dev-dependencies] +insta = { version = "1.34", features = ["json"] } diff --git a/packages/react/transform/crates/swc_plugin_element_template/asset.rs b/packages/react/transform/crates/swc_plugin_element_template/asset.rs new file mode 100644 index 0000000000..16c62fcf8a --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/asset.rs @@ -0,0 +1,64 @@ +use serde::Serialize; +use swc_core::common::comments::Comments; + +use super::JSXTransformer; + +#[derive(Serialize, Debug, Clone)] +pub struct ElementTemplateAsset { + // The compiled template is exported out-of-band from the JS module. The JS + // output keeps using a synthetic component tag so existing React/SWC passes can + // continue to own expression lowering and runtime value transport. + pub template_id: String, + pub compiled_template: serde_json::Value, + pub source_file: String, +} + +const BUILTIN_RAW_TEXT_TEMPLATE_ID: &str = "__et_builtin_raw_text__"; + +impl JSXTransformer +where + C: Comments + Clone, +{ + fn builtin_raw_text_template_asset(&self) -> ElementTemplateAsset { + ElementTemplateAsset { + template_id: BUILTIN_RAW_TEXT_TEMPLATE_ID.to_string(), + compiled_template: serde_json::json!({ + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0, + } + ], + "children": [], + }), + source_file: self.cfg.filename.clone(), + } + } + + pub(super) fn ensure_builtin_element_templates(&self) { + let Some(element_templates) = &self.element_templates else { + return; + }; + + let mut templates = element_templates.borrow_mut(); + if templates.is_empty() { + return; + } + + // Raw text can also appear as dynamic element-slot content. Emitting the + // builtin template only when user ET templates exist keeps non-ET transforms + // free of template metadata while giving runtime a stable key for text slots. + if templates + .iter() + .any(|template| template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID) + { + return; + } + + templates.push(self.builtin_raw_text_template_asset()); + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs new file mode 100644 index 0000000000..da40b3c053 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs @@ -0,0 +1,83 @@ +use regex::Regex; + +use swc_core::ecma::ast::*; + +#[derive(Debug, Clone)] +pub enum AttrName { + Attr, + Dataset, + Event, + WorkletEvent, + Style, + Class, + ID, + Ref, + TimingFlag, + WorkletRef, + Gesture, +} + +impl From for AttrName { + fn from(name: String) -> Self { + if name.strip_prefix("data-").is_some() { + AttrName::Dataset + } else if name == "class" || name == "className" { + AttrName::Class + } else if name == "style" { + AttrName::Style + } else if name == "id" { + AttrName::ID + } else if name == "ref" { + AttrName::Ref + } else if name == "__lynx_timing_flag" { + AttrName::TimingFlag + } else if get_event_type_and_name(name.as_str()).is_some() { + AttrName::Event + } else { + AttrName::Attr + } + } +} + +impl From for AttrName { + fn from(name: Str) -> Self { + let name = name.value.to_string_lossy().into_owned(); + Self::from(name) + } +} + +impl From for AttrName { + fn from(name: Ident) -> Self { + let name = name.sym.as_ref().to_string(); + Self::from(name) + } +} + +impl AttrName { + pub fn from_ns(_ns: Ident, name: Ident) -> Self { + let name_str = name.sym.as_ref().to_string(); + if name_str == "ref" { + AttrName::WorkletRef + } else if get_event_type_and_name(name_str.as_str()).is_some() { + AttrName::WorkletEvent + } else if name_str == "gesture" { + AttrName::Gesture + } else { + todo!() + } + } +} + +fn get_event_type_and_name(props_key: &str) -> Option<(String, String)> { + let re = Regex::new(r"^(global-bind|bind|catch|capture-bind|capture-catch)([A-Za-z]+)$").unwrap(); + if let Some(captures) = re.captures(props_key) { + let event_type = if captures.get(1).unwrap().as_str().contains("capture") { + captures.get(1).unwrap().as_str().to_string() + } else { + format!("{}Event", captures.get(1).unwrap().as_str()) + }; + let event_name = captures.get(2).unwrap().as_str().to_string(); + return Some((event_type, event_name)); + } + None +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/extractor.rs b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs new file mode 100644 index 0000000000..62353dc80a --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs @@ -0,0 +1,487 @@ +use once_cell::sync::Lazy; +use std::collections::HashSet; + +use swc_core::{ + common::{util::take::Take, Span, DUMMY_SP}, + ecma::{ + ast::{JSXExpr, *}, + utils::is_literal, + visit::{VisitMut, VisitMutWith}, + }, + quote_expr, +}; +use swc_plugins_shared::{ + css::get_string_inline_style_from_literal, + jsx_helpers::{ + jsx_attr_name, jsx_attr_value, jsx_children_to_expr, jsx_has_dynamic_key, + jsx_is_children_full_dynamic, jsx_is_custom, jsx_is_list, jsx_text_to_str, + }, +}; + +use super::attr_name::AttrName; +use super::template_attribute::{ + template_attribute_key, template_namespaced_attribute_descriptor_key, TemplateAttributeSlot, +}; +use super::template_slot::{is_slot_placeholder, slot_placeholder_node}; + +static NO_FLATTEN_ATTRIBUTES: Lazy> = Lazy::new(|| { + HashSet::from([ + "name".to_string(), + "clip-radius".to_string(), + "overlap".to_string(), + "exposure-scene".to_string(), + "exposure-id".to_string(), + ]) +}); + +#[derive(Debug)] +pub(super) enum DynamicAttributePart { + Attr { + value: Expr, + attr_name: AttrName, + slot_index: i32, + }, + Spread { + value: Expr, + slot_index: i32, + }, +} + +#[derive(Debug)] +pub(super) enum DynamicElementPart { + Slot(Expr, i32), + ListSlot(Expr, i32), +} + +pub(super) struct ExtractedTemplateParts { + pub key: Option, + pub dynamic_attrs: Vec, + pub dynamic_attr_slots: Vec, + pub dynamic_children: Vec, +} + +fn bool_jsx_attr(value: bool) -> JSXAttrValue { + JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(Expr::Lit(Lit::Bool(Bool { + span: DUMMY_SP, + value, + })))), + }) +} + +pub(super) struct ElementTemplateExtractor<'a, V, F> +where + V: VisitMut, + F: Fn(Span) -> Expr, +{ + parent_element: bool, + dynamic_attrs: Vec, + dynamic_attr_slots: Vec, + dynamic_children: Vec, + dynamic_part_visitor: &'a mut V, + pub(super) key: Option, + attr_slot_counter: i32, + element_slot_counter: i32, + enable_ui_source_map: bool, + node_index_fn: F, +} + +impl<'a, V, F> ElementTemplateExtractor<'a, V, F> +where + V: VisitMut, + F: Fn(Span) -> Expr, +{ + pub(super) fn new( + dynamic_part_visitor: &'a mut V, + enable_ui_source_map: bool, + node_index_fn: F, + ) -> Self { + Self { + parent_element: false, + dynamic_attrs: vec![], + dynamic_attr_slots: vec![], + dynamic_children: vec![], + dynamic_part_visitor, + key: None, + attr_slot_counter: 0, + element_slot_counter: 0, + enable_ui_source_map, + node_index_fn, + } + } + + fn record_node_index(&self, span: Span) { + if self.enable_ui_source_map { + let _ = (self.node_index_fn)(span); + } + } + + fn next_attr_slot_index(&mut self) -> i32 { + let idx = self.attr_slot_counter; + self.attr_slot_counter += 1; + idx + } + + fn next_children_slot_index(&mut self) -> i32 { + let idx = self.element_slot_counter; + self.element_slot_counter += 1; + idx + } + + fn push_dynamic_attr(&mut self, value: Expr, attr_name: AttrName, key: &str) { + let slot_index = self.next_attr_slot_index(); + self.dynamic_attr_slots.push(TemplateAttributeSlot::Attr { + key: key.to_string(), + slot_index, + }); + self.dynamic_attrs.push(DynamicAttributePart::Attr { + value, + attr_name, + slot_index, + }); + } + + fn push_dynamic_spread(&mut self, value: Expr) { + let slot_index = self.next_attr_slot_index(); + self + .dynamic_attr_slots + .push(TemplateAttributeSlot::Spread { slot_index }); + self + .dynamic_attrs + .push(DynamicAttributePart::Spread { value, slot_index }); + } + + fn normalize_inline_styles_if_static(&self, value: &mut Option) { + let mut static_style_val = None; + if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + span, + .. + })) = value + { + let expr = &**expr; + if is_literal(expr) { + if let Some(s) = get_string_inline_style_from_literal(expr, span) { + static_style_val = Some((s, *span)); + } + } + } + + if let Some((s_val, span)) = static_style_val { + *value = Some(JSXAttrValue::Str(Str { + span, + value: s_val.into(), + raw: None, + })); + } + } + + fn push_dynamic_jsx_attr( + &mut self, + attr_name: AttrName, + key: &str, + value: &mut Option, + preserve_literal_expr: bool, + ) { + match &attr_name { + AttrName::Attr | AttrName::Dataset | AttrName::Class | AttrName::ID => { + if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) = value + { + if !(preserve_literal_expr && matches!(&**expr, Expr::Lit(_))) { + self.push_dynamic_attr(*expr.clone(), attr_name, key); + } + } + } + AttrName::Style => { + self.normalize_inline_styles_if_static(value); + if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) = value + { + self.push_dynamic_attr(*expr.clone(), attr_name, key); + } + } + AttrName::Event | AttrName::Ref => { + self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name, key); + } + AttrName::TimingFlag => { + self.push_dynamic_attr( + *quote_expr!("{__ltf: $flag}", flag: Expr = *jsx_attr_value((*value).clone())), + attr_name, + key, + ); + } + AttrName::WorkletEvent | AttrName::WorkletRef | AttrName::Gesture => { + self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name, key); + } + } + } + + fn ensure_flatten_attr(&self, n: &mut JSXElement) { + let mut flatten = None; + for attr in &n.opening.attrs { + if let JSXAttrOrSpread::JSXAttr(attr) = attr { + let name = jsx_attr_name(&attr.name.clone()).to_string(); + if NO_FLATTEN_ATTRIBUTES.contains(&name) { + flatten = Some(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("flatten".into(), DUMMY_SP)), + value: Some(bool_jsx_attr(false)), + })); + break; + } + } + } + + if let Some(flatten) = flatten { + let mut has_origin_flatten = false; + for attr in &mut n.opening.attrs { + if let JSXAttrOrSpread::JSXAttr(attr) = attr { + let name = jsx_attr_name(&attr.name.clone()).to_string(); + if name == *"flatten" { + attr.value = Some(bool_jsx_attr(false)); + has_origin_flatten = true; + } + } + } + if !has_origin_flatten { + n.opening.attrs.push(flatten); + } + } + } + + fn push_dynamic_jsx_attr_by_name( + &mut self, + name: &JSXAttrName, + value: &mut Option, + ) { + match name { + JSXAttrName::Ident(ident_name) => { + let key = template_attribute_key(ident_name.sym.as_ref()).to_string(); + let attr_name = AttrName::from(>::into(ident_name.clone())); + self.push_dynamic_jsx_attr(attr_name, &key, value, true); + } + JSXAttrName::JSXNamespacedName(JSXNamespacedName { ns, name, .. }) => { + let key = template_namespaced_attribute_descriptor_key(ns, name); + let attr_name = AttrName::from_ns(ns.clone().into(), name.clone().into()); + match attr_name { + AttrName::WorkletEvent | AttrName::WorkletRef | AttrName::Gesture => { + self.push_dynamic_jsx_attr(attr_name, &key, value, false); + } + _ => todo!(), + } + } + } + } + + fn push_dynamic_jsx_attr_or_spread(&mut self, attr_or_spread: &mut JSXAttrOrSpread) { + match attr_or_spread { + JSXAttrOrSpread::SpreadElement(spread) => { + self.push_dynamic_spread(*spread.expr.clone()); + } + JSXAttrOrSpread::JSXAttr(JSXAttr { name, value, .. }) => { + let name = name.clone(); + self.push_dynamic_jsx_attr_by_name(&name, value); + } + } + } + + pub(super) fn into_extracted_template_parts(self) -> ExtractedTemplateParts { + ExtractedTemplateParts { + key: self.key, + dynamic_attrs: self.dynamic_attrs, + dynamic_attr_slots: self.dynamic_attr_slots, + dynamic_children: self.dynamic_children, + } + } +} + +impl VisitMut for ElementTemplateExtractor<'_, V, F> +where + V: VisitMut, + F: Fn(Span) -> Expr, +{ + fn visit_mut_jsx_element_childs(&mut self, n: &mut Vec) { + if n.is_empty() { + return; + } + + let mut merged_children: Vec = vec![]; + let mut current_chunk: Vec = vec![]; + + for mut child in n.take() { + let should_merge = match child { + JSXElementChild::JSXText(ref text) => { + if jsx_text_to_str(&text.value).is_empty() { + current_chunk.is_empty() + } else { + true + } + } + JSXElementChild::JSXElement(ref element) => !jsx_is_custom(element), + JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(_), + .. + }) => false, + JSXElementChild::JSXFragment(_) + | JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + }) => true, + JSXElementChild::JSXSpreadChild(_) => { + unreachable!("JSXSpreadChild is not supported yet"); + } + }; + + if should_merge { + if !current_chunk.is_empty() { + current_chunk.visit_mut_with(self.dynamic_part_visitor); + let slot_index = self.next_children_slot_index(); + self.dynamic_children.push(DynamicElementPart::Slot( + jsx_children_to_expr(current_chunk.take()), + slot_index, + )); + + let mut child = + JSXElementChild::JSXElement(Box::new(slot_placeholder_node(slot_index, false))); + child.visit_mut_with(self); + merged_children.push(child); + } + + child.visit_mut_with(self); + merged_children.push(child); + } else { + current_chunk.push(child); + } + } + + if !current_chunk.is_empty() { + current_chunk.visit_mut_with(self.dynamic_part_visitor); + let slot_index = self.next_children_slot_index(); + self.dynamic_children.push(DynamicElementPart::Slot( + jsx_children_to_expr(current_chunk.take()), + slot_index, + )); + + let mut child = + JSXElementChild::JSXElement(Box::new(slot_placeholder_node(slot_index, false))); + child.visit_mut_with(self); + merged_children.push(child); + } + + *n = merged_children; + } + + fn visit_mut_jsx_element(&mut self, n: &mut JSXElement) { + if is_slot_placeholder(n) { + return; + } + + if self.parent_element && jsx_is_list(n) { + let slot_index = self.next_children_slot_index(); + n.visit_mut_with(self.dynamic_part_visitor); + self.dynamic_children.push(DynamicElementPart::ListSlot( + Expr::JSXElement(Box::new(n.take())), + slot_index, + )); + + *n = slot_placeholder_node(slot_index, true); + n.visit_mut_with(self); + return; + } + + if !jsx_is_custom(n) { + self.record_node_index(n.span); + + if jsx_has_dynamic_key(n) && self.parent_element { + let is_list = jsx_is_list(n); + n.visit_mut_with(self.dynamic_part_visitor); + let expr = Expr::JSXElement(Box::new(n.take())); + let slot_index = self.next_children_slot_index(); + + if is_list { + self + .dynamic_children + .push(DynamicElementPart::ListSlot(expr, slot_index)); + } else { + self + .dynamic_children + .push(DynamicElementPart::Slot(expr, slot_index)); + } + + *n = slot_placeholder_node(slot_index, false); + } + + self.ensure_flatten_attr(n); + + n.opening + .attrs + .retain_mut(|attr_or_spread| match attr_or_spread { + JSXAttrOrSpread::SpreadElement(_) => true, + JSXAttrOrSpread::JSXAttr(JSXAttr { name, value, .. }) => match name { + JSXAttrName::Ident(ident_name) => match ident_name.sym.as_ref() { + "key" => { + if !self.parent_element { + self.key = value.take(); + } + false + } + _ => true, + }, + JSXAttrName::JSXNamespacedName(_) => true, + }, + }); + + for attr_or_spread in &mut n.opening.attrs { + self.push_dynamic_jsx_attr_or_spread(attr_or_spread); + } + + if !jsx_is_children_full_dynamic(n) { + let previous_parent_element = self.parent_element; + self.parent_element = true; + n.visit_mut_children_with(self); + self.parent_element = previous_parent_element; + } else { + n.visit_mut_children_with(self.dynamic_part_visitor); + let children_expr = jsx_children_to_expr(n.children.take()); + let slot_index = self.next_children_slot_index(); + if jsx_is_list(n) { + self + .dynamic_children + .push(DynamicElementPart::ListSlot(children_expr, slot_index)); + } else { + self + .dynamic_children + .push(DynamicElementPart::Slot(children_expr, slot_index)); + } + n.children = vec![JSXElementChild::JSXElement(Box::new( + slot_placeholder_node(slot_index, true), + ))]; + } + } else { + n.visit_mut_children_with(self.dynamic_part_visitor); + + if self.parent_element { + let slot_index = self.next_children_slot_index(); + self.dynamic_children.push(DynamicElementPart::Slot( + Expr::JSXElement(Box::new(n.take())), + slot_index, + )); + + *n = slot_placeholder_node(slot_index, true); + n.visit_mut_with(self); + } + } + } + + fn visit_mut_jsx_text(&mut self, n: &mut JSXText) { + if !jsx_text_to_str(&n.value).is_empty() { + self.record_node_index(n.span); + } + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/lib.rs b/packages/react/transform/crates/swc_plugin_element_template/lib.rs new file mode 100644 index 0000000000..5d7e03825e --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/lib.rs @@ -0,0 +1,434 @@ +use once_cell::sync::Lazy; +use serde::Deserialize; +use std::{cell::RefCell, rc::Rc}; +use swc_core::{ + common::{ + comments::{CommentKind, Comments}, + errors::HANDLER, + sync::Lrc, + util::take::Take, + Mark, SourceMap, Span, Spanned, SyntaxContext, DUMMY_SP, + }, + ecma::{ + ast::*, + utils::{prepend_stmt, private_ident}, + visit::{VisitMut, VisitMutWith}, + }, + quote, +}; + +mod asset; +mod attr_name; +mod extractor; +mod lowering; +mod slot; +mod template_attribute; +mod template_definition; +mod template_slot; + +pub use self::asset::ElementTemplateAsset; +use self::extractor::{ElementTemplateExtractor, ExtractedTemplateParts}; +use self::lowering::LoweredRuntimeJsx; +use self::template_slot::ET_SLOT_PLACEHOLDER_TAG; + +#[derive(Clone, Debug, Deserialize)] +pub struct UISourceMapRecord { + pub ui_source_map: i32, + pub line_number: u32, + pub column_number: u32, + pub template_id: String, +} + +pub type ElementTemplateTransformerConfig = JSXTransformerConfig; +pub type ElementTemplateTransformer = JSXTransformer; +pub type ElementTemplateUISourceMapRecord = UISourceMapRecord; + +#[cfg(feature = "napi")] +pub mod napi; + +use swc_plugins_shared::{ + jsx_helpers::jsx_name, + target::TransformTarget, + transform_mode::TransformMode, + utils::{calc_hash, calc_hash_number}, +}; + +pub fn i32_to_expr(i: &i32) -> Expr { + Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: *i as f64, + raw: None, + })) +} + +/// @internal +#[derive(Deserialize, PartialEq, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct JSXTransformerConfig { + /// @internal + pub preserve_jsx: bool, + /// @internal + pub runtime_pkg: String, + /// @internal + pub jsx_import_source: Option, + /// @internal + pub filename: String, + /// @internal + pub target: TransformTarget, + /// @internal + #[serde(default)] + pub enable_ui_source_map: bool, + /// @internal + pub is_dynamic_component: Option, +} + +impl Default for JSXTransformerConfig { + fn default() -> Self { + Self { + preserve_jsx: false, + runtime_pkg: "@lynx-js/react".into(), + jsx_import_source: Some("@lynx-js/react".into()), + filename: Default::default(), + target: TransformTarget::LEPUS, + enable_ui_source_map: false, + is_dynamic_component: Some(false), + } + } +} + +pub struct JSXTransformer +where + C: Comments + Clone, +{ + // react_transformer: Box, + cfg: JSXTransformerConfig, + filename_hash: String, + pub content_hash: String, + runtime_id: Lazy, + pub element_templates: Option>>>, + template_counter: u32, + current_template_defs: Vec, + comments: Option, + slot_ident: Ident, + used_slot: bool, + pub ui_source_map_records: Rc>>, + pub source_map: Option>, +} + +impl JSXTransformer +where + C: Comments + Clone, +{ + pub fn with_content_hash(mut self, content_hash: String) -> Self { + self.content_hash = content_hash; + self + } + + pub fn new( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + ) -> Self { + Self::new_with_element_templates(cfg, comments, mode, source_map, None) + } + + pub fn new_with_element_templates( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + element_templates: Option>>>, + ) -> Self { + JSXTransformer { + filename_hash: calc_hash(&cfg.filename.clone()), + content_hash: "test".into(), + runtime_id: match mode { + TransformMode::Development => { + // We should find a way to use `cfg.runtime_pkg` + Lazy::new(|| quote!("require('@lynx-js/react/internal')" as Expr)) + } + TransformMode::Production | TransformMode::Test => { + Lazy::new(|| Expr::Ident(private_ident!("ReactLynx"))) + } + }, + element_templates, + cfg, + template_counter: 0, + current_template_defs: vec![], + comments, + slot_ident: private_ident!("__etSlot"), + used_slot: false, + ui_source_map_records: Rc::new(RefCell::new(vec![])), + source_map, + } + } + + fn validate_directives(&self, span: Span) { + self.comments.with_leading(span.lo, |comments| { + for cmt in comments { + if cmt.kind != CommentKind::Block { + continue; + } + for line in cmt.text.lines() { + let mut line = line.trim(); + if line.starts_with('*') { + line = line[1..].trim(); + } + + if !line.starts_with("@jsx") { + continue; + } + + let mut words = line.split_whitespace(); + loop { + let pragma = words.next(); + if pragma.is_none() { + break; + } + let val = words.next(); + if let Some("@jsxCSSId") = pragma { + if let Some(css_id) = val { + css_id.parse::().expect("should have numeric cssId"); + } + } + } + } + } + }); + } +} + +impl VisitMut for JSXTransformer +where + C: Comments + Clone, +{ + fn visit_mut_jsx_element(&mut self, node: &mut JSXElement) { + match *jsx_name(node.opening.name.clone()) { + Expr::Lit(lit) => { + if let Lit::Str(s) = &lit { + let tag = s.value.to_string_lossy(); + let tag_str = tag.as_ref(); + if tag_str == ET_SLOT_PLACEHOLDER_TAG { + return node.visit_mut_children_with(self); + } + if tag_str == "page" || tag_str == "component" { + HANDLER.with(|handler| { + handler + .struct_span_err( + node.opening.name.span(), + &format!("<{tag_str} /> is not supported"), + ) + .emit() + }); + } + } + } + _ => { + return node.visit_mut_children_with(self); + } + } + + self.template_counter += 1; + let template_uid = format!( + "{}_{}_{}_{}", + "_et", self.filename_hash, self.content_hash, self.template_counter + ); + let template_ident = Ident::new( + template_uid.clone().into(), + DUMMY_SP, + SyntaxContext::default().apply_mark(Mark::fresh(Mark::root())), + ); + + let target = self.cfg.target; + let runtime_id = self.runtime_id.clone(); + let filename_hash = self.filename_hash.clone(); + let content_hash = self.content_hash.clone(); + let ui_source_map_records = self.ui_source_map_records.clone(); + let template_uid_for_captured = template_uid.clone(); + let source_map = self.source_map.clone(); + let node_index_fn = move |span: Span| { + let ui_source_map = + calc_hash_number(&format!("{}:{}:{}", filename_hash, content_hash, span.lo.0)); + + let mut line_number = 0; + let mut column_number = 0; + if span.lo.0 > 0 { + if let Some(cm) = &source_map { + let loc = cm.lookup_char_pos(span.lo); + line_number = loc.line as u32; + column_number = loc.col.0 as u32 + 1; + } + } + + ui_source_map_records.borrow_mut().push(UISourceMapRecord { + ui_source_map, + line_number, + column_number, + template_id: template_uid_for_captured.clone(), + }); + + Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: ui_source_map as f64, + raw: None, + })) + }; + let ExtractedTemplateParts { + key, + dynamic_attrs, + dynamic_attr_slots, + dynamic_children, + } = { + let mut extractor = + ElementTemplateExtractor::new(self, self.cfg.enable_ui_source_map, node_index_fn); + + node.visit_mut_with(&mut extractor); + extractor.into_extracted_template_parts() + }; + + let LoweredRuntimeJsx { + attrs: rendered_attrs, + children: rendered_children, + } = self.lower_runtime_jsx( + target, + runtime_id.clone(), + key, + dynamic_attrs, + dynamic_children, + ); + + let mut entry_template_uid = quote!("$template_uid" as Expr, template_uid: Expr = Expr::Lit(Lit::Str(template_uid.clone().into()))); + if matches!(self.cfg.is_dynamic_component, Some(true)) { + entry_template_uid = quote!("`${globDynamicComponentEntry}:${$template_uid}`" as Expr, template_uid: Expr = Expr::Lit(Lit::Str(template_uid.clone().into()))); + } + + let entry_template_uid_def = ModuleItem::Stmt(quote!( + r#"const $template_ident = $entry_template_uid"# + as Stmt, + template_ident = template_ident.clone(), + entry_template_uid: Expr = entry_template_uid.clone(), + )); + self.current_template_defs.push(entry_template_uid_def); + + let mut dynamic_attr_slot_cursor: usize = 0; + let mut element_slot_index: i32 = 0; + // Attribute slot indices come from ElementTemplateExtractor so runtime + // values and Template Definition descriptors share one compile-time source. + let template_expr = self.element_template_from_jsx_element( + node, + &dynamic_attr_slots, + &mut dynamic_attr_slot_cursor, + &mut element_slot_index, + ); + assert_eq!( + dynamic_attr_slot_cursor, + dynamic_attr_slots.len(), + "Template Definition must consume every ET attr slot produced by extractor" + ); + let compiled_template = self.element_template_to_json(&template_expr); + + // TODO(element-template): reintroduce cssId/entryName metadata once the + // runtime/native contract grows a dedicated replacement channel. + + if let Some(element_templates) = &self.element_templates { + element_templates.borrow_mut().push(ElementTemplateAsset { + template_id: template_uid.clone(), + compiled_template, + source_file: self.cfg.filename.clone(), + }); + } + + let rendered_children_is_empty = rendered_children.is_empty(); + + *node = JSXElement { + span: node.span(), + opening: JSXOpeningElement { + name: JSXElementName::Ident(template_ident.clone()), + span: node.span, + attrs: rendered_attrs, + self_closing: rendered_children_is_empty, + type_args: None, + }, + children: rendered_children, + closing: if rendered_children_is_empty { + None + } else { + Some(JSXClosingElement { + name: JSXElementName::Ident(template_ident.clone()), + span: DUMMY_SP, + }) + }, + }; + } + + fn visit_mut_module_items(&mut self, n: &mut Vec) { + let mut new_items: Vec = vec![]; + for item in n.iter_mut() { + item.visit_mut_with(self); + new_items.extend(self.current_template_defs.take()); + new_items.push(item.take()); + } + + *n = new_items; + } + + fn visit_mut_module(&mut self, n: &mut Module) { + self.validate_directives(n.span); + for item in &n.body { + let span = item.span(); + self.validate_directives(span); + } + + n.visit_mut_children_with(self); + self.ensure_builtin_element_templates(); + if let Some(Expr::Ident(runtime_id)) = Lazy::::get(&self.runtime_id) { + prepend_stmt( + &mut n.body, + ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Namespace(ImportStarAsSpecifier { + span: DUMMY_SP, + local: runtime_id.clone(), + })], + src: Box::new(Str { + span: DUMMY_SP, + raw: None, + value: self.cfg.runtime_pkg.clone().into(), + }), + type_only: Default::default(), + // asserts: Default::default(), + with: Default::default(), + phase: ImportPhase::Evaluation, + })), + ); + } + + if self.used_slot { + prepend_stmt( + &mut n.body, + ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: self.slot_ident.clone(), + imported: Some(ModuleExportName::Ident(Ident::new( + "__etSlot".into(), + DUMMY_SP, + SyntaxContext::default(), + ))), + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + raw: None, + value: self.cfg.runtime_pkg.clone().into(), + }), + type_only: Default::default(), + with: Default::default(), + phase: ImportPhase::Evaluation, + })), + ); + } + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/lowering.rs b/packages/react/transform/crates/swc_plugin_element_template/lowering.rs new file mode 100644 index 0000000000..6acec37881 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/lowering.rs @@ -0,0 +1,170 @@ +use swc_core::{ + common::{comments::Comments, DUMMY_SP}, + ecma::ast::*, + quote, +}; +use swc_plugins_shared::{jsx_helpers::jsx_children_to_expr, target::TransformTarget}; + +use super::{ + attr_name::AttrName, + extractor::{DynamicAttributePart, DynamicElementPart}, + slot::{expr_to_jsx_child, lower_lepus_et_children_expr, wrap_in_slot}, + JSXTransformer, +}; + +pub(super) struct LoweredRuntimeJsx { + pub attrs: Vec, + pub children: Vec, +} + +impl JSXTransformer +where + C: Comments + Clone, +{ + pub(super) fn lower_runtime_jsx( + &mut self, + target: TransformTarget, + runtime_id: Expr, + key: Option, + dynamic_attrs: Vec, + dynamic_children: Vec, + ) -> LoweredRuntimeJsx { + let mut rendered_slot_children: Vec = vec![]; + let mut attr_slot_values: Vec> = vec![]; + let mut rendered_attrs: Vec = vec![]; + + if let Some(key) = key { + rendered_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("key".into(), DUMMY_SP)), + value: Some(key), + })); + } + + for dynamic_attr in dynamic_attrs { + match dynamic_attr { + DynamicAttributePart::Attr { + value, + attr_name, + slot_index, + } => { + let slot_value = if let AttrName::Event = attr_name { + if target == TransformTarget::LEPUS { + quote!("1" as Expr) + } else { + value + } + } else if let AttrName::Ref = attr_name { + if target == TransformTarget::LEPUS { + quote!("1" as Expr) + } else { + quote!( + "$runtime_id.transformRef($value)" as Expr, + runtime_id: Expr = runtime_id.clone(), + value: Expr = value, + ) + } + } else { + value + }; + let idx = usize::try_from(slot_index).expect("ET attr slot index must be non-negative"); + if attr_slot_values.len() <= idx { + attr_slot_values.resize_with(idx + 1, || None); + } + debug_assert!(attr_slot_values[idx].is_none()); + attr_slot_values[idx] = Some(ExprOrSpread { + spread: None, + expr: Box::new(slot_value), + }); + } + DynamicAttributePart::Spread { value, slot_index } => { + let idx = usize::try_from(slot_index).expect("ET attr slot index must be non-negative"); + if attr_slot_values.len() <= idx { + attr_slot_values.resize_with(idx + 1, || None); + } + debug_assert!(attr_slot_values[idx].is_none()); + attr_slot_values[idx] = Some(ExprOrSpread { + spread: None, + expr: Box::new(value), + }); + } + } + } + + match (dynamic_children.len(), dynamic_children.first()) { + (0, _) => {} + (1, Some(DynamicElementPart::Slot(expr, 0))) => { + let child = expr_to_jsx_child(expr.clone()); + if target != TransformTarget::LEPUS { + self.used_slot = true; + } + rendered_slot_children.push(wrap_in_slot(&self.slot_ident, 0, vec![child])); + } + _ => { + for dynamic_child in dynamic_children { + match dynamic_child { + DynamicElementPart::ListSlot(expr, element_index) => { + let child = expr_to_jsx_child(expr); + if target != TransformTarget::LEPUS { + self.used_slot = true; + } + rendered_slot_children.push(wrap_in_slot( + &self.slot_ident, + element_index, + vec![child], + )); + } + DynamicElementPart::Slot(expr, element_index) => { + let child = expr_to_jsx_child(expr); + if target != TransformTarget::LEPUS { + self.used_slot = true; + } + rendered_slot_children.push(wrap_in_slot( + &self.slot_ident, + element_index, + vec![child], + )); + } + } + } + } + } + + if !attr_slot_values.is_empty() { + rendered_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("attributeSlots".into(), DUMMY_SP)), + value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: attr_slot_values, + }))), + })), + })); + } + + if target == TransformTarget::LEPUS && !rendered_slot_children.is_empty() { + let children_expr = jsx_children_to_expr(rendered_slot_children); + let lowered_children_expr = lower_lepus_et_children_expr(children_expr, &self.slot_ident) + .expect("LEPUS ET children should already be lowered to slot arrays"); + rendered_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("children".into(), DUMMY_SP)), + value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(lowered_children_expr)), + })), + })); + return LoweredRuntimeJsx { + attrs: rendered_attrs, + children: vec![], + }; + } + + LoweredRuntimeJsx { + attrs: rendered_attrs, + children: rendered_slot_children, + } + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/napi.rs b/packages/react/transform/crates/swc_plugin_element_template/napi.rs new file mode 100644 index 0000000000..0ffa7c6f38 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/napi.rs @@ -0,0 +1,223 @@ +use std::{cell::RefCell, rc::Rc}; + +use napi_derive::napi; +use swc_core::{ + common::{comments::Comments, sync::Lrc, SourceMap}, + ecma::{ast::*, visit::VisitMut}, +}; +use swc_plugins_shared::{target_napi::TransformTarget, transform_mode_napi::TransformMode}; + +use crate::{ + ElementTemplateAsset as CoreElementTemplateAsset, + ElementTemplateTransformer as CoreElementTemplateTransformer, + ElementTemplateTransformerConfig as CoreElementTemplateTransformerConfig, + ElementTemplateUISourceMapRecord as CoreElementTemplateUISourceMapRecord, +}; + +/// @internal +#[napi(object)] +#[derive(Clone, Debug)] +pub struct ElementTemplateAsset { + /// @internal + #[napi(js_name = "templateId")] + pub template_id: String, + /// @internal + #[napi(js_name = "compiledTemplate")] + pub compiled_template: serde_json::Value, + /// @internal + #[napi(js_name = "sourceFile")] + pub source_file: String, +} + +impl From for ElementTemplateAsset { + fn from(val: CoreElementTemplateAsset) -> Self { + Self { + template_id: val.template_id, + compiled_template: val.compiled_template, + source_file: val.source_file, + } + } +} + +/// @internal +#[napi(object)] +#[derive(Clone, Debug)] +pub struct UISourceMapRecord { + pub ui_source_map: i32, + pub filename: String, + pub line_number: u32, + pub column_number: u32, + #[napi(js_name = "templateId")] + pub template_id: String, +} + +impl From for CoreElementTemplateUISourceMapRecord { + fn from(val: UISourceMapRecord) -> Self { + Self { + ui_source_map: val.ui_source_map, + line_number: val.line_number, + column_number: val.column_number, + template_id: val.template_id, + } + } +} + +impl From for UISourceMapRecord { + fn from(val: CoreElementTemplateUISourceMapRecord) -> Self { + Self { + ui_source_map: val.ui_source_map, + filename: String::new(), + line_number: val.line_number, + column_number: val.column_number, + template_id: val.template_id, + } + } +} + +/// @internal +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JSXTransformerConfig { + /// @internal + pub preserve_jsx: bool, + /// @internal + pub runtime_pkg: String, + /// @internal + pub jsx_import_source: Option, + /// @internal + pub filename: String, + /// @internal + #[napi(ts_type = "'LEPUS' | 'JS' | 'MIXED'")] + pub target: TransformTarget, + /// @internal + pub enable_ui_source_map: Option, + /// @internal + pub is_dynamic_component: Option, +} + +impl Default for JSXTransformerConfig { + fn default() -> Self { + Self { + preserve_jsx: false, + runtime_pkg: "@lynx-js/react".into(), + jsx_import_source: Some("@lynx-js/react".into()), + filename: Default::default(), + target: TransformTarget::LEPUS, + enable_ui_source_map: Some(false), + is_dynamic_component: Some(false), + } + } +} + +impl From for CoreElementTemplateTransformerConfig { + fn from(val: JSXTransformerConfig) -> Self { + Self { + preserve_jsx: val.preserve_jsx, + runtime_pkg: val.runtime_pkg, + jsx_import_source: val.jsx_import_source, + filename: val.filename, + target: val.target.into(), + enable_ui_source_map: val.enable_ui_source_map.unwrap_or(false), + is_dynamic_component: val.is_dynamic_component, + } + } +} + +impl From for JSXTransformerConfig { + fn from(val: CoreElementTemplateTransformerConfig) -> Self { + Self { + preserve_jsx: val.preserve_jsx, + runtime_pkg: val.runtime_pkg, + jsx_import_source: val.jsx_import_source, + filename: val.filename, + target: val.target.into(), + enable_ui_source_map: Some(val.enable_ui_source_map), + is_dynamic_component: val.is_dynamic_component, + } + } +} + +pub struct JSXTransformer +where + C: Comments + Clone, +{ + inner: CoreElementTemplateTransformer, + pub ui_source_map_records: Rc>>, + pub element_templates: Rc>>, +} + +impl JSXTransformer +where + C: Comments + Clone, +{ + pub fn new_with_element_templates( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + element_templates: Option>>>, + ) -> Self { + let element_templates = element_templates.unwrap_or_else(|| Rc::new(RefCell::new(vec![]))); + let inner = CoreElementTemplateTransformer::new_with_element_templates( + cfg.into(), + comments, + mode.into(), + source_map, + Some(element_templates.clone()), + ); + Self { + ui_source_map_records: inner.ui_source_map_records.clone(), + element_templates, + inner, + } + } + + pub fn with_content_hash(mut self, content_hash: String) -> Self { + self.inner.content_hash = content_hash; + self + } + + pub fn with_ui_source_map_records( + mut self, + ui_source_map_records: Rc>>, + ) -> Self { + self.inner.ui_source_map_records = ui_source_map_records.clone(); + self.ui_source_map_records = ui_source_map_records; + self + } + + pub fn new( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + ) -> Self { + // The napi wrapper always keeps the collector side channel alive so callers can + // opt into ET asset export without needing a separate constructor shape. + Self::new_with_element_templates(cfg, comments, mode, source_map, None) + } + + pub fn take_element_templates(&self) -> Vec { + self.element_templates.borrow_mut().drain(..).collect() + } +} + +impl VisitMut for JSXTransformer +where + C: Comments + Clone, +{ + fn visit_mut_jsx_element(&mut self, node: &mut JSXElement) { + self.inner.visit_mut_jsx_element(node) + } + + fn visit_mut_module_items(&mut self, n: &mut Vec) { + self.inner.visit_mut_module_items(n) + } + + fn visit_mut_module(&mut self, n: &mut Module) { + self.inner.visit_mut_module(n) + } +} + +pub type ElementTemplateTransformerConfig = JSXTransformerConfig; +pub type ElementTemplateTransformer = JSXTransformer; diff --git a/packages/react/transform/crates/swc_plugin_element_template/slot.rs b/packages/react/transform/crates/swc_plugin_element_template/slot.rs new file mode 100644 index 0000000000..c9a2fc8219 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/slot.rs @@ -0,0 +1,99 @@ +use swc_core::{common::DUMMY_SP, ecma::ast::*, quote}; +use swc_plugins_shared::jsx_helpers::jsx_children_to_expr; + +use super::i32_to_expr; + +pub(super) fn wrap_in_slot( + slot_ident: &Ident, + id: i32, + children: Vec, +) -> JSXElementChild { + // ET dynamic children are transported as indexed element slots. Keeping the + // marker in JSX form lets the later React lowering decide the final JS shape + // without losing slot identity. + let children_expr = jsx_children_to_expr(children); + JSXElementChild::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(quote!( + "$slot_ident($id, $children_expr)" as Expr, + slot_ident: Ident = slot_ident.clone(), + id: Expr = i32_to_expr(&id), + children_expr: Expr = children_expr, + ))), + }) +} + +pub(super) fn expr_to_jsx_child(expr: Expr) -> JSXElementChild { + match expr { + Expr::JSXElement(jsx) => JSXElementChild::JSXElement(jsx), + _ => JSXElementChild::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(expr)), + }), + } +} + +fn unwrap_et_slot_expr(expr: &Expr, slot_ident: &Ident) -> Option<(usize, Expr)> { + let Expr::Call(call_expr) = expr else { + return None; + }; + let Callee::Expr(callee) = &call_expr.callee else { + return None; + }; + let Expr::Ident(ident) = &**callee else { + return None; + }; + if ident.sym != slot_ident.sym { + return None; + } + if call_expr.args.len() != 2 { + return None; + } + + let slot_id = match &*call_expr.args[0].expr { + Expr::Lit(Lit::Num(Number { value, .. })) if *value >= 0.0 => *value as usize, + _ => return None, + }; + + Some((slot_id, *call_expr.args[1].expr.clone())) +} + +fn build_et_slot_array_expr(entries: Vec<(usize, Expr)>) -> Expr { + let mut slots: Vec> = vec![]; + + for (slot_id, child_expr) in entries { + if slots.len() <= slot_id { + slots.resize(slot_id + 1, None); + } + slots[slot_id] = Some(ExprOrSpread { + spread: None, + expr: Box::new(child_expr), + }); + } + + Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: slots, + }) +} + +pub(super) fn lower_lepus_et_children_expr(expr: Expr, slot_ident: &Ident) -> Option { + // LEPUS host components receive children through a plain slot array. JS output + // can keep nested JSX children, but LEPUS needs the marker calls collapsed here + // so the main-thread ET runtime reads the same elementSlotIndex ordering as the + // compiled Template Definition. + let slot_entries = match expr { + Expr::Call(_) => vec![unwrap_et_slot_expr(&expr, slot_ident)?], + Expr::Array(array) => array + .elems + .iter() + .map(|elem| { + let elem = elem.as_ref()?; + unwrap_et_slot_expr(&elem.expr, slot_ident) + }) + .collect::>>()?, + _ => return None, + }; + + Some(build_et_slot_array_expr(slot_entries)) +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/template_attribute.rs b/packages/react/transform/crates/swc_plugin_element_template/template_attribute.rs new file mode 100644 index 0000000000..496675bb93 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/template_attribute.rs @@ -0,0 +1,31 @@ +use swc_core::ecma::ast::*; + +#[derive(Debug, Clone)] +pub(super) enum TemplateAttributeSlot { + Attr { key: String, slot_index: i32 }, + Spread { slot_index: i32 }, +} + +pub(super) fn template_attribute_key(key: &str) -> &str { + if key == "className" { + "class" + } else { + key + } +} + +pub(super) fn template_attribute_descriptor_key(name: &JSXAttrName) -> String { + match name { + JSXAttrName::Ident(name) => template_attribute_key(name.sym.as_ref()).to_string(), + JSXAttrName::JSXNamespacedName(JSXNamespacedName { ns, name, .. }) => { + template_namespaced_attribute_descriptor_key(ns, name) + } + } +} + +pub(super) fn template_namespaced_attribute_descriptor_key( + ns: &IdentName, + name: &IdentName, +) -> String { + format!("{}:{}", ns.sym, name.sym) +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs new file mode 100644 index 0000000000..ae0c6cb95d --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs @@ -0,0 +1,373 @@ +use swc_core::{ + common::{comments::Comments, DUMMY_SP}, + ecma::ast::*, + quote, +}; +use swc_plugins_shared::jsx_helpers::{jsx_name, jsx_text_to_str, transform_jsx_attr_str}; + +use super::{ + i32_to_expr, + template_attribute::{template_attribute_descriptor_key, TemplateAttributeSlot}, + template_slot::{is_slot_placeholder, slot_placeholder_index}, + JSXTransformer, +}; + +impl JSXTransformer +where + C: Comments + Clone, +{ + // Template Definition owns ET's serialized tree/descriptor contract. + pub(super) fn element_template_to_json(&self, expr: &Expr) -> serde_json::Value { + // Build descriptors with SWC Expr helpers first so the golden tests can inspect + // the same shape as codegen. Serialization intentionally accepts only the + // literal/object/array subset that belongs in the native Template Definition. + match expr { + Expr::Lit(lit) => match lit { + Lit::Str(s) => serde_json::Value::String(s.value.as_str().unwrap_or("").to_string()), + Lit::Num(n) => serde_json::Number::from_f64(n.value) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Lit::Bool(b) => serde_json::Value::Bool(b.value), + Lit::Null(_) => serde_json::Value::Null, + _ => serde_json::Value::Null, + }, + Expr::Array(arr) => { + let elems: Vec = arr + .elems + .iter() + .map(|elem| { + if let Some(elem) = elem { + self.element_template_to_json(&elem.expr) + } else { + serde_json::Value::Null + } + }) + .collect(); + serde_json::Value::Array(elems) + } + Expr::Object(obj) => { + let mut map = serde_json::Map::new(); + for prop in &obj.props { + if let PropOrSpread::Prop(prop) = prop { + if let Prop::KeyValue(kv) = &**prop { + let key; + if let PropName::Ident(ident) = &kv.key { + key = ident.sym.as_str().to_string(); + } else if let PropName::Str(s) = &kv.key { + key = s.value.as_str().unwrap_or("").to_string(); + } else { + continue; + }; + let value = self.element_template_to_json(&kv.value); + map.insert(key, value); + } + } + } + serde_json::Value::Object(map) + } + _ => serde_json::Value::Null, + } + } + + fn element_template_string_expr(&self, value: &str) -> Expr { + Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + raw: None, + value: value.into(), + })) + } + + fn element_template_array_expr(&self, items: Vec) -> Expr { + Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: items + .into_iter() + .map(|expr| { + Some(ExprOrSpread { + spread: None, + expr: Box::new(expr), + }) + }) + .collect(), + }) + } + + fn element_template_static_attribute_descriptor(&self, key: &str, value: Expr) -> Expr { + quote!( + r#"{ kind: "attribute", key: $key, binding: "static", value: $value }"# as Expr, + key: Expr = self.element_template_string_expr(key), + value: Expr = value, + ) + } + + fn element_template_attribute_slot_descriptor(&self, key: &str, attr_slot_index: i32) -> Expr { + quote!( + r#"{ kind: "attribute", key: $key, binding: "slot", attrSlotIndex: $attr_slot_index }"# as Expr, + key: Expr = self.element_template_string_expr(key), + attr_slot_index: Expr = i32_to_expr(&attr_slot_index), + ) + } + + fn element_template_spread_slot_descriptor(&self, attr_slot_index: i32) -> Expr { + quote!( + r#"{ kind: "spread", binding: "slot", attrSlotIndex: $attr_slot_index }"# as Expr, + attr_slot_index: Expr = i32_to_expr(&attr_slot_index), + ) + } + + fn element_template_element_slot(&self, element_slot_index: i32) -> Expr { + quote!( + r#"{ kind: "elementSlot", type: "slot", elementSlotIndex: $element_slot_index }"# as Expr, + element_slot_index: Expr = i32_to_expr(&element_slot_index), + ) + } + + fn element_template_element_node( + &self, + tag: &str, + attributes: Vec, + children: Vec, + ) -> Expr { + quote!( + r#"{ kind: "element", type: $tag, attributesArray: $attributes, children: $children }"# as Expr, + tag: Expr = self.element_template_string_expr(tag), + attributes: Expr = self.element_template_array_expr(attributes), + children: Expr = self.element_template_array_expr(children), + ) + } + + fn next_dynamic_attribute_slot<'a>( + &self, + dynamic_attr_slots: &'a [TemplateAttributeSlot], + dynamic_attr_slot_cursor: &mut usize, + ) -> &'a TemplateAttributeSlot { + let slot = dynamic_attr_slots + .get(*dynamic_attr_slot_cursor) + .unwrap_or_else(|| { + panic!( + "Template Definition requested attr slot {}, but extractor produced only {} slots", + *dynamic_attr_slot_cursor, + dynamic_attr_slots.len() + ) + }); + *dynamic_attr_slot_cursor += 1; + slot + } + + fn element_template_from_jsx_children( + &self, + children: &[JSXElementChild], + dynamic_attr_slots: &[TemplateAttributeSlot], + dynamic_attr_slot_cursor: &mut usize, + element_slot_index: &mut i32, + ) -> Vec { + let mut out: Vec = vec![]; + + for child in children { + match child { + JSXElementChild::JSXText(txt) => { + let s = jsx_text_to_str(&txt.value); + if s.trim().is_empty() { + continue; + } + + out.push(self.element_template_element_node( + "raw-text", + vec![self.element_template_static_attribute_descriptor( + "text", + self.element_template_string_expr(s.as_ref()), + )], + vec![], + )); + } + JSXElementChild::JSXElement(el) => out.push(self.element_template_from_jsx_element_impl( + el, + dynamic_attr_slots, + dynamic_attr_slot_cursor, + element_slot_index, + )), + JSXElementChild::JSXFragment(frag) => { + out.extend(self.element_template_from_jsx_children( + &frag.children, + dynamic_attr_slots, + dynamic_attr_slot_cursor, + element_slot_index, + )); + } + JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(_), + .. + }) => { + let idx = *element_slot_index; + *element_slot_index += 1; + out.push(self.element_template_element_slot(idx)); + } + JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + }) => {} + JSXElementChild::JSXSpreadChild(_) => { + let idx = *element_slot_index; + *element_slot_index += 1; + out.push(self.element_template_element_slot(idx)); + } + } + } + + out + } + + pub(super) fn element_template_from_jsx_element( + &self, + n: &JSXElement, + dynamic_attr_slots: &[TemplateAttributeSlot], + dynamic_attr_slot_cursor: &mut usize, + element_slot_index: &mut i32, + ) -> Expr { + self.element_template_from_jsx_element_impl( + n, + dynamic_attr_slots, + dynamic_attr_slot_cursor, + element_slot_index, + ) + } + + fn element_template_from_jsx_element_impl( + &self, + n: &JSXElement, + dynamic_attr_slots: &[TemplateAttributeSlot], + dynamic_attr_slot_cursor: &mut usize, + element_slot_index: &mut i32, + ) -> Expr { + if is_slot_placeholder(n) { + let idx = slot_placeholder_index(n).unwrap_or_else(|| { + let idx = *element_slot_index; + *element_slot_index += 1; + idx + }); + return self.element_template_element_slot(idx); + } + + let tag_expr = jsx_name(n.opening.name.clone()); + let tag_value = match *tag_expr { + Expr::Lit(Lit::Str(s)) => s.value, + _ => "".into(), + }; + + let mut attribute_descriptors: Vec = vec![]; + + for attr in &n.opening.attrs { + match attr { + JSXAttrOrSpread::JSXAttr(attr) => { + let key = template_attribute_descriptor_key(&attr.name); + + if key == "__lynx_part_id" { + continue; + } + + let static_value = match &attr.value { + None => Some(Expr::Lit(Lit::Bool(Bool { + span: DUMMY_SP, + value: true, + }))), + Some(JSXAttrValue::Str(s)) => Some(Expr::Lit(Lit::Str(Str { + span: s.span, + value: transform_jsx_attr_str(&s.value).into(), + raw: None, + }))), + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) => match &**expr { + Expr::Lit(Lit::Str(s)) => Some(Expr::Lit(Lit::Str(s.clone()))), + Expr::Lit(Lit::Num(n)) => Some(Expr::Lit(Lit::Num(n.clone()))), + Expr::Lit(Lit::Bool(b)) => Some(Expr::Lit(Lit::Bool(*b))), + Expr::Lit(Lit::Null(n)) => Some(Expr::Lit(Lit::Null(*n))), + // TODO: Support complex static values (Object, Array, Template Literal without expressions) + // See ElementTemplate/Todo-StaticAttributesOpts.md + _ => None, + }, + _ => None, + }; + + if let Some(static_value) = static_value { + attribute_descriptors + .push(self.element_template_static_attribute_descriptor(&key, static_value)); + } else { + match self.next_dynamic_attribute_slot(dynamic_attr_slots, dynamic_attr_slot_cursor) { + TemplateAttributeSlot::Attr { + key: slot_key, + slot_index, + } => { + debug_assert_eq!( + slot_key, &key, + "Template Definition attr slot key must match extractor output" + ); + attribute_descriptors + .push(self.element_template_attribute_slot_descriptor(slot_key, *slot_index)); + } + TemplateAttributeSlot::Spread { .. } => { + panic!("Template Definition expected attribute slot for {key}, got spread slot") + } + } + } + } + JSXAttrOrSpread::SpreadElement(_) => { + match self.next_dynamic_attribute_slot(dynamic_attr_slots, dynamic_attr_slot_cursor) { + TemplateAttributeSlot::Spread { slot_index } => { + attribute_descriptors.push(self.element_template_spread_slot_descriptor(*slot_index)); + } + TemplateAttributeSlot::Attr { key, .. } => { + panic!("Template Definition expected spread slot, got attribute slot for {key}") + } + } + } + } + } + + // Optimization for text tags: + // If (or similar) has only one static text child, use `text` attribute instead of checking children. + let is_text_tag = tag_value == "text" + || tag_value == "raw-text" + || tag_value == "inline-text" + || tag_value == "x-text" + || tag_value == "x-inline-text"; + let mut text_child_optimized = false; + + if is_text_tag { + let valid_children: Vec<&JSXElementChild> = n + .children + .iter() + .filter(|c| match c { + JSXElementChild::JSXText(t) => !jsx_text_to_str(&t.value).trim().is_empty(), + _ => true, + }) + .collect(); + + if valid_children.len() == 1 { + if let JSXElementChild::JSXText(txt) = valid_children[0] { + let s = jsx_text_to_str(&txt.value); + attribute_descriptors.push(self.element_template_static_attribute_descriptor( + "text", + self.element_template_string_expr(s.as_ref()), + )); + text_child_optimized = true; + } + } + } + + let children = if text_child_optimized { + vec![] + } else { + self.element_template_from_jsx_children( + &n.children, + dynamic_attr_slots, + dynamic_attr_slot_cursor, + element_slot_index, + ) + }; + + let final_tag = tag_value.to_string_lossy().to_string(); + self.element_template_element_node(&final_tag, attribute_descriptors, children) + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/template_slot.rs b/packages/react/transform/crates/swc_plugin_element_template/template_slot.rs new file mode 100644 index 0000000000..8e96617423 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/template_slot.rs @@ -0,0 +1,81 @@ +use swc_core::{ + common::{SyntaxContext, DUMMY_SP}, + ecma::ast::*, +}; + +pub(super) const ET_SLOT_PLACEHOLDER_TAG: &str = "__et_slot_placeholder"; +const ET_SLOT_PLACEHOLDER_INDEX_ATTR: &str = "__et_slot_index"; + +fn number_jsx_attr(value: i32) -> JSXAttrValue { + JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: value as f64, + raw: None, + })))), + }) +} + +pub(super) fn slot_placeholder_node(slot_index: i32, self_closing: bool) -> JSXElement { + let name = JSXElementName::Ident(Ident::new( + ET_SLOT_PLACEHOLDER_TAG.into(), + DUMMY_SP, + SyntaxContext::default(), + )); + JSXElement { + span: DUMMY_SP, + opening: JSXOpeningElement { + span: DUMMY_SP, + name: name.clone(), + attrs: vec![JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new( + ET_SLOT_PLACEHOLDER_INDEX_ATTR.into(), + DUMMY_SP, + )), + value: Some(number_jsx_attr(slot_index)), + })], + self_closing, + type_args: None, + }, + closing: (!self_closing).then_some(JSXClosingElement { + span: DUMMY_SP, + name, + }), + children: vec![], + } +} + +pub(super) fn is_slot_placeholder(n: &JSXElement) -> bool { + matches!( + &n.opening.name, + JSXElementName::Ident(ident) if ident.sym == ET_SLOT_PLACEHOLDER_TAG + ) +} + +pub(super) fn slot_placeholder_index(n: &JSXElement) -> Option { + n.opening.attrs.iter().find_map(|attr| { + let JSXAttrOrSpread::JSXAttr(attr) = attr else { + return None; + }; + let JSXAttrName::Ident(name) = &attr.name else { + return None; + }; + if name.sym != ET_SLOT_PLACEHOLDER_INDEX_ATTR { + return None; + } + let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) = &attr.value + else { + return None; + }; + let Expr::Lit(Lit::Num(Number { value, .. })) = &**expr else { + return None; + }; + + Some(*value as i32) + }) +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap new file mode 100644 index 0000000000..5749ea8702 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap @@ -0,0 +1,87 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n value\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "static" + }, + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "data-value", + "binding": "slot", + "attrSlotIndex": 1.0 + }, + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dynamic Value" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static" + } + ], + "children": [] + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap new file mode 100644 index 0000000000..de83144d57 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap @@ -0,0 +1,47 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\nfunction App() {\n const attrs = __BACKGROUND__ ? {\n 0: {\n id: 'b'\n },\n 2: {\n data: 'extra'\n }\n } : {\n 0: {\n id: 'a'\n },\n 1: {\n title: 'main'\n }\n };\n return (<_et_da39a_test_1 attributeSlots={[\n attrs,\n attrs\n ]}/>);\n}\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "data-a", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "b", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap new file mode 100644 index 0000000000..23ea3baeca --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap @@ -0,0 +1,67 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "disabled", + "binding": "static", + "value": true + }, + { + "kind": "attribute", + "key": "opacity", + "binding": "static", + "value": 0.5 + }, + { + "kind": "attribute", + "key": "lines", + "binding": "static", + "value": 2.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Attribute Types Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap new file mode 100644 index 0000000000..bb1f9092ed --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap @@ -0,0 +1,127 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Hello" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "World" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "!" + } + ], + "children": [] + } + ] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "First" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Second" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Third" + } + ], + "children": [] + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap new file mode 100644 index 0000000000..01b4256a29 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap @@ -0,0 +1,67 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "data-id", + "binding": "static", + "value": "123" + }, + { + "kind": "attribute", + "key": "data-name", + "binding": "static", + "value": "test" + }, + { + "kind": "attribute", + "key": "data-long-name", + "binding": "static", + "value": "long-value" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dataset Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_deeply_nested_user_components.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_deeply_nested_user_components.snap new file mode 100644 index 0000000000..260b553460 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_deeply_nested_user_components.snap @@ -0,0 +1,56 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n \n \n \n <_et_da39a_test_2/>\n \n \n \n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Deep Slot" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap new file mode 100644 index 0000000000..e647d8a548 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicClass\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "static-class" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dynamic Class Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap new file mode 100644 index 0000000000..6308c8bd9e --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n handleTap,\n handleTouch\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "bindtap", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "catchtouchstart", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Event Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap new file mode 100644 index 0000000000..1d28976637 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n 1,\n 1\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "bindtap", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "catchtouchstart", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Event Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap new file mode 100644 index 0000000000..46cc174846 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "static-id" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "ID Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap new file mode 100644 index 0000000000..298ddad22d --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap @@ -0,0 +1,88 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n {\n color: dynamicColor\n }\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "style", + "binding": "static", + "value": "color: red; width: 100px;" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "style", + "binding": "static", + "value": "font-size:16px;font-weight:bold" + }, + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static Style" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "style", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dynamic Style" + } + ], + "children": [] + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap new file mode 100644 index 0000000000..57bb5d47f4 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap @@ -0,0 +1,88 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import { __etSlot as __etSlot } from \"@lynx-js/react\";\nimport * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1>{__etSlot(0, items)}{__etSlot(1, items.map((item)=><_et_da39a_test_2>{__etSlot(0, item)}))};\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "1" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "2" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap new file mode 100644 index 0000000000..9288586bc3 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap @@ -0,0 +1,88 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n items,\n items.map((item)=><_et_da39a_test_2 children={[\n item\n ]}/>)\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "1" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "2" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap new file mode 100644 index 0000000000..ae07f349c0 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap @@ -0,0 +1,86 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n dynamicPart\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Start" + } + ], + "children": [] + }, + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + }, + { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Middle" + } + ], + "children": [] + } + ] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "End" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap new file mode 100644 index 0000000000..de8e043f07 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap @@ -0,0 +1,151 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_3 = \"_et_da39a_test_3\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n items.map((item)=><_et_da39a_test_2 children={[\n item\n ]}/>),\n showCopyright && <_et_da39a_test_3/>\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "_et_da39a_test_3", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Copyright" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "wrapper" + } + ], + "children": [ + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "header" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Header" + } + ], + "children": [] + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "content" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "footer" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Footer" + } + ], + "children": [] + }, + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap new file mode 100644 index 0000000000..cff90c1b7c --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap @@ -0,0 +1,55 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n ReactLynx.transformRef(viewRef)\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "ref", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Ref Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap new file mode 100644 index 0000000000..2b8a99f0d0 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap @@ -0,0 +1,55 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n 1\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "ref", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Ref Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap new file mode 100644 index 0000000000..306c891b40 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap @@ -0,0 +1,72 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_3 = \"_et_da39a_test_3\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n [\n \n <_et_da39a_test_2/>\n ,\n \n <_et_da39a_test_3/>\n \n ]\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Slot Content 1" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_3", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Slot Content 2" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap new file mode 100644 index 0000000000..74b1059d0c --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap @@ -0,0 +1,60 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n props\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "spread", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "data-extra", + "binding": "static", + "value": "value" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Spread Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap new file mode 100644 index 0000000000..a9fed63ae3 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap @@ -0,0 +1,56 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n \n <_et_da39a_test_2/>\n \n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "hello" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_element_slot_placeholder.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_element_slot_placeholder.snap new file mode 100644 index 0000000000..e26c1b9507 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_element_slot_placeholder.snap @@ -0,0 +1,58 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n [\n \"a\",\n \"b\"\n ],\n [\n \"c\",\n \"d\"\n ]\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static" + } + ], + "children": [] + }, + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap new file mode 100644 index 0000000000..72f9762955 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap @@ -0,0 +1,72 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n props,\n 1,\n 1\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "spread", + "binding": "slot", + "attrSlotIndex": 1.0 + }, + { + "kind": "attribute", + "key": "bindtap", + "binding": "slot", + "attrSlotIndex": 2.0 + }, + { + "kind": "attribute", + "key": "ref", + "binding": "slot", + "attrSlotIndex": 3.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Spread Sync" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap new file mode 100644 index 0000000000..e63047ae9b --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap @@ -0,0 +1,55 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "container" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Hello" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap new file mode 100644 index 0000000000..3c8dff1b48 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap @@ -0,0 +1,67 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "container" + }, + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "main" + }, + { + "kind": "attribute", + "key": "style", + "binding": "static", + "value": "color: red;" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Hello" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap new file mode 100644 index 0000000000..8cee3dc792 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap @@ -0,0 +1,74 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n url\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "container" + }, + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "image", + "attributesArray": [ + { + "kind": "attribute", + "key": "src", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap new file mode 100644 index 0000000000..148c55d58b --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap @@ -0,0 +1,73 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicText\n]} children={[\n dynamicText2\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Explicit Text Attribute" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs new file mode 100644 index 0000000000..c4da292e85 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs @@ -0,0 +1,684 @@ +use swc_core::ecma::parser::{EsSyntax, Syntax}; +use swc_plugin_element_template::{ElementTemplateAsset, JSXTransformer, JSXTransformerConfig}; +use swc_plugins_shared::target::TransformTarget; +use swc_plugins_shared::transform_mode::TransformMode; + +const BUILTIN_RAW_TEXT_TEMPLATE_ID: &str = "__et_builtin_raw_text__"; + +fn assert_has_single_builtin_raw_text_template(templates: &[ElementTemplateAsset]) { + let builtin_count = templates + .iter() + .filter(|template| template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID) + .count(); + assert_eq!( + builtin_count, 1, + "Expected exactly one builtin raw-text template, got {}", + builtin_count + ); +} + +fn template_snapshot_json(templates: &[ElementTemplateAsset]) -> Vec { + templates + .iter() + .map(|t| { + serde_json::json!({ + "template_id": t.template_id, + "template": t.compiled_template + }) + }) + .collect() +} + +fn max_attr_slot_index(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Object(map) => { + let own_slot = map + .get("attrSlotIndex") + .and_then(|slot| slot.as_u64()) + .map(|slot| slot as usize); + map + .values() + .filter_map(max_attr_slot_index) + .chain(own_slot) + .max() + } + serde_json::Value::Array(values) => values.iter().filter_map(max_attr_slot_index).max(), + _ => None, + } +} + +fn first_attribute_slots_len(code: &str) -> Option { + let prefix = "attributeSlots={["; + let start = code.find(prefix)? + prefix.len(); + let mut square_depth = 0usize; + let mut brace_depth = 0usize; + let mut paren_depth = 0usize; + let mut quote: Option = None; + let mut escape = false; + let mut has_content = false; + let mut len = 1usize; + + for ch in code[start..].chars() { + if let Some(quote_ch) = quote { + if escape { + escape = false; + continue; + } + if ch == '\\' { + escape = true; + continue; + } + if ch == quote_ch { + quote = None; + } + continue; + } + + match ch { + '\'' | '"' | '`' => { + has_content = true; + quote = Some(ch); + } + '[' => { + has_content = true; + square_depth += 1; + } + ']' if square_depth == 0 && brace_depth == 0 && paren_depth == 0 => { + return Some(if has_content { len } else { 0 }); + } + ']' => { + if square_depth == 0 { + return None; + } + square_depth -= 1; + } + '{' => { + has_content = true; + brace_depth += 1; + } + '}' => { + if brace_depth == 0 { + return None; + } + brace_depth -= 1; + } + '(' => { + has_content = true; + paren_depth += 1; + } + ')' => { + if paren_depth == 0 { + return None; + } + paren_depth -= 1; + } + ',' if square_depth == 0 && brace_depth == 0 && paren_depth == 0 => len += 1, + ch if !ch.is_whitespace() => has_content = true, + _ => {} + } + } + + None +} + +fn assert_attribute_slots_match_template(code: &str, templates: &[ElementTemplateAsset]) { + let expected = templates + .iter() + .filter(|template| template.template_id != BUILTIN_RAW_TEXT_TEMPLATE_ID) + .filter_map(|template| max_attr_slot_index(&template.compiled_template)) + .max() + .map(|slot| slot + 1); + + let Some(expected) = expected else { + return; + }; + + let actual = first_attribute_slots_len(code).expect("ET output should include attributeSlots"); + assert_eq!( + actual, expected, + "Template Definition attrSlotIndex expects {expected} attribute slot values, but transformed JSX emitted {actual}.\n{code}" + ); +} + +macro_rules! et_snapshot_test { + ($name:ident, $input:expr) => { + #[test] + fn $name() { + verify_code_and_template_json($input, stringify!($name)); + } + }; + ($name:ident, $cfg:expr, $input:expr) => { + #[test] + fn $name() { + verify_code_and_template_json_with_config($input, stringify!($name), $cfg); + } + }; +} + +et_snapshot_test!( + should_output_element_template_simple_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + Hello + + "# +); + +et_snapshot_test!( + should_output_template_with_static_attributes, + r#" + + Hello + + "# +); + +et_snapshot_test!( + should_handle_dataset_attributes, + r#" + + Dataset Test + + "# +); + +et_snapshot_test!( + should_handle_nested_structure_and_dynamic_content, + r#" + + + Header + + + {/* Expression should become an elementSlot */} + {items.map(item => {item})} + + + Footer + {/* Another slot */} + {showCopyright && Copyright} + + + "# +); + +et_snapshot_test!( + should_handle_mixed_content, + r#" + + Start + {dynamicPart} + Middle + End + + "# +); + +et_snapshot_test!( + should_handle_boolean_and_number_attributes, + r#" + + Attribute Types Test + + "# +); + +et_snapshot_test!( + should_generate_attribute_slots_for_dynamic_attributes, + r#" + + Dynamic Value + Static + + "# +); + +et_snapshot_test!( + should_handle_complex_text_structure, + r#" + + + Hello + World + ! + + + First + Second + Third + + + "# +); + +et_snapshot_test!( + should_handle_spread_attributes, + r#" + + Spread Test + + "# +); + +et_snapshot_test!( + should_handle_events_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + Event Test + + "# +); + +et_snapshot_test!( + should_handle_events_js, + element_template_config_for_target(TransformTarget::JS), + r#" + + Event Test + + "# +); + +et_snapshot_test!( + should_handle_inline_styles, + r#" + + Static Style + Dynamic Style + + "# +); + +et_snapshot_test!( + should_handle_refs_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + Ref Test + + "# +); + +et_snapshot_test!( + should_handle_refs_js, + element_template_config_for_target(TransformTarget::JS), + r#" + + Ref Test + + "# +); + +et_snapshot_test!( + should_handle_dynamic_class_attributes, + r#" + + Dynamic Class Test + + "# +); + +et_snapshot_test!( + should_handle_id_attributes, + r#" + + ID Test + + "# +); + +et_snapshot_test!( + should_isolate_arrays_with_element_slot_placeholder, + r#" + + {["a", "b"]} + Static + {["c", "d"]} + + "# +); + +et_snapshot_test!( + should_handle_user_component, + r#" + + + hello + + + "# +); + +et_snapshot_test!( + should_handle_interpolated_text_with_siblings_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + + {items} + + + {items.map((item) => {item})} + + + "# +); + +et_snapshot_test!( + should_handle_interpolated_text_with_siblings_js, + element_template_config_for_target(TransformTarget::JS), + r#" + + + {items} + + + {items.map((item) => {item})} + + + "# +); + +et_snapshot_test!( + should_handle_sibling_user_components, + r#" + + + Slot Content 1 + + + Slot Content 2 + + + "# +); + +et_snapshot_test!( + should_handle_deeply_nested_user_components, + r#" + + + + + Deep Slot + + + + + "# +); + +et_snapshot_test!( + should_handle_background_conditional_attributes, + r#" + function App() { + const attrs = __BACKGROUND__ + ? { + 0: { id: 'b' }, + 2: { data: 'extra' }, + } + : { + 0: { id: 'a' }, + 1: { title: 'main' }, + }; + return ( + + ); + } + "# +); + +et_snapshot_test!( + should_verify_template_structure_complex, + r#" + + Static + + + "# +); + +et_snapshot_test!( + should_verify_text_attribute_and_child_text_slots, + r#" + + + + {dynamicText2} + + "# +); + +et_snapshot_test!( + should_keep_code_and_template_attribute_slots_in_sync_for_spread, + r#" + + Spread Sync + + "# +); + +#[track_caller] +fn transform_to_code_and_templates( + input: &str, + cfg: JSXTransformerConfig, +) -> (String, Vec) { + let (code, templates, _) = transform_to_code_templates_and_diagnostics(input, cfg); + (code, templates) +} + +#[track_caller] +fn transform_to_code_templates_and_diagnostics( + input: &str, + cfg: JSXTransformerConfig, +) -> (String, Vec, Vec) { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use swc_core::common::{ + comments::SingleThreadedComments, + errors::{DiagnosticBuilder, Emitter as DiagnosticEmitter, Handler, HANDLER}, + FileName, Globals, SourceMap, GLOBALS, + }; + use swc_core::ecma::codegen::{text_writer::JsWriter, Emitter}; + use swc_core::ecma::parser::{lexer::Lexer, Parser, StringInput}; + use swc_core::ecma::visit::VisitMutWith; + + struct DiagnosticCollector { + messages: Arc>>, + } + + impl DiagnosticEmitter for DiagnosticCollector { + fn emit(&mut self, db: &mut DiagnosticBuilder<'_>) { + self.messages.lock().unwrap().push(db.message().to_string()); + } + } + + GLOBALS.set(&Globals::new(), || { + let cm: Arc = Arc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), input.to_string()); + let diagnostics = Arc::new(Mutex::new(vec![])); + let handler = Handler::with_emitter( + true, + false, + Box::new(DiagnosticCollector { + messages: diagnostics.clone(), + }), + ); + + let lexer = Lexer::new( + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + Default::default(), + StringInput::from(&*fm), + None, + ); + + let mut parser = Parser::new_from(lexer); + let module_result = parser.parse_module(); + let mut module = module_result.expect("Failed to parse module"); + + let comments = SingleThreadedComments::default(); + let element_templates = Rc::new(RefCell::new(vec![])); + + let mut transformer = JSXTransformer::new_with_element_templates( + cfg, + Some(comments), + TransformMode::Test, + None, + Some(element_templates.clone()), + ); + + HANDLER.set(&handler, || { + module.visit_mut_with(&mut transformer); + }); + + let mut buf = vec![]; + { + let mut emitter = Emitter { + cfg: swc_core::ecma::codegen::Config::default(), + cm: cm.clone(), + comments: None, + wr: JsWriter::new(cm.clone(), "\n", &mut buf, None), + }; + emitter.emit_module(&module).expect("Failed to emit module"); + } + + let code = String::from_utf8(buf).expect("Codegen output is not valid utf8"); + let templates: Vec<_> = element_templates.borrow_mut().drain(..).collect(); + let diagnostics = diagnostics.lock().unwrap().clone(); + + (code, templates, diagnostics) + }) +} + +#[track_caller] +fn verify_code_and_template_json( + input: &str, + snapshot_name: &str, +) -> (String, Vec) { + verify_code_and_template_json_with_config(input, snapshot_name, element_template_config()) +} + +fn element_template_config() -> JSXTransformerConfig { + JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + } +} + +fn element_template_config_for_target(target: TransformTarget) -> JSXTransformerConfig { + JSXTransformerConfig { + target, + ..element_template_config() + } +} + +fn dynamic_component_element_template_config() -> JSXTransformerConfig { + JSXTransformerConfig { + is_dynamic_component: Some(true), + ..element_template_config() + } +} + +#[track_caller] +fn verify_code_and_template_json_with_config( + input: &str, + snapshot_name: &str, + cfg: JSXTransformerConfig, +) -> (String, Vec) { + if std::env::var("UPDATE").as_deref() == Ok("1") { + std::env::set_var("INSTA_UPDATE", "always"); + } + + let (code, templates) = transform_to_code_and_templates(input, cfg); + + assert!(!templates.is_empty(), "Should collect element templates"); + assert_attribute_slots_match_template(&code, &templates); + + insta::with_settings!({ + snapshot_path => "__combined_snapshots__", + prepend_module_to_snapshot => false, + }, { + insta::assert_json_snapshot!(snapshot_name, serde_json::json!({ + "code": code, + "templates": template_snapshot_json(&templates), + })); + }); + + (code, templates) +} + +#[test] +fn should_not_emit_element_template_map_in_element_template_mode() { + let (code, templates) = transform_to_code_and_templates( + r#" + + Hello + + "#, + element_template_config(), + ); + + assert_has_single_builtin_raw_text_template(&templates); + assert!(!code.contains("__elementTemplateMap")); + assert!(!code.contains("__template_")); + assert!(code.contains("const _et_")); + assert!(!code.contains("const __snapshot_")); + + for template in templates { + if template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID { + continue; + } + assert!(template.template_id.starts_with("_et_")); + assert!(code.contains(&format!("\"{}\"", template.template_id))); + } +} + +#[test] +fn should_collect_element_templates_for_dynamic_component_in_element_template_mode() { + let (code, templates) = transform_to_code_and_templates( + r#" + + Hello + + "#, + dynamic_component_element_template_config(), + ); + + assert!(!templates.is_empty(), "Should collect element templates"); + assert_has_single_builtin_raw_text_template(&templates); + assert!(!code.contains("__elementTemplateMap")); + assert!(code.contains("globDynamicComponentEntry")); + + for template in templates { + assert!( + template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID + || template.template_id.starts_with("_et_") + ); + assert!(!template.template_id.contains(':')); + } +} + +#[test] +fn should_report_page_element_as_unsupported() { + let (_, _, diagnostics) = transform_to_code_templates_and_diagnostics( + r#" + + Page Element Test + + "#, + element_template_config(), + ); + + assert!( + diagnostics + .iter() + .any(|message| message == " is not supported"), + "expected unsupported diagnostic, got: {diagnostics:?}" + ); +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs new file mode 100644 index 0000000000..ca9b26253c --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs @@ -0,0 +1,365 @@ +use serde_json::Value; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use swc_core::common::{comments::SingleThreadedComments, FileName, Globals, SourceMap, GLOBALS}; +use swc_core::ecma::codegen::{text_writer::JsWriter, Emitter}; +use swc_core::ecma::parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax}; +use swc_core::ecma::visit::VisitMutWith; +use swc_plugin_element_template::{ElementTemplateAsset, JSXTransformer, JSXTransformerConfig}; +use swc_plugins_shared::transform_mode::TransformMode; + +const BUILTIN_RAW_TEXT_TEMPLATE_ID: &str = "__et_builtin_raw_text__"; + +fn transform_to_templates(input: &str, cfg: JSXTransformerConfig) -> Vec { + let (templates, _) = transform_fixture(input, cfg); + templates +} + +fn transform_fixture( + input: &str, + cfg: JSXTransformerConfig, +) -> (Vec, String) { + GLOBALS.set(&Globals::new(), || { + let cm: Arc = Arc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), input.to_string()); + let comments = SingleThreadedComments::default(); + + let lexer = Lexer::new( + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + Default::default(), + StringInput::from(&*fm), + Some(&comments), + ); + + let mut parser = Parser::new_from(lexer); + let mut module = parser.parse_module().expect("Failed to parse module"); + let element_templates = Rc::new(RefCell::new(vec![])); + + let mut transformer = JSXTransformer::new_with_element_templates( + cfg, + Some(comments), + TransformMode::Test, + None, + Some(element_templates.clone()), + ); + + module.visit_mut_with(&mut transformer); + + let mut sink = vec![]; + let mut emitter = Emitter { + cfg: swc_core::ecma::codegen::Config::default(), + cm: cm.clone(), + comments: None, + wr: JsWriter::new(cm.clone(), "\n", &mut sink, None), + }; + emitter.emit_module(&module).expect("Failed to emit module"); + + let templates = element_templates.borrow_mut().drain(..).collect(); + ( + templates, + String::from_utf8(sink).expect("transform output should be valid utf8"), + ) + }) +} + +fn element_template_config() -> JSXTransformerConfig { + JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + } +} + +fn dynamic_component_element_template_config() -> JSXTransformerConfig { + JSXTransformerConfig { + is_dynamic_component: Some(true), + ..element_template_config() + } +} + +fn first_user_template_json(input: &str) -> Value { + first_user_template_json_with_cfg(input, element_template_config()) +} + +fn first_user_template_json_with_cfg(input: &str, cfg: JSXTransformerConfig) -> Value { + let templates = transform_to_templates(input, cfg); + + templates + .into_iter() + .find(|template| template.template_id != BUILTIN_RAW_TEXT_TEMPLATE_ID) + .map(|template| { + serde_json::to_value(template.compiled_template).expect("compiled template to json") + }) + .expect("should collect a user template") +} + +fn first_user_template_json_with_code(input: &str, cfg: JSXTransformerConfig) -> (String, Value) { + let (templates, code) = transform_fixture(input, cfg); + let template = templates + .into_iter() + .find(|template| template.template_id != BUILTIN_RAW_TEXT_TEMPLATE_ID) + .map(|template| { + serde_json::to_value(template.compiled_template).expect("compiled template to json") + }) + .expect("should collect a user template"); + + (code, template) +} + +fn without_whitespace(value: &str) -> String { + value.chars().filter(|ch| !ch.is_whitespace()).collect() +} + +#[test] +fn should_not_inject_root_css_scope_attrs_for_element_template() { + let (code, template) = first_user_template_json_with_code( + r#" + /** + * @jsxCSSId 100 + */ + + Hello + + "#, + element_template_config(), + ); + assert!( + !code.contains("options={{") && !code.contains("cssId"), + "element template lowering should not emit legacy cssId create metadata, got: {code}" + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert!( + attrs + .iter() + .all(|attr| attr["key"] != "css-id" && attr["key"] != "entry-name"), + "root element should no longer carry css scope attrs once metadata moves to options", + ); + + let children = template["children"].as_array().expect("children array"); + let first_child_attrs = children[0]["attributesArray"] + .as_array() + .expect("child attributesArray"); + assert!( + first_child_attrs + .iter() + .all(|attr| attr["key"] != "css-id" && attr["key"] != "entry-name"), + "nested static elements should not receive css scope attrs", + ); +} + +#[test] +fn should_not_inject_root_entry_name_attr_for_dynamic_component_element_template() { + let (code, template) = first_user_template_json_with_code( + r#" + /** + * @jsxCSSId 100 + */ + + Hello + + "#, + dynamic_component_element_template_config(), + ); + assert!( + !code.contains("entryName") && !code.contains("options={{"), + "dynamic component ET lowering should not emit legacy entry metadata, got: {code}" + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert!( + attrs + .iter() + .all(|attr| attr["key"] != "entry-name" && attr["key"] != "css-id"), + "dynamic component root should no longer encode css scope metadata in attrs", + ); +} + +#[test] +fn should_keep_static_attribute_values_out_of_et_attribute_slots() { + let (code, template) = first_user_template_json_with_code( + r#" + + "#, + element_template_config(), + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + let attr_by_key = |key: &str| { + attrs + .iter() + .find(|attr| attr["key"] == key) + .unwrap_or_else(|| panic!("missing attribute descriptor for {key}: {attrs:?}")) + }; + + assert_eq!(attr_by_key("disabled")["binding"], "static"); + assert_eq!(attr_by_key("disabled")["value"].as_bool(), Some(true)); + assert_eq!(attr_by_key("id")["binding"], "static"); + assert_eq!(attr_by_key("id")["value"].as_f64(), Some(1.0)); + assert_eq!(attr_by_key("data-count")["binding"], "static"); + assert_eq!(attr_by_key("data-count")["value"].as_f64(), Some(2.0)); + assert_eq!(attr_by_key("data-overflow")["binding"], "static"); + assert_eq!(attr_by_key("data-overflow")["value"], Value::Null); + assert_eq!(attr_by_key("class")["binding"], "slot"); + assert_eq!(attr_by_key("class")["attrSlotIndex"].as_f64(), Some(0.0)); + let code = without_whitespace(&code); + assert!( + code.contains("attributeSlots={[cls]}"), + "only dynamic class should be transported as an ET attribute slot, got: {code}" + ); +} + +#[test] +fn should_not_consume_hidden_et_slots_for_list_item_platform_attrs() { + let (code, template) = first_user_template_json_with_code( + r#" + + Hello + + "#, + element_template_config(), + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + let item_key = attrs + .iter() + .find(|attr| attr["key"] == "item-key") + .expect("item-key descriptor"); + let recyclable = attrs + .iter() + .find(|attr| attr["key"] == "recyclable") + .expect("recyclable descriptor"); + + assert_eq!(item_key["binding"], "slot"); + assert_eq!(item_key["attrSlotIndex"].as_f64(), Some(0.0)); + assert_eq!(recyclable["binding"], "static"); + assert_eq!(recyclable["value"].as_bool(), Some(true)); + let code = without_whitespace(&code); + assert!( + code.contains("attributeSlots={[itemKey]}"), + "legacy list platform info must not consume ET attribute slots, got: {code}" + ); +} + +#[test] +fn should_keep_slot_descriptor_order_for_dynamic_attr_spread_event_and_ref() { + let template = first_user_template_json( + r#" + + "#, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert_eq!(attrs.len(), 4); + + assert_eq!(attrs[0]["kind"], "attribute"); + assert_eq!(attrs[0]["key"], "id"); + assert_eq!(attrs[0]["binding"], "slot"); + assert_eq!(attrs[0]["attrSlotIndex"].as_f64(), Some(0.0)); + + assert_eq!(attrs[1]["kind"], "spread"); + assert_eq!(attrs[1]["binding"], "slot"); + assert_eq!(attrs[1]["attrSlotIndex"].as_f64(), Some(1.0)); + + assert_eq!(attrs[2]["kind"], "attribute"); + assert_eq!(attrs[2]["key"], "bindtap"); + assert_eq!(attrs[2]["binding"], "slot"); + assert_eq!(attrs[2]["attrSlotIndex"].as_f64(), Some(2.0)); + + assert_eq!(attrs[3]["kind"], "attribute"); + assert_eq!(attrs[3]["key"], "ref"); + assert_eq!(attrs[3]["binding"], "slot"); + assert_eq!(attrs[3]["attrSlotIndex"].as_f64(), Some(3.0)); +} + +#[test] +fn should_keep_worklet_attr_descriptor_keys_for_namespaced_attrs() { + let template = first_user_template_json( + r#" + + "#, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert_eq!(attrs.len(), 2); + + assert_eq!(attrs[0]["kind"], "attribute"); + assert_eq!(attrs[0]["key"], "main-thread:bindtap"); + assert_eq!(attrs[0]["binding"], "slot"); + assert_eq!(attrs[0]["attrSlotIndex"].as_f64(), Some(0.0)); + + assert_eq!(attrs[1]["kind"], "attribute"); + assert_eq!(attrs[1]["key"], "main-thread:ref"); + assert_eq!(attrs[1]["binding"], "slot"); + assert_eq!(attrs[1]["attrSlotIndex"].as_f64(), Some(1.0)); +} + +#[test] +fn should_keep_element_slot_indices_stable_for_mixed_dynamic_children() { + let template = first_user_template_json( + r#" + + static + {first} + + {second} + + "#, + ); + + let children = template["children"].as_array().expect("children array"); + assert_eq!(children[0]["kind"], "element"); + assert_eq!(children[0]["type"], "text"); + assert_eq!(children[1]["kind"], "elementSlot"); + assert_eq!(children[1]["elementSlotIndex"].as_f64(), Some(0.0)); + assert_eq!(children[1]["type"], "slot"); + assert_eq!(children[2]["kind"], "element"); + assert_eq!(children[2]["type"], "image"); + assert_eq!(children[3]["kind"], "elementSlot"); + assert_eq!(children[3]["elementSlotIndex"].as_f64(), Some(1.0)); + assert_eq!(children[3]["type"], "slot"); +} + +#[test] +fn should_preserve_user_wrapper_elements_as_template_nodes() { + let template = first_user_template_json( + r#" + + + content + + {dynamicChild} + + "#, + ); + + let children = template["children"].as_array().expect("children array"); + assert_eq!(children[0]["kind"], "element"); + assert_eq!(children[0]["type"], "wrapper"); + + let wrapper_attrs = children[0]["attributesArray"] + .as_array() + .expect("wrapper attributesArray"); + assert_eq!(wrapper_attrs[0]["key"], "id"); + assert_eq!(wrapper_attrs[0]["binding"], "static"); + assert_eq!(wrapper_attrs[0]["value"], "user-wrapper"); + + assert_eq!(children[1]["kind"], "elementSlot"); + assert_eq!(children[1]["elementSlotIndex"].as_f64(), Some(0.0)); +} diff --git a/packages/react/transform/index.d.ts b/packages/react/transform/index.d.ts index c2443a7c52..a3b2a7b1f1 100644 --- a/packages/react/transform/index.d.ts +++ b/packages/react/transform/index.d.ts @@ -49,7 +49,8 @@ export interface UiSourceMapRecord { filename: string lineNumber: number columnNumber: number - snapshotId: string + snapshotId?: string + templateId?: string } export interface DarkModeConfig { /** @@ -599,6 +600,23 @@ export interface JsxTransformerConfig { /** @internal */ isDynamicComponent?: boolean } +/** @internal */ +export interface ElementTemplateConfig { + /** @internal */ + preserveJsx: boolean + /** @internal */ + runtimePkg: string + /** @internal */ + jsxImportSource?: string + /** @internal */ + filename: string + /** @internal */ + target: 'LEPUS' | 'JS' | 'MIXED' + /** @internal */ + enableUiSourceMap?: boolean + /** @internal */ + isDynamicComponent?: boolean +} export interface WorkletVisitorConfig { /** * @public @@ -635,6 +653,7 @@ export interface TransformNodiffOptions { isModule?: boolean | 'unknown' cssScope: boolean | CssScopeVisitorConfig snapshot?: boolean | JsxTransformerConfig + elementTemplate?: boolean | ElementTemplateConfig engineVersion?: string shake: boolean | ShakeVisitorConfig compat: boolean | CompatVisitorConfig @@ -653,6 +672,17 @@ export interface TransformNodiffOutput { errors: Array warnings: Array uiSourceMapRecords: Array + /** @internal */ + elementTemplates?: Array +} +/** @internal */ +export interface ElementTemplateAsset { + /** @internal */ + templateId: string + /** @internal */ + compiledTemplate: Record + /** @internal */ + sourceFile: string } export function transformReactLynxSync(code: string, options?: TransformNodiffOptions | undefined | null): TransformNodiffOutput export function transformReactLynx(code: string, options?: TransformNodiffOptions | undefined | null): Promise diff --git a/packages/react/transform/src/lib.rs b/packages/react/transform/src/lib.rs index 6bd3a2cbdc..0a5f34d6e6 100644 --- a/packages/react/transform/src/lib.rs +++ b/packages/react/transform/src/lib.rs @@ -55,12 +55,18 @@ use swc_plugin_css_scope::napi::{CSSScopeVisitor, CSSScopeVisitorConfig}; use swc_plugin_define_dce::napi::DefineDCEVisitorConfig; use swc_plugin_directive_dce::napi::{DirectiveDCEVisitor, DirectiveDCEVisitorConfig}; use swc_plugin_dynamic_import::napi::{DynamicImportVisitor, DynamicImportVisitorConfig}; +use swc_plugin_element_template::{ + napi::{ElementTemplateAsset, ElementTemplateTransformer, ElementTemplateTransformerConfig}, + ElementTemplateUISourceMapRecord as ElementTemplateCoreUISourceMapRecord, +}; use swc_plugin_inject::napi::{InjectVisitor, InjectVisitorConfig}; use swc_plugin_refresh::{RefreshVisitor, RefreshVisitorConfig}; use swc_plugin_shake::napi::{ShakeVisitor, ShakeVisitorConfig}; use swc_plugin_snapshot::{ - napi::{JSXTransformer, JSXTransformerConfig, UISourceMapRecord as SnapshotUISourceMapRecord}, - UISourceMapRecord as CoreUISourceMapRecord, + napi::{ + JSXTransformer as SnapshotJSXTransformer, JSXTransformerConfig as SnapshotJSXTransformerConfig, + }, + UISourceMapRecord as SnapshotCoreUISourceMapRecord, }; use swc_plugin_worklet::napi::{WorkletVisitor, WorkletVisitorConfig}; use swc_plugins_shared::{ @@ -200,7 +206,9 @@ pub struct TransformNodiffOptions { #[napi(ts_type = "boolean | 'unknown'")] pub is_module: Option, pub css_scope: Either, - pub snapshot: Option>, + pub snapshot: Option>, + #[napi(js_name = "elementTemplate")] + pub element_template: Option>, pub engine_version: Option, pub shake: Either, pub compat: Either, @@ -230,6 +238,7 @@ impl Default for TransformNodiffOptions { is_module: Default::default(), css_scope: Either::B(Default::default()), snapshot: Default::default(), + element_template: Default::default(), engine_version: None, shake: Either::A(false), compat: Either::A(false), @@ -244,6 +253,18 @@ impl Default for TransformNodiffOptions { } } +#[napi(object)] +pub struct UiSourceMapRecord { + pub ui_source_map: i32, + pub filename: String, + pub line_number: u32, + pub column_number: u32, + #[napi(js_name = "snapshotId")] + pub snapshot_id: Option, + #[napi(js_name = "templateId")] + pub template_id: Option, +} + #[napi(object)] pub struct TransformNodiffOutput { pub code: String, @@ -253,7 +274,10 @@ pub struct TransformNodiffOutput { pub errors: Vec, // #[napi(ts_type = "Array")] pub warnings: Vec, - pub ui_source_map_records: Vec, + pub ui_source_map_records: Vec, + /// @internal + #[napi(js_name = "elementTemplates")] + pub element_templates: Option>, } /// A multi emitter that forwards to multiple emitters. @@ -275,20 +299,40 @@ impl Emitter for MultiEmitter { } } -fn clone_ui_source_map_records( - ui_source_map_records: &Rc>>, +fn clone_snapshot_ui_source_map_records( + ui_source_map_records: &Rc>>, filename: &str, -) -> Vec { +) -> Vec { ui_source_map_records .borrow() .iter() .cloned() - .map(|record| SnapshotUISourceMapRecord { + .map(|record| UiSourceMapRecord { ui_source_map: record.ui_source_map, filename: filename.to_string(), line_number: record.line_number, column_number: record.column_number, - snapshot_id: record.snapshot_id, + snapshot_id: Some(record.snapshot_id), + template_id: None, + }) + .collect() +} + +fn clone_element_template_ui_source_map_records( + ui_source_map_records: &Rc>>, + filename: &str, +) -> Vec { + ui_source_map_records + .borrow() + .iter() + .cloned() + .map(|record| UiSourceMapRecord { + ui_source_map: record.ui_source_map, + filename: filename.to_string(), + line_number: record.line_number, + column_number: record.column_number, + snapshot_id: None, + template_id: Some(record.template_id), }) .collect() } @@ -318,8 +362,11 @@ fn transform_react_lynx_inner( let emitter = Box::new(MultiEmitter::new(vec![esbuild_emitter])); let handler = Handler::with_emitter(true, false, emitter); - let ui_source_map_records: Rc>> = + let snapshot_ui_source_map_records: Rc>> = Rc::new(RefCell::new(vec![])); + let element_template_ui_source_map_records: Rc< + RefCell>, + > = Rc::new(RefCell::new(vec![])); let result = GLOBALS.set(&Default::default(), || { let program = c.parse_js( @@ -338,10 +385,8 @@ fn transform_react_lynx_inner( map: None, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), - ui_source_map_records: clone_ui_source_map_records( - &ui_source_map_records, - &options.filename, - ), + ui_source_map_records: vec![], + element_templates: None, }; } }; @@ -415,15 +460,62 @@ fn transform_react_lynx_inner( ), }; - let (snapshot_plugin_config, enabled) = match &options.snapshot.unwrap_or(Either::A(true)) { - Either::A(config) => ( - JSXTransformerConfig { + let (snapshot_plugin_config, snapshot_enabled) = match options.snapshot.as_ref() { + Some(Either::A(config)) => ( + SnapshotJSXTransformerConfig { filename: options.filename.clone(), ..Default::default() }, *config, ), - Either::B(config) => (config.clone(), true), + Some(Either::B(config)) => (config.clone(), true), + None => ( + SnapshotJSXTransformerConfig { + filename: options.filename.clone(), + ..Default::default() + }, + true, + ), + }; + let (element_template_plugin_config, element_template_enabled) = + match options.element_template.as_ref() { + Some(Either::A(config)) => ( + ElementTemplateTransformerConfig { + filename: options.filename.clone(), + ..Default::default() + }, + *config, + ), + Some(Either::B(config)) => (config.clone(), true), + None => ( + ElementTemplateTransformerConfig { + filename: options.filename.clone(), + ..Default::default() + }, + false, + ), + }; + // `elementTemplate` chooses the ET backend directly. Once it is enabled, the + // public transform entrypoint no longer consults Snapshot-specific ET flags. + let use_element_template_plugin = element_template_enabled; + let use_snapshot_plugin = snapshot_enabled && !use_element_template_plugin; + let jsx_backend_enabled = use_snapshot_plugin || use_element_template_plugin; + let active_jsx_import_source = if use_element_template_plugin { + element_template_plugin_config.jsx_import_source.clone() + } else { + snapshot_plugin_config.jsx_import_source.clone() + }; + let preserve_jsx = if use_element_template_plugin { + element_template_plugin_config.preserve_jsx + } else { + snapshot_plugin_config.preserve_jsx + }; + let enable_ui_source_map = if use_element_template_plugin { + element_template_plugin_config + .enable_ui_source_map + .unwrap_or(false) + } else { + snapshot_plugin_config.enable_ui_source_map.unwrap_or(false) }; let react_transformer = Optional::new( @@ -433,10 +525,7 @@ fn transform_react_lynx_inner( react::Options { next: Some(false), runtime: Some(react::Runtime::Automatic), - import_source: snapshot_plugin_config - .jsx_import_source - .clone() - .map(Atom::from), + import_source: active_jsx_import_source.map(Atom::from), pragma: None, pragma_frag: None, // We may want `main-thread:foo={fooMainThreadFunc}` to work @@ -448,39 +537,78 @@ fn transform_react_lynx_inner( top_level_mark, unresolved_mark, ), - enabled && !snapshot_plugin_config.preserve_jsx, + jsx_backend_enabled && !preserve_jsx, ); - let enable_ui_source_map = snapshot_plugin_config.enable_ui_source_map.unwrap_or(false); + let snapshot_plugin = if use_snapshot_plugin { + let transformer = SnapshotJSXTransformer::new( + snapshot_plugin_config.clone(), + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + ) + .with_content_hash(content_hash.clone()); - let snapshot_plugin = Optional::new( - visit_mut_pass({ - let snapshot_plugin = JSXTransformer::new( + let transformer = if enable_ui_source_map { + transformer.with_ui_source_map_records(snapshot_ui_source_map_records.clone()) + } else { + transformer + }; + + Optional::new(visit_mut_pass(transformer), true) + } else { + Optional::new( + visit_mut_pass(SnapshotJSXTransformer::new( snapshot_plugin_config.clone(), Some(&comments), options.mode.unwrap_or(TransformMode::Production), Some(cm.clone()), - ) - .with_content_hash(content_hash.clone()); + )), + false, + ) + }; + let element_templates_collector = + use_element_template_plugin.then(|| Rc::new(RefCell::new(vec![]))); + let element_template_plugin = if use_element_template_plugin { + // ET template assets are a build artifact, not runtime JS. Keep one + // collector per transform invocation so the output contract stays stable. + let transformer = ElementTemplateTransformer::new_with_element_templates( + element_template_plugin_config.clone(), + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + element_templates_collector.clone(), + ) + .with_content_hash(content_hash.clone()); - if enable_ui_source_map { - snapshot_plugin.with_ui_source_map_records(ui_source_map_records.clone()) - } else { - snapshot_plugin - } - }), - enabled, - ); + let transformer = if enable_ui_source_map { + transformer.with_ui_source_map_records(element_template_ui_source_map_records.clone()) + } else { + transformer + }; + + Optional::new(visit_mut_pass(transformer), true) + } else { + Optional::new( + visit_mut_pass(ElementTemplateTransformer::new( + element_template_plugin_config.clone(), + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + )), + false, + ) + }; let list_plugin = Optional::new( visit_mut_pass(swc_plugin_list::ListVisitor::new(Some(&comments))), - enabled, + jsx_backend_enabled, ); let is_ge_3_1: bool = is_engine_version_ge(&options.engine_version, "3.1"); let text_plugin = Optional::new( visit_mut_pass(swc_plugin_text::TextVisitor {}), - enabled && is_ge_3_1, + jsx_backend_enabled && is_ge_3_1, ); let shake_plugin = match options.shake.clone() { @@ -607,7 +735,12 @@ fn transform_react_lynx_inner( compat_plugin, worklet_plugin, css_scope_plugin, - (text_plugin, list_plugin, snapshot_plugin), + ( + text_plugin, + list_plugin, + snapshot_plugin, + element_template_plugin, + ), directive_dce_plugin, define_dce_plugin, simplify_pass_1, // do simplify after DCE above to make shake below works better @@ -664,26 +797,54 @@ fn transform_react_lynx_inner( ); match result { - Ok(result) => TransformNodiffOutput { - code: result.code, - map: result.map, - errors: vec![], - warnings: vec![], - ui_source_map_records: clone_ui_source_map_records( - &ui_source_map_records, - &options.filename, - ), - }, + Ok(result) => { + // Drain after the whole SWC pass finishes: dynamic-component transforms + // can discover multiple template assets while walking one module, and + // the caller expects one stable array per transform invocation. + let element_templates = element_templates_collector.and_then(|collector| { + let templates: Vec = collector + .borrow_mut() + .drain(..) + .map(|template| template.into()) + .collect(); + if templates.is_empty() { + None + } else { + Some(templates) + } + }); + + TransformNodiffOutput { + code: result.code, + map: result.map, + errors: vec![], + warnings: vec![], + ui_source_map_records: if use_element_template_plugin { + clone_element_template_ui_source_map_records( + &element_template_ui_source_map_records, + &options.filename, + ) + } else { + clone_snapshot_ui_source_map_records(&snapshot_ui_source_map_records, &options.filename) + }, + element_templates, + } + } Err(_) => { return TransformNodiffOutput { code: "".into(), map: None, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), - ui_source_map_records: clone_ui_source_map_records( - &ui_source_map_records, - &options.filename, - ), + ui_source_map_records: if use_element_template_plugin { + clone_element_template_ui_source_map_records( + &element_template_ui_source_map_records, + &options.filename, + ) + } else { + clone_snapshot_ui_source_map_records(&snapshot_ui_source_map_records, &options.filename) + }, + element_templates: None, }; } } @@ -694,7 +855,10 @@ fn transform_react_lynx_inner( map: result.map, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), - ui_source_map_records: clone_ui_source_map_records(&ui_source_map_records, &options.filename), + ui_source_map_records: result.ui_source_map_records, + // Preserve the element-template assets collected in the successful transform + // path instead of dropping them in the final wrapper object. + element_templates: result.element_templates, }; r diff --git a/packages/react/transform/swc-plugin-reactlynx/Cargo.toml b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml index 0de6645361..ce432ab6bc 100644 --- a/packages/react/transform/swc-plugin-reactlynx/Cargo.toml +++ b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml @@ -16,6 +16,7 @@ swc_plugin_css_scope = { path = "../crates/swc_plugin_css_scope" } swc_plugin_define_dce = { path = "../crates/swc_plugin_define_dce" } swc_plugin_directive_dce = { path = "../crates/swc_plugin_directive_dce" } swc_plugin_dynamic_import = { path = "../crates/swc_plugin_dynamic_import" } +swc_plugin_element_template = { path = "../crates/swc_plugin_element_template" } swc_plugin_inject = { path = "../crates/swc_plugin_inject" } swc_plugin_list = { path = "../crates/swc_plugin_list" } swc_plugin_shake = { path = "../crates/swc_plugin_shake" } diff --git a/packages/react/transform/swc-plugin-reactlynx/index.d.ts b/packages/react/transform/swc-plugin-reactlynx/index.d.ts index 96a72c9d12..cff2b40d60 100644 --- a/packages/react/transform/swc-plugin-reactlynx/index.d.ts +++ b/packages/react/transform/swc-plugin-reactlynx/index.d.ts @@ -27,6 +27,24 @@ export interface JsxTransformerConfig { isDynamicComponent?: boolean; } +/** @internal */ +export interface ElementTemplateConfig { + /** @internal */ + preserveJsx: boolean; + /** @internal */ + runtimePkg: string; + /** @internal */ + jsxImportSource?: string; + /** @internal */ + filename: string; + /** @internal */ + target: 'LEPUS' | 'JS' | 'MIXED'; + /** @internal */ + enableUiSourceMap?: boolean; + /** @internal */ + isDynamicComponent?: boolean; +} + /** * {@inheritdoc PluginReactLynxOptions.shake} * @public @@ -238,6 +256,7 @@ export interface ReactLynxTransformOptions { filename?: string; cssScope: boolean | CssScopeVisitorConfig; snapshot?: boolean | JsxTransformerConfig; + elementTemplate?: boolean | ElementTemplateConfig; engineVersion?: string; shake: boolean | ShakeVisitorConfig; defineDCE: boolean | DefineDceVisitorConfig; diff --git a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs index 4bd6e5c9b9..53816fc961 100644 --- a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs +++ b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs @@ -1,3 +1,5 @@ +use std::{cell::RefCell, rc::Rc}; + use rustc_hash::FxBuildHasher; use serde::Deserialize; use swc_core::{ @@ -24,10 +26,13 @@ use swc_plugin_css_scope::{CSSScopeVisitor, CSSScopeVisitorConfig}; use swc_plugin_define_dce::DefineDCEVisitorConfig; use swc_plugin_directive_dce::{DirectiveDCEVisitor, DirectiveDCEVisitorConfig}; use swc_plugin_dynamic_import::{DynamicImportVisitor, DynamicImportVisitorConfig}; +use swc_plugin_element_template::{ElementTemplateTransformer, ElementTemplateTransformerConfig}; use swc_plugin_inject::{InjectVisitor, InjectVisitorConfig}; use swc_plugin_list::ListVisitor; use swc_plugin_shake::{ShakeVisitor, ShakeVisitorConfig}; -use swc_plugin_snapshot::{JSXTransformer, JSXTransformerConfig}; +use swc_plugin_snapshot::{ + JSXTransformer as SnapshotJSXTransformer, JSXTransformerConfig as SnapshotJSXTransformerConfig, +}; use swc_plugin_text::TextVisitor; use swc_plugin_worklet::{WorkletVisitor, WorkletVisitorConfig}; use swc_plugins_shared::{ @@ -53,7 +58,8 @@ pub struct ReactLynxTransformOptions { pub css_scope: Either, - pub snapshot: Option>, + pub snapshot: Option>, + pub element_template: Option>, pub engine_version: Option, @@ -80,6 +86,7 @@ impl Default for ReactLynxTransformOptions { mode: Some(TransformMode::Production), css_scope: Either::B(Default::default()), snapshot: Default::default(), + element_template: Default::default(), engine_version: None, shake: Either::A(false), define_dce: Either::A(false), @@ -187,9 +194,9 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad ), }; - let (snapshot_plugin_config, enabled) = match &options.snapshot.unwrap_or(Either::A(true)) { - Either::A(config) => ( - JSXTransformerConfig { + let (snapshot_plugin_config, snapshot_enabled) = match options.snapshot.as_ref() { + Some(Either::A(config)) => ( + SnapshotJSXTransformerConfig { // TODO: Environment-specific filename handling // - Test environment: use `options.filename` // - Production environment: use filename from metadata @@ -197,17 +204,45 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad // 1. snapshot_plugin // 2. css_scope_plugin // 3. worklet_plugin - filename: options.filename.unwrap_or(filename), + filename: options.filename.clone().unwrap_or(filename.clone()), ..Default::default() }, *config, ), - Either::B(config) => (config.clone(), true), + Some(Either::B(config)) => (config.clone(), true), + None => ( + SnapshotJSXTransformerConfig { + filename: options.filename.clone().unwrap_or(filename.clone()), + ..Default::default() + }, + true, + ), }; + let (element_template_plugin_config, element_template_enabled) = + match options.element_template.as_ref() { + Some(Either::A(config)) => ( + ElementTemplateTransformerConfig { + filename: options.filename.clone().unwrap_or(filename.clone()), + ..Default::default() + }, + *config, + ), + Some(Either::B(config)) => (config.clone(), true), + None => ( + ElementTemplateTransformerConfig { + filename: options.filename.clone().unwrap_or(filename.clone()), + ..Default::default() + }, + false, + ), + }; + let use_element_template_plugin = element_template_enabled; + let use_snapshot_plugin = snapshot_enabled && !use_element_template_plugin; + let jsx_backend_enabled = use_snapshot_plugin || use_element_template_plugin; let snapshot_plugin = Optional::new( visit_mut_pass( - JSXTransformer::new( + SnapshotJSXTransformer::new( snapshot_plugin_config, Some(&comments), options.mode.unwrap_or(TransformMode::Production), @@ -215,13 +250,32 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad ) .with_content_hash(content_hash.clone()), ), - enabled, + use_snapshot_plugin, + ); + let element_template_plugin = Optional::new( + visit_mut_pass( + ElementTemplateTransformer::new_with_element_templates( + element_template_plugin_config, + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + Some(Rc::new(RefCell::new(vec![]))), + ) + .with_content_hash(content_hash.clone()), + ), + use_element_template_plugin, ); - let list_plugin = Optional::new(visit_mut_pass(ListVisitor::new(Some(&comments))), enabled); + let list_plugin = Optional::new( + visit_mut_pass(ListVisitor::new(Some(&comments))), + jsx_backend_enabled, + ); let is_ge_3_1: bool = is_engine_version_ge(&options.engine_version, "3.1"); - let text_plugin = Optional::new(visit_mut_pass(TextVisitor {}), enabled && is_ge_3_1); + let text_plugin = Optional::new( + visit_mut_pass(TextVisitor {}), + jsx_backend_enabled && is_ge_3_1, + ); let shake_plugin = match options.shake.clone() { Either::A(config) => Optional::new(visit_mut_pass(ShakeVisitor::default()), config), @@ -292,7 +346,12 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad dynamic_import_plugin, worklet_plugin, css_scope_plugin, - (text_plugin, list_plugin, snapshot_plugin), + ( + text_plugin, + list_plugin, + snapshot_plugin, + element_template_plugin, + ), directive_dce_plugin, define_dce_plugin, simplify_pass_1, // do simplify after DCE above to make shake below works better @@ -335,6 +394,7 @@ mod tests { assert_eq!(options.mode, Some(TransformMode::Production)); assert_eq!(options.css_scope, Either::A(true)); assert_eq!(options.snapshot, None); + assert_eq!(options.element_template, None); assert_eq!(options.shake, Either::A(true)); assert_eq!(options.define_dce, Either::A(true)); assert_eq!(options.directive_dce, Either::A(true)); @@ -499,6 +559,37 @@ mod tests { } } + #[test] + fn test_element_template() { + let json_data = r#" + { + "elementTemplate": { + "preserveJsx": true, + "runtimePkg": "@lynx-js/react", + "jsxImportSource": "@lynx-js/react", + "filename": "test.js", + "target": "LEPUS", + "isDynamicComponent": false + } + }"#; + + let options: ReactLynxTransformOptions = serde_json::from_str(json_data).unwrap(); + + if let Some(Either::B(element_template)) = options.element_template { + assert!(element_template.preserve_jsx); + assert_eq!(element_template.runtime_pkg, "@lynx-js/react"); + assert_eq!( + element_template.jsx_import_source, + Some("@lynx-js/react".to_string()) + ); + assert_eq!(element_template.filename, "test.js"); + assert_eq!(element_template.target, TransformTarget::LEPUS); + assert_eq!(element_template.is_dynamic_component, Some(false)); + } else { + panic!("Expected element template config, got boolean or None"); + } + } + #[test] fn test_engine_version() { let json_data = r#" From e9d6cd215a605ee9c763e99a1fc35b8f1b38bc03 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:41:54 +0800 Subject: [PATCH 2/3] fix(et): address transform review comments --- Cargo.lock | 1 - .../swc_plugin_element_template/Cargo.toml | 2 +- .../swc_plugin_element_template/asset.rs | 3 +- .../swc_plugin_element_template/attr_name.rs | 11 ++-- .../swc_plugin_element_template/extractor.rs | 12 ++-- .../crates/swc_plugin_element_template/lib.rs | 8 ++- .../swc_plugin_element_template/napi.rs | 4 ++ .../template_definition.rs | 2 +- .../tests/element_template.rs | 28 +++++++-- .../tests/element_template_contract.rs | 62 ++++++++++++++++++- .../transform/swc-plugin-reactlynx/src/lib.rs | 4 +- 11 files changed, 110 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d8b26c4af..d675d5299a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2380,7 +2380,6 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_codegen", - "swc_ecma_minifier", "swc_ecma_parser", "swc_ecma_quote_macros", "swc_ecma_transforms_base", diff --git a/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml b/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml index 12500f66c0..6742935608 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml +++ b/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml @@ -19,7 +19,7 @@ regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } sha-1 = { workspace = true } -swc_core = { workspace = true, features = ["base", "ecma_codegen", "ecma_parser", "ecma_minifier", "ecma_transforms_typescript", "ecma_utils", "ecma_quote", "ecma_transforms_react", "ecma_transforms_optimization", "__visit", "__testing_transform"] } +swc_core = { workspace = true, features = ["base", "ecma_codegen", "ecma_parser", "ecma_transforms_typescript", "ecma_utils", "ecma_quote", "ecma_transforms_react", "ecma_transforms_optimization", "ecma_visit", "testing_transform"] } swc_plugins_shared = { path = "../swc_plugins_shared" } [lints.rust] diff --git a/packages/react/transform/crates/swc_plugin_element_template/asset.rs b/packages/react/transform/crates/swc_plugin_element_template/asset.rs index 16c62fcf8a..d28142c2b4 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/asset.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/asset.rs @@ -14,6 +14,7 @@ pub struct ElementTemplateAsset { } const BUILTIN_RAW_TEXT_TEMPLATE_ID: &str = "__et_builtin_raw_text__"; +const BUILTIN_SOURCE_FILE: &str = ""; impl JSXTransformer where @@ -35,7 +36,7 @@ where ], "children": [], }), - source_file: self.cfg.filename.clone(), + source_file: BUILTIN_SOURCE_FILE.to_string(), } } diff --git a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs index da40b3c053..2fb81a4d1c 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs @@ -54,16 +54,17 @@ impl From for AttrName { } impl AttrName { - pub fn from_ns(_ns: Ident, name: Ident) -> Self { + pub fn from_ns(ns: Ident, name: Ident) -> Self { + let ns_str = ns.sym.as_ref(); let name_str = name.sym.as_ref().to_string(); - if name_str == "ref" { + if ns_str == "main-thread" && name_str == "ref" { AttrName::WorkletRef - } else if get_event_type_and_name(name_str.as_str()).is_some() { + } else if ns_str == "main-thread" && get_event_type_and_name(name_str.as_str()).is_some() { AttrName::WorkletEvent - } else if name_str == "gesture" { + } else if ns_str == "main-thread" && name_str == "gesture" { AttrName::Gesture } else { - todo!() + AttrName::Attr } } } diff --git a/packages/react/transform/crates/swc_plugin_element_template/extractor.rs b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs index 62353dc80a..82a82e3b24 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/extractor.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs @@ -269,12 +269,11 @@ where JSXAttrName::JSXNamespacedName(JSXNamespacedName { ns, name, .. }) => { let key = template_namespaced_attribute_descriptor_key(ns, name); let attr_name = AttrName::from_ns(ns.clone().into(), name.clone().into()); - match attr_name { - AttrName::WorkletEvent | AttrName::WorkletRef | AttrName::Gesture => { - self.push_dynamic_jsx_attr(attr_name, &key, value, false); - } - _ => todo!(), - } + let preserve_literal_expr = !matches!( + attr_name, + AttrName::WorkletEvent | AttrName::WorkletRef | AttrName::Gesture + ); + self.push_dynamic_jsx_attr(attr_name, &key, value, preserve_literal_expr); } } } @@ -425,6 +424,7 @@ where JSXAttrOrSpread::SpreadElement(_) => true, JSXAttrOrSpread::JSXAttr(JSXAttr { name, value, .. }) => match name { JSXAttrName::Ident(ident_name) => match ident_name.sym.as_ref() { + "__lynx_part_id" => false, "key" => { if !self.parent_element { self.key = value.take(); diff --git a/packages/react/transform/crates/swc_plugin_element_template/lib.rs b/packages/react/transform/crates/swc_plugin_element_template/lib.rs index 5d7e03825e..cb2bf769d1 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/lib.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/lib.rs @@ -189,7 +189,13 @@ where let val = words.next(); if let Some("@jsxCSSId") = pragma { if let Some(css_id) = val { - css_id.parse::().expect("should have numeric cssId"); + if css_id.parse::().is_err() { + HANDLER.with(|handler| { + handler + .struct_span_err(span, &format!("@jsxCSSId must be numeric, got `{css_id}`")) + .emit() + }); + } } } } diff --git a/packages/react/transform/crates/swc_plugin_element_template/napi.rs b/packages/react/transform/crates/swc_plugin_element_template/napi.rs index 0ffa7c6f38..a55677c5d0 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/napi.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/napi.rs @@ -181,6 +181,10 @@ where mut self, ui_source_map_records: Rc>>, ) -> Self { + debug_assert!( + self.inner.ui_source_map_records.borrow().is_empty(), + "ElementTemplateTransformer::with_ui_source_map_records must be called before records are captured" + ); self.inner.ui_source_map_records = ui_source_map_records.clone(); self.ui_source_map_records = ui_source_map_records; self diff --git a/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs index ae0c6cb95d..4609ab435c 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs @@ -299,7 +299,7 @@ where key: slot_key, slot_index, } => { - debug_assert_eq!( + assert_eq!( slot_key, &key, "Template Definition attr slot key must match extractor output" ); diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs index c4da292e85..d234135c97 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs @@ -508,6 +508,7 @@ fn transform_to_code_templates_and_diagnostics( }), ); + let comments = SingleThreadedComments::default(); let lexer = Lexer::new( Syntax::Es(EsSyntax { jsx: true, @@ -515,14 +516,13 @@ fn transform_to_code_templates_and_diagnostics( }), Default::default(), StringInput::from(&*fm), - None, + Some(&comments), ); let mut parser = Parser::new_from(lexer); let module_result = parser.parse_module(); let mut module = module_result.expect("Failed to parse module"); - let comments = SingleThreadedComments::default(); let element_templates = Rc::new(RefCell::new(vec![])); let mut transformer = JSXTransformer::new_with_element_templates( @@ -591,10 +591,6 @@ fn verify_code_and_template_json_with_config( snapshot_name: &str, cfg: JSXTransformerConfig, ) -> (String, Vec) { - if std::env::var("UPDATE").as_deref() == Ok("1") { - std::env::set_var("INSTA_UPDATE", "always"); - } - let (code, templates) = transform_to_code_and_templates(input, cfg); assert!(!templates.is_empty(), "Should collect element templates"); @@ -682,3 +678,23 @@ fn should_report_page_element_as_unsupported() { "expected unsupported diagnostic, got: {diagnostics:?}" ); } + +#[test] +fn should_report_invalid_jsx_css_id_as_diagnostic() { + let (_, _, diagnostics) = transform_to_code_templates_and_diagnostics( + r#" + /** + * @jsxCSSId abc + */ + Invalid Css Id + "#, + element_template_config(), + ); + + assert!( + diagnostics + .iter() + .any(|message| message == "@jsxCSSId must be numeric, got `abc`"), + "expected invalid @jsxCSSId diagnostic, got: {diagnostics:?}" + ); +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs index ca9b26253c..8f51d280a7 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs @@ -2,7 +2,11 @@ use serde_json::Value; use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; -use swc_core::common::{comments::SingleThreadedComments, FileName, Globals, SourceMap, GLOBALS}; +use swc_core::common::{ + comments::SingleThreadedComments, + errors::{DiagnosticBuilder, Emitter as DiagnosticEmitter, Handler, HANDLER}, + FileName, Globals, SourceMap, GLOBALS, +}; use swc_core::ecma::codegen::{text_writer::JsWriter, Emitter}; use swc_core::ecma::parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax}; use swc_core::ecma::visit::VisitMutWith; @@ -20,10 +24,19 @@ fn transform_fixture( input: &str, cfg: JSXTransformerConfig, ) -> (Vec, String) { + struct DiagnosticCollector; + + impl DiagnosticEmitter for DiagnosticCollector { + fn emit(&mut self, db: &mut DiagnosticBuilder<'_>) { + panic!("unexpected transform diagnostic: {}", db.message()); + } + } + GLOBALS.set(&Globals::new(), || { let cm: Arc = Arc::new(SourceMap::default()); let fm = cm.new_source_file(FileName::Anon.into(), input.to_string()); let comments = SingleThreadedComments::default(); + let handler = Handler::with_emitter(true, false, Box::new(DiagnosticCollector)); let lexer = Lexer::new( Syntax::Es(EsSyntax { @@ -47,7 +60,9 @@ fn transform_fixture( Some(element_templates.clone()), ); - module.visit_mut_with(&mut transformer); + HANDLER.set(&handler, || { + module.visit_mut_with(&mut transformer); + }); let mut sink = vec![]; let mut emitter = Emitter { @@ -310,6 +325,49 @@ fn should_keep_worklet_attr_descriptor_keys_for_namespaced_attrs() { assert_eq!(attrs[1]["attrSlotIndex"].as_f64(), Some(1.0)); } +#[test] +fn should_treat_unknown_namespaced_attrs_as_regular_attrs() { + let template = first_user_template_json( + r#" + + "#, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert_eq!(attrs.len(), 2); + + assert_eq!(attrs[0]["kind"], "attribute"); + assert_eq!(attrs[0]["key"], "custom:flag"); + assert_eq!(attrs[0]["binding"], "slot"); + assert_eq!(attrs[0]["attrSlotIndex"].as_f64(), Some(0.0)); + + assert_eq!(attrs[1]["kind"], "attribute"); + assert_eq!(attrs[1]["key"], "custom:static"); + assert_eq!(attrs[1]["binding"], "static"); + assert_eq!(attrs[1]["value"].as_f64(), Some(1.0)); +} + +#[test] +fn should_skip_lynx_part_id_without_reserving_attr_slot() { + let template = first_user_template_json( + r#" + + "#, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert_eq!(attrs.len(), 1); + + assert_eq!(attrs[0]["kind"], "attribute"); + assert_eq!(attrs[0]["key"], "id"); + assert_eq!(attrs[0]["binding"], "slot"); + assert_eq!(attrs[0]["attrSlotIndex"].as_f64(), Some(0.0)); +} + #[test] fn should_keep_element_slot_indices_stable_for_mixed_dynamic_children() { let template = first_user_template_json( diff --git a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs index 53816fc961..95b83f1f58 100644 --- a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs +++ b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs @@ -1,5 +1,3 @@ -use std::{cell::RefCell, rc::Rc}; - use rustc_hash::FxBuildHasher; use serde::Deserialize; use swc_core::{ @@ -259,7 +257,7 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad Some(&comments), options.mode.unwrap_or(TransformMode::Production), Some(cm.clone()), - Some(Rc::new(RefCell::new(vec![]))), + None, ) .with_content_hash(content_hash.clone()), ), From 82183f1142d81d5cc97e5bdc8aced8c60638aa5d Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:15:28 +0800 Subject: [PATCH 3/3] fix(et): address follow-up review comments --- .../react/transform/__test__/fixture.spec.js | 50 +++++-- .../swc_plugin_element_template/attr_name.rs | 30 ++-- .../swc_plugin_element_template/extractor.rs | 67 +++------ .../crates/swc_plugin_element_template/lib.rs | 93 ++++-------- .../swc_plugin_element_template/napi.rs | 76 +--------- .../template_definition.rs | 49 +++---- .../tests/element_template.rs | 33 ++++- .../tests/element_template_contract.rs | 109 +++++++++++++- packages/react/transform/index.d.ts | 5 +- packages/react/transform/src/lib.rs | 134 ++++++++++-------- .../transform/swc-plugin-reactlynx/index.d.ts | 2 - 11 files changed, 344 insertions(+), 304 deletions(-) diff --git a/packages/react/transform/__test__/fixture.spec.js b/packages/react/transform/__test__/fixture.spec.js index 52b08d5b96..88429190fb 100644 --- a/packages/react/transform/__test__/fixture.spec.js +++ b/packages/react/transform/__test__/fixture.spec.js @@ -196,19 +196,19 @@ describe('element template', () => { expect(template?.sourceFile).toEqual(expect.any(String)); }); - it('should export template ids for element template uiSourceMapRecords', async () => { - const result = await transformReactLynx('const node = ;', { + it('should not bridge legacy snapshot ET flags into the new ET plugin path', async () => { + const result = await transformReactLynx('const node = ;', { mode: 'test', pluginName: '', filename: 'test.js', sourcemap: false, cssScope: false, - elementTemplate: { + snapshot: { preserveJsx: false, runtimePkg: '@lynx-js/react', filename: 'test.js', target: 'LEPUS', - enableUiSourceMap: true, + enableElementTemplate: true, }, jsx: true, directiveDCE: false, @@ -219,27 +219,21 @@ describe('element template', () => { refresh: false, }); - expect(result.uiSourceMapRecords).toHaveLength(1); - expect(result.uiSourceMapRecords[0]).toMatchObject({ - filename: 'test.js', - templateId: expect.stringMatching(/^_et_/), - }); - expect(result.uiSourceMapRecords[0].snapshotId).toBeUndefined(); + expect(result.elementTemplates).toBeUndefined(); }); - it('should not bridge legacy snapshot ET flags into the new ET plugin path', async () => { - const result = await transformReactLynx('const node = ;', { + it('should return an empty template list when element template emits no assets', async () => { + const result = await transformReactLynx('const value = 1;', { mode: 'test', pluginName: '', filename: 'test.js', sourcemap: false, cssScope: false, - snapshot: { + elementTemplate: { preserveJsx: false, runtimePkg: '@lynx-js/react', filename: 'test.js', target: 'LEPUS', - enableElementTemplate: true, }, jsx: true, directiveDCE: false, @@ -250,7 +244,33 @@ describe('element template', () => { refresh: false, }); - expect(result.elementTemplates).toBeUndefined(); + expect(result.elementTemplates).toEqual([]); + }); + + it('should warn when snapshot and element template are both explicitly enabled', async () => { + const result = await transformReactLynx('const node = ;', { + mode: 'test', + pluginName: '', + filename: 'test.js', + sourcemap: false, + cssScope: false, + snapshot: true, + elementTemplate: true, + jsx: true, + directiveDCE: false, + defineDCE: false, + shake: false, + compat: true, + worklet: false, + refresh: false, + }); + + expect(result.elementTemplates).toEqual(expect.any(Array)); + expect(result.warnings).toEqual([ + expect.objectContaining({ + text: expect.stringContaining('elementTemplate'), + }), + ]); }); }); diff --git a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs index 2fb81a4d1c..3c7044c839 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs @@ -1,7 +1,13 @@ +use once_cell::sync::Lazy; use regex::Regex; use swc_core::ecma::ast::*; +static EVENT_KEY_RE: Lazy = Lazy::new(|| { + Regex::new(r"^(global-bind|bind|catch|capture-bind|capture-catch)([A-Za-z]+)$") + .expect("event key regex must compile") +}); + #[derive(Debug, Clone)] pub enum AttrName { Attr, @@ -41,7 +47,7 @@ impl From for AttrName { impl From for AttrName { fn from(name: Str) -> Self { - let name = name.value.to_string_lossy().into_owned(); + let name = name.value.as_str().unwrap_or("").to_string(); Self::from(name) } } @@ -56,22 +62,22 @@ impl From for AttrName { impl AttrName { pub fn from_ns(ns: Ident, name: Ident) -> Self { let ns_str = ns.sym.as_ref(); - let name_str = name.sym.as_ref().to_string(); - if ns_str == "main-thread" && name_str == "ref" { - AttrName::WorkletRef - } else if ns_str == "main-thread" && get_event_type_and_name(name_str.as_str()).is_some() { - AttrName::WorkletEvent - } else if ns_str == "main-thread" && name_str == "gesture" { - AttrName::Gesture - } else { - AttrName::Attr + let name_str = name.sym.as_ref(); + if ns_str != "main-thread" { + return AttrName::Attr; + } + + match name_str { + "ref" => AttrName::WorkletRef, + "gesture" => AttrName::Gesture, + _ if get_event_type_and_name(name_str).is_some() => AttrName::WorkletEvent, + _ => AttrName::Attr, } } } fn get_event_type_and_name(props_key: &str) -> Option<(String, String)> { - let re = Regex::new(r"^(global-bind|bind|catch|capture-bind|capture-catch)([A-Za-z]+)$").unwrap(); - if let Some(captures) = re.captures(props_key) { + if let Some(captures) = EVENT_KEY_RE.captures(props_key) { let event_type = if captures.get(1).unwrap().as_str().contains("capture") { captures.get(1).unwrap().as_str().to_string() } else { diff --git a/packages/react/transform/crates/swc_plugin_element_template/extractor.rs b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs index 82a82e3b24..abe2cef375 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/extractor.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs @@ -2,7 +2,7 @@ use once_cell::sync::Lazy; use std::collections::HashSet; use swc_core::{ - common::{util::take::Take, Span, DUMMY_SP}, + common::{util::take::Take, DUMMY_SP}, ecma::{ ast::{JSXExpr, *}, utils::is_literal, @@ -70,10 +70,17 @@ fn bool_jsx_attr(value: bool) -> JSXAttrValue { }) } -pub(super) struct ElementTemplateExtractor<'a, V, F> +fn is_static_attr_literal(expr: &Expr) -> bool { + match expr { + Expr::Lit(Lit::Str(_)) | Expr::Lit(Lit::Bool(_)) | Expr::Lit(Lit::Null(_)) => true, + Expr::Lit(Lit::Num(n)) => n.value.is_finite(), + _ => false, + } +} + +pub(super) struct ElementTemplateExtractor<'a, V> where V: VisitMut, - F: Fn(Span) -> Expr, { parent_element: bool, dynamic_attrs: Vec, @@ -83,20 +90,13 @@ where pub(super) key: Option, attr_slot_counter: i32, element_slot_counter: i32, - enable_ui_source_map: bool, - node_index_fn: F, } -impl<'a, V, F> ElementTemplateExtractor<'a, V, F> +impl<'a, V> ElementTemplateExtractor<'a, V> where V: VisitMut, - F: Fn(Span) -> Expr, { - pub(super) fn new( - dynamic_part_visitor: &'a mut V, - enable_ui_source_map: bool, - node_index_fn: F, - ) -> Self { + pub(super) fn new(dynamic_part_visitor: &'a mut V) -> Self { Self { parent_element: false, dynamic_attrs: vec![], @@ -106,14 +106,6 @@ where key: None, attr_slot_counter: 0, element_slot_counter: 0, - enable_ui_source_map, - node_index_fn, - } - } - - fn record_node_index(&self, span: Span) { - if self.enable_ui_source_map { - let _ = (self.node_index_fn)(span); } } @@ -191,7 +183,7 @@ where .. })) = value { - if !(preserve_literal_expr && matches!(&**expr, Expr::Lit(_))) { + if !(preserve_literal_expr && is_static_attr_literal(expr)) { self.push_dynamic_attr(*expr.clone(), attr_name, key); } } @@ -300,10 +292,9 @@ where } } -impl VisitMut for ElementTemplateExtractor<'_, V, F> +impl VisitMut for ElementTemplateExtractor<'_, V> where V: VisitMut, - F: Fn(Span) -> Expr, { fn visit_mut_jsx_element_childs(&mut self, n: &mut Vec) { if n.is_empty() { @@ -346,9 +337,8 @@ where slot_index, )); - let mut child = + let child = JSXElementChild::JSXElement(Box::new(slot_placeholder_node(slot_index, false))); - child.visit_mut_with(self); merged_children.push(child); } @@ -367,9 +357,7 @@ where slot_index, )); - let mut child = - JSXElementChild::JSXElement(Box::new(slot_placeholder_node(slot_index, false))); - child.visit_mut_with(self); + let child = JSXElementChild::JSXElement(Box::new(slot_placeholder_node(slot_index, false))); merged_children.push(child); } @@ -395,25 +383,18 @@ where } if !jsx_is_custom(n) { - self.record_node_index(n.span); - if jsx_has_dynamic_key(n) && self.parent_element { - let is_list = jsx_is_list(n); n.visit_mut_with(self.dynamic_part_visitor); let expr = Expr::JSXElement(Box::new(n.take())); let slot_index = self.next_children_slot_index(); - if is_list { - self - .dynamic_children - .push(DynamicElementPart::ListSlot(expr, slot_index)); - } else { - self - .dynamic_children - .push(DynamicElementPart::Slot(expr, slot_index)); - } + self + .dynamic_children + .push(DynamicElementPart::Slot(expr, slot_index)); *n = slot_placeholder_node(slot_index, false); + n.visit_mut_with(self); + return; } self.ensure_flatten_attr(n); @@ -479,9 +460,5 @@ where } } - fn visit_mut_jsx_text(&mut self, n: &mut JSXText) { - if !jsx_text_to_str(&n.value).is_empty() { - self.record_node_index(n.span); - } - } + fn visit_mut_jsx_text(&mut self, _: &mut JSXText) {} } diff --git a/packages/react/transform/crates/swc_plugin_element_template/lib.rs b/packages/react/transform/crates/swc_plugin_element_template/lib.rs index cb2bf769d1..b6044ed91d 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/lib.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/lib.rs @@ -31,26 +31,15 @@ use self::extractor::{ElementTemplateExtractor, ExtractedTemplateParts}; use self::lowering::LoweredRuntimeJsx; use self::template_slot::ET_SLOT_PLACEHOLDER_TAG; -#[derive(Clone, Debug, Deserialize)] -pub struct UISourceMapRecord { - pub ui_source_map: i32, - pub line_number: u32, - pub column_number: u32, - pub template_id: String, -} - pub type ElementTemplateTransformerConfig = JSXTransformerConfig; pub type ElementTemplateTransformer = JSXTransformer; -pub type ElementTemplateUISourceMapRecord = UISourceMapRecord; +type RuntimeIdInitializer = Box Expr>; #[cfg(feature = "napi")] pub mod napi; use swc_plugins_shared::{ - jsx_helpers::jsx_name, - target::TransformTarget, - transform_mode::TransformMode, - utils::{calc_hash, calc_hash_number}, + jsx_helpers::jsx_name, target::TransformTarget, transform_mode::TransformMode, utils::calc_hash, }; pub fn i32_to_expr(i: &i32) -> Expr { @@ -61,6 +50,10 @@ pub fn i32_to_expr(i: &i32) -> Expr { })) } +fn lazy_runtime_id(init: impl FnOnce() -> Expr + 'static) -> Lazy { + Lazy::new(Box::new(init)) +} + /// @internal #[derive(Deserialize, PartialEq, Clone, Debug)] #[serde(rename_all = "camelCase")] @@ -75,10 +68,6 @@ pub struct JSXTransformerConfig { pub filename: String, /// @internal pub target: TransformTarget, - /// @internal - #[serde(default)] - pub enable_ui_source_map: bool, - /// @internal pub is_dynamic_component: Option, } @@ -90,7 +79,6 @@ impl Default for JSXTransformerConfig { jsx_import_source: Some("@lynx-js/react".into()), filename: Default::default(), target: TransformTarget::LEPUS, - enable_ui_source_map: false, is_dynamic_component: Some(false), } } @@ -104,15 +92,13 @@ where cfg: JSXTransformerConfig, filename_hash: String, pub content_hash: String, - runtime_id: Lazy, + runtime_id: Lazy, pub element_templates: Option>>>, template_counter: u32, current_template_defs: Vec, comments: Option, slot_ident: Ident, used_slot: bool, - pub ui_source_map_records: Rc>>, - pub source_map: Option>, } impl JSXTransformer @@ -137,7 +123,7 @@ where cfg: JSXTransformerConfig, comments: Option, mode: TransformMode, - source_map: Option>, + _source_map: Option>, element_templates: Option>>>, ) -> Self { JSXTransformer { @@ -145,11 +131,28 @@ where content_hash: "test".into(), runtime_id: match mode { TransformMode::Development => { - // We should find a way to use `cfg.runtime_pkg` - Lazy::new(|| quote!("require('@lynx-js/react/internal')" as Expr)) + let runtime_pkg = format!("{}/internal", cfg.runtime_pkg); + lazy_runtime_id(move || { + Expr::Call(CallExpr { + ctxt: SyntaxContext::default(), + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident( + IdentName::new("require".into(), DUMMY_SP).into(), + ))), + args: vec![ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: runtime_pkg.into(), + raw: None, + }))), + }], + type_args: None, + }) + }) } TransformMode::Production | TransformMode::Test => { - Lazy::new(|| Expr::Ident(private_ident!("ReactLynx"))) + lazy_runtime_id(|| Expr::Ident(private_ident!("ReactLynx"))) } }, element_templates, @@ -159,8 +162,6 @@ where comments, slot_ident: private_ident!("__etSlot"), used_slot: false, - ui_source_map_records: Rc::new(RefCell::new(vec![])), - source_map, } } @@ -227,6 +228,7 @@ where ) .emit() }); + return; } } } @@ -248,46 +250,13 @@ where let target = self.cfg.target; let runtime_id = self.runtime_id.clone(); - let filename_hash = self.filename_hash.clone(); - let content_hash = self.content_hash.clone(); - let ui_source_map_records = self.ui_source_map_records.clone(); - let template_uid_for_captured = template_uid.clone(); - let source_map = self.source_map.clone(); - let node_index_fn = move |span: Span| { - let ui_source_map = - calc_hash_number(&format!("{}:{}:{}", filename_hash, content_hash, span.lo.0)); - - let mut line_number = 0; - let mut column_number = 0; - if span.lo.0 > 0 { - if let Some(cm) = &source_map { - let loc = cm.lookup_char_pos(span.lo); - line_number = loc.line as u32; - column_number = loc.col.0 as u32 + 1; - } - } - - ui_source_map_records.borrow_mut().push(UISourceMapRecord { - ui_source_map, - line_number, - column_number, - template_id: template_uid_for_captured.clone(), - }); - - Expr::Lit(Lit::Num(Number { - span: DUMMY_SP, - value: ui_source_map as f64, - raw: None, - })) - }; let ExtractedTemplateParts { key, dynamic_attrs, dynamic_attr_slots, dynamic_children, } = { - let mut extractor = - ElementTemplateExtractor::new(self, self.cfg.enable_ui_source_map, node_index_fn); + let mut extractor = ElementTemplateExtractor::new(self); node.visit_mut_with(&mut extractor); extractor.into_extracted_template_parts() @@ -388,7 +357,7 @@ where n.visit_mut_children_with(self); self.ensure_builtin_element_templates(); - if let Some(Expr::Ident(runtime_id)) = Lazy::::get(&self.runtime_id) { + if let Some(Expr::Ident(runtime_id)) = Lazy::get(&self.runtime_id) { prepend_stmt( &mut n.body, ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { diff --git a/packages/react/transform/crates/swc_plugin_element_template/napi.rs b/packages/react/transform/crates/swc_plugin_element_template/napi.rs index a55677c5d0..132f3fccb4 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/napi.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/napi.rs @@ -11,7 +11,6 @@ use crate::{ ElementTemplateAsset as CoreElementTemplateAsset, ElementTemplateTransformer as CoreElementTemplateTransformer, ElementTemplateTransformerConfig as CoreElementTemplateTransformerConfig, - ElementTemplateUISourceMapRecord as CoreElementTemplateUISourceMapRecord, }; /// @internal @@ -23,6 +22,7 @@ pub struct ElementTemplateAsset { pub template_id: String, /// @internal #[napi(js_name = "compiledTemplate")] + #[napi(ts_type = "unknown")] pub compiled_template: serde_json::Value, /// @internal #[napi(js_name = "sourceFile")] @@ -39,41 +39,6 @@ impl From for ElementTemplateAsset { } } -/// @internal -#[napi(object)] -#[derive(Clone, Debug)] -pub struct UISourceMapRecord { - pub ui_source_map: i32, - pub filename: String, - pub line_number: u32, - pub column_number: u32, - #[napi(js_name = "templateId")] - pub template_id: String, -} - -impl From for CoreElementTemplateUISourceMapRecord { - fn from(val: UISourceMapRecord) -> Self { - Self { - ui_source_map: val.ui_source_map, - line_number: val.line_number, - column_number: val.column_number, - template_id: val.template_id, - } - } -} - -impl From for UISourceMapRecord { - fn from(val: CoreElementTemplateUISourceMapRecord) -> Self { - Self { - ui_source_map: val.ui_source_map, - filename: String::new(), - line_number: val.line_number, - column_number: val.column_number, - template_id: val.template_id, - } - } -} - /// @internal #[napi(object)] #[derive(Clone, Debug)] @@ -90,8 +55,6 @@ pub struct JSXTransformerConfig { #[napi(ts_type = "'LEPUS' | 'JS' | 'MIXED'")] pub target: TransformTarget, /// @internal - pub enable_ui_source_map: Option, - /// @internal pub is_dynamic_component: Option, } @@ -103,7 +66,6 @@ impl Default for JSXTransformerConfig { jsx_import_source: Some("@lynx-js/react".into()), filename: Default::default(), target: TransformTarget::LEPUS, - enable_ui_source_map: Some(false), is_dynamic_component: Some(false), } } @@ -117,21 +79,6 @@ impl From for CoreElementTemplateTransformerConfig { jsx_import_source: val.jsx_import_source, filename: val.filename, target: val.target.into(), - enable_ui_source_map: val.enable_ui_source_map.unwrap_or(false), - is_dynamic_component: val.is_dynamic_component, - } - } -} - -impl From for JSXTransformerConfig { - fn from(val: CoreElementTemplateTransformerConfig) -> Self { - Self { - preserve_jsx: val.preserve_jsx, - runtime_pkg: val.runtime_pkg, - jsx_import_source: val.jsx_import_source, - filename: val.filename, - target: val.target.into(), - enable_ui_source_map: Some(val.enable_ui_source_map), is_dynamic_component: val.is_dynamic_component, } } @@ -142,8 +89,7 @@ where C: Comments + Clone, { inner: CoreElementTemplateTransformer, - pub ui_source_map_records: Rc>>, - pub element_templates: Rc>>, + element_templates: Rc>>, } impl JSXTransformer @@ -166,7 +112,6 @@ where Some(element_templates.clone()), ); Self { - ui_source_map_records: inner.ui_source_map_records.clone(), element_templates, inner, } @@ -177,19 +122,6 @@ where self } - pub fn with_ui_source_map_records( - mut self, - ui_source_map_records: Rc>>, - ) -> Self { - debug_assert!( - self.inner.ui_source_map_records.borrow().is_empty(), - "ElementTemplateTransformer::with_ui_source_map_records must be called before records are captured" - ); - self.inner.ui_source_map_records = ui_source_map_records.clone(); - self.ui_source_map_records = ui_source_map_records; - self - } - pub fn new( cfg: JSXTransformerConfig, comments: Option, @@ -210,6 +142,10 @@ impl VisitMut for JSXTransformer where C: Comments + Clone, { + fn visit_mut_program(&mut self, node: &mut Program) { + self.inner.visit_mut_program(node) + } + fn visit_mut_jsx_element(&mut self, node: &mut JSXElement) { self.inner.visit_mut_jsx_element(node) } diff --git a/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs index 4609ab435c..6acda26d86 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs @@ -50,13 +50,10 @@ where for prop in &obj.props { if let PropOrSpread::Prop(prop) = prop { if let Prop::KeyValue(kv) = &**prop { - let key; - if let PropName::Ident(ident) = &kv.key { - key = ident.sym.as_str().to_string(); - } else if let PropName::Str(s) = &kv.key { - key = s.value.as_str().unwrap_or("").to_string(); - } else { - continue; + let key = match &kv.key { + PropName::Ident(ident) => ident.sym.as_str().to_string(), + PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), + _ => continue, }; let value = self.element_template_to_json(&kv.value); map.insert(key, value); @@ -197,20 +194,16 @@ where JSXElementChild::JSXExprContainer(JSXExprContainer { expr: JSXExpr::Expr(_), .. - }) => { - let idx = *element_slot_index; - *element_slot_index += 1; - out.push(self.element_template_element_slot(idx)); - } + }) => unreachable!( + "dynamic child should have been lowered to a slot placeholder by ElementTemplateExtractor" + ), JSXElementChild::JSXExprContainer(JSXExprContainer { expr: JSXExpr::JSXEmptyExpr(_), .. }) => {} - JSXElementChild::JSXSpreadChild(_) => { - let idx = *element_slot_index; - *element_slot_index += 1; - out.push(self.element_template_element_slot(idx)); - } + JSXElementChild::JSXSpreadChild(_) => unreachable!( + "dynamic child should have been lowered to a slot placeholder by ElementTemplateExtractor" + ), } } @@ -240,11 +233,8 @@ where element_slot_index: &mut i32, ) -> Expr { if is_slot_placeholder(n) { - let idx = slot_placeholder_index(n).unwrap_or_else(|| { - let idx = *element_slot_index; - *element_slot_index += 1; - idx - }); + let idx = + slot_placeholder_index(n).expect("ET slot placeholder should always carry a slot index"); return self.element_template_element_slot(idx); } @@ -280,7 +270,7 @@ where .. })) => match &**expr { Expr::Lit(Lit::Str(s)) => Some(Expr::Lit(Lit::Str(s.clone()))), - Expr::Lit(Lit::Num(n)) => Some(Expr::Lit(Lit::Num(n.clone()))), + Expr::Lit(Lit::Num(n)) if n.value.is_finite() => Some(Expr::Lit(Lit::Num(n.clone()))), Expr::Lit(Lit::Bool(b)) => Some(Expr::Lit(Lit::Bool(*b))), Expr::Lit(Lit::Null(n)) => Some(Expr::Lit(Lit::Null(*n))), // TODO: Support complex static values (Object, Array, Template Literal without expressions) @@ -332,9 +322,20 @@ where || tag_value == "inline-text" || tag_value == "x-text" || tag_value == "x-inline-text"; + let has_explicit_text_attr = n.opening.attrs.iter().any(|attr| { + matches!( + attr, + JSXAttrOrSpread::JSXAttr(attr) if template_attribute_descriptor_key(&attr.name) == "text" + ) + }); + let has_spread_attr = n + .opening + .attrs + .iter() + .any(|attr| matches!(attr, JSXAttrOrSpread::SpreadElement(_))); let mut text_child_optimized = false; - if is_text_tag { + if is_text_tag && !has_explicit_text_attr && !has_spread_attr { let valid_children: Vec<&JSXElementChild> = n .children .iter() diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs index d234135c97..207be6b92d 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs @@ -473,6 +473,15 @@ fn transform_to_code_and_templates( fn transform_to_code_templates_and_diagnostics( input: &str, cfg: JSXTransformerConfig, +) -> (String, Vec, Vec) { + transform_to_code_templates_and_diagnostics_with_mode(input, cfg, TransformMode::Test) +} + +#[track_caller] +fn transform_to_code_templates_and_diagnostics_with_mode( + input: &str, + cfg: JSXTransformerConfig, + mode: TransformMode, ) -> (String, Vec, Vec) { use std::cell::RefCell; use std::rc::Rc; @@ -528,7 +537,7 @@ fn transform_to_code_templates_and_diagnostics( let mut transformer = JSXTransformer::new_with_element_templates( cfg, Some(comments), - TransformMode::Test, + mode, None, Some(element_templates.clone()), ); @@ -635,6 +644,22 @@ fn should_not_emit_element_template_map_in_element_template_mode() { } } +#[test] +fn should_use_configured_runtime_package_in_development_mode() { + let (code, _, _) = transform_to_code_templates_and_diagnostics_with_mode( + r#""#, + JSXTransformerConfig { + runtime_pkg: "@custom/react".into(), + target: TransformTarget::JS, + ..element_template_config() + }, + TransformMode::Development, + ); + + assert!(code.contains(r#"require("@custom/react/internal").transformRef(viewRef)"#)); + assert!(!code.contains("@lynx-js/react/internal")); +} + #[test] fn should_collect_element_templates_for_dynamic_component_in_element_template_mode() { let (code, templates) = transform_to_code_and_templates( @@ -662,7 +687,7 @@ fn should_collect_element_templates_for_dynamic_component_in_element_template_mo #[test] fn should_report_page_element_as_unsupported() { - let (_, _, diagnostics) = transform_to_code_templates_and_diagnostics( + let (_, templates, diagnostics) = transform_to_code_templates_and_diagnostics( r#" Page Element Test @@ -677,6 +702,10 @@ fn should_report_page_element_as_unsupported() { .any(|message| message == " is not supported"), "expected unsupported diagnostic, got: {diagnostics:?}" ); + assert!( + templates.is_empty(), + "unsupported should not emit poisoned templates: {templates:?}" + ); } #[test] diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs index 8f51d280a7..af0efcd476 100644 --- a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs @@ -168,6 +168,77 @@ fn should_not_inject_root_css_scope_attrs_for_element_template() { ); } +#[test] +fn should_preserve_text_children_when_text_attr_is_explicit() { + let template = first_user_template_json( + r#" + + Child Text + + "#, + ); + + let text_node = &template["children"] + .as_array() + .expect("root children array")[0]; + let attrs = text_node["attributesArray"] + .as_array() + .expect("text attributesArray"); + let text_attrs: Vec<_> = attrs.iter().filter(|attr| attr["key"] == "text").collect(); + assert_eq!( + text_attrs.len(), + 1, + "explicit text attr should not be duplicated by child-text optimization" + ); + assert_eq!(text_attrs[0]["value"], "Explicit Text Attribute"); + + let children = text_node["children"] + .as_array() + .expect("text children array"); + assert_eq!( + children.len(), + 1, + "child text should stay as a child when text attr is already explicit" + ); + assert_eq!(children[0]["type"], "raw-text"); +} + +#[test] +fn should_preserve_text_children_when_text_attr_may_come_from_spread() { + let template = first_user_template_json( + r#" + + Child Text + + "#, + ); + + let text_node = &template["children"] + .as_array() + .expect("root children array")[0]; + let attrs = text_node["attributesArray"] + .as_array() + .expect("text attributesArray"); + assert!( + attrs.iter().any(|attr| attr["kind"] == "spread"), + "text spread descriptor should be preserved" + ); + assert!( + attrs.iter().all(|attr| attr["key"] != "text"), + "static child-text optimization must not add a text attr after a spread" + ); + + let children = text_node["children"] + .as_array() + .expect("text children array"); + assert_eq!( + children.len(), + 1, + "child text should stay as a child when spread could provide text" + ); + assert_eq!(children[0]["type"], "raw-text"); +} + #[test] fn should_not_inject_root_entry_name_attr_for_dynamic_component_element_template() { let (code, template) = first_user_template_json_with_code( @@ -223,14 +294,17 @@ fn should_keep_static_attribute_values_out_of_et_attribute_slots() { assert_eq!(attr_by_key("id")["value"].as_f64(), Some(1.0)); assert_eq!(attr_by_key("data-count")["binding"], "static"); assert_eq!(attr_by_key("data-count")["value"].as_f64(), Some(2.0)); - assert_eq!(attr_by_key("data-overflow")["binding"], "static"); - assert_eq!(attr_by_key("data-overflow")["value"], Value::Null); + assert_eq!(attr_by_key("data-overflow")["binding"], "slot"); + assert_eq!( + attr_by_key("data-overflow")["attrSlotIndex"].as_f64(), + Some(0.0) + ); assert_eq!(attr_by_key("class")["binding"], "slot"); - assert_eq!(attr_by_key("class")["attrSlotIndex"].as_f64(), Some(0.0)); + assert_eq!(attr_by_key("class")["attrSlotIndex"].as_f64(), Some(1.0)); let code = without_whitespace(&code); assert!( - code.contains("attributeSlots={[cls]}"), - "only dynamic class should be transported as an ET attribute slot, got: {code}" + code.contains("attributeSlots={[1e400,cls]}"), + "overflowed numeric literals should stay observable via ET attribute slots, got: {code}" ); } @@ -394,6 +468,31 @@ fn should_keep_element_slot_indices_stable_for_mixed_dynamic_children() { assert_eq!(children[3]["type"], "slot"); } +#[test] +fn should_extract_dynamic_key_child_as_element_slot() { + let templates = transform_to_templates( + r#" + + Child Text + + "#, + element_template_config(), + ); + let template = templates + .into_iter() + .map(|template| { + serde_json::to_value(template.compiled_template).expect("compiled template to json") + }) + .find(|template| template["type"] == "view") + .expect("root view template"); + + let children = template["children"].as_array().expect("children array"); + assert_eq!(children.len(), 1); + assert_eq!(children[0]["kind"], "elementSlot"); + assert_eq!(children[0]["elementSlotIndex"].as_f64(), Some(0.0)); + assert_eq!(children[0]["type"], "slot"); +} + #[test] fn should_preserve_user_wrapper_elements_as_template_nodes() { let template = first_user_template_json( diff --git a/packages/react/transform/index.d.ts b/packages/react/transform/index.d.ts index a3b2a7b1f1..d4d8632a29 100644 --- a/packages/react/transform/index.d.ts +++ b/packages/react/transform/index.d.ts @@ -50,7 +50,6 @@ export interface UiSourceMapRecord { lineNumber: number columnNumber: number snapshotId?: string - templateId?: string } export interface DarkModeConfig { /** @@ -613,8 +612,6 @@ export interface ElementTemplateConfig { /** @internal */ target: 'LEPUS' | 'JS' | 'MIXED' /** @internal */ - enableUiSourceMap?: boolean - /** @internal */ isDynamicComponent?: boolean } export interface WorkletVisitorConfig { @@ -680,7 +677,7 @@ export interface ElementTemplateAsset { /** @internal */ templateId: string /** @internal */ - compiledTemplate: Record + compiledTemplate: unknown /** @internal */ sourceFile: string } diff --git a/packages/react/transform/src/lib.rs b/packages/react/transform/src/lib.rs index 0a5f34d6e6..15ed980654 100644 --- a/packages/react/transform/src/lib.rs +++ b/packages/react/transform/src/lib.rs @@ -55,10 +55,10 @@ use swc_plugin_css_scope::napi::{CSSScopeVisitor, CSSScopeVisitorConfig}; use swc_plugin_define_dce::napi::DefineDCEVisitorConfig; use swc_plugin_directive_dce::napi::{DirectiveDCEVisitor, DirectiveDCEVisitorConfig}; use swc_plugin_dynamic_import::napi::{DynamicImportVisitor, DynamicImportVisitorConfig}; -use swc_plugin_element_template::{ - napi::{ElementTemplateAsset, ElementTemplateTransformer, ElementTemplateTransformerConfig}, - ElementTemplateUISourceMapRecord as ElementTemplateCoreUISourceMapRecord, +use swc_plugin_element_template::napi::{ + ElementTemplateAsset, ElementTemplateTransformer, ElementTemplateTransformerConfig, }; +use swc_plugin_element_template::ElementTemplateAsset as CoreElementTemplateAsset; use swc_plugin_inject::napi::{InjectVisitor, InjectVisitorConfig}; use swc_plugin_refresh::{RefreshVisitor, RefreshVisitorConfig}; use swc_plugin_shake::napi::{ShakeVisitor, ShakeVisitorConfig}; @@ -261,8 +261,6 @@ pub struct UiSourceMapRecord { pub column_number: u32, #[napi(js_name = "snapshotId")] pub snapshot_id: Option, - #[napi(js_name = "templateId")] - pub template_id: Option, } #[napi(object)] @@ -280,6 +278,20 @@ pub struct TransformNodiffOutput { pub element_templates: Option>, } +type ElementTemplateCollector = Rc>>; + +fn take_element_templates( + collector: Option, +) -> Option> { + collector.map(|collector| { + collector + .borrow_mut() + .drain(..) + .map(|template| template.into()) + .collect() + }) +} + /// A multi emitter that forwards to multiple emitters. pub struct MultiEmitter { emitters: Vec>, @@ -313,26 +325,6 @@ fn clone_snapshot_ui_source_map_records( line_number: record.line_number, column_number: record.column_number, snapshot_id: Some(record.snapshot_id), - template_id: None, - }) - .collect() -} - -fn clone_element_template_ui_source_map_records( - ui_source_map_records: &Rc>>, - filename: &str, -) -> Vec { - ui_source_map_records - .borrow() - .iter() - .cloned() - .map(|record| UiSourceMapRecord { - ui_source_map: record.ui_source_map, - filename: filename.to_string(), - line_number: record.line_number, - column_number: record.column_number, - snapshot_id: None, - template_id: Some(record.template_id), }) .collect() } @@ -364,9 +356,6 @@ fn transform_react_lynx_inner( let snapshot_ui_source_map_records: Rc>> = Rc::new(RefCell::new(vec![])); - let element_template_ui_source_map_records: Rc< - RefCell>, - > = Rc::new(RefCell::new(vec![])); let result = GLOBALS.set(&Default::default(), || { let program = c.parse_js( @@ -497,6 +486,22 @@ fn transform_react_lynx_inner( }; // `elementTemplate` chooses the ET backend directly. Once it is enabled, the // public transform entrypoint no longer consults Snapshot-specific ET flags. + if options.snapshot.is_some() + && options.element_template.is_some() + && snapshot_enabled + && element_template_enabled + { + warnings.write().unwrap().push(esbuild::PartialMessage { + id: None, + plugin_name: Some(options.plugin_name.clone()), + text: Some( + "`elementTemplate` takes precedence when both `snapshot` and `elementTemplate` are enabled; `snapshot` will be ignored.".into(), + ), + location: None, + notes: None, + detail: None, + }); + } let use_element_template_plugin = element_template_enabled; let use_snapshot_plugin = snapshot_enabled && !use_element_template_plugin; let jsx_backend_enabled = use_snapshot_plugin || use_element_template_plugin; @@ -510,13 +515,8 @@ fn transform_react_lynx_inner( } else { snapshot_plugin_config.preserve_jsx }; - let enable_ui_source_map = if use_element_template_plugin { - element_template_plugin_config - .enable_ui_source_map - .unwrap_or(false) - } else { - snapshot_plugin_config.enable_ui_source_map.unwrap_or(false) - }; + let enable_ui_source_map = + !use_element_template_plugin && snapshot_plugin_config.enable_ui_source_map.unwrap_or(false); let react_transformer = Optional::new( react::react( @@ -581,12 +581,6 @@ fn transform_react_lynx_inner( ) .with_content_hash(content_hash.clone()); - let transformer = if enable_ui_source_map { - transformer.with_ui_source_map_records(element_template_ui_source_map_records.clone()) - } else { - transformer - }; - Optional::new(visit_mut_pass(transformer), true) } else { Optional::new( @@ -801,18 +795,7 @@ fn transform_react_lynx_inner( // Drain after the whole SWC pass finishes: dynamic-component transforms // can discover multiple template assets while walking one module, and // the caller expects one stable array per transform invocation. - let element_templates = element_templates_collector.and_then(|collector| { - let templates: Vec = collector - .borrow_mut() - .drain(..) - .map(|template| template.into()) - .collect(); - if templates.is_empty() { - None - } else { - Some(templates) - } - }); + let element_templates = take_element_templates(element_templates_collector); TransformNodiffOutput { code: result.code, @@ -820,10 +803,7 @@ fn transform_react_lynx_inner( errors: vec![], warnings: vec![], ui_source_map_records: if use_element_template_plugin { - clone_element_template_ui_source_map_records( - &element_template_ui_source_map_records, - &options.filename, - ) + vec![] } else { clone_snapshot_ui_source_map_records(&snapshot_ui_source_map_records, &options.filename) }, @@ -831,20 +811,18 @@ fn transform_react_lynx_inner( } } Err(_) => { + let element_templates = take_element_templates(element_templates_collector); return TransformNodiffOutput { code: "".into(), map: None, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), ui_source_map_records: if use_element_template_plugin { - clone_element_template_ui_source_map_records( - &element_template_ui_source_map_records, - &options.filename, - ) + vec![] } else { clone_snapshot_ui_source_map_records(&snapshot_ui_source_map_records, &options.filename) }, - element_templates: None, + element_templates, }; } } @@ -997,9 +975,10 @@ mod wasm { #[cfg(test)] mod tests { + use super::*; + #[test] fn test_syntax_serialize_and_deserialize() { - use super::*; use serde_json::json; let json = json!({ @@ -1013,4 +992,33 @@ mod tests { assert!(s.typescript()); assert!(!s.decorators()); // default to false } + + #[test] + fn should_preserve_active_empty_element_template_collector() { + let collector = Rc::new(RefCell::new(vec![])); + + assert_eq!( + take_element_templates(Some(collector)).unwrap().len(), + 0, + "an active ET transform must return Some([]), not None" + ); + } + + #[test] + fn should_drain_partial_element_templates() { + let collector = Rc::new(RefCell::new(vec![CoreElementTemplateAsset { + template_id: "_et_test".into(), + compiled_template: serde_json::json!({ "kind": "element", "type": "view" }), + source_file: "test.js".into(), + }])); + + let templates = take_element_templates(Some(collector.clone())).unwrap(); + + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].template_id, "_et_test"); + assert!( + collector.borrow().is_empty(), + "collector should be drained exactly once" + ); + } } diff --git a/packages/react/transform/swc-plugin-reactlynx/index.d.ts b/packages/react/transform/swc-plugin-reactlynx/index.d.ts index cff2b40d60..4a119b40a6 100644 --- a/packages/react/transform/swc-plugin-reactlynx/index.d.ts +++ b/packages/react/transform/swc-plugin-reactlynx/index.d.ts @@ -40,8 +40,6 @@ export interface ElementTemplateConfig { /** @internal */ target: 'LEPUS' | 'JS' | 'MIXED'; /** @internal */ - enableUiSourceMap?: boolean; - /** @internal */ isDynamicComponent?: boolean; }