Compare commits

...

149 Commits

Author SHA1 Message Date
Ovidiu U
25cf022964 feat: add prediction rebuild design spec — Layer 1 ridge model, LLM news overlay, volatility regime detector
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Documents complete replacement of six-signal aggregator with calibrated
ridge forecaster trained on 435 weeks of BEIS pump prices. Five-layer
architecture: weekly baseline (Layer 1), local snapshot (Layer 2),
rule-gated verdict merger (Layer 3), daily LLM news
2026-05-01 13:23:10 +01:00
Ovidiu U
e821a934a5 feat: add weekly_pump_prices migration for BEIS fuel price data
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Created migration for storing UK weekly pump prices from BEIS publications.
Table uses Monday date as primary key and stores petrol/diesel pump prices,
duty, and VAT rates as integer pence or percentage values.
2026-05-01 13:22:50 +01:00
Ovidiu U
73de53994f fix: prevent sensitive field leaks in /me, add retry logic to Brent price sources
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Made `/api/auth/me` public and return explicit allowlist (name, email,
  two_factor_confirmed_at, tier, subscription fields) instead of spreading
  `$user->toArray()` which leaked is_admin, stripe_id, pm_type, pm_last_four,
  postcode. Returns `null` when unauthenticated rather than 401.
- Moved `/auth/logout` to remain behind auth:sanctum gate.
- Added 3×200ms retry with exponential backoff to EiaBrentPriceSource and
  FredBrentPriceSource on ConnectionException or 5xx responses. Timeout
  raised from 10s to 30s.
- Both sources now throw typed BrentPriceFetchException on exhausted retries
  instead of silently returning null + logging. Updated tests to assert
  exception message includes HTTP status or "connection failed".
2026-05-01 13:22:36 +01:00
Ovidiu U
df70e514e9 refactor: add hard-stop documentation and deny-list for destructive DB commands
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Documented explicit prohibition of `migrate:fresh`, `migrate:reset`,
`db:wipe`, and raw DROP/TRUNCATE operations in CLAUDE.md. Prose rule
clarifies that user phrases like "trust me" or "do the refactor" are
not authorisation for schema rebuilds — architectural decision is
separate from operational step.

Added matching deny patterns to `.claude/settings.json` to block
direct inv
2026-04-30 09:01:20 +01:00
Ovidiu U
28061541d4 refactor: remove auto-scroll on stations loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Disabled the watch hook that scrolled the viewport when station results
were loading. The 40px nudge disrupted user focus and was not needed
for usability.
2026-04-30 09:01:11 +01:00
Ovidiu U
895d55439b refactor: extract StationSearchService
Audit item #8. StationController::index was ~100 lines doing the
haversine join, in-PHP filter/sort/group, search-row write, and
prediction call — well past the "thin orchestrator" line in
.claude/rules/architecture.md.

- App\Services\StationSearch\SearchCriteria — DTO (lat/lng/fuelType/
  radiusKm/sort)
- App\Services\StationSearch\SearchResult — DTO (stations, prices
  summary, reliability counts, prediction payload)
- App\Services\StationSearch\StationSearchService::search(criteria,
  ?user, ?ipHash) — owns the haversine query, the per-row reliability
  memoisation, sort, count, search-row logging, and the tier-gated
  prediction.

The controller now resolves coordinates, builds a SearchCriteria, calls
the service, and shapes the JSON response. Down from 154 → 71 lines.
Public API contract unchanged — all 15 StationController tests pass
without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:20:23 +01:00
Ovidiu U
aff6dd1e0f refactor: SendScheduledWhatsAppJob — drop redundant filtering
Audit item #12. The fan-out job ran an upfront Plan query plus a
per-user tier-name comparison before checking canSendNow('whatsapp').
Both are already covered by canSendNow → canUseChannel + daily-limit
count, so the parent was duplicating filtering work that the child
DispatchUserNotificationJob would do anyway via channelsFor().

Now the parent does only the cheap pre-check (canSendNow) before
dispatching the per-user child job. Iteration uses chunkById(500) to
make the memory bound explicit. Each user remains its own queueable
unit — independent retry, no shared failure mode across the cohort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:30 +01:00
Ovidiu U
06f5f2035f refactor: extract iterateBatches helper in FuelPriceService
Audit item #9. pollPrices() and refreshStations() shared the same
do/while batch loop with token + try/catch + Log::error + clean-exit
detection but different bodies. Extracted to a private iterateBatches()
that takes the endpoint, optional since timestamp, and a process
callable. Returns [total_processed, completedCleanly] so pollPrices
can still gate its incremental-poll cache update on a clean exhaustion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:21 +01:00
Ovidiu U
69eb524e07 fix: ImportPostcodes streams into staging, swaps on success
Audit item #13. The command truncated postcodes + outcodes immediately
and then chunk-inserted from the CSV. A mid-stream failure (network
drive disconnect, malformed row) left the production tables empty
until a successful re-run.

Now streams into a postcodes_staging table created via Schema::create
(works on both MySQL and SQLite) and only swaps into the live tables
once the full CSV has been consumed. A try/finally ensures the file
handle and staging table are cleaned up on any path. Live data is now
preserved on partial failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:15 +01:00
Ovidiu U
b4ef1177b2 refactor: PollFuelPricesJob calls service directly
Audit item #11. The job was a one-line Artisan::call wrapper around
fuel:poll --full. Routing through Artisan adds output buffering and
swallows typed exceptions before they reach the queue's failed-job
handler. Now injects FuelPriceService and replicates the --full path
(refreshStations + pollPrices + PricesUpdatedEvent) directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:05 +01:00
Ovidiu U
8e29980dfe perf: memoize PriceReliability + PriceClassification per row
Audit item #10. PriceReliability::fromUpdatedAt was being invoked
~10× per station per /api/stations response — twice in the sort
comparator (once for $a, once for $b), once in the count groupBy, and
once per resource render. PriceClassification::fromUpdatedAt was
called twice inside the resource (value + label).

The controller now computes the parsed datetime + reliability +
classification once per row and stashes them on the row. Sort,
groupBy, and StationResource read the cached values; the resource
keeps a fresh-compute fallback for callers that bypass the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:20:59 +01:00
Ovidiu U
4ce5066596 refactor: persist EWMA only on LLM failure, dedup EWMA helper
Audit items #7 and #5.

#7 — BrentPricePredictor::generatePrediction previously wrote both an
EWMA row and an LLM row to price_predictions on every run. The
downstream OilSignal already prefers llm_with_context > llm > ewma, so
the EWMA row was dead weight 95% of the time. Now we try LLM first; if
it returns null (no API key, parse failure, etc.) we compute and persist
EWMA as a real fallback. This also avoids redundant work on the success
path.

Updated the "stores both" test to "stores only LLM" — asserts no EWMA
row is written when the provider succeeds.

#5 — BrentPricePredictor and AnthropicPredictionProvider both had
byte-identical computeEwma() methods with identical EWMA_ALPHA = 0.3
constants. Extracted to App\Services\Ewma::compute() and dropped both
private methods + their alpha constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:41 +01:00
Ovidiu U
c46b017b51 chore: audit nits — PlanFeatures, test boot, EIA log, static method
Audit items #15, #16, #20, #22.

#15 — AuthController::me and UserResource form/table now read tier via
PlanFeatures::for($user)->tier() instead of Plan::resolveForUser($user)
->name. Tiers.md: PlanFeatures is the single entitlement gate.

#16 — Moved SQLite GREATEST/LEAST PHP-backed function registration from
AppServiceProvider::boot to tests/TestCase::setUp. Production app boot no
longer checks the DB driver name.

#20 — FetchOilPrices: added Log::warning on EIA fallback and Log::error
on both-providers-failed so primary-source reliability can be trended
beyond the cron output buffer.

#22 — FuelPriceService::flattenEnabledFlags is now an instance method,
matching the rest of the class. No external callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:09 +01:00
Ovidiu U
7f64c42a23 refactor: extract DbDialect helper, inline ProfileValidationRules trait
Audit items #17 and #21.

#17 — DayOfWeekSignal and StickinessSignal each had their own
isSqlite ternary picking between SQLite (strftime/julianday) and
MySQL (DAYOFWEEK/DATEDIFF) date expressions. Centralised in
App\Services\Prediction\Signals\DbDialect.

#21 — ProfileValidationRules was a trait with one consumer
(CreateNewUser); inlined the rules into the action and deleted the
trait. Also dropped PasswordValidationRules::currentPasswordRules()
which was unused. PasswordValidationRules trait stays (two consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:01 +01:00
Ovidiu U
4d9df1ee19 chore: add OneSignal and Vonage env keys to .env.example
Follow-up to the FuelPriceAlert notification wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:21 +01:00
Ovidiu U
5369b4a5a0 feat: build FuelPriceAlert notification with multi-channel adapters
The DispatchUserNotificationJob has been logging sent=true for every
allowed channel without actually sending anything — a TODO marker covered
by the existing test suite, which only asserted log rows. The downstream
"missed today" widget read those rows and reported falsely. This commit
makes the telemetry truthful by wiring the real send.

- App\Notifications\FuelPriceAlert — Notification class with via() that
  returns the per-tier-filtered channel list passed in by the dispatcher.
  Implements toMail / toOneSignal / toVonageWhatsApp / toVonageSms.
  ShouldQueue on the 'notifications' queue.
- App\Notifications\Channels\OneSignalChannel — raw HTTP to OneSignal
  REST API, gated on services.onesignal.{app_id,api_key} + user
  push_token. Logs every call to api_logs via ApiLogger.
- App\Notifications\Channels\VonageWhatsAppChannel — raw HTTP to Vonage
  Messages API, gated on whatsapp_verified_at + whatsapp_number.
- App\Notifications\Channels\VonageSmsChannel — raw HTTP to Vonage SMS
  API, gated on whatsapp_number.
- DispatchUserNotificationJob now calls $user->notify(new
  FuelPriceAlert(...)) before logging.
- New tests: assert the notification IS dispatched with the right
  channels, and that nothing is dispatched when no channels are allowed.

Channels gracefully no-op when their credentials are unset (logging at
info level), so existing tests without a Notification::fake() still
pass — the channels just early-return on missing config.

No new composer dependencies — Vonage SDK avoided in favour of raw HTTP
through the existing ApiLogger pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:10 +01:00
Ovidiu U
27c82ef103 refactor: extract 6 prediction signals into Signal classes
The 803-line NationalFuelPredictionService had six private compute*Signal
methods, a private linearRegression helper, and a private disabledSignal
shape factory all crammed together. Each signal is now an independently
testable class.

- App\Services\Prediction\Signals\Signal — interface
- App\Services\Prediction\Signals\SignalContext — input value object
  (FuelType + optional lat/lng + hasCoordinates() helper)
- App\Services\Prediction\Signals\AbstractSignal — shared
  disabledSignal() and linearRegression() helpers
- TrendSignal, DayOfWeekSignal, BrandBehaviourSignal, StickinessSignal,
  RegionalMomentumSignal, OilSignal — one class each, extending
  AbstractSignal

NationalFuelPredictionService receives the 6 signal classes via constructor
injection and orchestrates them. The lat/lng null-guard for regional
momentum now lives inside RegionalMomentumSignal::compute() so the
coordinator no longer branches on coordinate presence.

Aggregation, weekly summary, and reasoning helpers stay in the service
for now — they are coupled to the public predict() output shape and are
candidates for a follow-up extraction once a stable API is locked in.

Service: 803 → 414 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:43:28 +01:00
Ovidiu U
e39618f5df refactor: extract AbstractLlmPredictionProvider for shared boilerplate
Anthropic, Gemini, and OpenAi providers each repeated: API-key gate,
chronological price-list building, response validation
(direction/confidence/reasoning), TrendDirection::tryFrom, confidence
cap at 85, and the top-level try/catch + Log::error.

Now in AbstractLlmPredictionProvider:
- LLM_MAX_CONFIDENCE constant
- buildPriceList(Collection) helper
- buildPrediction(input, ?source) — handles direction validation,
  confidence cap, model construction
- defaultPrompt(priceList) — shared by Gemini and OpenAi
- Default predict() flow (apiKey + callProvider + buildPrediction +
  try/catch). Gemini and OpenAi only implement apiKey() and
  callProvider(). Anthropic overrides predict() because of its
  multi-phase web-search + forced-tool flow but reuses the helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:35:57 +01:00
Ovidiu U
00d0f7c8ec refactor: extract HaversineQuery helper, fix LL bind quirk
The 5 haversine SQL fragments duplicated across StationController and
NationalFuelPredictionService disagreed on float-clamping (LEAST only,
GREATEST/LEAST, vs. CASE WHEN). Centralised in App\Services\HaversineQuery
with the safe GREATEST(-1.0, LEAST(1.0, …)) form everywhere.

withinKm() embeds the radius as a numeric literal (sprintf %F) because
PDO + SQLite binds float parameters as strings by default, which breaks
numeric comparison against the haversine expression — a NULL filter would
silently match all rows. Coordinates remain bound positionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:33:07 +01:00
Ovidiu U
48af2083f3 feat: add fuel:archive command and monthly scheduler entry
Completes Tasks 12 + 13 from docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md.

- ArchiveOldPricesCommand moves station_prices rows older than 12 months
  into station_prices_archive in chunks of 1000, wrapping each chunk's
  insert + delete in a DB::transaction so a partial failure can't
  duplicate rows.
- StationPriceArchive: add $table = 'station_prices_archive'; without it
  Eloquent infers 'station_price_archives' and the insert would fail.
- routes/console.php: register fuel:archive monthly on the 1st at 04:00
  UTC, alongside the other fuel/oil scheduler entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:05 +01:00
Ovidiu U
783297694c fix: model audit cleanups (primaryKey, fuel_type cast, cadence cache)
- StationPriceCurrent: $primaryKey was null; set to 'station_id' + keyType
  string so Eloquent has a sensible default for save() / find() paths.
- UserNotificationPreference: add FuelType enum cast on fuel_type so it
  hydrates as an enum like every other price model.
- Plan::resolveCadenceForUser: cache for 1h under the same plans tag as
  resolveForUser; HandleStripeWebhook busts both keys on subscription
  events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:32:55 +01:00
Ovidiu U
775e076bb7 Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Add current_period_start, current_period_end, and stripe_data columns to subscriptions table via migration. Extend Subscription model with datetime casts for new fields. Create comprehensive prediction engine documentation covering signals, aggregation, confidence calibration, and weekly summary logic. Add PredictionFull Vue component displaying action label, reasoning, and 7-day context. Refactor StationList to collapse outdated stations behind expandable section. Add UpsellBanner component with station count formatting. Create .claude/settings.json denying .env file access. Add todo.md tracking Stripe dashboard setup, production deployment steps, and E2E QA checklist. Update .env.example with fuel-finder credentials, Anthropic config, and complete Stripe price IDs.
2026-04-29 18:14:03 +01:00
Ovidiu U
8695d5ec95 refactor: flatten plans.features JSON to typed columns
The features JSON column required defensive fallback stubs in three
places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder)
and silently swallowed misspelled keys. Typed columns give Eloquent
type-safe reads, simplify the Filament form (no more dotted JSON
paths), and let resolveForUser fail loud when the free row is
missing.

PlanFeatures public API is unchanged so consumers (jobs, middleware)
need no rewrites — one missed JSON read in SendScheduledWhatsAppJob
was caught and converted to a typed where() query.

tests/Pest.php seeds PlanSeeder in beforeEach so any feature test
that resolves a plan finds the free row, mirroring production where
plans always exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:13:26 +01:00
Ovidiu U
088fd11058 Remove prediction API endpoint and integrate into stations search
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Consolidate prediction functionality by merging /api/prediction endpoint into /api/stations response. Move prediction logic from PredictionController into StationController, returning prediction data alongside station results. Replace usePrediction composable with unified useStations that returns {stations, meta, prediction}. Remove PredictionRequest, related tests, and unused Vue components (FuelFinderTest, MapTest, RecommendationTest, StationListTest). Add PredictionFull component and UpsellBanner. Extend NationalFuelPredictionService to include weekly_summary (7-day series, yesterday/today averages, cheapest/priciest days) and oil signal from price_predictions table. Update Home.vue to consume prediction from stations response. Add Plan::resolveCadenceForUser helper and configure Cashier to use custom Subscription model.
2026-04-29 13:28:33 +01:00
Ovidiu U
ee6de23709 feat: gate full prediction by ai_predictions feature flag
Add a prediction box above filter results on the homepage.
Server returns the full payload only when PlanFeatures::can(
'ai_predictions') — currently plus and pro. Other tiers and
guests get a trimmed {fuel_type, predicted_direction,
tier_locked: true} response so the gate is enforced server-side.

Frontend renders a compact one-liner with the national trend
direction for trimmed responses, full card for unlocked.

Hide the Pro plan card from the pricing section (pro plan
disabled in DB pending real Stripe price ids), and only show
the bottom signup CTA when the visitor is a guest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:29 +01:00
Ovidiu U
2ff3aeba4d fix: admin tier assignment when stripe price env vars are empty
env() returns an empty string (not null) when a STRIPE_PRICE_*
var is set but blank, so the ?? fallback never fired and the
synthetic subscription was created with stripe_price = '' —
which then resolved back to free in Plan::resolveForUser.

Switch to ?: so empty strings also fall back to the synthetic
price_admin_{tier}_{cadence} id, and backfill the matching Plan
row's stripe_price_id_{cadence} when empty so resolution succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:21 +01:00
Ovidiu U
b8adb81c79 chore: gitignore ONSPD source CSV
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:07 +01:00
Ovidiu U
3224b186b2 Merge branch 'feat/stripe-lifecycle'
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Stripe subscription lifecycle implementation:
- Consolidated HandleStripeWebhook listener (5 event types)
- users.grace_period_until column for past-due state
- Branded Day-3 / Day-5 payment-failure reminder mailables
- SendPaymentFailedReminderJob with grace-guard self-cancel
- Past-due dashboard banner
- Deletion of old DowngradeUserOnSubscriptionDeleted listener

Spec: docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md
Plan: docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:00:19 +01:00
Ovidiu U
36444cde05 feat: add past-due payment banner to dashboard
Show an amber banner to logged-in users whose grace_period_until is set,
linking to the Stripe Customer Portal to update their card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:59:51 +01:00
Ovidiu U
b7175169f0 feat: handle invoice.payment_failed — set grace period and queue reminders
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:56:26 +01:00
Ovidiu U
5b17f4cae4 feat: add SendPaymentFailedReminderJob with grace guard 2026-04-23 10:51:13 +01:00
Ovidiu U
c127cc379e feat: add day-5 branded payment-failure reminder mailable 2026-04-23 10:48:22 +01:00
Ovidiu U
de2499636f feat: add day-3 branded payment-failure reminder mailable 2026-04-23 10:44:37 +01:00
Ovidiu U
2078c4b83e feat: clear grace period on invoice.payment_succeeded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:37:50 +01:00
Ovidiu U
b9d457578c feat: fold subscription deletion handling into HandleStripeWebhook 2026-04-23 10:34:05 +01:00
Ovidiu U
25b79f095b feat: bust plan cache on customer.subscription.updated 2026-04-23 10:31:07 +01:00
Ovidiu U
a39d4b1b94 feat: consolidate stripe webhook handling into HandleStripeWebhook listener 2026-04-23 10:27:23 +01:00
Ovidiu U
f1c1a1c572 feat: add grace_period_until to users table 2026-04-23 10:23:27 +01:00
Ovidiu U
bf013926c0 docs: add stripe subscription lifecycle spec + implementation plan
Captures the agreed design for Stripe webhook handling, 5-day grace
period with branded day-3/day-5 reminders, and Stripe Customer Portal
as the single subscription-management surface. Updates payments rules
to match and ignores .worktrees/ for isolated implementation work.
2026-04-23 10:05:50 +01:00
Ovidiu U
19fc61a0a3 feat: accept ArcGIS ONSPD column aliases (PCD7/PCD8/PCDS) in postcodes:import 2026-04-22 13:31:27 +01:00
Ovidiu U
13fc227619 docs: link ONSPD attribution to source dataset page 2026-04-22 13:28:39 +01:00
Ovidiu U
d8f87f964d Merge branch 'feat/self-hosted-postcodes'
Self-hosted UK postcode lookup: ONS Postcode Directory loaded into
local postcodes/outcodes tables; postcodes.io retained as fallback
for place names and unknown postcodes, with successful fallback
results persisted back to the local tables.
2026-04-22 13:19:39 +01:00
Ovidiu U
975a1522cf docs: plan for self-hosted UK postcodes 2026-04-22 13:19:33 +01:00
Ovidiu U
29ba2f3d86 docs: add ONS/Royal Mail/OS attribution required by OGL v3 2026-04-22 12:39:11 +01:00
Ovidiu U
3ec7cda790 feat: derive outcode centroids from postcodes during import 2026-04-22 12:36:39 +01:00
Ovidiu U
d01a634f0b test: cover terminated + blank-coord skip paths for postcodes:import 2026-04-22 12:34:19 +01:00
Ovidiu U
9ad62538b9 fix: harden postcodes:import against duplicate headers and test collisions 2026-04-22 12:33:10 +01:00
Ovidiu U
4a60298606 feat: add postcodes:import command for loading ONSPD CSV 2026-04-22 12:28:08 +01:00
Ovidiu U
5426722c71 refactor: scope postcode cache to place names, DB is authoritative for postcodes 2026-04-22 12:23:50 +01:00
Ovidiu U
d460de1850 fix: guard malformed postcodes.io responses and isolate persist errors from HTTP success 2026-04-22 12:22:15 +01:00
Ovidiu U
45bf1c0d24 feat: persist postcodes.io fallback results into local DB 2026-04-22 12:18:20 +01:00
Ovidiu U
1e3b246172 feat: resolve outcodes from local DB before HTTP 2026-04-22 12:13:52 +01:00
Ovidiu U
9fa9ea7835 feat: resolve full postcodes from local DB before HTTP 2026-04-22 12:09:19 +01:00
Ovidiu U
55c81fab7b style: align Postcode/Outcode models with house Fillable+casts convention 2026-04-22 12:07:23 +01:00
Ovidiu U
64a7cc3de5 feat: add Postcode and Outcode Eloquent models 2026-04-22 12:04:39 +01:00
Ovidiu U
7c114c72e4 style: add void return type to postcodes migration closures 2026-04-22 12:03:51 +01:00
Ovidiu U
2fe9c3ef77 feat: add postcodes and outcodes tables for self-hosted lookup 2026-04-22 12:00:53 +01:00
Ovidiu U
b4bd78ab4c Rename SearchBar to PostSearchFilters, add sort controls and brand filter, relocate station count display
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Move SearchBar.vue to PostSearchFilters.vue and expand to include sort buttons, brand filter dropdown, and station count
- Integrate sort controls (Reliable/Price/Distance/Updated) with icons into filter bar
- Add brand filter dropdown with dynamic brand list from parent, emit update events
- Move station count from StationList to PostSearchFilters, display as "X station(s) found"
- Remove sort tabs and brand filter from StationList component
- Add force-new-line div for mobile layout between Refine and Sort groups
- Include brand filter in hasActive check and resetFilters function
- Update Home.vue to pass brands/brandFilter props and handle brandFilter updates
- Add reset() method to useStations composable to clear state on empty query
- Clear search state when route query is empty instead of attempting search
- Update Fuel Finder API base URL to include /api/v1 path
- Adjust map zoom levels for 10-15 mile radius range
- Update API token request to use retry and increase timeout to 60s
2026-04-22 11:50:59 +01:00
Ovidiu U
8335f49fd6 Redesign station cards with compact layout, improved typography, and expandable details
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Reduce header size and weight, convert all-caps brand names to title case
- Replace address line with distance-only in collapsed state, move brand label to expanded section
- Apply monospace font to pricing, reduce size and weight across labels
- Move badge list and full details into expandable section
- Normalize font weights throughout (semibold for headings, medium for labels)
- Create `.pill` component class with `.is-active` state for consistent filter styling
- Apply pill styling to SearchBar filters, StationList sort buttons, and brand filter
- Add `name` attributes to fuel type and radius selects
- Update package dependencies (@tailwindcss/node, @tailwindcss/oxide, @rolldown/*)
2026-04-22 11:23:05 +01:00
Ovidiu U
dd9bd95657 Redesign search UI with unified input, expandable filters, and integrated map controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Consolidate HeroSearch into single responsive form with inline geolocation button and submit actions
- Transform SearchBar into pill-based filter bar with visual state indicators (active filters highlighted)
- Move map toggle from separate component into SearchBar with open/close state management
- Redesign StationList sort controls as pills with icons, move brand filter inline, add result count
- Expand LeafletMap to full-width panel (96 viewport height) controlled by parent open state
- Remove nested mobile/desktop layouts in HeroSearch in favor of single adaptive form
- Add "Refine" and "Sort" labels to filter groups, implement clear-all filters button
- Show verdict card only before first search on mobile, hide after results load
- Position StatsRow within hero gradient, move results section into same gradient container
- Update map initialization to only occur when panel is open, destroy on close
- Add accessibility labels (aria-expanded, aria-controls) to map toggle button
2026-04-22 09:38:23 +01:00
Ovidiu U
afe459f248 Lazy-load dashboard and settings views to reduce initial bundle size
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Convert dashboard and settings component imports from static to dynamic imports using arrow functions, improving initial load performance by code-splitting these routes.
2026-04-20 20:38:04 +01:00
Ovidiu U
d822b77fb0 feat: redesign homepage with responsive hero, verdict card preview, and modular landing components
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Extract LandingNav, LiveTicker, StatsRow, VerdictCard, and HeroSearch into reusable landing components
- Implement responsive two-layout strategy: mobile stacked (hero search + verdict card + CTA) vs desktop inline pill input with verdict card sidebar
- Add serif/mono font tokens and live-dot pulse animation to CSS
- Move verdict card above search input on mobile, to right sidebar on desktop
- Replace hero "fill up now" mockup with dynamic VerdictCard showing top stations, pricing, and recommendation
- Simplify navigation with uppercase tracking, add Fleet anchor, and gate CTA by auth state
- Lazy-load LeafletMap with defineAsyncComponent to reduce initial bundle
- Relocate SearchBar below hero on search attempt for persistent filter UI
- Add meta description for SEO
2026-04-20 20:27:02 +01:00
Ovidiu U
831637380c feat: expand station cards with detailed information and add live statistics endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Add `/stats/live` endpoint returning station count and latest price timestamp with 5-minute cache
- Transform StationCard into expandable component with click/keyboard interaction showing full details
- Display brand label, badges (24h/Supermarket/Motorway), fuel types, amenities, opening hours, and price delta vs average
- Add brand filter dropdown to StationList with dynamic brand extraction from results
- Calculate and display price comparison against filtered stations average
- Redesign map markers to simpler price display; move directions link to popup alongside station details
- Add "locate-me" button to SearchBar for geolocation trigger
- Show "Live" indicator with station count and last-update time on homepage hero
- Remove standalone directions link from marker HTML; consolidate in popup with click propagation handling
- Persist `avgPence` calculation across StationList and pass to cards for delta display
- Add `@iconify-json/lucide` dev dependency and register collection on app mount
- Stop click propagation on card action buttons (directions, remove)
2026-04-20 18:58:13 +01:00
Ovidiu U
c2466e5a61 feat(tiers): add display-name layer, push.frequency entitlement, and rename pricing cards
Reconciles tier docs with `PlanSeeder` reality (basic has price_threshold
and score_alerts; schema is stripe_price_id_monthly + stripe_price_id_annual)
and introduces the display-name layer from pricing-plan.md v2.

- PlanTier::label() + Plan::displayName() + PlanFeatures::displayName()
  expose user-facing names (Free/Daily/Smart/Pro); backend identifiers stay
  basic/plus/pro so every call site, Stripe mapping, and test keeps working.
- push.frequency key added to features JSON (none/daily/triggered), mirroring
  email.frequency so Daily's daily push is distinguishable from Smart/Pro's
  triggered push. Seeder, factory, free-tier stubs, and Filament form updated.
- Homepage pricing cards renamed: Basic→Daily, Plus→Smart; badge
  "Most Popular"→"Most pick this"; CTAs refreshed.
- docs/tiers.md change log records the full diff.

Fleet tier, 14-day trial copy, and Smart dark-card treatment deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:57:24 +01:00
Ovidiu U
7dc41ba9ee feat: add location-based search, redesign station cards, and implement URL state management
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Support geolocation search (lat/lng) as alternative to postcode with automatic fallback
- Redesign StationCard with expanded layout showing address, distance in miles, reliability status, directions link, and optional remove button
- Add directions integration with Google Maps including origin parameter support
- Persist search parameters (postcode/coords, fuel type, radius, sort) in URL query and hydrate on mount
- Implement compact map markers with inline directions link and click-to-zoom behavior
- Auto-trigger search when filters change (fuel type, radius, sort) if search already performed
- Add removable prop to StationCard for watchlist integration
- Display reliability status (Current/Stale/Outdated) with color-coded pricing
- Remove 2-mile radius option from search filters
2026-04-20 15:51:02 +01:00
Ovidiu U
d29f3e6487 Add tier feature design spec, annual billing, fuel type normalization, and admin subscription management
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Add comprehensive tier feature matrix spec defining Free/Basic/Plus/Pro capabilities across recommendations, predictions, history, logs, tools, and family sharing
- Add `stripe_price_id_annual` column to plans table and rename existing column to `stripe_price_id_monthly`
- Normalize legacy fuel type aliases (petrol→e10, diesel→b7_standard) in users table
- Implement BillingController with checkout, portal, success/cancel routes supporting monthly/annual cadence
- Add admin subscription assignment in Filament user edit page with admin-granted subscription support
- Add DowngradeUserOnSubscriptionDeleted listener to disable WhatsApp/SMS preferences on subscription cancellation
- Add MissedNotificationsOverview widget to Filament user detail page
- Add PollFuelPricesTest covering auto-refresh scenarios
- Add PriceReliability enum with reliability classification based on price age
- Add fuelTypes.js constants file exporting FUEL_TYPES from window global
2026-04-20 14:13:03 +01:00
Ovidiu U
5acb99c9e3 Remove obsolete Livewire fuel search components and consolidate pricing tiers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Delete unused Livewire Search test and fuel type select Blade component
- Move subscription webhook listener from EventServiceProvider to AppServiceProvider
- Add FUEL_TYPES global config to app layout for client-side use
- Add Billable trait to User model and include email_verified_at in fillable
- Implement monthly/annual cadence toggle with pricing display and smart CTA routing on homepage
- Update VerifyApiKeyMiddlewareTest to use e10 instead of petrol
- Refactor PollFuelPrices to auto-refresh stale stations based on last_seen_at
- Add incremental polling with cached timestamp and effective-start-timestamp param to FuelPriceService
- Normalize amenities/fuel_types from API objects to flat arrays, skip stations missing required fields
- Log response body on API failures in ApiLogger
- Default homepage sort to 'reliable' instead of 'price'
2026-04-20 14:12:15 +01:00
Ovidiu U
aec547cd86 refactor: restructure Stripe pricing config to support monthly and annual tiers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Nest price IDs under `monthly` and `annual` keys for each tier (basic, plus, pro)
2026-04-14 19:26:01 +01:00
Ovidiu U
486f0e689c refactor: split oil price ingestion and prediction into separate services + commands
- BrentPriceFetcher owns ingestion (fetchFromEia / fetchFromFred, each throws on failure)
- BrentPricePredictor owns prediction and marks latest brent_prices row as generated
- oil:fetch command tries EIA, falls back to FRED, fails loudly if both fail
- oil:predict command prompts if latest price already has a prediction; --force bypasses
- add prediction_generated_at column to brent_prices
- delete OilPriceService (replaced by the two focused services)
2026-04-14 16:59:43 +01:00
Ovidiu U
1a0381265e refactor: extract Brent price sources into dedicated classes
OilPriceService no longer inlines per-provider fetch/transform/error logic.
EIA and FRED are now their own classes with a common shape; the service
just iterates and upserts the first successful result.
2026-04-14 16:29:52 +01:00
Ovidiu U
a7ee9f4557 feat: use EIA as primary Brent crude source with FRED fallback 2026-04-14 16:23:06 +01:00
Ovidiu U
4220b1b86a Add subscription tiers, notification preferences, and logging infrastructure
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Add database migrations for plans, subscriptions, notification preferences, and notification log tables
- Implement DispatchUserNotificationJob to handle channel resolution, daily limits, and logging (sent/tier_restricted/daily_limit)
- Add SendScheduledWhatsAppJob for scheduled notification delivery
- Create PlanFeatures service to resolve tier capabilities, check daily limits, and validate fuel
2026-04-14 16:20:51 +01:00
Ovidiu U
3cd3467178 config: add EIA API key for Brent crude price source 2026-04-14 16:20:07 +01:00
Ovidiu U
d25883ead4 feat: add geolocation support with Near Me button and user location marker on map
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Add "Near Me" button to SearchBar with loading state and geolocation via postcodes.io API
- Display user location on map with pulsing blue marker using geolocation API with IP fallback
- Adjust map zoom level based on search radius for better context
- Pass radiusMiles prop from
2026-04-11 21:27:11 +01:00
Ovidiu U
a969c1b347 feat: add fuel price classification markers and responsive search UI improvements
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Move map pin icon to right side of input with adjusted spacing
- Change button styling from accent to primary color
2026-04-11 20:51:07 +01:00
Ovidiu U
951bb0b98d feat: add sort select to homepage SearchBar 2026-04-11 18:57:03 +01:00
Ovidiu U
b8adc98669 feat: add brand and reliable sort options to StationList 2026-04-11 18:55:21 +01:00
Ovidiu U
2747047f53 fix: correct default fuelType in useStations from 'petrol' to 'e10' 2026-04-11 18:50:29 +01:00
Ovidiu U
5fe9f9dc6d fix: empty results state and validation error display on homepage 2026-04-11 18:48:25 +01:00
Ovidiu U
276f9bf612 feat: wire up homepage search with map and station list 2026-04-11 18:46:34 +01:00
Ovidiu U
6f52f3f0d7 feat: add defaultOpen prop to LeafletMap 2026-04-11 18:45:26 +01:00
Ovidiu U
d11d500a35 fix: accessibility and Enter key handling in SearchBar 2026-04-11 18:44:34 +01:00
Ovidiu U
b5ee25db67 feat: add fuel type and radius selects to SearchBar 2026-04-11 17:23:56 +01:00
Ovidiu U
66c662f471 docs: add homepage search implementation plan 2026-04-11 17:23:03 +01:00
Ovidiu U
9f7b45751e docs: add homepage search design spec 2026-04-11 17:15:23 +01:00
Ovidiu U
03b0bece2c feat: add auth guards and server-side logout with postcode search integration
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Add navigation guard requiring authentication for dashboard routes
- Create
2026-04-11 17:08:19 +01:00
Ovidiu U
4a3ce4cc1d docs: add advanced skills for Vitest, Pinia, and Vue built-ins
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Add comprehensive reference documentation for:
- Vitest: environments, projects/workspaces, type testing, vi utilities
- Pinia: HMR, Nuxt integration, SSR setup
- Vue: built-in components (Transition, Teleport, Suspense, KeepAlive) and advanced directives
2026-04-11 16:28:36 +01:00
Ovidiu U
069a85cf11 refactor: migrate from hardcoded hex colors to Tailwind CSS color tokens
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Replace all hardcoded hex color values with semantic Tailwind design tokens:
- `#bb5b3e` → `accent`
- `#a34a31` → `accent-content` / `primary-dark`
- `#4a3f3b`, `#89726c` → `zinc-800`, `zinc-500`
- `#e5ded7`, `#faf6f3` → `zinc-300`, `zinc-50`
- `#8
2026-04-11 16:26:34 +01:00
Ovidiu U
02b004f381 fix: handle TransientToken in logout for session-based auth
When the SPA authenticates via cookies (not Bearer token), Sanctum returns
a TransientToken from currentAccessToken() which has no delete() method.
Detect it and invalidate the session instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:29:03 +01:00
Ovidiu U
977ae8a5a1 chore: remove orphaned settings-heading partial
settings-heading.blade.php was a Livewire settings layout partial with no
remaining references after the settings Vue migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:27:47 +01:00
Ovidiu U
25770445bc fix: correct $route naming and aria-expanded type in DashboardLayout
- Rename \$route → route (no \$ prefix in script setup, that's Options API)
- Use string 'true'/'false' for aria-expanded (ARIA spec requires string)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:26:22 +01:00
Ovidiu U
3895356b0d fix: replace Alpine dropdown with Vue reactive state in DashboardLayout
Alpine.js is not loaded in the Vue SPA bundle, causing the avatar dropdown
to never open and making Settings and Log out inaccessible. Replaced x-data/
x-show/x-transition/@click.away with Vue refs, onMounted/onUnmounted click-
outside listener, and Vue's built-in <Transition> component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:21:27 +01:00
Ovidiu U
ea7a5b4f10 chore: remove Livewire settings pages — migrated to Vue SPA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:14:34 +01:00
Ovidiu U
83809cd4f3 feat: add Appearance settings view with light/dark/system theme toggle 2026-04-11 13:13:09 +01:00
Ovidiu U
f714169183 feat: add Security settings view with password update and 2FA management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:11:58 +01:00
Ovidiu U
00e99044f6 feat: add Profile settings view with name/email form and delete account modal 2026-04-11 13:09:40 +01:00
Ovidiu U
5bf8868124 feat: add settings routes and SettingsLayout sub-nav 2026-04-11 13:08:11 +01:00
Ovidiu U
bd68a179d8 feat: add user avatar dropdown with settings and logout to dashboard nav 2026-04-11 13:07:00 +01:00
Ovidiu U
7976b9facc feat: add logout, updateProfile, updatePassword, deleteAccount to useAuth 2026-04-11 13:05:04 +01:00
Ovidiu U
e90078d39e feat: add updateProfile, updatePassword, deleteAccount API endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:02:23 +01:00
Ovidiu U
94d695d637 docs: add settings Vue migration implementation plan 2026-04-11 12:54:57 +01:00
Ovidiu U
1d39c69fe4 docs: add settings Vue migration design spec 2026-04-11 12:41:27 +01:00
Ovidiu U
c6e65330b2 fix: make SPA catch-all param optional, add named dashboard route
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-10 18:17:49 +01:00
Ovidiu U
6224dedd45 fix: restore route('home'), remove dashboard Blade route, load iconify in SPA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:14:58 +01:00
Ovidiu U
1bfcb84402 feat: add dashboard Overview, SavedStations, and Preferences views 2026-04-10 18:09:31 +01:00
Ovidiu U
4c3ef5af99 feat: add DashboardLayout with sidebar navigation 2026-04-10 18:08:59 +01:00
Ovidiu U
fe01d2d6d0 feat: add useSavedStations composable 2026-04-10 18:08:38 +01:00
Ovidiu U
580f9c6929 feat: add user preferences and saved stations API endpoints
Adds authenticated endpoints for reading/updating fuel type preferences and managing saved stations, backed by new migrations and a SavedStation model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:06:31 +01:00
Ovidiu U
0bae0945c0 feat: build full Home.vue with search, station list, map, and prediction 2026-04-10 18:04:10 +01:00
Ovidiu U
d7054402dd feat: add PredictionCard component with tier gating 2026-04-10 18:02:23 +01:00
Ovidiu U
f9befb463f feat: add usePrediction composable 2026-04-10 18:02:02 +01:00
Ovidiu U
6785bf952f feat: add LeafletMap component (foldable), remove legacy station-map.js 2026-04-10 18:01:55 +01:00
Ovidiu U
393c9cc147 feat: add StationList component with sort tabs 2026-04-10 18:01:38 +01:00
Ovidiu U
d25e4e3747 feat: add StationCard component 2026-04-10 18:01:29 +01:00
Ovidiu U
bbbef2d60c feat: add useStations composable 2026-04-10 18:01:18 +01:00
Ovidiu U
acade5a735 feat: add SearchBar component with debounce 2026-04-10 18:01:10 +01:00
Ovidiu U
52bbfa5592 feat: add useAuth composable with user tier detection 2026-04-10 18:00:59 +01:00
Ovidiu U
87e7a9aa84 feat: bootstrap Vue 3 app with Vue Router and Axios
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:59:06 +01:00
Ovidiu U
05b5d1f3b3 feat: add SPA Blade shell and catch-all route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:57:54 +01:00
Ovidiu U
acaa791eda feat: allow Sanctum-authenticated sessions through VerifyApiKey middleware
Enables stateful API via Sanctum so the Vue SPA can call /api/* routes
using cookie auth, without requiring an X-Api-Key header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:56:14 +01:00
Ovidiu U
8cf5e210de feat: add Vue 3 and Axios, configure Vite plugin
Install vue@3.5, @vitejs/plugin-vue@6.0 (Vite 8 compatible), and
axios@1.9. Add vue() plugin to vite.config.js alongside existing
laravel and tailwindcss plugins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:54:32 +01:00
Ovidiu U
69e52afa7c chore: remove Livewire public components and homepage, prepare for Vue 2026-04-10 17:53:03 +01:00
Ovidiu U
771f499f36 feat: add pricing section and hero redesign to homepage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-10 11:41:13 +01:00
Ovidiu U
28d4a9df5c frontend
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-09 15:41:07 +01:00
Ovidiu U
c47d024b36 homepage layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-09 15:28:51 +01:00
Ovidiu U
7869d27c63 cleanup
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-09 15:13:44 +01:00
Ovidiu U
1074681dd9 livewire kit
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-09 15:10:53 +01:00
Ovidiu U
19d5c6eb0b feat: add Laravel Fortify skill, condense API data rules, add homepage mockup
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-09 14:19:04 +01:00
Ovidiu U
1848c070da feat: replace flux:select with dropdown menus using Alpine.js for fuel filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-08 10:06:55 +01:00
Ovidiu U
7b6aaac661 chore: remove StationSearch, dead Volt SFCs, mobile prototype, and fix homepage CTAs 2026-04-08 09:24:40 +01:00
Ovidiu U
c935903614 feat: strip FuelFinder to layout shell, wire sub-components 2026-04-08 08:48:15 +01:00
Ovidiu U
bce5aa72c8 feat: extract fuel.map component and wire Leaflet to map-update browser event 2026-04-08 08:46:33 +01:00
Ovidiu U
0809a5340b feat: extract fuel.recommendation Livewire component 2026-04-08 08:46:16 +01:00
Ovidiu U
c4f5fd042b fix: remove dead search property, simplify empty state message 2026-04-07 22:04:15 +01:00
Ovidiu U
a576ef6b4a feat: extract fuel.station-list Livewire component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:02:08 +01:00
Ovidiu U
266051c52b fix: use required rule for sort property (non-nullable string) 2026-04-07 22:00:38 +01:00
Ovidiu U
0b289c8ec2 feat: extract fuel.search Livewire component with stations-found dispatch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:58:01 +01:00
Ovidiu U
f4a958a76c docs: FuelFinder sub-component split design spec 2026-04-07 21:27:56 +01:00
Ovidiu U
4e9b809a10 feat: add user geolocation marker and auto-zoom to map based on search radius
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-07 20:21:31 +01:00
Ovidiu U
0b26c4c257 fix: convert avg_price from pence to pounds before linear regression calculation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-07 14:51:14 +01:00
Ovidiu U
6da626347b docs: add FuelFinder mobile landing implementation plan
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-07 14:43:03 +01:00
Ovidiu U
6a80c11f38 feat: add LLM prediction providers with structured output support
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:42:44 +01:00
Ovidiu U
e9612666e3 feat: wire FuelFinder to home route, replacing static homepage 2026-04-07 14:38:03 +01:00
Ovidiu U
41a2cc5f43 feat: build fuel-finder view with mobile layout 2026-04-07 14:36:21 +01:00
Ovidiu U
8a8dc15c0d feat: add x-mobile-header and x-mobile-footer components 2026-04-07 14:35:27 +01:00
Ovidiu U
b21c99411d feat: add x-fuel.* Blade components 2026-04-07 14:34:44 +01:00
Ovidiu U
6d6def18f1 feat: add FuelFinder Livewire component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:32:45 +01:00
Ovidiu U
80ae25d98a test: add failing FuelFinder component tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:31:00 +01:00
Ovidiu U
3d552e8fcb docs: add FuelFinder mobile landing page design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:11:04 +01:00
688 changed files with 67411 additions and 4635 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
# Generation Info
- **Source:** `sources/pinia`
- **Git SHA:** `55dbfc5c20d4461748996aa74d8c0913e89fb98e`
- **Generated:** 2026-01-28

View File

@@ -0,0 +1,59 @@
---
name: pinia
description: Pinia official Vue state management library, type-safe and extensible. Use when defining stores, working with state/getters/actions, or implementing store patterns in Vue apps.
metadata:
author: Anthony Fu
version: "2026.1.28"
source: Generated from https://github.com/vuejs/pinia, scripts located at https://github.com/antfu/skills
---
# Pinia
Pinia is the official state management library for Vue, designed to be intuitive and type-safe. It supports both Options API and Composition API styles, with first-class TypeScript support and devtools integration.
> The skill is based on Pinia v3.0.4, generated at 2026-01-28.
## Core References
| Topic | Description | Reference |
|-------|-------------|-----------|
| Stores | Defining stores, state, getters, actions, storeToRefs, subscriptions | [core-stores](references/core-stores.md) |
## Features
### Extensibility
| Topic | Description | Reference |
|-------|-------------|-----------|
| Plugins | Extend stores with custom properties, state, and behavior | [features-plugins](references/features-plugins.md) |
### Composability
| Topic | Description | Reference |
|-------|-------------|-----------|
| Composables | Using Vue composables within stores (VueUse, etc.) | [features-composables](references/features-composables.md) |
| Composing Stores | Store-to-store communication, avoiding circular dependencies | [features-composing-stores](references/features-composing-stores.md) |
## Best Practices
| Topic | Description | Reference |
|-------|-------------|-----------|
| Testing | Unit testing with @pinia/testing, mocking, stubbing | [best-practices-testing](references/best-practices-testing.md) |
| Outside Components | Using stores in navigation guards, plugins, middlewares | [best-practices-outside-component](references/best-practices-outside-component.md) |
## Advanced
| Topic | Description | Reference |
|-------|-------------|-----------|
| SSR | Server-side rendering, state hydration | [advanced-ssr](references/advanced-ssr.md) |
| Nuxt | Nuxt integration, auto-imports, SSR best practices | [advanced-nuxt](references/advanced-nuxt.md) |
| HMR | Hot module replacement for development | [advanced-hmr](references/advanced-hmr.md) |
## Key Recommendations
- **Prefer Setup Stores** for complex logic, composables, and watchers
- **Use `storeToRefs()`** when destructuring state/getters to preserve reactivity
- **Actions can be destructured directly** - they're bound to the store
- **Call stores inside functions** not at module scope, especially for SSR
- **Add HMR support** to each store for better development experience
- **Use `@pinia/testing`** for component tests with mocked stores

View File

@@ -0,0 +1,61 @@
---
name: hot-module-replacement
description: Enable HMR to preserve store state during development
---
# Hot Module Replacement (HMR)
Pinia supports HMR to edit stores without page reload, preserving existing state.
## Setup
Add this snippet after each store definition:
```ts
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useAuth = defineStore('auth', {
// store options...
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAuth, import.meta.hot))
}
```
## Setup Store Example
```ts
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}
```
## Bundler Support
- **Vite:** Officially supported via `import.meta.hot`
- **Webpack:** Uses `import.meta.webpackHot`
- Any bundler implementing the `import.meta.hot` spec should work
## Nuxt
With `@pinia/nuxt`, `acceptHMRUpdate` is auto-imported but you still need to add the HMR snippet manually.
## Benefits
- Edit store logic without losing state
- Add/remove state, actions, and getters on the fly
- Faster development iteration
<!--
Source references:
- https://pinia.vuejs.org/cookbook/hot-module-replacement.html
-->

View File

@@ -0,0 +1,119 @@
---
name: nuxt-integration
description: Using Pinia with Nuxt - auto-imports, SSR, and best practices
---
# Nuxt Integration
Pinia works seamlessly with Nuxt 3/4, handling SSR, serialization, and XSS protection automatically.
## Installation
```bash
npx nuxi@latest module add pinia
```
This installs both `@pinia/nuxt` and `pinia`. If `pinia` isn't installed, add it manually.
> **npm users:** If you get `ERESOLVE unable to resolve dependency tree`, add to `package.json`:
> ```json
> "overrides": { "vue": "latest" }
> ```
## Configuration
```ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
})
```
## Auto Imports
These are automatically available:
- `usePinia()` - get pinia instance
- `defineStore()` - define stores
- `storeToRefs()` - extract reactive refs
- `acceptHMRUpdate()` - HMR support
**All stores in `app/stores/` (Nuxt 4) or `stores/` are auto-imported.**
### Custom Store Directories
```ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
pinia: {
storesDirs: ['./stores/**', './custom-folder/stores/**'],
},
})
```
## Fetching Data in Pages
Use `callOnce()` for SSR-friendly data fetching:
```vue
<script setup>
const store = useStore()
// Run once, data persists across navigations
await callOnce('user', () => store.fetchUser())
</script>
```
### Refetch on Navigation
```vue
<script setup>
const store = useStore()
// Refetch on every navigation (like useFetch)
await callOnce('user', () => store.fetchUser(), { mode: 'navigation' })
</script>
```
## Using Stores Outside Components
In navigation guards, middlewares, or other stores, pass the `pinia` instance:
```ts
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const nuxtApp = useNuxtApp()
const store = useStore(nuxtApp.$pinia)
if (to.meta.requiresAuth && !store.isLoggedIn) {
return navigateTo('/login')
}
})
```
Most of the time, you don't need this - just use stores in components or other injection-aware contexts.
## Pinia Plugins with Nuxt
Create a Nuxt plugin:
```ts
// plugins/myPiniaPlugin.ts
import { PiniaPluginContext } from 'pinia'
function MyPiniaPlugin({ store }: PiniaPluginContext) {
store.$subscribe((mutation) => {
console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}`)
})
return { creationTime: new Date() }
}
export default defineNuxtPlugin(({ $pinia }) => {
$pinia.use(MyPiniaPlugin)
})
```
<!--
Source references:
- https://pinia.vuejs.org/ssr/nuxt.html
-->

View File

@@ -0,0 +1,121 @@
---
name: server-side-rendering
description: SSR setup, state hydration, and avoiding cross-request state pollution
---
# Server Side Rendering (SSR)
Pinia works with SSR when stores are called at the top of `setup`, getters, or actions.
> **Using Nuxt?** See the [Nuxt integration](advanced-nuxt.md) instead.
## Basic Usage
```vue
<script setup>
// ✅ Works - pinia knows the app context in setup
const main = useMainStore()
</script>
```
## Using Store Outside setup()
Pass the `pinia` instance explicitly:
```ts
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
router.beforeEach((to) => {
// ✅ Pass pinia for correct SSR context
const main = useMainStore(pinia)
if (to.meta.requiresAuth && !main.isLoggedIn) {
return '/login'
}
})
```
## serverPrefetch()
Access pinia via `this.$pinia`:
```ts
export default {
serverPrefetch() {
const store = useStore(this.$pinia)
return store.fetchData()
},
}
```
## onServerPrefetch()
Works normally:
```vue
<script setup>
const store = useStore()
onServerPrefetch(async () => {
await store.fetchData()
})
</script>
```
## State Hydration
Serialize state on server and hydrate on client.
### Server Side
Use [devalue](https://github.com/Rich-Harris/devalue) for XSS-safe serialization:
```ts
import devalue from 'devalue'
import { createPinia } from 'pinia'
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
// After rendering, state is available
const serializedState = devalue(pinia.state.value)
// Inject into HTML as global variable
```
### Client Side
Hydrate before any `useStore()` call:
```ts
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// Hydrate from serialized state (e.g., from window.__pinia)
if (typeof window !== 'undefined') {
pinia.state.value = JSON.parse(window.__pinia)
}
```
## SSR Examples
- [Vitesse template](https://github.com/antfu/vitesse/blob/main/src/modules/pinia.ts)
- [vite-plugin-ssr](https://vite-plugin-ssr.com/pinia)
## Key Points
1. Call stores inside functions, not at module scope
2. Pass `pinia` instance when using stores outside components in SSR
3. Hydrate state before calling any `useStore()`
4. Use `devalue` or similar for safe serialization
5. Avoid cross-request state pollution by creating fresh pinia per request
<!--
Source references:
- https://pinia.vuejs.org/ssr/
-->

View File

@@ -0,0 +1,115 @@
---
name: using-stores-outside-components
description: Correctly using stores in navigation guards, plugins, and other non-component contexts
---
# Using Stores Outside Components
Stores need the `pinia` instance, which is automatically injected in components. Outside components, you may need to provide it manually.
## Single Page Applications
Call stores **after** pinia is installed:
```ts
import { useUserStore } from '@/stores/user'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
// ❌ Fails - pinia not created yet
const userStore = useUserStore()
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// ✅ Works - pinia is active
const userStore = useUserStore()
```
## Navigation Guards
**Wrong:** Call at module level
```ts
import { createRouter } from 'vue-router'
const router = createRouter({ /* ... */ })
// ❌ May fail depending on import order
const store = useUserStore()
router.beforeEach((to) => {
if (store.isLoggedIn) { /* ... */ }
})
```
**Correct:** Call inside the guard
```ts
router.beforeEach((to) => {
// ✅ Called after pinia is installed
const store = useUserStore()
if (to.meta.requiresAuth && !store.isLoggedIn) {
return '/login'
}
})
```
## SSR Applications
Always pass the `pinia` instance to `useStore()`:
```ts
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
router.beforeEach((to) => {
// ✅ Pass pinia instance
const main = useMainStore(pinia)
if (to.meta.requiresAuth && !main.isLoggedIn) {
return '/login'
}
})
```
## serverPrefetch()
Access pinia via `this.$pinia`:
```ts
export default {
serverPrefetch() {
const store = useStore(this.$pinia)
return store.fetchData()
},
}
```
## onServerPrefetch()
Works normally in `<script setup>`:
```vue
<script setup>
const store = useStore()
onServerPrefetch(async () => {
// ✅ Just works
await store.fetchData()
})
</script>
```
## Key Takeaway
Defer `useStore()` calls to functions that run after pinia is installed, rather than calling at module scope.
<!--
Source references:
- https://pinia.vuejs.org/core-concepts/outside-component-usage.html
-->

View File

@@ -0,0 +1,212 @@
---
name: testing
description: Unit testing stores and components with @pinia/testing
---
# Testing Stores
## Unit Testing Stores
Create a fresh pinia instance for each test:
```ts
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '../src/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increments', () => {
const counter = useCounterStore()
expect(counter.n).toBe(0)
counter.increment()
expect(counter.n).toBe(1)
})
})
```
### With Plugins
```ts
import { setActivePinia, createPinia } from 'pinia'
import { createApp } from 'vue'
import { somePlugin } from '../src/stores/plugin'
const app = createApp({})
beforeEach(() => {
const pinia = createPinia().use(somePlugin)
app.use(pinia)
setActivePinia(pinia)
})
```
## Testing Components
Install `@pinia/testing`:
```bash
npm i -D @pinia/testing
```
Use `createTestingPinia()`:
```ts
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useSomeStore } from '@/stores/myStore'
const wrapper = mount(Counter, {
global: {
plugins: [createTestingPinia()],
},
})
const store = useSomeStore()
// Manipulate state directly
store.name = 'new name'
store.$patch({ name: 'new name' })
// Actions are stubbed by default
store.someAction()
expect(store.someAction).toHaveBeenCalledTimes(1)
```
## Initial State
Set initial state for tests:
```ts
const wrapper = mount(Counter, {
global: {
plugins: [
createTestingPinia({
initialState: {
counter: { n: 20 }, // Store name → initial state
},
}),
],
},
})
```
## Action Stubbing
### Execute Real Actions
```ts
createTestingPinia({ stubActions: false })
```
### Selective Stubbing
```ts
// Only stub specific actions
createTestingPinia({
stubActions: ['increment', 'reset'],
})
// Or use a function
createTestingPinia({
stubActions: (actionName, store) => {
if (actionName.startsWith('set')) return true
return false
},
})
```
### Mock Action Return Values
```ts
import type { Mock } from 'vitest'
// After getting store
store.someAction.mockResolvedValue('mocked value')
```
## Mocking Getters
Getters are writable in tests:
```ts
const pinia = createTestingPinia()
const counter = useCounterStore(pinia)
counter.double = 3 // Override computed value
// Reset to default behavior
counter.double = undefined
counter.double // Now computed normally
```
## Custom Spy Function
If not using Jest/Vitest with globals:
```ts
import { vi } from 'vitest'
createTestingPinia({
createSpy: vi.fn,
})
```
With Sinon:
```ts
import sinon from 'sinon'
createTestingPinia({
createSpy: sinon.spy,
})
```
## Pinia Plugins in Tests
Pass plugins to `createTestingPinia()`:
```ts
import { somePlugin } from '../src/stores/plugin'
createTestingPinia({
stubActions: false,
plugins: [somePlugin],
})
```
**Don't use** `testingPinia.use(MyPlugin)` - pass plugins in options.
## Type-Safe Mocked Store
```ts
import type { Mock } from 'vitest'
import type { Store, StoreDefinition } from 'pinia'
function mockedStore<TStoreDef extends () => unknown>(
useStore: TStoreDef
): TStoreDef extends StoreDefinition<infer Id, infer State, infer Getters, infer Actions>
? Store<Id, State, Record<string, never>, {
[K in keyof Actions]: Actions[K] extends (...args: any[]) => any
? Mock<Actions[K]>
: Actions[K]
}>
: ReturnType<TStoreDef> {
return useStore() as any
}
// Usage
const store = mockedStore(useSomeStore)
store.someAction.mockResolvedValue('value') // Typed!
```
## E2E Tests
No special handling needed - Pinia works normally.
<!--
Source references:
- https://pinia.vuejs.org/cookbook/testing.html
-->

View File

@@ -0,0 +1,389 @@
---
name: stores
description: Defining stores, state, getters, and actions in Pinia
---
# Pinia Stores
Stores are defined using `defineStore()` with a unique name. Each store has three core concepts: **state**, **getters**, and **actions**.
## Defining Stores
### Option Stores
Similar to Vue's Options API:
```ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo',
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
```
Think of `state` as `data`, `getters` as `computed`, and `actions` as `methods`.
### Setup Stores (Recommended)
Uses Composition API syntax - more flexible and powerful:
```ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Eduardo')
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, name, doubleCount, increment }
})
```
In Setup Stores: `ref()` → state, `computed()` → getters, `function()` → actions.
**Important:** You must return all state properties for Pinia to track them.
### Using Stores
```vue
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// Access: store.count, store.doubleCount, store.increment()
</script>
```
### Destructuring with storeToRefs
```vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ❌ Breaks reactivity
const { name, doubleCount } = store
// ✅ Preserves reactivity for state/getters
const { name, doubleCount } = storeToRefs(store)
// ✅ Actions can be destructured directly
const { increment } = store
</script>
```
---
## State
State is defined as a function returning the initial state.
### TypeScript
Type inference works automatically. For complex types:
```ts
interface UserInfo {
name: string
age: number
}
export const useUserStore = defineStore('user', {
state: () => ({
userList: [] as UserInfo[],
user: null as UserInfo | null,
}),
})
```
Or use an interface for the return type:
```ts
interface State {
userList: UserInfo[]
user: UserInfo | null
}
export const useUserStore = defineStore('user', {
state: (): State => ({
userList: [],
user: null,
}),
})
```
### Accessing and Modifying
```ts
const store = useStore()
store.count++
```
```vue
<input v-model="store.count" type="number" />
```
### Mutating with $patch
Apply multiple changes at once:
```ts
// Object syntax
store.$patch({
count: store.count + 1,
name: 'DIO',
})
// Function syntax (for complex mutations)
store.$patch((state) => {
state.items.push({ name: 'shoes', quantity: 1 })
state.hasChanged = true
})
```
### Resetting State
Option Stores have built-in `$reset()`. For Setup Stores, implement your own:
```ts
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function $reset() {
count.value = 0
}
return { count, $reset }
})
```
### Subscribing to State Changes
```ts
cartStore.$subscribe((mutation, state) => {
mutation.type // 'direct' | 'patch object' | 'patch function'
mutation.storeId // 'cart'
mutation.payload // patch object (only for 'patch object')
localStorage.setItem('cart', JSON.stringify(state))
})
// Options
cartStore.$subscribe(callback, { flush: 'sync' }) // Immediate
cartStore.$subscribe(callback, { detached: true }) // Keep after unmount
```
---
## Getters
Getters are computed values, equivalent to Vue's `computed()`.
### Basic Getters
```ts
getters: {
doubleCount: (state) => state.count * 2,
}
```
### Accessing Other Getters
Use `this` with explicit return type:
```ts
getters: {
doubleCount: (state) => state.count * 2,
doublePlusOne(): number {
return this.doubleCount + 1
},
},
```
### Getters with Arguments
Return a function (note: loses caching):
```ts
getters: {
getUserById: (state) => {
return (userId: string) => state.users.find((user) => user.id === userId)
},
},
```
Cache within parameterized getters:
```ts
getters: {
getActiveUserById(state) {
const activeUsers = state.users.filter((user) => user.active)
return (userId: string) => activeUsers.find((user) => user.id === userId)
},
},
```
### Accessing Other Stores in Getters
```ts
import { useOtherStore } from './other-store'
getters: {
combined(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
},
```
---
## Actions
Actions are methods for business logic. Unlike getters, they can be asynchronous.
### Defining Actions
```ts
actions: {
increment() {
this.count++
},
randomizeCounter() {
this.count = Math.round(100 * Math.random())
},
},
```
### Async Actions
```ts
actions: {
async registerUser(login: string, password: string) {
try {
this.userData = await api.post({ login, password })
} catch (error) {
return error
}
},
},
```
### Accessing Other Stores in Actions
```ts
import { useAuthStore } from './auth-store'
actions: {
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
}
},
},
```
**SSR:** Call all `useStore()` before any `await`:
```ts
async orderCart() {
// ✅ Call stores before await
const user = useUserStore()
await apiOrderCart(user.token, this.items)
// ❌ Don't call useStore() after await in SSR
}
```
### Subscribing to Actions
```ts
const unsubscribe = someStore.$onAction(
({ name, store, args, after, onError }) => {
const startTime = Date.now()
console.log(`Start "${name}" with params [${args.join(', ')}]`)
after((result) => {
console.log(`Finished "${name}" after ${Date.now() - startTime}ms`)
})
onError((error) => {
console.warn(`Failed "${name}": ${error}`)
})
}
)
unsubscribe() // Cleanup
```
Keep subscription after component unmount:
```ts
someStore.$onAction(callback, true)
```
---
## Options API Helpers
```ts
import { mapState, mapWritableState, mapActions } from 'pinia'
import { useCounterStore } from '../stores/counter'
export default {
computed: {
// Readonly state/getters
...mapState(useCounterStore, ['count', 'doubleCount']),
// Writable state
...mapWritableState(useCounterStore, ['count']),
},
methods: {
...mapActions(useCounterStore, ['increment']),
},
}
```
---
## Accessing Global Providers in Setup Stores
```ts
import { inject } from 'vue'
import { useRoute } from 'vue-router'
import { defineStore } from 'pinia'
export const useSearchFilters = defineStore('search-filters', () => {
const route = useRoute()
const appProvided = inject('appProvided')
// Don't return these - access them directly in components
return { /* ... */ }
})
```
<!--
Source references:
- https://pinia.vuejs.org/core-concepts/
- https://pinia.vuejs.org/core-concepts/state.html
- https://pinia.vuejs.org/core-concepts/getters.html
- https://pinia.vuejs.org/core-concepts/actions.html
-->

View File

@@ -0,0 +1,114 @@
---
name: composables-in-stores
description: Using Vue composables within Pinia stores
---
# Composables in Stores
Pinia stores can leverage Vue composables for reusable stateful logic.
## Option Stores
Call composables inside the `state` property, but only those returning writable refs:
```ts
import { defineStore } from 'pinia'
import { useLocalStorage } from '@vueuse/core'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: useLocalStorage('pinia/auth/login', 'bob'),
}),
})
```
**Works:** Composables returning `ref()`:
- `useLocalStorage`
- `useAsyncState`
**Doesn't work in Option Stores:**
- Composables exposing functions
- Composables exposing readonly data
## Setup Stores
More flexible - can use almost any composable:
```ts
import { defineStore } from 'pinia'
import { useMediaControls } from '@vueuse/core'
import { ref } from 'vue'
export const useVideoPlayer = defineStore('video', () => {
const videoElement = ref<HTMLVideoElement>()
const src = ref('/data/video.mp4')
const { playing, volume, currentTime, togglePictureInPicture } =
useMediaControls(videoElement, { src })
function loadVideo(element: HTMLVideoElement, newSrc: string) {
videoElement.value = element
src.value = newSrc
}
return {
src,
playing,
volume,
currentTime,
loadVideo,
togglePictureInPicture,
}
})
```
**Note:** Don't return non-serializable DOM refs like `videoElement` - they're internal implementation details.
## SSR Considerations
### Option Stores with hydrate()
Define a `hydrate()` function to handle client-side hydration:
```ts
import { defineStore } from 'pinia'
import { useLocalStorage } from '@vueuse/core'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: useLocalStorage('pinia/auth/login', 'bob'),
}),
hydrate(state, initialState) {
// Ignore server state, read from browser
state.user = useLocalStorage('pinia/auth/login', 'bob')
},
})
```
### Setup Stores with skipHydrate()
Mark state that shouldn't hydrate from server:
```ts
import { defineStore, skipHydrate } from 'pinia'
import { useEyeDropper, useLocalStorage } from '@vueuse/core'
export const useColorStore = defineStore('colors', () => {
const { isSupported, open, sRGBHex } = useEyeDropper()
const lastColor = useLocalStorage('lastColor', sRGBHex)
return {
// Skip hydration for client-only state
lastColor: skipHydrate(lastColor),
open, // Function - no hydration needed
isSupported, // Boolean - not reactive
}
})
```
`skipHydrate()` only applies to state properties (refs), not functions or non-reactive values.
<!--
Source references:
- https://pinia.vuejs.org/cookbook/composables.html
-->

View File

@@ -0,0 +1,134 @@
---
name: composing-stores
description: Store-to-store communication and avoiding circular dependencies
---
# Composing Stores
Stores can use each other for shared state and logic.
## Rule: Avoid Circular Dependencies
Two stores cannot directly read each other's state during setup:
```ts
// ❌ Infinite loop
const useX = defineStore('x', () => {
const y = useY()
y.name // Don't read here!
return { name: ref('X') }
})
const useY = defineStore('y', () => {
const x = useX()
x.name // Don't read here!
return { name: ref('Y') }
})
```
**Solution:** Read in getters, computed, or actions:
```ts
const useX = defineStore('x', () => {
const y = useY()
// ✅ Read in computed/actions
function doSomething() {
const yName = y.name
}
return { name: ref('X'), doSomething }
})
```
## Setup Stores: Use Store at Top
```ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const user = useUserStore()
const list = ref([])
const summary = computed(() => {
return `Hi ${user.name}, you have ${list.value.length} items`
})
function purchase() {
return apiPurchase(user.id, list.value)
}
return { list, summary, purchase }
})
```
## Shared Getters
Call `useStore()` inside a getter:
```ts
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', {
getters: {
summary(state) {
const user = useUserStore()
return `Hi ${user.name}, you have ${state.list.length} items`
},
},
})
```
## Shared Actions
Call `useStore()` inside an action:
```ts
import { useUserStore } from './user'
import { apiOrderCart } from './api'
export const useCartStore = defineStore('cart', {
actions: {
async orderCart() {
const user = useUserStore()
try {
await apiOrderCart(user.token, this.items)
this.emptyCart()
} catch (err) {
displayError(err)
}
},
},
})
```
## SSR: Call Stores Before Await
In async actions, call all stores before any `await`:
```ts
actions: {
async orderCart() {
// ✅ All useStore() calls before await
const user = useUserStore()
const analytics = useAnalyticsStore()
try {
await apiOrderCart(user.token, this.items)
// ❌ Don't call useStore() after await (SSR issue)
// const otherStore = useOtherStore()
} catch (err) {
displayError(err)
}
},
}
```
This ensures the correct Pinia instance is used during SSR.
<!--
Source references:
- https://pinia.vuejs.org/cookbook/composing-stores.html
-->

View File

@@ -0,0 +1,203 @@
---
name: plugins
description: Extend stores with custom properties, methods, and behavior
---
# Plugins
Plugins extend all stores with custom properties, methods, or behavior.
## Basic Plugin
```ts
import { createPinia } from 'pinia'
function SecretPiniaPlugin() {
return { secret: 'the cake is a lie' }
}
const pinia = createPinia()
pinia.use(SecretPiniaPlugin)
// In any store
const store = useStore()
store.secret // 'the cake is a lie'
```
## Plugin Context
Plugins receive a context object:
```ts
import { PiniaPluginContext } from 'pinia'
export function myPiniaPlugin(context: PiniaPluginContext) {
context.pinia // pinia instance
context.app // Vue app instance
context.store // store being augmented
context.options // store definition options
}
```
## Adding Properties
Return an object to add properties (tracked in devtools):
```ts
pinia.use(() => ({ hello: 'world' }))
```
Or set directly on store:
```ts
pinia.use(({ store }) => {
store.hello = 'world'
// For devtools visibility in dev mode
if (process.env.NODE_ENV === 'development') {
store._customProperties.add('hello')
}
})
```
## Adding State
Add to both `store` and `store.$state` for SSR/devtools:
```ts
import { toRef, ref } from 'vue'
pinia.use(({ store }) => {
if (!store.$state.hasOwnProperty('hasError')) {
const hasError = ref(false)
store.$state.hasError = hasError
}
store.hasError = toRef(store.$state, 'hasError')
})
```
## Adding External Properties
Wrap non-reactive objects with `markRaw()`:
```ts
import { markRaw } from 'vue'
import { router } from './router'
pinia.use(({ store }) => {
store.router = markRaw(router)
})
```
## Custom Store Options
Define custom options consumed by plugins:
```ts
// Store definition
defineStore('search', {
actions: {
searchContacts() { /* ... */ },
},
debounce: {
searchContacts: 300,
},
})
// Plugin reads custom option
import debounce from 'lodash/debounce'
pinia.use(({ options, store }) => {
if (options.debounce) {
return Object.keys(options.debounce).reduce((acc, action) => {
acc[action] = debounce(store[action], options.debounce[action])
return acc
}, {})
}
})
```
For Setup Stores, pass options as third argument:
```ts
defineStore(
'search',
() => { /* ... */ },
{
debounce: { searchContacts: 300 },
}
)
```
## TypeScript Augmentation
### Custom Properties
```ts
import 'pinia'
import type { Router } from 'vue-router'
declare module 'pinia' {
export interface PiniaCustomProperties {
router: Router
hello: string
}
}
```
### Custom State
```ts
declare module 'pinia' {
export interface PiniaCustomStateProperties<S> {
hasError: boolean
}
}
```
### Custom Options
```ts
declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
debounce?: Partial<Record<keyof StoreActions<Store>, number>>
}
}
```
## Subscribe in Plugins
```ts
pinia.use(({ store }) => {
store.$subscribe(() => {
// React to state changes
})
store.$onAction(() => {
// React to actions
})
})
```
## Nuxt Plugin
Create a Nuxt plugin to add Pinia plugins:
```ts
// plugins/myPiniaPlugin.ts
import { PiniaPluginContext } from 'pinia'
function MyPiniaPlugin({ store }: PiniaPluginContext) {
store.$subscribe((mutation) => {
console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}`)
})
return { creationTime: new Date() }
}
export default defineNuxtPlugin(({ $pinia }) => {
$pinia.use(MyPiniaPlugin)
})
```
<!--
Source references:
- https://pinia.vuejs.org/core-concepts/plugins.html
-->

View File

@@ -0,0 +1,5 @@
# Generation Info
- **Source:** `sources/vite`
- **Git SHA:** `c47015eba4f0de255218c35769628d87152216ca`
- **Generated:** 2026-01-31

View File

@@ -0,0 +1,72 @@
---
name: vite
description: Vite build tool configuration, plugin API, SSR, and Vite 8 Rolldown migration. Use when working with Vite projects, vite.config.ts, Vite plugins, or building libraries/SSR apps with Vite.
metadata:
author: Anthony Fu
version: "2026.1.31"
source: Generated from https://github.com/vitejs/vite, scripts at https://github.com/antfu/skills
---
# Vite
> Based on Vite 8 beta (Rolldown-powered). Vite 8 uses Rolldown bundler and Oxc transformer.
Vite is a next-generation frontend build tool with fast dev server (native ESM + HMR) and optimized production builds.
## Preferences
- Use TypeScript: prefer `vite.config.ts`
- Always use ESM, avoid CommonJS
## Core
| Topic | Description | Reference |
|-------|-------------|-----------|
| Configuration | `vite.config.ts`, `defineConfig`, conditional configs, `loadEnv` | [core-config](references/core-config.md) |
| Features | `import.meta.glob`, asset queries (`?raw`, `?url`), `import.meta.env`, HMR API | [core-features](references/core-features.md) |
| Plugin API | Vite-specific hooks, virtual modules, plugin ordering | [core-plugin-api](references/core-plugin-api.md) |
## Build & SSR
| Topic | Description | Reference |
|-------|-------------|-----------|
| Build & SSR | Library mode, SSR middleware mode, `ssrLoadModule`, JavaScript API | [build-and-ssr](references/build-and-ssr.md) |
## Advanced
| Topic | Description | Reference |
|-------|-------------|-----------|
| Environment API | Vite 6+ multi-environment support, custom runtimes | [environment-api](references/environment-api.md) |
| Rolldown Migration | Vite 8 changes: Rolldown bundler, Oxc transformer, config migration | [rolldown-migration](references/rolldown-migration.md) |
## Quick Reference
### CLI Commands
```bash
vite # Start dev server
vite build # Production build
vite preview # Preview production build
vite build --ssr # SSR build
```
### Common Config
```ts
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [],
resolve: { alias: { '@': '/src' } },
server: { port: 3000, proxy: { '/api': 'http://localhost:8080' } },
build: { target: 'esnext', outDir: 'dist' },
})
```
### Official Plugins
- `@vitejs/plugin-vue` - Vue 3 SFC support
- `@vitejs/plugin-vue-jsx` - Vue 3 JSX
- `@vitejs/plugin-react` - React with Oxc/Babel
- `@vitejs/plugin-react-swc` - React with SWC
- `@vitejs/plugin-legacy` - Legacy browser support

View File

@@ -0,0 +1,164 @@
---
name: vite-build-ssr
description: Vite library mode, multi-page apps, JavaScript API, and SSR guidance
---
# Build and SSR
## Library Mode
Build a library for distribution:
```ts
// vite.config.ts
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: resolve(import.meta.dirname, 'lib/main.ts'),
name: 'MyLib',
fileName: 'my-lib',
},
rolldownOptions: {
external: ['vue', 'react'],
output: {
globals: {
vue: 'Vue',
react: 'React',
},
},
},
},
})
```
### Multiple Entries
```ts
build: {
lib: {
entry: {
'my-lib': resolve(import.meta.dirname, 'lib/main.ts'),
secondary: resolve(import.meta.dirname, 'lib/secondary.ts'),
},
name: 'MyLib',
},
}
```
### Output Formats
- Single entry: `es` and `umd`
- Multiple entries: `es` and `cjs`
### Package.json Setup
```json
{
"name": "my-lib",
"type": "module",
"files": ["dist"],
"main": "./dist/my-lib.umd.cjs",
"module": "./dist/my-lib.js",
"exports": {
".": {
"import": "./dist/my-lib.js",
"require": "./dist/my-lib.umd.cjs"
},
"./style.css": "./dist/my-lib.css"
}
}
```
## Multi-Page App
```ts
export default defineConfig({
build: {
rolldownOptions: {
input: {
main: resolve(import.meta.dirname, 'index.html'),
nested: resolve(import.meta.dirname, 'nested/index.html'),
},
},
},
})
```
## SSR Development
**Note:** Vite's SSR support is **low-level** and designed mostly for meta-framework authors, not application developers. If you need SSR for your app, use a Vite-based meta-framework instead:
- **Nuxt** (Vue) - https://nuxt.com
- **SvelteKit** (Svelte) - https://svelte.dev/docs/kit
- **SolidStart** (Solid) - https://start.solidjs.com
- **TanStack Start** (React) - https://tanstack.com/start
These frameworks build on top of Vite's SSR primitives so you don't have to wire them up yourself.
**Need a server?** Consider [Nitro](https://nitro.build) -- think of it as "Vite for servers." Nitro provides a portable, framework-agnostic server layer with file-based API routing, auto-imports, and deployment presets for dozens of platforms (Node.js, Deno, Bun, Cloudflare Workers, Vercel, Netlify, etc.). It integrates naturally with Vite and is what powers Nuxt's server engine. See the [Nitro docs](https://nitro.build) for more details.
## JavaScript API
### createServer
```ts
import { createServer } from 'vite'
const server = await createServer({
configFile: false,
root: import.meta.dirname,
server: { port: 1337 },
})
await server.listen()
server.printUrls()
```
### build
```ts
import { build } from 'vite'
await build({
root: './project',
build: { outDir: 'dist' },
})
```
### preview
```ts
import { preview } from 'vite'
const previewServer = await preview({
preview: { port: 8080, open: true },
})
previewServer.printUrls()
```
### resolveConfig
```ts
import { resolveConfig } from 'vite'
const config = await resolveConfig({}, 'build')
```
### loadEnv
```ts
import { loadEnv } from 'vite'
const env = loadEnv('development', process.cwd(), '')
// Loads all env vars (empty prefix = no filtering)
```
<!--
Source references:
- https://vite.dev/guide/build
- https://vite.dev/guide/api-javascript
- https://nitro.build
-->

View File

@@ -0,0 +1,162 @@
---
name: vite-config
description: Vite configuration patterns using vite.config.ts
---
# Vite Configuration
## Basic Setup
```ts
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
// config options
})
```
Vite auto-resolves `vite.config.ts` from project root. Supports ES modules syntax regardless of `package.json` type.
## Conditional Config
Export a function to access command and mode:
```ts
export default defineConfig(({ command, mode, isSsrBuild, isPreview }) => {
if (command === 'serve') {
return { /* dev config */ }
} else {
return { /* build config */ }
}
})
```
- `command`: `'serve'` during dev, `'build'` for production
- `mode`: `'development'` or `'production'` (or custom via `--mode`)
## Async Config
```ts
export default defineConfig(async ({ command, mode }) => {
const data = await fetchSomething()
return { /* config */ }
})
```
## Using Environment Variables in Config
`.env` files are loaded **after** config resolution. Use `loadEnv` to access them in config:
```ts
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
// Load env files from cwd, include all vars (empty prefix)
const env = loadEnv(mode, process.cwd(), '')
return {
define: {
__APP_ENV__: JSON.stringify(env.APP_ENV),
},
server: {
port: env.APP_PORT ? Number(env.APP_PORT) : 5173,
},
}
})
```
## Key Config Options
### resolve.alias
```ts
export default defineConfig({
resolve: {
alias: {
'@': '/src',
'~': '/src',
},
},
})
```
### define (Global Constants)
```ts
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify('1.0.0'),
__API_URL__: 'window.__backend_api_url',
},
})
```
Values must be JSON-serializable or single identifiers. Non-strings auto-wrapped with `JSON.stringify`.
### plugins
```ts
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
```
Plugins array is flattened; falsy values ignored.
### server.proxy
```ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})
```
### build.target
Default: Baseline Widely Available browsers. Customize:
```ts
export default defineConfig({
build: {
target: 'esnext', // or 'es2020', ['chrome90', 'firefox88']
},
})
```
## TypeScript Intellisense
For plain JS config files:
```js
/** @type {import('vite').UserConfig} */
export default {
// ...
}
```
Or use `satisfies`:
```ts
import type { UserConfig } from 'vite'
export default {
// ...
} satisfies UserConfig
```
<!--
Source references:
- https://vite.dev/config/
- https://vite.dev/guide/
-->

View File

@@ -0,0 +1,205 @@
---
name: vite-features
description: Vite-specific import patterns and runtime features
---
# Vite Features
## Glob Import
Import multiple modules matching a pattern:
```ts
const modules = import.meta.glob('./dir/*.ts')
// { './dir/foo.ts': () => import('./dir/foo.ts'), ... }
for (const path in modules) {
modules[path]().then((mod) => {
console.log(path, mod)
})
}
```
### Eager Loading
```ts
const modules = import.meta.glob('./dir/*.ts', { eager: true })
// Modules loaded immediately, no dynamic import
```
### Named Imports
```ts
const modules = import.meta.glob('./dir/*.ts', { import: 'setup' })
// Only imports the 'setup' export from each module
const defaults = import.meta.glob('./dir/*.ts', { import: 'default', eager: true })
```
### Multiple Patterns
```ts
const modules = import.meta.glob(['./dir/*.ts', './another/*.ts'])
```
### Negative Patterns
```ts
const modules = import.meta.glob(['./dir/*.ts', '!**/ignored.ts'])
```
### Custom Queries
```ts
const svgRaw = import.meta.glob('./icons/*.svg', { query: '?raw', import: 'default' })
const svgUrls = import.meta.glob('./icons/*.svg', { query: '?url', import: 'default' })
```
## Asset Import Queries
### URL Import
```ts
import imgUrl from './img.png'
// Returns resolved URL: '/src/img.png' (dev) or '/assets/img.2d8efhg.png' (build)
```
### Explicit URL
```ts
import workletUrl from './worklet.js?url'
```
### Raw String
```ts
import shaderCode from './shader.glsl?raw'
```
### Inline/No-Inline
```ts
import inlined from './small.png?inline' // Force base64 inline
import notInlined from './large.png?no-inline' // Force separate file
```
### Web Workers
```ts
import Worker from './worker.ts?worker'
const worker = new Worker()
// Or inline:
import InlineWorker from './worker.ts?worker&inline'
```
Preferred pattern using constructor:
```ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
})
```
## Environment Variables
### Built-in Constants
```ts
import.meta.env.MODE // 'development' | 'production' | custom
import.meta.env.BASE_URL // Base URL from config
import.meta.env.PROD // true in production
import.meta.env.DEV // true in development
import.meta.env.SSR // true when running in server
```
### Custom Variables
Only `VITE_` prefixed vars exposed to client:
```
# .env
VITE_API_URL=https://api.example.com
DB_PASSWORD=secret # NOT exposed to client
```
```ts
console.log(import.meta.env.VITE_API_URL) // works
console.log(import.meta.env.DB_PASSWORD) // undefined
```
### Mode-specific Files
```
.env # always loaded
.env.local # always loaded, gitignored
.env.[mode] # only in specified mode
.env.[mode].local # only in specified mode, gitignored
```
### TypeScript Support
```ts
// vite-env.d.ts
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
```
### HTML Replacement
```html
<p>Running in %MODE%</p>
<script>window.API = "%VITE_API_URL%"</script>
```
## CSS Modules
Any `.module.css` file treated as CSS module:
```ts
import styles from './component.module.css'
element.className = styles.button
```
With camelCase conversion:
```ts
// .my-class -> myClass (if css.modules.localsConvention configured)
import { myClass } from './component.module.css'
```
## JSON Import
```ts
import pkg from './package.json'
import { version } from './package.json' // Named import with tree-shaking
```
## HMR API
```ts
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// Handle update
})
import.meta.hot.dispose((data) => {
// Cleanup before module is replaced
})
import.meta.hot.invalidate() // Force full reload
}
```
<!--
Source references:
- https://vite.dev/guide/features
- https://vite.dev/guide/env-and-mode
- https://vite.dev/guide/assets
- https://vite.dev/guide/api-hmr
-->

View File

@@ -0,0 +1,235 @@
---
name: vite-plugin-api
description: Vite plugin authoring with Vite-specific hooks
---
# Vite Plugin API
Vite plugins extend Rolldown's plugin interface with Vite-specific hooks.
## Basic Structure
```ts
function myPlugin(): Plugin {
return {
name: 'my-plugin',
// hooks...
}
}
```
## Vite-Specific Hooks
### config
Modify config before resolution:
```ts
const plugin = () => ({
name: 'add-alias',
config: () => ({
resolve: {
alias: { foo: 'bar' },
},
}),
})
```
### configResolved
Access final resolved config:
```ts
const plugin = () => {
let config: ResolvedConfig
return {
name: 'read-config',
configResolved(resolvedConfig) {
config = resolvedConfig
},
transform(code, id) {
if (config.command === 'serve') { /* dev */ }
},
}
}
```
### configureServer
Add custom middleware to dev server:
```ts
const plugin = () => ({
name: 'custom-middleware',
configureServer(server) {
server.middlewares.use((req, res, next) => {
// handle request
next()
})
},
})
```
Return function to run **after** internal middlewares:
```ts
configureServer(server) {
return () => {
server.middlewares.use((req, res, next) => {
// runs after Vite's middlewares
})
}
}
```
### transformIndexHtml
Transform HTML entry files:
```ts
const plugin = () => ({
name: 'html-transform',
transformIndexHtml(html) {
return html.replace(/<title>(.*?)<\/title>/, '<title>New Title</title>')
},
})
```
Inject tags:
```ts
transformIndexHtml() {
return [
{ tag: 'script', attrs: { src: '/inject.js' }, injectTo: 'body' },
]
}
```
### handleHotUpdate
Custom HMR handling:
```ts
handleHotUpdate({ server, modules, timestamp }) {
server.ws.send({ type: 'custom', event: 'special-update', data: {} })
return [] // empty = skip default HMR
}
```
## Virtual Modules
Serve virtual content without files on disk:
```ts
const plugin = () => {
const virtualModuleId = 'virtual:my-module'
const resolvedId = '\0' + virtualModuleId
return {
name: 'virtual-module',
resolveId(id) {
if (id === virtualModuleId) return resolvedId
},
load(id) {
if (id === resolvedId) {
return `export const msg = "from virtual module"`
}
},
}
}
```
Usage:
```ts
import { msg } from 'virtual:my-module'
```
Convention: prefix user-facing path with `virtual:`, prefix resolved id with `\0`.
## Plugin Ordering
Use `enforce` to control execution order:
```ts
{
name: 'pre-plugin',
enforce: 'pre', // runs before core plugins
}
{
name: 'post-plugin',
enforce: 'post', // runs after build plugins
}
```
Order: Alias → `enforce: 'pre'` → Core → User (no enforce) → Build → `enforce: 'post'` → Post-build
## Conditional Application
```ts
{
name: 'build-only',
apply: 'build', // or 'serve'
}
// Function form:
{
apply(config, { command }) {
return command === 'build' && !config.build.ssr
}
}
```
## Universal Hooks (from Rolldown)
These work in both dev and build:
- `resolveId(id, importer)` - Resolve import paths
- `load(id)` - Load module content
- `transform(code, id)` - Transform module code
```ts
transform(code, id) {
if (id.endsWith('.custom')) {
return { code: compile(code), map: null }
}
}
```
## Client-Server Communication
Server to client:
```ts
configureServer(server) {
server.ws.send('my:event', { msg: 'hello' })
}
```
Client side:
```ts
if (import.meta.hot) {
import.meta.hot.on('my:event', (data) => {
console.log(data.msg)
})
}
```
Client to server:
```ts
// Client
import.meta.hot.send('my:from-client', { msg: 'Hey!' })
// Server
server.ws.on('my:from-client', (data, client) => {
client.send('my:ack', { msg: 'Got it!' })
})
```
<!--
Source references:
- https://vite.dev/guide/api-plugin
-->

View File

@@ -0,0 +1,108 @@
---
name: vite-environment-api
description: Vite 6+ Environment API for multiple runtime environments
---
# Environment API (Vite 6+)
The Environment API formalizes multiple runtime environments beyond the traditional client/SSR split.
## Concept
Before Vite 6: Two implicit environments (`client` and `ssr`).
Vite 6+: Configure as many environments as needed (browser, node server, edge server, etc.).
## Basic Configuration
For SPA/MPA, nothing changes—options apply to the implicit `client` environment:
```ts
export default defineConfig({
build: { sourcemap: false },
optimizeDeps: { include: ['lib'] },
})
```
## Multiple Environments
```ts
export default defineConfig({
build: { sourcemap: false }, // Inherited by all environments
optimizeDeps: { include: ['lib'] }, // Client only
environments: {
// SSR environment
server: {},
// Edge runtime environment
edge: {
resolve: { noExternal: true },
},
},
})
```
Environments inherit top-level config. Some options (like `optimizeDeps`) only apply to `client` by default.
## Environment Options
```ts
interface EnvironmentOptions {
define?: Record<string, any>
resolve?: EnvironmentResolveOptions
optimizeDeps: DepOptimizationOptions
consumer?: 'client' | 'server'
dev: DevOptions
build: BuildOptions
}
```
## Custom Environment Instances
Runtime providers can define custom environments:
```ts
import { customEnvironment } from 'vite-environment-provider'
export default defineConfig({
environments: {
ssr: customEnvironment({
build: { outDir: '/dist/ssr' },
}),
},
})
```
Example: Cloudflare's Vite plugin runs code in `workerd` runtime during development.
## Backward Compatibility
- `server.moduleGraph` returns mixed client/SSR view
- `ssrLoadModule` still works
- Existing SSR apps work unchanged
## When to Use
- **End users**: Usually don't need to configure—frameworks handle it
- **Plugin authors**: Use for environment-aware transformations
- **Framework authors**: Create custom environments for their runtime needs
## Plugin Environment Access
Plugins can access environment in hooks:
```ts
{
name: 'env-aware',
transform(code, id, options) {
if (options?.ssr) {
// SSR-specific transform
}
},
}
```
<!--
Source references:
- https://vite.dev/guide/api-environment
- https://vite.dev/blog/announcing-vite6
-->

View File

@@ -0,0 +1,157 @@
---
name: vite-rolldown
description: Vite 8 Rolldown bundler and Oxc transformer migration
---
# Rolldown Migration (Vite 8)
Vite 8 replaces esbuild+Rollup with Rolldown, a unified Rust-based bundler.
## What Changed
| Before (Vite 7) | After (Vite 8) |
|-----------------|----------------|
| esbuild (dev transform) | Oxc Transformer |
| esbuild (dep pre-bundling) | Rolldown |
| Rollup (production build) | Rolldown |
| `rollupOptions` | `rolldownOptions` |
| `esbuild` option | `oxc` option |
## Performance Impact
- 10-30x faster than Rollup for production builds
- Matches esbuild's dev performance
- Unified behavior between dev and build
## Config Migration
### rollupOptions → rolldownOptions
```ts
// Before (Vite 7)
export default defineConfig({
build: {
rollupOptions: {
external: ['vue'],
output: { globals: { vue: 'Vue' } },
},
},
})
// After (Vite 8)
export default defineConfig({
build: {
rolldownOptions: {
external: ['vue'],
output: { globals: { vue: 'Vue' } },
},
},
})
```
### esbuild → oxc
```ts
// Before (Vite 7)
export default defineConfig({
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
},
})
// After (Vite 8)
export default defineConfig({
oxc: {
jsx: {
runtime: 'classic',
pragma: 'h',
pragmaFrag: 'Fragment',
},
},
})
```
### JSX Configuration
```ts
export default defineConfig({
oxc: {
jsx: {
runtime: 'automatic', // or 'classic'
importSource: 'react', // for automatic runtime
},
jsxInject: `import React from 'react'`, // auto-inject
},
})
```
### Custom Transform Targets
```ts
export default defineConfig({
oxc: {
include: ['**/*.ts', '**/*.tsx'],
exclude: ['node_modules/**'],
},
})
```
## Plugin Compatibility
Most Vite plugins work unchanged. Rolldown supports Rollup's plugin API.
If a plugin only works during build:
```ts
{
...rollupPlugin(),
enforce: 'post',
apply: 'build',
}
```
## New Capabilities
Rolldown unlocks features not possible before:
- Full bundle mode (experimental)
- Module-level persistent cache
- More flexible chunk splitting
- Module Federation support
## Gradual Migration
For large projects, migrate via `rolldown-vite` first:
```bash
# Step 1: Test with rolldown-vite
pnpm add -D rolldown-vite
# Replace vite import in config
import { defineConfig } from 'rolldown-vite'
# Step 2: Once stable, upgrade to Vite 8
pnpm add -D vite@8
```
## Overriding Vite in Frameworks
When framework depends on older Vite:
```json
{
"pnpm": {
"overrides": {
"vite": "8.0.0"
}
}
}
```
<!--
Source references:
- https://vite.dev/blog/announcing-vite8-beta
- https://vite.dev/blog/announcing-vite7
- https://vite.dev/config/shared-options#oxc
-->

View File

@@ -0,0 +1,5 @@
# Generation Info
- **Source:** `sources/vitest`
- **Git SHA:** `4a7321e10672f00f0bb698823a381c2cc245b8f7`
- **Generated:** 2026-01-28

View File

@@ -0,0 +1,52 @@
---
name: vitest
description: Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures.
metadata:
author: Anthony Fu
version: "2026.1.28"
source: Generated from https://github.com/vitest-dev/vitest, scripts located at https://github.com/antfu/skills
---
Vitest is a next-generation testing framework powered by Vite. It provides a Jest-compatible API with native ESM, TypeScript, and JSX support out of the box. Vitest shares the same config, transformers, resolvers, and plugins with your Vite app.
**Key Features:**
- Vite-native: Uses Vite's transformation pipeline for fast HMR-like test updates
- Jest-compatible: Drop-in replacement for most Jest test suites
- Smart watch mode: Only reruns affected tests based on module graph
- Native ESM, TypeScript, JSX support without configuration
- Multi-threaded workers for parallel test execution
- Built-in coverage via V8 or Istanbul
- Snapshot testing, mocking, and spy utilities
> The skill is based on Vitest 3.x, generated at 2026-01-28.
## Core
| Topic | Description | Reference |
|-------|-------------|-----------|
| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) |
| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) |
| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) |
| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) |
| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) |
| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) |
## Features
| Topic | Description | Reference |
|-------|-------------|-----------|
| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) |
| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) |
| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) |
| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) |
| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) |
| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) |
## Advanced
| Topic | Description | Reference |
|-------|-------------|-----------|
| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) |
| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) |
| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) |
| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) |

View File

@@ -0,0 +1,264 @@
---
name: test-environments
description: Configure environments like jsdom, happy-dom for browser APIs
---
# Test Environments
## Available Environments
- `node` (default) - Node.js environment
- `jsdom` - Browser-like with DOM APIs
- `happy-dom` - Faster alternative to jsdom
- `edge-runtime` - Vercel Edge Runtime
## Configuration
```ts
// vitest.config.ts
defineConfig({
test: {
environment: 'jsdom',
// Environment-specific options
environmentOptions: {
jsdom: {
url: 'http://localhost',
},
},
},
})
```
## Installing Environment Packages
```bash
# jsdom
npm i -D jsdom
# happy-dom (faster, fewer APIs)
npm i -D happy-dom
```
## Per-File Environment
Use magic comment at top of file:
```ts
// @vitest-environment jsdom
import { expect, test } from 'vitest'
test('DOM test', () => {
const div = document.createElement('div')
expect(div).toBeInstanceOf(HTMLDivElement)
})
```
## jsdom Environment
Full browser environment simulation:
```ts
// @vitest-environment jsdom
test('DOM manipulation', () => {
document.body.innerHTML = '<div id="app"></div>'
const app = document.getElementById('app')
app.textContent = 'Hello'
expect(app.textContent).toBe('Hello')
})
test('window APIs', () => {
expect(window.location.href).toBeDefined()
expect(localStorage).toBeDefined()
})
```
### jsdom Options
```ts
defineConfig({
test: {
environmentOptions: {
jsdom: {
url: 'http://localhost:3000',
html: '<!DOCTYPE html><html><body></body></html>',
userAgent: 'custom-agent',
resources: 'usable',
},
},
},
})
```
## happy-dom Environment
Faster but fewer APIs:
```ts
// @vitest-environment happy-dom
test('basic DOM', () => {
const el = document.createElement('div')
el.className = 'test'
expect(el.className).toBe('test')
})
```
## Multiple Environments per Project
Use projects for different environments:
```ts
defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'dom',
include: ['tests/dom/**/*.test.ts'],
environment: 'jsdom',
},
},
],
},
})
```
## Custom Environment
Create custom environment package:
```ts
// vitest-environment-custom/index.ts
import type { Environment } from 'vitest/runtime'
export default <Environment>{
name: 'custom',
viteEnvironment: 'ssr', // or 'client'
setup() {
// Setup global state
globalThis.myGlobal = 'value'
return {
teardown() {
delete globalThis.myGlobal
},
}
},
}
```
Use with:
```ts
defineConfig({
test: {
environment: 'custom',
},
})
```
## Environment with VM
For full isolation:
```ts
export default <Environment>{
name: 'isolated',
viteEnvironment: 'ssr',
async setupVM() {
const vm = await import('node:vm')
const context = vm.createContext()
return {
getVmContext() {
return context
},
teardown() {},
}
},
setup() {
return { teardown() {} }
},
}
```
## Browser Mode (Separate from Environments)
For real browser testing, use Vitest Browser Mode:
```ts
defineConfig({
test: {
browser: {
enabled: true,
name: 'chromium', // or 'firefox', 'webkit'
provider: 'playwright',
},
},
})
```
## CSS and Assets
In jsdom/happy-dom, configure CSS handling:
```ts
defineConfig({
test: {
css: true, // Process CSS
// Or with options
css: {
include: /\.module\.css$/,
modules: {
classNameStrategy: 'non-scoped',
},
},
},
})
```
## Fixing External Dependencies
If external deps fail with CSS/asset errors:
```ts
defineConfig({
test: {
server: {
deps: {
inline: ['problematic-package'],
},
},
},
})
```
## Key Points
- Default is `node` - no browser APIs
- Use `jsdom` for full browser simulation
- Use `happy-dom` for faster tests with basic DOM
- Per-file environment via `// @vitest-environment` comment
- Use projects for multiple environment configurations
- Browser Mode is for real browser testing, not environment
<!--
Source references:
- https://vitest.dev/guide/environment.html
-->

View File

@@ -0,0 +1,300 @@
---
name: projects-workspaces
description: Multi-project configuration for monorepos and different test types
---
# Projects
Run different test configurations in the same Vitest process.
## Basic Projects Setup
```ts
// vitest.config.ts
defineConfig({
test: {
projects: [
// Glob patterns for config files
'packages/*',
// Inline config
{
test: {
name: 'unit',
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'integration',
include: ['tests/integration/**/*.test.ts'],
environment: 'jsdom',
},
},
],
},
})
```
## Monorepo Pattern
```ts
defineConfig({
test: {
projects: [
// Each package has its own vitest.config.ts
'packages/core',
'packages/cli',
'packages/utils',
],
},
})
```
Package config:
```ts
// packages/core/vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'core',
include: ['src/**/*.test.ts'],
environment: 'node',
},
})
```
## Different Environments
Run same tests in different environments:
```ts
defineConfig({
test: {
projects: [
{
test: {
name: 'happy-dom',
root: './shared-tests',
environment: 'happy-dom',
setupFiles: ['./setup.happy-dom.ts'],
},
},
{
test: {
name: 'node',
root: './shared-tests',
environment: 'node',
setupFiles: ['./setup.node.ts'],
},
},
],
},
})
```
## Browser + Node Projects
```ts
defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'browser',
include: ['tests/browser/**/*.test.ts'],
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
},
},
},
],
},
})
```
## Shared Configuration
```ts
// vitest.shared.ts
export const sharedConfig = {
testTimeout: 10000,
setupFiles: ['./tests/setup.ts'],
}
// vitest.config.ts
import { sharedConfig } from './vitest.shared'
defineConfig({
test: {
projects: [
{
test: {
...sharedConfig,
name: 'unit',
include: ['tests/unit/**/*.test.ts'],
},
},
{
test: {
...sharedConfig,
name: 'e2e',
include: ['tests/e2e/**/*.test.ts'],
},
},
],
},
})
```
## Project-Specific Dependencies
Each project can have different dependencies inlined:
```ts
defineConfig({
test: {
projects: [
{
test: {
name: 'project-a',
server: {
deps: {
inline: ['package-a'],
},
},
},
},
],
},
})
```
## Running Specific Projects
```bash
# Run specific project
vitest --project unit
vitest --project integration
# Multiple projects
vitest --project unit --project e2e
# Exclude project
vitest --project.ignore browser
```
## Providing Values to Projects
Share values from config to tests:
```ts
// vitest.config.ts
defineConfig({
test: {
projects: [
{
test: {
name: 'staging',
provide: {
apiUrl: 'https://staging.api.com',
debug: true,
},
},
},
{
test: {
name: 'production',
provide: {
apiUrl: 'https://api.com',
debug: false,
},
},
},
],
},
})
// In tests, use inject
import { inject } from 'vitest'
test('uses correct api', () => {
const url = inject('apiUrl')
expect(url).toContain('api.com')
})
```
## With Fixtures
```ts
const test = base.extend({
apiUrl: ['/default', { injected: true }],
})
test('uses injected url', ({ apiUrl }) => {
// apiUrl comes from project's provide config
})
```
## Project Isolation
Each project runs in its own thread pool by default:
```ts
defineConfig({
test: {
projects: [
{
test: {
name: 'isolated',
isolate: true, // Full isolation
pool: 'forks',
},
},
],
},
})
```
## Global Setup per Project
```ts
defineConfig({
test: {
projects: [
{
test: {
name: 'with-db',
globalSetup: ['./tests/db-setup.ts'],
},
},
],
},
})
```
## Key Points
- Projects run in same Vitest process
- Each project can have different environment, config
- Use glob patterns for monorepo packages
- Run specific projects with `--project` flag
- Use `provide` to inject config values into tests
- Projects inherit from root config unless overridden
<!--
Source references:
- https://vitest.dev/guide/projects.html
-->

View File

@@ -0,0 +1,237 @@
---
name: type-testing
description: Test TypeScript types with expectTypeOf and assertType
---
# Type Testing
Test TypeScript types without runtime execution.
## Setup
Type tests use `.test-d.ts` extension:
```ts
// math.test-d.ts
import { expectTypeOf } from 'vitest'
import { add } from './math'
test('add returns number', () => {
expectTypeOf(add).returns.toBeNumber()
})
```
## Configuration
```ts
defineConfig({
test: {
typecheck: {
enabled: true,
// Only type check
only: false,
// Checker: 'tsc' or 'vue-tsc'
checker: 'tsc',
// Include patterns
include: ['**/*.test-d.ts'],
// tsconfig to use
tsconfig: './tsconfig.json',
},
},
})
```
## expectTypeOf API
```ts
import { expectTypeOf } from 'vitest'
// Basic type checks
expectTypeOf<string>().toBeString()
expectTypeOf<number>().toBeNumber()
expectTypeOf<boolean>().toBeBoolean()
expectTypeOf<null>().toBeNull()
expectTypeOf<undefined>().toBeUndefined()
expectTypeOf<void>().toBeVoid()
expectTypeOf<never>().toBeNever()
expectTypeOf<any>().toBeAny()
expectTypeOf<unknown>().toBeUnknown()
expectTypeOf<object>().toBeObject()
expectTypeOf<Function>().toBeFunction()
expectTypeOf<[]>().toBeArray()
expectTypeOf<symbol>().toBeSymbol()
```
## Value Type Checking
```ts
const value = 'hello'
expectTypeOf(value).toBeString()
const obj = { name: 'test', count: 42 }
expectTypeOf(obj).toMatchTypeOf<{ name: string }>()
expectTypeOf(obj).toHaveProperty('name')
```
## Function Types
```ts
function greet(name: string): string {
return `Hello, ${name}`
}
expectTypeOf(greet).toBeFunction()
expectTypeOf(greet).parameters.toEqualTypeOf<[string]>()
expectTypeOf(greet).returns.toBeString()
// Parameter checking
expectTypeOf(greet).parameter(0).toBeString()
```
## Object Types
```ts
interface User {
id: number
name: string
email?: string
}
expectTypeOf<User>().toHaveProperty('id')
expectTypeOf<User>().toHaveProperty('name').toBeString()
// Check shape
expectTypeOf({ id: 1, name: 'test' }).toMatchTypeOf<User>()
```
## Equality vs Matching
```ts
interface A { x: number }
interface B { x: number; y: string }
// toMatchTypeOf - subset matching
expectTypeOf<B>().toMatchTypeOf<A>() // B extends A
// toEqualTypeOf - exact match
expectTypeOf<A>().not.toEqualTypeOf<B>() // Not exact match
expectTypeOf<A>().toEqualTypeOf<{ x: number }>() // Exact match
```
## Branded Types
```ts
type UserId = number & { __brand: 'UserId' }
type PostId = number & { __brand: 'PostId' }
expectTypeOf<UserId>().not.toEqualTypeOf<PostId>()
expectTypeOf<UserId>().not.toEqualTypeOf<number>()
```
## Generic Types
```ts
function identity<T>(value: T): T {
return value
}
expectTypeOf(identity<string>).returns.toBeString()
expectTypeOf(identity<number>).returns.toBeNumber()
```
## Nullable Types
```ts
type MaybeString = string | null | undefined
expectTypeOf<MaybeString>().toBeNullable()
expectTypeOf<string>().not.toBeNullable()
```
## assertType
Assert a value matches a type (no assertion at runtime):
```ts
import { assertType } from 'vitest'
function getUser(): User | null {
return { id: 1, name: 'test' }
}
test('returns user', () => {
const result = getUser()
// @ts-expect-error - should fail type check
assertType<string>(result)
// Correct type
assertType<User | null>(result)
})
```
## Using @ts-expect-error
Test that code produces type error:
```ts
test('rejects wrong types', () => {
function requireString(s: string) {}
// @ts-expect-error - number not assignable to string
requireString(123)
})
```
## Running Type Tests
```bash
# Run type tests
vitest typecheck
# Run alongside unit tests
vitest --typecheck
# Type tests only
vitest --typecheck.only
```
## Mixed Test Files
Combine runtime and type tests:
```ts
// user.test.ts
import { describe, expect, expectTypeOf, test } from 'vitest'
import { createUser } from './user'
describe('createUser', () => {
test('runtime: creates user', () => {
const user = createUser('John')
expect(user.name).toBe('John')
})
test('types: returns User type', () => {
expectTypeOf(createUser).returns.toMatchTypeOf<{ name: string }>()
})
})
```
## Key Points
- Use `.test-d.ts` for type-only tests
- `expectTypeOf` for type assertions
- `toMatchTypeOf` for subset matching
- `toEqualTypeOf` for exact type matching
- Use `@ts-expect-error` to test type errors
- Run with `vitest typecheck` or `--typecheck`
<!--
Source references:
- https://vitest.dev/guide/testing-types.html
- https://vitest.dev/api/expect-typeof.html
-->

View File

@@ -0,0 +1,249 @@
---
name: vi-utilities
description: vi helper for mocking, timers, utilities
---
# Vi Utilities
The `vi` helper provides mocking and utility functions.
```ts
import { vi } from 'vitest'
```
## Mock Functions
```ts
// Create mock
const fn = vi.fn()
const fnWithImpl = vi.fn((x) => x * 2)
// Check if mock
vi.isMockFunction(fn) // true
// Mock methods
fn.mockReturnValue(42)
fn.mockReturnValueOnce(1)
fn.mockResolvedValue(data)
fn.mockRejectedValue(error)
fn.mockImplementation(() => 'result')
fn.mockImplementationOnce(() => 'once')
// Clear/reset
fn.mockClear() // Clear call history
fn.mockReset() // Clear history + implementation
fn.mockRestore() // Restore original (for spies)
```
## Spying
```ts
const obj = { method: () => 'original' }
const spy = vi.spyOn(obj, 'method')
obj.method()
expect(spy).toHaveBeenCalled()
// Mock implementation
spy.mockReturnValue('mocked')
// Spy on getter/setter
vi.spyOn(obj, 'prop', 'get').mockReturnValue('value')
```
## Module Mocking
```ts
// Hoisted to top of file
vi.mock('./module', () => ({
fn: vi.fn(),
}))
// Partial mock
vi.mock('./module', async (importOriginal) => ({
...(await importOriginal()),
specificFn: vi.fn(),
}))
// Spy mode - keep implementation
vi.mock('./module', { spy: true })
// Import actual module inside mock
const actual = await vi.importActual('./module')
// Import as mock
const mocked = await vi.importMock('./module')
```
## Dynamic Mocking
```ts
// Not hoisted - use with dynamic imports
vi.doMock('./config', () => ({ key: 'value' }))
const config = await import('./config')
// Unmock
vi.doUnmock('./config')
vi.unmock('./module') // Hoisted
```
## Reset Modules
```ts
// Clear module cache
vi.resetModules()
// Wait for dynamic imports
await vi.dynamicImportSettled()
```
## Fake Timers
```ts
vi.useFakeTimers()
setTimeout(() => console.log('done'), 1000)
// Advance time
vi.advanceTimersByTime(1000)
vi.advanceTimersByTimeAsync(1000) // For async callbacks
vi.advanceTimersToNextTimer()
vi.advanceTimersToNextFrame() // requestAnimationFrame
// Run all timers
vi.runAllTimers()
vi.runAllTimersAsync()
vi.runOnlyPendingTimers()
// Clear timers
vi.clearAllTimers()
// Check state
vi.getTimerCount()
vi.isFakeTimers()
// Restore
vi.useRealTimers()
```
## Mock Date/Time
```ts
vi.setSystemTime(new Date('2024-01-01'))
expect(new Date().getFullYear()).toBe(2024)
vi.getMockedSystemTime() // Get mocked date
vi.getRealSystemTime() // Get real time (ms)
```
## Global/Env Mocking
```ts
// Stub global
vi.stubGlobal('fetch', vi.fn())
vi.unstubAllGlobals()
// Stub environment
vi.stubEnv('API_KEY', 'test')
vi.stubEnv('NODE_ENV', 'test')
vi.unstubAllEnvs()
```
## Hoisted Code
Run code before imports:
```ts
const mock = vi.hoisted(() => vi.fn())
vi.mock('./module', () => ({
fn: mock, // Can reference hoisted variable
}))
```
## Waiting Utilities
```ts
// Wait for callback to succeed
await vi.waitFor(async () => {
const el = document.querySelector('.loaded')
expect(el).toBeTruthy()
}, { timeout: 5000, interval: 100 })
// Wait for truthy value
const element = await vi.waitUntil(
() => document.querySelector('.loaded'),
{ timeout: 5000 }
)
```
## Mock Object
Mock all methods of an object:
```ts
const original = {
method: () => 'real',
nested: { fn: () => 'nested' },
}
const mocked = vi.mockObject(original)
mocked.method() // undefined (mocked)
mocked.method.mockReturnValue('mocked')
// Spy mode
const spied = vi.mockObject(original, { spy: true })
spied.method() // 'real'
expect(spied.method).toHaveBeenCalled()
```
## Test Configuration
```ts
vi.setConfig({
testTimeout: 10_000,
hookTimeout: 10_000,
})
vi.resetConfig()
```
## Global Mock Management
```ts
vi.clearAllMocks() // Clear all mock call history
vi.resetAllMocks() // Reset + clear implementation
vi.restoreAllMocks() // Restore originals (spies)
```
## vi.mocked Type Helper
TypeScript helper for mocked values:
```ts
import { myFn } from './module'
vi.mock('./module')
// Type as mock
vi.mocked(myFn).mockReturnValue('typed')
// Deep mocking
vi.mocked(myModule, { deep: true })
// Partial mock typing
vi.mocked(fn, { partial: true }).mockResolvedValue({ ok: true })
```
## Key Points
- `vi.mock` is hoisted - use `vi.doMock` for dynamic mocking
- `vi.hoisted` lets you reference variables in mock factories
- Use `vi.spyOn` to spy on existing methods
- Fake timers require explicit setup and teardown
- `vi.waitFor` retries until assertion passes
<!--
Source references:
- https://vitest.dev/api/vi.html
-->

View File

@@ -0,0 +1,166 @@
---
name: vitest-cli
description: Command line interface commands and options
---
# Command Line Interface
## Commands
### `vitest`
Start Vitest in watch mode (dev) or run mode (CI):
```bash
vitest # Watch mode in dev, run mode in CI
vitest foobar # Run tests containing "foobar" in path
vitest basic/foo.test.ts:10 # Run specific test by file and line number
```
### `vitest run`
Run tests once without watch mode:
```bash
vitest run
vitest run --coverage
```
### `vitest watch`
Explicitly start watch mode:
```bash
vitest watch
```
### `vitest related`
Run tests that import specific files (useful with lint-staged):
```bash
vitest related src/index.ts src/utils.ts --run
```
### `vitest bench`
Run only benchmark tests:
```bash
vitest bench
```
### `vitest list`
List all matching tests without running them:
```bash
vitest list # List test names
vitest list --json # Output as JSON
vitest list --filesOnly # List only test files
```
### `vitest init`
Initialize project setup:
```bash
vitest init browser # Set up browser testing
```
## Common Options
```bash
# Configuration
--config <path> # Path to config file
--project <name> # Run specific project
# Filtering
--testNamePattern, -t # Run tests matching pattern
--changed # Run tests for changed files
--changed HEAD~1 # Tests for last commit changes
# Reporters
--reporter <name> # default, verbose, dot, json, html
--reporter=html --outputFile=report.html
# Coverage
--coverage # Enable coverage
--coverage.provider v8 # Use v8 provider
--coverage.reporter text,html
# Execution
--shard <index>/<count> # Split tests across machines
--bail <n> # Stop after n failures
--retry <n> # Retry failed tests n times
--sequence.shuffle # Randomize test order
# Watch mode
--no-watch # Disable watch mode
--standalone # Start without running tests
# Environment
--environment <env> # jsdom, happy-dom, node
--globals # Enable global APIs
# Debugging
--inspect # Enable Node inspector
--inspect-brk # Break on start
# Output
--silent # Suppress console output
--no-color # Disable colors
```
## Package.json Scripts
```json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
}
}
```
## Sharding for CI
Split tests across multiple machines:
```bash
# Machine 1
vitest run --shard=1/3 --reporter=blob
# Machine 2
vitest run --shard=2/3 --reporter=blob
# Machine 3
vitest run --shard=3/3 --reporter=blob
# Merge reports
vitest --merge-reports --reporter=junit
```
## Watch Mode Keyboard Shortcuts
In watch mode, press:
- `a` - Run all tests
- `f` - Run only failed tests
- `u` - Update snapshots
- `p` - Filter by filename pattern
- `t` - Filter by test name pattern
- `q` - Quit
## Key Points
- Watch mode is default in dev, run mode in CI (when `process.env.CI` is set)
- Use `--run` flag to ensure single run (important for lint-staged)
- Both camelCase (`--testTimeout`) and kebab-case (`--test-timeout`) work
- Boolean options can be negated with `--no-` prefix
<!--
Source references:
- https://vitest.dev/guide/cli.html
-->

View File

@@ -0,0 +1,174 @@
---
name: vitest-configuration
description: Configure Vitest with vite.config.ts or vitest.config.ts
---
# Configuration
Vitest reads configuration from `vitest.config.ts` or `vite.config.ts`. It shares the same config format as Vite.
## Basic Setup
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// test options
},
})
```
## Using with Existing Vite Config
Add Vitest types reference and use the `test` property:
```ts
// vite.config.ts
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
},
})
```
## Merging Configs
If you have separate config files, use `mergeConfig`:
```ts
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(viteConfig, defineConfig({
test: {
environment: 'jsdom',
},
}))
```
## Common Options
```ts
defineConfig({
test: {
// Enable global APIs (describe, it, expect) without imports
globals: true,
// Test environment: 'node', 'jsdom', 'happy-dom'
environment: 'node',
// Setup files to run before each test file
setupFiles: ['./tests/setup.ts'],
// Include patterns for test files
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
// Exclude patterns
exclude: ['**/node_modules/**', '**/dist/**'],
// Test timeout in ms
testTimeout: 5000,
// Hook timeout in ms
hookTimeout: 10000,
// Enable watch mode by default
watch: true,
// Coverage configuration
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'html'],
include: ['src/**/*.ts'],
},
// Run tests in isolation (each file in separate process)
isolate: true,
// Pool for running tests: 'threads', 'forks', 'vmThreads'
pool: 'threads',
// Number of threads/processes
poolOptions: {
threads: {
maxThreads: 4,
minThreads: 1,
},
},
// Automatically clear mocks between tests
clearMocks: true,
// Restore mocks between tests
restoreMocks: true,
// Retry failed tests
retry: 0,
// Stop after first failure
bail: 0,
},
})
```
## Conditional Configuration
Use `mode` or `process.env.VITEST` for test-specific config:
```ts
export default defineConfig(({ mode }) => ({
plugins: mode === 'test' ? [] : [myPlugin()],
test: {
// test options
},
}))
```
## Projects (Monorepos)
Run different configurations in the same Vitest process:
```ts
defineConfig({
test: {
projects: [
'packages/*',
{
test: {
name: 'unit',
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'integration',
include: ['tests/integration/**/*.test.ts'],
environment: 'jsdom',
},
},
],
},
})
```
## Key Points
- Vitest uses Vite's transformation pipeline - same `resolve.alias`, plugins work
- `vitest.config.ts` takes priority over `vite.config.ts`
- Use `--config` flag to specify a custom config path
- `process.env.VITEST` is set to `true` when running tests
- Test config uses `test` property, rest is Vite config
<!--
Source references:
- https://vitest.dev/guide/#configuring-vitest
- https://vitest.dev/config/
-->

View File

@@ -0,0 +1,193 @@
---
name: describe-api
description: describe/suite for grouping tests into logical blocks
---
# Describe API
Group related tests into suites for organization and shared setup.
## Basic Usage
```ts
import { describe, expect, test } from 'vitest'
describe('Math', () => {
test('adds numbers', () => {
expect(1 + 1).toBe(2)
})
test('subtracts numbers', () => {
expect(3 - 1).toBe(2)
})
})
// Alias: suite
import { suite } from 'vitest'
suite('equivalent to describe', () => {})
```
## Nested Suites
```ts
describe('User', () => {
describe('when logged in', () => {
test('shows dashboard', () => {})
test('can update profile', () => {})
})
describe('when logged out', () => {
test('shows login page', () => {})
})
})
```
## Suite Options
```ts
// All tests inherit options
describe('slow tests', { timeout: 30_000 }, () => {
test('test 1', () => {}) // 30s timeout
test('test 2', () => {}) // 30s timeout
})
```
## Suite Modifiers
### Skip Suites
```ts
describe.skip('skipped suite', () => {
test('wont run', () => {})
})
// Conditional
describe.skipIf(process.env.CI)('not in CI', () => {})
describe.runIf(!process.env.CI)('only local', () => {})
```
### Focus Suites
```ts
describe.only('only this suite runs', () => {
test('runs', () => {})
})
```
### Todo Suites
```ts
describe.todo('implement later')
```
### Concurrent Suites
```ts
// All tests run in parallel
describe.concurrent('parallel tests', () => {
test('test 1', async ({ expect }) => {})
test('test 2', async ({ expect }) => {})
})
```
### Sequential in Concurrent
```ts
describe.concurrent('parallel', () => {
test('concurrent 1', async () => {})
describe.sequential('must be sequential', () => {
test('step 1', async () => {})
test('step 2', async () => {})
})
})
```
### Shuffle Tests
```ts
describe.shuffle('random order', () => {
test('test 1', () => {})
test('test 2', () => {})
test('test 3', () => {})
})
// Or with option
describe('random', { shuffle: true }, () => {})
```
## Parameterized Suites
### describe.each
```ts
describe.each([
{ name: 'Chrome', version: 100 },
{ name: 'Firefox', version: 90 },
])('$name browser', ({ name, version }) => {
test('has version', () => {
expect(version).toBeGreaterThan(0)
})
})
```
### describe.for
```ts
describe.for([
['Chrome', 100],
['Firefox', 90],
])('%s browser', ([name, version]) => {
test('has version', () => {
expect(version).toBeGreaterThan(0)
})
})
```
## Hooks in Suites
```ts
describe('Database', () => {
let db
beforeAll(async () => {
db = await createDb()
})
afterAll(async () => {
await db.close()
})
beforeEach(async () => {
await db.clear()
})
test('insert works', async () => {
await db.insert({ name: 'test' })
expect(await db.count()).toBe(1)
})
})
```
## Modifier Combinations
All modifiers can be chained:
```ts
describe.skip.concurrent('skipped concurrent', () => {})
describe.only.shuffle('only and shuffled', () => {})
describe.concurrent.skip('equivalent', () => {})
```
## Key Points
- Top-level tests belong to an implicit file suite
- Nested suites inherit parent's options (timeout, retry, etc.)
- Hooks are scoped to their suite and nested suites
- Use `describe.concurrent` with context's `expect` for snapshots
- Shuffle order depends on `sequence.seed` config
<!--
Source references:
- https://vitest.dev/api/describe.html
-->

View File

@@ -0,0 +1,219 @@
---
name: expect-api
description: Assertions with matchers, asymmetric matchers, and custom matchers
---
# Expect API
Vitest uses Chai assertions with Jest-compatible API.
## Basic Assertions
```ts
import { expect, test } from 'vitest'
test('assertions', () => {
// Equality
expect(1 + 1).toBe(2) // Strict equality (===)
expect({ a: 1 }).toEqual({ a: 1 }) // Deep equality
// Truthiness
expect(true).toBeTruthy()
expect(false).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('value').toBeDefined()
// Numbers
expect(10).toBeGreaterThan(5)
expect(10).toBeGreaterThanOrEqual(10)
expect(5).toBeLessThan(10)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5)
// Strings
expect('hello world').toMatch(/world/)
expect('hello').toContain('ell')
// Arrays
expect([1, 2, 3]).toContain(2)
expect([{ a: 1 }]).toContainEqual({ a: 1 })
expect([1, 2, 3]).toHaveLength(3)
// Objects
expect({ a: 1, b: 2 }).toHaveProperty('a')
expect({ a: 1, b: 2 }).toHaveProperty('a', 1)
expect({ a: { b: 1 } }).toHaveProperty('a.b', 1)
expect({ a: 1 }).toMatchObject({ a: 1 })
// Types
expect('string').toBeTypeOf('string')
expect(new Date()).toBeInstanceOf(Date)
})
```
## Negation
```ts
expect(1).not.toBe(2)
expect({ a: 1 }).not.toEqual({ a: 2 })
```
## Error Assertions
```ts
// Sync errors - wrap in function
expect(() => throwError()).toThrow()
expect(() => throwError()).toThrow('message')
expect(() => throwError()).toThrow(/pattern/)
expect(() => throwError()).toThrow(CustomError)
// Async errors - use rejects
await expect(asyncThrow()).rejects.toThrow('error')
```
## Promise Assertions
```ts
// Resolves
await expect(Promise.resolve(1)).resolves.toBe(1)
await expect(fetchData()).resolves.toEqual({ data: true })
// Rejects
await expect(Promise.reject('error')).rejects.toBe('error')
await expect(failingFetch()).rejects.toThrow()
```
## Spy/Mock Assertions
```ts
const fn = vi.fn()
fn('arg1', 'arg2')
fn('arg3')
expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(2)
expect(fn).toHaveBeenCalledWith('arg1', 'arg2')
expect(fn).toHaveBeenLastCalledWith('arg3')
expect(fn).toHaveBeenNthCalledWith(1, 'arg1', 'arg2')
expect(fn).toHaveReturned()
expect(fn).toHaveReturnedWith(value)
```
## Asymmetric Matchers
Use inside `toEqual`, `toHaveBeenCalledWith`, etc:
```ts
expect({ id: 1, name: 'test' }).toEqual({
id: expect.any(Number),
name: expect.any(String),
})
expect({ a: 1, b: 2, c: 3 }).toEqual(
expect.objectContaining({ a: 1 })
)
expect([1, 2, 3, 4]).toEqual(
expect.arrayContaining([1, 3])
)
expect('hello world').toEqual(
expect.stringContaining('world')
)
expect('hello world').toEqual(
expect.stringMatching(/world$/)
)
expect({ value: null }).toEqual({
value: expect.anything() // Matches anything except null/undefined
})
// Negate with expect.not
expect([1, 2]).toEqual(
expect.not.arrayContaining([3])
)
```
## Soft Assertions
Continue test after failure:
```ts
expect.soft(1).toBe(2) // Marks test failed but continues
expect.soft(2).toBe(3) // Also runs
// All failures reported at end
```
## Poll Assertions
Retry until passes:
```ts
await expect.poll(() => fetchStatus()).toBe('ready')
await expect.poll(
() => document.querySelector('.element'),
{ interval: 100, timeout: 5000 }
).toBeTruthy()
```
## Assertion Count
```ts
test('async assertions', async () => {
expect.assertions(2) // Exactly 2 assertions must run
await doAsync((data) => {
expect(data).toBeDefined()
expect(data.id).toBe(1)
})
})
test('at least one', () => {
expect.hasAssertions() // At least 1 assertion must run
})
```
## Extending Matchers
```ts
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling
return {
pass,
message: () =>
`expected ${received} to be within range ${floor} - ${ceiling}`,
}
},
})
test('custom matcher', () => {
expect(100).toBeWithinRange(90, 110)
})
```
## Snapshot Assertions
```ts
expect(data).toMatchSnapshot()
expect(data).toMatchInlineSnapshot(`{ "id": 1 }`)
await expect(result).toMatchFileSnapshot('./expected.json')
expect(() => throw new Error('fail')).toThrowErrorMatchingSnapshot()
```
## Key Points
- Use `toBe` for primitives, `toEqual` for objects/arrays
- `toStrictEqual` checks undefined properties and array sparseness
- Always `await` async assertions (`resolves`, `rejects`, `poll`)
- Use context's `expect` in concurrent tests for correct tracking
- `toThrow` requires wrapping sync code in a function
<!--
Source references:
- https://vitest.dev/api/expect.html
-->

View File

@@ -0,0 +1,244 @@
---
name: lifecycle-hooks
description: beforeEach, afterEach, beforeAll, afterAll, and around hooks
---
# Lifecycle Hooks
## Basic Hooks
```ts
import { afterAll, afterEach, beforeAll, beforeEach, test } from 'vitest'
beforeAll(async () => {
// Runs once before all tests in file/suite
await setupDatabase()
})
afterAll(async () => {
// Runs once after all tests in file/suite
await teardownDatabase()
})
beforeEach(async () => {
// Runs before each test
await clearTestData()
})
afterEach(async () => {
// Runs after each test
await cleanupMocks()
})
```
## Cleanup Return Pattern
Return cleanup function from `before*` hooks:
```ts
beforeAll(async () => {
const server = await startServer()
// Returned function runs as afterAll
return async () => {
await server.close()
}
})
beforeEach(async () => {
const connection = await connect()
// Runs as afterEach
return () => connection.close()
})
```
## Scoped Hooks
Hooks apply to current suite and nested suites:
```ts
describe('outer', () => {
beforeEach(() => console.log('outer before'))
test('test 1', () => {}) // outer before → test
describe('inner', () => {
beforeEach(() => console.log('inner before'))
test('test 2', () => {}) // outer before → inner before → test
})
})
```
## Hook Timeout
```ts
beforeAll(async () => {
await slowSetup()
}, 30_000) // 30 second timeout
```
## Around Hooks
Wrap tests with setup/teardown context:
```ts
import { aroundEach, test } from 'vitest'
// Wrap each test in database transaction
aroundEach(async (runTest) => {
await db.beginTransaction()
await runTest() // Must be called!
await db.rollback()
})
test('insert user', async () => {
await db.insert({ name: 'Alice' })
// Automatically rolled back after test
})
```
### aroundAll
Wrap entire suite:
```ts
import { aroundAll, test } from 'vitest'
aroundAll(async (runSuite) => {
console.log('before all tests')
await runSuite() // Must be called!
console.log('after all tests')
})
```
### Multiple Around Hooks
Nested like onion layers:
```ts
aroundEach(async (runTest) => {
console.log('outer before')
await runTest()
console.log('outer after')
})
aroundEach(async (runTest) => {
console.log('inner before')
await runTest()
console.log('inner after')
})
// Order: outer before → inner before → test → inner after → outer after
```
## Test Hooks
Inside test body:
```ts
import { onTestFailed, onTestFinished, test } from 'vitest'
test('with cleanup', () => {
const db = connect()
// Runs after test finishes (pass or fail)
onTestFinished(() => db.close())
// Only runs if test fails
onTestFailed(({ task }) => {
console.log('Failed:', task.result?.errors)
})
db.query('SELECT * FROM users')
})
```
### Reusable Cleanup Pattern
```ts
function useTestDb() {
const db = connect()
onTestFinished(() => db.close())
return db
}
test('query users', () => {
const db = useTestDb()
expect(db.query('SELECT * FROM users')).toBeDefined()
})
test('query orders', () => {
const db = useTestDb() // Fresh connection, auto-closed
expect(db.query('SELECT * FROM orders')).toBeDefined()
})
```
## Concurrent Test Hooks
For concurrent tests, use context's hooks:
```ts
test.concurrent('concurrent', ({ onTestFinished }) => {
const resource = allocate()
onTestFinished(() => resource.release())
})
```
## Extended Test Hooks
With `test.extend`, hooks are type-aware:
```ts
const test = base.extend<{ db: Database }>({
db: async ({}, use) => {
const db = await createDb()
await use(db)
await db.close()
},
})
// These hooks know about `db` fixture
test.beforeEach(({ db }) => {
db.seed()
})
test.afterEach(({ db }) => {
db.clear()
})
```
## Hook Execution Order
Default order (stack):
1. `beforeAll` (in order)
2. `beforeEach` (in order)
3. Test
4. `afterEach` (reverse order)
5. `afterAll` (reverse order)
Configure with `sequence.hooks`:
```ts
defineConfig({
test: {
sequence: {
hooks: 'list', // 'stack' (default), 'list', 'parallel'
},
},
})
```
## Key Points
- Hooks are not called during type checking
- Return cleanup function from `before*` to avoid `after*` duplication
- `aroundEach`/`aroundAll` must call `runTest()`/`runSuite()`
- `onTestFinished` always runs, even if test fails
- Use context hooks for concurrent tests
<!--
Source references:
- https://vitest.dev/api/hooks.html
-->

View File

@@ -0,0 +1,233 @@
---
name: test-api
description: test/it function for defining tests with modifiers
---
# Test API
## Basic Test
```ts
import { expect, test } from 'vitest'
test('adds numbers', () => {
expect(1 + 1).toBe(2)
})
// Alias: it
import { it } from 'vitest'
it('works the same', () => {
expect(true).toBe(true)
})
```
## Async Tests
```ts
test('async test', async () => {
const result = await fetchData()
expect(result).toBeDefined()
})
// Promises are automatically awaited
test('returns promise', () => {
return fetchData().then(result => {
expect(result).toBeDefined()
})
})
```
## Test Options
```ts
// Timeout (default: 5000ms)
test('slow test', async () => {
// ...
}, 10_000)
// Or with options object
test('with options', { timeout: 10_000, retry: 2 }, async () => {
// ...
})
```
## Test Modifiers
### Skip Tests
```ts
test.skip('skipped test', () => {
// Won't run
})
// Conditional skip
test.skipIf(process.env.CI)('not in CI', () => {})
test.runIf(process.env.CI)('only in CI', () => {})
// Dynamic skip via context
test('dynamic skip', ({ skip }) => {
skip(someCondition, 'reason')
// ...
})
```
### Focus Tests
```ts
test.only('only this runs', () => {
// Other tests in file are skipped
})
```
### Todo Tests
```ts
test.todo('implement later')
test.todo('with body', () => {
// Not run, shows in report
})
```
### Failing Tests
```ts
test.fails('expected to fail', () => {
expect(1).toBe(2) // Test passes because assertion fails
})
```
### Concurrent Tests
```ts
// Run tests in parallel
test.concurrent('test 1', async ({ expect }) => {
// Use context.expect for concurrent tests
expect(await fetch1()).toBe('result')
})
test.concurrent('test 2', async ({ expect }) => {
expect(await fetch2()).toBe('result')
})
```
### Sequential Tests
```ts
// Force sequential in concurrent context
test.sequential('must run alone', async () => {})
```
## Parameterized Tests
### test.each
```ts
test.each([
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
])('add(%i, %i) = %i', (a, b, expected) => {
expect(a + b).toBe(expected)
})
// With objects
test.each([
{ a: 1, b: 1, expected: 2 },
{ a: 1, b: 2, expected: 3 },
])('add($a, $b) = $expected', ({ a, b, expected }) => {
expect(a + b).toBe(expected)
})
// Template literal
test.each`
a | b | expected
${1} | ${1} | ${2}
${1} | ${2} | ${3}
`('add($a, $b) = $expected', ({ a, b, expected }) => {
expect(a + b).toBe(expected)
})
```
### test.for
Preferred over `.each` - doesn't spread arrays:
```ts
test.for([
[1, 1, 2],
[1, 2, 3],
])('add(%i, %i) = %i', ([a, b, expected], { expect }) => {
// Second arg is TestContext
expect(a + b).toBe(expected)
})
```
## Test Context
First argument provides context utilities:
```ts
test('with context', ({ expect, skip, task }) => {
console.log(task.name) // Test name
skip(someCondition) // Skip dynamically
expect(1).toBe(1) // Context-bound expect
})
```
## Custom Test with Fixtures
```ts
import { test as base } from 'vitest'
const test = base.extend({
db: async ({}, use) => {
const db = await createDb()
await use(db)
await db.close()
},
})
test('query', async ({ db }) => {
const users = await db.query('SELECT * FROM users')
expect(users).toBeDefined()
})
```
## Retry Configuration
```ts
test('flaky test', { retry: 3 }, async () => {
// Retries up to 3 times on failure
})
// Advanced retry options
test('with delay', {
retry: {
count: 3,
delay: 1000,
condition: /timeout/i, // Only retry on timeout errors
},
}, async () => {})
```
## Tags
```ts
test('database test', { tags: ['db', 'slow'] }, async () => {})
// Run with: vitest --tags db
```
## Key Points
- Tests with no body are marked as `todo`
- `test.only` throws in CI unless `allowOnly: true`
- Use context's `expect` for concurrent tests and snapshots
- Function name is used as test name if passed as first arg
<!--
Source references:
- https://vitest.dev/api/test.html
-->

View File

@@ -0,0 +1,250 @@
---
name: concurrency-parallelism
description: Concurrent tests, parallel execution, and sharding
---
# Concurrency & Parallelism
## File Parallelism
By default, Vitest runs test files in parallel across workers:
```ts
defineConfig({
test: {
// Run files in parallel (default: true)
fileParallelism: true,
// Number of worker threads
maxWorkers: 4,
minWorkers: 1,
// Pool type: 'threads', 'forks', 'vmThreads'
pool: 'threads',
},
})
```
## Concurrent Tests
Run tests within a file in parallel:
```ts
// Individual concurrent tests
test.concurrent('test 1', async ({ expect }) => {
expect(await fetch1()).toBe('result')
})
test.concurrent('test 2', async ({ expect }) => {
expect(await fetch2()).toBe('result')
})
// All tests in suite concurrent
describe.concurrent('parallel suite', () => {
test('test 1', async ({ expect }) => {})
test('test 2', async ({ expect }) => {})
})
```
**Important:** Use `{ expect }` from context for concurrent tests.
## Sequential in Concurrent Context
Force sequential execution:
```ts
describe.concurrent('mostly parallel', () => {
test('parallel 1', async () => {})
test('parallel 2', async () => {})
test.sequential('must run alone 1', async () => {})
test.sequential('must run alone 2', async () => {})
})
// Or entire suite
describe.sequential('sequential suite', () => {
test('first', () => {})
test('second', () => {})
})
```
## Max Concurrency
Limit concurrent tests:
```ts
defineConfig({
test: {
maxConcurrency: 5, // Max concurrent tests per file
},
})
```
## Isolation
Each file runs in isolated environment by default:
```ts
defineConfig({
test: {
// Disable isolation for faster runs (less safe)
isolate: false,
},
})
```
## Sharding
Split tests across machines:
```bash
# Machine 1
vitest run --shard=1/3
# Machine 2
vitest run --shard=2/3
# Machine 3
vitest run --shard=3/3
```
### CI Example (GitHub Actions)
```yaml
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3]
steps:
- run: vitest run --shard=${{ matrix.shard }}/3 --reporter=blob
merge:
needs: test
steps:
- run: vitest --merge-reports --reporter=junit
```
### Merge Reports
```bash
# Each shard outputs blob
vitest run --shard=1/3 --reporter=blob --coverage
vitest run --shard=2/3 --reporter=blob --coverage
# Merge all blobs
vitest --merge-reports --reporter=json --coverage
```
## Test Sequence
Control test order:
```ts
defineConfig({
test: {
sequence: {
// Run tests in random order
shuffle: true,
// Seed for reproducible shuffle
seed: 12345,
// Hook execution order
hooks: 'stack', // 'stack', 'list', 'parallel'
// All tests concurrent by default
concurrent: true,
},
},
})
```
## Shuffle Tests
Randomize to catch hidden dependencies:
```ts
// Via CLI
vitest --sequence.shuffle
// Per suite
describe.shuffle('random order', () => {
test('test 1', () => {})
test('test 2', () => {})
test('test 3', () => {})
})
```
## Pool Options
### Threads (Default)
```ts
defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
maxThreads: 8,
minThreads: 2,
isolate: true,
},
},
},
})
```
### Forks
Better isolation, slower:
```ts
defineConfig({
test: {
pool: 'forks',
poolOptions: {
forks: {
maxForks: 4,
isolate: true,
},
},
},
})
```
### VM Threads
Full VM isolation per file:
```ts
defineConfig({
test: {
pool: 'vmThreads',
},
})
```
## Bail on Failure
Stop after first failure:
```bash
vitest --bail 1 # Stop after 1 failure
vitest --bail # Stop on first failure (same as --bail 1)
```
## Key Points
- Files run in parallel by default
- Use `.concurrent` for parallel tests within file
- Always use context's `expect` in concurrent tests
- Sharding splits tests across CI machines
- Use `--merge-reports` to combine sharded results
- Shuffle tests to find hidden dependencies
<!--
Source references:
- https://vitest.dev/guide/features.html#running-tests-concurrently
- https://vitest.dev/guide/improving-performance.html
-->

View File

@@ -0,0 +1,238 @@
---
name: test-context-fixtures
description: Test context, custom fixtures with test.extend
---
# Test Context & Fixtures
## Built-in Context
Every test receives context as first argument:
```ts
test('context', ({ task, expect, skip }) => {
console.log(task.name) // Test name
expect(1).toBe(1) // Context-bound expect
skip() // Skip test dynamically
})
```
### Context Properties
- `task` - Test metadata (name, file, etc.)
- `expect` - Expect bound to this test (important for concurrent tests)
- `skip(condition?, message?)` - Skip the test
- `onTestFinished(fn)` - Cleanup after test
- `onTestFailed(fn)` - Run on failure only
## Custom Fixtures with test.extend
Create reusable test utilities:
```ts
import { test as base } from 'vitest'
// Define fixture types
interface Fixtures {
db: Database
user: User
}
// Create extended test
export const test = base.extend<Fixtures>({
// Fixture with setup/teardown
db: async ({}, use) => {
const db = await createDatabase()
await use(db) // Provide to test
await db.close() // Cleanup
},
// Fixture depending on another fixture
user: async ({ db }, use) => {
const user = await db.createUser({ name: 'Test' })
await use(user)
await db.deleteUser(user.id)
},
})
```
Using fixtures:
```ts
test('query user', async ({ db, user }) => {
const found = await db.findUser(user.id)
expect(found).toEqual(user)
})
```
## Fixture Initialization
Fixtures only initialize when accessed:
```ts
const test = base.extend({
expensive: async ({}, use) => {
console.log('initializing') // Only runs if test uses it
await use('value')
},
})
test('no fixture', () => {}) // expensive not called
test('uses fixture', ({ expensive }) => {}) // expensive called
```
## Auto Fixtures
Run fixture for every test:
```ts
const test = base.extend({
setup: [
async ({}, use) => {
await globalSetup()
await use()
await globalTeardown()
},
{ auto: true } // Always run
],
})
```
## Scoped Fixtures
### File Scope
Initialize once per file:
```ts
const test = base.extend({
connection: [
async ({}, use) => {
const conn = await connect()
await use(conn)
await conn.close()
},
{ scope: 'file' }
],
})
```
### Worker Scope
Initialize once per worker:
```ts
const test = base.extend({
sharedResource: [
async ({}, use) => {
await use(globalResource)
},
{ scope: 'worker' }
],
})
```
## Injected Fixtures (from Config)
Override fixtures per project:
```ts
// test file
const test = base.extend({
apiUrl: ['/default', { injected: true }],
})
// vitest.config.ts
defineConfig({
test: {
projects: [
{
test: {
name: 'prod',
provide: { apiUrl: 'https://api.prod.com' },
},
},
],
},
})
```
## Scoped Values per Suite
Override fixture for specific suite:
```ts
const test = base.extend({
environment: 'development',
})
describe('production tests', () => {
test.scoped({ environment: 'production' })
test('uses production', ({ environment }) => {
expect(environment).toBe('production')
})
})
test('uses default', ({ environment }) => {
expect(environment).toBe('development')
})
```
## Extended Test Hooks
Type-aware hooks with fixtures:
```ts
const test = base.extend<{ db: Database }>({
db: async ({}, use) => {
const db = await createDb()
await use(db)
await db.close()
},
})
// Hooks know about fixtures
test.beforeEach(({ db }) => {
db.seed()
})
test.afterEach(({ db }) => {
db.clear()
})
```
## Composing Fixtures
Extend from another extended test:
```ts
// base-test.ts
export const test = base.extend<{ db: Database }>({
db: async ({}, use) => { /* ... */ },
})
// admin-test.ts
import { test as dbTest } from './base-test'
export const test = dbTest.extend<{ admin: User }>({
admin: async ({ db }, use) => {
const admin = await db.createAdmin()
await use(admin)
},
})
```
## Key Points
- Use `{ }` destructuring to access fixtures
- Fixtures are lazy - only initialize when accessed
- Return cleanup function from fixtures
- Use `{ auto: true }` for setup fixtures
- Use `{ scope: 'file' }` for expensive shared resources
- Fixtures compose - extend from extended tests
<!--
Source references:
- https://vitest.dev/guide/test-context.html
-->

View File

@@ -0,0 +1,207 @@
---
name: code-coverage
description: Code coverage with V8 or Istanbul providers
---
# Code Coverage
## Setup
```bash
# Run tests with coverage
vitest run --coverage
```
## Configuration
```ts
// vitest.config.ts
defineConfig({
test: {
coverage: {
// Provider: 'v8' (default, faster) or 'istanbul' (more compatible)
provider: 'v8',
// Enable coverage
enabled: true,
// Reporters
reporter: ['text', 'json', 'html'],
// Files to include
include: ['src/**/*.{ts,tsx}'],
// Files to exclude
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.test.ts',
],
// Report uncovered files
all: true,
// Thresholds
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
})
```
## Providers
### V8 (Default)
```bash
npm i -D @vitest/coverage-v8
```
- Faster, no pre-instrumentation
- Uses V8's native coverage
- Recommended for most projects
### Istanbul
```bash
npm i -D @vitest/coverage-istanbul
```
- Pre-instruments code
- Works in any JS runtime
- More overhead but widely compatible
## Reporters
```ts
coverage: {
reporter: [
'text', // Terminal output
'text-summary', // Summary only
'json', // JSON file
'html', // HTML report
'lcov', // For CI tools
'cobertura', // XML format
],
reportsDirectory: './coverage',
}
```
## Thresholds
Fail tests if coverage is below threshold:
```ts
coverage: {
thresholds: {
// Global thresholds
lines: 80,
functions: 75,
branches: 70,
statements: 80,
// Per-file thresholds
perFile: true,
// Auto-update thresholds (for gradual improvement)
autoUpdate: true,
},
}
```
## Ignoring Code
### V8
```ts
/* v8 ignore next -- @preserve */
function ignored() {
return 'not covered'
}
/* v8 ignore start -- @preserve */
// All code here ignored
/* v8 ignore stop -- @preserve */
```
### Istanbul
```ts
/* istanbul ignore next -- @preserve */
function ignored() {}
/* istanbul ignore if -- @preserve */
if (condition) {
// ignored
}
```
Note: `@preserve` keeps comments through esbuild.
## Package.json Scripts
```json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:coverage:watch": "vitest --coverage"
}
}
```
## Vitest UI Coverage
Enable HTML coverage in Vitest UI:
```ts
coverage: {
enabled: true,
reporter: ['text', 'html'],
}
```
Run with `vitest --ui` to view coverage visually.
## CI Integration
```yaml
# GitHub Actions
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
```
## Coverage with Sharding
Merge coverage from sharded runs:
```bash
vitest run --shard=1/3 --coverage --reporter=blob
vitest run --shard=2/3 --coverage --reporter=blob
vitest run --shard=3/3 --coverage --reporter=blob
vitest --merge-reports --coverage --reporter=json
```
## Key Points
- V8 is faster, Istanbul is more compatible
- Use `--coverage` flag or `coverage.enabled: true`
- Include `all: true` to see uncovered files
- Set thresholds to enforce minimum coverage
- Use `@preserve` comment to keep ignore hints
<!--
Source references:
- https://vitest.dev/guide/coverage.html
-->

View File

@@ -0,0 +1,211 @@
---
name: test-filtering
description: Filter tests by name, file patterns, and tags
---
# Test Filtering
## CLI Filtering
### By File Path
```bash
# Run files containing "user"
vitest user
# Multiple patterns
vitest user auth
# Specific file
vitest src/user.test.ts
# By line number
vitest src/user.test.ts:25
```
### By Test Name
```bash
# Tests matching pattern
vitest -t "login"
vitest --testNamePattern "should.*work"
# Regex patterns
vitest -t "/user|auth/"
```
## Changed Files
```bash
# Uncommitted changes
vitest --changed
# Since specific commit
vitest --changed HEAD~1
vitest --changed abc123
# Since branch
vitest --changed origin/main
```
## Related Files
Run tests that import specific files:
```bash
vitest related src/utils.ts src/api.ts --run
```
Useful with lint-staged:
```js
// .lintstagedrc.js
export default {
'*.{ts,tsx}': 'vitest related --run',
}
```
## Focus Tests (.only)
```ts
test.only('only this runs', () => {})
describe.only('only this suite', () => {
test('runs', () => {})
})
```
In CI, `.only` throws error unless configured:
```ts
defineConfig({
test: {
allowOnly: true, // Allow .only in CI
},
})
```
## Skip Tests
```ts
test.skip('skipped', () => {})
// Conditional
test.skipIf(process.env.CI)('not in CI', () => {})
test.runIf(!process.env.CI)('local only', () => {})
// Dynamic skip
test('dynamic', ({ skip }) => {
skip(someCondition, 'reason')
})
```
## Tags
Filter by custom tags:
```ts
test('database test', { tags: ['db'] }, () => {})
test('slow test', { tags: ['slow', 'integration'] }, () => {})
```
Run tagged tests:
```bash
vitest --tags db
vitest --tags "db,slow" # OR
vitest --tags db --tags slow # OR
```
Configure allowed tags:
```ts
defineConfig({
test: {
tags: ['db', 'slow', 'integration'],
strictTags: true, // Fail on unknown tags
},
})
```
## Include/Exclude Patterns
```ts
defineConfig({
test: {
// Test file patterns
include: ['**/*.{test,spec}.{ts,tsx}'],
// Exclude patterns
exclude: [
'**/node_modules/**',
'**/e2e/**',
'**/*.skip.test.ts',
],
// Include source for in-source testing
includeSource: ['src/**/*.ts'],
},
})
```
## Watch Mode Filtering
In watch mode, press:
- `p` - Filter by filename pattern
- `t` - Filter by test name pattern
- `a` - Run all tests
- `f` - Run only failed tests
## Projects Filtering
Run specific project:
```bash
vitest --project unit
vitest --project integration --project e2e
```
## Environment-based Filtering
```ts
const isDev = process.env.NODE_ENV === 'development'
const isCI = process.env.CI
describe.skipIf(isCI)('local only tests', () => {})
describe.runIf(isDev)('dev tests', () => {})
```
## Combining Filters
```bash
# File pattern + test name + changed
vitest user -t "login" --changed
# Related files + run mode
vitest related src/auth.ts --run
```
## List Tests Without Running
```bash
vitest list # Show all test names
vitest list -t "user" # Filter by name
vitest list --filesOnly # Show only file paths
vitest list --json # JSON output
```
## Key Points
- Use `-t` for test name pattern filtering
- `--changed` runs only tests affected by changes
- `--related` runs tests importing specific files
- Tags provide semantic test grouping
- Use `.only` for debugging, but configure CI to reject it
- Watch mode has interactive filtering
<!--
Source references:
- https://vitest.dev/guide/filtering.html
- https://vitest.dev/guide/cli.html
-->

View File

@@ -0,0 +1,265 @@
---
name: mocking
description: Mock functions, modules, timers, and dates with vi utilities
---
# Mocking
## Mock Functions
```ts
import { expect, vi } from 'vitest'
// Create mock function
const fn = vi.fn()
fn('hello')
expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledWith('hello')
// With implementation
const add = vi.fn((a, b) => a + b)
expect(add(1, 2)).toBe(3)
// Mock return values
fn.mockReturnValue(42)
fn.mockReturnValueOnce(1).mockReturnValueOnce(2)
fn.mockResolvedValue({ data: true })
fn.mockRejectedValue(new Error('fail'))
// Mock implementation
fn.mockImplementation((x) => x * 2)
fn.mockImplementationOnce(() => 'first call')
```
## Spying on Objects
```ts
const cart = {
getTotal: () => 100,
}
const spy = vi.spyOn(cart, 'getTotal')
cart.getTotal()
expect(spy).toHaveBeenCalled()
// Mock implementation
spy.mockReturnValue(200)
expect(cart.getTotal()).toBe(200)
// Restore original
spy.mockRestore()
```
## Module Mocking
```ts
// vi.mock is hoisted to top of file
vi.mock('./api', () => ({
fetchUser: vi.fn(() => ({ id: 1, name: 'Mock' })),
}))
import { fetchUser } from './api'
test('mocked module', () => {
expect(fetchUser()).toEqual({ id: 1, name: 'Mock' })
})
```
### Partial Mock
```ts
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
specificFunction: vi.fn(),
}
})
```
### Auto-mock with Spy
```ts
// Keep implementation but spy on calls
vi.mock('./calculator', { spy: true })
import { add } from './calculator'
test('spy on module', () => {
const result = add(1, 2) // Real implementation
expect(result).toBe(3)
expect(add).toHaveBeenCalledWith(1, 2)
})
```
### Manual Mocks (__mocks__)
```
src/
__mocks__/
axios.ts # Mocks 'axios'
api/
__mocks__/
client.ts # Mocks './client'
client.ts
```
```ts
// Just call vi.mock with no factory
vi.mock('axios')
vi.mock('./api/client')
```
## Dynamic Mocking (vi.doMock)
Not hoisted - use for dynamic imports:
```ts
test('dynamic mock', async () => {
vi.doMock('./config', () => ({
apiUrl: 'http://test.local',
}))
const { apiUrl } = await import('./config')
expect(apiUrl).toBe('http://test.local')
vi.doUnmock('./config')
})
```
## Mock Timers
```ts
import { afterEach, beforeEach, vi } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('timers', () => {
const fn = vi.fn()
setTimeout(fn, 1000)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(fn).toHaveBeenCalled()
})
// Other timer methods
vi.runAllTimers() // Run all pending timers
vi.runOnlyPendingTimers() // Run only currently pending
vi.advanceTimersToNextTimer() // Advance to next timer
```
### Async Timer Methods
```ts
test('async timers', async () => {
vi.useFakeTimers()
let resolved = false
setTimeout(() => Promise.resolve().then(() => { resolved = true }), 100)
await vi.advanceTimersByTimeAsync(100)
expect(resolved).toBe(true)
})
```
## Mock Dates
```ts
vi.setSystemTime(new Date('2024-01-01'))
expect(new Date().getFullYear()).toBe(2024)
vi.useRealTimers() // Restore
```
## Mock Globals
```ts
vi.stubGlobal('fetch', vi.fn(() =>
Promise.resolve({ json: () => ({ data: 'mock' }) })
))
// Restore
vi.unstubAllGlobals()
```
## Mock Environment Variables
```ts
vi.stubEnv('API_KEY', 'test-key')
expect(import.meta.env.API_KEY).toBe('test-key')
// Restore
vi.unstubAllEnvs()
```
## Clearing Mocks
```ts
const fn = vi.fn()
fn()
fn.mockClear() // Clear call history
fn.mockReset() // Clear history + implementation
fn.mockRestore() // Restore original (for spies)
// Global
vi.clearAllMocks()
vi.resetAllMocks()
vi.restoreAllMocks()
```
## Config Auto-Reset
```ts
// vitest.config.ts
defineConfig({
test: {
clearMocks: true, // Clear before each test
mockReset: true, // Reset before each test
restoreMocks: true, // Restore after each test
unstubEnvs: true, // Restore env vars
unstubGlobals: true, // Restore globals
},
})
```
## Hoisted Variables for Mocks
```ts
const mockFn = vi.hoisted(() => vi.fn())
vi.mock('./module', () => ({
getData: mockFn,
}))
import { getData } from './module'
test('hoisted mock', () => {
mockFn.mockReturnValue('test')
expect(getData()).toBe('test')
})
```
## Key Points
- `vi.mock` is hoisted - called before imports
- Use `vi.doMock` for dynamic, non-hoisted mocking
- Always restore mocks to avoid test pollution
- Use `{ spy: true }` to keep implementation but track calls
- `vi.hoisted` lets you reference variables in mock factories
<!--
Source references:
- https://vitest.dev/guide/mocking.html
- https://vitest.dev/api/vi.html
-->

View File

@@ -0,0 +1,207 @@
---
name: snapshot-testing
description: Snapshot testing with file, inline, and file snapshots
---
# Snapshot Testing
Snapshot tests capture output and compare against stored references.
## Basic Snapshot
```ts
import { expect, test } from 'vitest'
test('snapshot', () => {
const result = generateOutput()
expect(result).toMatchSnapshot()
})
```
First run creates `.snap` file:
```js
// __snapshots__/test.spec.ts.snap
exports['snapshot 1'] = `
{
"id": 1,
"name": "test"
}
`
```
## Inline Snapshots
Stored directly in test file:
```ts
test('inline snapshot', () => {
const data = { foo: 'bar' }
expect(data).toMatchInlineSnapshot()
})
```
Vitest updates the test file:
```ts
test('inline snapshot', () => {
const data = { foo: 'bar' }
expect(data).toMatchInlineSnapshot(`
{
"foo": "bar",
}
`)
})
```
## File Snapshots
Compare against explicit file:
```ts
test('render html', async () => {
const html = renderComponent()
await expect(html).toMatchFileSnapshot('./expected/component.html')
})
```
## Snapshot Hints
Add descriptive hints:
```ts
test('multiple snapshots', () => {
expect(header).toMatchSnapshot('header')
expect(body).toMatchSnapshot('body content')
expect(footer).toMatchSnapshot('footer')
})
```
## Object Shape Matching
Match partial structure:
```ts
test('shape snapshot', () => {
const data = {
id: Math.random(),
created: new Date(),
name: 'test'
}
expect(data).toMatchSnapshot({
id: expect.any(Number),
created: expect.any(Date),
})
})
```
## Error Snapshots
```ts
test('error message', () => {
expect(() => {
throw new Error('Something went wrong')
}).toThrowErrorMatchingSnapshot()
})
test('inline error', () => {
expect(() => {
throw new Error('Bad input')
}).toThrowErrorMatchingInlineSnapshot(`[Error: Bad input]`)
})
```
## Updating Snapshots
```bash
# Update all snapshots
vitest -u
vitest --update
# In watch mode, press 'u' to update failed snapshots
```
## Custom Serializers
Add custom snapshot formatting:
```ts
expect.addSnapshotSerializer({
test(val) {
return val && typeof val.toJSON === 'function'
},
serialize(val, config, indentation, depth, refs, printer) {
return printer(val.toJSON(), config, indentation, depth, refs)
},
})
```
Or via config:
```ts
// vitest.config.ts
defineConfig({
test: {
snapshotSerializers: ['./my-serializer.ts'],
},
})
```
## Snapshot Format Options
```ts
defineConfig({
test: {
snapshotFormat: {
printBasicPrototype: false, // Don't print Array/Object prototypes
escapeString: false,
},
},
})
```
## Concurrent Test Snapshots
Use context's expect:
```ts
test.concurrent('concurrent 1', async ({ expect }) => {
expect(await getData()).toMatchSnapshot()
})
test.concurrent('concurrent 2', async ({ expect }) => {
expect(await getOther()).toMatchSnapshot()
})
```
## Snapshot File Location
Default: `__snapshots__/<test-file>.snap`
Customize:
```ts
defineConfig({
test: {
resolveSnapshotPath: (testPath, snapExtension) => {
return testPath.replace('__tests__', '__snapshots__') + snapExtension
},
},
})
```
## Key Points
- Commit snapshot files to version control
- Review snapshot changes in code review
- Use hints for multiple snapshots in one test
- Use `toMatchFileSnapshot` for large outputs (HTML, JSON)
- Inline snapshots auto-update in test file
- Use context's `expect` for concurrent tests
<!--
Source references:
- https://vitest.dev/guide/snapshot.html
- https://vitest.dev/api/expect.html#tomatchsnapshot
-->

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 hyf0, SerKo <https://github.com/serkodev>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,154 @@
---
name: vue-best-practices
description: MUST be used for Vue.js tasks. Strongly recommends Composition API with `<script setup>` and TypeScript as the standard approach. Covers Vue 3, SSR, Volar, vue-tsc. Load for any Vue, .vue files, Vue Router, Pinia, or Vite with Vue work. ALWAYS use Composition API unless the project explicitly requires Options API.
license: MIT
metadata:
author: github.com/vuejs-ai
version: "18.0.0"
---
# Vue Best Practices Workflow
Use this skill as an instruction set. Follow the workflow in order unless the user explicitly asks for a different order.
## Core Principles
- **Keep state predictable:** one source of truth, derive everything else.
- **Make data flow explicit:** Props down, Events up for most cases.
- **Favor small, focused components:** easier to test, reuse, and maintain.
- **Avoid unnecessary re-renders:** use computed properties and watchers wisely.
- **Readability counts:** write clear, self-documenting code.
## 1) Confirm architecture before coding (required)
- Default stack: Vue 3 + Composition API + `<script setup lang="ts">`.
- If the project explicitly uses Options API, load `vue-options-api-best-practices` skill if available.
- If the project explicitly uses JSX, load `vue-jsx-best-practices` skill if available.
### 1.1 Must-read core references (required)
- Before implementing any Vue task, make sure to read and apply these core references:
- `references/reactivity.md`
- `references/sfc.md`
- `references/component-data-flow.md`
- `references/composables.md`
- Keep these references in active working context for the entire task, not only when a specific issue appears.
### 1.2 Plan component boundaries before coding (required)
Create a brief component map before implementation for any non-trivial feature.
- Define each component's single responsibility in one sentence.
- Keep entry/root and route-level view components as composition surfaces by default.
- Move feature UI and feature logic out of entry/root/view components unless the task is intentionally a tiny single-file demo.
- Define props/emits contracts for each child component in the map.
- Prefer a feature folder layout (`components/<feature>/...`, `composables/use<Feature>.ts`) when adding more than one component.
## 2) Apply essential Vue foundations (required)
These are essential, must-know foundations. Apply all of them in every Vue task using the core references already loaded in section `1.1`.
### Reactivity
- Must-read reference from `1.1`: [reactivity](references/reactivity.md)
- Keep source state minimal (`ref`/`reactive`), derive everything possible with `computed`.
- Use watchers for side effects if needed.
- Avoid recomputing expensive logic in templates.
### SFC structure and template safety
- Must-read reference from `1.1`: [sfc](references/sfc.md)
- Keep SFC sections in this order: `<script>``<template>``<style>`.
- Keep SFC responsibilities focused; split large components.
- Keep templates declarative; move branching/derivation to script.
- Apply Vue template safety rules (`v-html`, list rendering, conditional rendering choices).
### Keep components focused
Split a component when it has **more than one clear responsibility** (e.g. data orchestration + UI, or multiple independent UI sections).
- Prefer **smaller components + composables** over one “mega component”
- Move **UI sections** into child components (props in, events out).
- Move **state/side effects** into composables (`useXxx()`).
Apply objective split triggers. Split the component if **any** condition is true:
- It owns both orchestration/state and substantial presentational markup for multiple sections.
- It has 3+ distinct UI sections (for example: form, filters, list, footer/status).
- A template block is repeated or could become reusable (item rows, cards, list entries).
Entry/root and route view rule:
- Keep entry/root and route view components thin: app shell/layout, provider wiring, and feature composition.
- Do not place full feature implementations in entry/root/view components when those features contain independent parts.
- For CRUD/list features (todo, table, catalog, inbox), split at least into:
- feature container component
- input/form component
- list (and/or item) component
- footer/actions or filter/status component
- Allow a single-file implementation only for very small throwaway demos; if chosen, explicitly justify why splitting is unnecessary.
### Component data flow
- Must-read reference from `1.1`: [component-data-flow](references/component-data-flow.md)
- Use props down, events up as the primary model.
- Use `v-model` only for true two-way component contracts.
- Use provide/inject only for deep-tree dependencies or shared context.
- Keep contracts explicit and typed with `defineProps`, `defineEmits`, and `InjectionKey` as needed.
### Composables
- Must-read reference from `1.1`: [composables](references/composables.md)
- Extract logic into composables when it is reused, stateful, or side-effect heavy.
- Keep composable APIs small, typed, and predictable.
- Separate feature logic from presentational components.
## 3) Consider optional features only when requirements call for them
### 3.1 Standard optional features
Do not add these by default. Load the matching reference only when the requirement exists.
- Slots: parent needs to control child content/layout -> [component-slots](references/component-slots.md)
- Fallthrough attributes: wrapper/base components must forward attrs/events safely -> [component-fallthrough-attrs](references/component-fallthrough-attrs.md)
- Built-in component `<KeepAlive>` for stateful view caching -> [component-keep-alive](references/component-keep-alive.md)
- Built-in component `<Teleport>` for overlays/portals -> [component-teleport](references/component-teleport.md)
- Built-in component `<Suspense>` for async subtree fallback boundaries -> [component-suspense](references/component-suspense.md)
- Animation-related features: pick the simplest approach that matches the required motion behavior.
- Built-in component `<Transition>` for enter/leave effects -> [transition](references/component-transition.md)
- Built-in component `<TransitionGroup>` for animated list mutations -> [transition-group](references/component-transition-group.md)
- Class-based animation for non-enter/leave effects -> [animation-class-based-technique](references/animation-class-based-technique.md)
- State-driven animation for user-input-driven animation -> [animation-state-driven-technique](references/animation-state-driven-technique.md)
### 3.2 Less-common optional features
Use these only when there is explicit product or technical need.
- Directives: behavior is DOM-specific and not a good composable/component fit -> [directives](references/directives.md)
- Async components: heavy/rarely-used UI should be lazy loaded -> [component-async](references/component-async.md)
- Render functions only when templates cannot express the requirement -> [render-functions](references/render-functions.md)
- Plugins when behavior must be installed app-wide -> [plugins](references/plugins.md)
- State management patterns: app-wide shared state crosses feature boundaries -> [state-management](references/state-management.md)
## 4) Run performance optimization after behavior is correct
Performance work is a post-functionality pass. Do not optimize before core behavior is implemented and verified.
- Large list rendering bottlenecks -> [perf-virtualize-large-lists](references/perf-virtualize-large-lists.md)
- Static subtrees re-rendering unnecessarily -> [perf-v-once-v-memo-directives](references/perf-v-once-v-memo-directives.md)
- Over-abstraction in hot list paths -> [perf-avoid-component-abstraction-in-lists](references/perf-avoid-component-abstraction-in-lists.md)
- Expensive updates triggered too often -> [updated-hook-performance](references/updated-hook-performance.md)
## 5) Final self-check before finishing
- Core behavior works and matches requirements.
- All must-read references were read and applied.
- Reactivity model is minimal and predictable.
- SFC structure and template rules are followed.
- Components are focused and well-factored, splitting when needed.
- Entry/root and route view components remain composition surfaces unless there is an explicit small-demo exception.
- Component split decisions are explicit and defensible (responsibility boundaries are clear).
- Data flow contracts are explicit and typed.
- Composables are used where reuse/complexity justifies them.
- Moved state/side effects into composables if applicable
- Optional features are used only when requirements demand them.
- Performance changes were applied only after functionality was complete.

View File

@@ -0,0 +1,5 @@
# Sync Info
- **Source:** `vendor/vuejs-ai/skills/vue-best-practices`
- **Git SHA:** `f3dd1bf4d3ac78331bdc903e4519d561c538ca6a`
- **Synced:** 2026-03-16

View File

@@ -0,0 +1,254 @@
---
title: Use Class-based Animations for Non-Enter/Leave Effects
impact: LOW
impactDescription: Class-based animations are simpler and more performant for elements that remain in the DOM
type: best-practice
tags: [vue3, animation, css, class-binding, state]
---
# Use Class-based Animations for Non-Enter/Leave Effects
**Impact: LOW** - For animations on elements that are not entering or leaving the DOM, use CSS class-based animations triggered by Vue's reactive state. This is simpler than `<Transition>` and more appropriate for feedback animations like shake, pulse, or highlight effects.
## Task List
- Use class-based animations for elements staying in the DOM
- Use `<Transition>` only for enter/leave animations
- Combine CSS animations with Vue's class bindings (`:class`)
- Consider using `setTimeout` to auto-remove animation classes
**When to Use Class-based Animations:**
- User feedback (shake on error, pulse on success)
- Attention-grabbing effects (highlight changes)
- Hover/focus states that need more than CSS transitions
- Any animation where the element stays mounted
**When to Use Transition Component:**
- Elements entering/leaving the DOM (v-if/v-show)
- Route transitions
- List item additions/removals
## Basic Pattern
```vue
<template>
<div :class="{ shake: showError }">
<button @click="submitForm">Submit</button>
<span v-if="showError">This feature is disabled!</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const showError = ref(false)
function submitForm() {
if (!isValid()) {
// Trigger shake animation
showError.value = true
// Auto-remove class after animation completes
setTimeout(() => {
showError.value = false
}, 820) // Match animation duration
}
}
</script>
<style>
.shake {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0); /* Enable GPU acceleration */
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
</style>
```
## Common Animation Patterns
### Pulse on Success
```vue
<template>
<button
@click="save"
:class="{ pulse: saved }"
>
{{ saved ? 'Saved!' : 'Save' }}
</button>
</template>
<script setup>
import { ref } from 'vue'
const saved = ref(false)
async function save() {
await saveData()
saved.value = true
setTimeout(() => saved.value = false, 1000)
}
</script>
<style>
.pulse {
animation: pulse 0.5s ease-in-out;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
</style>
```
### Highlight on Change
```vue
<template>
<div
:class="{ highlight: justUpdated }"
>
Value: {{ value }}
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const value = ref(0)
const justUpdated = ref(false)
watch(value, () => {
justUpdated.value = true
setTimeout(() => justUpdated.value = false, 1000)
})
</script>
<style>
.highlight {
animation: highlight 1s ease-out;
}
@keyframes highlight {
0% { background-color: yellow; }
100% { background-color: transparent; }
}
</style>
```
### Bounce Attention
```vue
<template>
<div
:class="{ bounce: needsAttention }"
@animationend="needsAttention = false"
>
<BellIcon />
</div>
</template>
<script setup>
import { ref } from 'vue'
const needsAttention = ref(false)
function notifyUser() {
needsAttention.value = true
// No setTimeout needed - using animationend event
}
</script>
<style>
.bounce {
animation: bounce 0.5s ease;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
</style>
```
## Using animationend Event
Instead of `setTimeout`, use the `animationend` event for cleaner code:
```vue
<template>
<div
:class="{ animate: isAnimating }"
@animationend="isAnimating = false"
>
Content
</div>
</template>
<script setup>
import { ref } from 'vue'
const isAnimating = ref(false)
function triggerAnimation() {
isAnimating.value = true
// Class is automatically removed when animation ends
}
</script>
```
## Composable for Reusable Animations
```javascript
// composables/useAnimation.js
import { ref } from 'vue'
export function useAnimation(duration = 500) {
const isAnimating = ref(false)
function trigger() {
isAnimating.value = true
setTimeout(() => {
isAnimating.value = false
}, duration)
}
return {
isAnimating,
trigger
}
}
```
```vue
<script setup>
import { useAnimation } from '@/composables/useAnimation'
const shake = useAnimation(820)
const pulse = useAnimation(500)
</script>
<template>
<button
:class="{ shake: shake.isAnimating.value }"
@click="shake.trigger()"
>
Shake me
</button>
<button
:class="{ pulse: pulse.isAnimating.value }"
@click="pulse.trigger()"
>
Pulse me
</button>
</template>
```

View File

@@ -0,0 +1,291 @@
---
title: State-driven Animations with CSS Transitions and Style Bindings
impact: LOW
impactDescription: Combining Vue's reactive style bindings with CSS transitions creates smooth, interactive animations
type: best-practice
tags: [vue3, animation, css, transition, style-binding, state, interactive]
---
# State-driven Animations with CSS Transitions and Style Bindings
**Impact: LOW** - For responsive, interactive animations that react to user input or state changes, combine Vue's dynamic style bindings with CSS transitions. This creates smooth animations that interpolate values in real-time based on state.
## Task List
- Use `:style` binding for dynamic properties that change frequently
- Add CSS `transition` property to smoothly animate between values
- Consider using `transform` and `opacity` for GPU-accelerated animations
- For complex value interpolation, use watchers with animation libraries
## Basic Pattern
```vue
<template>
<div
@mousemove="onMousemove"
:style="{ backgroundColor: `hsl(${hue}, 80%, 50%)` }"
class="interactive-area"
>
<p>Move your mouse across this div...</p>
<p>Hue: {{ hue }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const hue = ref(0)
function onMousemove(e) {
// Map mouse X position to hue (0-360)
const rect = e.currentTarget.getBoundingClientRect()
hue.value = Math.round((e.clientX - rect.left) / rect.width * 360)
}
</script>
<style>
.interactive-area {
transition: background-color 0.3s ease;
height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
```
## Common Use Cases
### Following Mouse Position
```vue
<template>
<div
class="container"
@mousemove="onMousemove"
>
<div
class="follower"
:style="{
transform: `translate(${x}px, ${y}px)`
}"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const x = ref(0)
const y = ref(0)
function onMousemove(e) {
const rect = e.currentTarget.getBoundingClientRect()
x.value = e.clientX - rect.left
y.value = e.clientY - rect.top
}
</script>
<style>
.container {
position: relative;
height: 300px;
}
.follower {
position: absolute;
width: 20px;
height: 20px;
background: blue;
border-radius: 50%;
/* Smooth following with transition */
transition: transform 0.1s ease-out;
/* Prevent the follower from triggering mousemove */
pointer-events: none;
}
</style>
```
### Progress Animation
```vue
<template>
<div class="progress-container">
<div
class="progress-bar"
:style="{ width: `${progress}%` }"
/>
</div>
<input
type="range"
v-model.number="progress"
min="0"
max="100"
/>
</template>
<script setup>
import { ref } from 'vue'
const progress = ref(0)
</script>
<style>
.progress-container {
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
transition: width 0.3s ease;
}
</style>
```
### Scroll-based Animation
```vue
<template>
<div
class="hero"
:style="{
opacity: heroOpacity,
transform: `translateY(${scrollOffset}px)`
}"
>
<h1>Scroll Down</h1>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const scrollY = ref(0)
const heroOpacity = computed(() => {
return Math.max(0, 1 - scrollY.value / 300)
})
const scrollOffset = computed(() => {
return scrollY.value * 0.5 // Parallax effect
})
function handleScroll() {
scrollY.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style>
.hero {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
/* Note: No transition for scroll-based animations - they should be instant */
}
</style>
```
### Color Theme Transition
```vue
<template>
<div
class="app"
:style="themeStyles"
>
<button @click="toggleTheme">Toggle Theme</button>
<p>Current theme: {{ isDark ? 'Dark' : 'Light' }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const isDark = ref(false)
const themeStyles = computed(() => ({
'--bg-color': isDark.value ? '#1a1a1a' : '#ffffff',
'--text-color': isDark.value ? '#ffffff' : '#1a1a1a',
backgroundColor: 'var(--bg-color)',
color: 'var(--text-color)'
}))
function toggleTheme() {
isDark.value = !isDark.value
}
</script>
<style>
.app {
min-height: 100vh;
transition: background-color 0.5s ease, color 0.5s ease;
}
</style>
```
## Advanced: Numerical Tweening with Watchers
For smooth number animations (counters, stats), use watchers with animation libraries:
```vue
<template>
<div>
<input v-model.number="targetNumber" type="number" />
<p class="counter">{{ displayNumber.toFixed(0) }}</p>
</div>
</template>
<script setup>
import { computed, ref, reactive, watch } from 'vue'
import gsap from 'gsap'
const targetNumber = ref(0)
const tweened = reactive({ value: 0 })
// Computed for display
const displayNumber = computed(() => tweened.value)
watch(targetNumber, (newValue) => {
gsap.to(tweened, {
duration: 0.5,
value: Number(newValue) || 0,
ease: 'power2.out'
})
})
</script>
```
## Performance Considerations
```vue
<style>
/* GOOD: GPU-accelerated properties */
.element {
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* AVOID: Properties that trigger layout recalculation */
.element {
transition: width 0.3s ease, height 0.3s ease, margin 0.3s ease;
}
/* For high-frequency updates, consider will-change */
.frequently-animated {
will-change: transform;
}
</style>
```

View File

@@ -0,0 +1,97 @@
---
title: Async Component Best Practices
impact: MEDIUM
impactDescription: Poor async component strategy can delay interactivity in SSR apps and create loading UI flicker
type: best-practice
tags: [vue3, async-components, ssr, hydration, performance, ux]
---
# Async Component Best Practices
**Impact: MEDIUM** - Async components should reduce JavaScript cost without degrading perceived performance. Focus on hydration timing in SSR and stable loading UX.
## Task List
- Use lazy hydration strategies for non-critical SSR component trees
- Import only the hydration helpers you actually use
- Keep `loadingComponent` delay near the default `200ms` unless real UX data suggests otherwise
- Configure `delay` and `timeout` together for predictable loading behavior
## Use Lazy Hydration Strategies in SSR
In Vue 3.5+, async components can delay hydration until idle time, visibility, media query match, or user interaction.
**BAD:**
```vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const AsyncComments = defineAsyncComponent({
loader: () => import('./Comments.vue')
})
</script>
```
**GOOD:**
```vue
<script setup lang="ts">
import {
defineAsyncComponent,
hydrateOnVisible,
hydrateOnIdle
} from 'vue'
const AsyncComments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible({ rootMargin: '100px' })
})
const AsyncFooter = defineAsyncComponent({
loader: () => import('./Footer.vue'),
hydrate: hydrateOnIdle(5000)
})
</script>
```
## Prevent Loading Spinner Flicker
Avoid showing loading UI immediately for components that usually resolve quickly.
**BAD:**
```vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
delay: 0
})
</script>
```
**GOOD:**
```vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 30000
})
</script>
```
## Delay Guidelines
| Scenario | Recommended Delay |
|----------|-------------------|
| Small component, fast network | `200ms` |
| Known heavy component | `100ms` |
| Background or non-critical UI | `300-500ms` |

View File

@@ -0,0 +1,307 @@
---
title: Component Data Flow Best Practices
impact: HIGH
impactDescription: Clear data flow between components prevents state bugs, stale UI, and brittle coupling
type: best-practice
tags: [vue3, props, emits, v-model, provide-inject, data-flow, typescript]
---
# Component Data Flow Best Practices
**Impact: HIGH** - Vue components stay reliable when data flow is explicit: props go down, events go up, `v-model` handles two-way bindings, and provide/inject supports cross-tree dependencies. Blurring these boundaries leads to stale state, hidden coupling, and hard-to-debug UI.
The main principle of data flow in Vue.js is **Props Down / Events Up**. This is the most maintainable default, and one-way flow scales well.
## Task List
- Treat props as read-only inputs
- Use props/emit for component communication; reserve refs for imperative actions
- When refs are required for imperative APIs, type them with template refs
- Emit events instead of mutating parent state directly
- Use `defineModel` for v-model in modern Vue (3.4+)
- Handle v-model modifiers deliberately in child components
- Use symbols for provide/inject keys to avoid props drilling (over ~3 layers)
- Keep mutations in the provider or expose explicit actions
- In TypeScript projects, prefer type-based `defineProps`, `defineEmits`, and `InjectionKey`
## Props: One-Way Data Down
Props are inputs. Do not mutate them in the child.
**BAD:**
```vue
<script setup>
const props = defineProps({ count: Number })
function increment() {
props.count++
}
</script>
```
**GOOD:**
If state needs to change, emit an event, use `v-model` or create a local copy.
## Prefer props/emit over component refs
**BAD:**
```vue
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const formRef = ref(null)
function submitForm() {
if (formRef.value.isValid) {
formRef.value.submit()
}
}
</script>
<template>
<UserForm ref="formRef" />
<button @click="submitForm">Submit</button>
</template>
```
**GOOD:**
```vue
<script setup>
import UserForm from './UserForm.vue'
function handleSubmit(formData) {
api.submit(formData)
}
</script>
<template>
<UserForm @submit="handleSubmit" />
</template>
```
## Type component refs when imperative access is required
Prefer props/emits by default. When a parent must call an exposed child method, type the ref explicitly and expose only the intended API from the child with `defineExpose`.
**BAD:**
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import DialogPanel from './DialogPanel.vue'
const panelRef = ref(null)
onMounted(() => {
panelRef.value.open()
})
</script>
<template>
<DialogPanel ref="panelRef" />
</template>
```
**GOOD:**
```vue
<!-- DialogPanel.vue -->
<script setup lang="ts">
function open() {}
defineExpose({ open })
</script>
```
```vue
<!-- Parent.vue -->
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
import DialogPanel from './DialogPanel.vue'
// Vue 3.5+ with useTemplateRef
const panelRef = useTemplateRef('panelRef')
// Before Vue 3.5 with manual typing and ref
// const panelRef = ref<InstanceType<typeof DialogPanel> | null>(null)
onMounted(() => {
panelRef.value?.open()
})
</script>
<template>
<DialogPanel ref="panelRef" />
</template>
```
## Emits: Explicit Events Up
Component events do not bubble. If a parent needs to know about an event, re-emit it explicitly.
**BAD:**
```vue
<!-- Parent expects "saved" from grandchild, but it won't bubble -->
<Child @saved="onSaved" />
```
**GOOD:**
```vue
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['saved'])
function onGrandchildSaved(payload) {
emit('saved', payload)
}
</script>
<template>
<Grandchild @saved="onGrandchildSaved" />
</template>
```
**Event naming:** use kebab-case in templates and camelCase in script:
```vue
<script setup>
const emit = defineEmits(['updateUser'])
</script>
<template>
<ProfileForm @update-user="emit('updateUser', $event)" />
</template>
```
## `v-model`: Predictable Two-Way Bindings
Use `defineModel` by default for component bindings and emit updates on input. Only use the `modelValue` + `update:modelValue` pattern if you are on Vue < 3.4.
**BAD:**
```vue
<script setup>
const props = defineProps({ value: String })
</script>
<template>
<input :value="props.value" @input="$emit('input', $event.target.value)" />
</template>
```
**GOOD (Vue 3.4+):**
```vue
<script setup>
const model = defineModel({ type: String })
</script>
<template>
<input v-model="model" />
</template>
```
**GOOD (Vue < 3.4):**
```vue
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
```
If you need the updated value immediately after a change, use the input event value or `nextTick` in the parent.
## Provide/Inject: Shared Context Without Prop Drilling
Use provide/inject for cross-tree state, but keep mutations centralized in the provider and expose explicit actions.
**BAD:**
```vue
// Provider.vue
provide('theme', reactive({ dark: false }))
// Consumer.vue
const theme = inject('theme')
// Mutating shared state from any depth becomes hard to track
theme.dark = true
```
**GOOD:**
```vue
// Provider.vue
const theme = reactive({ dark: false })
const toggleTheme = () => { theme.dark = !theme.dark }
provide(themeKey, readonly(theme))
provide(themeActionsKey, { toggleTheme })
// Consumer.vue
const theme = inject(themeKey)
const { toggleTheme } = inject(themeActionsKey)
```
Use symbols for keys to avoid collisions in large apps:
```ts
export const themeKey = Symbol('theme')
export const themeActionsKey = Symbol('theme-actions')
```
## Use TypeScript Contracts for Public Component APIs
In TypeScript projects, type component boundaries directly with `defineProps`, `defineEmits`, and `InjectionKey` so invalid payloads and mismatched injections fail at compile time.
**BAD:**
```vue
<script setup lang="ts">
import { inject } from 'vue'
const props = defineProps({
userId: String
})
const emit = defineEmits(['save'])
const settings = inject('settings')
// Payload shape is not checked here
emit('save', 123)
// Key is string-based and not type-safe
settings?.theme = 'dark'
</script>
```
**GOOD:**
```vue
<script setup lang="ts">
import { inject, provide } from 'vue'
import type { InjectionKey } from 'vue'
interface Props {
userId: string
}
interface Emits {
save: [payload: { id: string; draft: boolean }]
}
interface Settings {
theme: 'light' | 'dark'
}
const settingsKey: InjectionKey<Settings> = Symbol('settings')
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
provide(settingsKey, { theme: 'light' })
const settings = inject(settingsKey)
if (settings) {
emit('save', { id: props.userId, draft: false })
}
</script>
```

View File

@@ -0,0 +1,174 @@
---
title: Component Fallthrough Attributes Best Practices
impact: MEDIUM
impactDescription: Incorrect $attrs access and reactivity assumptions can cause undefined values and watchers that never run
type: best-practice
tags: [vue3, attrs, fallthrough-attributes, composition-api, reactivity]
---
# Component Fallthrough Attributes Best Practices
**Impact: MEDIUM** - Fallthrough attributes are straightforward once you follow Vue's conventions: hyphenated names use bracket notation, listener keys are camelCase `onX`, and `useAttrs()` is current-but-not-reactive.
## Task List
- Access hyphenated attribute names with bracket notation (for example `attrs['data-testid']`)
- Access event listeners with camelCase `onX` keys (for example `attrs.onClick`)
- Do not `watch()` values returned from `useAttrs()`; those watchers do not trigger on attr changes
- Use `onUpdated()` for attr-driven side effects
- Promote frequently observed attrs to props when reactive observation is required
## Access Attribute and Listener Keys Correctly
Hyphenated attribute names preserve their original casing in JavaScript, so dot notation does not work for keys that include `-`.
**BAD:**
```vue
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.data-testid) // Syntax error
console.log(attrs.dataTestid) // undefined for data-testid
console.log(attrs['on-click']) // undefined
console.log(attrs['@click']) // undefined
</script>
```
**GOOD:**
```vue
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs['data-testid'])
console.log(attrs['aria-label'])
console.log(attrs['foo-bar'])
console.log(attrs.onClick)
console.log(attrs.onCustomEvent)
console.log(attrs.onMouseEnter)
</script>
```
### Naming Reference
| Parent Usage | Access in `attrs` |
|--------------|-------------------|
| `class="foo"` | `attrs.class` |
| `data-id="123"` | `attrs['data-id']` |
| `aria-label="..."` | `attrs['aria-label']` |
| `foo-bar="baz"` | `attrs['foo-bar']` |
| `@click="fn"` | `attrs.onClick` |
| `@custom-event="fn"` | `attrs.onCustomEvent` |
| `@update:modelValue="fn"` | `attrs['onUpdate:modelValue']` |
## `useAttrs()` Is Not Reactive
`useAttrs()` always reflects the latest values, but it is intentionally not reactive for watcher tracking.
**BAD:**
```vue
<script setup>
import { watch, watchEffect, useAttrs } from 'vue'
const attrs = useAttrs()
watch(
() => attrs.someAttr,
(newValue) => {
console.log('Changed:', newValue) // Never runs on attr changes
}
)
watchEffect(() => {
console.log(attrs.class) // Runs on setup, not on attr updates
})
</script>
```
**GOOD:**
```vue
<script setup>
import { onUpdated, useAttrs } from 'vue'
const attrs = useAttrs()
onUpdated(() => {
console.log('Latest attrs:', attrs)
})
</script>
```
**GOOD:**
```vue
<script setup>
import { watch } from 'vue'
const props = defineProps({
someAttr: String
})
watch(
() => props.someAttr,
(newValue) => {
console.log('Changed:', newValue)
}
)
</script>
```
## Common Patterns
### Check for optional attrs safely
```vue
<script setup>
import { computed, useAttrs } from 'vue'
const attrs = useAttrs()
const hasTestId = computed(() => 'data-testid' in attrs)
const ariaLabel = computed(() => attrs['aria-label'] ?? 'Default label')
</script>
```
### Forward listeners after internal logic
```vue
<script setup>
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
function handleClick(event) {
console.log('Internal handling first')
attrs.onClick?.(event)
}
</script>
<template>
<button @click="handleClick">
<slot />
</button>
</template>
```
## TypeScript Notes
`useAttrs()` is typed as `Record<string, unknown>`, so cast individual keys when needed.
```vue
<script setup lang="ts">
import { useAttrs } from 'vue'
const attrs = useAttrs()
const testId = attrs['data-testid'] as string | undefined
const onClick = attrs.onClick as ((event: MouseEvent) => void) | undefined
</script>
```

View File

@@ -0,0 +1,137 @@
---
title: KeepAlive Component Best Practices
impact: HIGH
impactDescription: KeepAlive caches component instances; misuse causes stale data, memory growth, or unexpected lifecycle behavior
type: best-practice
tags: [vue3, keepalive, cache, performance, router, dynamic-components]
---
# KeepAlive Component Best Practices
**Impact: HIGH** - `<KeepAlive>` caches component instances instead of destroying them. Use it to preserve state across switches, but manage cache size and freshness explicitly to avoid memory growth or stale UI.
## Task List
- Use KeepAlive only where state preservation improves UX
- Set a reasonable `max` to cap cache size
- Declare component names for include/exclude matching
- Use `onActivated`/`onDeactivated` for cache-aware logic
- Decide how and when cached views refresh their data
- Avoid caching memory-heavy or security-sensitive views
## When to Use KeepAlive
Use KeepAlive when switching between views where state should persist (tabs, multi-step forms, dashboards). Avoid it when each visit should start fresh.
**BAD:**
```vue
<template>
<!-- State resets on every switch -->
<component :is="currentTab" />
</template>
```
**GOOD:**
```vue
<template>
<!-- State preserved between switches -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
</template>
```
## When NOT to Use KeepAlive
- Search or filter pages where users expect fresh results
- Memory-heavy components (maps, large tables, media players)
- Sensitive flows where data must be cleared on exit
- Components with heavy background activity you cannot pause
## Limit and Control the Cache
Always cap cache size with `max` and restrict caching to specific components when possible.
```vue
<template>
<KeepAlive :max="5" include="Dashboard,Settings">
<component :is="currentView" />
</KeepAlive>
</template>
```
## Ensure Component Names Match include/exclude
`include` and `exclude` match the component `name` option. Explicitly set names for reliable caching.
```vue
<!-- TabA.vue -->
<script setup>
defineOptions({ name: 'TabA' })
</script>
```
```vue
<template>
<KeepAlive include="TabA,TabB">
<component :is="currentTab" />
</KeepAlive>
</template>
```
## Cache Invalidation Strategies
Vue 3 has no direct API to remove a specific cached instance. Use keys or dynamic include/exclude to force refreshes.
```vue
<script setup>
import { ref, reactive } from 'vue'
const currentView = ref('Dashboard')
const viewKeys = reactive({ Dashboard: 0, Settings: 0 })
function invalidateCache(view) {
viewKeys[view]++
}
</script>
<template>
<KeepAlive>
<component :is="currentView" :key="`${currentView}-${viewKeys[currentView]}`" />
</KeepAlive>
</template>
```
## Lifecycle Hooks for Cached Components
Cached components are not destroyed on switch. Use activation hooks for refresh and cleanup.
```vue
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
refreshData()
})
onDeactivated(() => {
pauseTimers()
})
</script>
```
## Router Caching and Freshness
Decide whether navigation should show cached state or a fresh view. A common pattern is to key by route when params change.
```vue
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive>
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</router-view>
</template>
```
If you want cache reuse but fresh data, refresh in `onActivated` and compare query/params before fetching.

View File

@@ -0,0 +1,216 @@
---
title: Component Slots Best Practices
impact: MEDIUM
impactDescription: Poor slot API design causes empty DOM wrappers, weak TypeScript safety, brittle defaults, and unnecessary component overhead
type: best-practice
tags: [vue3, slots, components, typescript, composables]
---
# Component Slots Best Practices
**Impact: MEDIUM** - Slots are a core component API surface in Vue. Structure them intentionally so templates stay predictable, typed, and performant.
## Task List
- Use shorthand syntax for named slots (`#` instead of `v-slot:`)
- Render optional slot wrapper elements only when slot content exists (`$slots` checks)
- Type scoped slot contracts with `defineSlots` in TypeScript components
- Provide fallback content for optional slots
- Prefer composables over renderless components for pure logic reuse
## Shorthand syntax for named slots
**BAD:**
```vue
<MyComponent>
<template v-slot:header> ... </template>
</MyComponent>
```
**GOOD:**
```vue
<MyComponent>
<template #header> ... </template>
</MyComponent>
```
## Conditionally Render Optional Slot Wrappers
Use `$slots` checks when wrapper elements add spacing, borders, or layout constraints.
**BAD:**
```vue
<!-- Card.vue -->
<template>
<article class="card">
<header class="card-header">
<slot name="header" />
</header>
<section class="card-body">
<slot />
</section>
<footer class="card-footer">
<slot name="footer" />
</footer>
</article>
</template>
```
**GOOD:**
```vue
<!-- Card.vue -->
<template>
<article class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
<section v-if="$slots.default" class="card-body">
<slot />
</section>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</article>
</template>
```
## Type Scoped Slot Props with defineSlots
In `<script setup lang="ts">`, use `defineSlots` so slot consumers get autocomplete and static checks.
**BAD:**
```vue
<!-- ProductList.vue -->
<script setup lang="ts">
interface Product {
id: number
name: string
}
defineProps<{ products: Product[] }>()
</script>
<template>
<ul>
<li v-for="(product, index) in products" :key="product.id">
<slot :product="product" :index="index" />
</li>
</ul>
</template>
```
**GOOD:**
```vue
<!-- ProductList.vue -->
<script setup lang="ts">
interface Product {
id: number
name: string
}
defineProps<{ products: Product[] }>()
defineSlots<{
default(props: { product: Product; index: number }): any
empty(): any
}>()
</script>
<template>
<ul v-if="products.length">
<li v-for="(product, index) in products" :key="product.id">
<slot :product="product" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>
```
## Provide Slot Fallback Content
Fallback content makes components resilient when parents omit optional slots.
**BAD:**
```vue
<!-- SubmitButton.vue -->
<template>
<button type="submit" class="btn-primary">
<slot />
</button>
</template>
```
**GOOD:**
```vue
<!-- SubmitButton.vue -->
<template>
<button type="submit" class="btn-primary">
<slot>Submit</slot>
</button>
</template>
```
## Prefer Composables for Pure Logic Reuse
Renderless components are still useful for slot-driven composition, but composables are usually cleaner for logic-only reuse.
**BAD:**
```vue
<!-- MouseTracker.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function onMove(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
</script>
<template>
<slot :x="x" :y="y" />
</template>
```
**GOOD:**
```ts
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function onMove(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
return { x, y }
}
```
```vue
<!-- MousePosition.vue -->
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>{{ x }}, {{ y }}</p>
</template>
```

View File

@@ -0,0 +1,228 @@
---
title: Suspense Component Best Practices
impact: MEDIUM
impactDescription: Suspense coordinates async dependencies with fallback UI; misconfiguration leads to missing loading states or confusing UX
type: best-practice
tags: [vue3, suspense, async-components, async-setup, loading, fallback, router, transition, keepalive]
---
# Suspense Component Best Practices
**Impact: MEDIUM** - `<Suspense>` coordinates async dependencies (async components or async setup) and renders a fallback while they resolve. Misconfiguration leads to missing loading states, empty renders, or subtle UX bugs.
## Task List
- Wrap default and fallback slot content in a single root node
- Use `timeout` when you need the fallback to appear on reverts
- Force root replacement with `:key` when you need Suspense to re-trigger
- Add `suspensible` to nested Suspense boundaries (Vue 3.3+)
- Use `@pending`, `@resolve`, and `@fallback` for programmatic loading state
- Nest `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` in that order
- Keep Suspense usage centralized and documented in production
## Single Root in Default and Fallback Slots
Suspense tracks a single immediate child in both slots. Wrap multiple elements in a single element or component.
**BAD:**
```vue
<template>
<Suspense>
<AsyncHeader />
<AsyncList />
<template #fallback>
<LoadingSpinner />
<LoadingHint />
</template>
</Suspense>
</template>
```
**GOOD:**
```vue
<template>
<Suspense>
<div>
<AsyncHeader />
<AsyncList />
</div>
<template #fallback>
<div>
<LoadingSpinner />
<LoadingHint />
</div>
</template>
</Suspense>
</template>
```
## Fallback Timing on Reverts (`timeout`)
When Suspense is already resolved and new async work starts, the previous content remains visible until the timeout elapses. Use `timeout="0"` for immediate fallback or a short delay to avoid flicker.
**BAD:**
```vue
<template>
<Suspense>
<component :is="currentView" :key="viewKey" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>
```
**GOOD:**
```vue
<template>
<Suspense :timeout="200">
<component :is="currentView" :key="viewKey" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>
```
## Pending State Only Re-triggers on Root Replacement
Once resolved, Suspense only re-enters pending when the root node of the default slot changes. If async work happens deeper in the tree, no fallback appears.
**BAD:**
```vue
<template>
<Suspense>
<TabContainer>
<AsyncDashboard v-if="tab === 'dashboard'" />
<AsyncSettings v-else />
</TabContainer>
<template #fallback>
Loading...
</template>
</Suspense>
</template>
```
**GOOD:**
```vue
<template>
<Suspense>
<component :is="tabs[tab]" :key="tab" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>
```
## Use `suspensible` for Nested Suspense (Vue 3.3+)
Nested Suspense boundaries need `suspensible` on the inner boundary so the parent can coordinate loading state. Without it, inner async content may render empty nodes until resolved.
**BAD:**
```vue
<template>
<Suspense>
<LayoutShell>
<Suspense>
<AsyncWidget />
<template #fallback>Loading widget...</template>
</Suspense>
</LayoutShell>
<template #fallback>Loading layout...</template>
</Suspense>
</template>
```
**GOOD:**
```vue
<template>
<Suspense>
<LayoutShell>
<Suspense suspensible>
<AsyncWidget />
<template #fallback>Loading widget...</template>
</Suspense>
</LayoutShell>
<template #fallback>Loading layout...</template>
</Suspense>
</template>
```
## Track Loading with Suspense Events
Use `@pending`, `@resolve`, and `@fallback` for analytics, global loading indicators, or coordinating UI outside the Suspense boundary.
```vue
<script setup>
import { ref } from 'vue'
const isLoading = ref(false)
const onPending = () => {
isLoading.value = true
}
const onResolve = () => {
isLoading.value = false
}
</script>
<template>
<LoadingBar v-if="isLoading" />
<Suspense @pending="onPending" @resolve="onResolve">
<AsyncPage />
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</template>
```
## Recommended Nesting with RouterView, Transition, KeepAlive
When combining these components, the nesting order should be `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` so each wrapper works correctly.
**BAD:**
```vue
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<KeepAlive>
<Transition mode="out-in">
<component :is="Component" />
</Transition>
</KeepAlive>
</Suspense>
</RouterView>
</template>
```
**GOOD:**
```vue
<template>
<RouterView v-slot="{ Component }">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>Loading...</template>
</Suspense>
</KeepAlive>
</Transition>
</RouterView>
</template>
```
## Treat Suspense Cautiously in Production
In production code, keep Suspense boundaries minimal, document where they are used, and have a fallback loading strategy if you ever need to replace or refactor them.

View File

@@ -0,0 +1,108 @@
---
title: Teleport Component Best Practices
impact: MEDIUM
impactDescription: Teleport renders content outside the component's DOM position, which is essential for overlays but affects styling and layout
type: best-practice
tags: [vue3, teleport, modal, overlay, positioning, responsive]
---
# Teleport Component Best Practices
**Impact: MEDIUM** - `<Teleport>` renders part of a component's template in a different place in the DOM while preserving the Vue component hierarchy. Use it for overlays (modals, toasts, tooltips) or any UI that must escape stacking contexts, overflow, or fixed positioning constraints.
## Task List
- Teleport overlays to `body` or a dedicated container outside the app root
- Keep a shared target for similar UI (`#modals`, `#notifications`) and control layering with order or z-index
- Use `:disabled` for responsive layouts that should render inline on small screens
- Remember props, emits, and provide/inject still work through teleport
- Avoid relying on parent stacking contexts or transforms for teleported UI
## Teleport Overlays Out of Transformed Containers
When an ancestor has `transform`, `filter`, or `perspective`, fixed-position overlays can behave like they are locally positioned. Teleport escapes that context.
**BAD:**
```vue
<template>
<div class="animated-container">
<button @click="open = true">Open</button>
<!-- Broken: fixed positioning is scoped to the transformed parent -->
<div v-if="open" class="modal">Modal</div>
</div>
</template>
<style>
.animated-container {
transform: translateZ(0);
}
.modal {
position: fixed;
inset: 0;
z-index: 9999;
}
</style>
```
**GOOD:**
```vue
<template>
<div class="animated-container">
<button @click="open = true">Open</button>
<Teleport to="body">
<div v-if="open" class="modal">Modal</div>
</Teleport>
</div>
</template>
```
## Responsive Layouts with `disabled`
Use `:disabled` to render inline on mobile and teleport on larger screens:
```vue
<script setup>
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 768px)')
</script>
<template>
<Teleport to="body" :disabled="isMobile">
<nav class="sidebar">Navigation</nav>
</Teleport>
</template>
```
## Logical Hierarchy Is Preserved
Teleport changes DOM position, not the Vue component tree. Props, emits, slots, and provide/inject still work:
```vue
<template>
<Teleport to="body">
<ChildPanel :message="message" @close="open = false" />
</Teleport>
</template>
```
## Multiple Teleports to the Same Target
Teleports to the same target append in declaration order:
```vue
<template>
<Teleport to="#notifications">
<div>First</div>
</Teleport>
<Teleport to="#notifications">
<div>Second</div>
</Teleport>
</template>
```
Use a shared container to keep stacking predictable, and apply z-index only when you need explicit layering.

View File

@@ -0,0 +1,128 @@
---
title: TransitionGroup Component Best Practices
impact: MEDIUM
impactDescription: TransitionGroup animates list items; missing keys or misuse leads to broken list transitions
type: best-practice
tags: [vue3, transition-group, animation, lists, keys]
---
# TransitionGroup Component Best Practices
**Impact: MEDIUM** - `<TransitionGroup>` animates lists of items entering, leaving, and moving. Use it for `v-for` lists or dynamic collections where individual items change over time.
## Task List
- Use `<TransitionGroup>` only for lists and repeated items
- Provide unique, stable keys for every direct child
- Use `tag` when you need semantic or layout wrappers
- Avoid the `mode` prop (not supported)
- Use JavaScript hooks for staggered effects
## Use TransitionGroup for Lists
`<TransitionGroup>` is designed for list items. Use `tag` to control the wrapper element when needed.
**BAD:**
```vue
<template>
<TransitionGroup name="fade">
<ComponentA />
<ComponentB />
</TransitionGroup>
</template>
```
**GOOD:**
```vue
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
</template>
```
## Always Provide Stable Keys
Keys are required. Without stable keys, Vue cannot track item positions and animations break.
**BAD:**
```vue
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="(item, index) in items" :key="index">
{{ item.name }}
</li>
</TransitionGroup>
</template>
```
**GOOD:**
```vue
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
</template>
```
## Do Not Use `mode` on TransitionGroup
`mode` is only for `<Transition>` because it swaps a single element. Use `<Transition>` if you need in/out sequencing.
**BAD:**
```vue
<template>
<TransitionGroup name="list" tag="div" mode="out-in">
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
</TransitionGroup>
</template>
```
**GOOD:**
```vue
<template>
<Transition name="fade" mode="out-in">
<component :is="currentView" :key="currentView" />
</Transition>
</template>
```
## Stagger List Animations with Data Attributes
For cascading list animations, pass the index to JavaScript hooks and compute delay per item.
```vue
<template>
<TransitionGroup
tag="ul"
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
>
<li v-for="(item, index) in items" :key="item.id" :data-index="index">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<script setup>
function onBeforeEnter(el) {
el.style.opacity = 0
el.style.transform = 'translateY(12px)'
}
function onEnter(el, done) {
const delay = Number(el.dataset.index) * 80
setTimeout(() => {
el.style.transition = 'all 0.25s ease'
el.style.opacity = 1
el.style.transform = 'translateY(0)'
setTimeout(done, 250)
}, delay)
}
</script>
```

View File

@@ -0,0 +1,125 @@
---
title: Transition Component Best Practices
impact: MEDIUM
impactDescription: Transition animates a single element or component; incorrect structure or keys prevent animations
type: best-practice
tags: [vue3, transition, animation, performance, keys]
---
# Transition Component Best Practices
**Impact: MEDIUM** - `<Transition>` animates entering/leaving of a single element or component. It is ideal for toggling UI states, swapping views, or animating one component at a time.
## Task List
- Wrap a single element or component inside `<Transition>`
- Provide a `key` when switching between same element types
- Use `mode="out-in"` when you need sequential swaps
- Prefer `transform` and `opacity` for smooth animations
## Use Transition for a Single Root Element
`<Transition>` only supports one direct child. Wrap multiple nodes in a single element or component.
**BAD:**
```vue
<template>
<Transition name="fade">
<h3>Title</h3>
<p>Description</p>
</Transition>
</template>
```
**GOOD:**
```vue
<template>
<Transition name="fade">
<div>
<h3>Title</h3>
<p>Description</p>
</div>
</Transition>
</template>
```
## Force Transitions Between Same Element Types
Vue reuses the same DOM element when the tag type does not change. Add `key` so Vue treats it as a new element and triggers enter/leave.
**BAD:**
```vue
<template>
<Transition name="fade">
<p v-if="isActive">Active</p>
<p v-else>Inactive</p>
</Transition>
</template>
```
**GOOD:**
```vue
<template>
<Transition name="fade" mode="out-in">
<p v-if="isActive" key="active">Active</p>
<p v-else key="inactive">Inactive</p>
</Transition>
</template>
```
## Use `mode` to Avoid Overlap During Swaps
When swapping components or views, use `mode="out-in"` to prevent both from being visible at the same time.
**BAD:**
```vue
<template>
<Transition name="fade">
<component :is="currentView" />
</Transition>
</template>
```
**GOOD:**
```vue
<template>
<Transition name="fade" mode="out-in">
<component :is="currentView" :key="currentView" />
</Transition>
</template>
```
## Animate `transform` and `opacity` for Performance
Avoid layout-triggering properties such as `height`, `margin`, or `top`. Use `transform` and `opacity` for smooth, GPU-friendly transitions.
**BAD:**
```css
.slide-enter-active,
.slide-leave-active {
transition: height 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
height: 0;
}
```
**GOOD:**
```css
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(-12px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(12px);
opacity: 0;
}
```

View File

@@ -0,0 +1,290 @@
---
title: Composable Organization Patterns
impact: MEDIUM
impactDescription: Well-structured composables improve maintainability, reusability, and update performance
type: best-practice
tags: [vue3, composables, composition-api, code-organization, api-design, readonly, utilities]
---
# Composable Organization Patterns
**Impact: MEDIUM** - Treat composables as reusable, stateful building blocks and keep their code organized by feature concern. This keeps large components maintainable and prevents hard-to-debug mutation and API design issues.
## Task List
- Compose complex behavior from small, focused composables
- Use options objects for composables with multiple optional parameters
- Return readonly state when updates must flow through explicit actions
- Keep pure utility functions as plain utilities, not composables
- Organize composable and component code by feature concern, and extract composables when components grow
## Compose Composables from Smaller Primitives
**BAD:**
```vue
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
const inside = ref(false)
const el = ref(null)
function onMove(e) {
x.value = e.pageX
y.value = e.pageY
if (!el.value) return
const r = el.value.getBoundingClientRect()
inside.value = x.value >= r.left && x.value <= r.right &&
y.value >= r.top && y.value <= r.bottom
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
</script>
```
**GOOD:**
```javascript
// composables/useEventListener.js
import { onMounted, onUnmounted, toValue } from 'vue'
export function useEventListener(target, event, callback) {
onMounted(() => toValue(target).addEventListener(event, callback))
onUnmounted(() => toValue(target).removeEventListener(event, callback))
}
```
```javascript
// composables/useMouse.js
import { ref } from 'vue'
import { useEventListener } from './useEventListener'
export function useMouse() {
const x = ref(0)
const y = ref(0)
useEventListener(window, 'mousemove', (e) => {
x.value = e.pageX
y.value = e.pageY
})
return { x, y }
}
```
```javascript
// composables/useMouseInElement.js
import { computed } from 'vue'
import { useMouse } from './useMouse'
export function useMouseInElement(elementRef) {
const { x, y } = useMouse()
const isOutside = computed(() => {
if (!elementRef.value) return true
const rect = elementRef.value.getBoundingClientRect()
return x.value < rect.left || x.value > rect.right ||
y.value < rect.top || y.value > rect.bottom
})
return { x, y, isOutside }
}
```
## Use Options Object Pattern for Composable Parameters
**BAD:**
```javascript
export function useFetch(url, method, headers, timeout, retries, immediate) {
// hard to read and easy to misorder
}
useFetch('/api/users', 'GET', null, 5000, 3, true)
```
**GOOD:**
```javascript
export function useFetch(url, options = {}) {
const {
method = 'GET',
headers = {},
timeout = 30000,
retries = 0,
immediate = true
} = options
// implementation
return { method, headers, timeout, retries, immediate }
}
useFetch('/api/users', {
method: 'POST',
timeout: 5000,
retries: 3
})
```
```typescript
interface UseCounterOptions {
initial?: number
min?: number
max?: number
step?: number
}
export function useCounter(options: UseCounterOptions = {}) {
const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options
// implementation
}
```
## Return Readonly State with Explicit Actions
**BAD:**
```javascript
export function useCart() {
const items = ref([])
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
return { items, total } // any consumer can mutate directly
}
const { items } = useCart()
items.value.push({ id: 1, price: 10 })
```
**GOOD:**
```javascript
import { ref, computed, readonly } from 'vue'
export function useCart() {
const _items = ref([])
const total = computed(() =>
_items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product, quantity = 1) {
const existing = _items.value.find(item => item.id === product.id)
if (existing) {
existing.quantity += quantity
return
}
_items.value.push({ ...product, quantity })
}
function removeItem(productId) {
_items.value = _items.value.filter(item => item.id !== productId)
}
return {
items: readonly(_items),
total,
addItem,
removeItem
}
}
```
## Keep Utilities as Utilities
**BAD:**
```javascript
export function useFormatters() {
const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date)
const formatCurrency = (amount) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
return { formatDate, formatCurrency }
}
const { formatDate } = useFormatters()
```
**GOOD:**
```javascript
// utils/formatters.js
export function formatDate(date) {
return new Intl.DateTimeFormat('en-US').format(date)
}
export function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
}
```
```javascript
// composables/useInvoiceSummary.js
import { computed } from 'vue'
import { formatCurrency } from '@/utils/formatters'
export function useInvoiceSummary(invoiceRef) {
const totalLabel = computed(() => formatCurrency(invoiceRef.value.total))
return { totalLabel }
}
```
## Organize Composable and Component Code by Feature Concern
**BAD:**
```vue
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const searchQuery = ref('')
const items = ref([])
const selected = ref(null)
const showModal = ref(false)
const sortBy = ref('name')
const filter = ref('all')
const loading = ref(false)
const filtered = computed(() => items.value.filter(i => i.category === filter.value))
function openModal() { showModal.value = true }
const sorted = computed(() => [...filtered.value].sort(/* ... */))
watch(searchQuery, () => { /* ... */ })
onMounted(() => { /* ... */ })
</script>
```
**GOOD:**
```vue
<script setup>
import { useItems } from '@/composables/useItems'
import { useSearch } from '@/composables/useSearch'
import { useSelectionModal } from '@/composables/useSelectionModal'
// Data
const { items, loading, fetchItems } = useItems()
// Search/filter/sort
const { query, visibleItems } = useSearch(items)
// Selection + modal
const { selectedItem, isModalOpen, selectItem, closeModal } = useSelectionModal()
</script>
```
```javascript
// composables/useItems.js
import { ref, onMounted } from 'vue'
export function useItems() {
const items = ref([])
const loading = ref(false)
async function fetchItems() {
loading.value = true
try {
items.value = await api.getItems()
} finally {
loading.value = false
}
}
onMounted(fetchItems)
return { items, loading, fetchItems }
}
```

View File

@@ -0,0 +1,162 @@
---
title: Directive Best Practices
impact: MEDIUM
impactDescription: Custom directives are powerful but easy to misuse; following patterns prevents leaks, invalid usage, and unclear abstractions
type: best-practice
tags: [vue3, directives, custom-directives, composition, typescript]
---
# Directive Best Practices
**Impact: MEDIUM** - Directives are for low-level DOM access. Use them sparingly, keep them side-effect safe, and prefer components or composables when you need stateful or reusable UI behavior.
## Task List
- Use directives only when you need direct DOM access
- Do not mutate directive arguments or binding objects
- Clean up timers, listeners, and observers in `unmounted`
- Register directives in `<script setup>` with the `v-` prefix
- In TypeScript projects, type directive values and augment template directive types
- Prefer components or composables for complex behavior
## Treat Directive Arguments as Read-Only
Directive bindings are not reactive storage. Dont write to them.
```ts
const vFocus = {
mounted(el, binding) {
// binding.value is read-only
el.focus()
}
}
```
## Avoid Directives on Components
Directives apply to DOM elements. When used on components, they attach to the root element and can break if the root changes.
**BAD:**
```vue
<MyInput v-focus />
```
**GOOD:**
```vue
<!-- MyInput.vue -->
<script setup>
const vFocus = (el) => el.focus()
</script>
<template>
<input v-focus />
</template>
```
## Clean Up Side Effects in `unmounted`
Any timers, listeners, or observers must be removed to avoid leaks.
```ts
const vResize = {
mounted(el) {
const observer = new ResizeObserver(() => {})
observer.observe(el)
el._observer = observer
},
unmounted(el) {
el._observer?.disconnect()
}
}
```
## Prefer Function Shorthand for Single-Hook Directives
If you only need `mounted`/`updated`, use the function form.
```ts
const vAutofocus = (el) => el.focus()
```
## Use the `v-` Prefix and Script Setup Registration
```vue
<script setup>
const vFocus = (el) => el.focus()
</script>
<template>
<input v-focus />
</template>
```
## Type Custom Directives in TypeScript Projects
Use `Directive<Element, ValueType>` so `binding.value` is typed, and augment Vue's template types so directives are recognized in SFC templates.
**BAD:**
```ts
// Untyped directive value and no template type augmentation
export const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value
}
}
```
**GOOD:**
```ts
import type { Directive } from 'vue'
type HighlightValue = string
export const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value
}
} satisfies Directive<HTMLElement, HighlightValue>
declare module 'vue' {
interface ComponentCustomProperties {
vHighlight: typeof vHighlight
}
}
```
## Handle SSR with `getSSRProps`
Directive hooks such as `mounted` and `updated` do not run during SSR. If a directive sets attributes/classes that affect rendered HTML, provide an SSR equivalent via `getSSRProps` to avoid hydration mismatches.
**BAD:**
```ts
const vTooltip = {
mounted(el, binding) {
el.setAttribute('data-tooltip', binding.value)
el.classList.add('has-tooltip')
}
}
```
**GOOD:**
```ts
const vTooltip = {
mounted(el, binding) {
el.setAttribute('data-tooltip', binding.value)
el.classList.add('has-tooltip')
},
getSSRProps(binding) {
return {
'data-tooltip': binding.value,
class: 'has-tooltip'
}
}
}
```
## Prefer Declarative Templates When Possible
If a standard attribute or binding works, use it instead of a directive.
## Decide Between Directives and Components
Use a directive for DOM-level behavior. Use a component when behavior affects structure, state, or rendering.

View File

@@ -0,0 +1,159 @@
---
title: Avoid Excessive Component Abstraction in Large Lists
impact: MEDIUM
impactDescription: Each component instance has memory and render overhead - abstractions multiply this in lists
type: efficiency
tags: [vue3, performance, components, abstraction, lists, optimization]
---
# Avoid Excessive Component Abstraction in Large Lists
**Impact: MEDIUM** - Component instances are more expensive than plain DOM nodes. While abstractions improve code organization, unnecessary nesting creates overhead. In large lists, this overhead multiplies - 100 items with 3 levels of abstraction means 300+ component instances instead of 100.
Don't avoid abstraction entirely, but be mindful of component depth in frequently-rendered elements like list items.
## Task List
- Review list item components for unnecessary wrapper components
- Consider flattening component hierarchies in hot paths
- Use native elements when a component adds no value
- Profile component counts using Vue DevTools
- Focus optimization efforts on the most-rendered components
**BAD:**
```vue
<!-- BAD: Deep abstraction in list items -->
<template>
<div class="user-list">
<!-- For 100 users: Creates 400 component instances -->
<UserCard v-for="user in users" :key="user.id" :user="user" />
</div>
</template>
<!-- UserCard.vue -->
<template>
<Card> <!-- Wrapper component #1 -->
<CardHeader> <!-- Wrapper component #2 -->
<UserAvatar :src="user.avatar" /> <!-- Wrapper component #3 -->
</CardHeader>
<CardBody> <!-- Wrapper component #4 -->
<Text>{{ user.name }}</Text>
</CardBody>
</Card>
</template>
<!-- Each UserCard creates: Card + CardHeader + CardBody + UserAvatar + Text
100 users = 500+ component instances -->
```
**GOOD:**
```vue
<!-- GOOD: Flattened structure in list items -->
<template>
<div class="user-list">
<!-- For 100 users: Creates 100 component instances -->
<UserCard v-for="user in users" :key="user.id" :user="user" />
</div>
</template>
<!-- UserCard.vue - Flattened, uses native elements -->
<template>
<div class="card">
<div class="card-header">
<img :src="user.avatar" :alt="user.name" class="avatar" />
</div>
<div class="card-body">
<span class="user-name">{{ user.name }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
user: Object
})
</script>
<style scoped>
/* Styles that would have been in Card, CardHeader, etc. */
.card { /* ... */ }
.card-header { /* ... */ }
.card-body { /* ... */ }
.avatar { /* ... */ }
</style>
```
## When Abstraction Is Still Worth It
```vue
<!-- Component abstraction is valuable when: -->
<!-- 1. Complex behavior is encapsulated -->
<UserStatusIndicator :user="user" /> <!-- Has logic, tooltips, etc. -->
<!-- 2. Reused outside of the hot path -->
<Card> <!-- OK to use in one-off places, not in 100-item lists -->
<!-- 3. The list itself is small -->
<template v-if="items.length < 20">
<FancyItem v-for="item in items" :key="item.id" />
</template>
<!-- 4. Virtualization is used (only ~20 items rendered at once) -->
<RecycleScroller :items="items">
<template #default="{ item }">
<ComplexItem :item="item" /> <!-- OK - only 20 instances exist -->
</template>
</RecycleScroller>
```
## Measuring Component Overhead
```javascript
// In development, profile component counts
import { onMounted, getCurrentInstance } from 'vue'
onMounted(() => {
const instance = getCurrentInstance()
let count = 0
function countComponents(vnode) {
if (vnode.component) count++
if (vnode.children) {
vnode.children.forEach(child => {
if (child.component || child.children) countComponents(child)
})
}
}
// Use Vue DevTools instead for accurate counts
console.log('Check Vue DevTools Components tab for instance counts')
})
```
## Alternatives to Wrapper Components
```vue
<!-- Instead of a <Button> component for styling: -->
<button class="btn btn-primary">Click</button>
<!-- Instead of a <Text> component: -->
<span class="text-body">{{ content }}</span>
<!-- Instead of layout wrapper components in lists: -->
<div class="flex items-center gap-2">
<!-- content -->
</div>
<!-- Use CSS classes or Tailwind instead of component abstractions for styling -->
```
## Impact Calculation
| List Size | Components per Item | Total Instances | Memory Impact |
|-----------|---------------------|-----------------|---------------|
| 100 items | 1 (flat) | 100 | Baseline |
| 100 items | 3 (nested) | 300 | ~3x memory |
| 100 items | 5 (deeply nested) | 500 | ~5x memory |
| 1000 items | 1 (flat) | 1000 | High |
| 1000 items | 5 (deeply nested) | 5000 | Very High |

View File

@@ -0,0 +1,182 @@
---
title: Use v-once and v-memo to Skip Unnecessary Updates
impact: MEDIUM
impactDescription: v-once skips all future updates for static content; v-memo conditionally memoizes subtrees
type: efficiency
tags: [vue3, performance, v-once, v-memo, optimization, directives]
---
# Use v-once and v-memo to Skip Unnecessary Updates
**Impact: MEDIUM** - Vue re-evaluates templates on every reactive change. For content that never changes or changes infrequently, `v-once` and `v-memo` tell Vue to skip updates, reducing render work.
Use `v-once` for truly static content and `v-memo` for conditionally-static content in lists.
## Task List
- Apply `v-once` to elements that use runtime data but never need updating
- Apply `v-memo` to list items that should only update on specific condition changes
- Verify memoized content doesn't need to respond to other state changes
- Profile with Vue DevTools to confirm update skipping
## v-once: Render Once, Never Update
**BAD:**
```vue
<template>
<!-- BAD: Re-evaluated on every parent re-render -->
<div class="terms-content">
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
<div v-html="termsContent"></div>
</div>
<!-- This content NEVER changes, but Vue checks it every render -->
<footer>
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
</footer>
</template>
```
**GOOD:**
```vue
<template>
<!-- GOOD: Rendered once, skipped on all future updates -->
<div class="terms-content" v-once>
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
<div v-html="termsContent"></div>
</div>
<!-- v-once tells Vue this never needs to update -->
<footer v-once>
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
</footer>
</template>
<script setup>
// These values are set once at component creation
const termsVersion = '2.1'
const termsContent = fetchedTermsHTML
const copyrightYear = 2024
const companyName = 'Acme Corp'
</script>
```
## v-memo: Conditional Memoization for Lists
**BAD:**
```vue
<template>
<!-- BAD: All items re-render when selectedId changes -->
<div v-for="item in list" :key="item.id">
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
</template>
```
**GOOD:**
```vue
<template>
<!-- GOOD: Items only re-render when their selection state changes -->
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id === selectedId]"
>
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([/* many items */])
const selectedId = ref(null)
// When selectedId changes:
// - Only the previously-selected item re-renders (selected: true -> false)
// - Only the newly-selected item re-renders (selected: false -> true)
// - All other items are SKIPPED (v-memo values unchanged)
</script>
```
## v-memo with Multiple Dependencies
```vue
<template>
<!-- Re-render only when item's selection OR editing state changes -->
<div
v-for="item in items"
:key="item.id"
v-memo="[item.id === selectedId, item.id === editingId]"
>
<ItemCard
:item="item"
:selected="item.id === selectedId"
:editing="item.id === editingId"
/>
</div>
</template>
<script setup>
const selectedId = ref(null)
const editingId = ref(null)
const items = ref([/* ... */])
</script>
```
## v-memo with Empty Array = v-once
```vue
<template>
<!-- v-memo="[]" is equivalent to v-once -->
<div v-for="item in staticList" :key="item.id" v-memo="[]">
{{ item.name }}
</div>
</template>
```
## When NOT to Use These Directives
```vue
<template>
<!-- DON'T: Content that DOES need to update -->
<div v-once>
<span>Count: {{ count }}</span> <!-- count won't update! -->
</div>
<!-- DON'T: When child components have their own reactive state -->
<div v-memo="[selected]">
<InputField v-model="item.name" /> <!-- v-model won't work properly -->
</div>
<!-- DON'T: When the memoization benefit is minimal -->
<span v-once>{{ simpleText }}</span> <!-- Overhead not worth it -->
</template>
```
## Performance Comparison
| Scenario | Without Directive | With v-once/v-memo |
|----------|-------------------|-------------------|
| Static header, parent re-renders 100x | Re-evaluated 100x | Evaluated 1x |
| 1000 items, selection changes | 1000 items re-render | 2 items re-render |
| Complex child component | Full re-render | Skipped if memoized |
## Debugging Memoized Components
```vue
<script setup>
import { onUpdated } from 'vue'
// This won't fire if v-memo prevents update
onUpdated(() => {
console.log('Component updated')
})
</script>
```

View File

@@ -0,0 +1,187 @@
---
title: Virtualize Large Lists to Avoid DOM Overload
impact: HIGH
impactDescription: Rendering thousands of list items creates excessive DOM nodes, causing slow renders and high memory usage
type: efficiency
tags: [vue3, performance, virtual-list, large-data, dom, optimization]
---
# Virtualize Large Lists to Avoid DOM Overload
**Impact: HIGH** - Rendering all items in a large list (hundreds or thousands) creates massive amounts of DOM nodes. Each node consumes memory, slows down initial render, and makes updates expensive. List virtualization only renders visible items, dramatically improving performance.
Use a virtualization library when dealing with lists that could exceed 50-100 items, especially if items have complex content.
## Task List
- Identify lists that render more than 50-100 items
- Install a virtualization library (vue-virtual-scroller, @tanstack/vue-virtual)
- Replace standard `v-for` with virtualized component
- Ensure list items have consistent or estimable heights
- Test with realistic data volumes during development
## Recommended Libraries
| Library | Best For | Notes |
|---------|----------|-------|
| `vue-virtual-scroller` | General use, easy setup | Most popular, good defaults |
| `@tanstack/vue-virtual` | Complex layouts, headless | Framework-agnostic, flexible |
| `vue-virtual-scroll-grid` | Grid layouts | 2D virtualization |
| `vueuc/VVirtualList` | Naive UI projects | Part of Naive UI ecosystem |
**BAD:**
```vue
<template>
<!-- BAD: Renders ALL 10,000 items immediately -->
<div class="user-list">
<UserCard
v-for="user in users"
:key="user.id"
:user="user"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import UserCard from './UserCard.vue'
const users = ref([])
onMounted(async () => {
// 10,000 DOM nodes created, browser struggles
users.value = await fetchAllUsers()
})
</script>
```
**GOOD:**
```vue
<template>
<!-- GOOD: Only renders ~20 visible items at a time -->
<RecycleScroller
class="user-list"
:items="users"
:item-size="80"
key-field="id"
v-slot="{ item }"
>
<UserCard :user="item" />
</RecycleScroller>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import UserCard from './UserCard.vue'
const users = ref([])
onMounted(async () => {
// 10,000 items in memory, but only ~20 DOM nodes
users.value = await fetchAllUsers()
})
</script>
<style scoped>
.user-list {
height: 600px; /* Container must have fixed height */
}
</style>
```
## Using @tanstack/vue-virtual
```vue
<template>
<div ref="parentRef" class="list-container">
<div
:style="{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative'
}"
>
<div
v-for="virtualRow in rowVirtualizer.getVirtualItems()"
:key="virtualRow.key"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}"
>
<UserCard :user="users[virtualRow.index]" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const users = ref([/* 10,000 users */])
const parentRef = ref(null)
const rowVirtualizer = useVirtualizer({
count: users.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 80, // Estimated row height
overscan: 5 // Render 5 extra items above/below viewport
})
</script>
<style scoped>
.list-container {
height: 600px;
overflow: auto;
}
</style>
```
## Dynamic Heights with vue-virtual-scroller
```vue
<template>
<!-- For variable height items, use DynamicScroller -->
<DynamicScroller
:items="messages"
:min-item-size="54"
key-field="id"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
>
<ChatMessage :message="item" />
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
<script setup>
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
</script>
```
## Performance Comparison
| Approach | 100 Items | 1,000 Items | 10,000 Items |
|----------|-----------|-------------|--------------|
| Regular v-for | ~100 DOM nodes | ~1,000 DOM nodes | ~10,000 DOM nodes |
| Virtualized | ~20 DOM nodes | ~20 DOM nodes | ~20 DOM nodes |
| Initial render | Fast | Slow | Very slow / crashes |
| Virtualized render | Fast | Fast | Fast |
## When NOT to Virtualize
- Lists under 50 items with simple content
- Lists where all items must be accessible to screen readers simultaneously
- Print layouts where all content must render
- SEO-critical content that must be in initial HTML

View File

@@ -0,0 +1,166 @@
---
title: Vue Plugin Best Practices
impact: MEDIUM
impactDescription: Incorrect plugin structure or injection key strategy causes install failures, collisions, and unsafe APIs
type: best-practice
tags: [vue3, plugins, provide-inject, typescript, dependency-injection]
---
# Vue Plugin Best Practices
**Impact: MEDIUM** - Vue plugins should follow the `app.use()` contract, expose explicit capabilities, and use collision-safe injection keys. This keeps plugin setup predictable and composable across large apps.
## Task List
- Export plugins as an object with `install()` or as an install function
- Use the `app` instance in `install()` to register components/directives/provides
- Type plugin APIs with `Plugin` (and options tuple types when needed)
- Use symbol keys (prefer `InjectionKey<T>`) for `provide/inject` in plugins
- Add a small typed composable wrapper for required injections to fail fast
## Structure Plugins for `app.use()`
A Vue plugin must be either:
- An object with `install(app, options?)`
- A function with the same signature
**BAD:**
```ts
const notAPlugin = {
doSomething() {}
}
app.use(notAPlugin)
```
**GOOD:**
```ts
import type { App } from 'vue'
interface PluginOptions {
prefix?: string
debug?: boolean
}
const myPlugin = {
install(app: App, options: PluginOptions = {}) {
const { prefix = 'my', debug = false } = options
if (debug) {
console.log('Installing myPlugin with prefix:', prefix)
}
app.provide('myPlugin', { prefix })
}
}
app.use(myPlugin, { prefix: 'custom', debug: true })
```
**GOOD:**
```ts
import type { App } from 'vue'
function simplePlugin(app: App, options?: { message: string }) {
app.config.globalProperties.$greet = () => options?.message ?? 'Hello!'
}
app.use(simplePlugin, { message: 'Welcome!' })
```
## Register Capabilities Explicitly in `install()`
Inside `install()`, wire behavior through Vue application APIs:
- `app.component()` for global components
- `app.directive()` for global directives
- `app.provide()` for injectable services and config
- `app.config.globalProperties` for optional global helpers (sparingly)
**BAD:**
```ts
const uselessPlugin = {
install(app, options) {
const service = createService(options)
}
}
```
**GOOD:**
```ts
const usefulPlugin = {
install(app, options) {
const service = createService(options)
app.provide(serviceKey, service)
}
}
```
## Type Plugin Contracts
Use Vue's `Plugin` type to keep install signatures and options type-safe.
```ts
import type { App, Plugin } from 'vue'
interface MyOptions {
apiKey: string
}
const myPlugin: Plugin<[MyOptions]> = {
install(app: App, options: MyOptions) {
app.provide(apiKeyKey, options.apiKey)
}
}
```
## Use Symbol Injection Keys in Plugins
String keys can collide (`'http'`, `'config'`, `'i18n'`). Use symbol keys with `InjectionKey<T>` so injections are unique and typed.
**BAD:**
```ts
export default {
install(app) {
app.provide('http', axios)
app.provide('config', appConfig)
}
}
```
**GOOD:**
```ts
import type { InjectionKey } from 'vue'
import type { AxiosInstance } from 'axios'
interface AppConfig {
apiUrl: string
timeout: number
}
export const httpKey: InjectionKey<AxiosInstance> = Symbol('http')
export const configKey: InjectionKey<AppConfig> = Symbol('appConfig')
export default {
install(app) {
app.provide(httpKey, axios)
app.provide(configKey, { apiUrl: '/api', timeout: 5000 })
}
}
```
## Provide Required Injection Helpers
Wrap required injections in composables that throw clear setup errors.
```ts
import { inject } from 'vue'
import { authKey, type AuthService } from '@/injection-keys'
export function useAuth(): AuthService {
const auth = inject(authKey)
if (!auth) {
throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?')
}
return auth
}
```

View File

@@ -0,0 +1,344 @@
---
title: Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch)
impact: MEDIUM
impactDescription: Clear reactivity choices keep state predictable and reduce unnecessary updates in Vue 3 apps
type: efficiency
tags: [vue3, reactivity, ref, reactive, shallowRef, computed, watch, watchEffect, external-state, best-practice]
---
# Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch)
**Impact: MEDIUM** - Choose the right reactive primitive first, derive with `computed`, and use watchers only for side effects.
This reference covers the core reactivity decisions for local state, external data, derived values, and effects.
## Task List
- Declare reactive state correctly
- Always use `shallowRef()` instead of `ref()` for primitive values
- Choose the correct reactive declaration method for objects/arrays/map/set
- Follow best practices for `reactive`
- Avoid destructuring from `reactive()` directly
- Watch correctly for `reactive`
- Follow best practices for `computed`
- Prefer `computed` over watcher-assigned derived refs
- Keep filtered/sorted derivations out of templates
- Use `computed` for reusable class/style logic
- Keep computed getters pure (no side effects) and put side effects in watchers
- Follow best practices for watchers
- Use `immediate: true` instead of duplicate initial calls
- Clean up async effects for watchers
## Declare reactive state correctly
### Always use `shallowRef()` instead of `ref()` for primitive values (string, number, boolean, null, etc.) for better performance.
**Incorrect:**
```ts
import { ref } from 'vue'
const count = ref(0)
```
**Correct:**
```ts
import { shallowRef } from 'vue'
const count = shallowRef(0)
```
### Choose the correct reactive declaration method for objects/arrays/map/set
Use `ref()` when you often **replace the entire value** (`state.value = newObj`) and still want deep reactivity inside it, usually used for:
- Frequently reassigned state (replace fetched object/list, reset to defaults, switch presets).
- Composable return values where updates happen mostly via `.value` reassignment.
Use `reactive()` when you mainly **mutate properties** and full replacement is uncommon, usually used for:
- “Single state object” patterns (stores/forms): `state.count++`, `state.items.push(...)`, `state.user.name = ...`.
- Situations where you want to avoid `.value` and update nested fields in place.
```ts
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Alice', age: 30 }
})
state.count++ // ✅ reactive
state.user.age = 31 // ✅ reactive
// ❌ avoid replacing the reactive object reference:
// state = reactive({ count: 1 })
```
Use `shallowRef()` when the value is **opaque / should not be proxied** (class instances, external library objects, very large nested data) and you only want updates to trigger when you **replace** `state.value` (no deep tracking), usually used for:
- Storing external instances/handles (SDK clients, class instances) without Vue proxying internals.
- Large data where you update by replacing the root reference (immutable-style updates).
```ts
import { shallowRef } from 'vue'
const user = shallowRef({ name: 'Alice', age: 30 })
user.value.age = 31 // ❌ not reactive
user.value = { name: 'Bob', age: 25 } // ✅ triggers update
```
Use `shallowReactive()` when you want **only top-level properties** reactive; nested objects remain raw, usually used for:
- Container objects where only top-level keys change and nested payloads should stay unmanaged/unproxied.
- Mixed structures where Vue tracks the wrapper object, but not deeply nested or foreign objects.
```ts
import { shallowReactive } from 'vue'
const state = shallowReactive({
count: 0,
user: { name: 'Alice', age: 30 }
})
state.count++ // ✅ reactive
state.user.age = 31 // ❌ not reactive
```
## Best practices for `reactive`
### Avoid destructuring from `reactive()` directly
**BAD:**
```ts
import { reactive } from 'vue'
const state = reactive({ count: 0 })
const { count } = state // ❌ disconnected from reactivity
```
### Watch correctly for reactive
**BAD:**
passing a non-getter value into `watch()`
```ts
import { reactive, watch } from 'vue'
const state = reactive({ count: 0 })
// ❌ watch expects a getter, ref, reactive object, or array of these
watch(state.count, () => { /* ... */ })
```
**GOOD:**
preserve reactivity with `toRefs()` and use a getter for `watch()`
```ts
import { reactive, toRefs, watch } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state) // ✅ count is a ref
watch(count, () => { /* ... */ }) // ✅
watch(() => state.count, () => { /* ... */ }) // ✅
```
## Best practices for `computed`
### Prefer `computed` over watcher-assigned derived refs
**BAD:**
```ts
import { ref, watchEffect } from 'vue'
const items = ref([{ price: 10 }, { price: 20 }])
const total = ref(0)
watchEffect(() => {
total.value = items.value.reduce((sum, item) => sum + item.price, 0)
})
```
**GOOD:**
```ts
import { ref, computed } from 'vue'
const items = ref([{ price: 10 }, { price: 20 }])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)
```
### Keep filtered/sorted derivations out of templates
**BAD:**
```vue
<template>
<li v-for="item in items.filter(item => item.active)" :key="item.id">
{{ item.name }}
</li>
<li v-for="item in getSortedItems()" :key="item.id">
{{ item.name }}
</li>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'B', active: true },
{ id: 2, name: 'A', active: false }
])
function getSortedItems() {
return [...items.value].sort((a, b) => a.name.localeCompare(b.name))
}
</script>
```
**GOOD:**
```vue
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'B', active: true },
{ id: 2, name: 'A', active: false }
])
const visibleItems = computed(() =>
items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
)
</script>
<template>
<li v-for="item in visibleItems" :key="item.id">
{{ item.name }}
</li>
</template>
```
### Use `computed` for reusable class/style logic
**BAD:**
```vue
<template>
<button :class="{ btn: true, 'btn-primary': type === 'primary' && !disabled, 'btn-disabled': disabled }">
{{ label }}
</button>
</template>
```
**GOOD:**
```vue
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: { type: String, default: 'primary' },
disabled: Boolean,
label: String
})
const buttonClasses = computed(() => ({
btn: true,
[`btn-${props.type}`]: !props.disabled,
'btn-disabled': props.disabled
}))
</script>
<template>
<button :class="buttonClasses">
{{ label }}
</button>
</template>
```
### Keep computed getters pure (no side effects) and put side effects in watchers instead
A computed getter should only derive a value. No mutation, no API calls, no storage writes, no event emits.
([Reference](https://vuejs.org/guide/essentials/computed.html#best-practices))
**BAD:**
side effects inside computed
```ts
const count = ref(0)
const doubled = computed(() => {
// ❌ side effect
if (count.value > 10) console.warn('Too big!')
return count.value * 2
})
```
**GOOD:**
pure computed + `watch()` for side effects
```ts
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (value) => {
if (value > 10) console.warn('Too big!')
})
```
## Best practices for watchers
### Use `immediate: true` instead of duplicate initial calls
**BAD:**
```ts
import { ref, watch, onMounted } from 'vue'
const userId = ref(1)
function loadUser(id) {
// ...
}
onMounted(() => loadUser(userId.value))
watch(userId, (id) => loadUser(id))
```
**GOOD:**
```ts
import { ref, watch } from 'vue'
const userId = ref(1)
watch(
userId,
(id) => loadUser(id),
{ immediate: true }
)
```
### Clean up async effects for watchers
When reacting to rapid changes (search boxes, filters), cancel the previous request.
**GOOD:**
```ts
const query = ref('')
const results = ref<string[]>([])
watch(query, async (q, _prev, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
results.value = await res.json()
})
```

View File

@@ -0,0 +1,201 @@
---
title: Render Function Patterns and Performance
impact: MEDIUM
impactDescription: Render functions require explicit patterns for lists, events, v-model, and performance to stay correct and maintainable
type: best-practice
tags: [vue3, render-function, h, v-model, directives, performance, jsx]
---
# Render Function Patterns and Performance
**Impact: MEDIUM** - Render functions are powerful but opt out of template compiler optimizations. Use them intentionally and apply the key patterns below to keep output correct and performant.
## Task List
- Prefer templates; use render functions only when templates cannot express the logic
- Always add stable keys when rendering lists with `h()`/JSX
- Use `withModifiers` / `withKeys` for event modifiers
- Implement `v-model` via `modelValue` + `onUpdate:modelValue`
- Apply custom directives with `withDirectives`
- Use functional components for stateless presentational UI
## Prefer templates over render functions
**BAD:**
```vue
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
const render = () => h('div', `Count: ${count.value}`)
</script>
```
**GOOD:**
```vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>
```
## Always add keys for list rendering
**BAD:**
```javascript
import { h, ref } from 'vue'
export default {
setup() {
const items = ref([{ id: 1, name: 'Apple' }])
return () => h('ul',
items.value.map(item => h('li', item.name))
)
}
}
```
**GOOD:**
```javascript
import { h, ref } from 'vue'
export default {
setup() {
const items = ref([{ id: 1, name: 'Apple' }])
return () => h('ul',
items.value.map(item => h('li', { key: item.id }, item.name))
)
}
}
```
## Use `withModifiers` / `withKeys` for event modifiers
**BAD:**
```javascript
import { h } from 'vue'
export default {
setup() {
const handleClick = (e) => {
e.stopPropagation()
e.preventDefault()
}
return () => h('button', { onClick: handleClick }, 'Click')
}
}
```
**GOOD:**
```javascript
import { h, withModifiers, withKeys } from 'vue'
export default {
setup() {
const handleClick = () => {}
const handleEnter = () => {}
return () => h('div', [
h('button', {
onClick: withModifiers(handleClick, ['stop', 'prevent'])
}, 'Click'),
h('input', {
onKeyup: withKeys(handleEnter, ['enter'])
})
])
}
}
```
## Implement `v-model` explicitly
**BAD:**
```javascript
import { h, ref } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
return () => h(CustomInput, { modelValue: text.value })
}
}
```
**GOOD:**
```javascript
import { h, ref } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
return () => h(CustomInput, {
modelValue: text.value,
'onUpdate:modelValue': (value) => { text.value = value }
})
}
}
```
## Use `withDirectives` for custom directives
**BAD:**
```javascript
import { h } from 'vue'
const vFocus = { mounted: (el) => el.focus() }
export default {
setup() {
return () => h('input', { 'v-focus': true })
}
}
```
**GOOD:**
```javascript
import { h, withDirectives } from 'vue'
const vFocus = { mounted: (el) => el.focus() }
export default {
setup() {
return () => withDirectives(h('input'), [[vFocus]])
}
}
```
## Prefer functional components for stateless UI
**BAD:**
```javascript
import { h } from 'vue'
export default {
setup() {
return () => h('span', { class: 'badge' }, 'New')
}
}
```
**GOOD:**
```javascript
import { h } from 'vue'
function Badge(props, { slots }) {
return h('span', { class: 'badge' }, slots.default?.())
}
Badge.props = ['variant']
export default Badge
```

View File

@@ -0,0 +1,310 @@
---
title: Single-File Component Structure, Styling, and Template Patterns
impact: MEDIUM
impactDescription: Consistent SFC structure and styling choices improve maintainability, tooling support, and render performance
type: best-practice
tags: [vue3, sfc, scoped-css, styles, build-tools, performance, template, v-html, v-for, computed, v-if, v-show]
---
# Single-File Component Structure, Styling, and Template Patterns
**Impact: MEDIUM** - Using SFCs with consistent structure and performant styling keeps components easier to maintain and avoids unnecessary render overhead.
## Task List
- Use `.vue` SFCs instead of separate `.js`/`.ts` and `.css` files for components
- Colocate template, script, and styles in the same SFC by default
- Use PascalCase for component names in templates and filenames
- Prefer component-scoped styles
- Prefer class selectors (not element selectors) in scoped CSS for performance
- Access DOM / component refs with `useTemplateRef()` in Vue 3.5+
- Use camelCase keys in `:style` bindings for consistency and IDE support
- Use `v-for` and `v-if` correctly
- Never use `v-html` with untrusted/user-provided content
- Choose `v-if` vs `v-show` based on toggle frequency and initial render cost
## Colocate template, script, and styles
**BAD:**
```
components/
├── UserCard.vue
├── UserCard.js
└── UserCard.css
```
**GOOD:**
```vue
<!-- components/UserCard.vue -->
<script setup>
import { computed } from 'vue'
const props = defineProps({
user: { type: Object, required: true }
})
const displayName = computed(() =>
`${props.user.firstName} ${props.user.lastName}`
)
</script>
<template>
<div class="user-card">
<h3 class="name">{{ displayName }}</h3>
</div>
</template>
<style scoped>
.user-card {
padding: 1rem;
}
.name {
margin: 0;
}
</style>
```
## Use PascalCase for component names
**BAD:**
```vue
<script setup>
import userProfile from './user-profile.vue'
</script>
<template>
<user-profile :user="currentUser" />
</template>
```
**GOOD:**
```vue
<script setup>
import UserProfile from './UserProfile.vue'
</script>
<template>
<UserProfile :user="currentUser" />
</template>
```
## Best practices for `<style>` block in SFCs
### Prefer component-scoped styles
- Use `<style scoped>` for styles that belong to a component.
- Keep **global CSS** in a dedicated file (e.g. `src/assets/main.css`) for resets, typography, tokens, etc.
- Use `:deep()` sparingly (edge cases only).
**BAD:**
```vue
<style>
/* ❌ leaks everywhere */
button { border-radius: 999px; }
</style>
```
**GOOD:**
```vue
<style scoped>
.button { border-radius: 999px; }
</style>
```
**GOOD:**
```css
/* src/assets/main.css */
/* ✅ resets, tokens, typography, app-wide rules */
:root { --radius: 999px; }
```
### Use class selectors in scoped CSS
**BAD:**
```vue
<template>
<article>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</article>
</template>
<style scoped>
article { max-width: 800px; }
h1 { font-size: 2rem; }
p { line-height: 1.6; }
</style>
```
**GOOD:**
```vue
<template>
<article class="article">
<h1 class="article-title">{{ title }}</h1>
<p class="article-subtitle">{{ subtitle }}</p>
</article>
</template>
<style scoped>
.article { max-width: 800px; }
.article-title { font-size: 2rem; }
.article-subtitle { line-height: 1.6; }
</style>
```
## Access DOM / component refs with `useTemplateRef()`
For Vue 3.5+: use `useTemplateRef()` to access template refs.
```vue
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const inputRef = useTemplateRef<HTMLInputElement>('input')
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="input" />
</template>
```
## Use camelCase in `:style` bindings
**BAD:**
```vue
<template>
<div :style="{ 'font-size': fontSize + 'px', 'background-color': bg }">
Content
</div>
</template>
```
**GOOD:**
```vue
<template>
<div :style="{ fontSize: fontSize + 'px', backgroundColor: bg }">
Content
</div>
</template>
```
## Use `v-for` and `v-if` correctly
### Always provide a stable `:key`
- Prefer primitive keys (`string | number`).
- Avoid using objects as keys.
**GOOD:**
```vue
<li v-for="item in items" :key="item.id">
<input v-model="item.text" />
</li>
```
### Avoid `v-if` and `v-for` on the same element
It leads to unclear intent and unnecessary work.
([Reference](https://vuejs.org/guide/essentials/list.html#v-for-with-v-if))
**To filter items**
**BAD:**
```vue
<li v-for="user in users" v-if="user.active" :key="user.id">
{{ user.name }}
</li>
```
**GOOD:**
```vue
<script setup lang="ts">
import { computed } from 'vue'
const activeUsers = computed(() => users.value.filter(u => u.active))
</script>
<template>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</template>
```
**To conditionally show/hide the entire list**
**GOOD:**
```vue
<ul v-if="shouldShowUsers">
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
```
## Never render untrusted HTML with `v-html`
**BAD:**
```vue
<template>
<!-- DANGEROUS: untrusted input can inject scripts -->
<article v-html="userProvidedContent"></article>
</template>
```
**GOOD:**
```vue
<script setup>
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const props = defineProps<{
trustedHtml?: string
plainText: string
}>()
const safeHtml = computed(() => DOMPurify.sanitize(props.trustedHtml ?? ''))
</script>
<template>
<!-- Preferred: escaped interpolation -->
<p>{{ props.plainText }}</p>
<!-- Only for trusted/sanitized HTML -->
<article v-html="safeHtml"></article>
</template>
```
## Choose `v-if` vs `v-show` by toggle behavior
**BAD:**
```vue
<template>
<!-- Frequent toggles with v-if cause repeated mount/unmount -->
<ComplexPanel v-if="isPanelOpen" />
<!-- Rarely shown content with v-show pays initial render cost -->
<AdminPanel v-show="isAdmin" />
</template>
```
**GOOD:**
```vue
<template>
<!-- Frequent toggles: keep in DOM, toggle display -->
<ComplexPanel v-show="isPanelOpen" />
<!-- Rare condition: lazy render only when true -->
<AdminPanel v-if="isAdmin" />
</template>
```

View File

@@ -0,0 +1,135 @@
---
title: State Management Strategy
impact: HIGH
impactDescription: Choosing the wrong store pattern can cause SSR request leaks, brittle mutation flows, and poor scaling
type: best-practice
tags: [vue3, state-management, pinia, composables, ssr, vueuse]
---
# State Management Strategy
**Impact: HIGH** - Use the lightest state solution that fits your app architecture. SPA-only apps can use lightweight global composables, while SSR/Nuxt apps should default to Pinia for request-safe isolation and predictable tooling.
## Task List
- Keep state local first, then promote to shared/global only when needed
- Use singleton composables only in non-SSR applications
- Expose global state as readonly and mutate through explicit actions
- Prefer Pinia for SSR/Nuxt, large apps, and advanced debugging/plugin needs
- Avoid exporting mutable module-level reactive state directly
## Choose the Lightest Store Approach
- **Feature composable:** Default for reusable logic with local/feature-level state.
- **Singleton composable or VueUse `createGlobalState`:** Small non-SSR apps needing shared app state.
- **Pinia:** SSR/Nuxt apps, medium-to-large apps, and cases requiring DevTools, plugins, or action tracing.
## Avoid Exporting Mutable Module State
**BAD:**
```ts
// store/cart.ts
import { reactive } from 'vue'
export const cart = reactive({
items: [] as Array<{ id: string; qty: number }>
})
```
**GOOD:**
```ts
// composables/useCartStore.ts
import { reactive, readonly } from 'vue'
let _store: ReturnType<typeof createCartStore> | null = null
function createCartStore() {
const state = reactive({
items: [] as Array<{ id: string; qty: number }>
})
function addItem(id: string, qty = 1) {
const existing = state.items.find((item) => item.id === id)
if (existing) {
existing.qty += qty
return
}
state.items.push({ id, qty })
}
return {
state: readonly(state),
addItem
}
}
export function useCartStore() {
if (!_store) _store = createCartStore()
return _store
}
```
## Do Not Use Runtime Singletons in SSR
Module singletons live for the runtime lifetime. In SSR this can leak state between requests.
**BAD:**
```ts
// shared singleton reused across requests
const cartStore = useCartStore()
export function useServerCart() {
return cartStore
}
```
**GOOD:**
> `pinia` dependency required.
```ts
// stores/cart.ts
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as Array<{ id: string; qty: number }>
}),
actions: {
addItem(id: string, qty = 1) {
const existing = this.items.find((item) => item.id === id)
if (existing) {
existing.qty += qty
return
}
this.items.push({ id, qty })
}
}
})
```
## Use `createGlobalState` for Small SPA Global State
> `@vueuse/core` dependency required.
If the app is non-SSR and already uses VueUse, `createGlobalState` removes singleton boilerplate.
```ts
import { createGlobalState } from '@vueuse/core'
import { computed, ref } from 'vue'
export const useAuthState = createGlobalState(() => {
const token = ref<string | null>(null)
const isAuthenticated = computed(() => token.value !== null)
function setToken(next: string | null) {
token.value = next
}
return {
token,
isAuthenticated,
setToken
}
})
```

View File

@@ -0,0 +1,187 @@
---
title: Avoid Expensive Operations in Updated Hook
impact: MEDIUM
impactDescription: Heavy computations in updated hook cause performance bottlenecks and potential infinite loops
type: capability
tags: [vue3, vue2, lifecycle, updated, performance, optimization, reactivity]
---
# Avoid Expensive Operations in Updated Hook
**Impact: MEDIUM** - The `updated` hook runs after every reactive state change that causes a re-render. Placing expensive operations, API calls, or state mutations here can cause severe performance degradation, infinite loops, and dropped frames below the optimal 60fps threshold.
Use `updated`/`onUpdated` sparingly for post-DOM-update operations that cannot be handled by watchers or computed properties. For most reactive data handling, prefer watchers (`watch`/`watchEffect`) which provide more control over what triggers the callback.
## Task List
- Never perform API calls in updated hook
- Never mutate reactive state inside updated (causes infinite loops)
- Use conditional checks to verify updates are relevant before acting
- Prefer `watch` or `watchEffect` for reacting to specific data changes
- Use throttling/debouncing if updated operations are expensive
- Reserve updated for low-level DOM synchronization tasks
**BAD:**
```javascript
// BAD: API call in updated - fires on every re-render
export default {
data() {
return { items: [], lastUpdate: null }
},
updated() {
// This runs after every single state change!
fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(this.items)
})
}
}
```
```javascript
// BAD: State mutation in updated - infinite loop
export default {
data() {
return { renderCount: 0 }
},
updated() {
// This causes another update, which triggers updated again!
this.renderCount++ // Infinite loop
}
}
```
```javascript
// BAD: Heavy computation on every update
export default {
updated() {
// Expensive operation runs on every keystroke, every state change
this.processedData = this.heavyComputation(this.rawData)
this.analytics = this.calculateMetrics(this.allData)
}
}
```
**GOOD:**
```javascript
import debounce from 'lodash-es/debounce'
// GOOD: Use watcher for specific data changes
export default {
data() {
return { items: [] }
},
watch: {
// Only fires when items actually changes
items: {
handler(newItems) {
this.syncToServer(newItems)
},
deep: true
}
},
methods: {
syncToServer: debounce(function(items) {
fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(items)
})
}, 500)
}
}
```
```vue
<!-- GOOD: Composition API with targeted watchers -->
<script setup>
import { ref, watch, onUpdated } from 'vue'
import { useDebounceFn } from '@vueuse/core'
const items = ref([])
const scrollContainer = ref(null)
// Watch specific data - not all updates
watch(items, (newItems) => {
syncToServer(newItems)
}, { deep: true })
const syncToServer = useDebounceFn((items) => {
fetch('/api/sync', { method: 'POST', body: JSON.stringify(items) })
}, 500)
// Only use onUpdated for DOM synchronization
onUpdated(() => {
// Scroll to bottom only if content changed height
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
})
</script>
```
```javascript
// GOOD: Conditional check in updated hook
export default {
data() {
return {
content: '',
lastSyncedContent: ''
}
},
updated() {
// Only act if specific condition is met
if (this.content !== this.lastSyncedContent) {
this.syncContent()
this.lastSyncedContent = this.content
}
},
methods: {
syncContent: debounce(function() {
// Sync logic
}, 300)
}
}
```
## Valid Use Cases for Updated Hook
```javascript
// GOOD: Low-level DOM synchronization
export default {
updated() {
// Sync third-party library with Vue's DOM
this.thirdPartyWidget.refresh()
// Update scroll position after content change
this.$nextTick(() => {
this.maintainScrollPosition()
})
}
}
```
## Prefer Computed Properties for Derived Data
```javascript
// BAD: Calculating derived data in updated
export default {
data() {
return { numbers: [1, 2, 3, 4, 5] }
},
updated() {
this.sum = this.numbers.reduce((a, b) => a + b, 0) // Causes another update!
}
}
// GOOD: Use computed property instead
export default {
data() {
return { numbers: [1, 2, 3, 4, 5] }
},
computed: {
sum() {
return this.numbers.reduce((a, b) => a + b, 0)
}
}
}
```

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 hyf0, SerKo <https://github.com/serkodev>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,23 @@
---
name: vue-router-best-practices
description: "Vue Router 4 patterns, navigation guards, route params, and route-component lifecycle interactions."
version: 1.0.0
license: MIT
author: github.com/vuejs-ai
---
Vue Router best practices, common gotchas, and navigation patterns.
### Navigation Guards
- Navigating between same route with different params → See [router-beforeenter-no-param-trigger](reference/router-beforeenter-no-param-trigger.md)
- Accessing component instance in beforeRouteEnter guard → See [router-beforerouteenter-no-this](reference/router-beforerouteenter-no-this.md)
- Navigation guard making API calls without awaiting → See [router-guard-async-await-pattern](reference/router-guard-async-await-pattern.md)
- Users trapped in infinite redirect loops → See [router-navigation-guard-infinite-loop](reference/router-navigation-guard-infinite-loop.md)
- Navigation guard using deprecated next() function → See [router-navigation-guard-next-deprecated](reference/router-navigation-guard-next-deprecated.md)
### Route Lifecycle
- Stale data when navigating between same route → See [router-param-change-no-lifecycle](reference/router-param-change-no-lifecycle.md)
- Event listeners persisting after component unmounts → See [router-simple-routing-cleanup](reference/router-simple-routing-cleanup.md)
### Setup
- Building production single-page application → See [router-use-vue-router-for-production](reference/router-use-vue-router-for-production.md)

View File

@@ -0,0 +1,5 @@
# Sync Info
- **Source:** `vendor/vuejs-ai/skills/vue-router-best-practices`
- **Git SHA:** `f3dd1bf4d3ac78331bdc903e4519d561c538ca6a`
- **Synced:** 2026-03-16

View File

@@ -0,0 +1,167 @@
---
title: Per-Route beforeEnter Guards Ignore Param/Query Changes
impact: MEDIUM
impactDescription: Route-level beforeEnter guards don't fire when only params, query, or hash change, causing unexpected bypasses of validation logic
type: gotcha
tags: [vue3, vue-router, navigation-guards, params, query]
---
# Per-Route beforeEnter Guards Ignore Param/Query Changes
**Impact: MEDIUM** - The `beforeEnter` guard defined in route configuration only triggers when entering a route from a DIFFERENT route. Changes to params, query strings, or hash within the same route do NOT trigger `beforeEnter`, potentially bypassing important validation logic.
## Task Checklist
- [ ] Use in-component `onBeforeRouteUpdate` for param/query changes
- [ ] Or use global `beforeEach` with route.params/query checks
- [ ] Document which guards protect which scenarios
- [ ] Test navigation between same route with different params
## The Problem
```javascript
// router.js
const routes = [
{
path: '/orders/:id',
component: OrderDetail,
beforeEnter: async (to, from) => {
// This runs when entering from /products
// But NOT when navigating from /orders/1 to /orders/2!
const order = await checkOrderAccess(to.params.id)
if (!order.canView) {
return '/unauthorized'
}
}
}
]
```
**Scenario:**
1. User navigates from `/products` to `/orders/1` - beforeEnter runs, access checked
2. User navigates from `/orders/1` to `/orders/2` - beforeEnter DOES NOT run!
3. User might access order they don't have permission for!
## What Triggers beforeEnter vs. What Doesn't
| Navigation | beforeEnter fires? |
|------------|-------------------|
| `/products``/orders/1` | YES |
| `/orders/1``/orders/2` | NO |
| `/orders/1``/orders/1?tab=details` | NO |
| `/orders/1#section``/orders/1#other` | NO |
| `/orders/1``/products``/orders/2` | YES (leaving and re-entering) |
## Solution 1: Add In-Component Guard
```vue
<!-- OrderDetail.vue -->
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'
// Handle param changes within the same route
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
const order = await checkOrderAccess(to.params.id)
if (!order.canView) {
return '/unauthorized'
}
}
})
</script>
```
## Solution 2: Use Global beforeEach Instead
```javascript
// router.js
router.beforeEach(async (to, from) => {
// Handle all order access checks globally
if (to.name === 'OrderDetail') {
// This runs on EVERY navigation to this route, including param changes
const order = await checkOrderAccess(to.params.id)
if (!order.canView) {
return '/unauthorized'
}
}
})
```
## Solution 3: Combine Both Guards
```javascript
// router.js - For entering from different route
const routes = [
{
path: '/orders/:id',
component: OrderDetail,
beforeEnter: (to) => validateOrderAccess(to.params.id)
}
]
// In component - For param changes within route
// OrderDetail.vue
onBeforeRouteUpdate((to) => validateOrderAccess(to.params.id))
// Shared validation function
async function validateOrderAccess(orderId) {
const order = await checkOrderAccess(orderId)
if (!order.canView) {
return '/unauthorized'
}
}
```
## Solution 4: Use beforeEnter with Array of Guards
```javascript
// guards/orderGuards.js
export const orderAccessGuard = async (to) => {
const order = await checkOrderAccess(to.params.id)
if (!order.canView) {
return '/unauthorized'
}
}
// router.js
const routes = [
{
path: '/orders/:id',
component: OrderDetail,
beforeEnter: [orderAccessGuard] // Can add multiple guards
}
]
// Still need in-component guard for param changes!
```
## Full Navigation Guard Execution Order
Understanding when each guard type fires:
```
1. beforeRouteLeave (in-component, leaving component)
2. beforeEach (global)
3. beforeEnter (per-route, ONLY when entering from different route)
4. beforeRouteEnter (in-component, entering component)
5. beforeResolve (global)
6. afterEach (global, after navigation confirmed)
For param/query changes on same route:
1. beforeRouteUpdate (in-component) - ONLY this fires!
2. beforeEach (global)
3. beforeResolve (global)
4. afterEach (global)
```
## Key Points
1. **beforeEnter is for route ENTRY only** - Not for within-route changes
2. **Use onBeforeRouteUpdate for param changes** - This is the in-component solution
3. **Global beforeEach always runs** - Good for centralized validation
4. **Test param change scenarios** - Easy to miss during development
5. **Consider security implications** - Param-based access control needs both guards
## Reference
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
- [Vue Router Per-Route Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html#per-route-guard)

View File

@@ -0,0 +1,176 @@
---
title: beforeRouteEnter Cannot Access Component Instance
impact: MEDIUM
impactDescription: The beforeRouteEnter guard runs before component creation, so 'this' is undefined; use the next callback to access the instance
type: gotcha
tags: [vue3, vue-router, navigation-guards, lifecycle, this]
---
# beforeRouteEnter Cannot Access Component Instance
**Impact: MEDIUM** - The `beforeRouteEnter` in-component navigation guard executes BEFORE the component is created, meaning you cannot access `this` or any component instance properties. This is the ONLY navigation guard that supports a callback in the `next()` function to access the component instance after navigation.
## Task Checklist
- [ ] Use next(vm => ...) callback to access component instance
- [ ] Or use composition API guards which have different patterns
- [ ] Move data fetching logic appropriately based on timing needs
- [ ] Consider using global guards for data that doesn't need component access
## The Problem
```javascript
// Options API - WRONG: this is undefined
export default {
data() {
return { user: null }
},
beforeRouteEnter(to, from, next) {
// BUG: this is undefined here - component doesn't exist yet!
this.user = await fetchUser(to.params.id) // ERROR!
next()
}
}
```
## Solution: Use next() Callback (Options API)
```javascript
// Options API - CORRECT: Use callback to access vm
export default {
data() {
return {
user: null,
loading: true
}
},
beforeRouteEnter(to, from, next) {
// Fetch data before component exists
fetchUser(to.params.id)
.then(user => {
// Pass callback to next() - receives component instance as 'vm'
next(vm => {
vm.user = user
vm.loading = false
})
})
.catch(error => {
next(vm => {
vm.error = error
vm.loading = false
})
})
}
}
```
## Solution: Async beforeRouteEnter (Options API)
```javascript
export default {
data() {
return { userData: null }
},
async beforeRouteEnter(to, from, next) {
try {
const user = await fetchUser(to.params.id)
// Still need callback for component access
next(vm => {
vm.userData = user
})
} catch (error) {
// Redirect on error
next('/error')
}
}
}
```
## Composition API Alternative
In Composition API with `<script setup>`, you cannot use `beforeRouteEnter` directly because the component instance is being set up. Use different patterns instead:
```vue
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
const route = useRoute()
const user = ref(null)
const loading = ref(true)
// Option 1: Fetch in onMounted (after component exists)
onMounted(async () => {
user.value = await fetchUser(route.params.id)
loading.value = false
})
// Option 2: Handle subsequent param changes
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
loading.value = true
user.value = await fetchUser(to.params.id)
loading.value = false
}
})
</script>
```
## Route-Level Data Fetching
For data that should load BEFORE navigation, use route-level guards:
```javascript
// router.js
const routes = [
{
path: '/users/:id',
component: () => import('./UserProfile.vue'),
beforeEnter: async (to, from) => {
try {
// Store data for component to access
const user = await fetchUser(to.params.id)
to.meta.user = user // Attach to route meta
} catch (error) {
return '/error'
}
}
}
]
```
```vue
<!-- UserProfile.vue -->
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
// Access pre-fetched data from meta
const user = route.meta.user
</script>
```
## Comparison of Navigation Guards
| Guard | Has `this`/component? | Can delay navigation? | Use case |
|-------|----------------------|----------------------|----------|
| beforeRouteEnter | NO (use next callback) | YES | Pre-fetch, redirect if data missing |
| beforeRouteUpdate | YES | YES | React to param changes |
| beforeRouteLeave | YES | YES | Unsaved changes warning |
| Global beforeEach | NO | YES | Auth checks |
| Route beforeEnter | NO | YES | Route-specific validation |
## Key Points
1. **beforeRouteEnter runs before component creation** - No access to `this`
2. **Use next(vm => ...) callback** - Only way to access component instance
3. **Composition API has limitations** - Use onMounted or global guards instead
4. **Consider route meta for pre-fetched data** - Clean separation of concerns
5. **beforeRouteUpdate and beforeRouteLeave have component access** - They run when component exists
## Reference
- [Vue Router In-Component Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html#in-component-guards)
- [Vue Router Navigation Resolution Flow](https://router.vuejs.org/guide/advanced/navigation-guards.html#the-full-navigation-resolution-flow)

View File

@@ -0,0 +1,227 @@
---
title: Async Navigation Guards Require Proper Promise Handling
impact: MEDIUM
impactDescription: Unawaited promises in guards cause navigation to complete before async checks finish, allowing unauthorized access or missing data
type: gotcha
tags: [vue3, vue-router, navigation-guards, async, promises]
---
# Async Navigation Guards Require Proper Promise Handling
**Impact: MEDIUM** - Navigation guards that perform async operations (API calls, auth checks) must properly handle promises. If you don't await async operations or return the promise, navigation completes before your check finishes, potentially allowing unauthorized access or navigating with incomplete data.
## Task Checklist
- [ ] Use async/await in navigation guards
- [ ] Return the promise if not using async/await
- [ ] Add loading states for long async operations
- [ ] Implement timeouts for slow API calls
- [ ] Handle errors to prevent navigation hanging
## The Problem
```javascript
// WRONG: Not awaiting - navigation proceeds immediately
router.beforeEach((to, from) => {
if (to.meta.requiresAuth) {
checkAuth() // This returns a Promise but we're not waiting!
// Navigation continues before checkAuth completes
}
})
// WRONG: Async function but forgot return
router.beforeEach(async (to, from) => {
if (to.meta.requiresAuth) {
const isValid = await checkAuth()
if (!isValid) {
// This redirect might happen after navigation already completed!
return '/login'
}
}
// Missing return - implicitly returns undefined, allowing navigation
})
```
## Solution: Proper Async/Await Pattern
```javascript
// CORRECT: Async function with proper returns
router.beforeEach(async (to, from) => {
if (to.meta.requiresAuth) {
try {
const isAuthenticated = await checkAuth()
if (!isAuthenticated) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
} catch (error) {
console.error('Auth check failed:', error)
return { name: 'Error', params: { message: 'Authentication failed' } }
}
}
// Explicitly return nothing to proceed
return true
})
```
## Solution: Promise-Based Pattern (Alternative)
```javascript
// CORRECT: Return promise explicitly
router.beforeEach((to, from) => {
if (to.meta.requiresAuth) {
return checkAuth()
.then(isAuthenticated => {
if (!isAuthenticated) {
return { name: 'Login' }
}
})
.catch(error => {
console.error('Auth check failed:', error)
return { name: 'Error' }
})
}
})
```
## Loading State During Async Guards
```javascript
// app/composables/useNavigationLoading.js
import { ref } from 'vue'
const isNavigating = ref(false)
export function useNavigationLoading() {
return { isNavigating }
}
export function setupNavigationLoading(router) {
router.beforeEach(() => {
isNavigating.value = true
})
router.afterEach(() => {
isNavigating.value = false
})
router.onError(() => {
isNavigating.value = false
})
}
```
```vue
<!-- App.vue -->
<script setup>
import { useNavigationLoading } from '@/composables/useNavigationLoading'
const { isNavigating } = useNavigationLoading()
</script>
<template>
<LoadingBar v-if="isNavigating" />
<router-view />
</template>
```
## Timeout Pattern for Slow APIs
```javascript
// CORRECT: Add timeout to prevent indefinite waiting
function withTimeout(promise, ms = 5000) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), ms)
)
])
}
router.beforeEach(async (to, from) => {
if (to.meta.requiresAuth) {
try {
const isValid = await withTimeout(checkAuth(), 5000)
if (!isValid) {
return '/login'
}
} catch (error) {
if (error.message === 'Request timeout') {
// Let user through but show warning
console.warn('Auth check timed out')
} else {
return '/login'
}
}
}
})
```
## Multiple Async Checks
```javascript
// CORRECT: Run independent checks in parallel
router.beforeEach(async (to, from) => {
if (to.meta.requiresAuth && to.meta.requiresSubscription) {
try {
const [isAuthenticated, hasSubscription] = await Promise.all([
checkAuth(),
checkSubscription()
])
if (!isAuthenticated) {
return '/login'
}
if (!hasSubscription) {
return '/subscribe'
}
} catch (error) {
return '/error'
}
}
})
```
## Error Handling Best Practices
```javascript
router.beforeEach(async (to, from) => {
try {
// Your async logic here
await performChecks(to)
} catch (error) {
// Always handle errors to prevent navigation from hanging
if (error.response?.status === 401) {
return '/login'
}
if (error.response?.status === 403) {
return '/forbidden'
}
if (error.code === 'NETWORK_ERROR') {
// Offline - maybe allow navigation but show warning
return true
}
// Unknown error - redirect to error page
console.error('Navigation guard error:', error)
return { name: 'Error', state: { error: error.message } }
}
})
```
## Key Points
1. **Always await async operations** - Otherwise navigation proceeds immediately
2. **Return values matter** - Return route to redirect, false to cancel, true/undefined to proceed
3. **Handle all error cases** - Uncaught errors can hang navigation
4. **Add timeouts** - Slow APIs shouldn't block navigation indefinitely
5. **Show loading state** - Users need feedback during async checks
6. **Parallelize independent checks** - Use Promise.all for better performance
## Reference
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
- [Vue Router Navigation Failures](https://router.vuejs.org/guide/advanced/navigation-failures.html)

View File

@@ -0,0 +1,187 @@
---
title: Navigation Guard Infinite Redirect Loops
impact: HIGH
impactDescription: Misconfigured navigation guards can trap users in infinite redirect loops, crashing the browser or making the app unusable
type: gotcha
tags: [vue3, vue-router, navigation-guards, redirect, debugging]
---
# Navigation Guard Infinite Redirect Loops
**Impact: HIGH** - A common mistake in navigation guards is creating conditions that cause infinite redirects. Vue Router will detect this and show a warning, but in production, it can crash the browser or create a broken user experience.
## Task Checklist
- [ ] Always check if already on target route before redirecting
- [ ] Test guard logic with all possible navigation scenarios
- [ ] Add route meta to control which routes need protection
- [ ] Use Vue Router devtools to debug redirect chains
## The Problem
```javascript
// WRONG: Infinite loop - always redirects to login, even when on login!
router.beforeEach((to, from) => {
if (!isAuthenticated()) {
return '/login' // Redirects to /login, which triggers guard again...
}
})
// WRONG: Circular redirect between two routes
router.beforeEach((to, from) => {
if (to.path === '/dashboard' && !hasProfile()) {
return '/profile'
}
if (to.path === '/profile' && !isVerified()) {
return '/dashboard' // Back to dashboard, which goes to profile...
}
})
```
**Error you'll see:**
```
[Vue Router warn]: Detected an infinite redirection in a navigation guard when going from "/" to "/login". Aborting to avoid a Stack Overflow.
```
## Solution 1: Exclude Target Route
```javascript
// CORRECT: Don't redirect if already going to login
router.beforeEach((to, from) => {
if (!isAuthenticated() && to.path !== '/login') {
return '/login'
}
})
// CORRECT: Use route name for cleaner check
router.beforeEach((to, from) => {
const publicPages = ['Login', 'Register', 'ForgotPassword']
if (!isAuthenticated() && !publicPages.includes(to.name)) {
return { name: 'Login' }
}
})
```
## Solution 2: Use Route Meta Fields
```javascript
// router.js
const routes = [
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false }
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true }
},
{
path: '/public',
name: 'PublicPage',
component: PublicPage,
meta: { requiresAuth: false }
}
]
// Guard checks meta field
router.beforeEach((to, from) => {
// Only redirect if route requires auth
if (to.meta.requiresAuth && !isAuthenticated()) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})
```
## Solution 3: Handle Redirect Chains Carefully
```javascript
// CORRECT: Break potential circular redirects
router.beforeEach((to, from) => {
// Prevent redirect loops by tracking redirect depth
const redirectCount = to.query._redirectCount || 0
if (redirectCount > 3) {
console.error('Too many redirects, stopping at:', to.path)
return '/error' // Escape hatch
}
if (needsRedirect(to)) {
return {
path: getRedirectTarget(to),
query: { ...to.query, _redirectCount: redirectCount + 1 }
}
}
})
```
## Solution 4: Centralized Redirect Logic
```javascript
// guards/auth.js
export function createAuthGuard(router) {
const publicRoutes = new Set(['Login', 'Register', 'ForgotPassword', 'ResetPassword'])
const guestOnlyRoutes = new Set(['Login', 'Register'])
router.beforeEach((to, from) => {
const isPublic = publicRoutes.has(to.name)
const isGuestOnly = guestOnlyRoutes.has(to.name)
const isLoggedIn = isAuthenticated()
// Not logged in, trying to access protected route
if (!isLoggedIn && !isPublic) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
// Logged in, trying to access guest-only route (like login page)
if (isLoggedIn && isGuestOnly) {
return { name: 'Dashboard' }
}
// All other cases: proceed
})
}
```
## Debugging Redirect Loops
```javascript
// Add logging to understand the redirect chain
router.beforeEach((to, from) => {
console.log(`Navigation: ${from.path} -> ${to.path}`)
console.log('Auth state:', isAuthenticated())
console.log('Route meta:', to.meta)
// Your guard logic here
})
// Or use afterEach for confirmed navigations
router.afterEach((to, from) => {
console.log(`Navigated: ${from.path} -> ${to.path}`)
})
```
## Common Redirect Loop Patterns
| Pattern | Problem | Fix |
|---------|---------|-----|
| Auth check without exclusion | Login redirects to login | Exclude `/login` from check |
| Role-based with circular deps | Admin -> User -> Admin | Use single source of truth for role requirements |
| Onboarding flow | Step 1 -> Step 2 -> Step 1 | Track completion state properly |
| Redirect query handling | Reading redirect creates new redirect | Process redirect only once |
## Key Points
1. **Always exclude the target route** - Never redirect to a route that would trigger the same redirect
2. **Use route meta fields** - Cleaner than path string comparisons
3. **Test edge cases** - Direct URL access, refresh, back button
4. **Add logging during development** - Helps trace redirect chains
5. **Have an escape hatch** - Error page or max redirect count
## Reference
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
- [Vue Router Route Meta Fields](https://router.vuejs.org/guide/advanced/meta.html)

View File

@@ -0,0 +1,150 @@
---
title: Vue Router Navigation Guard next() Function Deprecated
impact: HIGH
impactDescription: Using the deprecated next() function incorrectly causes navigation to hang, infinite loops, or silent failures
type: gotcha
tags: [vue3, vue-router, navigation-guards, migration, async]
---
# Vue Router Navigation Guard next() Function Deprecated
**Impact: HIGH** - The third `next()` argument in navigation guards is deprecated in Vue Router 4. While still supported for backward compatibility, using it incorrectly is one of the most common sources of bugs: calling it multiple times, forgetting to call it, or calling it conditionally without proper logic.
## Task Checklist
- [ ] Refactor guards to use return-based syntax instead of next()
- [ ] Remove all next() calls from navigation guards
- [ ] Use async/await pattern for asynchronous checks
- [ ] Return false to cancel, return route to redirect, return nothing to proceed
## The Problem
```javascript
// WRONG: Using deprecated next() function
router.beforeEach((to, from, next) => {
if (!isAuthenticated) {
next('/login') // Easy to forget this call
}
// BUG: next() not called when authenticated - navigation hangs!
})
// WRONG: Multiple next() calls
router.beforeEach((to, from, next) => {
if (!isAuthenticated) {
next('/login')
}
next() // BUG: Called twice when not authenticated!
})
// WRONG: next() in async code without proper handling
router.beforeEach(async (to, from, next) => {
const user = await fetchUser()
if (!user) {
next('/login')
}
next() // Still gets called even after redirect!
})
```
## Solution: Use Return-Based Guards
```javascript
// CORRECT: Return-based syntax (modern Vue Router 4+)
router.beforeEach((to, from) => {
if (!isAuthenticated) {
return '/login' // Redirect
}
// Return nothing (undefined) to proceed
})
// CORRECT: Return false to cancel navigation
router.beforeEach((to, from) => {
if (hasUnsavedChanges) {
return false // Cancel navigation
}
})
// CORRECT: Async with return-based syntax
router.beforeEach(async (to, from) => {
const user = await fetchUser()
if (!user) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
// Proceed with navigation
})
```
## Return Values Explained
```javascript
router.beforeEach((to, from) => {
// Return nothing/undefined - allow navigation
return
// Return false - cancel navigation, stay on current route
return false
// Return string path - redirect to path
return '/login'
// Return route object - redirect with full control
return { name: 'Login', query: { redirect: to.fullPath } }
// Return Error - cancel and trigger router.onError()
return new Error('Navigation cancelled')
})
```
## If You Must Use next() (Legacy Code)
If maintaining legacy code that uses `next()`, follow these rules strictly:
```javascript
// CORRECT: Exactly one next() call per code path
router.beforeEach((to, from, next) => {
if (!isAuthenticated) {
next('/login')
return // CRITICAL: Exit after calling next()
}
if (!hasPermission(to)) {
next('/forbidden')
return // CRITICAL: Exit after calling next()
}
next() // Only reached if all checks pass
})
```
## Error Handling Pattern
```javascript
router.beforeEach(async (to, from) => {
try {
await validateAccess(to)
// Proceed
} catch (error) {
if (error.status === 401) {
return '/login'
}
if (error.status === 403) {
return '/forbidden'
}
// Log error and proceed anyway (or return false)
console.error('Access validation failed:', error)
return false
}
})
```
## Key Points
1. **Prefer return-based syntax** - Cleaner, less error-prone, modern standard
2. **next() must be called exactly once** - If using legacy syntax, ensure single call per path
3. **Always return/exit after redirect** - Prevent multiple navigation actions
4. **Async guards work naturally** - Just return the redirect route or nothing
5. **Test all code paths** - Each branch must result in either return or next()
## Reference
- [Vue Router Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html)
- [RFC: Remove next() from Navigation Guards](https://github.com/vuejs/rfcs/discussions/302)

View File

@@ -0,0 +1,181 @@
---
title: Route Param Changes Do Not Trigger Lifecycle Hooks
impact: HIGH
impactDescription: Navigating between routes with different params reuses the component instance, skipping created/mounted hooks and leaving stale data
type: gotcha
tags: [vue3, vue-router, lifecycle, params, reactivity]
---
# Route Param Changes Do Not Trigger Lifecycle Hooks
**Impact: HIGH** - When navigating between routes that use the same component (e.g., `/users/1` to `/users/2`), Vue Router reuses the existing component instance for performance. This means `onMounted`, `created`, and other lifecycle hooks do NOT fire, leaving you with stale data from the previous route.
## Task Checklist
- [ ] Use `watch` on route params for data fetching
- [ ] Or use `onBeforeRouteUpdate` in-component guard
- [ ] Or use `:key="route.params.id"` to force re-creation (less efficient)
- [ ] Never rely solely on `onMounted` for route-param-dependent data
## The Problem
```vue
<!-- UserProfile.vue - Used for /users/:id -->
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const user = ref(null)
// BUG: Only runs once when component first mounts!
// Navigating from /users/1 to /users/2 does NOT trigger this
onMounted(async () => {
user.value = await fetchUser(route.params.id)
})
</script>
<template>
<div>
<!-- Still shows User 1 data when navigating to /users/2! -->
<h1>{{ user?.name }}</h1>
</div>
</template>
```
**Scenario:**
1. Visit `/users/1` - Component mounts, fetches User 1 data
2. Navigate to `/users/2` - Component is REUSED, onMounted doesn't run
3. UI still shows User 1's data!
## Solution 1: Watch Route Params (Recommended)
```vue
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const user = ref(null)
const loading = ref(false)
// Watch for param changes - handles both initial load and navigation
watch(
() => route.params.id,
async (newId) => {
loading.value = true
user.value = await fetchUser(newId)
loading.value = false
},
{ immediate: true } // Run immediately for initial load
)
</script>
```
## Solution 2: Use onBeforeRouteUpdate Guard
```vue
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
const route = useRoute()
const user = ref(null)
async function loadUser(id) {
user.value = await fetchUser(id)
}
// Initial load
onMounted(() => loadUser(route.params.id))
// Handle param changes within same route
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
await loadUser(to.params.id)
}
})
</script>
```
## Solution 3: Force Component Re-creation with Key
```vue
<!-- App.vue or parent component -->
<template>
<router-view :key="$route.fullPath" />
</template>
```
**Tradeoffs:**
- Simple but less performant
- Destroys and recreates component on every param change
- Loses component state
- Use only when component state should reset completely
## Solution 4: Composable for Route-Reactive Data
```javascript
// composables/useRouteData.js
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
export function useRouteData(paramName, fetcher) {
const route = useRoute()
const data = ref(null)
const loading = ref(false)
const error = ref(null)
watch(
() => route.params[paramName],
async (id) => {
if (!id) return
loading.value = true
error.value = null
try {
data.value = await fetcher(id)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
},
{ immediate: true }
)
return { data, loading, error }
}
```
```vue
<!-- Usage in component -->
<script setup>
import { useRouteData } from '@/composables/useRouteData'
import { fetchUser } from '@/api/users'
const { data: user, loading, error } = useRouteData('id', fetchUser)
</script>
```
## What Triggers vs. What Doesn't
| Navigation Type | Lifecycle Hooks | beforeRouteUpdate | Watch on params |
|----------------|-----------------|-------------------|-----------------|
| `/users/1` to `/posts/1` | YES | NO | YES |
| `/users/1` to `/users/2` | NO | YES | YES |
| `/users/1?tab=a` to `/users/1?tab=b` | NO | YES | NO (different watch) |
| `/users/1` to `/users/1` (same) | NO | NO | NO |
## Key Points
1. **Same route, different params = same component instance** - This is a performance optimization
2. **Lifecycle hooks only fire once** - When component first mounts
3. **Use `watch` with `immediate: true`** - Covers both initial load and updates
4. **`onBeforeRouteUpdate` is navigation-aware** - Good for data that must load before view updates
5. **`:key="route.fullPath"` is a sledgehammer** - Use only when necessary
## Reference
- [Vue Router Dynamic Route Matching](https://router.vuejs.org/guide/essentials/dynamic-matching.html#reacting-to-params-changes)
- [Vue School: Reacting to Param Changes](https://vueschool.io/lessons/reacting-to-param-changes)

View File

@@ -0,0 +1,209 @@
---
title: Simple Hash Routing Requires Event Listener Cleanup
impact: MEDIUM
impactDescription: When implementing basic routing without Vue Router, forgetting to remove hashchange listeners causes memory leaks and multiple handler execution
type: gotcha
tags: [vue3, routing, events, memory-leak, cleanup]
---
# Simple Hash Routing Requires Event Listener Cleanup
**Impact: MEDIUM** - When implementing basic client-side routing without Vue Router (using hash-based routing with `hashchange` events), you must clean up event listeners when the component unmounts. Failure to do so causes memory leaks and can result in multiple handlers firing after the component is recreated.
## Task Checklist
- [ ] Store event listener reference for cleanup
- [ ] Use onUnmounted to remove event listener
- [ ] Consider using Vue Router instead for production apps
- [ ] Test component mount/unmount cycles
## The Problem
```vue
<script setup>
import { ref, computed } from 'vue'
import Home from './Home.vue'
import About from './About.vue'
const routes = {
'/': Home,
'/about': About
}
const currentPath = ref(window.location.hash)
// BUG: Event listener is never removed!
// Each time this component mounts, a NEW listener is added
// After mounting 5 times, you have 5 listeners running
window.addEventListener('hashchange', () => {
currentPath.value = window.location.hash
})
const currentView = computed(() => {
return routes[currentPath.value.slice(1) || '/']
})
</script>
```
**What happens:**
1. Component mounts, adds listener
2. Component unmounts (e.g., route change, v-if toggle)
3. Component mounts again, adds ANOTHER listener
4. Now TWO listeners respond to each hash change
5. Eventually causes performance issues and memory leaks
## Solution: Proper Cleanup with onUnmounted
```vue
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import Home from './Home.vue'
import About from './About.vue'
import NotFound from './NotFound.vue'
const routes = {
'/': Home,
'/about': About
}
const currentPath = ref(window.location.hash)
// Store handler reference for cleanup
function handleHashChange() {
currentPath.value = window.location.hash
}
// Add listener
window.addEventListener('hashchange', handleHashChange)
// CRITICAL: Remove listener on unmount
onUnmounted(() => {
window.removeEventListener('hashchange', handleHashChange)
})
const currentView = computed(() => {
return routes[currentPath.value.slice(1) || '/'] || NotFound
})
</script>
```
## Solution: Using Options API
```vue
<script>
import Home from './Home.vue'
import About from './About.vue'
import NotFound from './NotFound.vue'
const routes = {
'/': Home,
'/about': About
}
export default {
data() {
return {
currentPath: window.location.hash
}
},
computed: {
currentView() {
return routes[this.currentPath.slice(1) || '/'] || NotFound
}
},
mounted() {
// Store bound handler for cleanup
this.hashHandler = () => {
this.currentPath = window.location.hash
}
window.addEventListener('hashchange', this.hashHandler)
},
beforeUnmount() {
// Clean up
window.removeEventListener('hashchange', this.hashHandler)
}
}
</script>
```
## Solution: Composable for Reusable Hash Routing
```javascript
// composables/useHashRouter.js
import { ref, computed, onUnmounted } from 'vue'
export function useHashRouter(routes, notFoundComponent = null) {
const currentPath = ref(window.location.hash)
function handleHashChange() {
currentPath.value = window.location.hash
}
// Setup
window.addEventListener('hashchange', handleHashChange)
// Cleanup - handled automatically when component unmounts
onUnmounted(() => {
window.removeEventListener('hashchange', handleHashChange)
})
const currentView = computed(() => {
const path = currentPath.value.slice(1) || '/'
return routes[path] || notFoundComponent
})
function navigate(path) {
window.location.hash = path
}
return {
currentPath,
currentView,
navigate
}
}
```
```vue
<!-- Usage -->
<script setup>
import { useHashRouter } from '@/composables/useHashRouter'
import Home from './Home.vue'
import About from './About.vue'
import NotFound from './NotFound.vue'
const { currentView } = useHashRouter({
'/': Home,
'/about': About
}, NotFound)
</script>
<template>
<component :is="currentView" />
</template>
```
## When to Use Simple Routing vs Vue Router
| Use Simple Hash Routing | Use Vue Router |
|------------------------|----------------|
| Learning/prototyping | Production apps |
| Very simple apps (2-3 pages) | Nested routes needed |
| No build step available | Navigation guards needed |
| Bundle size critical | Lazy loading needed |
| Static hosting only | History mode (clean URLs) |
## Key Points
1. **Always clean up event listeners** - Use onUnmounted or beforeUnmount
2. **Store handler reference** - Anonymous functions can't be removed
3. **Consider Vue Router for real apps** - It handles cleanup automatically
4. **Test unmount scenarios** - v-if toggling, hot module replacement
5. **Composables help encapsulate cleanup logic** - Reusable and automatic
## Reference
- [Vue.js Routing Documentation](https://vuejs.org/guide/scaling-up/routing.html)
- [Vue Router Official Library](https://router.vuejs.org/)

View File

@@ -0,0 +1,183 @@
---
title: Use Vue Router Library for Production Applications
impact: LOW
impactDescription: Simple hash routing lacks essential features for production SPAs; Vue Router provides navigation guards, lazy loading, and proper history management
type: best-practice
tags: [vue3, vue-router, spa, production, architecture]
---
# Use Vue Router Library for Production Applications
**Impact: LOW** - While you can implement basic routing with hash changes and dynamic components, the official Vue Router library should be used for any production single-page application. It provides essential features like navigation guards, nested routes, lazy loading, and proper browser history integration that are tedious and error-prone to implement manually.
## Task Checklist
- [ ] Install Vue Router for production SPAs
- [ ] Use simple routing only for learning or tiny prototypes
- [ ] Leverage built-in features: guards, lazy loading, meta fields
- [ ] Consider router-based state and data loading patterns
## When Simple Routing is Acceptable
```vue
<!-- Only for: learning, prototypes, or micro-apps with 2-3 pages -->
<script setup>
import { ref, computed } from 'vue'
import Home from './Home.vue'
import About from './About.vue'
const routes = { '/': Home, '/about': About }
const currentPath = ref(window.location.hash.slice(1) || '/')
window.addEventListener('hashchange', () => {
currentPath.value = window.location.hash.slice(1) || '/'
})
const currentView = computed(() => routes[currentPath.value])
</script>
<template>
<nav>
<a href="#/">Home</a>
<a href="#/about">About</a>
</nav>
<component :is="currentView" />
</template>
```
## Why Vue Router for Production
### Features You'd Have to Implement Manually
| Feature | Simple Routing | Vue Router |
|---------|---------------|------------|
| Navigation guards | Manual, error-prone | Built-in, composable |
| Nested routes | Complex to implement | Native support |
| Route params | Parse manually | Automatic extraction |
| Lazy loading | DIY with dynamic imports | Built-in with code splitting |
| History mode (clean URLs) | Requires server config + manual | Built-in |
| Scroll behavior | Manual | Configurable |
| Route transitions | DIY | Integrated with Transition |
| Active link styling | Manual class toggling | `router-link-active` class |
| Programmatic navigation | `location.hash = ...` | `router.push()`, `router.replace()` |
| Route meta fields | N/A | Built-in |
## Production Setup with Vue Router
```javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'), // Lazy loaded
meta: { requiresAuth: false }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue')
}
]
},
{
path: '/users/:id',
name: 'UserProfile',
component: () => import('@/views/UserProfile.vue'),
props: true // Pass params as props
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0 }
}
})
// Global navigation guard
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})
export default router
```
```javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')
```
```vue
<!-- App.vue -->
<template>
<nav>
<router-link to="/">Home</router-link>
<router-link to="/dashboard">Dashboard</router-link>
</nav>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</template>
```
## Modern Vue Router Features (2025+)
```javascript
// Data Loading API (Vue Router 4.2+)
const routes = [
{
path: '/users/:id',
component: UserProfile,
// Load data at route level
loader: async (route) => {
return { user: await fetchUser(route.params.id) }
}
}
]
// View Transitions API integration
const router = createRouter({
// Enable native browser view transitions
// Requires browser support (Chrome 111+)
})
```
## Key Points
1. **Use Vue Router for any app beyond a prototype** - The features are essential
2. **Simple routing is for learning** - Understand the concepts, then use the library
3. **Lazy loading is critical for bundle size** - Vue Router makes it trivial
4. **Navigation guards prevent security issues** - Hard to get right manually
5. **History mode requires Vue Router** - Clean URLs need proper handling
6. **New features keep coming** - Data Loading API, View Transitions
## Reference
- [Vue.js Routing Guide](https://vuejs.org/guide/scaling-up/routing.html)
- [Vue Router Documentation](https://router.vuejs.org/)
- [Vue Router Getting Started](https://router.vuejs.org/guide/)

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 hyf0, SerKo <https://github.com/serkodev>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,29 @@
---
name: vue-testing-best-practices
version: 1.0.0
license: MIT
author: github.com/vuejs-ai
description: Use for Vue.js testing. Covers Vitest, Vue Test Utils, component testing, mocking, testing patterns, and Playwright for E2E testing.
---
Vue.js testing best practices, patterns, and common gotchas.
### Testing
- Setting up test infrastructure for Vue 3 projects → See [testing-vitest-recommended-for-vue](reference/testing-vitest-recommended-for-vue.md)
- Tests keep breaking when refactoring component internals → See [testing-component-blackbox-approach](reference/testing-component-blackbox-approach.md)
- Tests fail intermittently with race conditions → See [testing-async-await-flushpromises](reference/testing-async-await-flushpromises.md)
- Composables using lifecycle hooks or inject fail to test → See [testing-composables-helper-wrapper](reference/testing-composables-helper-wrapper.md)
- Getting "injection Symbol(pinia) not found" errors in tests → See [testing-pinia-store-setup](reference/testing-pinia-store-setup.md)
- Components with async setup won't render in tests → See [testing-suspense-async-components](reference/testing-suspense-async-components.md)
- Snapshot tests keep passing despite broken functionality → See [testing-no-snapshot-only](reference/testing-no-snapshot-only.md)
- Choosing end-to-end testing framework for Vue apps → See [testing-e2e-playwright-recommended](reference/testing-e2e-playwright-recommended.md)
- Tests need to verify computed styles or real DOM events → See [testing-browser-vs-node-runners](reference/testing-browser-vs-node-runners.md)
- Testing components created with defineAsyncComponent fails → See [async-component-testing](reference/async-component-testing.md)
- Teleported modal content can't be found in wrapper queries → See [teleport-testing-complexity](reference/teleport-testing-complexity.md)
## Reference
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
- [Vue Test Utils](https://test-utils.vuejs.org/)
- [Vitest Documentation](https://vitest.dev/)
- [Playwright Documentation](https://playwright.dev/)

View File

@@ -0,0 +1,5 @@
# Sync Info
- **Source:** `vendor/vuejs-ai/skills/vue-testing-best-practices`
- **Git SHA:** `f3dd1bf4d3ac78331bdc903e4519d561c538ca6a`
- **Synced:** 2026-03-16

View File

@@ -0,0 +1,163 @@
---
title: Use flushPromises for Testing Async Components
impact: HIGH
impactDescription: Without awaiting async operations, tests make assertions before the component has rendered, causing false negatives
type: gotcha
tags: [vue3, testing, async, defineAsyncComponent, flushPromises, vitest]
---
# Use flushPromises for Testing Async Components
**Impact: HIGH** - When testing async components created with `defineAsyncComponent`, you must use `await flushPromises()` to ensure the component has loaded before making assertions. Vue updates asynchronously, so tests that don't account for this will make assertions before the component has rendered.
## Task Checklist
- [ ] Use `async/await` in test functions for async components
- [ ] Call `await flushPromises()` after mounting async components
- [ ] Test loading states by making assertions before `flushPromises()`
- [ ] Test error states using rejected promises in `defineAsyncComponent`
- [ ] Use `trigger()` with `await` as it returns a Promise
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import { defineAsyncComponent } from 'vue'
const AsyncWidget = defineAsyncComponent(() =>
import('./Widget.vue')
)
test('renders async component', () => {
const wrapper = mount(AsyncWidget)
// FAILS: Component hasn't loaded yet
expect(wrapper.text()).toContain('Widget Content')
})
```
**Correct:**
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineAsyncComponent, nextTick } from 'vue'
const AsyncWidget = defineAsyncComponent(() =>
import('./Widget.vue')
)
test('renders async component', async () => {
const wrapper = mount(AsyncWidget)
// Wait for async component to load
await flushPromises()
expect(wrapper.text()).toContain('Widget Content')
})
test('shows loading state initially', async () => {
const AsyncWithLoading = defineAsyncComponent({
loader: () => import('./Widget.vue'),
loadingComponent: { template: '<div>Loading...</div>' },
delay: 0
})
const wrapper = mount(AsyncWithLoading)
// Check loading state immediately
expect(wrapper.text()).toContain('Loading...')
// Wait for component to load
await flushPromises()
// Check final state
expect(wrapper.text()).toContain('Widget Content')
})
```
## Testing with Suspense
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { Suspense, defineAsyncComponent, h } from 'vue'
const AsyncWidget = defineAsyncComponent(() =>
import('./Widget.vue')
)
test('renders async component with Suspense', async () => {
const wrapper = mount({
components: { AsyncWidget },
template: `
<Suspense>
<AsyncWidget />
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
`
})
// Initially shows fallback
expect(wrapper.text()).toContain('Loading...')
// Wait for async resolution
await flushPromises()
// Now shows actual content
expect(wrapper.text()).toContain('Widget Content')
})
```
## Testing Error States
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineAsyncComponent } from 'vue'
test('shows error component on load failure', async () => {
const AsyncWithError = defineAsyncComponent({
loader: () => Promise.reject(new Error('Failed to load')),
errorComponent: { template: '<div>Error loading component</div>' }
})
const wrapper = mount(AsyncWithError)
await flushPromises()
expect(wrapper.text()).toContain('Error loading component')
})
```
## Utilities Reference
| Utility | Purpose |
|---------|---------|
| `await flushPromises()` | Resolves all pending promises |
| `await nextTick()` | Waits for Vue's next DOM update cycle |
| `await wrapper.trigger('click')` | Triggers event and waits for update |
## Dynamic Import Handling
**Note:** Dynamic imports (`import('./File.vue')`) may require additional handling beyond `flushPromises()` in test environments. Test runners like Vitest handle module resolution differently than runtime bundlers, which can cause timing issues with dynamic imports. If `flushPromises()` alone doesn't resolve the component, consider:
- Mocking the dynamic import to return the component synchronously
- Using multiple `await flushPromises()` calls in sequence
- Wrapping assertions in `waitFor()` or retry utilities
- Configuring your test runner's module resolution settings
```javascript
// If flushPromises() isn't sufficient, mock the import
vi.mock('./Widget.vue', () => ({
default: { template: '<div>Widget Content</div>' }
}))
// Or use multiple flush calls for nested async operations
await flushPromises()
await flushPromises()
```
## References
- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense)
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)

View File

@@ -0,0 +1,158 @@
---
title: Teleported Content Requires Special Testing Approach
impact: MEDIUM
impactDescription: Vue Test Utils cannot find teleported content using standard wrapper.find() methods
type: gotcha
tags: [vue3, teleport, testing, vue-test-utils]
---
# Teleported Content Requires Special Testing Approach
**Impact: MEDIUM** - Vue Test Utils scopes queries to the mounted component. Teleported content renders outside the component's DOM tree, so `wrapper.find()` cannot locate it. This leads to failing tests and confusion.
## Task Checklist
- [ ] Stub Teleport in unit tests to keep content in component tree
- [ ] Use `document.body` queries for integration tests with real Teleport
- [ ] Consider using `getComponent()` instead of DOM queries for teleported components
**Problem - Standard Testing Fails:**
```vue
<!-- Modal.vue -->
<template>
<button @click="open = true">Open</button>
<Teleport to="body">
<div v-if="open" class="modal" data-testid="modal">
<input type="text" data-testid="modal-input" />
</div>
</Teleport>
</template>
```
```ts
// Modal.spec.ts - BROKEN
import { mount } from '@vue/test-utils'
import Modal from './Modal.vue'
test('modal input exists', async () => {
const wrapper = mount(Modal)
await wrapper.find('button').trigger('click')
// FAILS: Teleported content is not in wrapper's DOM tree
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true)
})
```
**Solution 1 - Stub Teleport:**
```ts
import { mount } from '@vue/test-utils'
import Modal from './Modal.vue'
test('modal input exists', async () => {
const wrapper = mount(Modal, {
global: {
stubs: {
// Stub teleport to render content inline
Teleport: true
}
}
})
await wrapper.find('button').trigger('click')
// Works: Content renders inside wrapper
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true)
})
```
**Solution 2 - Query Document Body:**
```ts
import { mount } from '@vue/test-utils'
import Modal from './Modal.vue'
test('modal renders to body', async () => {
const wrapper = mount(Modal, {
attachTo: document.body // Required for Teleport to work
})
await wrapper.find('button').trigger('click')
// Query the actual DOM
const modal = document.querySelector('[data-testid="modal"]')
expect(modal).toBeTruthy()
const input = document.querySelector('[data-testid="modal-input"]')
expect(input).toBeTruthy()
// Cleanup
wrapper.unmount()
})
```
**Solution 3 - Custom Teleport Stub with Content Access:**
```ts
import { mount, config } from '@vue/test-utils'
import { h, Teleport } from 'vue'
import Modal from './Modal.vue'
// Custom stub that renders content in a testable way
const TeleportStub = {
setup(props, { slots }) {
return () => h('div', { class: 'teleport-stub' }, slots.default?.())
}
}
test('modal with custom stub', async () => {
const wrapper = mount(Modal, {
global: {
stubs: {
Teleport: TeleportStub
}
}
})
await wrapper.find('button').trigger('click')
// Content is inside .teleport-stub
expect(wrapper.find('.teleport-stub [data-testid="modal-input"]').exists()).toBe(true)
})
```
## Testing Vue Final Modal and UI Libraries
Libraries like Vue Final Modal use Teleport internally, causing test failures:
```ts
// Problem: Vue Final Modal teleports to body
import { VueFinalModal } from 'vue-final-modal'
test('modal content', async () => {
const wrapper = mount(MyComponent, {
global: {
stubs: {
// Stub the modal component to avoid teleport issues
VueFinalModal: true
}
}
})
})
```
## E2E Testing (Cypress, Playwright)
E2E tests query the real DOM, so Teleport works naturally:
```ts
// Cypress
it('opens modal', () => {
cy.visit('/page-with-modal')
cy.get('button').click()
// Works: Cypress queries the real DOM
cy.get('[data-testid="modal"]').should('be.visible')
})
```
## Reference
- [Vue Test Utils - Teleport](https://test-utils.vuejs.org/guide/advanced/teleport)
- [Vue Test Utils - Stubs](https://test-utils.vuejs.org/guide/advanced/stubs-shallow-mount)

View File

@@ -0,0 +1,175 @@
---
title: Properly Handle Async Updates with nextTick and flushPromises
impact: HIGH
impactDescription: Race conditions and flaky tests occur when async DOM updates or API calls complete after assertions run
type: gotcha
tags: [vue3, testing, async, flushPromises, nextTick, vitest, vue-test-utils, race-condition]
---
# Properly Handle Async Updates with nextTick and flushPromises
**Impact: HIGH** - Vue updates the DOM asynchronously. Without properly awaiting these updates, tests may assert against stale DOM state, causing intermittent failures and false negatives.
Use `await` with triggers and `setValue`, use `nextTick` for reactive updates, and use `flushPromises` for external async operations like API calls.
## Task Checklist
- [ ] Always await `trigger()` and `setValue()` calls
- [ ] Use `await nextTick()` after programmatic reactive state changes
- [ ] Use `await flushPromises()` for external async operations (API calls, timers)
- [ ] Don't chain multiple `nextTick` calls - use `flushPromises` instead
- [ ] Consider using `waitFor` from testing-library for polling assertions
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import SearchComponent from './SearchComponent.vue'
// BAD: Not awaiting trigger - assertion runs before DOM updates
test('search filters results', () => {
const wrapper = mount(SearchComponent)
wrapper.find('input').setValue('vue') // Missing await!
wrapper.find('button').trigger('click') // Missing await!
// This assertion likely fails - DOM hasn't updated yet
expect(wrapper.findAll('.result').length).toBe(3)
})
// BAD: Using nextTick for API calls
test('loads data from API', async () => {
const wrapper = mount(DataLoader)
await nextTick() // This won't wait for the API call!
// Assertion runs before fetch completes
expect(wrapper.find('.data').text()).toBe('Loaded data')
})
```
**Correct:**
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import SearchComponent from './SearchComponent.vue'
import DataLoader from './DataLoader.vue'
// CORRECT: Await trigger and setValue
test('search filters results', async () => {
const wrapper = mount(SearchComponent)
await wrapper.find('input').setValue('vue')
await wrapper.find('button').trigger('click')
expect(wrapper.findAll('.result').length).toBe(3)
})
// CORRECT: Use flushPromises for API calls
test('loads data from API', async () => {
const wrapper = mount(DataLoader)
// Wait for all pending promises to resolve
await flushPromises()
expect(wrapper.find('.data').text()).toBe('Loaded data')
})
```
## When to Use Each Method
### `await trigger()` / `await setValue()` - User Interactions
```javascript
// These methods return nextTick internally
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('new value')
await wrapper.find('form').trigger('submit')
```
### `await nextTick()` - Programmatic Reactive Updates
```javascript
import { nextTick } from 'vue'
test('reflects programmatic state changes', async () => {
const wrapper = mount(Counter)
// Direct state modification (when testing with exposed internals)
wrapper.vm.count = 5
await nextTick() // Wait for Vue to update DOM
expect(wrapper.find('.count').text()).toBe('5')
})
```
### `await flushPromises()` - External Async Operations
```javascript
import { flushPromises } from '@vue/test-utils'
test('displays fetched data', async () => {
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
// Wait for component's API call to complete
await flushPromises()
expect(wrapper.find('.username').text()).toBe('John')
})
// Sometimes you need multiple flushPromises for chained async operations
test('processes data after fetch', async () => {
const wrapper = mount(DataProcessor)
await flushPromises() // Wait for fetch
await flushPromises() // Wait for processing triggered by fetch
expect(wrapper.find('.processed').exists()).toBe(true)
})
```
## Common Pattern: Combining Methods
```javascript
test('submits form and shows success', async () => {
const wrapper = mount(ContactForm)
// Fill form (awaiting each interaction)
await wrapper.find('#name').setValue('John')
await wrapper.find('#email').setValue('john@example.com')
// Submit form
await wrapper.find('form').trigger('submit')
// Wait for API submission to complete
await flushPromises()
// Assert success state
expect(wrapper.find('.success-message').exists()).toBe(true)
})
```
## Testing with MSW or Mock APIs
```javascript
import { flushPromises } from '@vue/test-utils'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ name: 'John' }))
})
)
test('displays user data', async () => {
const wrapper = mount(UserCard)
// MSW might require multiple flushPromises
await flushPromises()
await flushPromises()
expect(wrapper.find('.name').text()).toBe('John')
})
```
## Reference
- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense)
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)

View File

@@ -0,0 +1,208 @@
---
title: Choose Browser-Based Runner for Style and DOM Event Testing
impact: MEDIUM
impactDescription: Node-based runners cannot test real CSS behavior, native DOM events, cookies, or computed styles
type: capability
tags: [vue3, testing, component-testing, vitest, browser, jsdom]
---
# Choose Browser-Based Runner for Style and DOM Event Testing
**Impact: MEDIUM** - Node-based test runners (Vitest with jsdom/happy-dom) simulate the DOM but cannot test real CSS rendering, native browser events, cookies, computed styles, or cross-browser behavior. Use browser-based runners when these matter.
Use Vitest for most component tests (fast), but use Vitest Browser Mode when testing visual/DOM-dependent features.
## Task Checklist
- [ ] Use Vitest (node) for logic-focused component tests
- [ ] Use Vitest Browser Mode for style-dependent tests
- [ ] Use Vitest Browser Mode for native events (focus, drag, resize)
- [ ] Use Vitest Browser Mode for cookies and computed CSS styles
- [ ] Accept slower speed tradeoff for browser accuracy
## When to Use Each Approach
### Node-Based Runner (Vitest + happy-dom/jsdom)
Best for:
- Pure logic testing
- State management
- Event emission
- Props/slots behavior
- Most component interactions
- Fast CI/CD pipelines
```javascript
// vitest.config.js
export default defineConfig({
test: {
environment: 'happy-dom', // or 'jsdom'
}
})
```
```javascript
// Fast but limited - fine for most tests
test('button emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
```
### Vitest Browser Mode
Required for:
- CSS computed styles verification
- CSS transitions/animations
- Real focus/blur behavior
- Drag and drop
- Cookie operations
- Viewport-dependent behavior
- Cross-browser validation
## Vitest Browser Mode Setup
```bash
npm install -D @vitest/browser playwright
```
```javascript
// vitest.config.js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
},
},
})
```
```javascript
// Button.browser.test.js
import { render } from 'vitest-browser-vue'
import Button from './Button.vue'
test('has correct hover styling', async () => {
const { getByRole } = render(Button, { props: { label: 'Click me' } })
const button = getByRole('button')
// Check initial style
await expect.element(button).toHaveStyle({
backgroundColor: 'rgb(59, 130, 246)' // blue
})
})
test('maintains focus after click', async () => {
const { getByRole } = render(Button)
const button = getByRole('button')
await button.click()
await expect.element(button).toHaveFocus()
})
```
## Examples: What Each Runner Can/Cannot Test
### Styles - Browser Required
```javascript
// Node runner: CANNOT verify actual CSS
test('danger button has red background', () => {
const wrapper = mount(Button, { props: { variant: 'danger' } })
// This only checks class exists, not actual color
expect(wrapper.classes()).toContain('bg-red-500')
})
// Vitest Browser Mode: CAN verify computed styles
test('danger button renders red', async () => {
const { getByRole } = render(Button, { props: { variant: 'danger' } })
await expect.element(getByRole('button')).toHaveStyle({
backgroundColor: 'rgb(239, 68, 68)'
})
})
```
### Computed CSS Styles - Browser Required
```javascript
// Node runner: CANNOT get real computed styles
test('button has correct padding', () => {
const wrapper = mount(Button)
// getComputedStyle returns empty/default values in jsdom
const style = window.getComputedStyle(wrapper.element)
// style.padding will be empty string, not actual computed value
})
// Vitest Browser Mode: Real computed styles
test('button has correct padding', async () => {
const { getByRole } = render(Button)
const button = getByRole('button')
await expect.element(button).toHaveStyle({
padding: '12px 24px'
})
})
```
### Native Events - Browser Required
```javascript
// Node runner: Synthetic events only
test('handles drag and drop', async () => {
const wrapper = mount(DraggableList)
// trigger('dragstart') is synthetic - may not work as expected
await wrapper.find('.item').trigger('dragstart')
})
// Vitest Browser Mode: Real native events via userEvent
import { userEvent } from '@vitest/browser/context'
test('reorders items on drag', async () => {
const { getByTestId } = render(DraggableList)
const item = getByTestId('item-1')
const target = getByTestId('item-3')
await userEvent.dragAndDrop(item, target)
// Assert reordering
})
```
## Recommended Testing Strategy
```javascript
// vitest.config.js - Separate test configurations
export default defineConfig({
test: {
// Default: Node environment for speed
environment: 'happy-dom',
// Browser tests in separate directory
include: ['src/**/*.test.{js,ts}'],
},
})
// Run browser tests separately
// npx vitest --browser.enabled
```
### Directory Structure
```
tests/
├── unit/ # Fast node-based tests
│ ├── Button.test.js
│ └── useCounter.test.js
├── component/ # Slower browser-based tests
│ ├── Button.browser.test.js
│ └── DragDrop.browser.test.js
└── e2e/ # Full E2E tests (Playwright)
└── user-flow.spec.ts
```
## Reference
- [Vue.js Testing - Component Testing](https://vuejs.org/guide/scaling-up/testing#component-testing)
- [Vitest Browser Mode](https://vitest.dev/guide/browser.html)

View File

@@ -0,0 +1,144 @@
---
title: Test Components Using Blackbox Approach - Focus on Behavior Not Implementation
impact: HIGH
impactDescription: Implementation-aware tests become brittle and break during refactoring, leading to high maintenance burden
type: best-practice
tags: [vue3, testing, component-testing, vitest, vue-test-utils, blackbox]
---
# Test Components Using Blackbox Approach - Focus on Behavior Not Implementation
**Impact: HIGH** - Tests that rely on implementation details (internal state, private methods, component structure) break during refactoring even when functionality remains correct. This leads to false negatives and high test maintenance burden.
Follow Kent C. Dodds' testing philosophy: "The more your tests resemble how your software is used, the more confidence they can give you."
## Task Checklist
- [ ] Test what the component does, not how it does it
- [ ] Query elements by user-visible attributes (text, role, testid)
- [ ] Simulate user interactions (click, type) rather than calling methods directly
- [ ] Assert on rendered output, emitted events, and visible state changes
- [ ] Avoid accessing component internal state or private methods
- [ ] Use data-testid attributes for elements without semantic meaning
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
// BAD: Testing implementation details
test('counter increments', async () => {
const wrapper = mount(Counter)
// Accessing internal state directly
expect(wrapper.vm.count).toBe(0)
// Calling internal method instead of simulating user action
wrapper.vm.increment()
// Checking internal state instead of visible output
expect(wrapper.vm.count).toBe(1)
})
// BAD: Testing component structure
test('has increment button', () => {
const wrapper = mount(Counter)
// Testing implementation detail - what if button becomes an anchor?
expect(wrapper.find('button').exists()).toBe(true)
})
```
**Correct:**
```javascript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
// CORRECT: Testing behavior like a user would
test('counter displays updated value after clicking increment', async () => {
const wrapper = mount(Counter, {
props: { max: 10 }
})
// Assert initial visible state
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('0')
// Simulate user action
await wrapper.find('[data-testid="increment-button"]').trigger('click')
// Assert visible result
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('1')
})
// CORRECT: Testing emitted events (public API)
test('emits change event with new value when incremented', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment-button"]').trigger('click')
expect(wrapper.emitted('change')).toHaveLength(1)
expect(wrapper.emitted('change')[0]).toEqual([1])
})
```
## Using @testing-library/vue for Better Blackbox Tests
```javascript
import { render, screen, fireEvent } from '@testing-library/vue'
import Counter from './Counter.vue'
// Testing Library encourages accessible, user-centric queries
test('increments counter on button click', async () => {
render(Counter)
// Query by role - how screen readers see it
const button = screen.getByRole('button', { name: /increment/i })
const display = screen.getByText('0')
await fireEvent.click(button)
expect(screen.getByText('1')).toBeInTheDocument()
})
```
## What to Test vs What Not to Test
### DO Test (Public Interface)
```javascript
// Props affect rendered output
test('shows title from props', () => {
const wrapper = mount(Card, {
props: { title: 'Hello World' }
})
expect(wrapper.text()).toContain('Hello World')
})
// Slots render correctly
test('renders slot content', () => {
const wrapper = mount(Card, {
slots: { default: '<p>Slot content</p>' }
})
expect(wrapper.text()).toContain('Slot content')
})
// Emitted events
test('emits close event when X clicked', async () => {
const wrapper = mount(Modal)
await wrapper.find('[data-testid="close-button"]').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
```
### DON'T Test (Implementation Details)
```javascript
// Don't test internal computed properties
// Don't test internal methods
// Don't test component options/setup internals
// Don't test that specific child components are rendered (unless critical)
// Don't rely exclusively on snapshot tests for correctness
```
## Reference
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
- [Vue Test Utils - Testing Philosophy](https://test-utils.vuejs.org/guide/)
- [Testing Library Guiding Principles](https://testing-library.com/docs/guiding-principles)

View File

@@ -0,0 +1,238 @@
---
title: Test Complex Composables with Host Component Wrapper
impact: MEDIUM
impactDescription: Composables using lifecycle hooks or provide/inject fail when tested directly without a component context
type: capability
tags: [vue3, testing, composables, vitest, lifecycle-hooks, provide-inject]
---
# Test Complex Composables with Host Component Wrapper
**Impact: MEDIUM** - Composables that use Vue lifecycle hooks (`onMounted`, `onUnmounted`) or dependency injection (`inject`) require a component context to function. Testing them directly will cause errors or incorrect behavior.
Simple composables using only reactivity APIs can be tested directly. Complex composables need a helper function that creates a host component context.
## Task Checklist
- [ ] Identify if composable uses lifecycle hooks or inject
- [ ] For simple composables (refs, computed only): test directly
- [ ] For complex composables: use `withSetup` helper pattern
- [ ] Clean up by unmounting the test app after each test
- [ ] Use `app.provide()` to mock injected dependencies
**Simple Composable - Test Directly:**
```javascript
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++
return { count, doubled, increment }
}
```
```javascript
// useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
// CORRECT: Simple composable can be tested directly
describe('useCounter', () => {
it('initializes with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('computes doubled value', () => {
const { count, doubled, increment } = useCounter(5)
expect(doubled.value).toBe(10)
increment()
expect(doubled.value).toBe(12)
})
})
```
**Complex Composable - Use Host Wrapper:**
```javascript
// composables/useFetch.js
import { ref, onMounted, onUnmounted, inject } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(true)
let controller = null
// Uses inject - needs component context
const apiClient = inject('apiClient')
// Uses lifecycle hooks - needs component context
onMounted(async () => {
controller = new AbortController()
try {
const response = await apiClient.get(url, { signal: controller.signal })
data.value = response.data
} catch (e) {
if (e.name !== 'AbortError') error.value = e
} finally {
loading.value = false
}
})
onUnmounted(() => {
controller?.abort()
})
return { data, error, loading }
}
```
```javascript
// test-utils.js
import { createApp } from 'vue'
/**
* Helper to test composables that need component context
*/
export function withSetup(composable) {
let result
const app = createApp({
setup() {
result = composable()
// Return a render function to suppress warnings
return () => {}
}
})
app.mount(document.createElement('div'))
return [result, app]
}
```
```javascript
// useFetch.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { withSetup } from './test-utils'
import { useFetch } from './useFetch'
describe('useFetch', () => {
let app
const mockApiClient = {
get: vi.fn()
}
afterEach(() => {
// IMPORTANT: Clean up to trigger onUnmounted
app?.unmount()
})
it('fetches data on mount', async () => {
mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } })
const [result, testApp] = withSetup(() => useFetch('/api/test'))
app = testApp
// Provide mocked dependency
app.provide('apiClient', mockApiClient)
// Wait for async operations
await flushPromises()
expect(result.data.value).toEqual({ id: 1, name: 'Test' })
expect(result.loading.value).toBe(false)
expect(result.error.value).toBeNull()
})
it('handles errors', async () => {
const testError = new Error('Network error')
mockApiClient.get.mockRejectedValue(testError)
const [result, testApp] = withSetup(() => useFetch('/api/test'))
app = testApp
app.provide('apiClient', mockApiClient)
await flushPromises()
expect(result.error.value).toBe(testError)
expect(result.data.value).toBeNull()
})
})
```
## Enhanced withSetup Helper with Provide Support
```javascript
// test-utils.js
export function withSetup(composable, options = {}) {
let result
const app = createApp({
setup() {
result = composable()
return () => {}
}
})
// Apply global provides before mounting
if (options.provide) {
Object.entries(options.provide).forEach(([key, value]) => {
app.provide(key, value)
})
}
app.mount(document.createElement('div'))
return [result, app]
}
// Usage
const [result, app] = withSetup(() => useMyComposable(), {
provide: {
apiClient: mockApiClient,
currentUser: { id: 1, name: 'Test User' }
}
})
```
## Testing with @vue/test-utils mount
```javascript
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { useFetch } from './useFetch'
test('useFetch in component context', async () => {
const TestComponent = defineComponent({
setup() {
const { data, loading } = useFetch('/api/users')
return { data, loading }
},
template: '<div>{{ loading ? "Loading..." : data }}</div>'
})
const wrapper = mount(TestComponent, {
global: {
provide: {
apiClient: mockApiClient
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('Test data')
})
```
## Reference
- [Vue.js Testing Guide - Testing Composables](https://vuejs.org/guide/scaling-up/testing#testing-composables)
- [Vue Test Utils - Mounting Components](https://test-utils.vuejs.org/guide/)

View File

@@ -0,0 +1,242 @@
---
title: Use Playwright for E2E Testing - Cross-Browser Support and Better DX
impact: MEDIUM
impactDescription: Cypress has browser limitations and some features require paid subscriptions
type: best-practice
tags: [vue3, testing, e2e, playwright, cypress, end-to-end]
---
# Use Playwright for E2E Testing - Cross-Browser Support and Better DX
**Impact: MEDIUM** - Playwright offers superior cross-browser testing (Chromium, WebKit, Firefox), excellent debugging tools, and is fully open source. Cypress has limitations with WebKit support and requires paid subscriptions for some features.
Use Playwright for new E2E testing setups. Consider Cypress if team already has expertise or for its visual debugging UI.
## Task Checklist
- [ ] Install Playwright with browsers for your target platforms
- [ ] Configure for Vue dev server integration
- [ ] Set up projects for different browsers
- [ ] Use locator strategies that match component test patterns
- [ ] Configure CI for parallel test execution
- [ ] Use trace and screenshot features for debugging
## Quick Setup
```bash
# Install Playwright
npm init playwright@latest
# This will create:
# - playwright.config.ts
# - tests/ directory
# - tests-examples/ directory
```
**playwright.config.ts:**
```typescript
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
// Base URL for navigation
baseURL: 'http://localhost:5173',
// Capture trace on first retry
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile viewports
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
// Run local dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})
```
## E2E Test Example
```typescript
// e2e/user-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('User Authentication', () => {
test('user can log in and see dashboard', async ({ page }) => {
// Navigate to login
await page.goto('/login')
// Fill login form
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign In' }).click()
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
})
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('wrong@example.com')
await page.getByLabel('Password').fill('wrongpassword')
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page.getByRole('alert')).toContainText('Invalid credentials')
await expect(page).toHaveURL('/login')
})
})
```
## Playwright vs Cypress Comparison
| Feature | Playwright | Cypress |
|---------|------------|---------|
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, Electron (WebKit experimental) |
| Cross-browser | Full support | Limited |
| Parallelization | Built-in | Requires Cypress Cloud |
| Open source | Fully | Core only |
| Mobile testing | Device emulation | Limited |
| Debugging | Inspector, trace viewer | Time-travel UI |
| API testing | Built-in | Plugin required |
| Iframes | Full support | Limited |
## Testing Vue Components with Data-Testid
```typescript
// e2e/product-list.spec.ts
import { test, expect } from '@playwright/test'
test('user can add product to cart', async ({ page }) => {
await page.goto('/products')
// Use data-testid for reliable selectors
await page.getByTestId('product-card').first().click()
// Verify product detail page
await expect(page.getByTestId('product-title')).toBeVisible()
// Add to cart
await page.getByTestId('add-to-cart-button').click()
// Verify cart updated
await expect(page.getByTestId('cart-count')).toHaveText('1')
})
```
## Page Object Pattern for Vue Apps
```typescript
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', { name: 'Sign In' })
this.errorMessage = page.getByRole('alert')
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
```
```typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'password123')
await expect(page).toHaveURL('/dashboard')
})
```
## Visual Regression Testing
```typescript
test('homepage visual regression', async ({ page }) => {
await page.goto('/')
// Full page screenshot comparison
await expect(page).toHaveScreenshot('homepage.png')
// Element-specific screenshot
await expect(page.getByTestId('hero-section')).toHaveScreenshot('hero.png')
})
```
## Running Tests
```bash
# Run all tests
npx playwright test
# Run in headed mode (see browser)
npx playwright test --headed
# Run specific file
npx playwright test e2e/auth.spec.ts
# Run in specific browser
npx playwright test --project=chromium
# Debug mode
npx playwright test --debug
# Generate test from actions
npx playwright codegen localhost:5173
```
## Reference
- [Playwright Documentation](https://playwright.dev/)
- [Vue.js E2E Testing Recommendations](https://vuejs.org/guide/scaling-up/testing#e2e-testing)
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)

View File

@@ -0,0 +1,197 @@
---
title: Avoid Snapshot-Only Tests - They Don't Prove Correctness
impact: MEDIUM
impactDescription: Snapshot tests verify structure but not functionality, leading to false confidence and brittle tests
type: best-practice
tags: [vue3, testing, snapshot, vitest, vue-test-utils, anti-pattern]
---
# Avoid Snapshot-Only Tests - They Don't Prove Correctness
**Impact: MEDIUM** - Snapshot tests only verify that HTML structure hasn't changed - they don't verify that the component works correctly. Relying exclusively on snapshots leads to false confidence and tests that break on any refactoring, even when functionality is preserved.
Use snapshots sparingly for regression detection. Prefer behavioral assertions that test what the component does.
## Task Checklist
- [ ] Don't use snapshots as the only assertion for component behavior
- [ ] Use snapshots for regression detection on stable UI components
- [ ] Always pair snapshots with behavioral assertions
- [ ] Keep snapshots small and focused (avoid full component snapshots)
- [ ] Review snapshot diffs carefully - don't blindly update
- [ ] Consider inline snapshots for small, critical structures
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
// BAD: Snapshot-only test proves nothing about functionality
test('UserCard renders correctly', () => {
const wrapper = mount(UserCard, {
props: { user: { name: 'John', email: 'john@example.com' } }
})
expect(wrapper.html()).toMatchSnapshot()
})
// This test passes even if:
// - The email isn't clickable
// - The avatar doesn't load
// - User actions are completely broken
// - Accessibility is broken
```
**Correct:**
```javascript
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
// CORRECT: Test actual behavior
test('UserCard displays user information', () => {
const wrapper = mount(UserCard, {
props: { user: { name: 'John', email: 'john@example.com' } }
})
expect(wrapper.find('[data-testid="user-name"]').text()).toBe('John')
expect(wrapper.find('[data-testid="user-email"]').text()).toBe('john@example.com')
})
test('UserCard email link is clickable', async () => {
const wrapper = mount(UserCard, {
props: { user: { name: 'John', email: 'john@example.com' } }
})
const emailLink = wrapper.find('a[href^="mailto:"]')
expect(emailLink.exists()).toBe(true)
expect(emailLink.attributes('href')).toBe('mailto:john@example.com')
})
test('UserCard emits select event when clicked', async () => {
const wrapper = mount(UserCard, {
props: { user: { id: 1, name: 'John' } }
})
await wrapper.trigger('click')
expect(wrapper.emitted('select')).toBeTruthy()
expect(wrapper.emitted('select')[0]).toEqual([{ id: 1, name: 'John' }])
})
```
## When Snapshots ARE Useful
### Regression Detection for Stable Components
```javascript
// ACCEPTABLE: Snapshot as additional check, not the only check
test('ErrorBoundary renders error message', () => {
const wrapper = mount(ErrorBoundary, {
props: { error: new Error('Something went wrong') }
})
// Primary assertions - verify behavior
expect(wrapper.find('.error-title').text()).toBe('Error')
expect(wrapper.find('.error-message').text()).toContain('Something went wrong')
// Secondary snapshot - catches unexpected structural changes
expect(wrapper.find('.error-container').html()).toMatchSnapshot()
})
```
### Inline Snapshots for Small Structures
```javascript
// ACCEPTABLE: Inline snapshot for small, critical structure
test('generates correct list markup', () => {
const wrapper = mount(ListItem, { props: { item: 'Test' } })
expect(wrapper.html()).toMatchInlineSnapshot(`
"<li class="list-item">Test</li>"
`)
})
```
### Complex SVG or Icon Output
```javascript
// ACCEPTABLE: Snapshot for complex generated content
test('renders correct chart SVG', () => {
const wrapper = mount(PieChart, {
props: { data: [30, 40, 30] }
})
// Verify key behavior
expect(wrapper.findAll('path').length).toBe(3)
// Snapshot for full SVG structure
expect(wrapper.find('svg').html()).toMatchSnapshot()
})
```
## Better Alternatives to Snapshots
### Test Specific Elements
```javascript
// Instead of snapshotting entire component
test('renders product with all required fields', () => {
const wrapper = mount(ProductCard, {
props: { product: { name: 'Widget', price: 9.99, inStock: true } }
})
expect(wrapper.find('.product-name').text()).toBe('Widget')
expect(wrapper.find('.product-price').text()).toContain('9.99')
expect(wrapper.find('.in-stock-badge').exists()).toBe(true)
})
```
### Test CSS Classes for Styling
```javascript
test('applies danger styling for errors', () => {
const wrapper = mount(Alert, {
props: { type: 'error', message: 'Failed!' }
})
expect(wrapper.classes()).toContain('alert-danger')
expect(wrapper.find('.alert-icon').classes()).toContain('icon-error')
})
```
### Use Testing Library Queries
```javascript
import { render, screen } from '@testing-library/vue'
test('form has accessible labels', () => {
render(LoginForm)
// Testing Library queries verify accessibility
expect(screen.getByLabelText('Email')).toBeInTheDocument()
expect(screen.getByLabelText('Password')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument()
})
```
## Snapshot Anti-Patterns
```javascript
// ANTI-PATTERN: Giant component snapshot
test('page renders', () => {
const wrapper = mount(EntirePageComponent)
expect(wrapper.html()).toMatchSnapshot() // 500+ lines of HTML
})
// ANTI-PATTERN: Snapshot with dynamic content
test('shows current date', () => {
const wrapper = mount(DateDisplay)
expect(wrapper.html()).toMatchSnapshot() // Fails every day!
})
// ANTI-PATTERN: Snapshot after every test
test('button works', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.html()).toMatchSnapshot() // Redundant
})
```
## Reference
- [Vue.js Testing Guide - What Not to Test](https://vuejs.org/guide/scaling-up/testing)
- [Effective Snapshot Testing](https://kentcdodds.com/blog/effective-snapshot-testing)
- [Vitest Snapshot Testing](https://vitest.dev/guide/snapshot.html)

View File

@@ -0,0 +1,228 @@
---
title: Configure Pinia Testing with createTestingPinia and setActivePinia
impact: HIGH
impactDescription: Missing Pinia configuration causes 'injection Symbol(pinia) not found' errors and failing tests
type: gotcha
tags: [vue3, testing, pinia, vitest, store, mocking, createTestingPinia]
---
# Configure Pinia Testing with createTestingPinia and setActivePinia
**Impact: HIGH** - Testing components or composables that use Pinia stores without proper configuration results in "[Vue warn]: injection Symbol(pinia) not found" errors. Tests will fail or behave unexpectedly.
Use `@pinia/testing` package with `createTestingPinia` for component tests and `setActivePinia(createPinia())` for unit testing stores directly.
## Task Checklist
- [ ] Install `@pinia/testing` as a dev dependency
- [ ] Use `createTestingPinia` in component tests with `global.plugins`
- [ ] Use `setActivePinia(createPinia())` in `beforeEach` for store unit tests
- [ ] Configure `createSpy: vi.fn` when NOT using `globals: true` in Vitest
- [ ] Initialize store inside each test to get fresh state
- [ ] Use `stubActions: false` when you need real action execution
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
// BAD: Missing Pinia - causes injection error
test('displays user name', () => {
const wrapper = mount(UserProfile) // ERROR: injection "Symbol(pinia)" not found
expect(wrapper.text()).toContain('John')
})
```
```javascript
import { useUserStore } from '@/stores/user'
// BAD: No active Pinia instance
test('user store actions', () => {
const store = useUserStore() // ERROR: no active Pinia
store.login('john', 'password')
})
```
**Correct - Component Testing:**
```javascript
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import UserProfile from './UserProfile.vue'
import { useUserStore } from '@/stores/user'
// CORRECT: Provide testing pinia with stubbed actions
test('displays user name', () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn, // Required if not using globals: true
initialState: {
user: { name: 'John', email: 'john@example.com' }
}
})
]
}
})
expect(wrapper.text()).toContain('John')
})
// CORRECT: Test with stubbed actions (default behavior)
test('calls logout action', async () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
})
// Get store AFTER mounting with createTestingPinia
const store = useUserStore()
await wrapper.find('[data-testid="logout"]').trigger('click')
// Actions are stubbed and wrapped in spies
expect(store.logout).toHaveBeenCalled()
})
```
**Correct - Store Unit Testing:**
```javascript
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
describe('User Store', () => {
beforeEach(() => {
// Create fresh Pinia instance for each test
setActivePinia(createPinia())
})
it('initializes with empty user', () => {
const store = useUserStore()
expect(store.user).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
it('updates user on login', async () => {
const store = useUserStore()
// Real action executes - not stubbed
await store.login('john', 'password')
expect(store.user).toEqual({ name: 'John' })
expect(store.isLoggedIn).toBe(true)
})
it('clears user on logout', () => {
const store = useUserStore()
store.user = { name: 'John' } // Set initial state
store.logout()
expect(store.user).toBeNull()
})
})
```
## Testing with Real Actions vs Stubbed Actions
```javascript
import { createTestingPinia } from '@pinia/testing'
// Stubbed actions (default) - for isolation
const wrapper = mount(Component, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
// stubActions: true (default) - actions are mocked
})
]
}
})
// Real actions - for integration testing
const wrapper = mount(Component, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false // Actions execute normally
})
]
}
})
```
## Mocking Specific Action Implementations
```javascript
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import { useCartStore } from '@/stores/cart'
test('handles checkout failure', async () => {
const wrapper = mount(Checkout, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
})
const cartStore = useCartStore()
// Mock specific action behavior
cartStore.checkout.mockRejectedValue(new Error('Payment failed'))
await wrapper.find('[data-testid="checkout"]').trigger('click')
await flushPromises()
expect(wrapper.find('.error').text()).toContain('Payment failed')
})
```
## Spying on Actions with vi.spyOn
```javascript
import { setActivePinia, createPinia } from 'pinia'
import { vi } from 'vitest'
import { useUserStore } from '@/stores/user'
test('tracks action calls', async () => {
setActivePinia(createPinia())
const store = useUserStore()
const loginSpy = vi.spyOn(store, 'login')
loginSpy.mockResolvedValue({ success: true })
await store.login('john', 'password')
expect(loginSpy).toHaveBeenCalledWith('john', 'password')
})
```
## Testing Store $subscribe
```javascript
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
test('subscription triggers on state change', () => {
setActivePinia(createPinia())
const store = useUserStore()
const callback = vi.fn()
store.$subscribe(callback)
store.user = { name: 'John' }
expect(callback).toHaveBeenCalled()
})
```
## Reference
- [Pinia Testing Guide](https://pinia.vuejs.org/cookbook/testing.html)
- [@pinia/testing Package](https://www.npmjs.com/package/@pinia/testing)
- [Vue Test Utils - Plugins](https://test-utils.vuejs.org/guide/advanced/plugins.html)

View File

@@ -0,0 +1,229 @@
---
title: Wrap Async Setup Components in Suspense for Testing
impact: HIGH
impactDescription: Components with async setup() fail to render in tests without Suspense wrapper, causing cryptic errors
type: gotcha
tags: [vue3, testing, suspense, async-setup, vue-test-utils, vitest]
---
# Wrap Async Setup Components in Suspense for Testing
**Impact: HIGH** - Components using `async setup()` require a `<Suspense>` wrapper to function correctly. Testing them without Suspense causes the component to never render, leading to test failures and confusing errors.
Create a test wrapper component with Suspense or use a `mountSuspense` helper function for testing async components.
## Task Checklist
- [ ] Identify components with async setup (uses `await` in `<script setup>` or `async setup()`)
- [ ] Create a wrapper component with `<Suspense>` for testing
- [ ] Use `flushPromises()` after mounting to wait for async resolution
- [ ] Access the actual component via `findComponent()` for assertions
- [ ] Consider using `@testing-library/vue` with caution (has Suspense issues)
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import AsyncUserProfile from './AsyncUserProfile.vue'
// BAD: Async component without Suspense wrapper
test('displays user data', async () => {
// This won't render - Vue expects Suspense wrapper for async setup
const wrapper = mount(AsyncUserProfile, {
props: { userId: 1 }
})
await flushPromises()
// This fails - component never rendered
expect(wrapper.find('.username').text()).toBe('John')
})
```
**Correct - Manual Wrapper Component:**
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, Suspense } from 'vue'
import AsyncUserProfile from './AsyncUserProfile.vue'
test('displays user data', async () => {
// Create wrapper component with Suspense
const TestWrapper = defineComponent({
components: { AsyncUserProfile },
template: `
<Suspense>
<AsyncUserProfile :user-id="1" />
<template #fallback>Loading...</template>
</Suspense>
`
})
const wrapper = mount(TestWrapper)
// Initially shows fallback
expect(wrapper.text()).toContain('Loading...')
// Wait for async setup to complete
await flushPromises()
// Find the actual component for detailed assertions
const profile = wrapper.findComponent(AsyncUserProfile)
expect(profile.find('.username').text()).toBe('John')
})
```
**Correct - Reusable Helper Function:**
```javascript
// test-utils.js
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, Suspense, h } from 'vue'
export async function mountSuspense(component, options = {}) {
const { props, slots, ...mountOptions } = options
const wrapper = mount(
defineComponent({
render() {
return h(
Suspense,
null,
{
default: () => h(component, props, slots),
fallback: () => h('div', 'Loading...')
}
)
}
}),
mountOptions
)
// Wait for async component to resolve
await flushPromises()
return {
wrapper,
// Provide easy access to the actual component
component: wrapper.findComponent(component)
}
}
```
```javascript
// AsyncUserProfile.test.js
import { mountSuspense } from './test-utils'
import AsyncUserProfile from './AsyncUserProfile.vue'
test('displays user data', async () => {
const { component } = await mountSuspense(AsyncUserProfile, {
props: { userId: 1 },
global: {
stubs: {
// Stub any child components if needed
}
}
})
expect(component.find('.username').text()).toBe('John')
})
test('handles errors gracefully', async () => {
const { component } = await mountSuspense(AsyncUserProfile, {
props: { userId: 'invalid' }
})
expect(component.find('.error').exists()).toBe(true)
})
```
## Testing with onErrorCaptured
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, Suspense, h, ref, onErrorCaptured } from 'vue'
import AsyncComponent from './AsyncComponent.vue'
test('catches async errors', async () => {
const capturedError = ref(null)
const TestWrapper = defineComponent({
setup() {
onErrorCaptured((error) => {
capturedError.value = error
return true // Prevent error propagation
})
return { capturedError }
},
render() {
return h(Suspense, null, {
default: () => h(AsyncComponent, { shouldFail: true }),
fallback: () => h('div', 'Loading...')
})
}
})
const wrapper = mount(TestWrapper)
await flushPromises()
expect(capturedError.value).toBeTruthy()
expect(capturedError.value.message).toContain('Failed to load')
})
```
## Using with Nuxt's mountSuspended
```javascript
// If using Nuxt, use the built-in mountSuspended helper
import { mountSuspended } from '@nuxt/test-utils/runtime'
import AsyncPage from './AsyncPage.vue'
test('renders async page', async () => {
const wrapper = await mountSuspended(AsyncPage, {
props: { id: 1 }
})
expect(wrapper.find('h1').text()).toBe('Page Title')
})
```
## Important Caveats
### @testing-library/vue Limitation
```javascript
// CAUTION: @testing-library/vue has issues with Suspense
// Use @vue/test-utils for async components instead
// If you must use Testing Library, create manual wrapper:
import { render, waitFor } from '@testing-library/vue'
test('async component with testing library', async () => {
const TestWrapper = {
template: `
<Suspense>
<AsyncComponent />
</Suspense>
`,
components: { AsyncComponent }
}
const { getByText } = render(TestWrapper)
await waitFor(() => {
expect(getByText('Loaded content')).toBeInTheDocument()
})
})
```
### Accessing Component Instance
```javascript
test('access vm on async component', async () => {
const { wrapper, component } = await mountSuspense(AsyncComponent)
// The wrapper.vm is the Suspense wrapper - not useful
// Use component.vm for the actual async component
expect(component.vm.someData).toBe('value')
})
```
## Reference
- [Vue Test Utils - Async Suspense](https://test-utils.vuejs.org/guide/advanced/async-suspense)
- [Vue.js Suspense Documentation](https://vuejs.org/guide/built-ins/suspense.html)
- [Testing Library Vue Suspense Issue](https://github.com/testing-library/vue-testing-library/issues/230)

View File

@@ -0,0 +1,204 @@
---
title: Use Vitest for Vue 3 Testing - Recommended by Vue Team
impact: MEDIUM
impactDescription: Using Jest or other runners with Vite projects requires complex configuration and causes slower test runs
type: best-practice
tags: [vue3, testing, vitest, vite, configuration, setup]
---
# Use Vitest for Vue 3 Testing - Recommended by Vue Team
**Impact: MEDIUM** - Vitest is created and maintained by Vue/Vite team members and shares the same configuration and transform pipeline as Vite. Using Jest or other test runners with Vite-based projects requires additional configuration and can result in slower test execution and compatibility issues.
Use Vitest for new Vue 3 projects. Only consider Jest if migrating an existing test suite.
## Task Checklist
- [ ] Install Vitest and related packages for Vue testing
- [ ] Configure vitest in vite.config.js or vitest.config.js
- [ ] Set up proper test environment (happy-dom or jsdom)
- [ ] Add test scripts to package.json
- [ ] Configure globals if desired for cleaner test syntax
- [ ] Use @vue/test-utils for component mounting
## Quick Setup
```bash
# Install required packages
npm install -D vitest @vue/test-utils happy-dom
# or with jsdom
npm install -D vitest @vue/test-utils jsdom
```
**vite.config.js:**
```javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
// Enable global test APIs (describe, it, expect)
globals: true,
// Use happy-dom for faster tests (or 'jsdom' for better compatibility)
environment: 'happy-dom',
// Optional: Setup files for global configuration
setupFiles: ['./src/test/setup.js']
}
})
```
**package.json:**
```json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
```
**tsconfig.json (if using TypeScript):**
```json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
```
## Test File Example
```javascript
// src/components/Counter.test.js
import { describe, it, expect, beforeEach } from 'vitest' // optional with globals: true
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
let wrapper
beforeEach(() => {
wrapper = mount(Counter)
})
it('renders initial count', () => {
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
})
it('increments when button clicked', async () => {
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
})
```
## Vitest vs Jest Comparison
| Feature | Vitest | Jest |
|---------|--------|------|
| Vite Integration | Native | Requires config |
| Speed | Very fast (ESM native) | Slower with Vite |
| Watch Mode | Excellent | Good |
| Vue SFC Support | Works with Vite | Needs vue-jest |
| Config Sharing | Same as vite.config | Separate |
| API | Jest-compatible | Standard |
## Using with Testing Library
```bash
npm install -D @testing-library/vue @testing-library/jest-dom
```
```javascript
// src/test/setup.js
import { expect } from 'vitest'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
```
```javascript
// Component.test.js
import { render, screen, fireEvent } from '@testing-library/vue'
import UserCard from './UserCard.vue'
test('displays user name', () => {
render(UserCard, {
props: { name: 'John Doe' }
})
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
```
## Advanced Configuration
```javascript
// vitest.config.js (separate file if preferred)
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
exclude: ['node_modules', 'dist', 'e2e'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules', 'test']
},
// Helpful for debugging
reporters: ['verbose'],
// Run tests in sequence in CI
poolOptions: {
threads: {
singleThread: process.env.CI === 'true'
}
}
}
})
```
## Common Patterns
### Mocking Modules
```javascript
import { vi } from 'vitest'
vi.mock('@/api/users', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'John' })
}))
```
### Testing with Fake Timers
```javascript
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
test('debounced search', async () => {
const wrapper = mount(SearchBox)
await wrapper.find('input').setValue('vue')
vi.advanceTimersByTime(300)
await flushPromises()
expect(wrapper.emitted('search')).toBeTruthy()
})
```
## Reference
- [Vitest Documentation](https://vitest.dev/)
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
- [Vue Test Utils](https://test-utils.vuejs.org/)

View File

@@ -0,0 +1,5 @@
# Generation Info
- **Source:** `sources/vue`
- **Git SHA:** `01abf2d03815d9d0ff0b06362a68d5d9542c9e48`
- **Generated:** 2026-01-31

View File

@@ -0,0 +1,84 @@
---
name: vue
description: Vue 3 Composition API, script setup macros, reactivity system, and built-in components. Use when writing Vue SFCs, defineProps/defineEmits/defineModel, watchers, or using Transition/Teleport/Suspense/KeepAlive.
metadata:
author: Anthony Fu
version: "2026.1.31"
source: Generated from https://github.com/vuejs/docs, scripts at https://github.com/antfu/skills
---
# Vue
> Based on Vue 3.5. Always use Composition API with `<script setup lang="ts">`.
## Preferences
- Prefer TypeScript over JavaScript
- Prefer `<script setup lang="ts">` over `<script>`
- For performance, prefer `shallowRef` over `ref` if deep reactivity is not needed
- Always use Composition API over Options API
- Discourage using Reactive Props Destructure
## Core
| Topic | Description | Reference |
|-------|-------------|-----------|
| Script Setup & Macros | `<script setup>`, defineProps, defineEmits, defineModel, defineExpose, defineOptions, defineSlots, generics | [script-setup-macros](references/script-setup-macros.md) |
| Reactivity & Lifecycle | ref, shallowRef, computed, watch, watchEffect, effectScope, lifecycle hooks, composables | [core-new-apis](references/core-new-apis.md) |
## Features
| Topic | Description | Reference |
|-------|-------------|-----------|
| Built-in Components & Directives | Transition, Teleport, Suspense, KeepAlive, v-memo, custom directives | [advanced-patterns](references/advanced-patterns.md) |
## Quick Reference
### Component Template
```vue
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps<{
title: string
count?: number
}>()
const emit = defineEmits<{
update: [value: string]
}>()
const model = defineModel<string>()
const doubled = computed(() => (props.count ?? 0) * 2)
watch(() => props.title, (newVal) => {
console.log('Title changed:', newVal)
})
onMounted(() => {
console.log('Component mounted')
})
</script>
<template>
<div>{{ title }} - {{ doubled }}</div>
</template>
```
### Key Imports
```ts
// Reactivity
import { ref, shallowRef, computed, reactive, readonly, toRef, toRefs, toValue } from 'vue'
// Watchers
import { watch, watchEffect, watchPostEffect, onWatcherCleanup } from 'vue'
// Lifecycle
import { onMounted, onUpdated, onUnmounted, onBeforeMount, onBeforeUpdate, onBeforeUnmount } from 'vue'
// Utilities
import { nextTick, defineComponent, defineAsyncComponent } from 'vue'
```

View File

@@ -0,0 +1,314 @@
---
name: advanced-patterns
description: Vue 3 built-in components (Transition, Teleport, Suspense, KeepAlive) and advanced directives
---
# Built-in Components & Directives
## Transition
Animate enter/leave of a single element or component.
```vue
<template>
<Transition name="fade">
<div v-if="show">Content</div>
</Transition>
</template>
<style>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>
```
### CSS Classes
| Class | When |
|-------|------|
| `{name}-enter-from` | Start state for enter |
| `{name}-enter-active` | Active state for enter (add transitions here) |
| `{name}-enter-to` | End state for enter |
| `{name}-leave-from` | Start state for leave |
| `{name}-leave-active` | Active state for leave |
| `{name}-leave-to` | End state for leave |
### Transition Modes
```vue
<!-- Wait for leave to complete before enter -->
<Transition name="fade" mode="out-in">
<component :is="currentView" />
</Transition>
```
### JavaScript Hooks
```vue
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
:css="false"
>
<div v-if="show">Content</div>
</Transition>
<script setup lang="ts">
function onEnter(el: Element, done: () => void) {
// Animate with JS library
gsap.to(el, { opacity: 1, onComplete: done })
}
</script>
```
### Appear on Initial Render
```vue
<Transition appear name="fade">
<div>Shows with animation on mount</div>
</Transition>
```
## TransitionGroup
Animate list items. Each child must have a unique `key`.
```vue
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.text }}
</li>
</TransitionGroup>
</template>
<style>
.list-enter-active, .list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from, .list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* Move animation for reordering */
.list-move {
transition: transform 0.3s ease;
}
</style>
```
## Teleport
Render content to a different DOM location.
```vue
<template>
<button @click="open = true">Open Modal</button>
<Teleport to="body">
<div v-if="open" class="modal">
Modal content rendered at body
</div>
</Teleport>
</template>
```
### Props
```vue
<!-- CSS selector -->
<Teleport to="#modal-container">
<!-- DOM element -->
<Teleport :to="targetElement">
<!-- Disable teleport conditionally -->
<Teleport to="body" :disabled="isMobile">
<!-- Defer until target exists (Vue 3.5+) -->
<Teleport defer to="#late-rendered-target">
```
## Suspense
Handle async dependencies with loading states. **Experimental feature.**
```vue
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
```
### Async Dependencies
Suspense waits for:
- Components with `async setup()`
- Components using top-level `await` in `<script setup>`
- Async components created with `defineAsyncComponent`
```vue
<!-- AsyncComponent.vue -->
<script setup lang="ts">
const data = await fetch('/api/data').then(r => r.json())
</script>
```
### Events
```vue
<Suspense
@pending="onPending"
@resolve="onResolve"
@fallback="onFallback"
>
...
</Suspense>
```
## KeepAlive
Cache component instances when toggled.
```vue
<template>
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
</template>
```
### Include/Exclude
```vue
<!-- By name (string or regex) -->
<KeepAlive include="ComponentA,ComponentB">
<KeepAlive :include="/^Tab/">
<KeepAlive :include="['TabA', 'TabB']">
<!-- Exclude -->
<KeepAlive exclude="ModalComponent">
<!-- Max cached instances -->
<KeepAlive :max="10">
```
### Lifecycle Hooks
```ts
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// Called when component is inserted from cache
fetchLatestData()
})
onDeactivated(() => {
// Called when component is removed to cache
pauseTimers()
})
```
## v-memo
Skip re-renders when dependencies unchanged. Use for performance optimization.
```vue
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
<!-- Only re-renders when item.selected changes -->
<ExpensiveComponent :item="item" />
</div>
</template>
```
Equivalent to `v-once` when empty:
```vue
<div v-memo="[]">Never updates</div>
```
## v-once
Render once, skip all future updates.
```vue
<span v-once>Static: {{ neverChanges }}</span>
```
## Custom Directives
Create reusable DOM manipulations.
```ts
// Directive definition
const vFocus: Directive<HTMLElement> = {
mounted: (el) => el.focus()
}
// Full hooks
const vColor: Directive<HTMLElement, string> = {
created(el, binding, vnode, prevVnode) {},
beforeMount(el, binding) {},
mounted(el, binding) {
el.style.color = binding.value
},
beforeUpdate(el, binding) {},
updated(el, binding) {
el.style.color = binding.value
},
beforeUnmount(el, binding) {},
unmounted(el, binding) {}
}
```
### Directive Arguments & Modifiers
```vue
<div v-color:background.bold="'red'">
<script setup lang="ts">
const vColor: Directive<HTMLElement, string> = {
mounted(el, binding) {
// binding.arg = 'background'
// binding.modifiers = { bold: true }
// binding.value = 'red'
el.style[binding.arg || 'color'] = binding.value
if (binding.modifiers.bold) {
el.style.fontWeight = 'bold'
}
}
}
</script>
```
### Global Registration
```ts
// main.ts
app.directive('focus', {
mounted: (el) => el.focus()
})
```
<!--
Source references:
- https://vuejs.org/api/built-in-components.html
- https://vuejs.org/guide/built-ins/transition.html
- https://vuejs.org/guide/built-ins/teleport.html
- https://vuejs.org/guide/built-ins/suspense.html
- https://vuejs.org/guide/built-ins/keep-alive.html
- https://vuejs.org/api/built-in-directives.html
- https://vuejs.org/guide/reusability/custom-directives.html
-->

View File

@@ -0,0 +1,264 @@
---
name: core-new-apis
description: Vue 3 reactivity system, lifecycle hooks, and composable patterns
---
# Reactivity, Lifecycle & Composables
## Reactivity
### ref vs shallowRef
```ts
import { ref, shallowRef } from 'vue'
// ref - deep reactivity (tracks nested changes)
const user = ref({ name: 'John', profile: { age: 30 } })
user.value.profile.age = 31 // Triggers reactivity
// shallowRef - only .value assignment triggers reactivity (better performance)
const data = shallowRef({ items: [] })
data.value.items.push('new') // Does NOT trigger reactivity
data.value = { items: ['new'] } // Triggers reactivity
```
**Prefer `shallowRef`** for large data structures or when deep reactivity is unnecessary.
### computed
```ts
import { ref, computed } from 'vue'
const count = ref(0)
// Read-only computed
const doubled = computed(() => count.value * 2)
// Writable computed
const plusOne = computed({
get: () => count.value + 1,
set: (val) => { count.value = val - 1 }
})
```
### reactive & readonly
```ts
import { reactive, readonly } from 'vue'
const state = reactive({ count: 0, nested: { value: 1 } })
state.count++ // Reactive
const readonlyState = readonly(state)
readonlyState.count++ // Warning, mutation blocked
```
Note: `reactive()` loses reactivity on destructuring. Use `ref()` or `toRefs()`.
## Watchers
### watch
```ts
import { ref, watch } from 'vue'
const count = ref(0)
// Watch single ref
watch(count, (newVal, oldVal) => {
console.log(`Changed from ${oldVal} to ${newVal}`)
})
// Watch getter
watch(
() => props.id,
(id) => fetchData(id),
{ immediate: true }
)
// Watch multiple sources
watch([firstName, lastName], ([first, last]) => {
fullName.value = `${first} ${last}`
})
// Deep watch with depth limit (Vue 3.5+)
watch(state, callback, { deep: 2 })
// Once (Vue 3.4+)
watch(source, callback, { once: true })
```
### watchEffect
Runs immediately and auto-tracks dependencies.
```ts
import { ref, watchEffect, onWatcherCleanup } from 'vue'
const id = ref(1)
watchEffect(async () => {
const controller = new AbortController()
// Cleanup on re-run or unmount (Vue 3.5+)
onWatcherCleanup(() => controller.abort())
const res = await fetch(`/api/${id.value}`, { signal: controller.signal })
data.value = await res.json()
})
// Pause/resume (Vue 3.5+)
const { pause, resume, stop } = watchEffect(() => {})
pause()
resume()
stop()
```
### Flush Timing
```ts
// 'pre' (default) - before component update
// 'post' - after component update (access updated DOM)
// 'sync' - immediate, use with caution
watch(source, callback, { flush: 'post' })
watchPostEffect(() => {}) // Alias for flush: 'post'
```
## Lifecycle Hooks
```ts
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
onActivated, // KeepAlive
onDeactivated, // KeepAlive
onServerPrefetch // SSR only
} from 'vue'
onMounted(() => {
console.log('DOM is ready')
})
onUnmounted(() => {
// Cleanup timers, listeners, etc.
})
// Error boundary
onErrorCaptured((err, instance, info) => {
console.error(err)
return false // Stop propagation
})
```
## Effect Scope
Group reactive effects for batch disposal.
```ts
import { effectScope, onScopeDispose } from 'vue'
const scope = effectScope()
scope.run(() => {
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, () => console.log(count.value))
// Cleanup when scope stops
onScopeDispose(() => {
console.log('Scope disposed')
})
})
// Dispose all effects
scope.stop()
```
## Composables
Composables are functions that encapsulate stateful logic using Composition API.
### Naming Convention
- Start with `use`: `useMouse`, `useFetch`, `useCounter`
### Pattern
```ts
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
const update = (e: MouseEvent) => {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
```
### Accept Reactive Input
Use `toValue()` (Vue 3.3+) to normalize refs, getters, or plain values.
```ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch(url: MaybeRefOrGetter<string>) {
const data = ref(null)
const error = ref(null)
watchEffect(async () => {
data.value = null
error.value = null
try {
const res = await fetch(toValue(url))
data.value = await res.json()
} catch (e) {
error.value = e
}
})
return { data, error }
}
// Usage - all work:
useFetch('/api/users')
useFetch(urlRef)
useFetch(() => `/api/users/${props.id}`)
```
### Return Refs (Not Reactive)
Always return plain object with refs for destructuring compatibility.
```ts
// Good - preserves reactivity when destructured
return { x, y }
// Bad - loses reactivity when destructured
return reactive({ x, y })
```
<!--
Source references:
- https://vuejs.org/api/reactivity-core.html
- https://vuejs.org/api/reactivity-advanced.html
- https://vuejs.org/api/composition-api-lifecycle.html
- https://vuejs.org/guide/reusability/composables.html
-->

View File

@@ -0,0 +1,204 @@
---
name: script-setup-macros
description: Vue 3 script setup syntax and compiler macros for defining props, emits, models, and more
---
# Script Setup & Macros
`<script setup>` is the recommended syntax for Vue SFCs with Composition API. It provides better runtime performance and IDE type inference.
## Basic Syntax
```vue
<script setup lang="ts">
// Top-level bindings are exposed to template
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
const count = ref(0)
const increment = () => count.value++
</script>
<template>
<button @click="increment">{{ count }}</button>
<MyComponent />
</template>
```
## defineProps
Declare component props with full TypeScript support.
```ts
// Type-based declaration (recommended)
const props = defineProps<{
title: string
count?: number
items: string[]
}>()
// With defaults (Vue 3.5+)
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()
// With defaults (Vue 3.4 and below)
const props = withDefaults(defineProps<{
title: string
items?: string[]
}>(), {
items: () => [] // Use factory for arrays/objects
})
```
## defineEmits
Declare emitted events with typed payloads.
```ts
// Named tuple syntax (recommended)
const emit = defineEmits<{
update: [value: string]
change: [id: number, name: string]
close: []
}>()
emit('update', 'new value')
emit('change', 1, 'name')
emit('close')
```
## defineModel
Two-way binding prop consumed via `v-model`. Available in Vue 3.4+.
```ts
// Basic usage - creates "modelValue" prop
const model = defineModel<string>()
model.value = 'hello' // Emits "update:modelValue"
// Named model - consumed via v-model:name
const count = defineModel<number>('count', { default: 0 })
// With modifiers
const [value, modifiers] = defineModel<string>()
if (modifiers.trim) {
// Handle trim modifier
}
// With transformers
const [value, modifiers] = defineModel({
get(val) { return val?.toLowerCase() },
set(val) { return modifiers.trim ? val?.trim() : val }
})
```
Parent usage:
```vue
<Child v-model="name" />
<Child v-model:count="total" />
<Child v-model.trim="text" />
```
## defineExpose
Explicitly expose properties to parent via template refs. Components are closed by default.
```ts
import { ref } from 'vue'
const count = ref(0)
const reset = () => { count.value = 0 }
defineExpose({
count,
reset
})
```
Parent access:
```ts
const childRef = ref<{ count: number; reset: () => void }>()
childRef.value?.reset()
```
## defineOptions
Declare component options without a separate `<script>` block. Available in Vue 3.3+.
```ts
defineOptions({
inheritAttrs: false,
name: 'CustomName'
})
```
## defineSlots
Provide type hints for slot props. Available in Vue 3.3+.
```ts
const slots = defineSlots<{
default(props: { item: string; index: number }): any
header(props: { title: string }): any
}>()
```
## Generic Components
Declare generic type parameters using the `generic` attribute.
```vue
<script setup lang="ts" generic="T extends string | number">
defineProps<{
items: T[]
selected: T
}>()
</script>
```
Multiple generics with constraints:
```vue
<script setup lang="ts" generic="T, U extends Record<string, T>">
import type { Item } from './types'
defineProps<{
data: U
key: keyof U
}>()
</script>
```
## Local Custom Directives
Use `vNameOfDirective` naming convention.
```ts
const vFocus = {
mounted: (el: HTMLElement) => el.focus()
}
// Or import and rename
import { myDirective as vMyDirective } from './directives'
```
```vue
<template>
<input v-focus />
</template>
```
## Top-level await
Use `await` directly in `<script setup>`. The component becomes async and must be used with `<Suspense>`.
```vue
<script setup lang="ts">
const data = await fetch('/api/data').then(r => r.json())
</script>
```
<!--
Source references:
- https://vuejs.org/api/sfc-script-setup.html
-->

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 SerKo <https://github.com/serkodev>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,419 @@
---
name: vueuse-functions
description: Apply VueUse composables where appropriate to build concise, maintainable Vue.js / Nuxt features.
license: MIT
metadata:
author: SerKo <https://github.com/serkodev>
version: "1.0"
compatibility: Requires Vue 3 (or above) or Nuxt 3 (or above) project
---
# VueUse Functions
This skill is a decision-and-implementation guide for VueUse composables in Vue.js / Nuxt projects. It maps requirements to the most suitable VueUse function, applies the correct usage pattern, and prefers composable-based solutions over bespoke code to keep implementations concise, maintainable, and performant.
## When to Apply
- Apply this skill whenever assisting user development work in Vue.js / Nuxt.
- Always check first whether a VueUse function can implement the requirement.
- Prefer VueUse composables over custom code to improve readability, maintainability, and performance.
- Map requirements to the most appropriate VueUse function and follow the functions invocation rule.
- Please refer to the `Invocation` field in the below functions table. For example:
- `AUTO`: Use automatically when applicable.
- `EXTERNAL`: Use only if the user already installed the required external dependency; otherwise reconsider, and ask to install only if truly needed.
- `EXPLICIT_ONLY`: Use only when explicitly requested by the user.
> *NOTE* User instructions in the prompt or `AGENTS.md` may override a functions default `Invocation` rule.
## Functions
All functions listed below are part of the [VueUse](https://vueuse.org/) library, each section categorizes functions based on their functionality.
IMPORTANT: Each function entry includes a short `Description` and a detailed `Reference`. When using any function, always consult the corresponding document in `./references` for Usage details and Type Declarations.
### State
| Function | Description | Invocation |
|----------|-------------|------------|
| [`createGlobalState`](references/createGlobalState.md) | Keep states in the global scope to be reusable across Vue instances | AUTO |
| [`createInjectionState`](references/createInjectionState.md) | Create global state that can be injected into components | AUTO |
| [`createSharedComposable`](references/createSharedComposable.md) | Make a composable function usable with multiple Vue instances | AUTO |
| [`injectLocal`](references/injectLocal.md) | Extended `inject` with ability to call `provideLocal` to provide the value in the same component | AUTO |
| [`provideLocal`](references/provideLocal.md) | Extended `provide` with ability to call `injectLocal` to obtain the value in the same component | AUTO |
| [`useAsyncState`](references/useAsyncState.md) | Reactive async state | AUTO |
| [`useDebouncedRefHistory`](references/useDebouncedRefHistory.md) | Shorthand for `useRefHistory` with debounced filter | AUTO |
| [`useLastChanged`](references/useLastChanged.md) | Records the timestamp of the last change | AUTO |
| [`useLocalStorage`](references/useLocalStorage.md) | Reactive [LocalStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) | AUTO |
| [`useManualRefHistory`](references/useManualRefHistory.md) | Manually track the change history of a ref when the using calls `commit()` | AUTO |
| [`useRefHistory`](references/useRefHistory.md) | Track the change history of a ref | AUTO |
| [`useSessionStorage`](references/useSessionStorage.md) | Reactive [SessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) | AUTO |
| [`useStorage`](references/useStorage.md) | Create a reactive ref that can be used to access & modify [LocalStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) or [SessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) | AUTO |
| [`useStorageAsync`](references/useStorageAsync.md) | Reactive Storage in with async support | AUTO |
| [`useThrottledRefHistory`](references/useThrottledRefHistory.md) | Shorthand for `useRefHistory` with throttled filter | AUTO |
### Elements
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useActiveElement`](references/useActiveElement.md) | Reactive `document.activeElement` | AUTO |
| [`useDocumentVisibility`](references/useDocumentVisibility.md) | Reactively track [`document.visibilityState`](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState) | AUTO |
| [`useDraggable`](references/useDraggable.md) | Make elements draggable | AUTO |
| [`useDropZone`](references/useDropZone.md) | Create a zone where files can be dropped | AUTO |
| [`useElementBounding`](references/useElementBounding.md) | Reactive [bounding box](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) of an HTML element | AUTO |
| [`useElementSize`](references/useElementSize.md) | Reactive size of an HTML element | AUTO |
| [`useElementVisibility`](references/useElementVisibility.md) | Tracks the visibility of an element within the viewport | AUTO |
| [`useIntersectionObserver`](references/useIntersectionObserver.md) | Detects that a target element's visibility | AUTO |
| [`useMouseInElement`](references/useMouseInElement.md) | Reactive mouse position related to an element | AUTO |
| [`useMutationObserver`](references/useMutationObserver.md) | Watch for changes being made to the DOM tree | AUTO |
| [`useParentElement`](references/useParentElement.md) | Get parent element of the given element | AUTO |
| [`useResizeObserver`](references/useResizeObserver.md) | Reports changes to the dimensions of an Element's content or the border-box | AUTO |
| [`useWindowFocus`](references/useWindowFocus.md) | Reactively track window focus with `window.onfocus` and `window.onblur` events | AUTO |
| [`useWindowScroll`](references/useWindowScroll.md) | Reactive window scroll | AUTO |
| [`useWindowSize`](references/useWindowSize.md) | Reactive window size | AUTO |
### Browser
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useBluetooth`](references/useBluetooth.md) | Reactive [Web Bluetooth API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API) | AUTO |
| [`useBreakpoints`](references/useBreakpoints.md) | Reactive viewport breakpoints | AUTO |
| [`useBroadcastChannel`](references/useBroadcastChannel.md) | Reactive [BroadcastChannel API](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) | AUTO |
| [`useBrowserLocation`](references/useBrowserLocation.md) | Reactive browser location | AUTO |
| [`useClipboard`](references/useClipboard.md) | Reactive [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API) | AUTO |
| [`useClipboardItems`](references/useClipboardItems.md) | Reactive [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API) | AUTO |
| [`useColorMode`](references/useColorMode.md) | Reactive color mode (dark / light / customs) with auto data persistence | AUTO |
| [`useCssSupports`](references/useCssSupports.md) | SSR compatible and reactive [`CSS.supports`](https://developer.mozilla.org/docs/Web/API/CSS/supports_static) | AUTO |
| [`useCssVar`](references/useCssVar.md) | Manipulate CSS variables | AUTO |
| [`useDark`](references/useDark.md) | Reactive dark mode with auto data persistence | AUTO |
| [`useEventListener`](references/useEventListener.md) | Use EventListener with ease | AUTO |
| [`useEyeDropper`](references/useEyeDropper.md) | Reactive [EyeDropper API](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper_API) | AUTO |
| [`useFavicon`](references/useFavicon.md) | Reactive favicon | AUTO |
| [`useFileDialog`](references/useFileDialog.md) | Open file dialog with ease | AUTO |
| [`useFileSystemAccess`](references/useFileSystemAccess.md) | Create and read and write local files with [FileSystemAccessAPI](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) | AUTO |
| [`useFullscreen`](references/useFullscreen.md) | Reactive [Fullscreen API](https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API) | AUTO |
| [`useGamepad`](references/useGamepad.md) | Provides reactive bindings for the [Gamepad API](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API) | AUTO |
| [`useImage`](references/useImage.md) | Reactive load an image in the browser | AUTO |
| [`useMediaControls`](references/useMediaControls.md) | Reactive media controls for both `audio` and `video` elements | AUTO |
| [`useMediaQuery`](references/useMediaQuery.md) | Reactive [Media Query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Testing_media_queries) | AUTO |
| [`useMemory`](references/useMemory.md) | Reactive Memory Info | AUTO |
| [`useObjectUrl`](references/useObjectUrl.md) | Reactive URL representing an object | AUTO |
| [`usePerformanceObserver`](references/usePerformanceObserver.md) | Observe performance metrics | AUTO |
| [`usePermission`](references/usePermission.md) | Reactive [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API) | AUTO |
| [`usePreferredColorScheme`](references/usePreferredColorScheme.md) | Reactive [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) media query | AUTO |
| [`usePreferredContrast`](references/usePreferredContrast.md) | Reactive [prefers-contrast](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-contrast) media query | AUTO |
| [`usePreferredDark`](references/usePreferredDark.md) | Reactive dark theme preference | AUTO |
| [`usePreferredLanguages`](references/usePreferredLanguages.md) | Reactive [Navigator Languages](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorLanguage/languages) | AUTO |
| [`usePreferredReducedMotion`](references/usePreferredReducedMotion.md) | Reactive [prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media query | AUTO |
| [`usePreferredReducedTransparency`](references/usePreferredReducedTransparency.md) | Reactive [prefers-reduced-transparency](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-transparency) media query | AUTO |
| [`useScreenOrientation`](references/useScreenOrientation.md) | Reactive [Screen Orientation API](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API) | AUTO |
| [`useScreenSafeArea`](references/useScreenSafeArea.md) | Reactive `env(safe-area-inset-*)` | AUTO |
| [`useScriptTag`](references/useScriptTag.md) | Creates a script tag | AUTO |
| [`useShare`](references/useShare.md) | Reactive [Web Share API](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share) | AUTO |
| [`useSSRWidth`](references/useSSRWidth.md) | Used to set a global viewport width which will be used when rendering SSR components that rely on the viewport width like [`useMediaQuery`](../useMediaQuery/index.md) or [`useBreakpoints`](../useBreakpoints/index.md) | AUTO |
| [`useStyleTag`](references/useStyleTag.md) | Inject reactive `style` element in head | AUTO |
| [`useTextareaAutosize`](references/useTextareaAutosize.md) | Automatically update the height of a textarea depending on the content | AUTO |
| [`useTextDirection`](references/useTextDirection.md) | Reactive [dir](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir) of the element's text | AUTO |
| [`useTitle`](references/useTitle.md) | Reactive document title | AUTO |
| [`useUrlSearchParams`](references/useUrlSearchParams.md) | Reactive [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | AUTO |
| [`useVibrate`](references/useVibrate.md) | Reactive [Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API) | AUTO |
| [`useWakeLock`](references/useWakeLock.md) | Reactive [Screen Wake Lock API](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) | AUTO |
| [`useWebNotification`](references/useWebNotification.md) | Reactive [Notification](https://developer.mozilla.org/en-US/docs/Web/API/notification) | AUTO |
| [`useWebWorker`](references/useWebWorker.md) | Simple [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) registration and communication | AUTO |
| [`useWebWorkerFn`](references/useWebWorkerFn.md) | Run expensive functions without blocking the UI | AUTO |
### Sensors
| Function | Description | Invocation |
|----------|-------------|------------|
| [`onClickOutside`](references/onClickOutside.md) | Listen for clicks outside of an element | AUTO |
| [`onElementRemoval`](references/onElementRemoval.md) | Fires when the element or any element containing it is removed from the DOM | AUTO |
| [`onKeyStroke`](references/onKeyStroke.md) | Listen for keyboard keystrokes | AUTO |
| [`onLongPress`](references/onLongPress.md) | Listen for a long press on an element | AUTO |
| [`onStartTyping`](references/onStartTyping.md) | Fires when users start typing on non-editable elements | AUTO |
| [`useBattery`](references/useBattery.md) | Reactive [Battery Status API](https://developer.mozilla.org/en-US/docs/Web/API/Battery_Status_API) | AUTO |
| [`useDeviceMotion`](references/useDeviceMotion.md) | Reactive [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent) | AUTO |
| [`useDeviceOrientation`](references/useDeviceOrientation.md) | Reactive [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent) | AUTO |
| [`useDevicePixelRatio`](references/useDevicePixelRatio.md) | Reactively track [`window.devicePixelRatio`](https://developer.mozilla.org/docs/Web/API/Window/devicePixelRatio) | AUTO |
| [`useDevicesList`](references/useDevicesList.md) | Reactive [enumerateDevices](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices) listing available input/output devices | AUTO |
| [`useDisplayMedia`](references/useDisplayMedia.md) | Reactive [`mediaDevices.getDisplayMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia) streaming | AUTO |
| [`useElementByPoint`](references/useElementByPoint.md) | Reactive element by point | AUTO |
| [`useElementHover`](references/useElementHover.md) | Reactive element's hover state | AUTO |
| [`useFocus`](references/useFocus.md) | Reactive utility to track or set the focus state of a DOM element | AUTO |
| [`useFocusWithin`](references/useFocusWithin.md) | Reactive utility to track if an element or one of its decendants has focus | AUTO |
| [`useFps`](references/useFps.md) | Reactive FPS (frames per second) | AUTO |
| [`useGeolocation`](references/useGeolocation.md) | Reactive [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API) | AUTO |
| [`useIdle`](references/useIdle.md) | Tracks whether the user is being inactive | AUTO |
| [`useInfiniteScroll`](references/useInfiniteScroll.md) | Infinite scrolling of the element | AUTO |
| [`useKeyModifier`](references/useKeyModifier.md) | Reactive [Modifier State](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState) | AUTO |
| [`useMagicKeys`](references/useMagicKeys.md) | Reactive keys pressed state | AUTO |
| [`useMouse`](references/useMouse.md) | Reactive mouse position | AUTO |
| [`useMousePressed`](references/useMousePressed.md) | Reactive mouse pressing state | AUTO |
| [`useNavigatorLanguage`](references/useNavigatorLanguage.md) | Reactive [navigator.language](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language) | AUTO |
| [`useNetwork`](references/useNetwork.md) | Reactive [Network status](https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API) | AUTO |
| [`useOnline`](references/useOnline.md) | Reactive online state | AUTO |
| [`usePageLeave`](references/usePageLeave.md) | Reactive state to show whether the mouse leaves the page | AUTO |
| [`useParallax`](references/useParallax.md) | Create parallax effect easily | AUTO |
| [`usePointer`](references/usePointer.md) | Reactive [pointer state](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) | AUTO |
| [`usePointerLock`](references/usePointerLock.md) | Reactive [pointer lock](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API) | AUTO |
| [`usePointerSwipe`](references/usePointerSwipe.md) | Reactive swipe detection based on [PointerEvents](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) | AUTO |
| [`useScroll`](references/useScroll.md) | Reactive scroll position and state | AUTO |
| [`useScrollLock`](references/useScrollLock.md) | Lock scrolling of the element | AUTO |
| [`useSpeechRecognition`](references/useSpeechRecognition.md) | Reactive [SpeechRecognition](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition) | AUTO |
| [`useSpeechSynthesis`](references/useSpeechSynthesis.md) | Reactive [SpeechSynthesis](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis) | AUTO |
| [`useSwipe`](references/useSwipe.md) | Reactive swipe detection based on [`TouchEvents`](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent) | AUTO |
| [`useTextSelection`](references/useTextSelection.md) | Reactively track user text selection based on [`Window.getSelection`](https://developer.mozilla.org/en-US/docs/Web/API/Window/getSelection) | AUTO |
| [`useUserMedia`](references/useUserMedia.md) | Reactive [`mediaDevices.getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) streaming | AUTO |
### Network
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useEventSource`](references/useEventSource.md) | An [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) or [Server-Sent-Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) instance opens a persistent connection to an HTTP server | AUTO |
| [`useFetch`](references/useFetch.md) | Reactive [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) provides the ability to abort requests | AUTO |
| [`useWebSocket`](references/useWebSocket.md) | Reactive [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket) client | AUTO |
### Animation
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useAnimate`](references/useAnimate.md) | Reactive [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) | AUTO |
| [`useInterval`](references/useInterval.md) | Reactive counter that increases on every interval | AUTO |
| [`useIntervalFn`](references/useIntervalFn.md) | Wrapper for `setInterval` with controls | AUTO |
| [`useNow`](references/useNow.md) | Reactive current Date instance | AUTO |
| [`useRafFn`](references/useRafFn.md) | Call function on every `requestAnimationFrame` | AUTO |
| [`useTimeout`](references/useTimeout.md) | Reactive value that becomes `true` after a given time | AUTO |
| [`useTimeoutFn`](references/useTimeoutFn.md) | Wrapper for `setTimeout` with controls | AUTO |
| [`useTimestamp`](references/useTimestamp.md) | Reactive current timestamp | AUTO |
| [`useTransition`](references/useTransition.md) | Transition between values | AUTO |
### Component
| Function | Description | Invocation |
|----------|-------------|------------|
| [`computedInject`](references/computedInject.md) | Combine `computed` and `inject` | AUTO |
| [`createReusableTemplate`](references/createReusableTemplate.md) | Define and reuse template inside the component scope | AUTO |
| [`createTemplatePromise`](references/createTemplatePromise.md) | Template as Promise | AUTO |
| [`templateRef`](references/templateRef.md) | Shorthand for binding ref to template element | AUTO |
| [`tryOnBeforeMount`](references/tryOnBeforeMount.md) | Safe `onBeforeMount` | AUTO |
| [`tryOnBeforeUnmount`](references/tryOnBeforeUnmount.md) | Safe `onBeforeUnmount` | AUTO |
| [`tryOnMounted`](references/tryOnMounted.md) | Safe `onMounted` | AUTO |
| [`tryOnScopeDispose`](references/tryOnScopeDispose.md) | Safe `onScopeDispose` | AUTO |
| [`tryOnUnmounted`](references/tryOnUnmounted.md) | Safe `onUnmounted` | AUTO |
| [`unrefElement`](references/unrefElement.md) | Retrieves the underlying DOM element from a Vue ref or component instance | AUTO |
| [`useCurrentElement`](references/useCurrentElement.md) | Get the DOM element of current component as a ref | AUTO |
| [`useMounted`](references/useMounted.md) | Mounted state in ref | AUTO |
| [`useTemplateRefsList`](references/useTemplateRefsList.md) | Shorthand for binding refs to template elements and components inside `v-for` | AUTO |
| [`useVirtualList`](references/useVirtualList.md) | Create virtual lists with ease | AUTO |
| [`useVModel`](references/useVModel.md) | Shorthand for v-model binding | AUTO |
| [`useVModels`](references/useVModels.md) | Shorthand for props v-model binding | AUTO |
### Watch
| Function | Description | Invocation |
|----------|-------------|------------|
| [`until`](references/until.md) | Promised one-time watch for changes | AUTO |
| [`watchArray`](references/watchArray.md) | Watch for an array with additions and removals | AUTO |
| [`watchAtMost`](references/watchAtMost.md) | `watch` with the number of times triggered | AUTO |
| [`watchDebounced`](references/watchDebounced.md) | Debounced watch | AUTO |
| [`watchDeep`](references/watchDeep.md) | Shorthand for watching value with `{deep: true}` | AUTO |
| [`watchIgnorable`](references/watchIgnorable.md) | Ignorable watch | AUTO |
| [`watchImmediate`](references/watchImmediate.md) | Shorthand for watching value with `{immediate: true}` | AUTO |
| [`watchOnce`](references/watchOnce.md) | Shorthand for watching value with `{ once: true }` | AUTO |
| [`watchPausable`](references/watchPausable.md) | Pausable watch | AUTO |
| [`watchThrottled`](references/watchThrottled.md) | Throttled watch | AUTO |
| [`watchTriggerable`](references/watchTriggerable.md) | Watch that can be triggered manually | AUTO |
| [`watchWithFilter`](references/watchWithFilter.md) | `watch` with additional EventFilter control | AUTO |
| [`whenever`](references/whenever.md) | Shorthand for watching value to be truthy | AUTO |
### Reactivity
| Function | Description | Invocation |
|----------|-------------|------------|
| [`computedAsync`](references/computedAsync.md) | Computed for async functions | AUTO |
| [`computedEager`](references/computedEager.md) | Eager computed without lazy evaluation | AUTO |
| [`computedWithControl`](references/computedWithControl.md) | Explicitly define the dependencies of computed | AUTO |
| [`createRef`](references/createRef.md) | Returns a `deepRef` or `shallowRef` depending on the `deep` param | AUTO |
| [`extendRef`](references/extendRef.md) | Add extra attributes to Ref | AUTO |
| [`reactify`](references/reactify.md) | Converts plain functions into reactive functions | AUTO |
| [`reactifyObject`](references/reactifyObject.md) | Apply `reactify` to an object | AUTO |
| [`reactiveComputed`](references/reactiveComputed.md) | Computed reactive object | AUTO |
| [`reactiveOmit`](references/reactiveOmit.md) | Reactively omit fields from a reactive object | AUTO |
| [`reactivePick`](references/reactivePick.md) | Reactively pick fields from a reactive object | AUTO |
| [`refAutoReset`](references/refAutoReset.md) | A ref which will be reset to the default value after some time | AUTO |
| [`refDebounced`](references/refDebounced.md) | Debounce execution of a ref value | AUTO |
| [`refDefault`](references/refDefault.md) | Apply default value to a ref | AUTO |
| [`refManualReset`](references/refManualReset.md) | Create a ref with manual reset functionality | AUTO |
| [`refThrottled`](references/refThrottled.md) | Throttle changing of a ref value | AUTO |
| [`refWithControl`](references/refWithControl.md) | Fine-grained controls over ref and its reactivity | AUTO |
| [`syncRef`](references/syncRef.md) | Two-way refs synchronization | AUTO |
| [`syncRefs`](references/syncRefs.md) | Keep target refs in sync with a source ref | AUTO |
| [`toReactive`](references/toReactive.md) | Converts ref to reactive | AUTO |
| [`toRef`](references/toRef.md) | Normalize value/ref/getter to `ref` or `computed` | EXPLICIT_ONLY |
| [`toRefs`](references/toRefs.md) | Extended [`toRefs`](https://vuejs.org/api/reactivity-utilities.html#torefs) that also accepts refs of an object | AUTO |
### Array
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useArrayDifference`](references/useArrayDifference.md) | Reactive get array difference of two arrays | AUTO |
| [`useArrayEvery`](references/useArrayEvery.md) | Reactive `Array.every` | AUTO |
| [`useArrayFilter`](references/useArrayFilter.md) | Reactive `Array.filter` | AUTO |
| [`useArrayFind`](references/useArrayFind.md) | Reactive `Array.find` | AUTO |
| [`useArrayFindIndex`](references/useArrayFindIndex.md) | Reactive `Array.findIndex` | AUTO |
| [`useArrayFindLast`](references/useArrayFindLast.md) | Reactive `Array.findLast` | AUTO |
| [`useArrayIncludes`](references/useArrayIncludes.md) | Reactive `Array.includes` | AUTO |
| [`useArrayJoin`](references/useArrayJoin.md) | Reactive `Array.join` | AUTO |
| [`useArrayMap`](references/useArrayMap.md) | Reactive `Array.map` | AUTO |
| [`useArrayReduce`](references/useArrayReduce.md) | Reactive `Array.reduce` | AUTO |
| [`useArraySome`](references/useArraySome.md) | Reactive `Array.some` | AUTO |
| [`useArrayUnique`](references/useArrayUnique.md) | Reactive unique array | AUTO |
| [`useSorted`](references/useSorted.md) | Reactive sort array | AUTO |
### Time
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useCountdown`](references/useCountdown.md) | Reactive countdown timer in seconds | AUTO |
| [`useDateFormat`](references/useDateFormat.md) | Get the formatted date according to the string of tokens passed in | AUTO |
| [`useTimeAgo`](references/useTimeAgo.md) | Reactive time ago | AUTO |
| [`useTimeAgoIntl`](references/useTimeAgoIntl.md) | Reactive time ago with i18n supported | AUTO |
### Utilities
| Function | Description | Invocation |
|----------|-------------|------------|
| [`createEventHook`](references/createEventHook.md) | Utility for creating event hooks | AUTO |
| [`createUnrefFn`](references/createUnrefFn.md) | Make a plain function accepting ref and raw values as arguments | AUTO |
| [`get`](references/get.md) | Shorthand for accessing `ref.value` | EXPLICIT_ONLY |
| [`isDefined`](references/isDefined.md) | Non-nullish checking type guard for Ref | AUTO |
| [`makeDestructurable`](references/makeDestructurable.md) | Make isomorphic destructurable for object and array at the same time | AUTO |
| [`set`](references/set.md) | Shorthand for `ref.value = x` | EXPLICIT_ONLY |
| [`useAsyncQueue`](references/useAsyncQueue.md) | Executes each asynchronous task sequentially and passes the current task result to the next task | AUTO |
| [`useBase64`](references/useBase64.md) | Reactive base64 transforming | AUTO |
| [`useCached`](references/useCached.md) | Cache a ref with a custom comparator | AUTO |
| [`useCloned`](references/useCloned.md) | Reactive clone of a ref | AUTO |
| [`useConfirmDialog`](references/useConfirmDialog.md) | Creates event hooks to support modals and confirmation dialog chains | AUTO |
| [`useCounter`](references/useCounter.md) | Basic counter with utility functions | AUTO |
| [`useCycleList`](references/useCycleList.md) | Cycle through a list of items | AUTO |
| [`useDebounceFn`](references/useDebounceFn.md) | Debounce execution of a function | AUTO |
| [`useEventBus`](references/useEventBus.md) | A basic event bus | AUTO |
| [`useMemoize`](references/useMemoize.md) | Cache results of functions depending on arguments and keep it reactive | AUTO |
| [`useOffsetPagination`](references/useOffsetPagination.md) | Reactive offset pagination | AUTO |
| [`usePrevious`](references/usePrevious.md) | Holds the previous value of a ref | AUTO |
| [`useStepper`](references/useStepper.md) | Provides helpers for building a multi-step wizard interface | AUTO |
| [`useSupported`](references/useSupported.md) | SSR compatibility `isSupported` | AUTO |
| [`useThrottleFn`](references/useThrottleFn.md) | Throttle execution of a function | AUTO |
| [`useTimeoutPoll`](references/useTimeoutPoll.md) | Use timeout to poll something | AUTO |
| [`useToggle`](references/useToggle.md) | A boolean switcher with utility functions | AUTO |
| [`useToNumber`](references/useToNumber.md) | Reactively convert a string ref to number | AUTO |
| [`useToString`](references/useToString.md) | Reactively convert a ref to string | AUTO |
### @Electron
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useIpcRenderer`](references/useIpcRenderer.md) | Provides [ipcRenderer](https://www.electronjs.org/docs/api/ipc-renderer) and all of its APIs with Vue reactivity | EXTERNAL |
| [`useIpcRendererInvoke`](references/useIpcRendererInvoke.md) | Reactive [ipcRenderer.invoke API](https://www.electronjs.org/docs/api/ipc-renderer#ipcrendererinvokechannel-args) result | EXTERNAL |
| [`useIpcRendererOn`](references/useIpcRendererOn.md) | Use [ipcRenderer.on](https://www.electronjs.org/docs/api/ipc-renderer#ipcrendereronchannel-listener) with ease and [ipcRenderer.removeListener](https://www.electronjs.org/docs/api/ipc-renderer#ipcrendererremovelistenerchannel-listener) automatically on unmounted | EXTERNAL |
| [`useZoomFactor`](references/useZoomFactor.md) | Reactive [WebFrame](https://www.electronjs.org/docs/api/web-frame#webframe) zoom factor | EXTERNAL |
| [`useZoomLevel`](references/useZoomLevel.md) | Reactive [WebFrame](https://www.electronjs.org/docs/api/web-frame#webframe) zoom level | EXTERNAL |
### @Firebase
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useAuth`](references/useAuth.md) | Reactive [Firebase Auth](https://firebase.google.com/docs/auth) binding | EXTERNAL |
| [`useFirestore`](references/useFirestore.md) | Reactive [Firestore](https://firebase.google.com/docs/firestore) binding | EXTERNAL |
| [`useRTDB`](references/useRTDB.md) | Reactive [Firebase Realtime Database](https://firebase.google.com/docs/database) binding | EXTERNAL |
### @Head
| Function | Description | Invocation |
|----------|-------------|------------|
| [`createHead`](https://github.com/vueuse/head#api) | Create the head manager instance. | EXTERNAL |
| [`useHead`](https://github.com/vueuse/head#api) | Update head meta tags reactively. | EXTERNAL |
### @Integrations
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useAsyncValidator`](references/useAsyncValidator.md) | Wrapper for [`async-validator`](https://github.com/yiminghe/async-validator) | EXTERNAL |
| [`useAxios`](references/useAxios.md) | Wrapper for [`axios`](https://github.com/axios/axios) | EXTERNAL |
| [`useChangeCase`](references/useChangeCase.md) | Reactive wrapper for [`change-case`](https://github.com/blakeembrey/change-case) | EXTERNAL |
| [`useCookies`](references/useCookies.md) | Wrapper for [`universal-cookie`](https://www.npmjs.com/package/universal-cookie) | EXTERNAL |
| [`useDrauu`](references/useDrauu.md) | Reactive instance for [drauu](https://github.com/antfu/drauu) | EXTERNAL |
| [`useFocusTrap`](references/useFocusTrap.md) | Reactive wrapper for [`focus-trap`](https://github.com/focus-trap/focus-trap) | EXTERNAL |
| [`useFuse`](references/useFuse.md) | Easily implement fuzzy search using a composable with [Fuse.js](https://github.com/krisk/fuse) | EXTERNAL |
| [`useIDBKeyval`](references/useIDBKeyval.md) | Wrapper for [`idb-keyval`](https://www.npmjs.com/package/idb-keyval) | EXTERNAL |
| [`useJwt`](references/useJwt.md) | Wrapper for [`jwt-decode`](https://github.com/auth0/jwt-decode) | EXTERNAL |
| [`useNProgress`](references/useNProgress.md) | Reactive wrapper for [`nprogress`](https://github.com/rstacruz/nprogress) | EXTERNAL |
| [`useQRCode`](references/useQRCode.md) | Wrapper for [`qrcode`](https://github.com/soldair/node-qrcode) | EXTERNAL |
| [`useSortable`](references/useSortable.md) | Wrapper for [`sortable`](https://github.com/SortableJS/Sortable) | EXTERNAL |
### @Math
| Function | Description | Invocation |
|----------|-------------|------------|
| [`createGenericProjection`](references/createGenericProjection.md) | Generic version of `createProjection` | EXTERNAL |
| [`createProjection`](references/createProjection.md) | Reactive numeric projection from one domain to another | EXTERNAL |
| [`logicAnd`](references/logicAnd.md) | `AND` condition for refs | EXTERNAL |
| [`logicNot`](references/logicNot.md) | `NOT` condition for ref | EXTERNAL |
| [`logicOr`](references/logicOr.md) | `OR` conditions for refs | EXTERNAL |
| [`useAbs`](references/useAbs.md) | Reactive `Math.abs` | EXTERNAL |
| [`useAverage`](references/useAverage.md) | Get the average of an array reactively | EXTERNAL |
| [`useCeil`](references/useCeil.md) | Reactive `Math.ceil` | EXTERNAL |
| [`useClamp`](references/useClamp.md) | Reactively clamp a value between two other values | EXTERNAL |
| [`useFloor`](references/useFloor.md) | Reactive `Math.floor` | EXTERNAL |
| [`useMath`](references/useMath.md) | Reactive `Math` methods | EXTERNAL |
| [`useMax`](references/useMax.md) | Reactive `Math.max` | EXTERNAL |
| [`useMin`](references/useMin.md) | Reactive `Math.min` | EXTERNAL |
| [`usePrecision`](references/usePrecision.md) | Reactively set the precision of a number | EXTERNAL |
| [`useProjection`](references/useProjection.md) | Reactive numeric projection from one domain to another | EXTERNAL |
| [`useRound`](references/useRound.md) | Reactive `Math.round` | EXTERNAL |
| [`useSum`](references/useSum.md) | Get the sum of an array reactively | EXTERNAL |
| [`useTrunc`](references/useTrunc.md) | Reactive `Math.trunc` | EXTERNAL |
### @Motion
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useElementStyle`](https://motion.vueuse.org/api/use-element-style) | Sync a reactive object to a target element CSS styling | EXTERNAL |
| [`useElementTransform`](https://motion.vueuse.org/api/use-element-transform) | Sync a reactive object to a target element CSS transform. | EXTERNAL |
| [`useMotion`](https://motion.vueuse.org/api/use-motion) | Putting your components in motion. | EXTERNAL |
| [`useMotionProperties`](https://motion.vueuse.org/api/use-motion-properties) | Access Motion Properties for a target element. | EXTERNAL |
| [`useMotionVariants`](https://motion.vueuse.org/api/use-motion-variants) | Handle the Variants state and selection. | EXTERNAL |
| [`useSpring`](https://motion.vueuse.org/api/use-spring) | Spring animations. | EXTERNAL |
### @Router
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useRouteHash`](references/useRouteHash.md) | Shorthand for a reactive `route.hash` | EXTERNAL |
| [`useRouteParams`](references/useRouteParams.md) | Shorthand for a reactive `route.params` | EXTERNAL |
| [`useRouteQuery`](references/useRouteQuery.md) | Shorthand for a reactive `route.query` | EXTERNAL |
### @RxJS
| Function | Description | Invocation |
|----------|-------------|------------|
| [`from`](references/from.md) | Wrappers around RxJS's [`from()`](https://rxjs.dev/api/index/function/from) and [`fromEvent()`](https://rxjs.dev/api/index/function/fromEvent) to allow them to accept `ref`s | EXTERNAL |
| [`toObserver`](references/toObserver.md) | Sugar function to convert a `ref` into an RxJS [Observer](https://rxjs.dev/guide/observer) | EXTERNAL |
| [`useExtractedObservable`](references/useExtractedObservable.md) | Use an RxJS [`Observable`](https://rxjs.dev/guide/observable) as extracted from one or more composables | EXTERNAL |
| [`useObservable`](references/useObservable.md) | Use an RxJS [`Observable`](https://rxjs.dev/guide/observable) | EXTERNAL |
| [`useSubject`](references/useSubject.md) | Bind an RxJS [`Subject`](https://rxjs.dev/guide/subject) to a `ref` and propagate value changes both ways | EXTERNAL |
| [`useSubscription`](references/useSubscription.md) | Use an RxJS [`Subscription`](https://rxjs.dev/guide/subscription) without worrying about unsubscribing from it or creating memory leaks | EXTERNAL |
| [`watchExtractedObservable`](references/watchExtractedObservable.md) | Watch the values of an RxJS [`Observable`](https://rxjs.dev/guide/observable) as extracted from one or more composables | EXTERNAL |
### @SchemaOrg
| Function | Description | Invocation |
|----------|-------------|------------|
| [`createSchemaOrg`](https://vue-schema-org.netlify.app/api/core/create-schema-org.html) | Create the schema.org manager instance. | EXTERNAL |
| [`useSchemaOrg`](https://vue-schema-org.netlify.app/api/core/use-schema-org.html) | Update schema.org reactively. | EXTERNAL |
### @Sound
| Function | Description | Invocation |
|----------|-------------|------------|
| [`useSound`](https://github.com/vueuse/sound#examples) | Play sound effects reactively. | EXTERNAL |

View File

@@ -0,0 +1,5 @@
# Sync Info
- **Source:** `vendor/vueuse/skills/vueuse-functions`
- **Git SHA:** `075b0d6d558cc5ca7d5ffe72a56b5fd92bbef2d1`
- **Synced:** 2026-03-16

View File

@@ -0,0 +1,195 @@
---
category: Reactivity
alias: asyncComputed
---
# computedAsync
Computed for async functions.
## Usage
```ts
import { computedAsync } from '@vueuse/core'
import { shallowRef } from 'vue'
const name = shallowRef('jack')
const userInfo = computedAsync(
async () => {
return await mockLookUp(name.value)
},
null, // initial state
)
```
### Evaluation State
Pass a ref to track if the async function is currently evaluating.
```ts
import { computedAsync } from '@vueuse/core'
import { shallowRef } from 'vue'
const evaluating = shallowRef(false)
const userInfo = computedAsync(
async () => { /* your logic */ },
null,
evaluating, // can also be passed via options: { evaluating }
)
```
### onCancel
When the computed source changes before the previous async function resolves, you may want to cancel the previous one. Here is an example showing how to incorporate with the fetch API.
```ts
import { computedAsync } from '@vueuse/core'
import { shallowRef } from 'vue'
const packageName = shallowRef('@vueuse/core')
const downloads = computedAsync(async (onCancel) => {
const abortController = new AbortController()
onCancel(() => abortController.abort())
return await fetch(
`https://api.npmjs.org/downloads/point/last-week/${packageName.value}`,
{ signal: abortController.signal },
)
.then(response => response.ok ? response.json() : { downloads: '—' })
.then(result => result.downloads)
}, 0)
```
### Lazy
By default, `computedAsync` will start resolving immediately on creation. Specify `lazy: true` to make it start resolving on the first access.
```ts
import { computedAsync } from '@vueuse/core'
import { shallowRef } from 'vue'
const evaluating = shallowRef(false)
const userInfo = computedAsync(
async () => { /* your logic */ },
null,
{ lazy: true, evaluating },
)
```
### Error Handling
Use the `onError` callback to handle errors from the async function.
```ts
import { computedAsync } from '@vueuse/core'
import { shallowRef } from 'vue'
const name = shallowRef('jack')
const userInfo = computedAsync(
async () => {
return await mockLookUp(name.value)
},
null,
{
onError(e) {
console.error('Failed to fetch user info', e)
},
},
)
```
### Shallow Ref
By default, `computedAsync` uses `shallowRef` internally. Set `shallow: false` to use a deep ref instead.
```ts
import { computedAsync } from '@vueuse/core'
import { shallowRef } from 'vue'
const name = shallowRef('jack')
const userInfo = computedAsync(
async () => {
return await fetchNestedData(name.value)
},
null,
{ shallow: false }, // enables deep reactivity
)
```
## Caveats
- Just like Vue's built-in `computed` function, `computedAsync` does dependency tracking and is automatically re-evaluated when dependencies change. Note however that only dependencies referenced in the first call stack are considered for this. In other words: **Dependencies that are accessed asynchronously will not trigger re-evaluation of the async computed value.**
- As opposed to Vue's built-in `computed` function, re-evaluation of the async computed value is triggered whenever dependencies are changing, regardless of whether its result is currently being tracked or not.
## Type Declarations
```ts
/**
* Handle overlapping async evaluations.
*
* @param cancelCallback The provided callback is invoked when a re-evaluation of the computed value is triggered before the previous one finished
*/
export type AsyncComputedOnCancel = (cancelCallback: Fn) => void
export interface AsyncComputedOptions<
Lazy = boolean,
> extends ConfigurableFlushSync {
/**
* Should value be evaluated lazily
*
* @default false
*/
lazy?: Lazy
/**
* Ref passed to receive the updated of async evaluation
*/
evaluating?: Ref<boolean>
/**
* Use shallowRef
*
* @default true
*/
shallow?: boolean
/**
* Callback when error is caught.
*/
onError?: (e: unknown) => void
}
/**
* Create an asynchronous computed dependency.
*
* @see https://vueuse.org/computedAsync
* @param evaluationCallback The promise-returning callback which generates the computed value
* @param initialState The initial state, used until the first evaluation finishes
* @param optionsOrRef Additional options or a ref passed to receive the updates of the async evaluation
*/
export declare function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState: T,
optionsOrRef: AsyncComputedOptions<true>,
): ComputedRef<T>
export declare function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState: undefined,
optionsOrRef: AsyncComputedOptions<true>,
): ComputedRef<T | undefined>
export declare function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState: T,
optionsOrRef?: Ref<boolean> | AsyncComputedOptions,
): Ref<T>
export declare function computedAsync<T>(
evaluationCallback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
initialState?: undefined,
optionsOrRef?: Ref<boolean> | AsyncComputedOptions,
): Ref<T | undefined>
/** @deprecated use `computedAsync` instead */
export declare const asyncComputed: typeof computedAsync
```

View File

@@ -0,0 +1,62 @@
---
category: Reactivity
alias: eagerComputed
---
# computedEager
Eager computed without lazy evaluation.
::: info
This function will be removed in future version.
:::
::: tip
Note💡: If you are using Vue 3.4+, you can use `computed` right away, you no longer need this function.
In Vue 3.4+, if the computed new value does not change, `computed`, `effect`, `watch`, `watchEffect`, `render` dependencies will not be triggered.
See: https://github.com/vuejs/core/pull/5912
:::
Learn more at [Vue: When a computed property can be the wrong tool](https://dev.to/linusborg/vue-when-a-computed-property-can-be-the-wrong-tool-195j).
- Use `computed()` when you have a complex calculation going on, which can actually profit from caching and lazy evaluation and should only be (re-)calculated if really necessary.
- Use `computedEager()` when you have a simple operation, with a rarely changing return value often a boolean.
## Usage
```ts
import { computedEager } from '@vueuse/core'
const todos = ref([])
const hasOpenTodos = computedEager(() => !!todos.length)
console.log(hasOpenTodos.value) // false
toTodos.value.push({ title: 'Learn Vue' })
console.log(hasOpenTodos.value) // true
```
## Type Declarations
```ts
export type ComputedEagerOptions = WatchOptionsBase
export type ComputedEagerReturn<T = any> = Readonly<ShallowRef<T>>
/**
*
* @deprecated This function will be removed in future version.
*
* Note: If you are using Vue 3.4+, you can straight use computed instead.
* Because in Vue 3.4+, if computed new value does not change,
* computed, effect, watch, watchEffect, render dependencies will not be triggered.
* refer: https://github.com/vuejs/core/pull/5912
*
* @param fn effect function
* @param options WatchOptionsBase
* @returns readonly shallowRef
*/
export declare function computedEager<T>(
fn: () => T,
options?: ComputedEagerOptions,
): ComputedEagerReturn<T>
/** @deprecated use `computedEager` instead */
export declare const eagerComputed: typeof computedEager
```

View File

@@ -0,0 +1,137 @@
---
category: Component
---
# computedInject
Combine `computed` and `inject`. Useful for creating a computed property based on an injected value.
## Usage
In Provider Component
```ts twoslash include main
import type { InjectionKey, Ref } from 'vue'
import { provide, ref } from 'vue'
interface Item {
key: number
value: string
}
export const ArrayKey: InjectionKey<Ref<Item[]>> = Symbol('symbol-key')
const array = ref([{ key: 1, value: '1' }, { key: 2, value: '2' }, { key: 3, value: '3' }])
provide(ArrayKey, array)
```
In Receiver Component
```ts
// @filename: provider.ts
// @include: main
// ---cut---
import { computedInject } from '@vueuse/core'
import { ArrayKey } from './provider'
const computedArray = computedInject(ArrayKey, (source) => {
const arr = [...source.value]
arr.unshift({ key: 0, value: 'all' })
return arr
})
```
### Default Value
You can provide a default value that will be used if the injection key is not provided by a parent component.
```ts
import { computedInject } from '@vueuse/core'
const computedArray = computedInject(
ArrayKey,
(source) => {
return source.value.map(item => item.value)
},
ref([]), // default source value
)
```
### Factory Default
Pass `true` as the fourth argument to treat the default value as a factory function.
```ts
import { computedInject } from '@vueuse/core'
const computedArray = computedInject(
ArrayKey,
(source) => {
return source.value.map(item => item.value)
},
() => ref([]), // factory function for default
true, // treat default as factory
)
```
### Writable Computed
You can also create a writable computed property by passing an object with `get` and `set` functions.
```ts
import { computedInject } from '@vueuse/core'
const computedArray = computedInject(ArrayKey, {
get(source) {
return source.value.map(item => item.value)
},
set(value) {
// handle setting the value
console.log('Setting value:', value)
},
})
```
## Type Declarations
```ts
export type ComputedInjectGetter<T, K> = (
source: T | undefined,
oldValue?: K,
) => K
export type ComputedInjectGetterWithDefault<T, K> = (
source: T,
oldValue?: K,
) => K
export type ComputedInjectSetter<T> = (v: T) => void
export interface WritableComputedInjectOptions<T, K> {
get: ComputedInjectGetter<T, K>
set: ComputedInjectSetter<K>
}
export interface WritableComputedInjectOptionsWithDefault<T, K> {
get: ComputedInjectGetterWithDefault<T, K>
set: ComputedInjectSetter<K>
}
export declare function computedInject<T, K = any>(
key: InjectionKey<T> | string,
getter: ComputedInjectGetter<T, K>,
): ComputedRef<K | undefined>
export declare function computedInject<T, K = any>(
key: InjectionKey<T> | string,
options: WritableComputedInjectOptions<T, K>,
): ComputedRef<K | undefined>
export declare function computedInject<T, K = any>(
key: InjectionKey<T> | string,
getter: ComputedInjectGetterWithDefault<T, K>,
defaultSource: T,
treatDefaultAsFactory?: false,
): ComputedRef<K>
export declare function computedInject<T, K = any>(
key: InjectionKey<T> | string,
options: WritableComputedInjectOptionsWithDefault<T, K>,
defaultSource: T | (() => T),
treatDefaultAsFactory: true,
): ComputedRef<K>
```

View File

@@ -0,0 +1,98 @@
---
category: Reactivity
alias: controlledComputed
---
# computedWithControl
Explicitly define the dependencies of computed.
## Usage
```ts twoslash include main
import { computedWithControl } from '@vueuse/core'
const source = ref('foo')
const counter = ref(0)
const computedRef = computedWithControl(
() => source.value, // watch source, same as `watch`
() => counter.value, // computed getter, same as `computed`
)
```
With this, the changes of `counter` won't trigger `computedRef` to update but the `source` ref does.
```ts
// @include: main
// ---cut---
console.log(computedRef.value) // 0
counter.value += 1
console.log(computedRef.value) // 0
source.value = 'bar'
console.log(computedRef.value) // 1
```
### Manual Triggering
You can also manually trigger the update of the computed by:
```ts
// @include: main
// ---cut---
const computedRef = computedWithControl(
() => source.value,
() => counter.value,
)
computedRef.trigger()
```
### Deep Watch
Unlike `computed`, `computedWithControl` is shallow by default.
You can specify the same options as `watch` to control the behavior:
```ts
const source = ref({ name: 'foo' })
const computedRef = computedWithControl(
source,
() => counter.value,
{ deep: true },
)
```
## Type Declarations
```ts
export interface ComputedWithControlRefExtra {
/**
* Force update the computed value.
*/
trigger: () => void
}
export interface ComputedRefWithControl<T>
extends ComputedRef<T>, ComputedWithControlRefExtra {}
export interface WritableComputedRefWithControl<T>
extends WritableComputedRef<T>, ComputedWithControlRefExtra {}
export type ComputedWithControlRef<T = any> =
| ComputedRefWithControl<T>
| WritableComputedRefWithControl<T>
export declare function computedWithControl<T>(
source: WatchSource | MultiWatchSources,
fn: ComputedGetter<T>,
options?: WatchOptions,
): ComputedRefWithControl<T>
export declare function computedWithControl<T>(
source: WatchSource | MultiWatchSources,
fn: WritableComputedOptions<T>,
options?: WatchOptions,
): WritableComputedRefWithControl<T>
/** @deprecated use `computedWithControl` instead */
export declare const controlledComputed: typeof computedWithControl
```

Some files were not shown because too many files have changed in this diff Show More