Vue

Vue 3 and Nuxt — reactivity that silently stops updating, v-html, SSR state leaks.

What this is

Vue 3 and Nuxt — reactivity that silently stops updating, v-html, SSR state leaks.

How to onboard it

A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:

terminal
$ npx redlinegate init  # detects the profile
$ npx redlinegate init --profile web-vue  # or name one

One profile pulls these rules in: web-vue.

Once onboarded, your files match this stack when they fit any of these globs:

  • **/*.vue
  • composables/**
  • pages/**
  • server/**/*.ts
  • src/**/*.ts
  • app/**/*.ts
  • apps/**/src/**/*.ts
  • packages/**/src/**/*.ts

How to use it — 20 rules

Nothing to run. Once your profile includes Vue, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.

Rule idSeverityCatches
vue/v-html-sinkBLOCKER`v-html` bound to anything not sanitised on the server. Vue does not sanitise it. A comment, a CMS field or a translation string rendered this way is script execution.
vue/dynamic-component-from-inputBLOCKER`<component :is>` resolved from a user-supplied string. It selects which component runs; an attacker choosing that is an injection sink.
vue/ssr-module-scope-stateBLOCKERMutable state declared at module scope in an SSR app. On the server the module is shared by every request, so one user's data renders in another user's response. State belongs in `setup()`, a store factory, or `useState()` in Nuxt.
vue/public-env-secretBLOCKERA secret read through `import.meta.env.VITE_*` or Nuxt `runtimeConfig.public`. Both are inlined into the client bundle by design. Server-only values go in `runtimeConfig` without `public`.
vue/reactivity-lost-on-destructureBLOCKERA `reactive()` object or `props` destructured into plain variables. The binding is a one-time copy: the value freezes at its first read and the view silently stops updating. Use `toRefs()`, or keep the object.
vue/prop-mutationBLOCKERA child writing to a prop, or mutating an object it received as one. The parent owns that value; the write is lost on the next parent render and the two trees disagree until then.
vue/lifecycle-after-awaitHIGH`onMounted`/`onUnmounted` registered after an `await` in `setup()`. Registration is synchronous — past the first await there is no active instance, so the hook never runs and its cleanup never runs either.
vue/watcher-missing-cleanupHIGHA `watch`/`watchEffect` that starts a timer, a listener or a request without `onWatcherCleanup` / `onScopeDispose` — the previous run keeps going and its late response overwrites the current one.
vue/watch-instead-of-computedHIGHA `watch` whose only job is assigning to another ref. That is derived state: `computed()` recomputes correctly, cannot go stale, and needs no cleanup.
vue/reactive-reassignmentHIGHReassigning a whole `reactive()` array or object (`state.items = next`) where callers captured the original reference — they keep the old proxy. Mutate in place, or use `ref()` and assign `.value`.
vue/deep-watch-large-objectHIGH`{ deep: true }` over a large structure or an entire store. Every mutation anywhere inside re-runs the handler; watch the specific getter instead.
vue/v-for-index-keyHIGH`:key="index"` on a list that reorders, inserts or deletes — Vue reuses the wrong node and component state follows the index, not the item.
vue/v-if-with-v-forHIGH`v-if` and `v-for` on the same element. The precedence is not what it reads like and the filter re-runs per item; filter in a `computed`.
vue/async-setup-without-suspenseHIGH`async setup()` in a component not wrapped in `<Suspense>` — it renders nothing, with no error, until the promise settles.
vue/composable-called-conditionallyHIGHA composable called inside a condition, a loop or a callback. Like hooks, they bind to the active instance at call time.
vue/unvalidated-route-paramHIGH`route.params` / `route.query` consumed as a typed value with no validation — external input, whatever the router's types claim.
vue/prefer-script-setupSUGGESTION`<script setup>` over the options object in new components — less ceremony and better type inference.
vue/prefer-typed-definepropsSUGGESTIONType-based `defineProps<T>()` over the runtime object form where the project is TypeScript.
vue/prefer-shallowrefSUGGESTION`shallowRef` for large immutable payloads that are replaced rather than edited — deep proxying them costs on every access.
vue/oversized-sfcSUGGESTIONSingle-file component beyond ~300 lines — suggest extracting a composable.

Expected output

A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:

a finding from this file
Redline/BLOCKER [vue/v-html-sink]: <one-line problem>

Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 20 rules here, 6 BLOCKER, 10 HIGH and 4 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.

If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.

How to edit it

Rules are edited in standards/stacks/vue.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.

  1. Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
  2. node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
  3. node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
  4. Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
  5. Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
  6. node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.

Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.

The full file

standards/stacks/vue.md · 55 lines · 4.4 KB
# Vue Review Rules

**Scope:** Vue 3 single-file components, composables, and Nuxt applications. Options-API
files are reviewed against the same rules — the reactivity model is shared even where the
syntax is not.

Losing reactivity is the failure mode this file exists for: nothing throws, nothing logs,
the screen simply stops matching the data.

## BLOCKER — request changes

- `vue/v-html-sink` — **`v-html` bound to anything not sanitised on the server.** Vue does not sanitise it. A comment, a CMS field or a translation string rendered this way is script execution.
- `vue/dynamic-component-from-input` — **`<component :is>` resolved from a user-supplied string.** It selects which component runs; an attacker choosing that is an injection sink.
- `vue/ssr-module-scope-state` — **Mutable state declared at module scope in an SSR app.** On the server the module is shared by every request, so one user's data renders in another user's response. State belongs in `setup()`, a store factory, or `useState()` in Nuxt.

  ```ts
  // WRONG — one `cart` for the whole server process
  const cart = reactive({ items: [] });
  export const useCart = () => cart;
  // RIGHT
  export const useCart = () => useState('cart', () => ({ items: [] }));
  ```

- `vue/public-env-secret` — **A secret read through `import.meta.env.VITE_*` or Nuxt `runtimeConfig.public`.** Both are inlined into the client bundle by design. Server-only values go in `runtimeConfig` without `public`.
- `vue/reactivity-lost-on-destructure` — **A `reactive()` object or `props` destructured into plain variables.** The binding is a one-time copy: the value freezes at its first read and the view silently stops updating. Use `toRefs()`, or keep the object.

  ```ts
  // WRONG
  const { count } = reactive({ count: 0 });
  // RIGHT
  const { count } = toRefs(state);
  ```

- `vue/prop-mutation` — **A child writing to a prop, or mutating an object it received as one.** The parent owns that value; the write is lost on the next parent render and the two trees disagree until then.

## HIGH

- `vue/lifecycle-after-await` — `onMounted`/`onUnmounted` registered after an `await` in `setup()`. Registration is synchronous — past the first await there is no active instance, so the hook never runs and its cleanup never runs either.
- `vue/watcher-missing-cleanup` — A `watch`/`watchEffect` that starts a timer, a listener or a request without `onWatcherCleanup` / `onScopeDispose` — the previous run keeps going and its late response overwrites the current one.
- `vue/watch-instead-of-computed` — A `watch` whose only job is assigning to another ref. That is derived state: `computed()` recomputes correctly, cannot go stale, and needs no cleanup.
- `vue/reactive-reassignment` — Reassigning a whole `reactive()` array or object (`state.items = next`) where callers captured the original reference — they keep the old proxy. Mutate in place, or use `ref()` and assign `.value`.
- `vue/deep-watch-large-object` — `{ deep: true }` over a large structure or an entire store. Every mutation anywhere inside re-runs the handler; watch the specific getter instead.
- `vue/v-for-index-key` — `:key="index"` on a list that reorders, inserts or deletes — Vue reuses the wrong node and component state follows the index, not the item.
- `vue/v-if-with-v-for` — `v-if` and `v-for` on the same element. The precedence is not what it reads like and the filter re-runs per item; filter in a `computed`.
- `vue/async-setup-without-suspense` — `async setup()` in a component not wrapped in `<Suspense>` — it renders nothing, with no error, until the promise settles.
- `vue/composable-called-conditionally` — A composable called inside a condition, a loop or a callback. Like hooks, they bind to the active instance at call time.
- `vue/unvalidated-route-param` — `route.params` / `route.query` consumed as a typed value with no validation — external input, whatever the router's types claim.

## SUGGESTION

- `vue/prefer-script-setup` — `<script setup>` over the options object in new components — less ceremony and better type inference.
- `vue/prefer-typed-defineprops` — Type-based `defineProps<T>()` over the runtime object form where the project is TypeScript.
- `vue/prefer-shallowref` — `shallowRef` for large immutable payloads that are replaced rather than edited — deep proxying them costs on every access.
- `vue/oversized-sfc` — Single-file component beyond ~300 lines — suggest extracting a composable.