@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.
Files changed (32) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +1 -1
  4. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +23 -8
  5. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +1 -0
  6. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/caching.md +68 -0
  7. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +31 -1
  8. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/error-handling.md +74 -0
  9. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +18 -0
  10. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/formatters.md +11 -0
  11. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/layers.md +40 -0
  12. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +1 -0
  13. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/route-rules.md +41 -0
  14. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-runtime.md +97 -0
  15. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +60 -0
  16. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/state-management.md +68 -0
  17. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/storage.md +59 -0
  18. package/plugins/volt-primevue/.claude-plugin/plugin.json +8 -0
  19. package/plugins/volt-primevue/skills/volt-primevue/SKILL.md +170 -0
  20. package/plugins/volt-primevue/skills/volt-primevue/config.md +51 -0
  21. package/plugins/volt-primevue/skills/volt-primevue/gotchas.md +115 -0
  22. package/plugins/volt-primevue/skills/volt-primevue/theming.md +137 -0
  23. package/plugins/vue-nuxt/.claude-plugin/plugin.json +8 -0
  24. package/plugins/vue-nuxt/skills/vue-nuxt/SKILL.md +48 -0
  25. package/plugins/vue-nuxt/skills/vue-nuxt/auto-imports.md +48 -0
  26. package/plugins/vue-nuxt/skills/vue-nuxt/component-authoring.md +159 -0
  27. package/plugins/vue-nuxt/skills/vue-nuxt/composables.md +95 -0
  28. package/plugins/vue-nuxt/skills/vue-nuxt/reactivity.md +133 -0
  29. package/plugins/vue-nuxt/skills/vue-nuxt/slots.md +139 -0
  30. package/plugins/vue-nuxt/skills/vue-nuxt/template-idioms.md +142 -0
  31. package/plugins/vue-nuxt/skills/vue-nuxt/v-model.md +106 -0
  32. 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.