Auditing a Vue 2 component library for a Vue 3 upgrade, which template and event patterns break, including silently, and how do you find them before consumers do?
answer
- listeners now live in $attrs
- undeclared events fall through natively
- .native ignored, .sync one-way
- v-if now outranks v-for
- functional and filters removed
basics
~20 sVisible breaks: value/input v-model, functional components, $on buses, v-if beside v-for. Silent ones: .sync goes one-way, .native no longer applies, undeclared re-emitted events fire twice, class moves under inheritAttrs: false. Find them by search, migration-build warnings and contract tests.
solid answer
~50 sI sort findings into visible and silent breaks. Visible: `value`/`input` v-model contracts (fields render empty), `functional: true` or `<template functional>`, `$on`/`$off` buses, and `v-on="$listeners"`, which beside `v-bind="$attrs"` now just warns, since listeners already travel in `$attrs`. Silent ones need testing: `:prop.sync` becomes a one-way binding, `.native` on a click is simply ignored, a leftover filter computes a bitwise OR, and a button that re-emits `click` without declaring it in `emits` now fires the parent's handler **twice**, once from `$emit` and once as a native listener falling through to the root; with `inheritAttrs: false`, `class` and `style` are now in `$attrs` and move to the inner element. `v-if` beside `v-for` now evaluates first, cannot see the loop variable and throws at render. I find them by searching for the syntactic patterns, running the library under the migration build and triaging its deprecation warnings, and adding component tests that assert emitted-event counts and root attributes.
code
vue · 12 lines<!-- BaseButton.vue, Vue 3 -->
<script setup lang="ts">
// Without this declaration, a parent's @click would ALSO be bound
// natively to <button>, and the handler would run twice per click.
const emit = defineEmits<{ click: [event: MouseEvent] }>()
</script>
<template>
<button type="button" @click="emit('click', $event)">
<slot />
</button>
</template>go deeper
Know the removed pieces by name: $listeners, .native, .sync, filters, functional components and $on, and that v-if now wins over v-for.
Explain the mechanisms behind the changes: listeners merged into $attrs, undeclared events falling through natively, and class and style joining $attrs.
Separate loud from silent breaks, catch the double-fire and moved-class regressions with tests that assert counts and attributes, and triage migration-build warnings.
Treat the upgrade as a contract change for every consumer: version renamed events and v-model props, and sequence library releases ahead of the apps.
## Why a component library is the hard case An application can be fixed and redeployed in one go. A **component library** is consumed by many apps, and its public contract is exactly what Vue 3 changed: the props that `v-model` binds, the events a component emits, which attributes land on which element. A library audit therefore looks for two kinds of breakage: - **loud breaks**, where the old API is gone and something visibly fails; - **silent breaks**, where the code still runs but behaves differently for consumers. ## The checklist | Vue 2 pattern | What happens in Vue 3 | Fix | |---|---|---| | `v-bind="$attrs" v-on="$listeners"` | `$listeners` removed; listeners are `on*` entries in `$attrs` | Keep `v-bind="$attrs"`, delete `v-on="$listeners"` | | `inheritAttrs: false` relying on `class`/`style` staying on the root | `class` and `style` are now part of `$attrs` | Re-check styling; bind them where they should go | | Consumers writing `@click.native` | `.native` removed and no longer recognised, so on a click it is simply ignored | Drop the modifier; declare real component events in `emits` | | No `emits` on components that re-emit native events | Undeclared listeners fall through to the root as native listeners | Declare the event in `emits`, or stop re-emitting | | `value`/`input` v-model, `model` option, `:x.sync` | Replaced by `modelValue`/`update:modelValue` and `v-model:x` | Rename the prop and event; update docs and call sites | | Filters in templates | Removed; `\|` is bitwise OR | Method calls or imported functions | | `v-if` and `v-for` on one element | `v-if` now has higher precedence | Filter in a computed, or move `v-if` to a wrapper | | `functional: true`, `<template functional>` | Removed | Plain function components, or ordinary stateful ones | | `$on`/`$off`/`$once` buses | Removed | External emitter as stopgap, then props, events, `provide`/`inject` | ## The breaks that surprise people **Double events.** Take a button whose template is `<button @click="$emit('click', $event)">`. In Vue 2, a consumer's `@click` listened only to the emitted event. In Vue 3, a listener for an event the component does not declare in `emits` is treated as a fallthrough attribute and bound to the root element as a native listener. The consumer's handler now runs **twice** per click: once from `$emit`, once from the native event. Declaring `emits: ['click']` fixes it, as does removing the re-emit, since consumers can now listen to the native event without `.native`. **Moving classes.** A Vue 2 input wrapper with `inheritAttrs: false` and `v-bind="$attrs"` on an inner `<input>` kept the consumer's `class` on the outer root, because Vue 2 excluded `class` and `style` from `$attrs`. In Vue 3 they are in `$attrs`, so the class silently moves to the `<input>` and the consumer's layout changes. **Ignored modifiers.** Two Vue 2 modifiers do not fail in Vue 3, they just stop doing their job. `.sync` is no longer recognised, so `:title.sync` compiles to a plain one-way `:title` and the child's `update:title` reaches no listener. `.native` is no longer recognised either; on a click listener it is dropped, and the handler then behaves like any other component listener: it reaches the root element natively only if the child does not declare that event in `emits` and keeps attribute inheritance on. **Precedence flip.** `<li v-for="u in users" v-if="u.active">` filtered per item in Vue 2. In Vue 3, `v-if` runs first, before `u` exists, so the condition reads an undefined property and rendering throws. The fix is a computed `activeUsers`, or `<template v-for>` with the `v-if` on the inner element. ## Other removals worth one search each A library audit should also sweep for the smaller removals listed in the migration guide, each of which fails loudly once found: - `$children`, removed: reach child components through template refs instead; - `$scopedSlots`, removed: all slots are exposed as functions on `$slots`; - `hook:` lifecycle listeners, renamed to the `vue:` prefix; - `keyCode` modifiers such as `@keyup.13`, removed in favour of key-name modifiers like `.enter`; - the `inline-template` attribute and the `$destroy` instance method, both removed. ## How to find them 1. **Search for the syntactic patterns**: `$listeners`, `.native`, `.sync`, `functional`, `$on(`, `$once(`, `model:`, and pipes inside `{{ }}` and bound attributes. Most loud breaks surface here. 2. **Run the library and a consumer app on the migration build** and triage its deprecation warnings; each one names the changed behaviour and links to the migration guide entry. 3. **Pin the contract with component tests** before changing code: assert how many times an event is emitted, what the root element's attributes and classes are, and which list items render. Those tests catch the silent breaks that searching cannot. 4. **Fix per component and version the changes**: renamed v-model props and newly declared events are breaking changes for consumers and belong in the library's changelog.
- Why did removing `.native` make the `emits` option important in Vue 3?In Vue 2, `.native` was how a consumer asked for a native listener on a component's root; without it, listeners only heard `$emit`. Vue 3 drops `.native` and instead treats every listener the component does not declare in `emits` as a fallthrough attribute bound natively to the root. So `emits` is now how a component says which events are its own; undeclared ones reach the DOM element.
- How would you write a test that catches the double-click regression in a migrated button?Mount the button with an `onClick` handler passed as a prop (or attribute), trigger one click on the rendered `<button>`, and assert the handler was called exactly once. Before `click` is declared in `emits` the assertion fails with two calls; after, it passes. Asserting counts, not just that an event occurred, is what exposes this class of silent break.
saying these in an interview costs you the question
- If every component compiles under Vue 3, the library is migrated
- Vue 3 still needs v-on="$listeners" next to v-bind="$attrs"
- Undeclared events in Vue 3 fire only through $emit, like Vue 2
- v-for still evaluates before v-if on the same element in Vue 3
- inheritAttrs: false still keeps class and style on the root