@gallopsystems/agent-skills 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +1 -1
- package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +1 -1
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +23 -8
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +1 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/caching.md +68 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +31 -1
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/error-handling.md +74 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +18 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/formatters.md +11 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/layers.md +40 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +1 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/route-rules.md +41 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-runtime.md +97 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +60 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/state-management.md +68 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/storage.md +59 -0
- package/plugins/volt-primevue/.claude-plugin/plugin.json +8 -0
- package/plugins/volt-primevue/skills/volt-primevue/SKILL.md +170 -0
- package/plugins/volt-primevue/skills/volt-primevue/config.md +51 -0
- package/plugins/volt-primevue/skills/volt-primevue/gotchas.md +115 -0
- package/plugins/volt-primevue/skills/volt-primevue/theming.md +137 -0
- package/plugins/vue-nuxt/.claude-plugin/plugin.json +8 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/SKILL.md +48 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/auto-imports.md +48 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/component-authoring.md +159 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/composables.md +95 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/reactivity.md +133 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/slots.md +139 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/template-idioms.md +142 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/v-model.md +106 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/watch.md +194 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vue-nuxt
|
|
3
|
+
description: Author Vue 3 components inside a Nuxt 4 app. Covers Nuxt auto-import rules, component authoring (props/emits/withDefaults/generics), v-model/defineModel, reactivity, when watch is a code smell, and Vue-shaped template idioms.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Vue-in-Nuxt component authoring
|
|
7
|
+
|
|
8
|
+
Patterns for writing Vue 3 `<script setup>` components inside a Nuxt 4 app. This
|
|
9
|
+
is the **frontend authoring** slice — the data layer (`useFetch`/`$fetch`, SSR
|
|
10
|
+
storage, hydration, `definePageMeta`/auth, formatters) lives in the
|
|
11
|
+
`nuxt-nitro-api` skill; Volt/PrimeVue styling and dark mode live in
|
|
12
|
+
`volt-primevue`. This skill cross-links to those rather than restating them.
|
|
13
|
+
|
|
14
|
+
## When to Use This Skill
|
|
15
|
+
|
|
16
|
+
- Authoring or reviewing a `.vue` component in a Nuxt 4 project
|
|
17
|
+
- Deciding how a component is named / auto-imported
|
|
18
|
+
- Typing props & emits, defaulting props, building a generic component
|
|
19
|
+
- Wiring `v-model` on a component
|
|
20
|
+
- Designing a component's content API — props vs slots, named/scoped slots
|
|
21
|
+
- Authoring a composable — argument shape, what to return, cleanup
|
|
22
|
+
- Anything reactivity-shaped: `computed` vs `watch`, prop→state sync, DOM measurement
|
|
23
|
+
- You see `watch` and want to know if it should be something else
|
|
24
|
+
|
|
25
|
+
## Reference Files
|
|
26
|
+
|
|
27
|
+
- [auto-imports.md](./auto-imports.md) — what auto-imports (components with dir-prefix names, composables, utils, Vue/Nuxt APIs) and what does NOT (third-party, types, test files)
|
|
28
|
+
- [component-authoring.md](./component-authoring.md) — type-only `defineProps`/`defineEmits`, `withDefaults`, the Boolean-prop trap, factory defaults, generic components, `defineExpose`, what to extract into a shared component
|
|
29
|
+
- [v-model.md](./v-model.md) — `defineModel` vs the props+emit+computed proxy, named models, paired fields
|
|
30
|
+
- [slots.md](./slots.md) — slots vs props for markup, named/scoped slots, `defineSlots`/`useSlots`, avoiding empty wrappers, forwarding, slot transitions
|
|
31
|
+
- [composables.md](./composables.md) — `MaybeRefOrGetter`/`toValue` argument contract, return refs not `reactive()`, thin pure-core shell, `onScopeDispose`/`effectScope` cleanup
|
|
32
|
+
- [reactivity.md](./reactivity.md) — `ref` over `reactive`, `useTemplateRef`, pure computeds, mutate-don't-reassign, DOM-measure + `ResizeObserver`, `shallowRef`, watch-getter prop sync, `:key` remount, listener cleanup
|
|
33
|
+
- [watch.md](./watch.md) — **`watch` is the escape hatch, not the default**: when it's right, and the four smell shapes (with refactors) found auditing 159 real watchers
|
|
34
|
+
- [template-idioms.md](./template-idioms.md) — duplicate-`@keyup` TS error, `:deep()`/`:slotted()`/`:global()`, click-outside marker class, `NuxtLink`/thin `app.vue`, `useHead`, `v-bind` shorthand, `useId`, `<Teleport>`/`<KeepAlive>`, `v-memo`/`v-once`, file-input reset
|
|
35
|
+
|
|
36
|
+
## Core Principles
|
|
37
|
+
|
|
38
|
+
1. **Lean on auto-imports.** `app/components`, `app/composables`, `app/utils`, and the Vue/Nuxt APIs all auto-import. Add an explicit `import` only for third-party symbols and TS types. A nested component's tag carries its directory as a prefix (`components/customers/ProfileCard.vue` → `<CustomersProfileCard>`).
|
|
39
|
+
2. **Type props/emits, default the booleans.** Use the type-only macros (`defineProps<{...}>()`, `defineEmits<{...}>()`). A bare `boolean` prop coerces to `false` when absent (not `undefined`), so any "defaults-on" flag MUST be defaulted — via reactive destructure (`{ flag = true } = defineProps<…>()`, the 3.5 default, no factory needed for arrays/objects) or `withDefaults` (factory required for non-primitives).
|
|
40
|
+
3. **`computed` for derivation, `watch` for escaping the graph.** If a watcher body just assigns one reactive value from others, it's a `computed`. Need to write a value back? A `computed` can have a setter — reach for a writable `computed` or `defineModel` before a sync watcher. Keep computed getters pure (no fetch, no mutation, no DOM).
|
|
41
|
+
4. **Tie effects to lifecycle.** DOM measurement, listeners, observers, and timers go in `onMounted` and are torn down in `onUnmounted`. A computed reading live DOM geometry needs an explicit re-measure signal (DOM size isn't reactive).
|
|
42
|
+
5. **Call composables at the top of `<script setup>`** — never inside a callback or a template expression (both lose Nuxt's request scope). Derive display state with `computed`, guarding for possibly-null data.
|
|
43
|
+
6. **Defer to the right skill.** Fetch/SSR/auth/middleware → `nuxt-nitro-api`. Volt components, `pt:` styling, color tokens, dark mode → `volt-primevue`. Don't duplicate them here.
|
|
44
|
+
|
|
45
|
+
## Contributing Back
|
|
46
|
+
|
|
47
|
+
If you hit a Vue-in-Nuxt authoring gotcha this skill doesn't cover (or one it gets
|
|
48
|
+
wrong), upstream it — run `/contribute-skill`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Nuxt auto-imports
|
|
2
|
+
|
|
3
|
+
Nuxt auto-imports your own UI surface and the Vue/Nuxt APIs. Add an explicit
|
|
4
|
+
`import` only for third-party symbols and TypeScript types. Knowing exactly what
|
|
5
|
+
resolves — and how component tags are named — prevents the silent "renders
|
|
6
|
+
nothing" failures.
|
|
7
|
+
|
|
8
|
+
## Components: the tag carries the directory prefix
|
|
9
|
+
|
|
10
|
+
Files in `app/components` auto-import with **no manual import**. A nested
|
|
11
|
+
component's tag is the PascalCased **directory path prefixed onto** the
|
|
12
|
+
PascalCased filename:
|
|
13
|
+
|
|
14
|
+
| File | Tag |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `app/components/AppHeader.vue` | `<AppHeader>` |
|
|
17
|
+
| `app/components/customers/ProfileCard.vue` | `<CustomersProfileCard>` |
|
|
18
|
+
| `app/components/form/FileUpload.vue` | `<FormFileUpload>` |
|
|
19
|
+
| `app/components/settings/Sidebar.vue` | `<SettingsSidebar>` |
|
|
20
|
+
|
|
21
|
+
- **Name to avoid stutter.** Put `ProfileCard.vue` in `customers/` → `<CustomersProfileCard>`, not `CustomerProfileCard.vue` → `<CustomersCustomerProfileCard>`.
|
|
22
|
+
- **Guessing the bare filename fails silently.** `<FileUpload>` for `form/FileUpload.vue` resolves to nothing — no error, just an unrendered tag. If a component "isn't showing up," check the prefix first.
|
|
23
|
+
- **Want a clean unprefixed tag?** Either explicitly import it
|
|
24
|
+
(`import EstimateHeader from '~/components/estimates/EstimateHeader.vue'`), or
|
|
25
|
+
register a directory with a prefix in `nuxt.config` (`components: [{ path: '../src/volt', prefix: 'Volt' }]` → `<VoltButton>`).
|
|
26
|
+
|
|
27
|
+
## What auto-imports (no `import` line)
|
|
28
|
+
|
|
29
|
+
- **Vue reactivity & lifecycle:** `ref`, `shallowRef`, `reactive`, `computed`, `watch`, `watchEffect`, `onWatcherCleanup`, `toValue`/`toRef`/`toRefs`, `useTemplateRef`, `useId`, `effectScope`/`onScopeDispose`, `onMounted`, `onBeforeUnmount`/`onUnmounted`, `nextTick`, `defineProps`/`defineEmits`/`defineModel`/`withDefaults`/`defineOptions`, `resolveComponent`.
|
|
30
|
+
- **Nuxt helpers:** `useRoute`, `useRouter`, `navigateTo`, `useFetch`, `$fetch`, `useAsyncData`, `useState`, `useCookie`, `useRuntimeConfig`, `useHead`, `definePageMeta`, `useNuxtApp`.
|
|
31
|
+
- **Your `app/composables`** (`useFoo`) and **`app/utils`** (pure helpers, by bare name) — app-wide.
|
|
32
|
+
- **`<NuxtLink>`, `<NuxtPage>`, `<NuxtLayout>`, `<ClientOnly>`, `<Teleport>`** in templates.
|
|
33
|
+
|
|
34
|
+
Reach for `#imports` (`import { useFoo } from '#imports'`) only to disambiguate a
|
|
35
|
+
naming collision.
|
|
36
|
+
|
|
37
|
+
## What does NOT auto-import (import explicitly)
|
|
38
|
+
|
|
39
|
+
- **Third-party composables/components:** `useToast` from `'primevue/usetoast'`, `Column` from `'primevue/column'`, `date-fns` helpers, etc.
|
|
40
|
+
- **TypeScript types** — interfaces, enums, type aliases are never auto-imported. `import type { Foo } from '...'`.
|
|
41
|
+
- **`server/utils`** — auto-imported on the *server* only, never into client code.
|
|
42
|
+
- **Vitest unit tests** — plain `*.test.ts` files do NOT get Nuxt's auto-import context. Import the composable/util under test explicitly: `import { useFormatters } from './useFormatters'`. (Component tests via `@nuxt/test-utils/runtime` + `mountSuspended` DO have the context — see the `nitro-testing` skill.)
|
|
43
|
+
|
|
44
|
+
## Consistency caveat
|
|
45
|
+
|
|
46
|
+
Some repos still write explicit `import { ref } from 'vue'` everywhere. **Match
|
|
47
|
+
the file you're editing** — but the default for new code is to rely on
|
|
48
|
+
auto-import.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Component authoring
|
|
2
|
+
|
|
3
|
+
Type-only macros, props/emits, the Boolean trap, generics, and what to extract.
|
|
4
|
+
|
|
5
|
+
## Props & emits: type-only macros
|
|
6
|
+
|
|
7
|
+
Declare props and emits with the type-only generic form; emit payloads as named
|
|
8
|
+
tuple types:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
const props = defineProps<{ label: string; size?: 'sm' | 'md'; rows: Row[] }>()
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
'update:modelValue': [value: string]
|
|
14
|
+
saved: []
|
|
15
|
+
rowClick: [row: Row]
|
|
16
|
+
}>()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use the runtime/object form only when you need `withDefaults`.
|
|
20
|
+
|
|
21
|
+
### Forward many props / handlers at once
|
|
22
|
+
|
|
23
|
+
Spread a whole object of props with `v-bind="obj"`, or a map of handlers with
|
|
24
|
+
`v-on="handlers"`, instead of listing each:
|
|
25
|
+
|
|
26
|
+
```vue
|
|
27
|
+
<UserCard v-bind="user" v-on="cardHandlers" />
|
|
28
|
+
<!-- equivalent to :name="user.name" :email="user.email" … @edit="…" @delete="…" -->
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Handy for forwarding `$attrs`/`$props` straight through a thin wrapper component
|
|
32
|
+
(`<Inner v-bind="$attrs" />`). Be deliberate, though — spreading a large object
|
|
33
|
+
binds *every* key, which can pass props the child didn't ask for.
|
|
34
|
+
|
|
35
|
+
When you forward `$attrs` onto a specific inner element, set `inheritAttrs: false`
|
|
36
|
+
via **`defineOptions`** so Vue doesn't *also* dump them on the root — the only
|
|
37
|
+
`<script setup>`-native way to set component options (`name`, `inheritAttrs`):
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
defineOptions({ inheritAttrs: false })
|
|
41
|
+
// then: <input v-bind="$attrs" /> — attrs land only where you put them
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Defaulting props: reactive destructure (3.5) vs `withDefaults`
|
|
45
|
+
|
|
46
|
+
As of Vue 3.5, you can **destructure** `defineProps` and give defaults with `=`.
|
|
47
|
+
The compiler rewrites each reference back to `props.x`, so reactivity is
|
|
48
|
+
preserved, and — unlike `withDefaults` — **non-primitive defaults need no
|
|
49
|
+
factory**. This is now the idiomatic way to default props:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const { responsive = true, size = 'md', items = [] } = defineProps<{
|
|
53
|
+
responsive?: boolean; size?: 'sm' | 'md'; items?: Item[]
|
|
54
|
+
}>()
|
|
55
|
+
// items = [] is safe here — no shared-reference leak, no factory needed
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
One gotcha: passing a destructured prop into a `watch` source or a standalone
|
|
59
|
+
function **snapshots** it (you destructured a value, not a ref), losing
|
|
60
|
+
reactivity. Wrap it in a getter:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
watch(() => responsive, onChange) // ✅ watch(responsive, …) ❌ passes a bool, never re-fires
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`withDefaults(defineProps<…>(), {…})` remains correct and is what you'll see in
|
|
67
|
+
existing code — match the file you're in. The two sections below describe the
|
|
68
|
+
traps `withDefaults` has that destructure defaults sidestep.
|
|
69
|
+
|
|
70
|
+
## The Boolean prop trap (the most-cited authoring gotcha)
|
|
71
|
+
|
|
72
|
+
A bare `boolean` prop is subject to Vue's **Boolean casting**: when the attribute
|
|
73
|
+
is absent it coerces to **`false`**, not `undefined`. So `props.flag ?? true`
|
|
74
|
+
never sees `undefined`, and a "defaults-on" flag silently stays off.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// ❌ absent → false → feature silently disabled (typechecks fine)
|
|
78
|
+
const props = defineProps<{ responsive?: boolean }>()
|
|
79
|
+
const on = props.responsive !== false // always false when not passed
|
|
80
|
+
|
|
81
|
+
// ✅ default it explicitly
|
|
82
|
+
const props = withDefaults(defineProps<{ responsive?: boolean }>(), { responsive: true })
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Any boolean that should default **on** MUST be defaulted explicitly — via
|
|
86
|
+
destructure (`const { responsive = true } = defineProps<…>()`) or `withDefaults`
|
|
87
|
+
— or be inverted to an opt-*out* flag that naturally defaults `false`. (The
|
|
88
|
+
Boolean casting still happens; the default just gives the absent case a value.)
|
|
89
|
+
|
|
90
|
+
## Factory defaults for arrays/objects
|
|
91
|
+
|
|
92
|
+
Non-primitive defaults need a **factory** in `withDefaults`, or every instance
|
|
93
|
+
shares one object:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
withDefaults(defineProps<{ items?: Item[]; config?: Cfg }>(), {
|
|
97
|
+
items: () => [], // ✅ factory
|
|
98
|
+
config: () => ({ x: 1 }), // ✅ factory
|
|
99
|
+
})
|
|
100
|
+
// ❌ { items: [] } — one array shared across all instances, leaks state
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Bare literals are only safe for primitives (`isAdmin: false`, `size: 'md'`). This
|
|
104
|
+
factory requirement is a `withDefaults`-ism — reactive destructure defaults
|
|
105
|
+
(`{ items = [] } = defineProps(…)`) take a plain literal safely.
|
|
106
|
+
|
|
107
|
+
## Generic components
|
|
108
|
+
|
|
109
|
+
Declare the type param in the tag and thread it through props/emits so a
|
|
110
|
+
tabs/select preserves the caller's literal union instead of widening to `string`:
|
|
111
|
+
|
|
112
|
+
```vue
|
|
113
|
+
<script setup lang="ts" generic="T extends string">
|
|
114
|
+
const props = defineProps<{ modelValue: T; options: readonly { value: T; label: string }[] }>()
|
|
115
|
+
const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
|
|
116
|
+
</script>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## `defineExpose` — the sanctioned imperative escape hatch
|
|
120
|
+
|
|
121
|
+
Expose child methods for a parent to call (reset a form after save, trigger
|
|
122
|
+
submit from a dialog footer) instead of prop hacks:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
defineExpose({ reset, submit })
|
|
126
|
+
// parent: const child = useTemplateRef('child'); child.value?.reset()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## What to put in a shared component vs leave in the caller
|
|
130
|
+
|
|
131
|
+
When extracting, a **composable shares logic-only**; a **component shares
|
|
132
|
+
markup + logic**. Extract only the genuinely universal surface and leave
|
|
133
|
+
page-specific chrome in each caller. E.g. a reusable `<Dropzone>` owns the
|
|
134
|
+
drag/drop box and file handling; the page keeps its own heading, helper banner,
|
|
135
|
+
and "or paste text" affordance. If you find yourself adding props just to toggle
|
|
136
|
+
caller-specific chrome inside the shared component, that chrome belongs in the
|
|
137
|
+
caller.
|
|
138
|
+
|
|
139
|
+
## Type off the server contract, don't hand-write DTOs
|
|
140
|
+
|
|
141
|
+
Derive prop/state types from the data you fetch rather than redeclaring an
|
|
142
|
+
interface that drifts from the API:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
// from a fetch ref:
|
|
146
|
+
type Project = NonNullable<typeof projects.value>[number]
|
|
147
|
+
// or an endpoint-response helper indexing the generated InternalApi (illustrative —
|
|
148
|
+
// the general rule is "derive from the endpoint type, never hand-write the DTO";
|
|
149
|
+
// see nuxt-nitro-api/fetch-patterns.md type-extraction)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Advanced: config-driven generic shells
|
|
153
|
+
|
|
154
|
+
A generic table/form shell can take a typed config array
|
|
155
|
+
(`columns: Column[]`, `fields: FormField[]`) whose entries optionally carry a
|
|
156
|
+
`template?: Component` rendered via `<component :is="col.template" v-bind="...">`,
|
|
157
|
+
plus `defineExpose`d imperative methods. Powerful, but it's an advanced pattern —
|
|
158
|
+
reach for it only when several call sites genuinely share the shell, not as a
|
|
159
|
+
default.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Authoring a composable
|
|
2
|
+
|
|
3
|
+
How to *write* a composable's reactive surface. The **decision** of composable vs
|
|
4
|
+
plain util lives in `nuxt-nitro-api/composables-utils.md` (logic that touches Vue
|
|
5
|
+
reactivity/lifecycle → composable; pure transform → util). This file is the
|
|
6
|
+
Vue-shaped mechanics: argument shape, what to return, and cleanup.
|
|
7
|
+
|
|
8
|
+
## Accept ref-or-getter-or-value; normalize with `toValue`
|
|
9
|
+
|
|
10
|
+
A reactive input should accept a plain value, a `ref`, **or** a getter — typed
|
|
11
|
+
`MaybeRefOrGetter<T>` and read through `toValue()` inside the effect. Don't branch
|
|
12
|
+
on `isRef`/`unref`, and don't read `.value` once at the top (that snapshots and
|
|
13
|
+
loses reactivity):
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { toValue, type MaybeRefOrGetter } from 'vue'
|
|
17
|
+
|
|
18
|
+
export function useDoubled(input: MaybeRefOrGetter<number>) {
|
|
19
|
+
// re-reads through toValue on every recompute → tracks input if it's reactive
|
|
20
|
+
return computed(() => toValue(input) * 2) // caller passes 3, ref(3), or () => count.value
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`toValue(x)` returns `x` for a plain value, `x.value` for a ref, `x()` for a
|
|
25
|
+
getter. This is the VueUse-shaped contract and the single biggest "make a
|
|
26
|
+
composable ergonomic" move — callers shouldn't have to wrap a literal in `ref()`
|
|
27
|
+
just to call you.
|
|
28
|
+
|
|
29
|
+
## Return a plain object of refs — not `reactive()`
|
|
30
|
+
|
|
31
|
+
Return refs/computeds in a **plain object**. A caller destructures the result, and
|
|
32
|
+
a plain object of refs survives destructuring; a `reactive()` return does not (the
|
|
33
|
+
properties detach into plain values). See [reactivity.md](./reactivity.md) for the
|
|
34
|
+
`ref`-over-`reactive` rule this mirrors.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
export function usePager(total: MaybeRefOrGetter<number>) {
|
|
38
|
+
const page = ref(1)
|
|
39
|
+
const atEnd = computed(() => page.value >= toValue(total))
|
|
40
|
+
return { page, atEnd } // ✅ const { page } = usePager(n) stays reactive
|
|
41
|
+
// ❌ return reactive({ page, atEnd }) → const { page } = … is a dead number
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Thin composable: pure core, reactive shell
|
|
46
|
+
|
|
47
|
+
Keep business logic in plain functions (no Vue import — trivially unit-testable,
|
|
48
|
+
no Nuxt context needed) and let the composable be a thin reactive wrapper that
|
|
49
|
+
wires that logic to `ref`/`computed`. This is "functional core, imperative shell"
|
|
50
|
+
applied to composables, and it matters here specifically: plain `*.test.ts` files
|
|
51
|
+
**don't** get Nuxt's auto-import context (see [auto-imports.md](./auto-imports.md)),
|
|
52
|
+
so pure functions test without `mountSuspended`.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// core.ts — pure, no Vue, unit-test directly
|
|
56
|
+
export const clampPage = (p: number, total: number) => Math.min(Math.max(p, 1), total)
|
|
57
|
+
|
|
58
|
+
// usePager.ts — thin shell
|
|
59
|
+
export function usePager(total: MaybeRefOrGetter<number>) {
|
|
60
|
+
const page = ref(1)
|
|
61
|
+
return { page, go: (p: number) => (page.value = clampPage(p, toValue(total))) }
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Clean up with `onScopeDispose` — not only `onUnmounted`
|
|
66
|
+
|
|
67
|
+
A composable that starts a listener/timer/observer must tear it down. Inside a
|
|
68
|
+
composable, register cleanup with **`onScopeDispose`** rather than `onUnmounted`:
|
|
69
|
+
it fires on component unmount too, but *also* works when the composable runs in a
|
|
70
|
+
detached `effectScope()` (a shared singleton, a manually-stopped scope) where
|
|
71
|
+
there's no component instance and `onUnmounted` would silently no-op.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
export function useNow(intervalMs = 1000) {
|
|
75
|
+
const now = ref(Date.now())
|
|
76
|
+
const id = setInterval(() => (now.value = Date.now()), intervalMs)
|
|
77
|
+
onScopeDispose(() => clearInterval(id)) // fires on unmount OR scope.stop()
|
|
78
|
+
return { now }
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For a composable that itself creates watchers/computeds you want to dispose as a
|
|
83
|
+
unit (a store you stand up and tear down by hand), wrap them in an
|
|
84
|
+
**`effectScope()`** and stop the whole scope at once:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const scope = effectScope()
|
|
88
|
+
scope.run(() => { /* watchers/computeds created here */ })
|
|
89
|
+
// later: scope.stop() → disposes every effect created inside, and runs onScopeDispose
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`useTemplateRef`, `MaybeRefOrGetter`, and the "return refs" rule together let a
|
|
93
|
+
composable own DOM refs and reactive inputs internally instead of demanding the
|
|
94
|
+
caller thread them in — see [reactivity.md](./reactivity.md) and
|
|
95
|
+
[component-authoring.md](./component-authoring.md).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Reactivity gotchas
|
|
2
|
+
|
|
3
|
+
Derivation, prop sync, DOM measurement, remounting, and cleanup. For the
|
|
4
|
+
`watch`-vs-`computed` decision and the `watch` smell catalog, see
|
|
5
|
+
[watch.md](./watch.md).
|
|
6
|
+
|
|
7
|
+
## Keep computed getters pure
|
|
8
|
+
|
|
9
|
+
A `computed` getter derives and returns — nothing else. Mutating another ref/Set,
|
|
10
|
+
firing an async request, or touching the DOM inside a getter makes it
|
|
11
|
+
order-dependent, can re-trigger itself, and is undebuggable. Move side effects to
|
|
12
|
+
a `watch` or an event handler. (And never mutate a computed's *return* value —
|
|
13
|
+
it's a read-only snapshot; update the source state instead.)
|
|
14
|
+
|
|
15
|
+
## Mutate a ref's object in place — don't reassign
|
|
16
|
+
|
|
17
|
+
When a ref's object is bound to `v-model` inputs, update it **in place**;
|
|
18
|
+
reassigning a fresh literal swaps the proxied target the template tracks, and
|
|
19
|
+
later programmatic writes to the old reference are lost:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// ❌ form.value = { ...next } // swaps the tracked proxy
|
|
23
|
+
// ✅
|
|
24
|
+
Object.assign(form.value, next) // or per-key assignment
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Default to `ref`; reach for `reactive` rarely
|
|
28
|
+
|
|
29
|
+
`ref` is the default for all state. Use `reactive` only to **group** tightly
|
|
30
|
+
related fields or to wrap a non-Vue object (`Map`/`Set`). `reactive`'s traps are
|
|
31
|
+
why: it loses reactivity when **destructured** (the keys become plain values —
|
|
32
|
+
`toRefs` to recover) and when **reassigned wholesale** (the proxy identity swaps).
|
|
33
|
+
A `ref` has neither problem — `.value` is always the live cell.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const state = reactive({ count: 0 })
|
|
37
|
+
const { count } = state // ❌ plain number now, not reactive
|
|
38
|
+
const { count } = toRefs(state) // ✅ if you must destructure a reactive
|
|
39
|
+
// Rule: default ref(); reactive() only to group state or wrap Map/Set.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Template refs: prefer `useTemplateRef`
|
|
43
|
+
|
|
44
|
+
To reach a DOM element or child component, use **`useTemplateRef('name')`** (Vue
|
|
45
|
+
3.5 / Nuxt 4) — pass the string that matches `ref="name"` in the template. It
|
|
46
|
+
self-documents that the value is an element/component handle, infers the type, and
|
|
47
|
+
lets a composable own the ref internally. The legacy "declare `const el =
|
|
48
|
+
ref(null)` whose variable name must match `ref="el"`" form still works but is
|
|
49
|
+
fragile to rename drift — prefer `useTemplateRef` in new code.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const input = useTemplateRef('input') // <input ref="input">
|
|
53
|
+
onMounted(() => input.value?.focus())
|
|
54
|
+
// v-for: useTemplateRef returns an ARRAY, and its order is NOT guaranteed —
|
|
55
|
+
// key/sort yourself rather than trusting index alignment.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## DOM-measured computeds need a re-measure signal
|
|
59
|
+
|
|
60
|
+
DOM size isn't reactive, so a `computed` reading live geometry
|
|
61
|
+
(`offsetLeft`/`offsetWidth`) won't recompute on resize or a breakpoint flip. Add
|
|
62
|
+
an explicit version ref and bump it from a `ResizeObserver`:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const container = useTemplateRef('container') // <div ref="container">
|
|
66
|
+
const layoutVersion = ref(0)
|
|
67
|
+
let ro: ResizeObserver | null = null
|
|
68
|
+
onMounted(() => { ro = new ResizeObserver(() => layoutVersion.value++); ro.observe(container.value!) })
|
|
69
|
+
onBeforeUnmount(() => ro?.disconnect())
|
|
70
|
+
|
|
71
|
+
const indicator = computed(() => {
|
|
72
|
+
void layoutVersion.value // subscribe to re-measure
|
|
73
|
+
const b = buttons.value[active.value]
|
|
74
|
+
return b ? { left: `${b.offsetLeft}px`, width: `${b.offsetWidth}px` } : { left: '0px', width: '0px' }
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Measure conditionally-mounted elements only **after `await nextTick()`** (and hold
|
|
79
|
+
them via a null-guarded template ref) — right after toggling `visible`, the
|
|
80
|
+
element isn't laid out and a sync `getBoundingClientRect()` reads 0.
|
|
81
|
+
|
|
82
|
+
## React to prop changes with a watch on a getter
|
|
83
|
+
|
|
84
|
+
Props aren't directly watchable — watch a getter, and seed local state with
|
|
85
|
+
`{ immediate: true }`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
watch(() => props.thing, (next) => { local.value = next }, { immediate: true })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The legitimate prop→local case is **owning an editable draft**: a parent owns
|
|
92
|
+
server data via `useFetch` (keep `refresh`), passes it as a prop; the child clones
|
|
93
|
+
it into a local draft (`structuredClone(toRaw(prop))`), re-syncs on a watch of the
|
|
94
|
+
prop, and emits `@saved`/`@updated` so the parent re-fetches. Copy into local
|
|
95
|
+
state — never mutate the prop. (A bare `local = ref(props.x)` + sync watch with no
|
|
96
|
+
emit-back is the `prop-sync` smell — use `defineModel`; see [v-model.md](./v-model.md).)
|
|
97
|
+
|
|
98
|
+
## Remount on identity change with `:key`
|
|
99
|
+
|
|
100
|
+
```vue
|
|
101
|
+
<UserDetail :key="route.params.id" :id="route.params.id" />
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Bind `:key` to the **identifying value** so the component remounts cleanly when
|
|
105
|
+
identity changes. Do NOT abuse an incrementing `:key="bump++"` to force a data
|
|
106
|
+
re-pull — that destroys child state/scroll; use a watcher or `refresh()` instead.
|
|
107
|
+
When a *full remount* genuinely is the goal (reset all of a child's internal
|
|
108
|
+
state), bumping `:key` is the right tool — and `$forceUpdate()` is the wrong one
|
|
109
|
+
(it re-renders but skips computeds and bypasses the reactivity graph; if you
|
|
110
|
+
"need" it, you have a reactivity bug to fix instead).
|
|
111
|
+
|
|
112
|
+
## Always pair setup with teardown
|
|
113
|
+
|
|
114
|
+
Anything started in `onMounted` — `addEventListener`, `setInterval`,
|
|
115
|
+
`ResizeObserver`, `MutationObserver` — must be torn down in
|
|
116
|
+
`onUnmounted`/`onBeforeUnmount` (`removeEventListener`, `clearInterval`,
|
|
117
|
+
`disconnect`). Keep the handle in a module-scope `let` so teardown can reach it.
|
|
118
|
+
Inside a **composable**, register teardown with `onScopeDispose` instead so it
|
|
119
|
+
also fires for a detached `effectScope` — see [composables.md](./composables.md).
|
|
120
|
+
|
|
121
|
+
## `shallowRef` for large or wholesale-replaced data
|
|
122
|
+
|
|
123
|
+
Deep reactivity has a cost: `ref`/`reactive` recursively proxy every nested
|
|
124
|
+
property. For a large list, a third-party class instance (a `Map`, an editor/map
|
|
125
|
+
object you drive imperatively), or data you always **replace wholesale** rather
|
|
126
|
+
than mutate, use `shallowRef` (or `shallowReactive`) — only `.value` reassignment
|
|
127
|
+
triggers, nested mutation does not.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const rows = shallowRef<Row[]>([])
|
|
131
|
+
rows.value = [...rows.value, next] // ✅ replace .value — triggers
|
|
132
|
+
// rows.value.push(next) // ❌ no trigger under shallowRef (use triggerRef if you must mutate)
|
|
133
|
+
```
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Slots & reusability
|
|
2
|
+
|
|
3
|
+
Slots are how a component accepts **markup** from its caller. Reach for a slot
|
|
4
|
+
whenever a component is a *container* for caller-provided content — cards, panels,
|
|
5
|
+
menus, layouts, list rows, buttons.
|
|
6
|
+
|
|
7
|
+
## Slots beat props for markup and open-endedness
|
|
8
|
+
|
|
9
|
+
A prop can carry a string, but not a chunk of markup, and it forces you to
|
|
10
|
+
pre-plan every variant. If a `<Button>` takes `type: 'primary' | 'secondary'`, a
|
|
11
|
+
caller can't add a third look without you editing `Button`. A slot is
|
|
12
|
+
open-ended — the caller passes whatever they need:
|
|
13
|
+
|
|
14
|
+
```vue
|
|
15
|
+
<!-- ❌ prop must anticipate every case -->
|
|
16
|
+
<Button label="Save" type="primary" />
|
|
17
|
+
<!-- ✅ slot accepts any content -->
|
|
18
|
+
<Button @click="save">Save <Spinner v-if="saving" /></Button>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Rule of thumb: **prop for data, slot for markup.** If you find yourself adding a
|
|
22
|
+
prop just to toggle a bit of caller-specific UI, that UI belongs in a slot.
|
|
23
|
+
|
|
24
|
+
## Named slots + the `#` shorthand
|
|
25
|
+
|
|
26
|
+
```vue
|
|
27
|
+
<!-- Card.vue -->
|
|
28
|
+
<template>
|
|
29
|
+
<article>
|
|
30
|
+
<header><slot name="header" /></header>
|
|
31
|
+
<slot /> <!-- default slot -->
|
|
32
|
+
<footer><slot name="footer" /></footer>
|
|
33
|
+
</article>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<!-- caller -->
|
|
37
|
+
<Card>
|
|
38
|
+
<template #header><h2>Title</h2></template> <!-- #header == v-slot:header -->
|
|
39
|
+
Body content goes in the default slot.
|
|
40
|
+
<template #footer><Button>OK</Button></template>
|
|
41
|
+
</Card>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Fallback (default) content
|
|
45
|
+
|
|
46
|
+
Content between the `<slot>` tags renders when the caller provides nothing:
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<slot name="empty">No results yet.</slot>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Scoped slots — the mental model
|
|
53
|
+
|
|
54
|
+
A scoped slot is **a function the parent supplies that returns markup; the child
|
|
55
|
+
calls it with data.** The child exposes values by binding them on `<slot>`; the
|
|
56
|
+
caller receives them via the slot prop:
|
|
57
|
+
|
|
58
|
+
```vue
|
|
59
|
+
<!-- List.vue — child owns iteration, caller owns each row's markup -->
|
|
60
|
+
<template>
|
|
61
|
+
<ul>
|
|
62
|
+
<li v-for="item in items" :key="item.id">
|
|
63
|
+
<slot :item="item" :index="index" /> <!-- expose data to the slot -->
|
|
64
|
+
</li>
|
|
65
|
+
</ul>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<!-- caller -->
|
|
69
|
+
<List :items="invoices">
|
|
70
|
+
<template #default="{ item }">
|
|
71
|
+
<strong>{{ item.number }}</strong> — {{ item.total }}
|
|
72
|
+
</template>
|
|
73
|
+
</List>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This is the core reusability move: the child encapsulates *logic* (fetching,
|
|
77
|
+
iterating, state) while the caller decides *presentation*.
|
|
78
|
+
|
|
79
|
+
## Typing & inspecting slots (Composition API)
|
|
80
|
+
|
|
81
|
+
- **Type** the slots a component accepts with `defineSlots` (compile-time only,
|
|
82
|
+
like `defineProps`):
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
defineSlots<{
|
|
86
|
+
default(props: { item: Invoice; index: number }): any
|
|
87
|
+
header(): any
|
|
88
|
+
}>()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- **Inspect** which slots the caller actually passed with `useSlots()` — use it to
|
|
92
|
+
avoid rendering an empty wrapper:
|
|
93
|
+
|
|
94
|
+
```vue
|
|
95
|
+
<script setup lang="ts">
|
|
96
|
+
const slots = useSlots()
|
|
97
|
+
</script>
|
|
98
|
+
<template>
|
|
99
|
+
<!-- only render the styled footer wrapper if a footer slot was given -->
|
|
100
|
+
<footer v-if="slots.footer" class="border-t p-4"><slot name="footer" /></footer>
|
|
101
|
+
</template>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
(In a template you can also test `$slots.footer` directly.) Without the guard
|
|
105
|
+
you ship an empty `<footer>` whose padding/border still affects layout.
|
|
106
|
+
|
|
107
|
+
## Forwarding & splitting slots
|
|
108
|
+
|
|
109
|
+
- **Forward** a slot through a wrapper so the inner component receives it:
|
|
110
|
+
|
|
111
|
+
```vue
|
|
112
|
+
<Inner>
|
|
113
|
+
<template v-for="(_, name) in $slots" #[name]="scope">
|
|
114
|
+
<slot :name="name" v-bind="scope" />
|
|
115
|
+
</template>
|
|
116
|
+
</Inner>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **Split** one incoming slot into two render positions by branching with `v-if`
|
|
120
|
+
on a condition, each `<slot>` keeping its own fallback.
|
|
121
|
+
|
|
122
|
+
## Slot transitions need keyed content
|
|
123
|
+
|
|
124
|
+
Wrapping a `<slot>` in `<Transition>` only animates if the slotted content is
|
|
125
|
+
**keyed**, so Vue can tell one state from the next:
|
|
126
|
+
|
|
127
|
+
```vue
|
|
128
|
+
<Transition name="fade" mode="out-in">
|
|
129
|
+
<component :is="current" :key="current" />
|
|
130
|
+
</Transition>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Reusable ≠ big
|
|
134
|
+
|
|
135
|
+
Small components are worth extracting too. A three-line `OverflowMenu` that always
|
|
136
|
+
pairs the same trigger icon + a11y wiring is worth a component: every use stays
|
|
137
|
+
identical, and a change happens in one place. Extract the genuinely shared
|
|
138
|
+
surface; leave caller-specific chrome in the caller (see the "what to extract"
|
|
139
|
+
note in [component-authoring.md](./component-authoring.md)).
|