Skip to content

fix(language-core): detect duplicate event listeners across name formats#6094

Merged
KazariEX merged 4 commits into
vuejs:masterfrom
whysopaul:feat/vue-duplicate-event-listeners
Jun 8, 2026
Merged

fix(language-core): detect duplicate event listeners across name formats#6094
KazariEX merged 4 commits into
vuejs:masterfrom
whysopaul:feat/vue-duplicate-event-listeners

Conversation

@whysopaul

Copy link
Copy Markdown
Contributor

Problem

Vue normalizes event listener names when merging props. Both @create-post and @createPost resolve to the same handler key onCreatePost at runtime via mergeProps():

<!-- Both listeners resolve to onCreatePost — both will be called -->
<BlogPost @create-post="handlerA" @createPost="handlerB" />

The same applies to update: events:

<BlogPost @update:post-title="handlerA" @update:postTitle="handlerB" />

Neither vue(2) (duplicate attribute) nor ts-plugin(1117) (duplicate object property) catch this. vue(2) compares names literally, and the TypeScript codegen produces two different string keys ("onCreate-post" vs "onCreatePost"), so TypeScript does not see the conflict either.

Root cause

mergeProps() normalizes all onXxx handler keys via camelize() before merging:

import { mergeProps } from 'vue'
 
mergeProps(
  { 'onCreate-post': handlerA },
  { onCreatePost: handlerB },
)
// -> { onCreatePost: [handlerA, handlerB] }
// Both listeners are called. mergeProps concatenates them into an array.

However, in a single JSX/template object literal the compiler does not call mergeProps — it emits a plain object where the keys come from the template verbatim. If both @create-post and @createPost appear on the same element, the codegen produces:

{ 'onCreate-post': handlerA, onCreatePost: handlerB }

TypeScript sees two different string keys and raises no error. At runtime Vue processes the vnode props through mergeProps which normalizes them, so handlerA and handlerB both end up under onCreatePost, and both are called. This looks correct on the surface but is almost certainly a typo: two different handlers registered for what the author thought were two different events.

What this PR adds

A new @vue/language-service plugin (vue-duplicate-event-listeners) that detects this class of bug statically, at edit time.

When two or more v-on listeners on the same component resolve to the same normalized key, the plugin highlights the duplicate(s) as an error: Duplicate event listener. vue(duplicate-event-listener).

Only the second (and further) occurrence is flagged, consistent with how vue(2) and ts-plugin(1117) behave.

Normalization rules

The plugin mirrors the exact normalization that mergeProps() applies at runtime. All cases below are detected:

Template Normalized key
@create-post onCreatePost
@createPost onCreatePost (duplicate)
@update:post-title onUpdate:postTitle
@update:postTitle onUpdate:postTitle (duplicate)
@vue:before-mount onVnodeBeforeMount
@vue:beforeMount onVnodeBeforeMount (duplicate)

Design decisions

Only events, not props

Duplicate static props and v-model bindings are already covered:

  • ts-plugin(1117) — fires when two props produce the same key in the codegen object literal (e.g. post-title="a" postTitle="b")
  • ts-plugin(2783) — fires when a static prop conflicts with a v-bind spread (e.g. post-title="a" v-bind="{ postTitle: 'b' }")
    Adding a third diagnostic for the same cases would produce redundant errors on the same attribute. This plugin covers only the gap that existing tools leave: v-on listeners.

Only components, not native elements

The tagType !== COMPONENT guard is intentional. On native HTML elements, @create-post and @createPost map to two separate addEventListener calls with literally different event names. The browser does not normalize them: a CustomEvent dispatched as create-post will not trigger a createPost listener. The normalization is a Vue-only mechanism that only applies to component prop passing.

Only static arguments

Dynamic event arguments (@[eventName]="fn") are skipped. Their value is unknown at static analysis time, so no false positives are possible.

v-on without argument is not covered

<!-- Not flagged — cannot be statically analyzed -->
<BlogPost @create-post="fn" v-on="{ createPost: fn }" />

v-on="expr" stores an arbitrary JavaScript expression as an unparsed string in the AST (isStatic: false). Extracting keys from it would require a JS parser and would still fail for non-literal expressions (v-on="handlers", v-on="isAdmin ? a : b"). This is an intentional limitation consistent with how all other static analysis tools treat this syntax.

First occurrence is the canonical one

When duplicates are found, only entries[1..n] are flagged — the first listener by source position is treated as the original. This matches the convention of vue(2) and ts-plugin(1117).

Severity is Error, not Warning

Other duplicate-attribute diagnostics in the same toolchain (vue(2), ts-plugin(1117), ts-plugin(2783)) all use Error severity.

Does not overlap with vue(2)

vue(2) (DUPLICATE_ATTRIBUTE from @vue/compiler-dom) fires only on literally identical attribute names. It does not normalize — @create-post and @createPost produce no vue(2) error. This plugin covers the orthogonal case: same normalized key, different literal names.

The only apparent overlap appears when three listeners are present, e.g.:

<BlogPost @create-post="a" @createPost="b" @createPost="c" />

Here vue(2) fires on @createPost (third, literally duplicates the second), and this plugin fires on @createPost (second, normalized duplicate of @create-post) and @createPost (third). The second @createPost appears to have two errors. This is technically correct — it is simultaneously a normalized duplicate of @create-post and an exact duplicate of the third @createPost — and is not worth special-casing since three listeners for the same event on one component is an extreme edge case.

Files changed

packages/language-service/
  index.ts                                              — register plugin
  lib/plugins/vue-duplicate-event-listeners.ts          — new plugin
  tests/
    utils/diagnostics.ts                                — test helper
    diagnostics/duplicate-event-listener.spec.ts        — tests

Test cases

Positive (must flag):

  • @create-post + @createPost — basic kebab vs camel
  • @create-post + @create-post — identical names (regression: must not break existing behavior)
  • @update:post-title + @update:postTitle — update events
  • @vue:before-mount + @vue:beforeMount — vnode lifecycle hooks
  • three duplicates: first is kept, second and third are flagged

Negative (must not flag):

  • @create-post + @update:title — different events
  • :title + @update:title — prop and event, different namespaces
  • @[eventName] + @[eventName] — dynamic arguments
  • <div @click @click> — native element

@KazariEX

KazariEX commented Jun 7, 2026

Copy link
Copy Markdown
Member

The language service plugin is not working for tsc environment. Could you try resolving this issue during the codegen phase in the @vue/language-core package?

@KazariEX KazariEX changed the title feat(language-service): vue duplicate event listeners plugin fix(language-core): detect duplicate event listeners across name formats Jun 8, 2026
@KazariEX KazariEX merged commit e70cb29 into vuejs:master Jun 8, 2026
4 checks passed
@whysopaul

Copy link
Copy Markdown
Contributor Author

Hello,

Thanks for revising and merging! Definitely an elegant solution.

@Airkro

Airkro commented Jun 9, 2026

Copy link
Copy Markdown

In the past, I often did this by triggering an additional event while binding data. This change causes errors in the current version. May I ask why this is not recommended?

<MyPicker v-model="result" @update:model-value="action" />

@KazariEX

KazariEX commented Jun 9, 2026

Copy link
Copy Markdown
Member

I guess you have strictVModel / strictTemplates options enabled. I will fix this issue.

@adube

adube commented Jun 9, 2026

Copy link
Copy Markdown

With vue-tsc 3.3.4, which includes this change, the following are detected as TS1117: An object literal cannot have multiple properties with the same name. errors:

      @click.exact.stop="handleItemClick"
      @click.ctrl.exact.stop="handleItemCtrlClick"
      @click.shift.exact.stop="handleItemShiftClick"

With vue-tsc 3.3.3, they were not detected as errors.

Any hint would be appreciated. Thanks.

@KazariEX

KazariEX commented Jun 9, 2026

Copy link
Copy Markdown
Member

The fix will be released at next version.

@mika76

mika76 commented Jun 10, 2026

Copy link
Copy Markdown

@adube Oh my God this has been driving me insane today!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants