@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,142 @@
|
|
|
1
|
+
# Template & vue-tsc idioms
|
|
2
|
+
|
|
3
|
+
Smaller Vue-shaped template patterns and the compile errors they prevent.
|
|
4
|
+
|
|
5
|
+
## Never two handlers of the same event on one element
|
|
6
|
+
|
|
7
|
+
Two `@keyup.*` (or two `@keydown.*`) modifier handlers on one element both compile
|
|
8
|
+
to a single `onKeyup` object property → vue-tsc fails with **TS1117 "duplicate
|
|
9
|
+
property"**, and only one wires up at runtime. Use a single handler and branch:
|
|
10
|
+
|
|
11
|
+
```vue
|
|
12
|
+
<!-- ❌ <input @keyup.enter="submit" @keyup.esc="cancel" /> -->
|
|
13
|
+
<input @keyup="onKey" />
|
|
14
|
+
<!-- function onKey(e) { if (e.key === 'Enter') submit(); else if (e.key === 'Escape') cancel() } -->
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Scoped-CSS reach: `:deep()`, `:slotted()`, `:global()`
|
|
18
|
+
|
|
19
|
+
Scoped CSS adds a data attribute that only matches the component's own elements.
|
|
20
|
+
Three pseudo-selectors reach past that boundary:
|
|
21
|
+
|
|
22
|
+
```vue
|
|
23
|
+
<style scoped>
|
|
24
|
+
.prose :deep(table) { @apply w-full; } /* child/3rd-party-rendered HTML (markdown, rich text) */
|
|
25
|
+
:slotted(p) { @apply mt-2; } /* content the PARENT passed into a <slot> */
|
|
26
|
+
:global(body) { @apply antialiased; } /* escape scope to a global rule */
|
|
27
|
+
</style>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`:deep()` for descendants the component renders dynamically, `:slotted()` for
|
|
31
|
+
slot content the caller supplied (scoped styles don't reach it by default), and
|
|
32
|
+
`:global()` for the occasional global rule without a second `<style>` block.
|
|
33
|
+
|
|
34
|
+
## Click-outside: match a unique marker class
|
|
35
|
+
|
|
36
|
+
Scope document click-outside detection to a **purpose-named marker class** added
|
|
37
|
+
solely for it — never `target.closest('.relative.flex')` or other structural
|
|
38
|
+
Tailwind selectors that silently match unrelated elements:
|
|
39
|
+
|
|
40
|
+
```vue
|
|
41
|
+
<div class="send-menu relative …">
|
|
42
|
+
<!-- if (!e.target.closest('.send-menu')) close() -->
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## `<NuxtLink>` and a thin `app.vue`
|
|
46
|
+
|
|
47
|
+
Use `<NuxtLink to="…">` for internal navigation (client routing + prefetch), not a
|
|
48
|
+
raw `<a>`. Keep `app.vue` a thin shell — `<NuxtLayout><NuxtPage /></NuxtLayout>`
|
|
49
|
+
plus app-global hosts (a `<Toast />`, a confirm dialog). `NuxtLink`/`NuxtLayout`/
|
|
50
|
+
`NuxtPage` are auto-imported.
|
|
51
|
+
|
|
52
|
+
## Per-page `<head>` with `useHead`
|
|
53
|
+
|
|
54
|
+
Set the document title/head reactively and SSR-safely with `useHead` in
|
|
55
|
+
`<script setup>` — never `document.title`. Set a default in `app.vue` and override
|
|
56
|
+
per page:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
useHead({ title: 'Invoices' }) // page
|
|
60
|
+
// app.vue: useHead({ titleTemplate: (t) => (t ? `${t} · Acme` : 'Acme') })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Reset a file input after reading it
|
|
64
|
+
|
|
65
|
+
`<input type="file">` won't re-fire `change` for the same file twice — clear it
|
|
66
|
+
after handling so re-selecting the same file works:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
function onFile(e: Event) { const t = e.target as HTMLInputElement; /* …read t.files… */ t.value = '' }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Status → label/severity lookup
|
|
73
|
+
|
|
74
|
+
Map enums to labels/severities with a small `Record` lookup + `||` fallback,
|
|
75
|
+
called as a plain function (functions, not `computed`, when they take a per-row
|
|
76
|
+
argument):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const SEV: Record<Status, string> = { paid: 'success', overdue: 'danger' }
|
|
80
|
+
const sev = (s: Status) => SEV[s] || 'secondary'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## `v-bind` same-name shorthand
|
|
84
|
+
|
|
85
|
+
When a bound attribute matches the variable name (Vue 3.4+), drop the value —
|
|
86
|
+
`:src` expands to `:src="src"`. Terser, and worth recognizing when reading code:
|
|
87
|
+
|
|
88
|
+
```vue
|
|
89
|
+
<img :src :alt /> <!-- ≡ :src="src" :alt="alt" -->
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## `useId()` for stable, SSR-safe element IDs
|
|
93
|
+
|
|
94
|
+
Wiring `<label for>` / `aria-describedby` needs an ID that's stable across SSR and
|
|
95
|
+
hydration — a hand-rolled counter or `Math.random()` causes a hydration mismatch.
|
|
96
|
+
`useId()` (Vue 3.5, auto-imported) gives an app-unique, hydration-stable ID:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const id = useId() // <label :for="id">Email</label> <input :id :aria-describedby="`${id}-help`">
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## `<Teleport>` overlays out to `body`
|
|
103
|
+
|
|
104
|
+
Render modals/dropdowns/toasts to `<body>` so they escape a parent's
|
|
105
|
+
`overflow: hidden`, `z-index`, or `transform` stacking context (the usual cause of
|
|
106
|
+
a dialog clipped inside a scrolling panel):
|
|
107
|
+
|
|
108
|
+
```vue
|
|
109
|
+
<Teleport to="body"><div class="modal">…</div></Teleport>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`<Teleport>` is auto-imported. (Volt/PrimeVue overlays already teleport
|
|
113
|
+
internally; this is for your own hand-rolled overlays.)
|
|
114
|
+
|
|
115
|
+
## `<KeepAlive>` caches toggled-component state
|
|
116
|
+
|
|
117
|
+
Wrap a dynamic `<component>`/`v-if` swap in `<KeepAlive>` to preserve a toggled
|
|
118
|
+
child's state (a half-filled form, scroll position) instead of remounting it.
|
|
119
|
+
Teardown gotcha: a cached component fires `onActivated`/`onDeactivated`, **not**
|
|
120
|
+
`onMounted`/`onUnmounted` — put pause/resume logic there, not in the mount hooks:
|
|
121
|
+
|
|
122
|
+
```vue
|
|
123
|
+
<KeepAlive :include="['EditForm']"><component :is="currentTab" /></KeepAlive>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## `v-memo` / `v-once` for expensive render subtrees
|
|
127
|
+
|
|
128
|
+
Declarative render-skipping for big lists/tables. `v-once` renders a subtree once
|
|
129
|
+
and never updates it; `v-memo="[deps]"` skips a `v-for` row's re-render unless a
|
|
130
|
+
dep changes. Reach for these only when a large grid actually shows up in a profile
|
|
131
|
+
— not by default.
|
|
132
|
+
|
|
133
|
+
```vue
|
|
134
|
+
<div v-for="row in rows" :key="row.id" v-memo="[row.id === selectedId]">…</div>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## One-off caveats (not general conventions)
|
|
138
|
+
|
|
139
|
+
These surfaced once each — apply only if they bite, don't treat as rules:
|
|
140
|
+
|
|
141
|
+
- **Confirm which motion library is installed** before animating: `motion-v` uses the component API (`<motion.div :initial :animate>`), while `@vueuse/motion` uses the `v-motion` directive (`:visible-once`). They're different packages.
|
|
142
|
+
- **A parent's `whitespace-nowrap`** (common on table `th`/`td`) is inherited by a child tooltip/popover and forces it onto one overflowing line — reset with `whitespace-normal break-words` on the bubble.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# `v-model` on components
|
|
2
|
+
|
|
3
|
+
Two sanctioned patterns for two-way binding. Default to `defineModel` for new
|
|
4
|
+
code; the computed-proxy is for forwarding an existing component's model.
|
|
5
|
+
|
|
6
|
+
## `defineModel` — preferred (Vue 3.4+ / Nuxt 4)
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const model = defineModel<boolean>() // parent: v-model
|
|
10
|
+
const visible = defineModel<boolean>('visible', { required: true }) // parent: v-model:visible
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Read/write `model.value` directly — no `props` + `emit` boilerplate. Give
|
|
14
|
+
non-primitive models a factory default like any prop.
|
|
15
|
+
|
|
16
|
+
**Named models** give one component multiple independent two-way bindings:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const years = defineModel<string>('years')
|
|
20
|
+
const months = defineModel<string>('months')
|
|
21
|
+
// parent: <DurationInput v-model:years="y" v-model:months="m" />
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Transform on the boundary with `get`/`set`.** For a component's *own* model,
|
|
25
|
+
`defineModel` takes `get`/`set` transformers directly — no separate writable
|
|
26
|
+
`computed` needed (reserve that for forwarding *someone else's* model, below):
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const model = defineModel<string>({ get: (v) => v.toUpperCase(), set: (v) => v.trim() })
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Custom `v-model` modifiers** (`v-model.capitalize`) arrive via the
|
|
33
|
+
`[model, modifiers]` tuple:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const [model, modifiers] = defineModel<string>({
|
|
37
|
+
set: (v) => (modifiers.capitalize ? v[0].toUpperCase() + v.slice(1) : v),
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## The computed-proxy — for forwarding an existing model
|
|
42
|
+
|
|
43
|
+
When you wrap a component that already has its own `v-model` (a Volt/PrimeVue
|
|
44
|
+
`Dialog`'s `visible`, say), or you need a named local handle or a value
|
|
45
|
+
transform, use a writable `computed` over `props` + `emit`:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const props = defineProps<{ visible: boolean }>()
|
|
49
|
+
const emit = defineEmits<{ 'update:visible': [v: boolean] }>()
|
|
50
|
+
const visible = computed({ get: () => props.visible, set: (v) => emit('update:visible', v) })
|
|
51
|
+
// <Dialog v-model:visible="visible" />
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A `computed({ get, set })` transforms a *forwarded* value shape (date string ↔
|
|
55
|
+
`Date`) before writing back — for a component's **own** model, prefer
|
|
56
|
+
`defineModel`'s `get`/`set` (above) over a separate computed. Either way, do NOT
|
|
57
|
+
reach for two mirror `watch`es to do this; that's the `prop-sync` smell (see
|
|
58
|
+
[watch.md](./watch.md)).
|
|
59
|
+
|
|
60
|
+
## Mutually-exclusive paired fields
|
|
61
|
+
|
|
62
|
+
When setting one field must clear its sibling, expose **two** typed `update:`
|
|
63
|
+
events and bind the inner widget with `:modelValue` + `@update:modelValue` (NOT
|
|
64
|
+
`v-model`) so your handler can emit one update and null the other in the same tick:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const emit = defineEmits<{ 'update:years': [v: string | null]; 'update:months': [v: string | null] }>()
|
|
68
|
+
function setYears(v: string) { emit('update:years', v); emit('update:months', null) }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A single `v-model` can't express "set A, clear B" atomically.
|
|
72
|
+
|
|
73
|
+
## Controlled / uncontrolled: work standalone OR be parent-driven
|
|
74
|
+
|
|
75
|
+
For a component that should manage its own state **unless** a parent supplies the
|
|
76
|
+
value (a toggle that works alone but a parent can override), detect whether the
|
|
77
|
+
control prop was passed and fall back to internal state per render:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const props = defineProps<{ open?: boolean }>() // undefined ⇒ uncontrolled
|
|
81
|
+
const emit = defineEmits<{ 'update:open': [v: boolean] }>()
|
|
82
|
+
const internal = ref(false)
|
|
83
|
+
const controlled = computed(() => props.open !== undefined)
|
|
84
|
+
const open = computed(() => (controlled.value ? props.open! : internal.value))
|
|
85
|
+
function toggle() {
|
|
86
|
+
if (controlled.value) emit('update:open', !props.open)
|
|
87
|
+
else internal.value = !internal.value
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This relies on `undefined` meaning "unset", so the control prop must **not** be a
|
|
92
|
+
bare `boolean` (the Boolean-casting trap coerces absent → `false`; see
|
|
93
|
+
[component-authoring.md](./component-authoring.md)) — type it `boolean | undefined`
|
|
94
|
+
and give it no `withDefaults` default.
|
|
95
|
+
|
|
96
|
+
## Reset / lazy-load on open
|
|
97
|
+
|
|
98
|
+
To reset transient state or lazy-load when a dialog opens, **watch the bound
|
|
99
|
+
flag** (it covers every close path — overlay click, ESC, programmatic) — see the
|
|
100
|
+
"re-seed local state on dialog open" case in [watch.md](./watch.md).
|
|
101
|
+
|
|
102
|
+
## Consistency caveat
|
|
103
|
+
|
|
104
|
+
A repo with hundreds of `props`+`emit`+`computed` `v-model`s and zero
|
|
105
|
+
`defineModel` means matching the manual pattern in that file. Introduce
|
|
106
|
+
`defineModel` deliberately, not as a drive-by in an otherwise-consistent file.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# `watch` — the escape hatch, not the default
|
|
2
|
+
|
|
3
|
+
`watch` reactively performs **side effects**. It is not for deriving values —
|
|
4
|
+
that's `computed`. Heavy `watch` use is usually a `computed`, a *writable*
|
|
5
|
+
`computed`, a `defineModel`, or an event handler in disguise.
|
|
6
|
+
|
|
7
|
+
**Rule of thumb:** read the watcher body. If it ends by assigning one reactive
|
|
8
|
+
value from others and nothing leaves Vue's world (no fetch, DOM, third-party lib,
|
|
9
|
+
storage, URL), delete it and write a `computed`.
|
|
10
|
+
|
|
11
|
+
> Grounded in a 159-watcher audit across 7 Nuxt apps: **77% were legitimate, ~9%
|
|
12
|
+
> clear smells, ~14% borderline.** The smells clustered into the four shapes
|
|
13
|
+
> below; over half the *borderline* cases were one pattern ("reset a field in a
|
|
14
|
+
> watcher"). The legit cases are real — the trap is using `watch` for what the
|
|
15
|
+
> framework already does declaratively.
|
|
16
|
+
|
|
17
|
+
## Writing a value back? Use a writable `computed`, not a watch
|
|
18
|
+
|
|
19
|
+
The most common reason people reach for `watch` is to *write a value back* —
|
|
20
|
+
mirror a prop, transform a bound value, proxy a child's `v-model`. A `computed`
|
|
21
|
+
can have a setter; use it.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// ❌ two watches mirroring a prop both ways — desyncs, pure boilerplate
|
|
25
|
+
const visible = ref(props.visible)
|
|
26
|
+
watch(() => props.visible, (v) => (visible.value = v))
|
|
27
|
+
watch(visible, (v) => emit('update:visible', v))
|
|
28
|
+
|
|
29
|
+
// ✅ writable computed — one source of truth, genuinely two-way
|
|
30
|
+
const visible = computed({
|
|
31
|
+
get: () => props.visible,
|
|
32
|
+
set: (v) => emit('update:visible', v),
|
|
33
|
+
})
|
|
34
|
+
// ✅ or, for a component's OWN model: const visible = defineModel<boolean>('visible')
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A writable `computed` also transforms a value across the boundary (string ↔ Date):
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const date = computed({
|
|
41
|
+
get: () => parseISO(model.value),
|
|
42
|
+
set: (d) => (model.value = d.toISOString()),
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
(Both getter and setter receive the previous value as their first argument if you
|
|
47
|
+
need it.)
|
|
48
|
+
|
|
49
|
+
Two rules from the Vue docs that keep this correct:
|
|
50
|
+
|
|
51
|
+
- **Getters must be side-effect-free.** A `computed` getter only derives and
|
|
52
|
+
returns — no mutating other state, no async, no DOM. Side effects *in reaction
|
|
53
|
+
to* a change are what `watch` is for.
|
|
54
|
+
- **A computed value is a read-only snapshot.** Never mutate what a `computed`
|
|
55
|
+
returns; update the *source* state it derives from and let a new snapshot be
|
|
56
|
+
produced.
|
|
57
|
+
|
|
58
|
+
## When `watch` IS the right tool
|
|
59
|
+
|
|
60
|
+
Only when the effect crosses **out of** the reactive graph:
|
|
61
|
+
|
|
62
|
+
- **Fetch on a changing key** — prefer a reactive `useFetch` `query`/key (it
|
|
63
|
+
auto-refetches; see `nuxt-nitro-api/fetch-patterns.md`). Reach for `watch` only
|
|
64
|
+
for imperative `$fetch` keyed off a single id.
|
|
65
|
+
- **Drive a non-Vue library** — Tiptap, a Leaflet/Google map, a signature canvas,
|
|
66
|
+
a PrimeVue popover you `.hide()` imperatively.
|
|
67
|
+
- **DOM / timers** — `document.body.style.overflow`, a debounce `setTimeout`, a
|
|
68
|
+
polling `setInterval` (clear it in `onUnmounted`).
|
|
69
|
+
- **Persist** — a `localStorage`/`useCookie` write, debounced auto-save of a
|
|
70
|
+
deep-watched form.
|
|
71
|
+
- **URL sync** — `router.replace({ query: { ...route.query, tab } })`.
|
|
72
|
+
- **Re-seed local state on dialog open** — `watch(visible, (v) => { if (v) initForm() })`.
|
|
73
|
+
The single most common legit pattern.
|
|
74
|
+
- **Clone a server prop into a locally-editable draft** —
|
|
75
|
+
`watch(() => props.record, (r) => { if (r) form.value = structuredClone(toRaw(r)) }, { immediate: true })`.
|
|
76
|
+
|
|
77
|
+
### Cancel stale work with `onWatcherCleanup`
|
|
78
|
+
|
|
79
|
+
A watcher that starts async work (a keyed `$fetch`, a timer) must cancel the
|
|
80
|
+
previous run, or out-of-order responses clobber state — the #1 correctness bug in
|
|
81
|
+
keyed-fetch watchers. Vue 3.5's `onWatcherCleanup(fn)` registers teardown that
|
|
82
|
+
runs before the next fire and on stop:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { onWatcherCleanup } from 'vue'
|
|
86
|
+
|
|
87
|
+
watch(query, async (q) => {
|
|
88
|
+
const ctrl = new AbortController()
|
|
89
|
+
onWatcherCleanup(() => ctrl.abort()) // abort the in-flight request if q changes again
|
|
90
|
+
results.value = await $fetch('/api/search', { query: { q }, signal: ctrl.signal })
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Gotcha: `onWatcherCleanup` only registers **synchronously, before the first
|
|
95
|
+
`await`**. For teardown decided after an await, use the callback's third arg
|
|
96
|
+
instead — `watch(src, (v, _old, onCleanup) => { … onCleanup(fn) })`.
|
|
97
|
+
|
|
98
|
+
### `flush: 'post'` runs the callback after the DOM patches
|
|
99
|
+
|
|
100
|
+
When a watcher reacts to a change by reading/scrolling the **updated** DOM, give
|
|
101
|
+
it `{ flush: 'post' }` so it runs after Vue patches — no manual `await nextTick()`.
|
|
102
|
+
They're ~one microtask apart and interchangeable; pick by clarity (prefer
|
|
103
|
+
`flush: 'post'` for an effect that *reacts to reactive change*, `nextTick` for a
|
|
104
|
+
one-shot after an imperative toggle).
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
watch(activeId, () => scrollActiveIntoView(), { flush: 'post' }) // DOM already updated
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Smell catalog (with refactors)
|
|
111
|
+
|
|
112
|
+
| Smell | Tell | Reach for |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| **derive-state** | body assigns a filtered/mapped view into another ref | `computed` |
|
|
115
|
+
| **prop-sync** | local `ref(props.x)` kept in sync by a watch (often + a 2nd watch emitting back) | `defineModel` or a writable `computed` |
|
|
116
|
+
| **side-effect-in-handler** | watching a value that only changes via one control, to clear a dependent field | that control's `@update:model-value` handler |
|
|
117
|
+
| **manual-refetch** | watching filter refs to call a function that calls `useFetch` | a `computed` `query` passed to `useFetch` |
|
|
118
|
+
|
|
119
|
+
The biggest real cluster was **side-effect-in-handler** — resetting dependent
|
|
120
|
+
fields when a Select changed. In a watcher it hides cause/effect and re-fires on
|
|
121
|
+
programmatic form reseeds; the colocated handler is direct and only fires on the
|
|
122
|
+
user action:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// ❌ watch(() => form.entityType, () => { form.eventType = ''; form.conditionValue = null })
|
|
126
|
+
// ✅
|
|
127
|
+
function onEntityType(v) { form.entityType = v; form.eventType = ''; form.conditionValue = null }
|
|
128
|
+
// <Select v-model="form.entityType" @update:model-value="onEntityType" />
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Watch for **cascades** — a chain of watches each resetting the next (entityType →
|
|
132
|
+
eventType → conditionProperty) is just a chain of handlers, far harder to follow.
|
|
133
|
+
|
|
134
|
+
**Borderline tell — `seed-via-immediate`:** `watch(asyncList, (l) => { if (!local.value) local.value = pick(l) }, { immediate: true })` to default a *user-mutable* ref from fetched data is defensible (a pure `computed` can't be user-overridden) — but keep the `!local.value` guard so a refetch doesn't clobber an in-progress edit.
|
|
135
|
+
|
|
136
|
+
## `deep: true` is expensive — use it sparingly
|
|
137
|
+
|
|
138
|
+
A deep watch traverses **every** nested property of the watched object on each
|
|
139
|
+
check, so it gets costly on large structures. Before reaching for it:
|
|
140
|
+
|
|
141
|
+
- **Watch a getter of the specific field** instead of the whole object:
|
|
142
|
+
`watch(() => form.address.zip, ...)` rather than `watch(form, ..., { deep: true })`.
|
|
143
|
+
- If you genuinely need to react to *any* field of a form (e.g. debounced
|
|
144
|
+
auto-save), a deep watch is legitimate — but scope it as tightly as you can.
|
|
145
|
+
- For deriving from a few keys of a nested object, a `computed` (or `watchEffect`)
|
|
146
|
+
tracks **only the keys actually read**, avoiding the full traversal a deep watch
|
|
147
|
+
pays for.
|
|
148
|
+
|
|
149
|
+
**Deep-watch aliasing trap:** in a deep watch the callback's `oldValue` and
|
|
150
|
+
`newValue` are the **same reference** (Vue mutated the object in place), so you
|
|
151
|
+
*cannot* diff old-vs-new inside the callback — `if (next.x !== prev.x)` is always
|
|
152
|
+
false. Watch a derived getter instead, so old/new are distinct primitives:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// ❌ watch(items, (next, prev) => { if (next.length !== prev.length) … }, { deep: true }) // never fires
|
|
156
|
+
// ✅ watch(() => items.value.length, (next, prev) => { if (next > prev) onAdded() })
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## `watch` vs `watchEffect`
|
|
160
|
+
|
|
161
|
+
Both run side effects reactively; they differ in how they **track dependencies**:
|
|
162
|
+
|
|
163
|
+
- **`watch(source, cb)`** tracks ONLY the explicit source, fires lazily and only
|
|
164
|
+
when it *actually changed*, and hands you **old + new** values. Use it when you
|
|
165
|
+
want precise control over what fires the effect, need the previous value, or
|
|
166
|
+
want it lazy.
|
|
167
|
+
- **`watchEffect(cb)`** runs immediately and auto-tracks every reactive property
|
|
168
|
+
read during its **synchronous** execution. Terser, and it removes the burden of
|
|
169
|
+
maintaining a source list — genuinely better for a side effect with **several**
|
|
170
|
+
dependencies, or one reading a few keys of a nested object (it tracks only
|
|
171
|
+
what's used, unlike a `deep` watch).
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// watch: todoId named twice (source + inside callback)
|
|
175
|
+
watch(todoId, async () => { data.value = await $fetch(`/api/todos/${todoId.value}`) }, { immediate: true })
|
|
176
|
+
// watchEffect: todoId.value is both the read and the tracked dep — no immediate flag, no repeated source
|
|
177
|
+
watchEffect(async () => { data.value = await $fetch(`/api/todos/${todoId.value}`) })
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Two cautions on `watchEffect`:
|
|
181
|
+
|
|
182
|
+
- **Async tracking gotcha:** it only tracks deps accessed **before the first
|
|
183
|
+
`await`**. Read a ref *after* an `await` and it silently won't re-trigger on
|
|
184
|
+
that ref. With multiple async deps, read them all up front or use an explicit
|
|
185
|
+
`watch`.
|
|
186
|
+
- **It tempts the derive-state smell.** Because it runs immediately and
|
|
187
|
+
auto-tracks, `watchEffect` is the most common home for "compute and assign" —
|
|
188
|
+
which is a `computed`. The *only* `watchEffect` smell in the audit was exactly
|
|
189
|
+
this: `watchEffect(() => { orgField.options = organizations.value })` → should
|
|
190
|
+
be a `computed` that maps the field list.
|
|
191
|
+
|
|
192
|
+
Default: **`computed` for derivation, `watch` for a precise single-source effect,
|
|
193
|
+
`watchEffect` for a genuine multi-dependency side effect** whose deps are all read
|
|
194
|
+
synchronously.
|