@gallopsystems/agent-skills 1.8.0 → 1.10.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/package.json +1 -1
- package/plugins/copier-template/skills/copier-template/SKILL.md +14 -0
- package/plugins/nitro-testing/skills/nitro-testing/frontend-testing.md +71 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/SKILL.md +4 -2
- package/plugins/vue-nuxt/skills/vue-nuxt/vueuse.md +101 -0
- package/plugins/vue-nuxt/skills/vue-nuxt/watch.md +41 -2
package/package.json
CHANGED
|
@@ -62,6 +62,20 @@ git ls-remote --tags --refs --sort=-v:refname <template-url> 'v*' | head -1
|
|
|
62
62
|
|
|
63
63
|
If newer, it pushes a **static branch name** (e.g. `chore/template-update`) with an `--allow-empty` commit and opens a PR whose body contains the version delta, release-notes/compare links, and step-by-step instructions an agent can execute. Hard-won details to keep if reimplementing: an explicit `permissions: contents: write, pull-requests: write` block (default token can't open PRs), a static branch name (dated branches caused duplicate PRs), and comparing **tag versions, not commit SHAs**.
|
|
64
64
|
|
|
65
|
+
## Branch Protection in Descendants
|
|
66
|
+
|
|
67
|
+
A template **cannot** enable branch protection for the repos it generates — GitHub reads required status checks from repo config, never from committed workflow files. So every descendant starts with nothing gating merges until someone sets it once (after the first CI run, so the check is known). This template's CI exposes a **`ci-success`** summary job to be exactly that gate — require it on `main`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
echo '{"required_status_checks":{"strict":false,"contexts":["ci-success"]},"enforce_admins":false,"required_pull_request_reviews":null,"restrictions":null}' \
|
|
71
|
+
| gh api -X PUT repos/<owner>/<repo>/branches/main/protection --input -
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **Require the single `ci-success` context, not individual job names.** CI shards the test suite, so the per-leg check names embed the matrix size (`test (shard 1/4)`) and change as the project grows — protection pinned to them blocks every PR the moment the count shifts. `ci-success` is a summary job (`needs:` all gating jobs, `if: always()`, fails unless every `needs.*.result` is `success`); `needs.test.result` rolls the whole matrix into one value, so it stays correct at any shard count. If you add a template whose CI lacks such a job, create one rather than requiring the matrix legs directly.
|
|
75
|
+
- **`coverage` is deliberately excluded** from `ci-success` so the report never blocks a merge — don't add it to the required contexts.
|
|
76
|
+
- The PUT body must include `required_status_checks`, `enforce_admins`, `required_pull_request_reviews`, and `restrictions` (any may be `null`) or the call 422s. Needs admin on the repo.
|
|
77
|
+
- A Conventional-Commits **PR-title check is only worth requiring on repos something actually reads the title** — i.e. a published package with release-please/changelog automation. A private app (no release tooling, `"private": true`, no version) gains nothing from it; don't gate on it there.
|
|
78
|
+
|
|
65
79
|
## Further Reading
|
|
66
80
|
|
|
67
81
|
- **Template anatomy & testing changes**: [template-authoring.md](template-authoring.md)
|
|
@@ -482,6 +482,77 @@ for (const tz of timezones) {
|
|
|
482
482
|
}
|
|
483
483
|
```
|
|
484
484
|
|
|
485
|
+
### 7. Reading or driving a stubbed child needs an explicit stub
|
|
486
|
+
|
|
487
|
+
`Stub: true` (auto-stub) renders the child but **does not expose its props** to
|
|
488
|
+
`findComponent(Stub).props("x")`. To read or drive a child's `modelValue`, give it
|
|
489
|
+
an explicit stub that declares the prop:
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
const SelectStub = {
|
|
493
|
+
name: "Select",
|
|
494
|
+
props: ["modelValue"],
|
|
495
|
+
emits: ["update:modelValue"],
|
|
496
|
+
template: "<div />",
|
|
497
|
+
};
|
|
498
|
+
// findComponent(SelectStub).props("modelValue") — now readable
|
|
499
|
+
// findComponent(SelectStub).vm.$emit("update:modelValue", x) — drives the v-model
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
For a deep tree (a dialog full of fields), `shallow: true` auto-stubs everything;
|
|
503
|
+
add explicit stubs only for the parts you assert on — plus a dialog stub that
|
|
504
|
+
renders its slot, since UI-lib dialogs gate content behind a `visible` prop:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
const DialogStub = { props: ["visible"], template: "<div v-if='visible'><slot /><slot name='footer' /></div>" };
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### 8. Driving and asserting route/query state
|
|
511
|
+
|
|
512
|
+
A global route middleware that redirects unauthenticated navigations (to a public
|
|
513
|
+
`/login`) means `mountSuspended(Comp, { route: "/private?foo=1" })` lands on the
|
|
514
|
+
public path and **drops the query** — so a `useRouteQuery`-bound ref reads empty.
|
|
515
|
+
Two ways around it:
|
|
516
|
+
|
|
517
|
+
- **Carry the params on the public route:** `route: "/login?foo=1"`. `useRouteQuery`
|
|
518
|
+
binds to `route.query` regardless of the path.
|
|
519
|
+
- **Assert URL *writes* by spying on `router.replace`**, not by reading
|
|
520
|
+
`currentRoute` (the redirect strips what you'd read back):
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
const replace = vi.spyOn((wrapper.vm as any).$router, "replace");
|
|
524
|
+
input.vm.$emit("update:modelValue", "foo");
|
|
525
|
+
await flushPromises();
|
|
526
|
+
expect(replace.mock.calls.some((c) => (c[0] as any)?.query?.q === "foo")).toBe(true);
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### 9. Testing a composable that needs a component scope
|
|
530
|
+
|
|
531
|
+
For a composable using lifecycle hooks or `provide`/`inject`, mount a throwaway
|
|
532
|
+
component whose `setup()` calls it and capture the return:
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
let api: ReturnType<typeof useThing>;
|
|
536
|
+
const Comp = defineComponent({ setup() { api = useThing(); return () => h("div"); } });
|
|
537
|
+
await mountSuspended(Comp);
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Control a VueUse dependency with `vi.mock("@vueuse/core", …)` to return refs you
|
|
541
|
+
drive. And make sure the vitest `include` glob covers `app/composables/**` — a
|
|
542
|
+
composables dir is easy to leave out of the frontend config.
|
|
543
|
+
|
|
544
|
+
### 10. Reset the shared `useFetch` cache between mounts
|
|
545
|
+
|
|
546
|
+
`useFetch`/`useAsyncData` cache by key, and the cache is **shared across
|
|
547
|
+
`mountSuspended` calls in a file**. When several tests mount the same component
|
|
548
|
+
(fixed URL) but `registerEndpoint` returns different data per test, the second
|
|
549
|
+
test sees the first's response unless you clear it:
|
|
550
|
+
|
|
551
|
+
```typescript
|
|
552
|
+
import { clearNuxtData } from "#imports"; // not exported from @nuxt/test-utils/runtime
|
|
553
|
+
beforeEach(() => clearNuxtData());
|
|
554
|
+
```
|
|
555
|
+
|
|
485
556
|
## File Organization
|
|
486
557
|
|
|
487
558
|
Co-locate tests with source files:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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, slots, composables, reactivity, when watch is a code smell, page structure, display formatting, and Vue-shaped template idioms.
|
|
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, slots, composables, reactivity, when watch is a code smell, reaching for VueUse over hand-rolled effects, page structure, display formatting, and Vue-shaped template idioms.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Vue-in-Nuxt component authoring
|
|
@@ -23,6 +23,7 @@ cross-links to those rather than restating them.
|
|
|
23
23
|
- Formatting display values (currency/date/number) consistently
|
|
24
24
|
- Anything reactivity-shaped: `computed` vs `watch`, prop→state sync, DOM measurement
|
|
25
25
|
- You see `watch` and want to know if it should be something else
|
|
26
|
+
- About to hand-roll a `watch` + lifecycle teardown for a browser API (storage, timers, DOM, listeners, URL) — VueUse likely wraps it
|
|
26
27
|
|
|
27
28
|
## Reference Files
|
|
28
29
|
|
|
@@ -33,6 +34,7 @@ cross-links to those rather than restating them.
|
|
|
33
34
|
- [composables.md](./composables.md) — `MaybeRefOrGetter`/`toValue` argument contract, return refs not `reactive()`, thin pure-core shell, `onScopeDispose`/`effectScope` cleanup
|
|
34
35
|
- [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
|
|
35
36
|
- [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
|
|
37
|
+
- [vueuse.md](./vueuse.md) — reach for a VueUse composable before hand-rolling a `watch` + lifecycle teardown for an external-world effect (DOM, timers, storage, URL, listeners); the `@vueuse/nuxt` setup, the watch-sugar functions, and what to keep on Nuxt's own APIs
|
|
36
38
|
- [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
|
|
37
39
|
- [page-structure.md](./page-structure.md) — keep pages thin: route-param parsing + layout in the page, data/logic/forms in components
|
|
38
40
|
- [formatters.md](./formatters.md) — never inline a currency/date/number formatter; centralize in `useFormatters`, prefer Intl/date-fns
|
|
@@ -42,7 +44,7 @@ cross-links to those rather than restating them.
|
|
|
42
44
|
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>`).
|
|
43
45
|
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).
|
|
44
46
|
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).
|
|
45
|
-
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).
|
|
47
|
+
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). Before hand-rolling that effect-plus-teardown, check whether a **VueUse** composable already wraps it (`vueuse.md`) — they bundle the cleanup.
|
|
46
48
|
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.
|
|
47
49
|
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.
|
|
48
50
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# VueUse — reach for a composable before hand-rolling an effect
|
|
2
|
+
|
|
3
|
+
`watch.md` lists the cases where `watch` is legit: they're all effects that cross
|
|
4
|
+
**out of** the reactive graph (DOM, timers, storage, URL, the network). For most
|
|
5
|
+
of those, **VueUse already wraps the boundary** — and a VueUse composable bundles
|
|
6
|
+
the *teardown* with the reactive effect, so it retires the manual-`onUnmounted`
|
|
7
|
+
cleanup, not just the watch. Before you write a `watch` + `onMounted`/`onUnmounted`
|
|
8
|
+
pair against a browser API, check whether a composable does it.
|
|
9
|
+
|
|
10
|
+
> Not a replacement for the framework. Data fetching stays on
|
|
11
|
+
> `useFetch`/`useAsyncData` (see `nuxt-nitro-api`); cookies use Nuxt's built-in
|
|
12
|
+
> `useCookie`; derivation stays a `computed`. VueUse is for the *external-world*
|
|
13
|
+
> effects — see the caveats at the bottom.
|
|
14
|
+
|
|
15
|
+
## Setup in Nuxt
|
|
16
|
+
|
|
17
|
+
Add the module; it auto-imports every `@vueuse/core` function, so they need no
|
|
18
|
+
explicit `import` (same as Nuxt's own composables — see `auto-imports.md`):
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// nuxt.config.ts
|
|
22
|
+
export default defineNuxtConfig({ modules: ['@vueuse/nuxt'] })
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The **`@vueuse/router`** and **`@vueuse/integrations`** add-ons are separate
|
|
26
|
+
installs and are **not** auto-imported by the module — import them explicitly.
|
|
27
|
+
|
|
28
|
+
## The boundary cases (mapped from `watch.md`)
|
|
29
|
+
|
|
30
|
+
| You were about to `watch` for… | Reach for | Why it's better |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `document.body.style.overflow` toggle | `useScrollLock(document.body)` | writable ref; restores the prior value on unmount |
|
|
33
|
+
| `setInterval` polling | `useIntervalFn(fn, ms)` / `useTimeoutPoll` | **auto-clears on unmount** — no `clearInterval` to forget |
|
|
34
|
+
| `setTimeout` | `useTimeoutFn(fn, ms)` | same auto-cleanup, with `start`/`stop` controls |
|
|
35
|
+
| debounced auto-save | `useDebounceFn` / `refDebounced` | no manual timer ref + cleanup |
|
|
36
|
+
| `localStorage`/`sessionStorage` write-back | `useLocalStorage(key, init)` / `useStorage` | a reactive ref mirrored to storage both ways — the write-back watch disappears |
|
|
37
|
+
| `addEventListener` in `onMounted` | `useEventListener(target, evt, fn)` | auto-removes on unmount |
|
|
38
|
+
| click-outside marker class | `onClickOutside(el, fn)` | replaces the hand-rolled document listener (cf. `template-idioms.md`) |
|
|
39
|
+
| `ResizeObserver` re-measure | `useElementSize` / `useElementBounding` / `useResizeObserver` | reactive size, observer torn down for you (cf. `reactivity.md`) |
|
|
40
|
+
| `matchMedia` listener | `useMediaQuery` / `usePreferredColorScheme` / `usePreferredDark` | reactive boolean, no listener bookkeeping |
|
|
41
|
+
| clipboard write + "copied!" flag | `useClipboard()` → `{ copy, copied }` | `copied` auto-resets |
|
|
42
|
+
|
|
43
|
+
URL sync (`router.replace({ query: { ...route.query, tab } })`) →
|
|
44
|
+
`useRouteQuery('tab')` from `@vueuse/router` gives a ref bound two-way to the query
|
|
45
|
+
param. Nuxt's own `useRoute()` is already reactive for *reads*; reach for
|
|
46
|
+
`useRouteQuery` when you want a **writable** ref bound to a single param.
|
|
47
|
+
|
|
48
|
+
### `useLocalStorage` reads at setup — guard SSR hydration
|
|
49
|
+
|
|
50
|
+
`useLocalStorage`/`useStorage` is SSR-safe: on the server there's no `window`, so
|
|
51
|
+
it returns the default and never touches storage (no `import.meta.client` guard
|
|
52
|
+
needed — that guard was only for raw `localStorage.*` calls, which throw on the
|
|
53
|
+
server). But on the client it reads storage **synchronously at setup**, so a value
|
|
54
|
+
rendered *without a mount gate* differs between the server render (the default) and
|
|
55
|
+
the hydrating client render (the stored value) → a hydration mismatch. Pass
|
|
56
|
+
`{ initOnMounted: true }` to defer the read to `onMounted` so the first client
|
|
57
|
+
render matches the server, or gate the rendering until mounted.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// shown straight away (no v-if gate) → defer the storage read to onMounted
|
|
61
|
+
const view = useLocalStorage('view', 'list', { initOnMounted: true })
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### `useRouteQuery` makes the URL the single source of truth
|
|
65
|
+
|
|
66
|
+
Because the bound ref reads from and writes to the query param, the URL *is* the
|
|
67
|
+
state — so it **can't represent a ref with two distinct "empty" states** (e.g. a
|
|
68
|
+
filter that defaults to `"Open"` on load but clears to `null`; both would be "param
|
|
69
|
+
absent"). When you need that distinction, keep a plain `ref` + a projecting `watch`
|
|
70
|
+
(a sanctioned `watch.md` "URL sync" case). And **don't mix `useUrlSearchParams`
|
|
71
|
+
(History API) with `useRouteQuery` (vue-router) in the same component** — they write
|
|
72
|
+
the URL through different mechanisms and clobber each other's params; pick one.
|
|
73
|
+
|
|
74
|
+
## VueUse's `watch` sugar — when a `watch` IS warranted
|
|
75
|
+
|
|
76
|
+
When the effect genuinely belongs in a watcher, VueUse's Watch category removes the
|
|
77
|
+
boilerplate `watch.md` warns about:
|
|
78
|
+
|
|
79
|
+
- **`watchDebounced(src, cb, { debounce: 300 })`** / **`watchThrottled`** — the
|
|
80
|
+
debounced-auto-save case without a `setTimeout` inside the callback.
|
|
81
|
+
- **`whenever(source, cb)`** — fires only when `source` becomes truthy; the
|
|
82
|
+
"re-seed on dialog open" pattern (`watch(visible, v => { if (v) … })`) as one line.
|
|
83
|
+
- **`until(source).toBe(x)`** — await a reactive condition instead of polling.
|
|
84
|
+
- **`watchOnce`** — auto-stops after the first fire; no manual `stop()` handle.
|
|
85
|
+
- **`watchIgnorable` / `watchPausable`** — suppress or pause a watcher around a
|
|
86
|
+
programmatic write (a cleaner answer than guard flags for the "re-fires on
|
|
87
|
+
programmatic reseed" trap in `watch.md`).
|
|
88
|
+
|
|
89
|
+
`onWatcherCleanup` and `flush: 'post'` (in `watch.md`) still apply — these are sugar
|
|
90
|
+
over the same watcher, not a different mechanism.
|
|
91
|
+
|
|
92
|
+
## Caveats — don't over-reach
|
|
93
|
+
|
|
94
|
+
- **Cookies:** use Nuxt's built-in **`useCookie`** (SSR-aware), not VueUse's
|
|
95
|
+
`@vueuse/integrations` `useCookies` (a `universal-cookie` wrapper).
|
|
96
|
+
- **Fetching:** `useFetch`/`useAsyncData`/`$fetch` own the data layer
|
|
97
|
+
(`nuxt-nitro-api`). Skip VueUse's `useFetch`/`useAsyncState` in a Nuxt app.
|
|
98
|
+
- **Head/title:** prefer Nuxt's `useHead`/`useSeoMeta` over `useTitle`/`useFavicon`.
|
|
99
|
+
- **Derivation is still a `computed`.** VueUse doesn't change the core rule: if the
|
|
100
|
+
body just assigns one reactive value from others, it's a `computed`, not a
|
|
101
|
+
composable and not a watch.
|
|
@@ -68,12 +68,35 @@ Only when the effect crosses **out of** the reactive graph:
|
|
|
68
68
|
polling `setInterval` (clear it in `onUnmounted`).
|
|
69
69
|
- **Persist** — a `localStorage`/`useCookie` write, debounced auto-save of a
|
|
70
70
|
deep-watched form.
|
|
71
|
-
- **URL sync** — `router.replace({ query: { ...route.query, tab } })`.
|
|
71
|
+
- **URL sync** — `router.replace({ query: { ...route.query, tab } })`. For a single
|
|
72
|
+
ref ↔ one query param, prefer `useRouteQuery` (see below). A write-back watch is
|
|
73
|
+
the right tool only when the URL can't model the state: a ref with **two distinct
|
|
74
|
+
"empty" states** (e.g. a filter that defaults to `"Open"` on load but clears to
|
|
75
|
+
`null` — an absent param can map to only one of them), or a **composite object
|
|
76
|
+
fanning out to many params** (a PrimeVue filter object) that a one-ref-per-param
|
|
77
|
+
`useRouteQuery` can't express.
|
|
72
78
|
- **Re-seed local state on dialog open** — `watch(visible, (v) => { if (v) initForm() })`.
|
|
73
|
-
The single most common legit pattern.
|
|
79
|
+
The single most common legit pattern. Its one-liner is `whenever(visible, initForm)`
|
|
80
|
+
(see `vueuse.md`); a compound guard keeps its inner half —
|
|
81
|
+
`watch(visible, (v) => { if (v && !props.x) reset() })` →
|
|
82
|
+
`whenever(visible, () => { if (!props.x) reset() })`.
|
|
74
83
|
- **Clone a server prop into a locally-editable draft** —
|
|
75
84
|
`watch(() => props.record, (r) => { if (r) form.value = structuredClone(toRaw(r)) }, { immediate: true })`.
|
|
76
85
|
|
|
86
|
+
> **Before hand-rolling the external-world cases, check VueUse.** Most of the
|
|
87
|
+
> bullets above (DOM, timers, persist, URL) are exactly what VueUse wraps — and a
|
|
88
|
+
> VueUse composable bundles the *teardown* with the reactive effect, so it
|
|
89
|
+
> retires the manual-`onUnmounted` footgun, not just the watch:
|
|
90
|
+
>
|
|
91
|
+
> - **DOM / body scroll** — `useScrollLock(document.body)` over a watch toggling `document.body.style.overflow`.
|
|
92
|
+
> - **Timers** — `useIntervalFn` / `useTimeoutFn` / `useTimeoutPoll` auto-clear on unmount (no manual `clearInterval`); `useDebounceFn` / `refDebounced` for debounce.
|
|
93
|
+
> - **Persist** — `useLocalStorage` / `useStorage`: a reactive ref mirrored to storage, no write-back watch.
|
|
94
|
+
> - **URL sync** — `useRouteQuery` (from `@vueuse/router`) for a ref bound to a query param.
|
|
95
|
+
>
|
|
96
|
+
> Nuxt caveats: cookies use the built-in `useCookie`, **not** VueUse's
|
|
97
|
+
> `@vueuse/integrations` `useCookies`; data fetching stays on
|
|
98
|
+
> `useFetch`/`useAsyncData`. Auto-import the rest via the `@vueuse/nuxt` module.
|
|
99
|
+
|
|
77
100
|
### Cancel stale work with `onWatcherCleanup`
|
|
78
101
|
|
|
79
102
|
A watcher that starts async work (a keyed `$fetch`, a timer) must cancel the
|
|
@@ -116,6 +139,22 @@ watch(activeId, () => scrollActiveIntoView(), { flush: 'post' }) // DOM already
|
|
|
116
139
|
| **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
140
|
| **manual-refetch** | watching filter refs to call a function that calls `useFetch` | a `computed` `query` passed to `useFetch` |
|
|
118
141
|
|
|
142
|
+
**An external source doesn't legitimize a deriving watch.** The
|
|
143
|
+
watch-vs-`computed` decision turns on whether the *body* leaves the reactive
|
|
144
|
+
graph — **not** on whether the *source* came from outside it. A watcher whose
|
|
145
|
+
source is a composable/library ref (`useEventSource`'s `status`, a store getter,
|
|
146
|
+
a `useWindowSize`) but whose body only assigns a derived value is still a
|
|
147
|
+
`computed`. "The source is external state" is the most common excuse for keeping
|
|
148
|
+
such a watch, and it's wrong — it's still derivation.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// ❌ a library ref as source tempts a watch, but the body only derives
|
|
152
|
+
const isConnected = ref(false)
|
|
153
|
+
watch(status, (s) => { isConnected.value = s === 'OPEN' }) // status from useEventSource
|
|
154
|
+
// ✅ derivation is a computed, regardless of where status came from
|
|
155
|
+
const isConnected = computed(() => status.value === 'OPEN')
|
|
156
|
+
```
|
|
157
|
+
|
|
119
158
|
The biggest real cluster was **side-effect-in-handler** — resetting dependent
|
|
120
159
|
fields when a Select changed. In a watcher it hides cause/effect and re-fires on
|
|
121
160
|
programmatic form reseeds; the colocated handler is direct and only fires on the
|