use fallow_types::extract::{ComponentFunctionKind, HookUseKind};
use crate::tests::{parse_ts, parse_tsx};
fn component_names(info: &crate::ModuleInfo) -> Vec<&str> {
info.component_functions
.iter()
.map(|c| c.name.as_str())
.collect()
}
fn render_child_names(info: &crate::ModuleInfo) -> Vec<&str> {
info.render_edges
.iter()
.map(|e| e.child_component_name.as_str())
.collect()
}
fn hook_kinds(info: &crate::ModuleInfo) -> Vec<HookUseKind> {
info.hook_uses.iter().map(|h| h.kind).collect()
}
#[test]
fn capitalized_tag_records_render_edge() {
let info = parse_tsx("export const App = () => <Child name=\"x\" id=\"y\" />;");
assert_eq!(info.render_edges.len(), 1);
let edge = &info.render_edges[0];
assert_eq!(edge.parent_component, "App");
assert_eq!(edge.child_component_name, "Child");
assert_eq!(edge.attr_names, vec!["name".to_string(), "id".to_string()]);
assert!(!edge.has_spread);
}
#[test]
fn member_expression_tag_records_render_edge() {
let info = parse_tsx("export const App = () => <Foo.Bar value={1} />;");
assert!(
info.render_edges
.iter()
.any(|e| e.child_component_name == "Foo.Bar")
);
}
#[test]
fn lowercase_host_element_is_not_a_render_edge() {
let info = parse_tsx("export const App = () => <div className=\"a\"><span>hi</span></div>;");
assert!(
info.render_edges.is_empty(),
"host elements must not be render edges, got {:?}",
info.render_edges
);
}
#[test]
fn jsx_spread_is_recorded() {
let info = parse_tsx("export const App = (props) => <Child {...props} extra=\"z\" />;");
let edge = &info.render_edges[0];
assert!(edge.has_spread);
assert_eq!(edge.attr_names, vec!["extra".to_string()]);
}
#[test]
fn bare_props_passthrough_marks_thin_wrapper_candidate() {
let info = parse_tsx("const App = (props) => <Child {...props} />;");
let component = &info.component_functions[0];
assert!(component.is_pure_passthrough);
assert!(component.has_unharvestable_props);
assert!(info.react_props.is_empty());
}
#[test]
fn host_element_wrapping_component_records_only_the_component() {
let info = parse_tsx("export const App = () => <div><Child a=\"1\" /></div>;");
assert_eq!(info.render_edges.len(), 1);
assert_eq!(info.render_edges[0].child_component_name, "Child");
}
#[test]
fn arrow_component_is_identified() {
let info = parse_tsx("export const App = () => <div />;");
assert_eq!(info.component_functions.len(), 1);
let component = &info.component_functions[0];
assert_eq!(component.name, "App");
assert_eq!(component.kind, ComponentFunctionKind::Arrow);
assert!(component.is_exported);
}
#[test]
fn function_declaration_component_is_identified() {
let info = parse_tsx("export function App() { return <div />; }");
let component = &info.component_functions[0];
assert_eq!(component.name, "App");
assert_eq!(component.kind, ComponentFunctionKind::FnDecl);
assert!(component.is_exported);
}
#[test]
fn non_exported_component_is_marked_not_exported() {
let info = parse_tsx("const App = () => <div />;\nfunction render() { return App; }");
let component = info
.component_functions
.iter()
.find(|c| c.name == "App")
.expect("App component");
assert!(!component.is_exported);
}
#[test]
fn forward_ref_wrapper_is_identified() {
let info = parse_tsx(
"import { forwardRef } from 'react';\nexport const Input = forwardRef((props, ref) => <input ref={ref} />);",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "Input")
.expect("Input component");
assert_eq!(component.kind, ComponentFunctionKind::ForwardRefWrapper);
}
#[test]
fn memo_wrapper_is_identified() {
let info =
parse_tsx("import { memo } from 'react';\nexport const Card = memo((props) => <div />);");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Card")
.expect("Card component");
assert_eq!(component.kind, ComponentFunctionKind::MemoWrapper);
}
#[test]
fn react_member_wrapper_is_identified() {
let info = parse_tsx(
"import React from 'react';\nexport const Card = React.memo((props) => <div />);",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "Card")
.expect("Card component");
assert_eq!(component.kind, ComponentFunctionKind::MemoWrapper);
}
#[test]
fn destructured_props_are_harvested() {
let info = parse_tsx("export const App = ({ name, count }) => <div>{name}{count}</div>;");
let names: Vec<_> = info.react_props.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"name"));
assert!(names.contains(&"count"));
let component = &info.component_functions[0];
assert!(!component.has_unharvestable_props);
}
#[test]
fn renamed_destructured_prop_records_local_alias() {
let info = parse_tsx("export const App = ({ name: label }) => <div>{label}</div>;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "name")
.expect("name prop");
assert_eq!(prop.local, "label");
assert!(!prop.used_in_template);
}
#[test]
fn bare_props_identifier_abstains() {
let info = parse_tsx("export const App = (props) => <div>{props.name}</div>;");
assert!(info.react_props.is_empty());
assert!(info.component_functions[0].has_unharvestable_props);
}
#[test]
fn rest_spread_in_props_abstains() {
let info = parse_tsx("export const App = ({ name, ...rest }) => <div {...rest}>{name}</div>;");
assert!(info.component_functions[0].has_unharvestable_props);
}
#[test]
fn hooks_are_recorded_with_kinds() {
let info = parse_tsx(
"import { useState, useEffect } from 'react';\nexport const App = () => { const [n] = useState(0); useEffect(() => {}, [n]); return <div />; };",
);
let kinds: Vec<_> = info.hook_uses.iter().map(|h| h.kind).collect();
assert!(kinds.contains(&HookUseKind::UseState));
assert!(kinds.contains(&HookUseKind::UseEffect));
}
#[test]
fn custom_hook_is_recorded() {
let info = parse_tsx(
"export const App = () => { const v = useCustomThing(); return <div>{v}</div>; };",
);
assert!(info.hook_uses.iter().any(|h| h.kind == HookUseKind::Custom));
}
#[test]
fn use_effect_dep_array_arity_is_captured_only_when_literal() {
let info = parse_tsx(
"import { useEffect } from 'react';\nexport const App = () => { useEffect(() => {}, [a, b]); return <div />; };",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::UseEffect)
.expect("useEffect");
assert_eq!(hook.dep_array_arity, Some(2));
}
#[test]
fn use_effect_without_dep_array_has_no_arity() {
let info = parse_tsx(
"import { useEffect } from 'react';\nexport const App = () => { useEffect(() => {}); return <div />; };",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::UseEffect)
.expect("useEffect");
assert_eq!(hook.dep_array_arity, None);
}
#[test]
fn use_state_has_no_dep_arity() {
let info = parse_tsx(
"import { useState } from 'react';\nexport const App = () => { const [n] = useState(0); return <div>{n}</div>; };",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::UseState)
.expect("useState");
assert_eq!(hook.dep_array_arity, None);
}
#[test]
fn non_jsx_file_is_a_no_op() {
let info =
parse_ts("export const App = () => 42;\nexport function helper() { return useState; }");
assert!(info.component_functions.is_empty());
assert!(info.render_edges.is_empty());
assert!(info.hook_uses.is_empty());
assert!(info.react_props.is_empty());
}
#[test]
fn nested_render_edges_carry_correct_parent() {
let info = parse_tsx(
"export const Outer = () => <div><Inner /></div>;\nexport const Other = () => <Sibling />;",
);
let inner = info
.render_edges
.iter()
.find(|e| e.child_component_name == "Inner")
.expect("Inner edge");
assert_eq!(inner.parent_component, "Outer");
let sibling = info
.render_edges
.iter()
.find(|e| e.child_component_name == "Sibling")
.expect("Sibling edge");
assert_eq!(sibling.parent_component, "Other");
}
#[test]
fn hook_outside_component_is_not_recorded() {
let info = parse_tsx("const x = useThing();\nexport const App = () => <div />;");
assert!(
info.hook_uses.is_empty(),
"module-scope hook call should not be recorded as a component hook"
);
}
#[test]
fn jsx_fragment_returning_arrow_is_a_component() {
let info = parse_tsx("export const App = () => <><Child /></>;");
assert_eq!(info.component_functions[0].name, "App");
assert!(
info.render_edges
.iter()
.any(|e| e.child_component_name == "Child")
);
}
#[test]
fn parenthesized_arrow_init_is_identified_as_arrow_component() {
let info = parse_tsx("export const App = (() => <div />);");
let component = info
.component_functions
.iter()
.find(|c| c.name == "App")
.expect("App should be detected through the parenthesized wrapper");
assert_eq!(component.kind, ComponentFunctionKind::Arrow);
}
#[test]
fn ts_as_expression_init_is_identified_as_component() {
let info = parse_tsx("export const App = (() => <div />) as React.FC;");
assert!(
component_names(&info).contains(&"App"),
"App should be detected through TSAsExpression; got {:?}",
component_names(&info),
);
}
#[test]
fn ts_satisfies_expression_init_is_identified_as_component() {
let info = parse_tsx("export const App = (() => <div />) satisfies React.FC;");
assert!(
component_names(&info).contains(&"App"),
"App should be detected through TSSatisfiesExpression; got {:?}",
component_names(&info),
);
}
#[test]
fn forward_ref_with_parenthesized_function_expression_is_classified() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef((function(props, ref) { return <input ref={ref} />; }));",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "Input")
.expect("Input should be detected through parenthesized function in forwardRef");
assert_eq!(component.kind, ComponentFunctionKind::ForwardRefWrapper);
}
#[test]
fn memo_with_parenthesized_arrow_is_classified() {
let info = parse_tsx(
"import { memo } from 'react';\n\
export const Card = memo(((props) => <div />));",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "Card")
.expect("Card should be detected through double-parenthesized arrow in memo");
assert_eq!(component.kind, ComponentFunctionKind::MemoWrapper);
}
#[test]
fn function_expression_init_without_wrapper_is_identified_as_component() {
let info = parse_tsx("export const App = function() { return <div />; };");
assert!(
component_names(&info).contains(&"App"),
"App defined via function expression should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn classify_wrapper_call_rejects_unknown_wrapper_name() {
let info = parse_tsx("export const App = withSomething(() => <div />);");
assert!(
info.component_functions.is_empty(),
"unknown wrapper must not classify as a component; got {:?}",
info.component_functions,
);
}
#[test]
fn function_component_with_if_returning_jsx_is_identified() {
let info = parse_tsx(
"export function App({ ok }) {\n\
if (ok) return <span />;\n\
return null;\n\
}",
);
assert!(
component_names(&info).contains(&"App"),
"App returning JSX from an if-consequent should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn function_component_with_else_returning_jsx_is_identified() {
let info = parse_tsx(
"export function App({ ok }) {\n\
if (!ok) return null;\n\
else return <div />;\n\
}",
);
assert!(
component_names(&info).contains(&"App"),
"App returning JSX from an if-alternate should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn function_component_with_nested_block_returning_jsx_is_identified() {
let info = parse_tsx(
"export function App() {\n\
{\n\
return <div />;\n\
}\n\
}",
);
assert!(
component_names(&info).contains(&"App"),
"App returning JSX from a nested block should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn function_component_with_switch_returning_jsx_is_identified() {
let info = parse_tsx(
"export function App({ kind }) {\n\
switch (kind) {\n\
case 'a': return <span />;\n\
default: return null;\n\
}\n\
}",
);
assert!(
component_names(&info).contains(&"App"),
"App returning JSX from a switch case should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn function_component_with_try_returning_jsx_is_identified() {
let info = parse_tsx(
"export function App() {\n\
try {\n\
return <div />;\n\
} catch (e) {\n\
return null;\n\
}\n\
}",
);
assert!(
component_names(&info).contains(&"App"),
"App returning JSX from a try block should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn function_component_with_catch_returning_jsx_is_identified() {
let info = parse_tsx(
"export function App() {\n\
try {\n\
return null;\n\
} catch (e) {\n\
return <div />;\n\
}\n\
}",
);
assert!(
component_names(&info).contains(&"App"),
"App returning JSX from a catch clause should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn function_component_with_finally_returning_jsx_is_identified() {
let info = parse_tsx(
"export function App() {\n\
try {\n\
doSomething();\n\
} finally {\n\
return <div />;\n\
}\n\
}",
);
assert!(
component_names(&info).contains(&"App"),
"App returning JSX from a finally clause should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn conditional_return_that_might_be_jsx_classifies_as_component() {
let info = parse_tsx("export const App = ({ ok }) => ok ? <div /> : null;");
assert!(
component_names(&info).contains(&"App"),
"App with conditional JSX expression should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn logical_and_jsx_return_classifies_as_component() {
let info = parse_tsx("export const App = ({ ok }) => ok && <div />;");
assert!(
component_names(&info).contains(&"App"),
"App with logical-and JSX expression should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn ts_non_null_jsx_return_classifies_as_component() {
let info = parse_tsx("export const App = () => (<div />)!;");
assert!(
component_names(&info).contains(&"App"),
"App with non-null assertion on JSX should be detected; got {:?}",
component_names(&info),
);
}
#[test]
fn fragment_wrapping_single_child_is_pure_passthrough() {
let info = parse_tsx("const Wrap = (props) => <><Child {...props} /></>;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
component.is_pure_passthrough,
"fragment with single child spread must be detected as pure passthrough",
);
}
#[test]
fn fragment_with_multiple_children_is_not_pure_passthrough() {
let info = parse_tsx("const Wrap = (props) => <><A {...props} /><B /></>;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
!component.is_pure_passthrough,
"fragment with multiple children must NOT be marked as pure passthrough",
);
}
#[test]
fn passthrough_detected_through_parenthesized_element() {
let info = parse_tsx("const Wrap = (props) => (<Child {...props} />);");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
component.is_pure_passthrough,
"parenthesized JSX spread should be detected as pure passthrough",
);
}
#[test]
fn passthrough_detected_through_ts_as_expression() {
let info = parse_tsx("const Wrap = (props) => (<Child {...props} />) as JSX.Element;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
component.is_pure_passthrough,
"TSAs-cast JSX spread should be detected as pure passthrough",
);
}
#[test]
fn passthrough_detected_through_ts_satisfies_expression() {
let info = parse_tsx("const Wrap = (props) => (<Child {...props} />) satisfies JSX.Element;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
component.is_pure_passthrough,
"satisfies-cast JSX spread should be detected as pure passthrough",
);
}
#[test]
fn passthrough_detected_through_ts_non_null_expression() {
let info = parse_tsx("const Wrap = (props) => (<Child {...props} />)!;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
component.is_pure_passthrough,
"non-null asserted JSX spread should be detected as pure passthrough",
);
}
#[test]
fn forward_ref_with_function_expression_inner_harvests_props() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef(function(props, ref) {\n\
const { value, onChange } = props;\n\
return <input value={value} onChange={onChange} ref={ref} />;\n\
});",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "Input")
.expect("Input component via forwardRef function expression");
assert_eq!(component.kind, ComponentFunctionKind::ForwardRefWrapper);
assert!(
component.has_unharvestable_props,
"bare identifier props param must be unharvestable",
);
}
#[test]
fn provider_tag_marks_renders_provider_on_enclosing_component() {
let info = parse_tsx(
"export const App = () => <MyContext.Provider value={42}><Child /></MyContext.Provider>;",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "App")
.expect("App component");
assert!(
component.renders_provider,
"a *.Provider child must set renders_provider on the enclosing component",
);
}
#[test]
fn render_prop_attribute_marks_has_children_as_function() {
let info = parse_tsx("export const App = () => <List render={() => <Item />} />;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "App")
.expect("App component");
assert!(
component.has_children_as_function,
"render-prop attribute must set has_children_as_function on the enclosing component",
);
}
#[test]
fn children_as_function_in_jsx_body_marks_flag() {
let info = parse_tsx("export const App = () => <List>{() => <Item />}</List>;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "App")
.expect("App component");
assert!(
component.has_children_as_function,
"children-as-function must set has_children_as_function on the enclosing component",
);
}
#[test]
fn clone_element_call_marks_uses_clone_element() {
let info = parse_tsx(
"export const App = ({ children }) => {\n\
const el = cloneElement(children, { extra: true });\n\
return <div>{el}</div>;\n\
};",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "App")
.expect("App component");
assert!(
component.uses_clone_element,
"cloneElement call must set uses_clone_element on the enclosing component",
);
}
#[test]
fn react_clone_element_member_call_marks_uses_clone_element() {
let info = parse_tsx(
"export const App = ({ children }) => {\n\
const el = React.cloneElement(children, { extra: true });\n\
return <div>{el}</div>;\n\
};",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "App")
.expect("App component");
assert!(
component.uses_clone_element,
"React.cloneElement call must set uses_clone_element on the enclosing component",
);
}
#[test]
fn identifier_valued_attribute_records_forward_attr() {
let info = parse_tsx("export const App = ({ label }) => <Child text={label} />;");
let edge = info
.render_edges
.iter()
.find(|e| e.child_component_name == "Child")
.expect("render edge to Child");
assert!(
edge.forward_attrs
.iter()
.any(|fa| fa.attr == "text" && fa.root == "label"),
"identifier prop value must be recorded as forward_attr; got {:?}",
edge.forward_attrs,
);
}
#[test]
fn member_expression_attribute_value_records_forward_attr_with_root() {
let info = parse_tsx("export const App = ({ data }) => <Child value={data.count} />;");
let edge = info
.render_edges
.iter()
.find(|e| e.child_component_name == "Child")
.expect("render edge to Child");
assert!(
edge.forward_attrs
.iter()
.any(|fa| fa.attr == "value" && fa.root == "data"),
"member-expression prop value must record root identifier as forward_attr root; got {:?}",
edge.forward_attrs,
);
}
#[test]
fn call_expression_attribute_value_sets_has_complex_forward() {
let info = parse_tsx("export const App = ({ fn }) => <Child handler={fn()} />;");
let edge = info
.render_edges
.iter()
.find(|e| e.child_component_name == "Child")
.expect("render edge to Child");
assert!(
edge.has_complex_forward,
"call expression attribute value must set has_complex_forward; got {:?}",
edge.forward_attrs,
);
}
#[test]
fn element_as_prop_sets_has_complex_forward() {
let info = parse_tsx("export const App = () => <Button icon={<Icon />} />;");
let edge = info
.render_edges
.iter()
.find(|e| e.child_component_name == "Button")
.expect("render edge to Button");
assert!(
edge.has_complex_forward,
"element-as-prop attribute value must set has_complex_forward",
);
}
#[test]
fn namespaced_attribute_name_is_collected() {
let info = parse_tsx("export const App = () => <Svg xmlns:xl=\"http://example.com\" />;");
let edge = info
.render_edges
.iter()
.find(|e| e.child_component_name == "Svg")
.expect("render edge to Svg");
assert!(
edge.attr_names.iter().any(|n| n == "xmlns:xl"),
"namespaced attribute name must be collected; got {:?}",
edge.attr_names,
);
}
#[test]
fn jsx_member_expression_tag_flattens_to_dotted_path() {
let info = parse_tsx("export const App = () => <Foo.Bar />;");
assert!(
render_child_names(&info).contains(&"Foo.Bar"),
"Foo.Bar member-expression tag must produce child_component_name 'Foo.Bar'; got {:?}",
render_child_names(&info),
);
}
#[test]
fn jsx_deep_member_expression_tag_flattens_to_dotted_path() {
let info = parse_tsx("export const App = () => <A.B.C />;");
assert!(
render_child_names(&info).contains(&"A.B.C"),
"three-level member-expression tag must produce 'A.B.C'; got {:?}",
render_child_names(&info),
);
}
#[test]
fn used_prop_sets_used_in_script_flag() {
let info = parse_tsx("export const App = ({ name }) => <div>{name}</div>;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "name")
.expect("name prop");
assert!(
prop.used_in_script,
"prop read in body must be marked used_in_script",
);
assert!(
!prop.used_in_template,
"React has no template; used_in_template must always be false",
);
}
#[test]
fn unused_prop_has_used_in_script_false() {
let info = parse_tsx("export const App = ({ dead }) => <div />;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "dead")
.expect("dead prop");
assert!(
!prop.used_in_script,
"prop never read in body must have used_in_script = false",
);
}
#[test]
fn prop_used_only_as_forwarded_attribute_is_used_but_not_outside_forward() {
let info = parse_tsx("export const App = ({ label }) => <Child text={label} />;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "label")
.expect("label prop");
assert!(
prop.used_in_script,
"forwarded prop must count as used_in_script"
);
assert!(
!prop.used_outside_forward,
"prop used only as attribute value must NOT be used_outside_forward",
);
}
#[test]
fn prop_used_in_text_body_is_used_outside_forward() {
let info = parse_tsx("export const App = ({ title }) => <div><span>{title}</span></div>;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "title")
.expect("title prop");
assert!(
prop.used_in_script,
"prop read in body must be used_in_script"
);
assert!(
prop.used_outside_forward,
"prop read in JSX children must be used_outside_forward",
);
}
#[test]
fn prop_with_default_value_is_harvested() {
let info = parse_tsx("export const App = ({ count = 0 }) => <div>{count}</div>;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "count")
.expect("count prop with default");
assert_eq!(prop.local, "count");
assert!(prop.used_in_script);
}
#[test]
fn renamed_prop_with_default_value_is_harvested() {
let info = parse_tsx("export const App = ({ value: v = 0 }) => <div>{v}</div>;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "value")
.expect("value prop");
assert_eq!(prop.local, "v");
assert!(prop.used_in_script);
}
#[test]
fn string_key_prop_is_harvested() {
let info = parse_tsx("export const App = ({ 'data-id': dataId }) => <div id={dataId} />;");
let prop = info
.react_props
.iter()
.find(|p| p.name == "data-id")
.expect("data-id prop");
assert_eq!(prop.local, "dataId");
}
#[test]
fn nested_destructured_prop_abstains() {
let info = parse_tsx("export const App = ({ user: { name } }) => <div>{name}</div>;");
assert!(
info.component_functions[0].has_unharvestable_props,
"nested destructure must mark has_unharvestable_props",
);
assert!(
info.react_props.is_empty(),
"nested destructure must yield no harvested props",
);
}
#[test]
fn computed_key_prop_abstains() {
let info =
parse_tsx("const KEY = 'x';\nexport const App = ({ [KEY]: val }) => <div>{val}</div>;");
assert!(
info.component_functions[0].has_unharvestable_props,
"computed key must mark has_unharvestable_props",
);
}
#[test]
fn use_memo_dep_array_arity_is_captured() {
let info = parse_tsx(
"import { useMemo } from 'react';\n\
export const App = () => {\n\
const v = useMemo(() => 1, [a, b, c]);\n\
return <div>{v}</div>;\n\
};",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::UseMemo)
.expect("useMemo hook");
assert_eq!(
hook.dep_array_arity,
Some(3),
"useMemo dep array with 3 elements should have arity 3",
);
}
#[test]
fn use_callback_dep_array_arity_is_captured() {
let info = parse_tsx(
"import { useCallback } from 'react';\n\
export const App = () => {\n\
const fn = useCallback(() => {}, [x]);\n\
return <div />;\n\
};",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::UseCallback)
.expect("useCallback hook");
assert_eq!(
hook.dep_array_arity,
Some(1),
"useCallback dep array with 1 element should have arity 1",
);
}
#[test]
fn use_effect_empty_dep_array_has_arity_zero() {
let info = parse_tsx(
"import { useEffect } from 'react';\n\
export const App = () => {\n\
useEffect(() => {}, []);\n\
return <div />;\n\
};",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::UseEffect)
.expect("useEffect hook");
assert_eq!(
hook.dep_array_arity,
Some(0),
"useEffect with empty dep array should have arity 0",
);
}
#[test]
fn custom_hook_has_no_dep_array_arity() {
let info = parse_tsx(
"export const App = () => {\n\
const v = useCustomData([a, b]);\n\
return <div>{v}</div>;\n\
};",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::Custom)
.expect("custom hook");
assert_eq!(
hook.dep_array_arity, None,
"custom hooks always have dep_array_arity = None",
);
}
#[test]
fn is_custom_hook_name_requires_uppercase_after_use() {
let info = parse_tsx(
"export const App = () => {\n\
useFoo();\n\
usefoo();\n\
return <div />;\n\
};",
);
let kinds = hook_kinds(&info);
let custom_count = kinds.iter().filter(|&&k| k == HookUseKind::Custom).count();
assert_eq!(
custom_count, 1,
"only useFoo (uppercase) should be recorded as a custom hook; got {kinds:?}",
);
}
#[test]
fn use_memo_without_dep_array_has_no_arity() {
let info = parse_tsx(
"import { useMemo } from 'react';\n\
export const App = () => {\n\
const v = useMemo(() => 1);\n\
return <div>{v}</div>;\n\
};",
);
let hook = info
.hook_uses
.iter()
.find(|h| h.kind == HookUseKind::UseMemo)
.expect("useMemo hook");
assert_eq!(
hook.dep_array_arity, None,
"useMemo without dep array should have arity None",
);
}
#[test]
fn object_rest_spread_root_is_detected_as_passthrough_candidate() {
let info = parse_tsx("const Wrap = ({ className, ...rest }) => <Child {...rest} />;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
component.has_unharvestable_props,
"rest spread in props must mark has_unharvestable_props",
);
assert!(
component.is_pure_passthrough,
"spreading the rest local into the single child must mark is_pure_passthrough",
);
}
#[test]
fn non_identifier_child_in_passthrough_is_not_pure() {
let info = parse_tsx("const Wrap = (props) => <Child {...other} />;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
!component.is_pure_passthrough,
"spreading a different object must not mark is_pure_passthrough",
);
}
#[test]
fn component_with_extra_local_statement_is_not_pure_passthrough() {
let info = parse_tsx(
"const Wrap = (props) => {\n\
const extra = 1;\n\
return <Child {...props} />;\n\
};",
);
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
!component.is_pure_passthrough,
"body with more than one statement must not be pure passthrough",
);
}
#[test]
fn named_attribute_alongside_spread_is_not_pure_passthrough() {
let info = parse_tsx("const Wrap = (props) => <Child {...props} extra=\"x\" />;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
!component.is_pure_passthrough,
"named attribute alongside spread must not be pure passthrough",
);
}
#[test]
fn host_element_child_in_passthrough_is_not_pure() {
let info = parse_tsx("const Wrap = (props) => <div {...props} />;");
let component = info
.component_functions
.iter()
.find(|c| c.name == "Wrap")
.expect("Wrap component");
assert!(
!component.is_pure_passthrough,
"spread onto a host element must not be pure passthrough",
);
}
fn typed_prop<'a>(
info: &'a crate::ModuleInfo,
name: &str,
) -> &'a fallow_types::extract::ComponentProp {
info.react_props
.iter()
.find(|p| p.name == name)
.unwrap_or_else(|| {
panic!(
"expected harvested prop `{name}`, got {:?}",
info.react_props
)
})
}
#[test]
fn matrix_a_member_access_credits_prop() {
let info = parse_tsx(
"interface Props { size: string }\nexport const App = (props: Props) => <div>{props.size}</div>;",
);
let prop = typed_prop(&info, "size");
assert!(
prop.used_in_script,
"props.size member access must credit `size` as used",
);
assert!(!info.component_functions[0].has_unharvestable_props);
}
#[test]
fn matrix_b_renamed_destructure_after_credits_prop() {
let info = parse_tsx(
"interface Props { size: string }\nexport const App = (props: Props) => { const { size: s } = props; return <div>{s}</div>; };",
);
let prop = typed_prop(&info, "size");
assert!(
prop.used_in_script,
"const {{ size: s }} = props destructure must credit `size`",
);
}
#[test]
fn matrix_c_default_valued_inline_destructure_credits_prop() {
let info = parse_tsx(
"interface Props { size: number }\nexport const App = ({ size = 4 }: Props) => <div style={{ width: size }} />;",
);
let prop = typed_prop(&info, "size");
assert_eq!(prop.local, "size");
assert!(
prop.used_in_script,
"default-valued destructure must credit `size`"
);
}
#[test]
fn matrix_d_rest_after_named_typed_destructure_abstains() {
let info = parse_tsx(
"interface Props { size: number }\nexport const App = ({ size, ...rest }: Props) => <div {...rest}>{size}</div>;",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"rest after named must abstain",
);
assert!(info.react_props.is_empty());
}
#[test]
fn matrix_e_props_spread_into_child_abstains() {
let info = parse_tsx(
"interface Props { size: number }\nexport const App = (props: Props) => <Child {...props} />;",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"spreading props into a child is a whole-object use; must abstain",
);
assert!(info.react_props.is_empty());
}
#[test]
fn matrix_e_props_passed_to_hook_abstains() {
let info = parse_tsx(
"interface Props { size: number }\nexport const App = (props: Props) => { const v = useFoo(props); return <div>{v}</div>; };",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"passing props to a hook is a whole-object use; must abstain",
);
assert!(info.react_props.is_empty());
}
#[test]
fn matrix_f_genuinely_unused_typed_prop_is_harvested_unused() {
let info = parse_tsx(
"interface Props { size: number }\nexport const App = (props: Props) => <div />;",
);
let prop = typed_prop(&info, "size");
assert!(
!prop.used_in_script,
"a typed prop read nowhere must be harvested with used_in_script=false",
);
assert!(!info.component_functions[0].has_unharvestable_props);
}
#[test]
fn matrix_g_imported_interface_abstains() {
let info = parse_tsx(
"import { Props } from './types';\nexport const App = (props: Props) => <div>{props.size}</div>;",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"an imported props interface must abstain in v1",
);
assert!(info.react_props.is_empty());
}
#[test]
fn type_alias_object_literal_backs_typed_props() {
let info = parse_tsx(
"type Props = { a: string; b: number };\nexport const App = (props: Props) => <div>{props.a}</div>;",
);
assert!(typed_prop(&info, "a").used_in_script);
assert!(!typed_prop(&info, "b").used_in_script);
}
#[test]
fn typed_interface_hoists_after_component() {
let info = parse_tsx(
"export const App = (props: Props) => <div>{props.size}</div>;\ninterface Props { size: string }",
);
assert!(typed_prop(&info, "size").used_in_script);
assert!(!info.component_functions[0].has_unharvestable_props);
}
#[test]
fn interface_extends_abstains() {
let info = parse_tsx(
"interface Base { x: number }\ninterface Props extends Base { size: string }\nexport const App = (props: Props) => <div>{props.size}</div>;",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"interface extends must abstain (cannot expand parent members)",
);
assert!(info.react_props.is_empty());
}
#[test]
fn generic_props_type_abstains() {
let info = parse_tsx(
"interface Props<T> { value: T }\nexport const App = (props: Props<string>) => <div>{props.value}</div>;",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"a generic props type reference with args must abstain",
);
assert!(info.react_props.is_empty());
}
#[test]
fn props_with_children_wrapper_abstains() {
let info = parse_tsx(
"interface Own { size: number }\nexport const App = (props: React.PropsWithChildren<Own>) => <div>{props.size}</div>;",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"a React.PropsWithChildren wrapper must abstain",
);
assert!(info.react_props.is_empty());
}
#[test]
fn function_declaration_typed_props_are_harvested() {
let info = parse_tsx(
"interface Props { size: number }\nexport function App(props: Props) { return <div>{props.size}</div>; }",
);
assert!(typed_prop(&info, "size").used_in_script);
}
#[test]
fn props_returned_whole_object_abstains() {
let info = parse_tsx(
"interface Props { size: number }\nexport const App = (props: Props) => { doSomething(props); return <div>{props.size}</div>; };",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"passing props to a function call must abstain (whole-object use)",
);
}
#[test]
fn computed_member_access_on_props_abstains() {
let info = parse_tsx(
"interface Props { size: number }\nexport const App = (props: Props) => <div>{props[key]}</div>;",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"computed member access on props must abstain",
);
}
#[test]
fn forwardref_inline_destructure_used_and_unused() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef(({ size, label }, ref) => <input ref={ref}>{size}</input>);",
);
assert!(
typed_prop(&info, "size").used_in_script,
"destructured `size` read in the inner body must be credited",
);
assert!(
!typed_prop(&info, "label").used_in_script,
"destructured `label` read nowhere must be unused",
);
assert!(!info.component_functions[0].has_unharvestable_props);
}
#[test]
fn forwardref_inline_destructure_renamed_local() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef(({ size: s }, ref) => <input ref={ref}>{s}</input>);",
);
let prop = typed_prop(&info, "size");
assert_eq!(prop.local, "s");
assert!(prop.used_in_script, "renamed local read must credit `size`");
}
#[test]
fn forwardref_inline_destructure_default_valued() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef(({ size = 4 }, ref) => <input ref={ref} width={size} />);",
);
assert!(
typed_prop(&info, "size").used_in_script,
"default-valued destructure read must credit `size`",
);
}
#[test]
fn forwardref_inline_destructure_rest_abstains() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef(({ size, ...rest }, ref) => <input ref={ref} {...rest}>{size}</input>);",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"rest after named in a forwardRef inner param must abstain",
);
assert!(info.react_props.is_empty());
}
#[test]
fn forwardref_bare_typed_param_same_file_used_and_unused() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
interface Props { size: number; label: string }\n\
export const Input = forwardRef((props: Props, ref) => <input ref={ref}>{props.size}</input>);",
);
assert!(typed_prop(&info, "size").used_in_script);
assert!(!typed_prop(&info, "label").used_in_script);
assert!(!info.component_functions[0].has_unharvestable_props);
}
#[test]
fn forwardref_generic_type_arg_same_file_used_and_unused() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
interface Props { size: number; label: string }\n\
export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => <input ref={ref}>{props.size}</input>);",
);
assert!(
typed_prop(&info, "size").used_in_script,
"props.size read in the inner body must credit `size`",
);
assert!(
!typed_prop(&info, "label").used_in_script,
"unread `label` from the generic type arg must be harvested unused",
);
assert!(!info.component_functions[0].has_unharvestable_props);
}
#[test]
fn forwardref_generic_type_arg_type_alias_same_file() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
type Props = { size: number; label: string };\n\
export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => <input ref={ref}>{props.size}</input>);",
);
assert!(typed_prop(&info, "size").used_in_script);
assert!(!typed_prop(&info, "label").used_in_script);
}
#[test]
fn forwardref_generic_inner_annotation_wins_over_generic() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
interface A { a: number }\n\
interface B { b: number }\n\
export const Input = forwardRef<HTMLInputElement, A>((props: B, ref) => <input ref={ref}>{props.b}</input>);",
);
assert!(typed_prop(&info, "b").used_in_script);
assert!(
info.react_props.iter().all(|p| p.name != "a"),
"the wrapper generic `A` must not back the harvest when the inner param is annotated",
);
}
#[test]
fn forwardref_generic_imported_type_arg_abstains() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
import { Props } from './types';\n\
export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => <input ref={ref}>{props.size}</input>);",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"an imported props generic arg must abstain in v2",
);
assert!(info.react_props.is_empty());
}
#[test]
fn forwardref_generic_single_type_arg_abstains() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef<HTMLInputElement>((props, ref) => <input ref={ref}>{props.size}</input>);",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"a single ref-only generic arg leaves the bare props param unharvestable",
);
assert!(info.react_props.is_empty());
}
#[test]
fn forwardref_generic_inline_object_type_arg_abstains() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
export const Input = forwardRef<HTMLInputElement, { size: number }>((props, ref) => <input ref={ref}>{props.size}</input>);",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"an inline object-literal generic type arg must abstain",
);
assert!(info.react_props.is_empty());
}
#[test]
fn memo_bare_typed_param_same_file() {
let info = parse_tsx(
"import { memo } from 'react';\n\
interface Props { size: number; label: string }\n\
export const Card = memo((props: Props) => <div>{props.size}</div>);",
);
assert!(typed_prop(&info, "size").used_in_script);
assert!(!typed_prop(&info, "label").used_in_script);
assert!(!info.component_functions[0].has_unharvestable_props);
}
#[test]
fn memo_inline_destructure_used_and_unused() {
let info = parse_tsx(
"import { memo } from 'react';\n\
export const Card = memo(({ size, label }) => <div>{size}</div>);",
);
assert!(typed_prop(&info, "size").used_in_script);
assert!(!typed_prop(&info, "label").used_in_script);
}
#[test]
fn forwardref_whole_object_props_use_in_inner_body_abstains() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
interface Props { size: number }\n\
export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => <Child {...props} ref={ref} />);",
);
assert!(
info.component_functions[0].has_unharvestable_props,
"spreading props into a child in the inner body must abstain (whole-object use)",
);
assert!(info.react_props.is_empty());
}
#[test]
fn forwardref_genuinely_unused_generic_prop_is_harvested_unused() {
let info = parse_tsx(
"import { forwardRef } from 'react';\n\
interface Props { size: number }\n\
const Input = forwardRef<HTMLInputElement, Props>((props, ref) => <input ref={ref} />);",
);
assert!(
!typed_prop(&info, "size").used_in_script,
"a generic-typed prop read nowhere must be harvested unused",
);
assert!(!info.component_functions[0].has_unharvestable_props);
}