vue-ops
Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 4. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt4, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/vue-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Vue Operations
Comprehensive Vue 3 reference covering Composition API, Pinia, Vue Router, Nuxt 4, and testing — production patterns with TypeScript throughout.
Vue 3 / Nuxt 4 ecosystem facts verified as of 2026-07-05.
Reactivity Decision Tree
What data do I need to make reactive?
│
├─ A single primitive (string, number, boolean)?
│ └─ ref()
│ const count = ref(0)
│ const name = ref('')
│
├─ A plain object or array with deep reactivity?
│ ├─ Will I destructure it or pass properties individually?
│ │ └─ reactive() — but use toRefs() when destructuring
│ └─ Will I replace the whole object at once?
│ └─ ref() — ref.value = newObject
│
├─ Derived/computed state from other reactive sources?
│ └─ computed()
│ const doubled = computed(() => count.value * 2)
│
├─ A large object where only top-level keys change?
│ └─ shallowRef() or shallowReactive()
│ const state = shallowRef({ nested: { big: 'data' } })
│
├─ Side effects that should run when dependencies change?
│ ├─ Don't need to know old value, auto-tracks dependencies?
│ │ └─ watchEffect(() => { ... })
│ └─ Need old/new values, explicit sources, or lazy execution?
│ └─ watch(source, (newVal, oldVal) => { ... })
│
└─ Data that should NOT be reactive (raw DOM, third-party instances)?
└─ markRaw(obj) or shallowRef(obj)
Component Communication Decision Tree
How far does data need to travel?
│
├─ Parent → direct child?
│ └─ props (defineProps)
│ Direct, explicit, type-safe
│
├─ Child → parent (user action / data update)?
│ └─ emit (defineEmits)
│ defineEmits<{ change: [value: string] }>()
│
├─ Parent ↔ child bidirectional binding?
│ └─ v-model via defineModel() (Vue 3.4+)
│ const model = defineModel<string>()
│
├─ Ancestor → deep descendant (prop drilling problem)?
│ └─ provide / inject
│ Use InjectionKey<T> for type safety
│
├─ Siblings or unrelated components?
│ ├─ Simple/few shared values?
│ │ └─ provide / inject from a common ancestor
│ └─ Complex shared state or cross-tree communication?
│ └─ Pinia store
│
├─ Truly global state (user session, cart, preferences)?
│ └─ Pinia store
│ defineStore with setup syntax
│
└─ One-time events between distant components (rare)?
└─ Pinia action + watch, or mitt event bus
Avoid: Vue removed $emit on root in Vue 3
Composition API Quick Reference
<script setup> — the standard
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
// Props — with TypeScript generics (no runtime declaration needed)
const props = defineProps<{
title: string
count?: number
}>()
// Props with defaults
const props = withDefaults(defineProps<{
size: 'sm' | 'md' | 'lg'
disabled?: boolean
}>(), {
size: 'md',
disabled: false,
})
// Emits — type-safe event signatures
const emit = defineEmits<{
change: [value: string] // named tuple syntax (Vue 3.3+)
update: [id: number, data: object]
close: []
}>()
// Reactive state
const count = ref(0)
const user = reactive({ name: '', email: '' })
// Computed
const doubled = computed(() => count.value * 2)
// Watch
watch(count, (newVal, oldVal) => {
console.log(`count changed from ${oldVal} to ${newVal}`)
})
// Lifecycle
onMounted(() => {
console.log('component mounted')
})
</script>
defineModel — v-model binding (Vue 3.4+)
<!-- Child component: MyInput.vue -->
<script setup lang="ts">
const model = defineModel<string>({ required: true })
// Named v-model: <MyInput v-model:title="..." />
const title = defineModel<string>('title')
// With modifiers
const [modelValue, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
defineExpose — expose to parent refs
<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)
function focus() {
inputRef.value?.focus()
}
// Expose public API for parent template refs
defineExpose({ focus })
</script>
defineOptions — component meta (Vue 3.3+)
<script setup lang="ts">
defineOptions({
name: 'MyComponent',
inheritAttrs: false,
})
</script>
defineSlots — type slots (Vue 3.3+)
<script setup lang="ts">
defineSlots<{
default(props: { item: User }): any
header(props: {}): any
}>()
</script>
Pinia Quick Start
Setup syntax (recommended — composable style)
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0)
const name = ref('Counter')
// getters
const doubled = computed(() => count.value * 2)
// actions
function increment() {
count.value++
}
async function fetchData() {
const data = await api.get('/data')
count.value = data.total
}
return { count, name, doubled, increment, fetchData }
})
Options syntax
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubled: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
},
})
Using stores in components
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// storeToRefs preserves reactivity when destructuring state/getters
// Actions can be destructured directly (they're not reactive)
const { count, doubled } = storeToRefs(store)
const { increment } = store
</script>
Pinia plugins — persistence example
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// In store:
export const useAuthStore = defineStore('auth', () => { ... }, {
persist: true, // or { storage: sessionStorage, paths: ['token'] }
})
Vue Router Quick Reference
Basic configuration
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'), // lazy load
},
{
path: '/users/:id',
name: 'user',
component: () => import('@/views/UserView.vue'),
props: true, // passes :id as prop
meta: { requiresAuth: true },
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
children: [
{ path: '', component: () => import('@/views/admin/Dashboard.vue') },
{ path: 'users', component: () => import('@/views/admin/Users.vue') },
],
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
},
})
export default router
Navigation guards
// Global guard — auth check
router.beforeEach((to, from) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
// Per-route guard
{
path: '/admin',
beforeEnter: (to, from) => {
if (!isAdmin()) return { name: 'forbidden' }
},
}
<!-- In-component guard -->
<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
return confirm('Leave without saving?')
}
})
</script>
TypeScript meta typing
// router/index.ts — augment RouteMeta
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
title?: string
breadcrumb?: string
}
}
Nuxt 4 Decision Tree
Nuxt 4's flagship change over Nuxt 3 is the app/ source directory (app code separated
from server/ and root config — see ./references/nuxt.md); the
rendering strategies below are unchanged.
What rendering strategy does my app need?
│
├─ Public content (blogs, marketing, docs)?
│ ├─ Content rarely changes (< daily)?
│ │ └─ SSG — prerender: { routes: ['/', '/about'] }
│ └─ Content updated frequently?
│ └─ ISR — routeRules: { '/blog/**': { isr: 3600 } }
│
├─ Dynamic per-user content (dashboards, apps)?
│ └─ SSR — ssr: true (Nuxt default)
│ Best for SEO + authenticated data
│
├─ Admin panel / internal tool (no SEO needed)?
│ └─ SPA — ssr: false in nuxt.config.ts
│
├─ Mixed needs (marketing pages + app)?
│ └─ Hybrid — routeRules per path
│ routeRules: {
│ '/': { prerender: true },
│ '/blog/**': { isr: 3600 },
│ '/app/**': { ssr: true },
│ '/admin/**': { ssr: false },
│ }
│
└─ Deploying to...
├─ Cloudflare Workers/Pages → preset: 'cloudflare'
├─ Vercel → preset: 'vercel' (auto-detected)
├─ Netlify → preset: 'netlify' (auto-detected)
└─ Node.js server → preset: 'node-server'
Rendering Performance Quick Wins
| Technique | When to Use |
|---|---|
v-memo="[dep1, dep2]" |
Skip re-rendering a subtree (usually a v-for row) unless listed deps changed — only for measured hot lists |
<KeepAlive> |
Cache component instances across tab/route switches; pair with onActivated/onDeactivated for refresh logic |
| Virtual scrolling | Lists with hundreds+ of rows — vue-virtual-scroller or @tanstack/vue-virtual render only visible items |
shallowRef / markRaw |
Large objects or third-party instances that don't need deep reactivity (see Reactivity Decision Tree) |
<!-- v-memo: row re-renders only when item.id or selection state changes -->
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id, item.id === selectedId]"
>
{{ item.name }} — {{ item.id === selectedId ? 'selected' : '' }}
</div>
Tip: before writing a composable, check VueUse — 200+ battle-tested composables (useLocalStorage, useIntersectionObserver, useDark, ...) that handle SSR and cleanup edge cases.
Common Gotchas
| Gotcha | Why | Fix |
|---|---|---|
Reactivity lost after destructuring reactive() |
Destructuring extracts plain values, not refs | Use toRefs(state) when destructuring, or use ref() instead of reactive() |
ref.value needed in <script>, not in <template> |
Template auto-unwraps top-level refs | Access as count in template, count.value in script |
watch doesn't fire on nested object changes |
Default is shallow watch | Add { deep: true } or watch a specific nested path () => obj.nested.prop |
| Async setup breaks SSR in Nuxt | await in setup() suspends the component |
Use useAsyncData or useFetch — never raw await fetch() in Nuxt setup |
watchEffect runs immediately and tracks lazily |
Tracks dependencies at runtime, not statically | Use watch with explicit sources when you need control over what's tracked |
Template refs are null before mount |
ref() is null until component is mounted |
Access template refs inside onMounted or use watch with { immediate: false } |
| Pinia store state lost when destructuring | State properties are not reactive when pulled out directly | Always use storeToRefs(store) for state/getters; destructure actions directly |
| Props are readonly — mutating causes warning | Vue enforces one-way data flow | Emit event to parent and let parent update; or use defineModel() for two-way binding |
computed setter not called on direct assignment |
Computed with no setter is read-only by default | Define get and set: computed({ get: () => ..., set: (v) => ... }) |
v-model on component uses wrong prop/event name |
Default v-model uses modelValue prop and update:modelValue event |
Use defineModel() (Vue 3.4+) or manually wire modelValue prop + update:modelValue emit |
provide value is not reactive |
Providing a raw value instead of a ref | Provide ref() or reactive() so injectors see updates: provide('key', ref(value)) |
defineAsyncComponent error not caught |
Async component rejects without error boundary | Add errorComponent option or wrap in <Suspense> with error slot |
Reference Files
| File | When to Load |
|---|---|
| ./references/composition-api.md | Composables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns |
| ./references/state-routing.md | Pinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions) |
| ./references/nuxt.md | Nuxt 4 directory structure, data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content |
| ./references/testing.md | Vitest setup, Vue Test Utils, Pinia/Router testing, composable testing, MSW, Playwright, Nuxt test utils |
Staleness Verifier
This skill encodes fast-moving facts (the Vue 3 minor-version gates, the Nuxt 4
meta-framework, the ecosystem package stack). scripts/check-vue-facts.py
guards them against silent drift — internal consistency in PR CI, live
major-version drift in the scheduled freshness job:
# Structural (PR CI, no network): every catalogued package + Vue version gate is
# still named in this skill's prose, and the currency note still carries a year.
python3 skills/vue-ops/scripts/check-vue-facts.py --offline # exit 0 consistent, 10 drift
# Live (weekly freshness job, never blocks a PR): is any documented major
# now behind npm's latest dist-tag? (e.g. Nuxt 5 while the prose says Nuxt 4.)
python3 skills/vue-ops/scripts/check-vue-facts.py --live # exit 10 a major moved ahead, 7 npm unreachable
The canonical fact list lives in assets/vue-facts.json; when you add or drop a recommendation or the prose stops naming one, update it to match or --offline fails CI.
See Also
- typescript-ops — TypeScript generics, utility types, strict mode configuration
- testing-ops — General testing patterns, TDD, mocking strategies, CI integration
- tailwind-ops — Tailwind CSS with Vue component patterns, dark mode, responsive design
- javascript-ops — Modern JS patterns used alongside Vue (async/await, modules, iterators)
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
vue-facts.json 1.7 KB
{ "_comment": "Canonical fast-moving facts the vue-ops skill encodes. scripts/check-vue-facts.py asserts SKILL.md + references name these consistently (--offline) and probes the npm registry for major-version drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping. documented_major is the major the skill's prose commits to (vue 3, nuxt 4) or the current tracked major for ecosystem libs the prose names without pinning a version.", "schema": "claude-mods.vue-ops.facts/v1", "as_of": "2026-07-05", "vue_major": 3, "version_gates": { "_comment": "Vue 3 minor-version gates the skill centers on. --offline asserts each token is named in SKILL.md/references prose; a missing token means the prose stopped teaching a feature it claims.", "define_model": "Vue 3.4", "define_options": "Vue 3.3", "use_template_ref": "Vue 3.5", "nuxt_major": "Nuxt 4" }, "packages": { "vue": { "documented_major": 3, "prose": ["Vue 3"], "role": "core (Composition API, <script setup>)" }, "nuxt": { "documented_major": 4, "prose": ["Nuxt 4"], "role": "meta-framework (SSR/SSG/ISR, app/ srcDir host)" }, "pinia": { "documented_major": 3, "prose": ["pinia"], "role": "store" }, "vue-router": { "documented_major": 5, "prose": ["vue-router"], "role": "routing" }, "@vueuse/core": { "documented_major": 14, "prose": ["VueUse"], "role": "composable collection" }, "@vue/test-utils": { "documented_major": 2, "prose": ["@vue/test-utils"], "role": "component testing" }, "vitest": { "documented_major": 4, "prose": ["vitest"], "role": "test runner" } } }
-
-
references
-
composition-api.md 18.1 KB
# Composition API Reference Deep-dive patterns for Vue 3 Composition API: composables, lifecycle, template refs, provide/inject, v-model, slots, transitions, Teleport, Suspense, and custom directives. --- ## Composables ### Naming and structure convention ```ts // composables/useCounter.ts import { ref, computed, onUnmounted } from 'vue' // Rule: always prefix with "use" export function useCounter(initialValue = 0) { // State: return refs so callers can destructure while keeping reactivity const count = ref(initialValue) const isNegative = computed(() => count.value < 0) function increment() { count.value++ } function decrement() { count.value-- } function reset() { count.value = initialValue } // Cleanup: always handle in onUnmounted if you register listeners/timers // (onUnmounted is a no-op when called outside a component) return { count, isNegative, increment, decrement, reset } } ``` ### Accepting refs as arguments (reactive composable inputs) ```ts // composables/useDouble.ts import { computed, toRef, MaybeRefOrGetter, toValue } from 'vue' // toValue() (Vue 3.3+) unwraps ref, getter, or raw value export function useDouble(value: MaybeRefOrGetter<number>) { return computed(() => toValue(value) * 2) } // Usage: works with raw value, ref, or getter const x = ref(5) const doubled = useDouble(x) // reactive const doubled2 = useDouble(5) // static const doubled3 = useDouble(() => x.value + 1) // getter ``` ### useFetch — data fetching with cancellation ```ts // composables/useFetch.ts import { ref, watchEffect, toValue, MaybeRefOrGetter } from 'vue' export function useFetch<T>(url: MaybeRefOrGetter<string>) { const data = ref<T | null>(null) const error = ref<Error | null>(null) const pending = ref(false) watchEffect((onCleanup) => { const controller = new AbortController() // Register cleanup BEFORE the async work onCleanup(() => controller.abort()) pending.value = true error.value = null fetch(toValue(url), { signal: controller.signal }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) .then((json) => { data.value = json }) .catch((err) => { if (err.name !== 'AbortError') error.value = err }) .finally(() => { pending.value = false }) }) return { data, error, pending } } ``` ### useLocalStorage — synced persistent state ```ts // composables/useLocalStorage.ts import { ref, watch } from 'vue' export function useLocalStorage<T>(key: string, defaultValue: T) { const stored = localStorage.getItem(key) const initial = stored ? (JSON.parse(stored) as T) : defaultValue const state = ref<T>(initial) watch( state, (value) => localStorage.setItem(key, JSON.stringify(value)), { deep: true } ) return state } // Usage const theme = useLocalStorage<'light' | 'dark'>('theme', 'light') ``` ### useEventListener — safe event binding ```ts // composables/useEventListener.ts import { onMounted, onUnmounted, isRef, watch } from 'vue' import type { Ref } from 'vue' export function useEventListener<K extends keyof WindowEventMap>( target: Window | Document | Ref<HTMLElement | null>, event: K, handler: (e: WindowEventMap[K]) => void ) { if (isRef(target)) { watch(target, (el, _, onCleanup) => { el?.addEventListener(event, handler as EventListener) onCleanup(() => el?.removeEventListener(event, handler as EventListener)) }) } else { onMounted(() => target.addEventListener(event, handler as EventListener)) onUnmounted(() => target.removeEventListener(event, handler as EventListener)) } } // Usage useEventListener(window, 'resize', () => { console.log('window resized') }) ``` ### useDark — dark mode toggle ```ts // composables/useDark.ts import { ref, watch, onMounted } from 'vue' export function useDark() { const isDark = ref(false) onMounted(() => { isDark.value = document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches }) watch(isDark, (dark) => { document.documentElement.classList.toggle('dark', dark) }) function toggle() { isDark.value = !isDark.value } return { isDark, toggle } } ``` ### useIntersectionObserver — lazy loading / scroll tracking ```ts // composables/useIntersectionObserver.ts import { ref, onMounted, onUnmounted } from 'vue' import type { Ref } from 'vue' export function useIntersectionObserver( target: Ref<HTMLElement | null>, options: IntersectionObserverInit = {} ) { const isIntersecting = ref(false) let observer: IntersectionObserver | null = null onMounted(() => { observer = new IntersectionObserver(([entry]) => { isIntersecting.value = entry.isIntersecting }, options) if (target.value) observer.observe(target.value) }) onUnmounted(() => observer?.disconnect()) return { isIntersecting } } // Usage const el = ref<HTMLElement | null>(null) const { isIntersecting } = useIntersectionObserver(el, { threshold: 0.1 }) ``` --- ## Lifecycle Hooks ```ts import { onBeforeMount, // before first render, DOM not yet created onMounted, // after first render, DOM available onBeforeUpdate, // before re-render triggered by reactive change onUpdated, // after re-render (DOM updated) onBeforeUnmount, // before component teardown (still fully functional) onUnmounted, // after component teardown onActivated, // component re-activated inside <KeepAlive> onDeactivated, // component deactivated inside <KeepAlive> onErrorCaptured, // error from descendant component } from 'vue' // Pattern: separate concerns into multiple onMounted calls onMounted(() => { initChart() }) onMounted(() => { attachKeyboardListeners() }) // KeepAlive lifecycle — fetch fresh data on each activation onActivated(() => { refreshData() }) onDeactivated(() => { pauseAnimations() }) // Error boundary at composable level onErrorCaptured((err, instance, info) => { logError(err) return false // prevent propagation }) ``` --- ## Template Refs ### Basic ref() approach ```vue <script setup lang="ts"> import { ref, onMounted } from 'vue' const inputEl = ref<HTMLInputElement | null>(null) onMounted(() => { inputEl.value?.focus() }) </script> <template> <input ref="inputEl" type="text" /> </template> ``` ### useTemplateRef() — Vue 3.5+ ```vue <script setup lang="ts"> import { useTemplateRef, onMounted } from 'vue' // String key matches the ref="..." attribute in template const input = useTemplateRef<HTMLInputElement>('inputEl') onMounted(() => { input.value?.focus() }) </script> <template> <input ref="inputEl" type="text" /> </template> ``` ### Component refs — accessing exposed methods ```vue <!-- Parent --> <script setup lang="ts"> import { ref } from 'vue' import type ChildComponent from './ChildComponent.vue' const child = ref<InstanceType<typeof ChildComponent> | null>(null) function focusChild() { child.value?.focus() // only works if child uses defineExpose } </script> <template> <ChildComponent ref="child" /> </template> ``` ```vue <!-- ChildComponent.vue --> <script setup lang="ts"> import { ref } from 'vue' const inputEl = ref<HTMLInputElement | null>(null) function focus() { inputEl.value?.focus() } defineExpose({ focus }) </script> ``` ### Dynamic template refs in v-for ```vue <script setup lang="ts"> import { ref } from 'vue' const itemRefs = ref<HTMLElement[]>([]) const items = ref(['a', 'b', 'c']) </script> <template> <ul> <li v-for="item in items" :key="item" :ref="(el) => { if (el) itemRefs.push(el as HTMLElement) }" > {{ item }} </li> </ul> </template> ``` --- ## provide / inject ### Typed injection keys (InjectionKey<T>) ```ts // keys/injection-keys.ts import { InjectionKey, Ref } from 'vue' export interface UserContext { user: Ref<User | null> logout: () => void } // The key carries the type — no casts needed at inject site export const UserContextKey: InjectionKey<UserContext> = Symbol('UserContext') ``` ### Providing values (ancestor component) ```vue <!-- App.vue or layout component --> <script setup lang="ts"> import { provide, ref, readonly } from 'vue' import { UserContextKey } from '@/keys/injection-keys' import type { User } from '@/types' const user = ref<User | null>(null) function logout() { user.value = null } // Wrap in readonly to prevent descendants from mutating directly provide(UserContextKey, { user: readonly(user), logout }) </script> ``` ### Injecting in descendants ```vue <script setup lang="ts"> import { inject } from 'vue' import { UserContextKey } from '@/keys/injection-keys' // TypeScript knows the type from the InjectionKey const ctx = inject(UserContextKey) // ctx is UserContext | undefined — handle the undefined case // With default value (ensures non-null) const ctx2 = inject(UserContextKey, { user: ref(null), logout: () => {}, }) </script> ``` --- ## v-model Patterns ### defineModel() — Vue 3.4+ ```vue <!-- SimpleInput.vue --> <script setup lang="ts"> // Single v-model — replaces modelValue prop + update:modelValue emit const model = defineModel<string>({ required: true }) </script> <template> <input :value="model" @input="model = ($event.target as HTMLInputElement).value" /> </template> ``` ```vue <!-- Parent usage --> <SimpleInput v-model="username" /> ``` ### Multiple v-models ```vue <!-- RangeInput.vue --> <script setup lang="ts"> const min = defineModel<number>('min', { default: 0 }) const max = defineModel<number>('max', { default: 100 }) </script> <template> <input type="number" :value="min" @input="min = +($event.target as HTMLInputElement).value" /> <input type="number" :value="max" @input="max = +($event.target as HTMLInputElement).value" /> </template> ``` ```vue <!-- Parent usage --> <RangeInput v-model:min="rangeMin" v-model:max="rangeMax" /> ``` ### v-model with modifiers ```vue <!-- UpperInput.vue --> <script setup lang="ts"> const [model, modifiers] = defineModel<string, 'uppercase' | 'trim'>({ set(value) { if (modifiers.trim) value = value.trim() if (modifiers.uppercase) value = value.toUpperCase() return value } }) </script> ``` ```vue <!-- Parent usage --> <UpperInput v-model.uppercase.trim="text" /> ``` --- ## Slots ### Named slots with TypeScript types ```vue <!-- DataTable.vue --> <script setup lang="ts"> defineSlots<{ default?: (props: {}) => any header?: (props: { title: string }) => any row: (props: { item: User; index: number }) => any empty?: (props: {}) => any }>() const props = defineProps<{ items: User[] }>() </script> <template> <div> <slot name="header" :title="'Users'" /> <div v-if="props.items.length === 0"> <slot name="empty" /> </div> <div v-for="(item, index) in props.items" :key="item.id"> <slot name="row" :item="item" :index="index" /> </div> <slot /> </div> </template> ``` ```vue <!-- Parent usage — scoped slot destructuring --> <DataTable :items="users"> <template #header="{ title }"> <h2>{{ title }}</h2> </template> <template #row="{ item, index }"> <div>{{ index + 1 }}. {{ item.name }}</div> </template> <template #empty> <p>No users found.</p> </template> </DataTable> ``` ### Renderless components ```vue <!-- Renderless: MouseTracker.vue --> <script setup lang="ts"> import { ref } from 'vue' import { useEventListener } from '@/composables/useEventListener' const x = ref(0) const y = ref(0) useEventListener(window, 'mousemove', (e) => { x.value = e.clientX y.value = e.clientY }) </script> <template> <!-- Only renders what's in the default scoped slot --> <slot :x="x" :y="y" /> </template> ``` ```vue <!-- Usage --> <MouseTracker v-slot="{ x, y }"> Cursor: {{ x }}, {{ y }} </MouseTracker> ``` ### useSlots() in composables ```ts import { useSlots, computed } from 'vue' // Check if a named slot is provided export function useHasSlot(name: string) { const slots = useSlots() return computed(() => !!slots[name]) } ``` --- ## Transitions ### CSS transitions ```vue <script setup lang="ts"> import { ref } from 'vue' const show = ref(true) </script> <template> <button @click="show = !show">Toggle</button> <Transition name="fade"> <div v-if="show" class="box">Hello</div> </Transition> </template> <style scoped> .fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } </style> ``` ### JavaScript hooks (GSAP / Web Animations API) ```vue <template> <Transition @before-enter="onBeforeEnter" @enter="onEnter" @leave="onLeave" :css="false" > <div v-if="show" /> </Transition> </template> <script setup lang="ts"> import gsap from 'gsap' function onBeforeEnter(el: Element) { gsap.set(el, { opacity: 0, y: -20 }) } function onEnter(el: Element, done: () => void) { gsap.to(el, { opacity: 1, y: 0, duration: 0.4, onComplete: done }) } function onLeave(el: Element, done: () => void) { gsap.to(el, { opacity: 0, y: 20, duration: 0.3, onComplete: done }) } </script> ``` ### TransitionGroup — list animations ```vue <template> <TransitionGroup name="list" tag="ul"> <li v-for="item in items" :key="item.id"> {{ item.name }} </li> </TransitionGroup> </template> <style> .list-enter-active, .list-leave-active { transition: all 0.3s ease; } .list-enter-from { opacity: 0; transform: translateX(-30px); } .list-leave-to { opacity: 0; transform: translateX(30px); } /* Animate position changes of remaining items */ .list-move { transition: transform 0.3s ease; } /* Ensure leaving items take up no space during animation */ .list-leave-active { position: absolute; } </style> ``` --- ## Teleport ### Modal pattern ```vue <!-- Modal.vue --> <script setup lang="ts"> defineProps<{ open: boolean }>() const emit = defineEmits<{ close: [] }>() </script> <template> <Teleport to="body"> <Transition name="fade"> <div v-if="open" class="modal-overlay" @click.self="emit('close')"> <div class="modal-content" role="dialog" aria-modal="true"> <slot /> <button @click="emit('close')">Close</button> </div> </div> </Transition> </Teleport> </template> ``` ### Disabling Teleport conditionally ```vue <!-- Disable teleport in SSR or based on prop --> <Teleport to="#modals" :disabled="!isMounted"> <div>Content</div> </Teleport> ``` --- ## Suspense ### Async setup with Suspense ```vue <!-- AsyncUserProfile.vue — top-level await allowed in <script setup> --> <script setup lang="ts"> const { data: user } = await useFetch<User>('/api/user') // ^ Component is now async — must be wrapped in <Suspense> </script> <template> <div>{{ user?.name }}</div> </template> ``` ```vue <!-- Parent wraps async component --> <template> <Suspense> <template #default> <AsyncUserProfile /> </template> <template #fallback> <div class="skeleton" aria-busy="true">Loading...</div> </template> </Suspense> </template> ``` ### Error handling with Suspense ```vue <script setup lang="ts"> import { ref } from 'vue' const error = ref<Error | null>(null) function handleError(e: Error) { error.value = e } </script> <template> <div v-if="error">Error: {{ error.message }}</div> <Suspense v-else @resolve="onResolved" @fallback="onFallback" @pending="onPending"> <AsyncComponent /> <template #fallback>Loading...</template> </Suspense> </template> ``` --- ## Custom Directives ### vFocus — auto-focus on mount ```ts // directives/vFocus.ts import type { Directive } from 'vue' export const vFocus: Directive<HTMLElement> = { mounted(el) { el.focus() } } ``` ```vue <script setup lang="ts"> import { vFocus } from '@/directives/vFocus' // Directives imported in <script setup> are automatically available </script> <template> <input v-focus type="text" /> </template> ``` ### vClickOutside — dismiss on outside click ```ts // directives/vClickOutside.ts import type { Directive } from 'vue' type ClickOutsideHandler = (event: MouseEvent) => void export const vClickOutside: Directive<HTMLElement, ClickOutsideHandler> = { mounted(el, binding) { el._clickOutside = (event: MouseEvent) => { if (!el.contains(event.target as Node)) { binding.value(event) } } document.addEventListener('click', el._clickOutside) }, unmounted(el) { document.removeEventListener('click', el._clickOutside) delete el._clickOutside }, } ``` ### vIntersect — visibility tracking ```ts // directives/vIntersect.ts import type { Directive } from 'vue' interface IntersectBinding { handler: (isIntersecting: boolean) => void options?: IntersectionObserverInit } export const vIntersect: Directive<HTMLElement, IntersectBinding> = { mounted(el, { value }) { const observer = new IntersectionObserver( ([entry]) => value.handler(entry.isIntersecting), value.options ) observer.observe(el) el._intersectObserver = observer }, unmounted(el) { el._intersectObserver?.disconnect() }, } ``` ### Registering directives globally ```ts // main.ts import { createApp } from 'vue' import { vFocus } from '@/directives/vFocus' import { vClickOutside } from '@/directives/vClickOutside' const app = createApp(App) app.directive('focus', vFocus) app.directive('click-outside', vClickOutside) app.mount('#app') ``` ### Directive lifecycle hooks reference ```ts const myDirective: Directive = { created(el, binding, vnode) {}, // before component attrs/events applied beforeMount(el, binding, vnode) {}, // before element inserted into DOM mounted(el, binding, vnode) {}, // after element inserted, children mounted beforeUpdate(el, binding, vnode, prevVnode) {}, // before parent component updates updated(el, binding, vnode, prevVnode) {}, // after parent and children updated beforeUnmount(el, binding, vnode) {}, // before element removed unmounted(el, binding, vnode) {}, // after element removed } // binding object shape: // binding.value — value passed to directive (v-my-dir="value") // binding.oldValue — previous value (updated hook only) // binding.arg — argument (v-my-dir:arg) // binding.modifiers — object { lazy: true } for v-my-dir.lazy // binding.instance — component instance ``` -
nuxt.md 20.2 KB
# Nuxt 4 Reference Production patterns for Nuxt 4: directory structure, rendering modes, data fetching, server routes, middleware, plugins, modules, SEO, deployment, and Nuxt Content. --- ## Architecture Overview Nuxt 4 is built on: - **Nitro** — universal server engine (runs on Node, Cloudflare Workers, Deno, Bun, etc.) - **Vite** — fast dev server and build tool - **Vue 3** — Composition API throughout (Nuxt 4.4+ ships Vue Router v5) - **Auto-imports** — no need to import `ref`, `computed`, `useFetch`, etc. — Nuxt imports them automatically - **File-based routing** — `app/pages/` directory maps to routes --- ## Directory Structure (the Nuxt 4 change) Application code lives under `app/` (the srcDir); server code and config stay at the root. This separates app-environment code from server-environment code — faster dev-server boot, better IDE type inference per environment. ``` my-app/ ├── app/ # srcDir — everything that runs in the Vue app │ ├── assets/ │ ├── components/ │ ├── composables/ │ ├── layouts/ │ ├── middleware/ # route middleware (client navigation) │ ├── pages/ │ ├── plugins/ │ ├── utils/ │ ├── app.vue │ └── error.vue ├── public/ ├── server/ # stays at root — Nitro (api/, routes/, middleware/) ├── shared/ # code shared by app/ and server/ — import via #shared ├── nuxt.config.ts └── package.json ``` Key rules: - **`~/` and `@/` resolve to `app/`**, not the project root. Helpers that sat at root level and were imported with `~/` need to move into `app/` (or `shared/`) or get an explicit alias. - **`shared/`** is the sanctioned home for code both environments import (types, validators, constants) via the `#shared` alias — server code must not import from `app/`. - **Auto-imports scan `app/`** — `app/composables/`, `app/components/`, `app/utils/` work exactly as their root-level Nuxt 3 counterparts did. **Nuxt 3 differences**: in Nuxt 3 all of these directories sat at the project root and `~/` pointed to the root. Nuxt 4 auto-detects the old flat layout and keeps working with it, so migration is mechanical — move the app directories (plus `app.vue` / `error.vue`) into `app/` and fix any `~/` imports that referenced root-level files. --- ## Rendering Modes ### nuxt.config.ts — rendering configuration ```ts // nuxt.config.ts export default defineNuxtConfig({ // SSR (default) — server renders each request ssr: true, // SPA mode — no server rendering // ssr: false, // Hybrid rendering — per-route rules (most powerful) routeRules: { '/': { prerender: true }, // SSG — render at build time '/blog': { prerender: true }, '/blog/**': { isr: 3600 }, // ISR — regenerate every hour '/shop/**': { swr: 600 }, // SWR — stale-while-revalidate 10min '/app/**': { ssr: true }, // SSR — always server rendered '/admin/**': { ssr: false }, // SPA — client-only '/api/**': { cors: true, headers: { 'cache-control': 's-maxage=0' } }, }, }) ``` ### Prerendering specific routes ```ts export default defineNuxtConfig({ nitro: { prerender: { routes: ['/', '/about', '/contact'], crawlLinks: true, // follow all <a> links and prerender them too ignore: ['/admin'], }, }, }) ``` --- ## Data Fetching ### useFetch — SSR-safe primary fetching Nuxt 4 semantics: `data`/`error` default to `undefined` (Nuxt 3 used `null`), results are shallow-reactive by default (`deep: false`), requests sharing a key are deduplicated and their state is cleaned up when the last consuming component unmounts. The `pending` ref is deprecated — branch on `status` (`'idle' | 'pending' | 'success' | 'error'`) instead. ```vue <script setup lang="ts"> interface Post { id: number; title: string; body: string } // Automatically de-duplicates on server/client, serializes for hydration const { data: post, status, error, refresh } = await useFetch<Post>( '/api/posts/1', { key: 'post-1', // deduplicate key (auto-generated if omitted) server: true, // fetch on server (default) lazy: false, // await before rendering (default) default: () => ({ id: 0, title: '', body: '' } as Post), transform: (data) => data, // transform response before storing pick: ['id', 'title'], // pick only these fields (reduces payload) watch: [userId], // re-fetch when these refs change } ) // Re-fetch manually async function reload() { await refresh() } </script> ``` ### useFetch with dynamic URL ```vue <script setup lang="ts"> const route = useRoute() // Reactive URL — re-fetches when route param changes const { data: user } = await useFetch(() => `/api/users/${route.params.id}`) </script> ``` ### useAsyncData — custom async logic ```vue <script setup lang="ts"> // When you need more than a simple fetch (multiple sources, custom logic) const { data: stats } = await useAsyncData('dashboard-stats', async () => { const [users, orders, revenue] = await Promise.all([ $fetch<User[]>('/api/users'), $fetch<Order[]>('/api/orders'), $fetch<number>('/api/revenue'), ]) return { users, orders, revenue } }) </script> ``` ### $fetch — client-side / server-to-server fetching ```ts // Use $fetch for: // - Actions triggered by user interaction (form submit, button click) // - Server route handlers // - Inside useAsyncData when you need to compose data // In a component action (not in setup): async function submitForm(data: FormData) { const result = await $fetch('/api/submit', { method: 'POST', body: data, }) } // With error handling try { const user = await $fetch<User>('/api/user', { headers: useRequestHeaders(['cookie']), // forward cookies for auth }) } catch (error) { if (error.statusCode === 401) { await navigateTo('/login') } } ``` ### Lazy fetching — render immediately, load async ```vue <script setup lang="ts"> // lazy: true — don't block render, data loads async const { data: comments, status } = useFetch('/api/comments', { lazy: true }) </script> <template> <div v-if="status === 'pending'" class="skeleton">Loading comments...</div> <CommentList v-else :comments="comments" /> </template> ``` --- ## Server Routes ``` server/ ├── api/ # Accessible at /api/* │ ├── users/ │ │ ├── index.get.ts # GET /api/users │ │ ├── index.post.ts # POST /api/users │ │ └── [id].get.ts # GET /api/users/:id │ └── auth/ │ ├── login.post.ts │ └── logout.post.ts ├── routes/ # Accessible at any path │ └── sitemap.xml.get.ts # GET /sitemap.xml └── middleware/ # Runs on every server request └── auth.ts ``` ### Basic API route ```ts // server/api/users/index.get.ts import { defineEventHandler, getQuery, H3Event } from 'h3' export default defineEventHandler(async (event: H3Event) => { const query = getQuery(event) const page = Number(query.page ?? 1) const limit = Number(query.limit ?? 20) const users = await db.users.findMany({ skip: (page - 1) * limit, take: limit, }) return users // automatically serialized as JSON }) ``` ### POST with validation (zod) ```ts // server/api/users/index.post.ts import { defineEventHandler, readBody } from 'h3' import { z } from 'zod' const CreateUserSchema = z.object({ name: z.string().min(2).max(100), email: z.email(), role: z.enum(['user', 'admin']).default('user'), }) export default defineEventHandler(async (event) => { const body = await readBody(event) // Validate — throws H3Error 400 on failure const data = await CreateUserSchema.parseAsync(body).catch(() => { throw createError({ statusCode: 400, statusMessage: 'Invalid request body' }) }) const user = await db.users.create({ data }) setResponseStatus(event, 201) return user }) ``` ### Dynamic route parameter ```ts // server/api/users/[id].get.ts import { defineEventHandler, getRouterParam } from 'h3' export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') if (!id) throw createError({ statusCode: 400, statusMessage: 'ID required' }) const user = await db.users.findUnique({ where: { id: Number(id) } }) if (!user) throw createError({ statusCode: 404, statusMessage: 'User not found' }) return user }) ``` ### Server middleware — authentication ```ts // server/middleware/auth.ts import { defineEventHandler, getCookie, createError } from 'h3' export default defineEventHandler(async (event) => { // Only run auth check on /api/protected/* routes if (!event.node.req.url?.startsWith('/api/protected')) return const token = getCookie(event, 'auth_token') ?? getHeader(event, 'authorization')?.replace('Bearer ', '') if (!token) { throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) } const user = await verifyToken(token) event.context.user = user // attach to context for route handlers }) ``` --- ## Nuxt Middleware ### Route middleware (client-side navigation) ```ts // middleware/auth.ts export default defineNuxtRouteMiddleware((to, from) => { const auth = useAuthStore() if (!auth.isLoggedIn) { return navigateTo({ path: '/login', query: { redirect: to.fullPath }, }) } }) ``` ### Using middleware in pages ```vue <script setup lang="ts"> // Named middleware — run auth.ts middleware definePageMeta({ middleware: ['auth'], // Or inline: // middleware: (to, from) => { ... } }) </script> ``` ### Global middleware (runs on every navigation) ```ts // middleware/analytics.global.ts ← '.global' suffix makes it run always export default defineNuxtRouteMiddleware((to) => { if (import.meta.client) { trackPageView(to.fullPath) } }) ``` ### Server middleware (every HTTP request) ```ts // server/middleware/logger.ts export default defineEventHandler((event) => { console.log(`[${new Date().toISOString()}] ${event.node.req.method} ${event.node.req.url}`) }) ``` --- ## Plugins ### Client and server plugins ```ts // plugins/my-plugin.ts — runs on both server and client export default defineNuxtPlugin((nuxtApp) => { // Provide a helper to all components and composables return { provide: { formatDate: (date: Date) => date.toLocaleDateString(), }, } }) ``` ```ts // plugins/sentry.client.ts — client-only (filename convention) import * as Sentry from '@sentry/vue' export default defineNuxtPlugin((nuxtApp) => { Sentry.init({ app: nuxtApp.vueApp, dsn: useRuntimeConfig().public.sentryDsn, }) }) ``` ```ts // plugins/db.server.ts — server-only import { PrismaClient } from '@prisma/client' let prisma: PrismaClient export default defineNuxtPlugin(() => { if (!prisma) prisma = new PrismaClient() return { provide: { prisma } } }) ``` ### Accessing provided values ```vue <script setup lang="ts"> const { $formatDate, $prisma } = useNuxtApp() </script> ``` --- ## Modules ### Using published modules ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: [ '@nuxtjs/tailwindcss', '@pinia/nuxt', '@nuxt/content', '@nuxt/image', '@nuxtjs/i18n', 'nuxt-icon', ], // Module configuration pinia: { autoImports: ['defineStore', 'storeToRefs'], }, }) ``` ### Building a custom module ```ts // modules/feature-flags/index.ts import { defineNuxtModule, addPlugin, addImports, createResolver } from '@nuxt/kit' interface ModuleOptions { flags: Record<string, boolean> } export default defineNuxtModule<ModuleOptions>({ meta: { name: 'feature-flags', configKey: 'featureFlags', }, defaults: { flags: {}, }, setup(options, nuxt) { const resolver = createResolver(import.meta.url) // Add runtime config nuxt.options.runtimeConfig.public.featureFlags = options.flags // Add a plugin addPlugin(resolver.resolve('./runtime/plugin')) // Add auto-imports addImports({ name: 'useFeatureFlag', from: resolver.resolve('./runtime/composables'), }) // Hook into build process nuxt.hook('build:before', () => { console.log('Feature flags module: build starting') }) }, }) ``` --- ## State Management in Nuxt ### useState — SSR-safe shared state ```ts // composables/useSharedState.ts // useState() is SSR-safe: same key = same state across components in same request export const useTheme = () => useState<'light' | 'dark'>('theme', () => 'light') export const useUser = () => useState<User | null>('user', () => null) ``` ```vue <script setup lang="ts"> const theme = useTheme() // Reactive and synced — changing in one component updates all others </script> ``` ### Pinia with Nuxt (recommended for complex state) ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ['@pinia/nuxt'], pinia: { autoImports: ['defineStore', 'storeToRefs'] }, }) ``` ```ts // stores/user.ts — works in Nuxt with SSR hydration export const useUserStore = defineStore('user', () => { const user = ref<User | null>(null) // In Nuxt: fetch on server, hydrate on client async function fetchUser() { user.value = await $fetch<User>('/api/user') } return { user, fetchUser } }) ``` --- ## Runtime Config & Environment Variables ```ts // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { // Private — only available on server (server routes, server-only plugins) databaseUrl: process.env.DATABASE_URL, jwtSecret: process.env.JWT_SECRET, // Public — exposed to client via useRuntimeConfig().public public: { apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api', sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN, appVersion: process.env.npm_package_version, }, }, }) ``` ```ts // app.config.ts — UI configuration (not secrets, bundled into client) export default defineAppConfig({ ui: { primary: 'blue', notifications: { position: 'top-right' }, }, }) ``` ```vue <script setup lang="ts"> // Client and server: public config const config = useRuntimeConfig() const apiBase = config.public.apiBase // App config const appConfig = useAppConfig() const primaryColor = appConfig.ui.primary </script> ``` --- ## SEO ### useHead and useSeoMeta ```vue <script setup lang="ts"> // useHead — full control useHead({ title: 'My Page', titleTemplate: '%s — My Site', meta: [ { name: 'description', content: 'Page description' }, { property: 'og:type', content: 'website' }, ], link: [ { rel: 'canonical', href: 'https://mysite.com/page' }, ], bodyAttrs: { class: 'dark-mode' }, }) // useSeoMeta — typed, tree-shakeable (preferred for meta tags) useSeoMeta({ title: 'My Page', ogTitle: 'My Page', description: 'Page description for SEO', ogDescription: 'Page description for social sharing', ogImage: 'https://mysite.com/og-image.png', twitterCard: 'summary_large_image', }) </script> ``` ### defineOgImage — dynamic OG images ```vue <script setup lang="ts"> // @nuxtjs/og-image module defineOgImage({ component: 'MyOgImageTemplate', props: { title: 'My Page', description: 'Description' }, }) </script> ``` ### Dynamic head in layouts ```vue <!-- layouts/default.vue --> <script setup lang="ts"> useHead({ titleTemplate: (title) => title ? `${title} — My App` : 'My App', htmlAttrs: { lang: 'en' }, link: [ { rel: 'icon', href: '/favicon.ico' }, ], }) </script> ``` --- ## Error Handling ### Error page (error.vue) ```vue <!-- app/error.vue — replaces app.vue on error --> <script setup lang="ts"> const props = defineProps<{ error: { statusCode: number statusMessage: string message: string } }>() function handleError() { clearError({ redirect: '/' }) } </script> <template> <div> <h1>{{ error.statusCode }}</h1> <p>{{ error.statusMessage }}</p> <button @click="handleError">Go Home</button> </div> </template> ``` ### NuxtErrorBoundary — catch errors in subtree ```vue <template> <NuxtErrorBoundary @error="onError"> <AsyncComponent /> <template #error="{ error, clearError }"> <div> <p>Something went wrong: {{ error.message }}</p> <button @click="clearError()">Retry</button> </div> </template> </NuxtErrorBoundary> </template> ``` ### Throwing errors in server routes ```ts // server/api/users/[id].get.ts export default defineEventHandler(async (event) => { const user = await db.findUser(getRouterParam(event, 'id')) if (!user) { throw createError({ statusCode: 404, statusMessage: 'User not found', data: { id: getRouterParam(event, 'id') }, }) } return user }) ``` --- ## Deployment ### Cloudflare Workers / Pages ```ts // nuxt.config.ts export default defineNuxtConfig({ nitro: { preset: 'cloudflare-pages', // or 'cloudflare' }, }) ``` ```toml # wrangler.toml (if using Workers) name = "my-nuxt-app" main = ".output/server/index.mjs" compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] [[kv_namespaces]] binding = "KV" id = "your-kv-namespace-id" ``` ### Vercel (auto-detected) ```ts // nuxt.config.ts — Vercel detects automatically, no preset needed // But you can be explicit: export default defineNuxtConfig({ nitro: { preset: 'vercel' }, }) ``` ### Node.js server ```bash # Build npx nuxi build # Run node .output/server/index.mjs # With PM2 pm2 start .output/server/index.mjs --name my-app ``` ### Static hosting (full SSG) ```bash # Generate static files npx nuxi generate # Output in .output/public/ — deploy to any static host ``` ```ts // nuxt.config.ts for full static export default defineNuxtConfig({ ssr: true, nitro: { prerender: { crawlLinks: true, routes: ['/sitemap.xml'], }, }, }) ``` --- ## Nuxt Content ### Setup ```bash npx nuxi module add content ``` ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ['@nuxt/content'], content: { highlight: { theme: 'github-dark', langs: ['ts', 'vue', 'bash'], }, markdown: { anchorLinks: true, }, }, }) ``` ### Querying content > The examples below use the Nuxt Content v2 `queryContent()` API. Nuxt Content v3 > replaces it with typed collections (`content.config.ts` + `queryCollection()`); check > which major the project uses before reaching for either API. ```vue <!-- app/pages/blog/[slug].vue --> <script setup lang="ts"> const route = useRoute() // Query a single document const { data: post } = await useAsyncData( `blog-${route.params.slug}`, () => queryContent('blog').where({ _path: `/blog/${route.params.slug}` }).findOne() ) if (!post.value) throw createError({ statusCode: 404 }) // SEO from frontmatter useSeoMeta({ title: post.value.title, description: post.value.description, }) </script> <template> <!-- Renders markdown with MDC components --> <ContentRenderer :value="post" /> </template> ``` ```vue <!-- Blog listing page --> <script setup lang="ts"> const { data: posts } = await useAsyncData('blog-list', () => queryContent('blog') .where({ published: true }) .sort({ date: -1 }) .only(['_path', 'title', 'description', 'date']) .find() ) </script> ``` ### MDC — Markdown Components ```md <!-- content/blog/my-post.md --> --- title: My Post description: Post description date: 2024-01-15 published: true --- Regular markdown with **bold** and `code`. ::alert{type="warning"} This renders the Alert.vue component from components/content/ :: :MyInlineComponent{prop="value"} ``` --- ## Performance Patterns ### Component islands (selective hydration) ```vue <!-- Heavy chart that only runs client-side --> <template> <NuxtIsland name="HeavyChart" :props="{ data: chartData }" /> </template> ``` ### Payload optimization ```vue <script setup lang="ts"> // clearNuxtData removes payload after navigation (saves memory) onBeforeRouteLeave(() => { clearNuxtData('heavy-data-key') }) </script> ``` ### Client-only components ```vue <template> <!-- Only renders on client — no SSR attempt --> <ClientOnly> <MapComponent /> <template #fallback> <div class="map-skeleton" /> </template> </ClientOnly> </template> ``` -
state-routing.md 16.2 KB
# State Management & Routing Reference Advanced Pinia patterns and Vue Router configuration with TypeScript. --- ## Pinia — Setup Syntax (Recommended) The setup syntax mirrors `<script setup>` and is the preferred approach — full TypeScript inference, composables allowed inside, no `this` binding. ```ts // stores/auth.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { User } from '@/types' export const useAuthStore = defineStore('auth', () => { // --- state (refs) --- const user = ref<User | null>(null) const token = ref<string | null>(null) const loading = ref(false) // --- getters (computed) --- const isLoggedIn = computed(() => !!token.value) const isAdmin = computed(() => user.value?.role === 'admin') const displayName = computed(() => user.value?.name ?? 'Guest') // --- actions (functions) --- async function login(email: string, password: string) { loading.value = true try { const res = await $fetch<{ user: User; token: string }>('/api/auth/login', { method: 'POST', body: { email, password }, }) user.value = res.user token.value = res.token } finally { loading.value = false } } function logout() { user.value = null token.value = null } return { user, token, loading, isLoggedIn, isAdmin, displayName, login, logout } }) ``` --- ## Pinia — Options Syntax ```ts // stores/cart.ts import { defineStore } from 'pinia' import type { CartItem, Product } from '@/types' export const useCartStore = defineStore('cart', { state: () => ({ items: [] as CartItem[], discount: 0, }), getters: { // Getter with argument — return a function itemById: (state) => (id: string) => state.items.find((item) => item.id === id), total: (state): number => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0), discountedTotal(): number { // Can reference other getters via this return this.total * (1 - this.discount) }, }, actions: { addItem(product: Product) { const existing = this.itemById(product.id) if (existing) { existing.quantity++ } else { this.items.push({ ...product, quantity: 1 }) } }, removeItem(id: string) { this.items = this.items.filter((item) => item.id !== id) }, clearCart() { // $reset() is available in options syntax to reset to initial state this.$reset() }, }, }) ``` --- ## Store Composition — Using One Store Inside Another ```ts // stores/orders.ts import { defineStore } from 'pinia' import { computed } from 'vue' import { useAuthStore } from './auth' export const useOrdersStore = defineStore('orders', () => { const auth = useAuthStore() // Reactive dependency on auth store state const userOrders = computed(() => allOrders.value.filter((o) => o.userId === auth.user?.id) ) // Cross-store action async function placeOrder(items: CartItem[]) { if (!auth.isLoggedIn) throw new Error('Must be logged in') return await $fetch('/api/orders', { method: 'POST', body: { userId: auth.user!.id, items }, }) } return { userOrders, placeOrder } }) ``` --- ## storeToRefs — Destructuring Without Losing Reactivity ```vue <script setup lang="ts"> import { storeToRefs } from 'pinia' import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() // storeToRefs wraps state/getters in refs — safe to destructure const { user, isLoggedIn, displayName } = storeToRefs(auth) // Actions are plain functions — destructure directly from store const { login, logout } = auth // BAD — loses reactivity: // const { user } = auth // user is now a plain value, not reactive </script> ``` --- ## Pinia Plugins ### Persistence plugin (pinia-plugin-persistedstate) ```ts // main.ts import { createApp } from 'vue' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import App from './App.vue' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) createApp(App).use(pinia).mount('#app') ``` ```ts // Store with selective persistence export const usePreferencesStore = defineStore('preferences', () => { const theme = ref<'light' | 'dark'>('light') const language = ref('en') const notifications = ref(true) return { theme, language, notifications } }, { persist: { paths: ['theme', 'language'], // only persist these storage: localStorage, serializer: { deserialize: JSON.parse, serialize: JSON.stringify, }, }, }) ``` ### Custom plugin — logging ```ts // plugins/pinia-logger.ts import type { PiniaPluginContext } from 'pinia' export function PiniaLogger({ store }: PiniaPluginContext) { store.$onAction(({ name, args, after, onError }) => { console.group(`[Pinia] ${store.$id}.${name}`) console.log('args:', args) after((result) => { console.log('result:', result) console.groupEnd() }) onError((error) => { console.error('error:', error) console.groupEnd() }) }) } ``` ### Custom plugin — undo/redo ```ts // plugins/pinia-history.ts import { ref } from 'vue' import type { PiniaPluginContext } from 'pinia' export function PiniaHistory({ store }: PiniaPluginContext) { const history: string[] = [] let historyIndex = -1 // Snapshot state on every change store.$subscribe((mutation, state) => { // Drop future history on new action history.splice(historyIndex + 1) history.push(JSON.stringify(state)) historyIndex = history.length - 1 }) store.undo = () => { if (historyIndex > 0) { historyIndex-- store.$patch(JSON.parse(history[historyIndex])) } } store.redo = () => { if (historyIndex < history.length - 1) { historyIndex++ store.$patch(JSON.parse(history[historyIndex])) } } } ``` --- ## Pinia SSR — State Hydration ```ts // Nuxt: state is automatically serialized and hydrated via useNuxtApp().$pinia // For custom SSR with Vite/Express: // server.ts import { createPinia } from 'pinia' export async function render(url: string) { const pinia = createPinia() const app = createApp(App) app.use(pinia) await renderToString(app) // Serialize state to embed in HTML const state = JSON.stringify(pinia.state.value) return { state } } // client.ts import { createPinia } from 'pinia' const pinia = createPinia() // Hydrate from server-serialized state if (window.__INITIAL_STATE__) { pinia.state.value = JSON.parse( decodeURIComponent(atob(window.__INITIAL_STATE__)) ) } createApp(App).use(pinia).mount('#app') ``` --- ## Pinia Store Subscriptions ```ts const store = useCartStore() // Subscribe to state changes const unsubscribe = store.$subscribe((mutation, state) => { // mutation.type: 'direct' | 'patch object' | 'patch function' // mutation.storeId: store id // mutation.payload: patch object (if type is 'patch object') console.log('state changed', state) }) // Subscribe to actions store.$onAction(({ name, store, args, after, onError }) => { after((result) => { /* action succeeded */ }) onError((error) => { /* action threw */ }) }) // Cleanup onUnmounted(unsubscribe) ``` --- ## Vue Router — Full Configuration ```ts // router/index.ts import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' // TypeScript meta augmentation declare module 'vue-router' { interface RouteMeta { requiresAuth?: boolean roles?: string[] title?: string breadcrumb?: string transition?: string keepAlive?: boolean } } const routes: RouteRecordRaw[] = [ { path: '/', name: 'home', component: () => import('@/views/HomeView.vue'), meta: { title: 'Home' }, }, { path: '/about', name: 'about', // Route-level code splitting — this route is lazy loaded component: () => import('@/views/AboutView.vue'), }, { path: '/users/:id(\\d+)', // only match numeric ids name: 'user', component: () => import('@/views/UserView.vue'), props: true, // route params passed as props meta: { requiresAuth: true, title: 'User Profile' }, }, { path: '/users/:id/settings', name: 'user-settings', component: () => import('@/views/UserSettingsView.vue'), props: (route) => ({ id: Number(route.params.id) }), // transform params }, { path: '/blog/:slug?', // optional param name: 'blog-post', component: () => import('@/views/BlogView.vue'), }, { path: '/admin', redirect: '/admin/dashboard', component: () => import('@/layouts/AdminLayout.vue'), meta: { requiresAuth: true, roles: ['admin'] }, children: [ { path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), }, { path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue'), alias: '/users-admin', // accessible at both paths }, ], }, { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFoundView.vue'), }, ] export const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), // history: createWebHashHistory() — for hash-based routing (#/path) routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) { // Restore scroll position when using browser back/forward return savedPosition } if (to.hash) { return { el: to.hash, behavior: 'smooth', top: 80 } } // Scroll to top on navigation, but only if path changed if (to.path !== from.path) { return { top: 0 } } }, }) ``` --- ## Navigation Guards ### Global guards — auth and title ```ts // router/guards.ts import { router } from './index' import { useAuthStore } from '@/stores/auth' router.beforeEach(async (to, from) => { // Set page title document.title = to.meta.title ? `${to.meta.title} — MyApp` : 'MyApp' const auth = useAuthStore() // Wait for auth to initialize (e.g., token check from localStorage) if (!auth.initialized) { await auth.initialize() } // Auth guard if (to.meta.requiresAuth && !auth.isLoggedIn) { return { name: 'login', query: { redirect: to.fullPath }, } } // Role guard if (to.meta.roles?.length && !to.meta.roles.includes(auth.user?.role ?? '')) { return { name: 'forbidden' } } }) router.afterEach((to, from, failure) => { if (!failure) { // Analytics, etc. trackPageView(to.fullPath) } }) ``` ### Per-route beforeEnter guard ```ts { path: '/checkout', name: 'checkout', component: () => import('@/views/CheckoutView.vue'), beforeEnter: [ // Multiple guards as array — executed in order requireAuth, requireNonEmptyCart, ], } function requireAuth(to, from) { const auth = useAuthStore() if (!auth.isLoggedIn) return { name: 'login', query: { redirect: to.fullPath } } } function requireNonEmptyCart(to, from) { const cart = useCartStore() if (cart.items.length === 0) return { name: 'cart' } } ``` ### In-component guards (Composition API) ```vue <script setup lang="ts"> import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter, } from 'vue-router' import { ref, watch } from 'vue' const route = useRoute() const router = useRouter() const isDirty = ref(false) // Guard: prevent navigating away with unsaved changes onBeforeRouteLeave((to, from) => { if (isDirty.value) { const confirmed = window.confirm('Leave without saving?') if (!confirmed) return false } }) // Guard: refetch data when param changes (e.g., /users/1 → /users/2) onBeforeRouteUpdate(async (to, from) => { if (to.params.id !== from.params.id) { await fetchUser(to.params.id as string) } }) // Alternative: watch route params reactively watch(() => route.params.id, async (newId) => { if (newId) await fetchUser(newId as string) }, { immediate: true }) </script> ``` --- ## Dynamic Routes ```ts // Programmatic navigation const router = useRouter() // Navigate to named route router.push({ name: 'user', params: { id: 42 } }) // Navigate with query params router.push({ name: 'search', query: { q: 'vue', page: 2 } }) // Replace current history entry (no back button) router.replace({ name: 'login' }) // Navigate back/forward router.go(-1) router.back() router.forward() ``` ```ts // Adding routes dynamically (e.g., from plugin or feature flag) const removeRoute = router.addRoute({ path: '/feature-x', name: 'feature-x', component: () => import('@/views/FeatureX.vue'), }) // Remove the route when feature is disabled removeRoute() ``` ### useRoute — accessing route state ```vue <script setup lang="ts"> import { useRoute } from 'vue-router' import { computed } from 'vue' const route = useRoute() // Params — always strings or arrays of strings const userId = computed(() => Number(route.params.id)) // Query params const search = computed(() => route.query.q as string ?? '') const page = computed(() => Number(route.query.page ?? 1)) // Route meta const pageTitle = computed(() => route.meta.title) // Full path and matched routes (breadcrumb data) const breadcrumbs = computed(() => route.matched.map((r) => ({ name: r.name, label: r.meta.breadcrumb })) ) </script> ``` --- ## Route Transitions ### Per-route transition names ```vue <!-- App.vue --> <script setup lang="ts"> import { useRoute } from 'vue-router' const route = useRoute() </script> <template> <RouterView v-slot="{ Component, route }"> <Transition :name="route.meta.transition ?? 'fade'" mode="out-in"> <component :is="Component" :key="route.path" /> </Transition> </RouterView> </template> <style> .fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } .slide-enter-active, .slide-leave-active { transition: transform 0.3s ease; } .slide-enter-from { transform: translateX(100%); } .slide-leave-to { transform: translateX(-100%); } </style> ``` ```ts // Route definition with transition { path: '/users', component: () => import('@/views/UsersView.vue'), meta: { transition: 'slide' }, } ``` ### View Transitions API (Chrome 111+) ```ts router.beforeEach(() => { if (!document.startViewTransition) return return new Promise((resolve) => { document.startViewTransition(resolve) }) }) ``` --- ## Lazy Loading ### Route-level code splitting ```ts // Each () => import() creates a separate chunk const routes = [ { path: '/dashboard', component: () => import('@/views/Dashboard.vue') }, { path: '/settings', component: () => import('@/views/Settings.vue') }, ] ``` ### defineAsyncComponent with loading and error states ```ts import { defineAsyncComponent } from 'vue' import Spinner from '@/components/Spinner.vue' import ErrorDisplay from '@/components/ErrorDisplay.vue' const AsyncHeavyChart = defineAsyncComponent({ loader: () => import('@/components/HeavyChart.vue'), loadingComponent: Spinner, errorComponent: ErrorDisplay, delay: 200, // show loading after 200ms (avoids flash) timeout: 10000, // error if not loaded within 10s onError(error, retry, fail, attempts) { if (attempts <= 3) retry() // retry up to 3 times else fail() }, }) ``` ### Grouping chunks with magic comments ```ts // Vite: chunks are auto-split, but you can group with same chunk name const UserProfile = () => import(/* @vite-ignore */ '@/views/UserProfile.vue') // Prefetch on hover (manual) function prefetchDashboard() { import('@/views/Dashboard.vue') } ``` --- ## Scroll Behavior Patterns ```ts scrollBehavior(to, from, savedPosition) { // 1. Browser back/forward → restore exact position if (savedPosition) return savedPosition // 2. Hash link → scroll to element if (to.hash) { return { el: to.hash, top: 80, // offset for sticky header behavior: 'smooth', } } // 3. New page → scroll to top return { top: 0, left: 0 } } ``` ### Async scroll (wait for transition) ```ts scrollBehavior(to, from, savedPosition) { return new Promise((resolve) => { // Wait for page transition to complete setTimeout(() => { resolve(savedPosition ?? { top: 0 }) }, 300) }) } ``` -
testing.md 20.8 KB
# Testing Reference Vue 3 testing with Vitest, Vue Test Utils, Pinia, Vue Router, MSW, Playwright, and Nuxt test utils. --- ## Vitest Setup for Vue ### Installation ```bash npm install -D vitest @vue/test-utils happy-dom @vitest/coverage-v8 # Or jsdom: npm install -D jsdom ``` ### vitest.config.ts ```ts import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import { fileURLToPath } from 'node:url' export default defineConfig({ plugins: [vue()], test: { environment: 'happy-dom', // or 'jsdom' globals: true, // describe/it/expect without importing setupFiles: ['./tests/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'lcov', 'html'], thresholds: { lines: 80, branches: 75, functions: 80, }, exclude: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'], }, }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, }) ``` ### tests/setup.ts — global test setup ```ts import { config } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' // Global component stubs config.global.stubs = { RouterLink: true, RouterView: true, Teleport: true, } // Suppress Vue warnings in tests (optional — often better to fix them) // config.global.config.warnHandler = () => null ``` ### Auto-imports with unplugin-auto-import ```ts // vitest.config.ts — if using auto-imports in app import AutoImport from 'unplugin-auto-import/vite' export default defineConfig({ plugins: [ vue(), AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: true, }), ], test: { globals: true }, }) ``` --- ## Vue Test Utils — Mounting ### mount vs shallowMount ```ts import { mount, shallowMount } from '@vue/test-utils' import UserCard from '@/components/UserCard.vue' // mount — renders the full component tree (children included) const wrapper = mount(UserCard, { props: { user: { id: 1, name: 'Alice' } }, }) // shallowMount — stubs child components (faster, more isolated) // WARNING: can hide integration bugs; prefer mount for most cases const wrapper = shallowMount(UserCard, { props: { user: { id: 1, name: 'Alice' } }, }) ``` ### Mounting options ```ts const wrapper = mount(MyComponent, { props: { title: 'Hello', items: [1, 2, 3], }, slots: { default: '<p>Default slot content</p>', header: '<h2>Header slot</h2>', }, global: { plugins: [router, createTestingPinia()], stubs: { 'FontAwesomeIcon': true, // stub by name ChildComponent: { template: '<div class="child-stub" />' }, }, mocks: { $t: (key: string) => key, // mock i18n }, provide: { theme: ref('dark'), }, }, attachTo: document.body, // needed for focus tests }) ``` --- ## Component Testing Patterns ### Rendering and querying the DOM ```ts import { mount } from '@vue/test-utils' import { describe, it, expect } from 'vitest' import UserList from '@/components/UserList.vue' const users = [ { id: 1, name: 'Alice', role: 'admin' }, { id: 2, name: 'Bob', role: 'user' }, ] describe('UserList', () => { it('renders a list of users', () => { const wrapper = mount(UserList, { props: { users } }) // Text content expect(wrapper.text()).toContain('Alice') // Element exists expect(wrapper.find('[data-testid="user-list"]').exists()).toBe(true) // Count elements expect(wrapper.findAll('.user-card')).toHaveLength(2) // Check attribute expect(wrapper.find('input').attributes('disabled')).toBeDefined() // Check CSS class expect(wrapper.find('.user-card').classes()).toContain('admin') }) it('renders empty state when no users', () => { const wrapper = mount(UserList, { props: { users: [] } }) expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true) }) }) ``` ### User interactions ```ts import { mount, flushPromises } from '@vue/test-utils' import SearchInput from '@/components/SearchInput.vue' import { nextTick } from 'vue' describe('SearchInput', () => { it('emits search event when user types and submits', async () => { const wrapper = mount(SearchInput) // Fill input await wrapper.find('input').setValue('vue testing') // Click button await wrapper.find('button[type="submit"]').trigger('click') // Check emitted events expect(wrapper.emitted('search')).toBeTruthy() expect(wrapper.emitted('search')![0]).toEqual(['vue testing']) }) it('clears input on escape key', async () => { const wrapper = mount(SearchInput) await wrapper.find('input').setValue('hello') await wrapper.find('input').trigger('keydown', { key: 'Escape' }) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') }) }) ``` ### Async behavior ```ts import { mount, flushPromises } from '@vue/test-utils' import { vi } from 'vitest' import PostList from '@/components/PostList.vue' describe('PostList', () => { it('shows loading then content after fetch', async () => { // Mock fetch vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: async () => [{ id: 1, title: 'Post One' }], } as Response) const wrapper = mount(PostList) // Initially shows loading expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true) // Wait for all promises to resolve await flushPromises() // Now shows content expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false) expect(wrapper.text()).toContain('Post One') }) }) ``` ### Slot testing ```ts import { mount } from '@vue/test-utils' import Card from '@/components/Card.vue' describe('Card slots', () => { it('renders named slots', () => { const wrapper = mount(Card, { slots: { header: '<h2 data-testid="card-header">My Title</h2>', default: '<p>Card body</p>', footer: '<button>Action</button>', }, }) expect(wrapper.find('[data-testid="card-header"]').text()).toBe('My Title') expect(wrapper.find('p').text()).toBe('Card body') }) it('renders scoped slot with data', () => { const wrapper = mount(DataTable, { props: { items: [{ id: 1, name: 'Alice' }] }, slots: { row: `<template #row="{ item }"> <span data-testid="row-name">{{ item.name }}</span> </template>`, }, }) expect(wrapper.find('[data-testid="row-name"]').text()).toBe('Alice') }) }) ``` --- ## Testing Composables ### Simple composable test ```ts // tests/composables/useCounter.test.ts import { describe, it, expect } from 'vitest' import { useCounter } from '@/composables/useCounter' describe('useCounter', () => { it('starts at initial value', () => { const { count } = useCounter(5) expect(count.value).toBe(5) }) it('increments and decrements', () => { const { count, increment, decrement } = useCounter(0) increment() increment() expect(count.value).toBe(2) decrement() expect(count.value).toBe(1) }) it('resets to initial value', () => { const { count, increment, reset } = useCounter(10) increment() reset() expect(count.value).toBe(10) }) }) ``` ### Composable requiring component context (lifecycle hooks) ```ts // tests/composables/useEventListener.test.ts import { describe, it, expect, vi } from 'vitest' import { defineComponent, ref } from 'vue' import { mount } from '@vue/test-utils' import { useEventListener } from '@/composables/useEventListener' describe('useEventListener', () => { it('adds and removes event listener with component lifecycle', async () => { const handler = vi.fn() // Wrap in a component to get lifecycle const TestComponent = defineComponent({ setup() { useEventListener(window, 'resize', handler) }, template: '<div />', }) const wrapper = mount(TestComponent) // Trigger event window.dispatchEvent(new Event('resize')) expect(handler).toHaveBeenCalledTimes(1) // Unmount — listener should be removed wrapper.unmount() window.dispatchEvent(new Event('resize')) expect(handler).toHaveBeenCalledTimes(1) // still 1, not 2 }) }) ``` ### Composable with mocked fetch ```ts // tests/composables/useFetch.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' import { defineComponent, ref } from 'vue' import { mount, flushPromises } from '@vue/test-utils' import { useFetch } from '@/composables/useFetch' const mockData = { id: 1, name: 'Alice' } describe('useFetch', () => { beforeEach(() => { vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: async () => mockData, } as Response) }) it('fetches data and updates refs', async () => { const TestComponent = defineComponent({ setup() { const { data, pending, error } = useFetch<typeof mockData>('/api/user') return { data, pending, error } }, template: '<div />', }) const wrapper = mount(TestComponent) expect(wrapper.vm.pending).toBe(true) await flushPromises() expect(wrapper.vm.pending).toBe(false) expect(wrapper.vm.data).toEqual(mockData) expect(wrapper.vm.error).toBeNull() }) }) ``` --- ## Pinia Testing ### createTestingPinia — mock store ```ts import { mount } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' import { vi } from 'vitest' import UserProfile from '@/components/UserProfile.vue' import { useUserStore } from '@/stores/user' describe('UserProfile', () => { it('displays user name from store', () => { const wrapper = mount(UserProfile, { global: { plugins: [ createTestingPinia({ initialState: { user: { currentUser: { id: 1, name: 'Alice', role: 'admin' } }, }, }), ], }, }) expect(wrapper.text()).toContain('Alice') }) it('calls logout action when button clicked', async () => { const wrapper = mount(UserProfile, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn, // makes all actions spies }), ], }, }) const store = useUserStore() await wrapper.find('[data-testid="logout-btn"]').trigger('click') expect(store.logout).toHaveBeenCalledOnce() }) it('can stub specific action', async () => { const wrapper = mount(UserProfile, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn, stubActions: false, // let real actions run }), ], }, }) const store = useUserStore() // Override specific action store.logout = vi.fn().mockResolvedValue(undefined) }) }) ``` ### Testing store in isolation ```ts import { setActivePinia, createPinia } from 'pinia' import { beforeEach, describe, it, expect, vi } from 'vitest' import { useCartStore } from '@/stores/cart' describe('useCartStore', () => { beforeEach(() => { // Create a fresh pinia before each test setActivePinia(createPinia()) }) it('adds item to cart', () => { const cart = useCartStore() const product = { id: '1', name: 'Widget', price: 9.99 } cart.addItem(product) expect(cart.items).toHaveLength(1) expect(cart.total).toBe(9.99) }) it('increments quantity for duplicate item', () => { const cart = useCartStore() const product = { id: '1', name: 'Widget', price: 9.99 } cart.addItem(product) cart.addItem(product) expect(cart.items).toHaveLength(1) expect(cart.items[0].quantity).toBe(2) }) it('calls API when placing order', async () => { const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: async () => ({ orderId: 'abc123' }), } as Response) const cart = useCartStore() await cart.checkout() expect(fetchSpy).toHaveBeenCalledWith('/api/orders', expect.any(Object)) }) }) ``` --- ## Vue Router Testing ### Router mock for navigation testing ```ts import { mount, RouterLinkStub } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' import NavBar from '@/components/NavBar.vue' describe('NavBar navigation', () => { it('has correct links', () => { const wrapper = mount(NavBar, { global: { stubs: { RouterLink: RouterLinkStub }, }, }) const links = wrapper.findAllComponents(RouterLinkStub) expect(links.some((l) => l.props('to') === '/')).toBe(true) expect(links.some((l) => l.props('to') === '/about')).toBe(true) }) it('navigates on click', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: { template: '<div>Home</div>' } }, { path: '/about', component: { template: '<div>About</div>' } }, ], }) const wrapper = mount(NavBar, { global: { plugins: [router] }, }) await router.isReady() await wrapper.find('[data-testid="about-link"]').trigger('click') await router.isReady() expect(router.currentRoute.value.path).toBe('/about') }) }) ``` ### Testing components that use useRoute/useRouter ```ts import { mount } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' import UserView from '@/views/UserView.vue' describe('UserView', () => { it('loads user from route param', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/users/:id', component: UserView }], }) await router.push('/users/42') await router.isReady() const wrapper = mount(UserView, { global: { plugins: [router] }, }) await flushPromises() // UserView reads route.params.id = '42' expect(wrapper.text()).toContain('User 42') }) }) ``` --- ## API Mocking with MSW ### Setup ```bash npm install -D msw npx msw init public/ ``` ```ts // tests/mocks/handlers.ts import { http, HttpResponse } from 'msw' import type { User } from '@/types' export const handlers = [ http.get('/api/users', () => { return HttpResponse.json<User[]>([ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' }, ]) }), http.get('/api/users/:id', ({ params }) => { const user = { id: Number(params.id), name: 'Alice', email: 'alice@example.com' } return HttpResponse.json(user) }), http.post('/api/users', async ({ request }) => { const body = await request.json() as Partial<User> return HttpResponse.json({ ...body, id: 999 }, { status: 201 }) }), ] ``` ```ts // tests/setup.ts — global MSW setup import { setupServer } from 'msw/node' import { handlers } from './mocks/handlers' const server = setupServer(...handlers) beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) ``` ```ts // Override handlers per test import { http, HttpResponse } from 'msw' it('shows error when API fails', async () => { server.use( http.get('/api/users', () => { return HttpResponse.json({ message: 'Server Error' }, { status: 500 }) }) ) // ... test error state }) ``` --- ## Snapshot Testing ```ts import { mount } from '@vue/test-utils' import { describe, it, expect } from 'vitest' import Button from '@/components/Button.vue' describe('Button', () => { it('matches snapshot', () => { const wrapper = mount(Button, { props: { variant: 'primary', size: 'md' }, slots: { default: 'Click me' }, }) // HTML snapshot expect(wrapper.html()).toMatchSnapshot() }) it('matches inline snapshot', () => { const wrapper = mount(Button, { props: { variant: 'danger' }, slots: { default: 'Delete' }, }) expect(wrapper.html()).toMatchInlineSnapshot(` "<button class="btn btn-danger">Delete</button>" `) }) }) ``` --- ## E2E with Playwright ### Setup ```bash npm install -D @playwright/test npx playwright install ``` ### Page Object pattern for Vue apps ```ts // tests/e2e/pages/LoginPage.ts import { Page, Locator } from '@playwright/test' export class LoginPage { readonly page: Page readonly emailInput: Locator readonly passwordInput: Locator readonly submitButton: Locator readonly errorMessage: Locator constructor(page: Page) { this.page = page this.emailInput = page.getByLabel('Email') this.passwordInput = page.getByLabel('Password') this.submitButton = page.getByRole('button', { name: 'Sign in' }) this.errorMessage = page.getByTestId('login-error') } async goto() { await this.page.goto('/login') } async login(email: string, password: string) { await this.emailInput.fill(email) await this.passwordInput.fill(password) await this.submitButton.click() } } ``` ```ts // tests/e2e/auth.spec.ts import { test, expect } from '@playwright/test' import { LoginPage } from './pages/LoginPage' test.describe('Authentication', () => { test('successful login redirects to dashboard', async ({ page }) => { const loginPage = new LoginPage(page) await loginPage.goto() await loginPage.login('alice@example.com', 'password123') await expect(page).toHaveURL('/dashboard') await expect(page.getByTestId('welcome-message')).toContainText('Alice') }) test('invalid credentials shows error', async ({ page }) => { const loginPage = new LoginPage(page) await loginPage.goto() await loginPage.login('bad@example.com', 'wrong') await expect(loginPage.errorMessage).toBeVisible() await expect(loginPage.errorMessage).toContainText('Invalid credentials') }) }) ``` ### playwright.config.ts ```ts import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'Mobile Safari', use: { ...devices['iPhone 13'] } }, ], webServer: { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI, }, }) ``` --- ## Nuxt Testing ### Setup with @nuxt/test-utils ```bash npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom ``` ```ts // vitest.config.ts for Nuxt import { defineVitestConfig } from '@nuxt/test-utils/config' export default defineVitestConfig({ test: { environment: 'nuxt', // uses Nuxt-aware environment environmentOptions: { nuxt: { rootDir: '.', overrides: { ssr: false, // disable SSR for component tests }, }, }, }, }) ``` ### Testing Nuxt components with renderSuspended ```ts import { describe, it, expect } from 'vitest' import { renderSuspended } from '@nuxt/test-utils/runtime' import { screen } from '@testing-library/vue' import MyComponent from '@/components/MyComponent.vue' describe('MyComponent', () => { it('renders correctly', async () => { await renderSuspended(MyComponent, { props: { title: 'Hello Nuxt' }, }) expect(screen.getByText('Hello Nuxt')).toBeDefined() }) }) ``` ### Testing Nuxt composables ```ts import { describe, it, expect } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import { defineComponent } from 'vue' describe('useMyNuxtComposable', () => { it('works with Nuxt context', async () => { const TestComponent = defineComponent({ setup() { const state = useState('test', () => 'initial') return { state } }, template: '<div>{{ state }}</div>', }) const wrapper = await mountSuspended(TestComponent) expect(wrapper.text()).toBe('initial') }) }) ``` --- ## Common Testing Pitfalls | Pitfall | Problem | Fix | |---------|---------|-----| | Not awaiting `nextTick` | DOM not updated after reactive change | `await nextTick()` or `await wrapper.vm.$nextTick()` after triggering updates | | Not awaiting `flushPromises` | Async operations still pending | `await flushPromises()` after triggering async actions | | Using `shallowMount` exclusively | Child component bugs hidden | Default to `mount()`, use `shallowMount` only for focused unit tests | | Testing implementation details | Brittle tests that break on refactor | Test behavior and output, not internal refs or methods | | Missing `setActivePinia` in store tests | Pinia has no active instance | Call `setActivePinia(createPinia())` in `beforeEach` | | Forgetting `router.isReady()` | Navigation not complete | Await `router.isReady()` after `router.push()` in tests | | Not cleaning up global mocks | Tests pollute each other | Use `afterEach(() => vi.restoreAllMocks())` or `vi.resetAllMocks()` | | Querying by text that changes | Brittle to copy changes | Use `data-testid` attributes or ARIA roles for stable selectors |
-
-
scripts
-
.gitkeep 0 B · in bundle
-
check-vue-facts.py 11 KB
#!/usr/bin/env python3 """Staleness verifier for vue-ops: the Vue 3 / Nuxt 3 facts the skill encodes must stay real and named in the prose. vue-ops centers on Vue 3 (defineModel in 3.4, defineOptions/defineSlots in 3.3, useTemplateRef in 3.5) and Nuxt 3, and names an ecosystem stack (Pinia, Vue Router, VueUse, Vitest, @vue/test-utils). That is exactly the fact that drifts silently (SKILL-RESOURCE-PROTOCOL.md §7): Vue or Nuxt ships a new major, or the prose stops mentioning a package the catalog lists, and nobody notices for months. Two modes guard it: --offline (default, safe for PR CI): structural consistency, no network. * assets/vue-facts.json parses and every package + version gate is named somewhere in the skill prose (SKILL.md / references/*.md) — the catalog can't drift from the docs * SKILL.md still carries a dated "as of <year>" currency note --live (scheduled freshness.yml, never a PR gate): query the npm registry for each package's latest dist-tag; flag DRIFT when the live major is newer than the documented major (the skill is now behind — e.g. Nuxt 4 while the prose still says "Nuxt 3"), or when a package is gone (404). Transient registry failure is UNAVAILABLE (exit 7), never a failure. Usage: check-vue-facts.py [--offline | --live] [--facts FILE] [--skill DIR] [--json] [--timeout S] Input: argv flags only (no stdin). Output: stdout = findings (plain rows, or a --json envelope). Data only. Stderr: the verdict line, notices, errors. Exit: 0 ok, 2 usage, 3 facts/skill missing, 4 facts unparseable, 7 npm registry unreachable (live, advisory — never a real failure), 10 drift (offline: uncited/undocumented/missing note; live: major ahead or gone) Examples: check-vue-facts.py --offline # PR CI: catalog ⇆ prose consistency check-vue-facts.py --live # weekly: is any documented major behind npm? check-vue-facts.py --offline --json | jq '.data[]' """ from __future__ import annotations import argparse import json import os import re import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path EX_OK = 0 EX_USAGE = 2 EX_NOTFOUND = 3 EX_UNPARSEABLE = 4 EX_UNAVAILABLE = 7 EX_DRIFT = 10 SCHEMA = "claude-mods.vue-ops.facts/v1" HERE = Path(__file__).resolve().parent DEFAULT_FACTS = HERE.parent / "assets" / "vue-facts.json" DEFAULT_SKILL = HERE.parent REGISTRY = "https://registry.npmjs.org" CURRENCY_RE = re.compile(r"as of 20\d\d") class Term: """Minimal ANSI helper. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the bound stream's TTY + encoding so piped data stays plain ASCII.""" _C = {"green": "\033[32m", "red": "\033[31m", "dim": "\033[2m", "off": "\033[0m"} def __init__(self, stream=sys.stderr): enc = (getattr(stream, "encoding", "") or "").lower() self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc if os.environ.get("FORCE_COLOR"): self.color = True elif (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb" or not getattr(stream, "isatty", lambda: False)()): self.color = False else: self.color = True def c(self, name, text): return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text def mark(self, ok): g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗") return self.c("green" if ok else "red", g) def load_facts(path: Path) -> dict: if not path.is_file(): print(f"error: facts catalog not found: {path}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) try: data = json.loads(path.read_text(encoding="utf-8")) if data.get("schema") != SCHEMA: raise ValueError(f"schema {data.get('schema')!r} != {SCHEMA!r}") if not isinstance(data.get("packages"), dict) or not data["packages"]: raise ValueError("'packages' must be a non-empty object") for name, info in data["packages"].items(): if not isinstance(info, dict) or "documented_major" not in info: raise ValueError(f"package {name!r} missing documented_major") if not isinstance(info.get("prose"), list) or not info["prose"]: raise ValueError(f"package {name!r} missing prose tokens") return data except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: print(f"error: could not parse facts {path}: {exc}", file=sys.stderr) raise SystemExit(EX_UNPARSEABLE) def read_corpus(skill_dir: Path) -> tuple[str, str]: """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md.""" doc = skill_dir / "SKILL.md" if not doc.is_file(): print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) skill_md = doc.read_text(encoding="utf-8", errors="replace") parts = [skill_md] for ref in sorted((skill_dir / "references").glob("*.md")): parts.append(ref.read_text(encoding="utf-8", errors="replace")) return skill_md, "\n".join(parts) def check_offline(facts: dict, skill_dir: Path) -> list[dict]: skill_md, corpus = read_corpus(skill_dir) findings: list[dict] = [] for name, info in facts["packages"].items(): for token in info["prose"]: if token not in corpus: findings.append({"package": name, "issue": f"prose token {token!r} not named in skill"}) for key, token in facts.get("version_gates", {}).items(): if key == "_comment": continue if str(token) not in corpus: findings.append({"package": "(gate)", "issue": f"version gate {key}={token!r} not stated in skill prose"}) if not CURRENCY_RE.search(skill_md): findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of <year>' currency note"}) return findings def npm_latest(name: str, timeout: float) -> tuple[str, object]: """Return (resolved|notfound|unavailable, version-string-or-status).""" url = f"{REGISTRY}/{urllib.parse.quote(name, safe='')}/latest" req = urllib.request.Request(url, method="GET", headers={"User-Agent": "claude-mods-vue-ops-check/1", "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: manifest = json.loads(resp.read().decode("utf-8")) return ("resolved", manifest.get("version", "")) except urllib.error.HTTPError as exc: if exc.code in (404, 410): return ("notfound", exc.code) return ("unavailable", exc.code) except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: return ("unavailable", str(getattr(exc, "reason", exc))) def major_of(version: str) -> int | None: m = re.match(r"\d+", version.strip()) return int(m.group(0)) if m else None def check_live(facts: dict, timeout: float) -> tuple[list[dict], list[dict]]: drift: list[dict] = [] unreachable: list[dict] = [] for name, info in facts["packages"].items(): documented = info["documented_major"] status, info2 = npm_latest(name, timeout) if status == "notfound": drift.append({"package": name, "issue": "no longer resolves on npm (404) — renamed/removed"}) elif status == "unavailable": unreachable.append({"package": name, "issue": f"registry unreachable: {info2}"}) else: live = major_of(str(info2)) if live is None: unreachable.append({"package": name, "issue": f"could not parse version {info2!r}"}) elif live > documented: drift.append({"package": name, "issue": f"live major {live} ({info2}) ahead of documented major {documented}"}) return drift, unreachable def main(argv: list[str]) -> int: p = argparse.ArgumentParser( prog="check-vue-facts.py", description="Verify vue-ops' Vue 3 / Nuxt 3 facts stay named (offline) and current on npm (live).", ) mode = p.add_mutually_exclusive_group() mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)") mode.add_argument("--live", action="store_true", help="check each package's npm major vs documented") p.add_argument("--facts", default=str(DEFAULT_FACTS), help="facts catalog JSON") p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)") p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)") p.add_argument("--json", action="store_true", help="emit a JSON envelope") try: args = p.parse_args(argv) except SystemExit as exc: return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK) facts = load_facts(Path(args.facts)) live = args.live and not args.offline t = Term(sys.stderr) if live: drift, unreachable = check_live(facts, args.timeout) findings = drift + unreachable if args.json: print(json.dumps({ "data": findings, "meta": {"mode": "live", "packages_checked": len(facts["packages"]), "drift": len(drift), "unreachable": len(unreachable), "registry": REGISTRY, "schema": SCHEMA}, }, indent=2)) else: for f in findings: kind = "DRIFT" if f in drift else "UNREACH" print(f"{kind} {f['package']}: {f['issue']}") if drift: print(f"{t.mark(False)} vue-facts/live: {len(drift)} package(s) drifted " f"{t.c('dim', '(' + REGISTRY + ')')}", file=sys.stderr) return EX_DRIFT if unreachable: print(f"{t.mark(False)} vue-facts/live: npm unreachable for " f"{len(unreachable)}/{len(facts['packages'])} {t.c('dim', '(advisory - retry next run)')}", file=sys.stderr) return EX_UNAVAILABLE print(f"{t.mark(True)} vue-facts/live: all {len(facts['packages'])} package(s) " f"at or below documented major", file=sys.stderr) return EX_OK # offline (default) findings = check_offline(facts, Path(args.skill)) if args.json: print(json.dumps({ "data": findings, "meta": {"mode": "offline", "packages_checked": len(facts["packages"]), "drift": len(findings), "consistent": not findings, "schema": SCHEMA}, }, indent=2)) else: for f in findings: print(f"DRIFT {f['package']}: {f['issue']}") ok = not findings print(f"{t.mark(ok)} vue-facts/offline: {len(facts['packages'])} package(s) + " f"{sum(1 for k in facts.get('version_gates', {}) if k != '_comment')} gate(s) checked, " f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}", file=sys.stderr) return EX_DRIFT if findings else EX_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
-
-
tests
-
run.sh 3.5 KB
#!/usr/bin/env bash # Offline self-test for the vue-ops skill — structure, frontmatter, and the # staleness-verifier contract (SKILL-RESOURCE-PROTOCOL.md §7, §10). # # Offline-deterministic (no network, no Vue install). Resolves paths relative # to itself so it works in the repo and once installed to ~/.claude/skills/. # # Usage: bash tests/run.sh # Input: none (self-contained; no network) # Output: TAP-ish progress on stderr; final PASS/FAIL line. # Exit: 0 all pass, 1 any failure set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" DOC="$SKILL/SKILL.md" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1" >&2; } no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1" >&2; } # Resolve a *working* python (python3, else python). The bare `command -v` is # not enough on Windows, where `python3` is a Microsoft Store stub that exits # nonzero. Skip the whole verifier block if none works. PY="" for c in python3 python py; do if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PY="$c"; break; fi done echo "=== vue-ops self-test ===" >&2 # ── SKILL.md frontmatter ─────────────────────────────────────────────────── [[ -f "$DOC" ]] && ok "SKILL.md present" || { no "SKILL.md missing"; echo "=== $PASS passed, $FAIL failed ===" >&2; exit 1; } doc="$(cat "$DOC")" case "$doc" in *"name: vue-ops"*) ok "frontmatter declares name: vue-ops";; *) no "frontmatter name != vue-ops";; esac case "$doc" in *"license: MIT"*) ok "frontmatter declares license: MIT";; *) no "missing license: MIT";; esac case "$doc" in *"as of 20"*) ok "currency note carries a year";; *) no "no dated 'as of <year>' currency note";; esac # ── resources present + cited ────────────────────────────────────────────── for res in assets/vue-facts.json scripts/check-vue-facts.py; do [[ -f "$SKILL/$res" ]] && ok "resource present: $res" || no "missing resource: $res" done case "$doc" in *"scripts/check-vue-facts.py"*) ok "verifier cited from SKILL.md";; *) no "verifier uncited";; esac # ── staleness verifier: offline contract (§7) ─────────────────────────────── if [[ -n "$PY" ]]; then V="$SKILL/scripts/check-vue-facts.py" ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$? [[ "$got" == "$want" ]] && ok "$lbl (exit $got)" || no "$lbl (want $want got $got)"; } TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT ec 0 "py_compile" "$PY" -m py_compile "$V" ec 0 "--help" "$PY" "$V" --help ec 0 "--offline consistent" "$PY" "$V" --offline ec 2 "bad flag -> 2" "$PY" "$V" --bogus ec 2 "conflicting modes -> 2" "$PY" "$V" --offline --live jout="$("$PY" "$V" --offline --json 2>/dev/null)" case "$jout" in *"claude-mods.vue-ops.facts/v1"*) ok "--json envelope schema";; *) no "--json envelope schema missing";; esac ec 3 "missing facts -> 3" "$PY" "$V" --offline --facts "$TMP/nope.json" printf '{"schema":"claude-mods.vue-ops.facts/v1","packages":{"zzz":{"documented_major":1,"prose":["zzznotreal"]}}}' > "$TMP/drift.json" ec 10 "uncited package -> 10" "$PY" "$V" --offline --facts "$TMP/drift.json" else no "no working python to exercise the verifier" fi echo "=== $PASS passed, $FAIL failed ===" >&2 [[ "$FAIL" -eq 0 ]] || exit 1
-
-
SKILL.md 15.3 KB
--- name: vue-ops description: "Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 4. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt4, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: typescript-ops, testing-ops, tailwind-ops, javascript-ops --- # Vue Operations Comprehensive Vue 3 reference covering Composition API, Pinia, Vue Router, Nuxt 4, and testing — production patterns with TypeScript throughout. > Vue 3 / Nuxt 4 ecosystem facts verified as of 2026-07-05. --- ## Reactivity Decision Tree ``` What data do I need to make reactive? │ ├─ A single primitive (string, number, boolean)? │ └─ ref() │ const count = ref(0) │ const name = ref('') │ ├─ A plain object or array with deep reactivity? │ ├─ Will I destructure it or pass properties individually? │ │ └─ reactive() — but use toRefs() when destructuring │ └─ Will I replace the whole object at once? │ └─ ref() — ref.value = newObject │ ├─ Derived/computed state from other reactive sources? │ └─ computed() │ const doubled = computed(() => count.value * 2) │ ├─ A large object where only top-level keys change? │ └─ shallowRef() or shallowReactive() │ const state = shallowRef({ nested: { big: 'data' } }) │ ├─ Side effects that should run when dependencies change? │ ├─ Don't need to know old value, auto-tracks dependencies? │ │ └─ watchEffect(() => { ... }) │ └─ Need old/new values, explicit sources, or lazy execution? │ └─ watch(source, (newVal, oldVal) => { ... }) │ └─ Data that should NOT be reactive (raw DOM, third-party instances)? └─ markRaw(obj) or shallowRef(obj) ``` --- ## Component Communication Decision Tree ``` How far does data need to travel? │ ├─ Parent → direct child? │ └─ props (defineProps) │ Direct, explicit, type-safe │ ├─ Child → parent (user action / data update)? │ └─ emit (defineEmits) │ defineEmits<{ change: [value: string] }>() │ ├─ Parent ↔ child bidirectional binding? │ └─ v-model via defineModel() (Vue 3.4+) │ const model = defineModel<string>() │ ├─ Ancestor → deep descendant (prop drilling problem)? │ └─ provide / inject │ Use InjectionKey<T> for type safety │ ├─ Siblings or unrelated components? │ ├─ Simple/few shared values? │ │ └─ provide / inject from a common ancestor │ └─ Complex shared state or cross-tree communication? │ └─ Pinia store │ ├─ Truly global state (user session, cart, preferences)? │ └─ Pinia store │ defineStore with setup syntax │ └─ One-time events between distant components (rare)? └─ Pinia action + watch, or mitt event bus Avoid: Vue removed $emit on root in Vue 3 ``` --- ## Composition API Quick Reference ### `<script setup>` — the standard ```vue <script setup lang="ts"> import { ref, computed, watch, onMounted } from 'vue' // Props — with TypeScript generics (no runtime declaration needed) const props = defineProps<{ title: string count?: number }>() // Props with defaults const props = withDefaults(defineProps<{ size: 'sm' | 'md' | 'lg' disabled?: boolean }>(), { size: 'md', disabled: false, }) // Emits — type-safe event signatures const emit = defineEmits<{ change: [value: string] // named tuple syntax (Vue 3.3+) update: [id: number, data: object] close: [] }>() // Reactive state const count = ref(0) const user = reactive({ name: '', email: '' }) // Computed const doubled = computed(() => count.value * 2) // Watch watch(count, (newVal, oldVal) => { console.log(`count changed from ${oldVal} to ${newVal}`) }) // Lifecycle onMounted(() => { console.log('component mounted') }) </script> ``` ### `defineModel` — v-model binding (Vue 3.4+) ```vue <!-- Child component: MyInput.vue --> <script setup lang="ts"> const model = defineModel<string>({ required: true }) // Named v-model: <MyInput v-model:title="..." /> const title = defineModel<string>('title') // With modifiers const [modelValue, modifiers] = defineModel<string, 'trim' | 'uppercase'>() </script> <template> <input :value="model" @input="model = $event.target.value" /> </template> ``` ### `defineExpose` — expose to parent refs ```vue <script setup lang="ts"> const inputRef = ref<HTMLInputElement | null>(null) function focus() { inputRef.value?.focus() } // Expose public API for parent template refs defineExpose({ focus }) </script> ``` ### `defineOptions` — component meta (Vue 3.3+) ```vue <script setup lang="ts"> defineOptions({ name: 'MyComponent', inheritAttrs: false, }) </script> ``` ### `defineSlots` — type slots (Vue 3.3+) ```vue <script setup lang="ts"> defineSlots<{ default(props: { item: User }): any header(props: {}): any }>() </script> ``` --- ## Pinia Quick Start ### Setup syntax (recommended — composable style) ```ts // stores/counter.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useCounterStore = defineStore('counter', () => { // state const count = ref(0) const name = ref('Counter') // getters const doubled = computed(() => count.value * 2) // actions function increment() { count.value++ } async function fetchData() { const data = await api.get('/data') count.value = data.total } return { count, name, doubled, increment, fetchData } }) ``` ### Options syntax ```ts export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), getters: { doubled: (state) => state.count * 2, }, actions: { increment() { this.count++ }, }, }) ``` ### Using stores in components ```vue <script setup lang="ts"> import { storeToRefs } from 'pinia' import { useCounterStore } from '@/stores/counter' const store = useCounterStore() // storeToRefs preserves reactivity when destructuring state/getters // Actions can be destructured directly (they're not reactive) const { count, doubled } = storeToRefs(store) const { increment } = store </script> ``` ### Pinia plugins — persistence example ```ts // main.ts import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) // In store: export const useAuthStore = defineStore('auth', () => { ... }, { persist: true, // or { storage: sessionStorage, paths: ['token'] } }) ``` --- ## Vue Router Quick Reference ### Basic configuration ```ts // router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', component: () => import('@/views/HomeView.vue'), // lazy load }, { path: '/users/:id', name: 'user', component: () => import('@/views/UserView.vue'), props: true, // passes :id as prop meta: { requiresAuth: true }, }, { path: '/admin', component: () => import('@/layouts/AdminLayout.vue'), children: [ { path: '', component: () => import('@/views/admin/Dashboard.vue') }, { path: 'users', component: () => import('@/views/admin/Users.vue') }, ], }, { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound }, ], scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition if (to.hash) return { el: to.hash, behavior: 'smooth' } return { top: 0 } }, }) export default router ``` ### Navigation guards ```ts // Global guard — auth check router.beforeEach((to, from) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isLoggedIn) { return { name: 'login', query: { redirect: to.fullPath } } } }) // Per-route guard { path: '/admin', beforeEnter: (to, from) => { if (!isAdmin()) return { name: 'forbidden' } }, } ``` ```vue <!-- In-component guard --> <script setup lang="ts"> import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router' onBeforeRouteLeave((to, from) => { if (hasUnsavedChanges.value) { return confirm('Leave without saving?') } }) </script> ``` ### TypeScript meta typing ```ts // router/index.ts — augment RouteMeta declare module 'vue-router' { interface RouteMeta { requiresAuth?: boolean title?: string breadcrumb?: string } } ``` --- ## Nuxt 4 Decision Tree Nuxt 4's flagship change over Nuxt 3 is the `app/` source directory (app code separated from `server/` and root config — see [./references/nuxt.md](./references/nuxt.md)); the rendering strategies below are unchanged. ``` What rendering strategy does my app need? │ ├─ Public content (blogs, marketing, docs)? │ ├─ Content rarely changes (< daily)? │ │ └─ SSG — prerender: { routes: ['/', '/about'] } │ └─ Content updated frequently? │ └─ ISR — routeRules: { '/blog/**': { isr: 3600 } } │ ├─ Dynamic per-user content (dashboards, apps)? │ └─ SSR — ssr: true (Nuxt default) │ Best for SEO + authenticated data │ ├─ Admin panel / internal tool (no SEO needed)? │ └─ SPA — ssr: false in nuxt.config.ts │ ├─ Mixed needs (marketing pages + app)? │ └─ Hybrid — routeRules per path │ routeRules: { │ '/': { prerender: true }, │ '/blog/**': { isr: 3600 }, │ '/app/**': { ssr: true }, │ '/admin/**': { ssr: false }, │ } │ └─ Deploying to... ├─ Cloudflare Workers/Pages → preset: 'cloudflare' ├─ Vercel → preset: 'vercel' (auto-detected) ├─ Netlify → preset: 'netlify' (auto-detected) └─ Node.js server → preset: 'node-server' ``` --- ## Rendering Performance Quick Wins | Technique | When to Use | |-----------|-------------| | `v-memo="[dep1, dep2]"` | Skip re-rendering a subtree (usually a `v-for` row) unless listed deps changed — only for measured hot lists | | `<KeepAlive>` | Cache component instances across tab/route switches; pair with `onActivated`/`onDeactivated` for refresh logic | | Virtual scrolling | Lists with hundreds+ of rows — `vue-virtual-scroller` or `@tanstack/vue-virtual` render only visible items | | `shallowRef` / `markRaw` | Large objects or third-party instances that don't need deep reactivity (see Reactivity Decision Tree) | ```vue <!-- v-memo: row re-renders only when item.id or selection state changes --> <div v-for="item in list" :key="item.id" v-memo="[item.id, item.id === selectedId]" > {{ item.name }} — {{ item.id === selectedId ? 'selected' : '' }} </div> ``` **Tip:** before writing a composable, check [VueUse](https://vueuse.org/) — 200+ battle-tested composables (`useLocalStorage`, `useIntersectionObserver`, `useDark`, ...) that handle SSR and cleanup edge cases. ## Common Gotchas | Gotcha | Why | Fix | |--------|-----|-----| | Reactivity lost after destructuring `reactive()` | Destructuring extracts plain values, not refs | Use `toRefs(state)` when destructuring, or use `ref()` instead of `reactive()` | | `ref.value` needed in `<script>`, not in `<template>` | Template auto-unwraps top-level refs | Access as `count` in template, `count.value` in script | | `watch` doesn't fire on nested object changes | Default is shallow watch | Add `{ deep: true }` or watch a specific nested path `() => obj.nested.prop` | | Async setup breaks SSR in Nuxt | `await` in `setup()` suspends the component | Use `useAsyncData` or `useFetch` — never raw `await fetch()` in Nuxt setup | | `watchEffect` runs immediately and tracks lazily | Tracks dependencies at runtime, not statically | Use `watch` with explicit sources when you need control over what's tracked | | Template refs are `null` before mount | `ref()` is null until component is mounted | Access template refs inside `onMounted` or use `watch` with `{ immediate: false }` | | Pinia store state lost when destructuring | State properties are not reactive when pulled out directly | Always use `storeToRefs(store)` for state/getters; destructure actions directly | | Props are readonly — mutating causes warning | Vue enforces one-way data flow | Emit event to parent and let parent update; or use `defineModel()` for two-way binding | | `computed` setter not called on direct assignment | Computed with no setter is read-only by default | Define `get` and `set`: `computed({ get: () => ..., set: (v) => ... })` | | `v-model` on component uses wrong prop/event name | Default v-model uses `modelValue` prop and `update:modelValue` event | Use `defineModel()` (Vue 3.4+) or manually wire `modelValue` prop + `update:modelValue` emit | | `provide` value is not reactive | Providing a raw value instead of a ref | Provide `ref()` or `reactive()` so injectors see updates: `provide('key', ref(value))` | | `defineAsyncComponent` error not caught | Async component rejects without error boundary | Add `errorComponent` option or wrap in `<Suspense>` with error slot | --- ## Reference Files | File | When to Load | |------|-------------| | [./references/composition-api.md](./references/composition-api.md) | Composables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns | | [./references/state-routing.md](./references/state-routing.md) | Pinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions) | | [./references/nuxt.md](./references/nuxt.md) | Nuxt 4 directory structure, data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content | | [./references/testing.md](./references/testing.md) | Vitest setup, Vue Test Utils, Pinia/Router testing, composable testing, MSW, Playwright, Nuxt test utils | ## Staleness Verifier This skill encodes fast-moving facts (the Vue 3 minor-version gates, the Nuxt 4 meta-framework, the ecosystem package stack). [`scripts/check-vue-facts.py`](scripts/check-vue-facts.py) guards them against silent drift — internal consistency in PR CI, live major-version drift in the scheduled freshness job: ```bash # Structural (PR CI, no network): every catalogued package + Vue version gate is # still named in this skill's prose, and the currency note still carries a year. python3 skills/vue-ops/scripts/check-vue-facts.py --offline # exit 0 consistent, 10 drift # Live (weekly freshness job, never blocks a PR): is any documented major # now behind npm's latest dist-tag? (e.g. Nuxt 5 while the prose says Nuxt 4.) python3 skills/vue-ops/scripts/check-vue-facts.py --live # exit 10 a major moved ahead, 7 npm unreachable ``` The canonical fact list lives in [`assets/vue-facts.json`](assets/vue-facts.json); when you add or drop a recommendation or the prose stops naming one, update it to match or `--offline` fails CI. --- ## See Also - **typescript-ops** — TypeScript generics, utility types, strict mode configuration - **testing-ops** — General testing patterns, TDD, mocking strategies, CI integration - **tailwind-ops** — Tailwind CSS with Vue component patterns, dark mode, responsive design - **javascript-ops** — Modern JS patterns used alongside Vue (async/await, modules, iterators)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.