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
5 changes: 5 additions & 0 deletions .changeset/fix-no-vue-duplicate-keys-to-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Fixed [#9068](https://github.com/biomejs/biome/issues/9068): The `noVueDuplicateKeys` rule now correctly handles `toRefs(props)` patterns and no longer produces false positives when destructuring props, particularly in `<script setup>` blocks.
21 changes: 21 additions & 0 deletions crates/biome_js_analyze/src/frameworks/vue/vue_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub fn is_vue_compiler_macro_call(
model.binding(&reference).is_none()
}

/// Checks if the given expression is a reference to a Vue API function (e.g., from `vue` package or `Vue` global).
pub fn is_vue_api_reference(
expression: &AnyJsExpression,
model: &SemanticModel,
Expand All @@ -47,6 +48,26 @@ pub fn is_vue_api_reference(
)
}

/// Checks if the given call expression is a call to `toRefs` from the Vue package.
///
/// `toRefs` is used to convert a reactive object to a plain object where each property
/// is a ref pointing to the corresponding property of the original object. This is commonly
/// used with `defineProps` to destructure props while maintaining reactivity.
///
/// This function handles both imported `toRefs` and auto-imported `toRefs` in Vue `<script setup>`.
///
/// Example: `const { foo, bar } = toRefs(props)`
pub fn is_to_refs_call(call: &JsCallExpression, model: &SemanticModel) -> bool {
let Some(callee) = call.callee().ok() else {
return false;
};
let Some(expression) = callee.inner_expression() else {
return false;
};

is_vue_api_reference(&expression, model, "toRefs")
}
Comment on lines +51 to +69
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use a fenced rustdoc code block for the example.
Inline snippets make the docs harder to validate.

✍️ Suggested rustdoc tweak
-/// Example: `const { foo, bar } = toRefs(props)`
+/// Example:
+/// ```js
+/// const { foo, bar } = toRefs(props);
+/// ```

As per coding guidelines, "Use doc tests (doctest) format with code blocks in rustdoc comments; ensure assertions pass in tests".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Checks if the given call expression is a call to `toRefs` from the Vue package.
///
/// `toRefs` is used to convert a reactive object to a plain object where each property
/// is a ref pointing to the corresponding property of the original object. This is commonly
/// used with `defineProps` to destructure props while maintaining reactivity.
///
/// This function handles both imported `toRefs` and auto-imported `toRefs` in Vue `<script setup>`.
///
/// Example: `const { foo, bar } = toRefs(props)`
pub fn is_to_refs_call(call: &JsCallExpression, model: &SemanticModel) -> bool {
let Some(callee) = call.callee().ok() else {
return false;
};
let Some(expression) = callee.inner_expression() else {
return false;
};
is_vue_api_reference(&expression, model, "toRefs")
}
/// Checks if the given call expression is a call to `toRefs` from the Vue package.
///
/// `toRefs` is used to convert a reactive object to a plain object where each property
/// is a ref pointing to the corresponding property of the original object. This is commonly
/// used with `defineProps` to destructure props while maintaining reactivity.
///
/// This function handles both imported `toRefs` and auto-imported `toRefs` in Vue `<script setup>`.
///
/// Example:
///
🤖 Prompt for AI Agents
In `@crates/biome_js_analyze/src/frameworks/vue/vue_call.rs` around lines 51 - 69,
Update the rustdoc for is_to_refs_call to use a fenced code block in doctest
format: replace the inline example comment with a triple-backtick fenced block
specifying the language (js) and include the example line(s) inside (e.g., const
{ foo, bar } = toRefs(props);) followed by closing triple backticks so the
example becomes a proper rustdoc code block that can be validated as a doctest.


const VUE_PACKAGE_NAMES: &[&str] = &["vue"];
const VUE_GLOBAL_NAME: Option<&str> = Some("Vue");

Expand Down
67 changes: 66 additions & 1 deletion crates/biome_js_analyze/src/frameworks/vue/vue_component.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::frameworks::vue::vue_call::{is_vue_api_reference, is_vue_compiler_macro_call};
use crate::frameworks::vue::vue_call::{
is_to_refs_call, is_vue_api_reference, is_vue_compiler_macro_call,
};
use crate::services::semantic::Semantic;
use biome_js_semantic::SemanticModel;
use biome_js_syntax::{
Expand Down Expand Up @@ -743,6 +745,10 @@ declare_node_union! {
}

impl AnyVueSetupDeclaration {
/// Checks if this setup declaration is assigned directly from `defineProps`.
///
/// This handles cases like `const { foo } = defineProps<{ foo: string }>()` where
/// the destructured property comes from the props definition.
pub fn is_assigned_to_props(&self, model: &SemanticModel) -> bool {
Comment on lines +748 to 752
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Move the defineProps example into a rustdoc code block.
Keeps docs consistent with doctest formatting expectations.

✍️ Suggested rustdoc tweak
-/// This handles cases like `const { foo } = defineProps<{ foo: string }>()` where
-/// the destructured property comes from the props definition.
+/// This handles cases like:
+/// ```js
+/// const { foo } = defineProps<{ foo: string }>();
+/// ```
+/// where the destructured property comes from the props definition.

As per coding guidelines, "Use doc tests (doctest) format with code blocks in rustdoc comments; ensure assertions pass in tests".

🤖 Prompt for AI Agents
In `@crates/biome_js_analyze/src/frameworks/vue/vue_component.rs` around lines 748
- 752, The rustdoc for the method is_assigned_to_props should put the example
usage into a doctest code block: replace the inline example "const { foo } =
defineProps<{ foo: string }>()" with a fenced code block using triple backticks
and the js language tag (/// ```js ... /// ```), so the comment becomes a proper
rustdoc code block that will be picked up by doctests and keep the surrounding
explanatory sentence intact; update the doc comment above pub fn
is_assigned_to_props accordingly.

if let Self::JsIdentifierBinding(binding) = self
&& let Some(declarator) = binding
Expand All @@ -760,6 +766,65 @@ impl AnyVueSetupDeclaration {
}
false
}

/// Checks if this setup declaration is assigned from `toRefs(props)`.
///
/// This handles cases like `const { foo } = toRefs(props)` where the destructured
/// property comes from converting props to refs. These should not be flagged as
/// duplicates of the props themselves.
///
/// Only returns true if the argument to `toRefs` is either:
/// - A direct `defineProps()` call
/// - An identifier whose binding resolves to a variable initialized from `defineProps`
pub fn is_assigned_to_to_refs(&self, model: &SemanticModel) -> bool {
if let Self::JsIdentifierBinding(binding) = self
&& let Some(declarator) = binding
.syntax()
.ancestors()
.find_map(|syntax| JsVariableDeclarator::try_cast(syntax).ok())
&& let Some(initializer) = declarator.initializer()
&& let Some(expression) = initializer
.expression()
.ok()
.and_then(|expression| expression.inner_expression())
&& let AnyJsExpression::JsCallExpression(call) = expression
&& is_to_refs_call(&call, model)
{
// Check that the first argument to `toRefs` is the props source
if let Some(Ok(first_arg)) = call
.arguments()
.ok()
.and_then(|args| args.args().iter().next())
&& let Some(arg_expr) = first_arg.as_any_js_expression()
&& let Some(arg_expr) = arg_expr.inner_expression()
{
// Case 1: Direct defineProps() call: `toRefs(defineProps(...))`
if let AnyJsExpression::JsCallExpression(arg_call) = &arg_expr
&& is_vue_compiler_macro_call(arg_call, model, "defineProps")
{
return true;
}

// Case 2: Identifier bound to defineProps: `const props = defineProps(...); toRefs(props)`
if let Some(ident_ref) = arg_expr.as_js_reference_identifier()
&& let Some(binding) = model.binding(&ident_ref)
&& let Some(declarator) = binding
.syntax()
.ancestors()
.find_map(|syntax| JsVariableDeclarator::try_cast(syntax).ok())
&& let Some(decl_initializer) = declarator.initializer()
&& let Some(decl_expr) = decl_initializer
.expression()
.ok()
.and_then(|expr| expr.inner_expression())
&& let AnyJsExpression::JsCallExpression(decl_call) = decl_expr
{
return is_vue_compiler_macro_call(&decl_call, model, "defineProps");
}
}
}
false
}
}

impl VueDeclarationName for AnyVueSetupDeclaration {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ impl Rule for NoVueDuplicateKeys {
if let Some(name) = declaration.declaration_name() {
// Handle cases like `const { foo } = defineProps(...);`.
if let VueDeclaration::Setup(ref setup_decl) = declaration
&& setup_decl.is_assigned_to_props(model)
&& (setup_decl.is_assigned_to_props(model)
|| setup_decl.is_assigned_to_to_refs(model))
{
continue;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- should emit diagnostics -->

<script setup>
import { toRefs, reactive } from 'vue';
defineProps<{ toast: string; number?: number }>();
const obj = reactive({ toast: 'toast' });
const { toast } = toRefs(obj)
</script>

<template>
<!-- Which `toast` this refers to is ambiguous, because defineProps makes the props available in the template area -->
{{ toast }}
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: invalid-script-setup-to-refs-no-assign.vue
---
# Input
```ts
import { toRefs, reactive } from 'vue';
defineProps<{ toast: string; number?: number }>();
const obj = reactive({ toast: 'toast' });
const { toast } = toRefs(obj)

```

# Diagnostics
```
invalid-script-setup-to-refs-no-assign.vue:2:15 lint/correctness/noVueDuplicateKeys ━━━━━━━━━━━━━━━━

× Duplicate key toast found in Vue component.

1 │ import { toRefs, reactive } from 'vue';
> 2 │ defineProps<{ toast: string; number?: number }>();
│ ^^^^^
3 │ const obj = reactive({ toast: 'toast' });
4 │ const { toast } = toRefs(obj)

i Key toast is also defined here.

2 │ defineProps<{ toast: string; number?: number }>();
3 │ const obj = reactive({ toast: 'toast' });
> 4 │ const { toast } = toRefs(obj)
│ ^^^^^
5 │

i Keys defined in different Vue component options (props, data, methods, computed) can conflict when accessed in the template. Rename the key to avoid conflicts.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!-- should not emit diagnostics -->

<script setup lang="ts">
import { toRefs } from 'vue';
interface Props {
videoUrl: string;
thumbnail?: string;
}
const props = defineProps<Props>()
const { videoUrl, thumbnail } = toRefs(props)
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: valid-script-setup-to-refs-interface.vue
---
# Input
```ts
import { toRefs } from 'vue';
interface Props {
videoUrl: string;
thumbnail?: string;
}
const props = defineProps<Props>()
const { videoUrl, thumbnail } = toRefs(props)

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!-- should not emit diagnostics -->

<script setup>
import { toRefs } from 'vue';
const props = defineProps<{ toast: string; number?: number }>()
const { toast } = toRefs(props)
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: valid-script-setup-to-refs.vue
---
# Input
```ts
import { toRefs } from 'vue';
const props = defineProps<{ toast: string; number?: number }>()
const { toast } = toRefs(props)

```