Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions crates/vize_canon/src/virtual_ts/expressions/component_props.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<std::ops::Range<usize>> {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<HelloWorld msg="You did it!" />"#;

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 = "<Child label='say \"hi\" \\ done' />";

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#"<Child disabled />"#;

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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}

Expand Down
8 changes: 4 additions & 4 deletions crates/vize_canon/src/virtual_ts/scope/component_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
}
Comment on lines +93 to 97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Potential TS compilation error for components with only valueless attributes.

There is a mismatch in gating between the type declarations loop and the value checks loop.
If a component usage has only valueless static attributes (e.g., <Child disabled />), has_value_props will evaluate to false. Assuming has_navigable_props is also false, the first loop will continue, skipping the declaration of __{component}_Check_{idx}.
However, has_inference_props will evaluate to true (since it doesn't require prop.value.is_some()). This causes the second loop to call generate_generic_props_call, which emits a reference to the undeclared __{component}_Check_{idx}, leading to a TypeScript Cannot find name error in the generated virtual file.

Consider changing this condition to include has_inference_props(usage), or verify that checkable_usages strictly filters out components that lack value/navigable props beforehand.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/vize_canon/src/virtual_ts/scope/component_props.rs` around lines 93 -
97, The declaration-loop guard must stay consistent with the later value-check
loop: update the condition in the component props processing flow to also allow
usages where has_inference_props(usage) is true, ensuring the corresponding
__{component}_Check_{idx} declaration exists before generate_generic_props_call
references it.


Expand All @@ -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!(
Expand Down
Loading