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
18 changes: 18 additions & 0 deletions .changeset/yummy-melons-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@biomejs/biome": patch
---

Added the nursery rule [`noVueSetupPropsReactivityLoss`](https://biomejs.dev/linter/rules/no-vue-setup-props-reactivity-loss/).

This new rule disallows usages that cause the reactivity of `props` passed to the `setup` function to be lost.

Invalid code example:

```jsx
export default {
setup({ count }) {
// `count` is no longer reactive here.
return () => h("div", count);
},
};
```
16 changes: 16 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

129 changes: 75 additions & 54 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ define_categories! {
"lint/nursery/noVueReservedKeys": "https://biomejs.dev/linter/rules/no-vue-reserved-keys",
"lint/nursery/noVueReservedProps": "https://biomejs.dev/linter/rules/no-vue-reserved-props",
"lint/nursery/noVueVIfWithVFor": "https://biomejs.dev/linter/rules/no-vue-v-if-with-v-for",
"lint/nursery/noVueSetupPropsReactivityLoss": "https://biomejs.dev/linter/rules/no-vue-setup-props-reactivity-loss",
"lint/nursery/useAnchorHref": "https://biomejs.dev/linter/rules/use-anchor-href",
"lint/nursery/useArraySortCompare": "https://biomejs.dev/linter/rules/use-array-sort-compare",
"lint/nursery/useBiomeSuppressionComment": "https://biomejs.dev/linter/rules/use-biome-suppression-comment",
Expand Down
3 changes: 2 additions & 1 deletion crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod no_vue_data_object_declaration;
pub mod no_vue_duplicate_keys;
pub mod no_vue_reserved_keys;
pub mod no_vue_reserved_props;
pub mod no_vue_setup_props_reactivity_loss;
pub mod use_array_sort_compare;
pub mod use_consistent_arrow_return;
pub mod use_exhaustive_switch_cases;
Expand All @@ -46,4 +47,4 @@ pub mod use_sorted_classes;
pub mod use_spread;
pub mod use_vue_define_macros_order;
pub mod use_vue_multi_word_component_names;
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
use crate::frameworks::vue::vue_component::{AnyPotentialVueComponent, VueComponentQuery};
use biome_analyze::{
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_js_syntax::{
AnyJsArrowFunctionParameters, AnyJsBindingPattern, AnyJsExpression, AnyJsFunction,
AnyJsObjectMember, AnyJsObjectMemberName, JsCallExpression, JsMethodObjectMember,
JsObjectMemberList, JsParameters,
};
use biome_rowan::{AstNode, AstSeparatedList, TextRange};

declare_lint_rule! {
/// Disallow destructuring of `props` passed to `setup` in Vue projects.
///
/// In Vue's Composition API, props must be accessed as `props.propertyName` to maintain
/// reactivity. Destructuring `props` directly in the `setup` function parameters will
/// cause the resulting variables to lose their reactive nature.
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// export default {
/// setup({ count }) {
/// return () => h('div', count);
/// }
/// }
/// ```
///
/// ### Valid
///
/// ```js
/// export default {
/// setup(props) {
/// return () => h('div', props.count);
/// }
/// }
/// ```
///
pub NoVueSetupPropsReactivityLoss {
version: "2.2.6",
name: "noVueSetupPropsReactivityLoss",
language: "js",
domains: &[RuleDomain::Vue],
recommended: false,
sources: &[RuleSource::EslintVueJs("no-setup-props-reactivity-loss").inspired()],
}
}

pub struct Violation(TextRange);

impl Violation {
fn range(&self) -> TextRange {
self.0
}
}

enum SetupFunction {
Function(AnyJsFunction),
Method(JsMethodObjectMember),
}

impl Rule for NoVueSetupPropsReactivityLoss {
type Query = VueComponentQuery;
type State = Violation;
type Signals = Vec<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
match ctx.query() {
// Case: export default { setup(props) { ... } }
AnyPotentialVueComponent::JsExportDefaultExpressionClause(export) => {
let Some(expr) = export.expression().ok() else {
return vec![];
};
let Some(obj_expr) = expr.as_js_object_expression() else {
return vec![];
};
check_object_members(&obj_expr.members())
}
// Case: export default defineComponent({ setup(props) { ... } })
AnyPotentialVueComponent::JsCallExpression(call_expr) => {
check_call_expression_setup(call_expr)
}
}
}

fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
state.range(),
markup! {
"Destructuring `props` in the `setup` function parameters loses reactivity."
},
)
.note(markup! {
"To preserve reactivity, access props as properties: `props.propertyName`."
}),
)
}
}

fn check_call_expression_setup(call_expr: &JsCallExpression) -> Vec<Violation> {
if let Ok(args) = call_expr.arguments()
&& let Some(Ok(arg)) = args.args().iter().next()
&& let Some(expr) = arg.as_any_js_expression()
&& let Some(obj_expr) = expr.as_js_object_expression()
{
check_object_members(&obj_expr.members())
} else {
vec![]
}
}

fn check_object_members(members: &JsObjectMemberList) -> Vec<Violation> {
members
.iter()
.filter_map(|m| m.ok())
.filter_map(|member| find_setup_function(&member))
.filter_map(|setup| check_setup_params(&setup))
.collect()
}

fn check_setup_params(setup_fn: &SetupFunction) -> Option<Violation> {
let first_param = get_first_parameter(setup_fn)?;

match first_param {
AnyJsBindingPattern::JsObjectBindingPattern(obj) => Some(Violation(obj.range())),
AnyJsBindingPattern::JsArrayBindingPattern(arr) => Some(Violation(arr.range())),
AnyJsBindingPattern::AnyJsBinding(_) => None,
}
}

fn get_first_parameter(setup_fn: &SetupFunction) -> Option<AnyJsBindingPattern> {
match setup_fn {
SetupFunction::Method(method) => {
let params = method.parameters().ok()?;
get_first_binding_from_params(&params)
}
SetupFunction::Function(func) => get_function_first_parameter(func),
}
}

fn get_function_first_parameter(func: &AnyJsFunction) -> Option<AnyJsBindingPattern> {
match func {
AnyJsFunction::JsArrowFunctionExpression(arrow) => match arrow.parameters().ok()? {
AnyJsArrowFunctionParameters::AnyJsBinding(binding) => {
Some(AnyJsBindingPattern::AnyJsBinding(binding))
}
AnyJsArrowFunctionParameters::JsParameters(params) => {
get_first_binding_from_params(&params)
}
},
AnyJsFunction::JsFunctionDeclaration(decl) => {
get_first_binding_from_params(&decl.parameters().ok()?)
}
AnyJsFunction::JsFunctionExpression(expr) => {
get_first_binding_from_params(&expr.parameters().ok()?)
}
_ => None,
}
}

fn find_setup_function(member: &AnyJsObjectMember) -> Option<SetupFunction> {
match member {
AnyJsObjectMember::JsMethodObjectMember(method) => method
.name()
.ok()
.filter(is_named_setup)
.map(|_| SetupFunction::Method(method.clone())),
AnyJsObjectMember::JsPropertyObjectMember(property) => {
let name = property.name().ok()?;
if !is_named_setup(&name) {
return None;
}
let value = property.value().ok()?;
let func = get_function_from_expression(&value)?;
Some(SetupFunction::Function(func))
}
_ => None,
}
}

fn get_function_from_expression(expr: &AnyJsExpression) -> Option<AnyJsFunction> {
match expr {
AnyJsExpression::JsFunctionExpression(func) => {
Some(AnyJsFunction::JsFunctionExpression(func.clone()))
}
AnyJsExpression::JsArrowFunctionExpression(arrow) => {
Some(AnyJsFunction::JsArrowFunctionExpression(arrow.clone()))
}
_ => None,
}
}

fn is_named_setup(name: &AnyJsObjectMemberName) -> bool {
name.name().is_some_and(|text| text.text() == "setup")
}

fn get_first_binding_from_params(params: &JsParameters) -> Option<AnyJsBindingPattern> {
params
.items()
.iter()
.next()?
.ok()?
.as_any_js_formal_parameter()?
.as_js_formal_parameter()?
.binding()
.ok()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Parameter destructuring patterns that lose reactivity

// Basic parameter destructuring
export default {
setup({ foo, bar }) {
return () => h('div', foo + bar)
}
}

// Destructuring with default values
export default {
setup({ foo = 'default', bar }) {
return () => h('div', foo + bar)
}
}

// Destructuring with renaming
export default {
setup({ foo: renamedFoo, bar }) {
return () => h('div', renamedFoo + bar)
}
}

// Destructuring with rest pattern
export default {
setup({ foo, ...rest }) {
return () => h('div', foo + rest.bar)
}
}

// defineComponent with parameter destructuring
export default defineComponent({
setup({ foo, bar }) {
return () => h('div', foo + bar)
}
})

// Named export with parameter destructuring
export const MyComponent = {
setup({ foo, bar }) {
return () => h('div', foo + bar)
}
}
Loading