diff --git a/crates/vize_canon/src/virtual_ts/expressions/component_props.rs b/crates/vize_canon/src/virtual_ts/expressions/component_props.rs index 1658722e8..543330687 100644 --- a/crates/vize_canon/src/virtual_ts/expressions/component_props.rs +++ b/crates/vize_canon/src/virtual_ts/expressions/component_props.rs @@ -1,9 +1,9 @@ //! Component prop value type-check generation. //! -//! For each dynamic prop bound on a child component usage, emits a typed -//! assertion plus a single call into the child's generic functional -//! prop-checker so TypeScript can validate the bindings and infer generics -//! across the component boundary. +//! For each prop passed to a child component usage — dynamic bindings and +//! static attribute values alike — emits a typed assertion plus a single call +//! into the child's generic functional prop-checker so TypeScript can +//! validate the values and infer generics across the component boundary. use super::super::helpers::{to_camel_case, to_safe_identifier_fragment}; use super::super::types::{VizeMapping, VizeSubSpan}; @@ -74,7 +74,7 @@ impl<'a> ComponentPropSource<'a> { } } -fn dynamic_prop_value_source_range( +fn prop_value_source_range( source_context: ComponentPropSource<'_>, prop: &PassedProp, ) -> Option> { @@ -136,10 +136,13 @@ pub(crate) fn generate_component_prop_checks( if !is_checkable_prop(prop) { continue; } - if prop.value.is_some() && prop.is_dynamic { + // Static attribute values are checked exactly like dynamic bindings: + // a static `msg="text"` must still satisfy the child's prop type + // (vue-tsc reports TS2322 here; skipping them was a false negative). + if prop.value.is_some() { let prop_src_start = (source_context.offset + prop.start) as usize; let prop_src_end = (source_context.offset + prop.end) as usize; - let value_src_range = dynamic_prop_value_source_range(source_context, prop); + let value_src_range = prop_value_source_range(source_context, prop); let generated_value = profile!( "canon.virtual_ts.prop_check.value", generated_prop_value(prop, template_prop_names).unwrap_or_default() diff --git a/crates/vize_canon/src/virtual_ts/expressions/component_props_tests.rs b/crates/vize_canon/src/virtual_ts/expressions/component_props_tests.rs index 050ed0a08..7d169cd90 100644 --- a/crates/vize_canon/src/virtual_ts/expressions/component_props_tests.rs +++ b/crates/vize_canon/src/virtual_ts/expressions/component_props_tests.rs @@ -68,3 +68,92 @@ const benchmarkMirror = 1 "synthetic check identifier should map to the bound expression: {sub_span:?}" ); } + +#[test] +fn static_attribute_values_are_type_checked_like_dynamic_bindings() { + let script = r#"import HelloWorld from "./HelloWorld.vue" +"#; + let template = r#""#; + + let allocator = vize_carton::Bump::new(); + let (root, _) = vize_armature::parse(&allocator, template); + let mut analyzer = Analyzer::with_options(AnalyzerOptions::full()); + analyzer.analyze_script_setup(script); + analyzer.analyze_template(&root); + let summary = analyzer.finish(); + + let output = generate_virtual_ts(&summary, Some(script), Some(&root), 0); + + assert!( + output.code.contains( + "type __HelloWorld_0_prop_msg = __VizePropValue<__HelloWorld_Props_0, 'msg'>;" + ), + "static prop must declare its child prop type alias:\n{}", + output.code + ); + assert!( + output + .code + .contains(": __HelloWorld_0_prop_msg = \"You did it!\";"), + "static prop value must be asserted against the child prop type:\n{}", + output.code + ); + + // The synthetic check identifier maps back to the authored attribute value. + let value_start = template.find("You did it!").expect("static value present"); + let value_range = value_start..value_start + "You did it!".len(); + let sub_span = output + .mappings + .iter() + .flat_map(|mapping| &mapping.sub_spans) + .find(|span| span.src_range == value_range) + .expect("static prop check should map to the authored attribute value"); + assert!( + output.code[sub_span.gen_range.clone()].starts_with("__vize_prop_check_"), + "synthetic check identifier should map to the static value: {sub_span:?}" + ); +} + +#[test] +fn static_attribute_values_escape_into_exact_string_literals() { + let script = r#"import Child from "./Child.vue" +"#; + let template = ""; + + let allocator = vize_carton::Bump::new(); + let (root, _) = vize_armature::parse(&allocator, template); + let mut analyzer = Analyzer::with_options(AnalyzerOptions::full()); + analyzer.analyze_script_setup(script); + analyzer.analyze_template(&root); + let summary = analyzer.finish(); + + let output = generate_virtual_ts(&summary, Some(script), Some(&root), 0); + assert!( + output + .code + .contains(": __Child_0_prop_label = \"say \\\"hi\\\" \\\\ done\";"), + "static value must escape quotes and backslashes:\n{}", + output.code + ); +} + +#[test] +fn valueless_static_attributes_stay_out_of_per_prop_checks() { + let script = r#"import Child from "./Child.vue" +"#; + let template = r#""#; + + let allocator = vize_carton::Bump::new(); + let (root, _) = vize_armature::parse(&allocator, template); + let mut analyzer = Analyzer::with_options(AnalyzerOptions::full()); + analyzer.analyze_script_setup(script); + analyzer.analyze_template(&root); + let summary = analyzer.finish(); + + let output = generate_virtual_ts(&summary, Some(script), Some(&root), 0); + assert!( + !output.code.contains("__Child_0_prop_disabled ="), + "valueless attributes keep their boolean-shorthand semantics:\n{}", + output.code + ); +} diff --git a/crates/vize_canon/src/virtual_ts/scope/component_prop_checker.rs b/crates/vize_canon/src/virtual_ts/scope/component_prop_checker.rs index f94293967..5e8896d00 100644 --- a/crates/vize_canon/src/virtual_ts/scope/component_prop_checker.rs +++ b/crates/vize_canon/src/virtual_ts/scope/component_prop_checker.rs @@ -8,13 +8,16 @@ pub(super) fn is_inline_function_prop_value(value: &str) -> bool { value.contains("=>") || value.starts_with("function") || value.starts_with("async function") } -pub(super) fn has_dynamic_props(usage: &ComponentUsage) -> bool { +/// Returns whether any checkable prop carries a value to type-check. +/// +/// Static attribute values count: `msg="text"` must satisfy the child's +/// prop type just like `:msg="expr"`. +pub(super) fn has_value_props(usage: &ComponentUsage) -> bool { usage.props.iter().any(|prop| { !prop.name_is_dynamic && prop.name.as_str() != "key" && prop.name.as_str() != "ref" && prop.value.is_some() - && prop.is_dynamic }) } diff --git a/crates/vize_canon/src/virtual_ts/scope/component_props.rs b/crates/vize_canon/src/virtual_ts/scope/component_props.rs index 80a2c5588..c0631b29f 100644 --- a/crates/vize_canon/src/virtual_ts/scope/component_props.rs +++ b/crates/vize_canon/src/virtual_ts/scope/component_props.rs @@ -15,7 +15,7 @@ use crate::virtual_ts::helpers::{to_camel_case, to_safe_identifier, to_safe_iden use crate::virtual_ts::types::VizeMapping; use super::component_prop_checker::{ - append_prop_checker_alias, has_dynamic_props, has_inference_props, + append_prop_checker_alias, has_inference_props, has_value_props, }; use super::component_prop_navigation; use super::context::{ComponentPropsContext, VForPropsContext}; @@ -90,9 +90,9 @@ pub(super) fn generate_component_props( let component_ref = to_safe_identifier(usage.name.as_str()); let component_type_name = to_safe_identifier_fragment(usage.name.as_str()); - let has_dynamic_props = has_dynamic_props(usage); + let has_value_props = has_value_props(usage); let has_navigable_props = component_prop_navigation::has_navigable_props(ctx, usage); - if !has_dynamic_props && !has_navigable_props { + if !has_value_props && !has_navigable_props { continue; } @@ -108,7 +108,7 @@ pub(super) fn generate_component_props( if prop.name_is_dynamic || prop.name.as_str() == "key" || prop.name.as_str() == "ref" { continue; } - if prop.value.is_some() && prop.is_dynamic { + if prop.value.is_some() { let camel_prop_name = to_camel_case(prop.name.as_str()); let safe_prop_name = to_safe_identifier_fragment(prop.name.as_str()); append!(