diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 924393a..6258818 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,44 +19,6 @@ Vue utilities monorepo with independently-versioned packages: **When you need to...** - **Write tests**: Read [Testing Decisions](./skills/testing-decisions.md) - decision tree for test type, anti-patterns, implementations - **Manage state**: Read [State Management](./skills/state-management.md) - singleton patterns, cleanup strategies, scope awareness -- **Organize files**: Read [Package Structure](./skills/package-structure.md) - exports, dependencies, file creation rules -- **Build features**: Read [Common Workflows](./skills/common-workflows.md) - step-by-step guides, debugging, code style -- **Use types safely**: Read [Type Patterns](./skills/type-patterns.md) - type guards, branded types, generics, ambient declarations - **Avoid common mistakes**: Read [Common Pitfalls](./skills/common-pitfalls.md) - listener cleanup, export rules, test environments -- **Debug issues**: Read [Debugging Strategies](./skills/debugging-strategies.md) - Vitest UI, spies, state inspection, linting errors -- **Improve instructions**: Read [Instruction Validation & Refinement](./skills/instruction-validation.md) - validate patterns, cross-check skills, self-improvement workflow -## Available Scripts - -| Command | Purpose | -|---------|---------| -| `pnpm test` | Run all tests (unit + browser) | -| `pnpm build` | Build all packages | -| `pnpm dev` | Watch mode for all packages | -| `pnpm lint` | Run oxlint | -| `pnpm format` | Format with oxfmt | -| `pnpm changeset` | Create changelog entry after changes | - -## Before Committing - -- [ ] Tests pass: `pnpm test` -- [ ] No lint errors: `pnpm lint` -- [ ] Code formatted: `pnpm format` -- [ ] Changeset created: `pnpm changeset` or manually in `.changeset/*.md` (if not docs-only) -- [ ] Demo updated (if user-facing feature) - -## Key Files to Follow - -**Testing:** [registry.unit.test.ts](../packages/vuebugger/src/registry.unit.test.ts), [directive.browser.test.ts](../packages/vueltip/src/directive.browser.test.ts) - -**State:** [registry.ts](../packages/vuebugger/src/registry.ts), [state.ts](../packages/vueltip/src/state.ts) - -**Package exports:** [vuebugger/index.ts](../packages/vuebugger/src/index.ts), [vueltip/index.ts](../packages/vueltip/src/index.ts) - -## Core Patterns - -- **Dev-only code**: Guard with `import.meta.env.DEV` (stripped in prod) -- **Module singletons**: Maps for tracking, refs for reactive state -- **Cleanup**: `onScopeDispose()` in composables, directive hooks for listeners -- **Tree-shaking**: ESM-only, flat package structure, no subdirectories in src/ diff --git a/.github/skills/common-pitfalls.md b/.github/skills/common-pitfalls.md index 5d218e1..da28fb8 100644 --- a/.github/skills/common-pitfalls.md +++ b/.github/skills/common-pitfalls.md @@ -1,468 +1,11 @@ # Common Pitfalls -## Quick Reference: Critical Mistakes - | Pitfall | Impact | Prevention | |---------|--------|-----------| -| Inline event listeners | Listeners not removed on unmount | Store handler as const, reuse same reference | | Forgetting to remove listeners | Memory leaks, test failures | Always pair `addEventListener` + `removeEventListener` | | Exporting internal state | Breaks encapsulation, API instability | Only export from index.ts, keep internals private | -| Direct Map/ref access | No reactivity tracking | Use getter/setter functions for refs | -| No cleanup in composables | Memory leaks on unmount | Use `onScopeDispose()` for auto-cleanup | -| Import cycles | Build failures | Check `.oxlintrc.json` enforces `import/no-cycle` | +| No cleanup in composables | Memory leaks | Use `onScopeDispose()` for auto-cleanup | +| Import cycles | Build failures | Check with `mise run oxlint` | | Mixing test environments | Tests fail in wrong environment | Use `.unit.test.ts` or `.browser.test.ts` suffix | -| Overusing vitest hooks for setup | Implicit dependencies, hidden failures | Use hooks only for global/singleton reset; keep test-specific setup inline in each test | -| Multiple timers stacking | Race conditions, doubled effects | Always `clearTimeout()` before setting new one | | Inline functions in event listeners | Handlers created on every render | Store as module-level const, reference it | ---- - -## Detailed Pitfalls & Fixes - -### Pitfall 1: Inline Event Listeners - -**Problem:** Event listeners created inline can't be removed because there's no reference. - -```typescript -// ❌ WRONG - Can't remove this listener -el.addEventListener('mouseenter', () => { - console.log('entered') -}) - -// On unmount - which function do we remove? -el.removeEventListener('mouseenter', () => { - console.log('entered') -}) // Doesn't match! New function instance -``` - -**Solution:** Store handler as module-level const - -```typescript -// ✅ CORRECT - Store reference -const onMouseenter = () => { - console.log('entered') -} - -// Add listener -el.addEventListener('mouseenter', onMouseenter) - -// Remove listener - same reference -el.removeEventListener('mouseenter', onMouseenter) -``` - -**In directives:** - -```typescript -export const vueltipDirective = { - created: (el, binding) => { - // Store reference for removal - el.addEventListener('mouseenter', onMouseover) // onMouseover is stored const - el.addEventListener('mouseleave', onMouseout) - }, - beforeUnmount: (el) => { - // Remove same reference - el.removeEventListener('mouseenter', onMouseover) - el.removeEventListener('mouseleave', onMouseout) - }, -} -``` - -**Where to define handlers:** [listeners.ts](../../packages/vueltip/src/listeners.ts) - module-level, not inside hooks - ---- - -### Pitfall 2: Forgetting to Remove Listeners - -**Problem:** Listeners accumulate, tests fail, memory leaks in production. - -```typescript -// ❌ WRONG - No cleanup -it('attaches listener', () => { - const el = document.createElement('div') - el.addEventListener('mouseenter', onMouseover) - // End of test - listener still attached - // If you run 100 tests, 100 listeners still attached -}) -``` - -**Solution:** Always pair add/remove - -```typescript -// ✅ CORRECT - Explicit cleanup -const setupDirective = () => { - const el = document.createElement('div') - document.body.appendChild(el) - return el -} - -const teardownDirective = (el: HTMLElement) => { - el.removeEventListener('mouseenter', onMouseover) - el.removeEventListener('mouseleave', onMouseout) - el.remove() -} - -it('attaches listener', () => { - const el = setupDirective() - el.addEventListener('mouseenter', onMouseover) - expect(el.addEventListener).toHaveBeenCalled() - teardownDirective(el) // Clean up! -}) -``` - -**Critical:** Check [directive.browser.test.ts](../../packages/vueltip/src/directive.browser.test.ts) for complete removal test: - -```typescript -it('removes event listeners', () => { - const el = setupDirective() - const binding = { value: 'Text' } - vueltipDirective.created?.(el, binding as any) - - const removeEventListenerSpy = vi.spyOn( - el, - 'removeEventListener', - ) - - vueltipDirective.beforeUnmount?.(el) - - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'mouseenter', - expect.any(Function), - ) - // ... more expectations - teardownDirective(el) -}) -``` - ---- - -### Pitfall 3: Exporting Internal State - -**Problem:** Exports infrastructure as public API, breaks when refactored - -```typescript -// ❌ WRONG - Exports internals -export { byUid, upsert, remove } from './registry' -export { hoveredElement, setContent } from './state' -export { getOption } from './options' - -// Users import internals directly: -// import { byUid } from '@vingy/vueltip' // Now you can't change registry! -``` - -**Solution:** Only export public API from [index.ts](../../packages/vueltip/src/index.ts) - -```typescript -// ✅ CORRECT - Public API only -export { useVueltip } from './composables' -export { vueltipDirective } from './directive' -export { setOptions } from './options' -export { vueltipPlugin } from './plugin' -export type { Content } from './types' - -// Keep internal, don't export: -// - hoveredElement, setContent (state.ts - internal) -// - getOption (options.ts - internal) -// - onMouseover, onMouseout (listeners.ts - internal) -// - byUid, byGroupId (registry.ts - internal) -``` - -**Consequence:** Breaking changes are internal-only, you can refactor freely - ---- - -### Pitfall 4: Direct Map/Ref Access vs Getters/Setters - -**Problem:** Direct access loses reactivity tracking and type safety - -```typescript -// ❌ WRONG - Direct ref access -export const contentMap = ref(new Map()) - -// Users do this: -contentMap.value.set(key, content) // Direct mutation -contentMap.value.delete(key) - -// Problems: -// 1. Not obvious API -// 2. Can't add validation -// 3. Breaks if you refactor to computed -``` - -**Solution:** Export getters/setters, keep Map internal - -```typescript -// ✅ CORRECT - Public interface, private implementation -const contentMap = ref(new Map()) - -export const getContent = (key: string) => - contentMap.value.get(key) - -export const setContent = (key: string, value: Content) => - contentMap.value.set(key, value) - -export const deleteContent = (key: string) => - contentMap.value.delete(key) - -// Users do this - clear API: -setContent(key, content) -deleteContent(key) -``` - -**Benefits:** -- Can add validation in setters -- Can refactor Map → computed without breaking users -- Type-safe access - ---- - -### Pitfall 5: No Cleanup in Composables - -**Problem:** Watchers/listeners not cleaned up on component unmount - -```typescript -// ❌ WRONG - No cleanup, memory leaks -export const debug = (state: any) => { - watch( - () => state, - (value) => { - upsert({ ...entry, debugState: value }) - }, - { deep: true }, - ) - // Watch never removed when component unmounts! -} -``` - -**Solution:** Use scope-aware cleanup - -```typescript -// ✅ CORRECT - Auto-cleanup with scope -export const debug = (state: any) => { - const scope = getCurrentScope() ?? effectScope() - scope.run(() => { - onScopeDispose(() => remove(entry)) // Auto-cleanup - watch( - () => state, - (value) => { - upsert({ ...entry, debugState: value }) - }, - { deep: true }, - ) - }) - return entry -} -``` - -**Pattern:** -- `getCurrentScope()` inside composable → active scope -- `?? effectScope()` standalone → manual scope -- Everything in `scope.run()` auto-disposes on unmount -- `onScopeDispose()` runs cleanup automatically - ---- - -### Pitfall 6: Import Cycles - -**Problem:** Circular imports cause build failures - -```typescript -// ❌ WRONG - Circular import -// state.ts imports from listeners.ts -// listeners.ts imports from state.ts -// Build fails - -import { onMouseover } from './listeners' // state.ts -export const hoveredElement = ref(null) - -import { hoveredElement } from './state' // listeners.ts -export const onMouseover = () => { - hoveredElement.value = element -} -``` - -**Solution:** Check linter enforces rules - -```bash -pnpm lint -# oxlint will catch: import/no-cycle: error -``` - -**Prevention:** -- Check [.oxlintrc.json](../../.oxlintrc.json) has `import/no-cycle: error` -- Run `pnpm lint` before committing -- Break cycles by extracting shared utilities to new file - ---- - -### Pitfall 7: Mixing Test Environments - -**Problem:** DOM tests fail in Node environment, logic tests too slow in Browser - -```typescript -// ❌ WRONG - Mixed in one file -it('updates state', () => { - const state = { count: 0 } - state.count++ - expect(state.count).toBe(1) // Should be unit test (Node) -}) - -it('adds listener', () => { - const el = document.createElement('div') // DOM needs Browser environment - el.addEventListener('click', () => {}) // But same file! -}) -``` - -**Solution:** Use correct file suffix - -```typescript -// ✅ CORRECT - Separate by environment - -// state.unit.test.ts (Node environment) -it('updates state', () => { - const state = { count: 0 } - state.count++ - expect(state.count).toBe(1) -}) - -// directive.browser.test.ts (Browser environment) -it('adds listener', () => { - const el = document.createElement('div') - el.addEventListener('click', () => {}) - expect(el.addEventListener).toHaveBeenCalled() -}) -``` - -**Vitest projects in [vitest.config.ts](../../vitest.config.ts):** -- `.unit.test.ts` → Node environment -- `.browser.test.ts` → Browser environment (Playwright) - -Run specific environment: -```bash -pnpm vitest --run --project unit # Only Node -pnpm vitest --run --project browser # Only Browser -``` - ---- - -### Pitfall 8: Using Vitest Hooks for Setup/Teardown - -**Problem:** Hidden dependencies, unclear test flow, implicit state sharing - -```typescript -// ❌ WRONG - Vitest hooks hide setup -beforeEach(() => { - el = document.createElement('div') - setOptions({ keyAttribute: 'tooltip-key' }) -}) - -afterEach(() => { - el.remove() -}) - -it('test 1', () => { - // El created by beforeEach - not obvious - vueltipDirective.created(el, binding as any) - expect(el.getAttribute('tooltip-key')).toBeTruthy() -}) - -// Problem: Test depends on beforeEach - not visible unless you scroll up -// Problem: Test fails if afterEach doesn't run (race condition) -``` - -**Solution:** Explicit setup/teardown functions - -```typescript -// ✅ CORRECT - Explicit functions, clear dependencies -const setupDirective = () => { - const el = document.createElement('div') - document.body.appendChild(el) - setOptions({ keyAttribute: 'tooltip-key' }) - return el -} - -const teardownDirective = (el: HTMLElement) => { - el.remove() -} - -it('test 1', () => { - const el = setupDirective() // Clear: setup happens here - vueltipDirective.created(el, binding as any) - expect(el.getAttribute('tooltip-key')).toBeTruthy() - teardownDirective(el) // Clear: cleanup happens here -}) -``` - -**Benefits:** -- Test is self-contained and readable -- Setup/teardown visible in every test -- Each test is independent (no hidden state) - -See [testing-decisions.md](./testing-decisions.md) for full pattern. - ---- - -### Pitfall 9: Multiple Timers Stacking - -**Problem:** Setting multiple timeouts without clearing previous ones - -```typescript -// ❌ WRONG - Timers accumulate -let timerId: Maybe> - -watch(hoveredElement, () => { - timerId = setTimeout(() => { - tooltipContent.value = getContent(key) - }, 200) - // Previous timer still running! Sets timer twice, executes twice -}) -``` - -**Solution:** Clear before setting new timeout - -```typescript -// ✅ CORRECT - Always clear first -let timerId: Maybe> - -watch(hoveredElement, () => { - if (timerId) { - clearTimeout(timerId) // Cancel previous - } - timerId = setTimeout(() => { - tooltipContent.value = getContent(key) - timerId = undefined // Reset after firing - }, 200) -}) -``` - -**Critical:** Reset `timerId = undefined` after firing so next watch detects change - ---- - -### Pitfall 10: Export Cycles - -**Problem:** Public exports that create circular dependencies - -```typescript -// ❌ WRONG - Can't refactor without breaking users -// index.ts exports everything -export { byUid, upsert } from './registry' -export { hoveredElement, getContent } from './state' - -// Users import internals -import { byUid, getContent } from '@vingy/package' - -// Now if you want to move registry to different file, users break -``` - -**Solution:** Export only stable public API - -```typescript -// ✅ CORRECT - Minimal, stable public API -export { useVueltip } from './composables' -export { vueltipDirective } from './directive' -export { setOptions } from './options' -export type { Content } from './types' - -// Internal state/registry stay private -// You can refactor internals freely -``` diff --git a/.github/skills/common-workflows.md b/.github/skills/common-workflows.md deleted file mode 100644 index 6e951fe..0000000 --- a/.github/skills/common-workflows.md +++ /dev/null @@ -1,376 +0,0 @@ -# Common Workflows - -## Quick Reference: When to Use Which Workflow - -| Task | Workflow | -|------|----------| -| Add new feature to package | [Adding a Feature](#adding-a-feature) | -| Create new package in monorepo | [Creating a New Package](#creating-a-new-package) | -| Tests aren't passing | [Debugging Test Failures](#debugging-test-failures) | -| Code formatting issues | [Code Style & Linting](#code-style--linting) | - ---- - -## Adding a Feature - -### Step 1: Write tests first - -Decide test type using [testing-decisions.md](./testing-decisions.md): -- DOM logic? → `.browser.test.ts` -- State/logic? → `.unit.test.ts` - -```bash -# Example: Add state feature to vueltip -# Create: src/new-feature.unit.test.ts -# Write: Test what it should do -``` - -### Step 2: Implement the feature - -```bash -# src/new-feature.ts -# Implement to make tests pass -``` - -Check state management patterns in [state-management.md](./state-management.md): -- Using refs? Follow `state.ts` pattern -- Singleton tracking? Follow `registry.ts` pattern -- Need cleanup? Use `onScopeDispose` or directive hooks - -### Step 3: Export from index.ts - -If public API, add to [index.ts](../../packages/vueltip/src/index.ts): - -```typescript -export { useNewFeature } from './new-feature' -export type { NewFeatureOptions } from './types' -``` - -Internal utilities stay private (not exported). - -### Step 4: Verify tests pass - -```bash -pnpm test # All tests -pnpm vitest --ui # Interactive UI for debugging -``` - -### Step 5: Demo in dev app - -Add example to [demo/src/](../../demo/src/): - -```vue - -``` - -Start demo server: -```bash -cd demo && pnpm dev -``` - -### Step 6: Create changeset - -**Option 1: Use CLI** -```bash -pnpm changeset -# Select affected packages (e.g., vueltip) -# Choose: patch (fix), minor (feature), major (breaking) -# Write summary: "Add new feature: description" -``` - -**Option 2: Create manually** -Create `.changeset/[name].md`: -```markdown ---- -"@vingy/vueltip": minor -"@vingy/vuebugger": patch ---- - -Add new feature: description of what changed -``` - -Commit the generated `.changeset/*.md` file. - -## Creating a Vue Plugin + Directive - -**Pattern:** Vueltip combines plugin + directive + composable for full integration - -### Plugin Structure - -**plugin.ts:** Installs options and mounts component if provided - -```typescript -export const vueltipPlugin = { - install: (app: App, options: Partial) => { - const { component, ...rest } = options - setOptions(rest) // Store config in module-level state - if (!component) return - - // Mount component to DOM if provided - const container = document.createElement('div') - container.id = '__vueltip_root__' - document.body.appendChild(container) - const tooltipApp = createApp(component) - tooltipApp._context = app._context // Share parent plugin context - tooltipApp.mount(container) - }, -} -``` - -### Directive Structure - -**directive.ts:** Handles lifecycle + event listener setup/teardown - -```typescript -const LISTENERS: [ - event: string, - handler: EventListener, -][] = [ - ['eventA', onEnter], - ['eventB', onLeave], -] - -export const vueltipDirective = { - created: (el, binding) => { - const key = generateKey() - setContent(key, toContent(binding.value)) - el.setAttribute(getOption('keyAttribute'), key) - for (const [event, handler] of LISTENERS) { - el.addEventListener(event, handler) - } - }, - updated: (el, binding) => { - // Re-sync state/attributes on binding change - }, - beforeUnmount: (el) => { - ensureKey(el, (key) => deleteContent(key)) - for (const [event, handler] of LISTENERS) { - el.removeEventListener(event, handler) - } - }, -} -``` - -**Durability note:** Keep this as a lifecycle template. -Event names and attribute defaults can evolve. - -### Composable Structure - -**composables.ts:** Exposes floating UI + state binding for template - -```typescript -export const useVueltip = ({ tooltipElement, arrowElement, ... }) => { - // Use @floating-ui/vue for positioning - const { x, y } = useFloating(...) - - // Watch module-level state changes - watch(() => debouncedHoveredElement.value, () => { - // Compute positioning - }) - - // Return styles for template - return { x, y, show: computed(() => !!tooltipContent.value) } -} -``` - -### Usage in Demo - -```vue - -``` - -**Key insight:** Plugin manages app-level setup, directive manages element-level listeners, composable binds state to template. - -## Creating a New Package - -### 1. Directory structure - -```bash -mkdir packages/new-package -cd packages/new-package -``` - -### 2. Create essential files - -**package.json:** -```json -{ - "name": "@vingy/new-package", - "version": "0.0.0", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "dependencies": { - "@vingy/shared": "workspace:^", - "vue": "catalog:" - }, - "devDependencies": { - "tsdown": "catalog:", - "vitest": "catalog:" - }, - "scripts": { - "build": "tsdown", - "dev": "tsdown -w" - } -} -``` - -**tsdown.config.ts:** -```typescript -import { defineConfig } from 'tsdown' - -export default defineConfig({ - dts: true, - entry: 'src/index.ts', - format: 'esm', - inlineOnly: false, -}) -``` - -**src/index.ts:** (empty or minimal) -```typescript -export {} -``` - -**src/types.ts:** (if needed) -```typescript -// Type definitions -``` - -### 3. Add to pnpm workspaces - -Update [pnpm-workspace.yaml](../../pnpm-workspace.yaml): -```yaml -packages: - - packages/* # Already includes new-package - - demo -``` - -(Auto-included by glob pattern) - -### 4. Create changeset - -```bash -pnpm changeset -# Select your new package -# Choose major (0.1.0 for new packages) -``` - -## Debugging Test Failures - -### Browser tests failing? - -1. Check test suffix: `.browser.test.ts` required -2. Verify file creates/manipulates DOM: - ```bash - # Should have: document, HTMLElement - # Should NOT run in: Node environment - ``` - -3. Run with UI: - ```bash - pnpm vitest --ui - ``` - -4. Check [vitest.config.ts](../../vitest.config.ts) projects - -### Unit tests failing? - -1. Check suffix: `.unit.test.ts` -2. Verify no DOM operations: - ```typescript - // ❌ Don't do this in unit tests - document.createElement('div') - el.addEventListener(...) - - // ✅ Do this instead - vi.spyOn(element, 'addEventListener') - ``` - -3. Run specific test: - ```bash - pnpm vitest --run src/registry.unit.test.ts - ``` - -## Code Style & Linting - -### Format with oxfmt - -```bash -pnpm format -``` - -Rules: -- Single quotes: `'string'` -- No semicolons: `const x = 1` -- Print width: 60 chars -- See [.oxfmtrc.json](../../.oxfmtrc.json) - -### Lint with oxlint - -```bash -pnpm lint -``` - -Enforces: -- No import cycles: `import/no-cycle: error` -- No unused exports: `import/no-unused-modules: warn` -- See [.oxlintrc.json](../../.oxlintrc.json) - -### Pre-commit checklist - -- [ ] `pnpm test` passes -- [ ] `pnpm lint` passes -- [ ] `pnpm format` run -- [ ] `pnpm typecheck` passes -- [ ] Tests added for new code -- [ ] `pnpm changeset` created -- [ ] Demo updated (if user-facing) - -## Adding Workspace Dependencies - -To add `@vingy/shared` to `vueltip`: - -```json -{ - "dependencies": { - "@vingy/shared": "workspace:^" - } -} -``` - -Then install: -```bash -pnpm install -``` - -Imports work naturally: -```typescript -import type { Maybe } from '@vingy/shared/types' -``` - -The `workspace:^` protocol: -- Links to local source -- No npm registry lookup -- Updates instantly during dev -- Publishes as exact version in released package diff --git a/.github/skills/debugging-strategies.md b/.github/skills/debugging-strategies.md deleted file mode 100644 index 788e8a9..0000000 --- a/.github/skills/debugging-strategies.md +++ /dev/null @@ -1,467 +0,0 @@ -# Debugging Strategies - -## Quick Reference: Debugging Tools - -| Problem | Tool | Command | -|---------|------|---------| -| Test failing, don't know why | Vitest UI | `pnpm vitest --ui` | -| Specific test failing repeatedly | Run single file | `pnpm vitest --run src/file.test.ts` | -| Browser test not in browser env | Check file suffix | Must be `.browser.test.ts` | -| Browser test works, unit test fails | Separate envs | `.browser.test.ts` needs DOM, `.unit.test.ts` doesn't | -| Can't find error location | Terminal output | Scroll to top of output, check file:line | -| Listeners not attaching | Check console | Use spies: `vi.spyOn(el, 'addEventListener')` | -| State not updating | Add logs or debugger | `console.log()` or `debugger` in watch | -| Import cycle error | Check oxlint | `pnpm lint` - should show import/no-cycle | -| Code formatting issues | Format repo | `pnpm format` (oxfmt) | -| Multiple errors after change | Run tests | `pnpm test` to see what broke | - ---- - -## Decision Tree - -**Is the issue in tests or production code?** -- Tests failing? → [Debugging Tests](#debugging-tests) -- Production code? → [Debugging Runtime](#debugging-runtime) - -**If tests failing, what type?** -- Unit test failing? → [Unit Test Debugging](#unit-test-debugging) -- Browser test failing? → [Browser Test Debugging](#browser-test-debugging) - -**If runtime issue, where?** -- Listeners not firing? → [Listener Debugging](#listener-debugging) -- State not updating? → [State Debugging](#state-debugging) -- Component not rendering? → [Component Debugging](#component-debugging) - ---- - -## Debugging Tests - -### Start with Vitest UI - -**Open interactive test runner:** -```bash -pnpm vitest --ui -``` - -This opens browser UI showing: -- All test files listed -- Pass/fail status -- Test output and errors -- Click to re-run specific tests -- View console.log output - -**Best for:** -- Seeing which tests pass/fail at a glance -- Reading full error messages without terminal truncation -- Re-running individual tests quickly - -### Unit Test Debugging - -**File: `.unit.test.ts` (Node environment)** - -**Problem: Logic test failing** - -```typescript -// ❌ Test failing -it('upserts entry', () => { - const entry = { uid: 'test', groupId: '1' } - upsert(entry) - - expect(byUid.get('test')).toBe(entry) // Fails here -}) -``` - -**Step 1: Check actual value** -```typescript -it('upserts entry', () => { - const entry = { uid: 'test', groupId: '1' } - upsert(entry) - - // What's actually stored? - console.log('byUid:', byUid) - console.log('value:', byUid.get('test')) - - expect(byUid.get('test')).toBe(entry) -}) -``` - -**Step 2: Run in UI, read output** -```bash -pnpm vitest --ui -# Click on failing test -# See console.log output at bottom -``` - -**Step 3: Verify setup/teardown** -```typescript -// ✅ ACCEPTABLE: Using beforeEach for global/singleton reset -beforeEach(() => { - byUid.clear() // Clear module-level singleton state - byGroupId.clear() -}) -``` - -Check [registry.unit.test.ts](../../packages/vuebugger/src/registry.unit.test.ts) for pattern - -**Key point:** Vitest hooks are acceptable here because `byUid`/`byGroupId` are module-level singletons that need resetting between tests. For test-specific setup, use explicit functions inside each test (see [Testing Decisions](./testing-decisions.md)). - -**Anti-pattern:** -- ❌ Not clearing state between tests -- ❌ Tests passing locally but failing in CI (state leaking) - -### Browser Test Debugging - -**File: `.browser.test.ts` (Browser environment with Playwright)** - -**Problem: DOM interaction failing** - -```typescript -// ❌ Test failing -it('adds listener', () => { - const el = document.createElement('div') - const spy = vi.spyOn(el, 'addEventListener') - - vueltipDirective.created(el, { value: 'text' } as any) - - expect(spy).toHaveBeenCalledWith('mouseenter', expect.any(Function)) -}) -``` - -**Step 1: Verify setup** -```typescript -const setupDirective = () => { - const el = document.createElement('div') - document.body.appendChild(el) // Must append! - setOptions({ keyAttribute: 'tooltip-key' }) - return el -} - -it('adds listener', () => { - const el = setupDirective() // Use setup function - const spy = vi.spyOn(el, 'addEventListener') - - vueltipDirective.created(el, { value: 'text' } as any) - - expect(spy).toHaveBeenCalledWith('mouseenter', expect.any(Function)) - teardownDirective(el) // Clean up -}) -``` - -**Step 2: Check file suffix** -- Must be `.browser.test.ts` (not `.unit.test.ts`) -- [vitest.config.ts](../../vitest.config.ts) routes by suffix - -**Step 3: Run browser tests only** -```bash -pnpm vitest --run --project browser src/directive.browser.test.ts -``` - -**Step 4: Add console.log** -```typescript -it('adds listener', () => { - const el = setupDirective() - console.log('el:', el) - console.log('el.addEventListener:', el.addEventListener) - - const spy = vi.spyOn(el, 'addEventListener') - console.log('spy created:', spy) - - vueltipDirective.created(el, { value: 'text' } as any) - console.log('spy called with:', spy.mock.calls) - - expect(spy).toHaveBeenCalledWith('mouseenter', expect.any(Function)) -}) -``` - -**Run with UI to see console output:** -```bash -pnpm vitest --ui -``` - ---- - -## Debugging Runtime - -### Listener Debugging - -**Problem: Event listener not firing** - -**Checklist:** -1. Is listener attached to correct element? -2. Is the correct event type being listened for? -3. Are both add and remove using same handler reference? - -**Debug in console (browser devtools):** - -```javascript -// Get element -const el = document.querySelector('[tooltip-key]') - -// Check listeners attached -getEventListeners(el) // Chrome DevTools only -// Returns: { mouseenter: [...], mouseleave: [...] } - -// Manually trigger event -el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })) - -// Check if handler fired -console.log('Handler fired?') // Should see logs from listener -``` - -**Check with test spy:** -```typescript -it('attaches mouseenter listener', () => { - const el = setupDirective() - const spy = vi.spyOn(el, 'addEventListener') - - vueltipDirective.created(el, { value: 'text' } as any) - - expect(spy).toHaveBeenCalledWith( - 'mouseenter', - expect.any(Function), - ) - - // Actually call the handler - const [event, handler] = spy.mock.calls[0] - if (typeof handler === 'function') { - handler(new MouseEvent('mouseenter')) - } - - // Check what happened after handler ran - console.log('tooltipKey:', tooltipKey.value) - - teardownDirective(el) -}) -``` - -See [directive.browser.test.ts](../../packages/vueltip/src/directive.browser.test.ts) for full patterns - -**Anti-patterns:** -- ❌ Not using same handler reference for remove -- ❌ Creating inline handlers that can't be removed -- ❌ Listeners defined inside conditionals (not always added) - -### State Debugging - -**Problem: Reactive ref not updating** - -**Scenario: `tooltipKey` not updating in [state.ts](../../packages/vueltip/src/state.ts)** - -```typescript -// ❌ Not updating -const { hoveredElement } = useVueltip() -console.log('hoveredElement:', hoveredElement.value) // undefined -// Expected something else -``` - -**Step 1: Check the watch dependencies** -```typescript -// state.ts -watch( - [ - tooltipKey, - hoveredElement, - tooltipPlacement, - () => getContent(tooltipKey.value ?? ''), - ], - ([key, el, placement]) => { - // ... debounce logic - }, -) -``` - -**Step 2: Verify updates are happening** -```typescript -// In composable test -it('updates hovered element', () => { - const { tooltipKey, hoveredElement } = useVueltip() - - console.log('Initial:', hoveredElement.value) // undefined - - hoveredElement.value = el // Update - - console.log('After update:', hoveredElement.value) // Should be el - expect(hoveredElement.value).toBe(el) -}) -``` - -**Step 3: Check debounce timing** -```typescript -// state.ts has setTimeout delay -// Tests need to flush timers - -import { vi } from 'vitest' - -it('updates content after delay', async () => { - hoveredElement.value = el - - // Flush pending timers - vi.runAllTimers() // or await new Promise(resolve => setTimeout(resolve, 0)) - - expect(tooltipContent.value).toBeTruthy() -}) -``` - -**Step 4: Use Vue DevTools** - -Install [Vue DevTools](https://devtools.vuejs.org): - -```bash -# Chrome/Edge extension or Firefox addon -# Open DevTools → Vue tab → Inspect component -``` - -Shows: -- All reactive refs in component -- Real-time updates as you interact -- Time-travel debugging - -### Component Debugging - -**Problem: Component not rendering** - -**In demo app [demo/src/App.vue](../../demo/src/App.vue):** - -```vue - - - -``` - -**Step 1: Check directive is registered** -```bash -# In browser console -const app = document.querySelector('#app').__vue_app__ -console.log(app._context.directives) // Should have vTooltip -``` - -**Step 2: Verify options are set** -```typescript -// plugin.ts should call setOptions -import { getOption } from '@vingy/vueltip' -console.log(getOption('showDelay')) // Should be configured value -``` - -**Step 3: Check composable returns** -```vue - -``` - ---- - -## Linting & Formatting Errors - -### Import Cycle Detection - -**Error in terminal:** -``` -error: import/no-cycle - ↳ cycle detected: a.ts → b.ts → a.ts -``` - -**Fix:** -1. Identify files in cycle -2. Move shared logic to third file -3. Both files import from shared - -**Example:** -```typescript -// ❌ Cycle: state.ts ↔ listeners.ts -import { hoveredElement } from './state' // listeners.ts -export const onMouseover = () => { - hoveredElement.value = el -} - -import { onMouseover } from './listeners' // state.ts -watch(() => hoveredElement, onMouseover) -``` - -**Solution: Extract shared helpers** -```typescript -// shared-handlers.ts -export const handlers = { - onMouseover: (el) => { /* ... */ } -} - -// listeners.ts imports from shared -import { handlers } from './shared-handlers' - -// state.ts imports from shared -import { handlers } from './shared-handlers' -``` - -### Code Format Issues - -**Oxfmt rules (60 char width, single quotes, no semis):** - -```bash -# Show formatting issues -pnpm lint - -# Fix all issues -pnpm format -``` - -**Common issues:** -- ❌ Double quotes → Single quotes -- ❌ Semicolons at end of lines -- ❌ Long lines > 60 chars → Wrap - ---- - -## Useful Commands - -```bash -# Run all tests with detailed output -pnpm test - -# Run single test file -pnpm vitest --run src/registry.unit.test.ts - -# Run only unit tests (Node) -pnpm vitest --run --project unit - -# Run only browser tests (Playwright) -pnpm vitest --run --project browser - -# Open interactive test UI -pnpm vitest --ui - -# Watch mode (re-run on file change) -pnpm vitest - -# Check for lint errors -pnpm lint - -# Format code -pnpm format - -# Build packages -pnpm build - -# Watch build -pnpm dev -``` - ---- - -## When to Use Each Strategy - -| Situation | Strategy | -|-----------|----------| -| Test fails, no error message | Vitest UI + console.log | -| Listener not firing | Browser spy + manual trigger | -| State not updating | Check watch deps + add logging | -| Component not rendering | Vue DevTools + check registration | -| Import cycle error | Use `pnpm lint` to find, extract shared | -| Format issues | Run `pnpm format` automatically | -| Multiple failures | `pnpm test` first to get overview | diff --git a/.github/skills/instruction-validation.md b/.github/skills/instruction-validation.md deleted file mode 100644 index 5b3313b..0000000 --- a/.github/skills/instruction-validation.md +++ /dev/null @@ -1,405 +0,0 @@ -# Instruction Validation & Refinement - -## Quick Reference: Validation Checklist - -| Check | How to Verify | Fix If Failed | -|-------|---------------|---------------| -| Instructions match codebase | Read source files, compare patterns | Update instructions with real code examples | -| Examples are current | Check file links still exist | Update links or remove outdated examples | -| Patterns are cohesive | Cross-reference skills for consistency | Consolidate or clarify conflicting advice | -| Instructions overfit internals | Scan for exact literals and private names | Replace with stable pattern + one concrete reference | -| Anti-patterns are clear | Scan all ❌ marked items | Ensure each has explanation and correct approach | -| Decision trees are accurate | Follow trees on real tasks | Add missing branches, remove irrelevant ones | -| Completeness coverage | Map all file types and workflows | Add missing patterns, remove duplicates | -| Practical applicability | Use instruction in real task | Simplify if too complex, add examples if unclear | - ---- - -## Decision Tree: When to Validate - -**Are you about to commit code?** -- Yes → Run validation before commit -- No → Continue - -**Did you make significant changes?** -- Yes → Validate affected skills -- No → Continue - -**Are you uncertain if instructions match reality?** -- Yes → Run full validation -- No → Continue - -**Do instructions feel incomplete for a common task?** -- Yes → Add missing skill or pattern -- No → Done - ---- - -## Validation Workflow - -### Step 1: Identify What to Validate - -**Questions to ask:** -1. What file did I just modify or create? -2. Does an instruction skill cover this pattern? -3. Are examples in instructions pointing to this file? -4. Would the instructions have made this task faster? - -**Example workflow:** -``` -Modified: packages/vueltip/src/listeners.ts -↓ -Skills covering this: -- Common Pitfalls (inline listeners, handler references) -- State Management (event handler wrapper pattern) -↓ -Check: Do examples match current implementation? -``` - -### Step 2: Read Actual Implementation - -**Always verify against real code, not memory:** - -```bash -# Check if example pattern still exists -grep -n "export const onMouseover" packages/vueltip/src/listeners.ts - -# Read the full implementation -cat packages/vueltip/src/listeners.ts | head -50 - -# Compare with instructions -cat .github/skills/state-management.md | grep -A 10 "Event Handler Wrapper" -``` - -**Critical:** Instructions are only valuable if examples are accurate and current. - -### Step 3: Cross-Reference Skills for Consistency - -**Common inconsistencies to catch:** - -| Conflict | How to Find | How to Fix | -|----------|-------------|-----------| -| Same pattern explained differently | Search both skills for same keyword | Pick clearer explanation, remove duplicate | -| Contradictory advice | Search for opposing ❌ markings | Determine which is correct, remove error | -| Different terminology | Search for synonyms across skills | Standardize term usage everywhere | -| Over-specific literals | Search defaults/attribute names in docs | Keep literals only when part of public API | -| Missing links | Grep for file references | Verify all links exist, update if moved | -| Outdated examples | Check line counts match | Update example code to match current file | - -**Example validation:** - -```bash -# Find all references to "state.ts" across skills -grep -r "state.ts" .github/skills/ | wc -l - -# Check if file actually exists and at correct location -ls -la packages/vueltip/src/state.ts - -# Verify line numbers in examples are still valid -wc -l packages/vueltip/src/state.ts # Should match any line ranges in examples -``` - -### Step 4: Verify Examples Are Real - -**For each code example in instructions:** - -1. Does the code block actually exist in the codebase? -2. Are line numbers accurate if provided? -3. Would copying the code work as-is? -4. Are imports complete and correct? -5. Is this showing a durable pattern, not an unstable literal? - -**Example check:** - -```typescript -// From common-pitfalls.md: -// "Example: [listeners.ts](../../packages/vueltip/src/listeners.ts)" - -// Verify file exists and has example: -grep -A 5 "export const onMouseenter" packages/vueltip/src/listeners.ts -``` - -If example doesn't match, update instructions. - -### Step 5: Test Instructions Against Real Work - -**Before committing updates to skills:** - -1. **Pick a real task** - "Add a new feature to vueltip" -2. **Use instructions as guide** - Read through relevant skills -3. **Verify they help** - Did they guide you correctly? -4. **Note gaps** - Did you need info not in instructions? -5. **Update or add** - Fill gaps immediately - -**Red flags during use:** -- ❌ Searching for pattern that should be in instructions -- ❌ Example doesn't match your file -- ❌ Instruction contradicts what you're doing -- ❌ Missing decision tree branch - -If any flag occurs, pause and fix instructions. - -### Step 6: Validate Completeness - -**Coverage areas to check:** - -| Area | Validation | Example | -|------|-----------|---------| -| All file types | Can I find pattern for each src/ file? | `state.ts`, `listeners.ts`, `options.ts` all covered? | -| All workflows | Does skill cover: add feature, fix bug, test, debug? | Run through each scenario mentally | -| All anti-patterns | Does each ❌ have corresponding ✅ fix? | Inline listener → stored reference shown | -| All decisions | Can I follow tree to reach correct choice? | Pick random tree node, can I reach decision? | -| All imports | Are imports correct and complete in examples? | Can I copy example code and run it? | - ---- - -## Iteration Workflow - -### When to Update Skills - -**Trigger 1: Found a gap while working** -``` -Working on feature → Need pattern not in skills -↓ -PAUSE and update skills with new pattern -↓ -Resume work, but now instructions are more complete -``` - -**Trigger 2: Discovered pattern is outdated** -``` -Read instruction → Check example in codebase -↓ -Example doesn't match → Pattern must have changed -↓ -STOP and update instruction with current pattern -↓ -Resume task with accurate guidance -``` - -**Trigger 3: Pattern exists but unclear** -``` -Read instruction → Try to apply it -↓ -Result is still confusing or hard to follow -↓ -Rewrite with clearer language, better examples -↓ -Test rewritten version on new task -``` - -### Update Checklist - -Before committing changes to any skill: - -- [ ] **Accuracy**: Example matches current codebase -- [ ] **Completeness**: All related patterns included -- [ ] **Clarity**: Language is direct and unambiguous -- [ ] **Consistency**: Terms match other skills -- [ ] **Linkage**: All file links still valid -- [ ] **Durability**: Guidance survives field/default renames -- [ ] **Anti-patterns**: Each ❌ has ✅ fix shown -- [ ] **Decision trees**: All branches covered -- [ ] **Practicality**: Real-world applicability verified - ---- - -## Validation Commands - -### Check for Broken Links - -```bash -# Find all file references in skills -grep -rho '\[.*\](.*packages.*\.ts' .github/skills/ - -# Verify each file exists -ls packages/vueltip/src/listeners.ts -ls packages/vuebugger/src/registry.ts -# etc... -``` - -### Verify Examples Are Current - -```bash -# Check if pattern still exists -grep "export const useVueltip" packages/vueltip/src/composables.ts - -# Check line count (for line range validation) -wc -l packages/vueltip/src/state.ts - -# Search for pattern mentioned in instructions -grep -A 5 "export const onMouseover" packages/vueltip/src/listeners.ts -``` - -### Cross-Check Skills Consistency - -```bash -# Find all mentions of a pattern -grep -r "onScopeDispose" .github/skills/ - -# Check if it's defined in codebase -grep -r "onScopeDispose" packages/ - -# Ensure terminology is consistent -grep -r "module-level" .github/skills/ | wc -l -``` - -### Validate Completeness - -```bash -# List all TypeScript files in packages -find packages -name "*.ts" -not -name "*.test.ts" | sort - -# For each file, check if it's mentioned in instructions -# If not, may indicate missing pattern -for file in $(find packages -name "*.ts" -not -name "*.test.ts"); do - if ! grep -q "$(basename $file)" .github/skills/*.md; then - echo "Missing pattern: $file" - fi -done -``` - ---- - -## Red Flags: Patterns to Catch - -**Red Flag 0: Docs mirror private internals too closely** -``` -Instruction includes exact defaults and private key names -↓ -Small refactor causes many skill edits -↓ -Fix by documenting invariant behavior and linking to source -for current literals -``` - -**Red Flag 1: Example code doesn't compile** -```typescript -// ❌ Instruction shows: -import { byUid } from './registry' -export const entries = byUid.get('id') // This works? - -// ✅ Verify in actual file: -grep -A 5 "export const byUid" packages/vuebugger/src/registry.ts -``` - -**Red Flag 2: Outdated file structure** -``` -Instruction says: "import from ./state" -But actually: File was renamed to ./reactive-state -↓ -STOP: Update all references in instructions -``` - -**Red Flag 3: Anti-pattern doesn't match reality** -``` -Instruction warns: "Don't use vitest hooks" -But in actual tests: All tests use hooks -↓ -Either instructions are wrong OR tests need fixing -Check git blame to see why pattern differs -``` - -**Red Flag 4: Decision tree has dead ends** -``` -Tree: "Do you need cleanup?" → Yes → "Use onScopeDispose()" -But in codebase: Some cleanup uses directives, not composables -↓ -Tree branch is incomplete, needs both paths -``` - ---- - -## Validation Checklist Before Committing - -``` -Skills updated? Run this before git commit: - -[ ] All ❌ anti-patterns have ✅ correct approach shown -[ ] All file links still valid: grep -r "packages/.*\.ts" -[ ] All example code matches current implementation -[ ] Decision trees have all branches covered -[ ] No contradictions between skills on same topic -[ ] New patterns in codebase are documented in skills -[ ] Line count of documentation > code? (Coverage ratio) -[ ] Searched for term in codebase to verify it exists -[ ] Ran skill's own command examples, they work? -[ ] Asked: "Would this have helped me 30 mins ago?" -``` - -**Failed check → Update before committing.** - ---- - -## When to Add New Skills - -**Add new skill if:** -1. ✅ Multiple patterns don't fit existing skills -2. ✅ Common task type not covered -3. ✅ New tool/technology added to monorepo -4. ✅ Pattern discovered during work that no skill covers - -**Don't add if:** -1. ❌ Pattern fits existing skill (consolidate instead) -2. ❌ Only one small example exists -3. ❌ Overlaps heavily with another skill - -**Example decision:** -``` -New pattern: "Floating-UI integration with Vue" -↓ -Check existing skills: -- common-workflows.md: Covers high-level plugin pattern -- type-patterns.md: Not relevant -- state-management.md: Not directly about this -↓ -Decision: Could fit in common-workflows as subsection -OR: Specific enough to warrant new skill? -↓ -If multiple patterns (positioning, mounting, updates): -ADD new skill: Integration Patterns -↓ -If just positioning example: -ADD to common-workflows.md -``` - ---- - -## Meta-Validation: Are Instructions Good? - -**Ask these questions:** - -1. **Would I have written it this way?** - If no, it's too verbose -2. **Can I find what I need in 30 seconds?** - If no, organization is poor -3. **Are decision trees natural?** - If no, they miss actual branch points -4. **Do examples exactly match code?** - If no, they mislead -5. **Is terminology consistent?** - If no, confusing across skills -6. **Would new agent understand this?** - If no, too advanced or unclear -7. **Does it save time vs reading code?** - If no, not valuable - -**If any answer is "no":** -→ Rewrite that section immediately -→ Test with real task before re-committing - ---- - -## Self-Improvement Loop - -``` -1. Use instructions on real task - ↓ -2. Note: What helped? What was missing? - ↓ -3. Find pattern in codebase that instructions missed - ↓ -4. Update skill with new pattern + example - ↓ -5. Test updated instruction on new similar task - ↓ -6. Commit when validated - ↓ -7. Loop back to step 1 with different task -``` - -**Result:** Instructions continuously improve through real usage. - diff --git a/.github/skills/package-structure.md b/.github/skills/package-structure.md deleted file mode 100644 index ffd06c4..0000000 --- a/.github/skills/package-structure.md +++ /dev/null @@ -1,309 +0,0 @@ -# Package Structure - -## Quick Reference - -| Question | Answer | Rule | -|----------|--------|------| -| Export this from `index.ts`? | Public API? | Yes → Export \| No → Keep private | -| New file needed? | > 100 lines? | Yes → Create new \| No → Extend existing | -| Dependencies? | `@vingy/*`? | Yes → `workspace:^` \| No → `catalog:` | -| Exports config? | Always? | ESM-only with `.d.mts` types | -| File structure? | Always? | Flat `src/`, co-locate tests | - ---- - -## Decision Tree - -**Should this go in `src/index.ts` (exported)?** -- Is it part of the public API? → Yes → Export it -- Is it internal infrastructure? → No → Keep private - -**Do I need a new file?** -- Implementation + tests > 100 lines? → Yes, create `[feature].ts` -- Adding to existing module? → No, extend existing file -- New public composable/plugin/directive? → Yes, create `[name].ts` - -**What dependency protocol to use?** -- Internal package (@vingy/*)? → `workspace:^` -- External package? → `catalog:` - ---- - -## Strict Rules - -**Rule 1: Export public API only from `index.ts`** - -Internal modules must NEVER be re-exported. Users should not import internals directly. - -**vuebugger example:** - -```typescript -// ✅ index.ts - Public API only -export { debug } from './debug' -export const DebugPlugin = plugin -export type { PluginOptions } from './types' - -// ❌ Do NOT export: -// export { byUid, upsert, remove } from './registry' // Internal -// export { setupComposableDevtools } from './devtools' // Internal -``` - -**vueltip example:** - -```typescript -// ✅ index.ts - Public API -export { vueltipPlugin } from './plugin' -export { vueltipDirective } from './directive' -export { useVueltip } from './composables' -export type { - PublicPayload, - PublicValue, -} from './types' - -// ❌ Do NOT export: -// export { hoveredElement, setContent } from './state' // Internal -// export { getOption } from './options' // Internal -// export { onMouseover } from './listeners' // Internal -``` - -**Anti-pattern:** Exporting implementation details or intermediate utilities - ---- - -## File Organization - -``` -packages/*/ - src/ - index.ts # Public API only - types.ts # All exported types + ambient declarations - [feature].ts # Feature implementation - [feature].unit.test.ts - [feature].browser.test.ts - constants.ts # Constants (if needed) - utils.ts # Shared utilities (keep internal, don't export) - options.ts # Configuration state (internal) - tsdown.config.ts # Build config - package.json # Package metadata - README.md -``` - -**Key constraints:** -- Flat structure: no subdirectories in `src/` -- Co-locate tests adjacent to source files -- One test file per environment (`.unit.test.ts` or `.browser.test.ts`) -- `env.d.ts` for ambient type declarations (module augmentation) - ---- - -## Patterns by File Type - -| File Type | Pattern | Export? | Example | -|-----------|---------|---------|---------| -| `index.ts` | Public API | Yes, only public | Composables, plugins, directives, types | -| `types.ts` | All type definitions | Re-export public types from index.ts | Public content/value/options types | -| `[feature].ts` | Feature logic | Only if public API | `debug.ts`, `directive.ts`, `plugin.ts` | -| `listeners.ts` | Event handlers | No, internal | `onMouseover`, `onMouseout` | -| `utils.ts` | Helper functions | No, internal | `isTruncated`, `isHtmlElement`, `elementContainsText` | -| `options.ts` | Config state | No, internal | `getOption()`, `setOptions()` | -| `constants.ts` | Dev/config constants | No, internal | `INSPECTOR_ID`, `TIMELINE_ID` | -| `state.ts` | Reactive/state | No, internal | `hoveredElement`, `contentMap`, `byUid` | -| `registry.ts` | Entry tracking | No, internal | `byUid`, `byGroupId` maps | - ---- - -## Types (types.ts) - -**Pattern:** Define all types in one place, re-export public ones from `index.ts` - -```typescript -// src/types.ts -export type Placement = 'top' | 'bottom' | 'left' | 'right' -export interface PublicPayload { - text: string | null | undefined -} -export type PublicValue = - | string - | null - | undefined - | (PublicPayload & { placement?: Placement }) -export interface Options { showDelay: number } - -// Keep internal types here too - they stay private unless re-exported -type InternalHelper = { ... } -interface InternalConfig { ... } - -// Ambient declarations (module augmentation) -declare module 'vue' { - export interface GlobalDirectives { - vTooltip: TooltipDirective - } -} -``` - -```typescript -// src/index.ts -export type { - PublicPayload, - PublicValue, - Options, -} from './types' -// Don't re-export internal types -``` - -> Prefer describing type *roles* (content/value/options, -> public vs internal) rather than locking docs to exact -> field names that may change during refactors. - ---- - -## Dependencies - -### Internal Packages (workspace:^) - -```json -{ - "@vingy/shared": "workspace:^" -} -``` - -**What it does:** -- Links to local source in monorepo -- Auto-installs and updates instantly -- Publishes as exact version to npm - -**When to use:** Any `@vingy/*` package - -### External Packages (catalog:) - -```json -{ - "vue": "catalog:", - "@floating-ui/vue": "catalog:" -} -``` - -**What it does:** -- Centralizes version in `pnpm-workspace.yaml` -- All packages use same version -- Single source of truth - -**When to use:** All external npm packages - -**Anti-patterns:** -- ❌ Using exact versions or version ranges -- ❌ Mixing `workspace:^` and `catalog:` inconsistently -- ❌ Always use `workspace:^` or `catalog:` - ---- - -## Utility Functions & Type Guards - -**Pattern:** Keep utilities in `utils.ts` (internal, don't export) - -```typescript -// Type guard for safe casting -export function isHtmlElement( - el: EventTarget, -): el is HTMLElement { - return el instanceof HTMLElement -} - -// Utility for checking state -export function isTruncated(el: HTMLElement) { - return el.offsetWidth < el.scrollWidth - 1 -} - -// Helper for nested checks -export function elementContainsText( - el: HTMLElement, - text: string, -) { - if (isInputElement(el)) { - return getInputValue(el).includes(text) - } - return !!(el.innerText || el.textContent)?.includes(text) -} -``` - -**Critical:** -- Type guards return `type is X` for narrowing -- Avoid exporting; use in internal files only -- Keep related utilities together in one file - ---- - -## Tree-shaking & Dev-only Code - -**Pattern: Guard with `import.meta.env.DEV`** - -Entire blocks are stripped in production—zero runtime overhead. - -```typescript -// ✅ For functions -export const debug = (state: T): T => { - if (!import.meta.env.DEV) return state - // ... dev logic here is completely removed in prod - return state -} - -// ✅ For plugins/initialization -const plugin: Plugin = { - install: (app, options?) => { - if (!import.meta.env.DEV) return - // ... setup stripped in prod - }, -} -``` - -**Result:** -- Dev mode: Full functionality -- Prod mode: Guard evaluated at build time, entire block removed - -**Anti-patterns:** -- ❌ Not guarding dev-only code (leaks code to prod) -- ❌ Unnecessary nesting of `import.meta.env.DEV` checks - ---- - -## Build System - -**Tool:** tsdown (ESM-only) - -```bash -pnpm build # Build all packages -tsdown # Build this package (one-time) -tsdown -w # Build this package (watch mode) -``` - -**Output:** -- `dist/index.mjs` - ES module -- `dist/index.d.mts` - TypeScript definitions - -**Configuration** (same for all packages): -```typescript -export default defineConfig({ - dts: true, - entry: 'src/index.ts', - format: 'esm', - inlineOnly: false, -}) -``` - ---- - -## When to Create New Files - -| Scenario | Action | Example | -|----------|--------|---------| -| Implementation + tests < 100 lines | Extend existing file | Add to `state.ts` | -| Implementation + tests > 100 lines | Create `[feature].ts` with tests | `composables.ts` with `.unit.test.ts` + `.browser.test.ts` | -| New composable/plugin/directive | New `[name].ts`, export from `index.ts` | `debug.ts` exported as `debug` | -| Config/options | Add to `options.ts` or internal file | Don't export unless part of public API | -| Shared utility | Create `utils.ts` (internal, don't export) | Keep private | -| Public type | Add to `types.ts`, re-export from `index.ts` | `Placement` type | - -**Anti-patterns:** -- ❌ Creating small utility files that aren't re-exported -- ❌ Co-locating unrelated logic instead -- ❌ More than 100 lines mixed in one file diff --git a/.github/skills/state-management.md b/.github/skills/state-management.md index 67fc74f..30ac0b9 100644 --- a/.github/skills/state-management.md +++ b/.github/skills/state-management.md @@ -1,304 +1,8 @@ # State Management -## Quick Reference: When to Use Each +There is no specific solution for state management. Depending on the usecase an appropriate system shall be implemented. -| Need | Pattern | Cleanup | Example | -|------|---------|---------|---------| -| Track entries app-wide | Module Map | `onScopeDispose` + `remove()` | `byUid`, `byGroupId` | -| Reactive state in templates | Module refs | None needed | `hoveredElement`, `contentMap` | -| Auto-cleanup watchers | `getCurrentScope() ?? effectScope()` | `onScopeDispose` | Vuebugger `debug()` | -| Event listeners | Directive hooks | `beforeUnmount` | Vueltip `created`/`beforeUnmount` | -| Debounce rapid updates | `watch` + `setTimeout` | `clearTimeout` | Tooltip show/hide delay | -| Type-safe event handlers | Wrapper functions | None (stored ref) | `ensureEventTarget()` | -| App configuration | Getter functions | None | `getOption()`, `setOptions()` | +When a package wide state is needed, encapsulate it into a module. If the state needs to be reusable, encapsulate it into a function. Any other state management solution have to be verified with the user. -> Durability rule: prefer stable patterns over exact -> literals. Treat concrete default values and exact -> attribute names as examples unless they are part of -> documented public API. +The most important aspect is to avoid memory leaks and to keep it as minimal as possible. ---- - -## Decision Tree - -**Do you need to track state across the entire app?** -- Yes → **Module-level singleton** (Map or ref at module level) -- No → Local reactive state (composable or component) - -**If using module-level state, is it Vue-reactive?** -- Yes (needs reactivity in templates) → **Module-level refs** (See Vueltip [state.ts](../../packages/vueltip/src/state.ts)) -- No (just tracking data) → **Module-level Maps** (See Vuebugger [registry.ts](../../packages/vuebugger/src/registry.ts)) - -**Do you need automatic cleanup?** -- Yes, inside a composable → **`onScopeDispose()`** (auto-cleanup on unmount) -- Yes, in a directive → **Directive hooks** (`created`/`beforeUnmount`) -- No → No cleanup needed - -**Do you need to debounce rapid updates?** -- Yes → **`watch()` + `setTimeout`** with `clearTimeout` pattern -- No → Direct state updates - ---- - -## Patterns & Implementation - -### Pattern 1: Module-level Map (Non-reactive Tracking) - -**Use for:** App-wide entry tracking that doesn't need Vue reactivity - -**Example:** [Vuebugger Registry](../../packages/vuebugger/src/registry.ts) - -```typescript -export const byUid = new Map< - VuebuggerEntry['uid'], - VuebuggerEntry ->() -export const byGroupId = new Map< - VuebuggerEntry['groupId'], - Set ->() - -const upsertInternal = (entry: VuebuggerEntry) => { - byUid.set(entry.uid, entry) - const group = byGroupId.get(entry.groupId) - if (!group) byGroupId.set(entry.groupId, new Set([entry.uid])) - else group.add(entry.uid) -} - -export const remove = (entry: VuebuggerEntry) => { - const { uid, groupId } = entry - byUid.delete(uid) - const group = byGroupId.get(groupId) - group?.delete(uid) - if (group?.size === 0) byGroupId.delete(groupId) -} - -// Callback pattern for listeners -const callbacks: ((entry: VuebuggerEntry) => void)[] = [] -const runCallbacks = (entry: VuebuggerEntry) => - callbacks.forEach((cb) => cb(entry)) -const withCallbacks = - (fn: (entry: VuebuggerEntry) => void) => - (entry: VuebuggerEntry) => { - fn(entry) - runCallbacks(entry) - } - -export const upsert = withCallbacks(upsertInternal) - -export const onUpdate = ( - fn: (entry: VuebuggerEntry) => void, -) => { - callbacks.push(fn) -} -``` - -**Cleanup:** Not needed for Map itself, but track when entries are created and call `remove()` via `onScopeDispose()` - -**Use with:** Callback pattern for listeners (`onUpdate()`, `onRemove()`), or scope-aware cleanup - ---- - -### Pattern 2: Module-level Refs (Vue-reactive State) - -**Use for:** State that needs to be reactive in templates/watchers - -**Example:** [Vueltip State](../../packages/vueltip/src/state.ts) - -```typescript -export const hoveredElement = ref>() - -// Keep Map internal - only export accessors -const contentMap = ref(new Map()) - -// Getter function -export const getContent = (key: string) => - contentMap.value.get(key) - -// Setter function -export const setContent = (key: string, value: Content) => - contentMap.value.set(key, value) - -export const deleteContent = (key: string) => - contentMap.value.delete(key) -``` - -**Cleanup:** Not needed for refs themselves. Refs auto-update when mounted/unmounted. - -**Use with:** `watch()` to respond to changes, composables to bind to UI - -**Critical:** -- Keep Map internal (not exported), expose only getter/setter functions -- Use `ref(new Map(...))` for reactive Map, then `.value.set/get/delete` -- Watch debounces rapid state changes (see Pattern 5) - ---- - -### Pattern 3: Scope-aware Cleanup (Composables) - -**Use for:** Registering watchers/listeners that auto-cleanup on component unmount - -**When:** Tracking state change inside a composable or component setup - -**Example:** [Vuebugger debug.ts](../../packages/vuebugger/src/debug.ts) - -```typescript -const scope = getCurrentScope() ?? effectScope() -scope.run(() => { - onScopeDispose(() => remove(entry)) - watch(() => state, (value) => upsert(entry), { deep: true }) -}) -``` - -**Critical:** -- `getCurrentScope()` returns active scope inside composable/setup -- `?? effectScope()` creates manual scope if called standalone -- Everything in `scope.run()` auto-disposes when scope ends (component unmount) -- Do NOT manually call cleanup functions; `onScopeDispose` handles it - -**Anti-pattern:** -- ❌ Calling `remove()` directly in setup without scope—it won't cleanup - ---- - -### Pattern 4: Directive Lifecycle Cleanup - -**Use for:** Event listeners in directives - -**When:** `created` hook → add listeners, `beforeUnmount` hook → remove listeners - -**Example:** [Vueltip directive.ts](../../packages/vueltip/src/directive.ts) - -```typescript -export const vueltipDirective = { - created: (el) => { - el.addEventListener('eventA', handlerA) - el.addEventListener('eventB', handlerB) - }, - beforeUnmount: (el) => { - el.removeEventListener('eventA', handlerA) - el.removeEventListener('eventB', handlerB) - }, -} -``` - -**Critical:** -- Store handler reference before adding; can't use inline functions -- Use same function for add/remove -- Must remove ALL listeners added in `created` - -**Anti-patterns:** -- ❌ Inline handlers `() => onMouseover()` -- ❌ Forgetting to remove listeners - ---- - -### Pattern 5: Debouncing State Changes - -**Use for:** Rate-limiting rapid state updates (show/hide delays) - -**When:** Multiple rapid triggers should batch into one update - -**Example:** [Vueltip state.ts](../../packages/vueltip/src/state.ts) - -```typescript -let timerId: Maybe> - -watch([tooltipKey, hoveredElement], () => { - if (timerId) clearTimeout(timerId) // Cancel previous - timerId = setTimeout(() => { - tooltipContent.value = getContent(key) - timerId = undefined - }, timeout) -}) -``` - -**Critical:** -- Always `clearTimeout()` before setting new timeout -- Reset `timerId` after firing to detect next change -- Use `watch()` array for batching related triggers - -**Anti-patterns:** -- ❌ Setting timeout without clearing previous one (stacks timers) -- ❌ Not resetting `timerId` after firing - ---- - -## Additional Patterns - -### Pattern 6: Event Handler Wrapper (Listeners) - -**Use for:** Event handlers that need type guards and state access - -**Example:** [Vueltip listeners.ts](../../packages/vueltip/src/listeners.ts) - -```typescript -// Higher-order function that ensures HTMLElement -const ensureEventTarget = - (fn: (target: HTMLElement) => void) => - (event: MouseEvent | FocusEvent) => { - const { target } = event - if (!target || !isHtmlElement(target)) { - return - } - fn(target) - } - -// Handler that accesses module-level state -export const onMouseover = ensureEventTarget((target) => { - ensureKey(target, (key) => { - const content = getContent(key) - if (!content) return - - // Update reactive state - tooltipKey.value = key - hoveredElement.value = target - }) -}) -``` - -**Critical:** -- Type guards at wrapper level (avoid null checks in every handler) -- Store as module-level const (reuse same reference) -- Access reactive state inside -- Never use inline functions as event listeners - ---- - -### Pattern 7: Configuration Getters (Options) - -**Use for:** Centralized app configuration - -**Example:** [Vueltip options.ts](../../packages/vueltip/src/options.ts) - -```typescript -import type { Options } from './types' - -let options: Options = { - placementAttribute: DEFAULT_PLACEMENT_ATTRIBUTE, - keyAttribute: DEFAULT_KEY_ATTRIBUTE, - truncateAttribute: DEFAULT_TRUNCATE_ATTRIBUTE, - showDelay: 0, - hideDelay: 200, -} - -export const setOptions = (opts?: Partial) => { - options = { ...options, ...opts } -} - -export const getOption = ( - key: T, -): Options[T] => options[key] -``` - -**Critical:** -- Provide typed getter: `getOption('showDelay')` returns `number` -- Merge partial options: `{ ...defaults, ...provided }` -- Keep defaults centralized in one module; avoid - hardcoding the same literal in multiple files/docs -- Keep internal: don't export `options` directly -- Use in composables/directives to access config - -**Anti-patterns:** -- ❌ Direct module-level `options` export -- ❌ Untyped getters that return `any` diff --git a/.github/skills/testing-decisions.md b/.github/skills/testing-decisions.md index fb008c8..d7c250b 100644 --- a/.github/skills/testing-decisions.md +++ b/.github/skills/testing-decisions.md @@ -1,35 +1,13 @@ # Testing Decisions -## Quick Reference +There are 2 different test types available unit tests `.unit.test.ts`, and browser tests `.browser.test.ts`. -| Scenario | Test Type | Environment | Example | -|----------|-----------|-------------|---------| -| State updates, Map operations, logic | `.unit.test.ts` | Node | `registry.unit.test.ts` | -| DOM interaction, event listeners, styles | `.browser.test.ts` | Browser | `directive.browser.test.ts` | -| Composable returns, reactive refs | `.unit.test.ts` | Node | `state.unit.test.ts` | -| Component mounting, DOM queries | `.browser.test.ts` | Browser | `listeners.browser.test.ts` | +## When to use what test type? ---- +`.browser.test.ts` are to be used whenever the code in question relies on either the DOM or some browser APIs. In any other case `.unit.test.ts` should be used. -## Decision Tree -**Are you testing DOM interaction or user events?** -- Yes → **`.browser.test.ts`** (Browser environment with `document`, `HTMLElement`) -- No → **`.unit.test.ts`** (Node environment) - -**Specific scenarios:** - -| What You're Testing | Test Type | Why | -|---|---|---| -| State updates, Map operations, logic | `.unit.test.ts` | No DOM needed, Node is faster | -| DOM interaction, event listeners, DOM attributes | `.browser.test.ts` | Must have real DOM | -| Composable returns, reactive refs (no DOM) | `.unit.test.ts` | Just returning values | -| Component mounting, DOM queries, styling | `.browser.test.ts` | Needs browser APIs | -| Vue directives lifecycle | `.browser.test.ts` | Directive hooks need DOM context | - ---- - -## Implementation +## Styleguide ### Test File Location & Naming @@ -44,9 +22,9 @@ src/ **Rule:** One test file per environment. Don't mix unit + browser in one file. -### Setup & Teardown Pattern +### Always use explicit functions, never vitest hooks -**Always use explicit functions, never vitest hooks:** +Vitest hooks make it harder to read tests. Instead, extract the hooks content into functions which are called within the tests. ```typescript // ✅ Explicit setup function @@ -75,130 +53,15 @@ beforeEach(() => { ... }) afterEach(() => { ... }) ``` -### Unit Tests (`.unit.test.ts`) - -**Environment:** Node (no browser APIs) - -**Examples from codebase:** -- [registry.unit.test.ts](../../packages/vuebugger/src/registry.unit.test.ts) - Map upsert/remove logic -- [devtools.unit.test.ts](../../packages/vuebugger/src/devtools.unit.test.ts) - Devtools API calls -- [state.unit.test.ts](../../packages/vueltip/src/state.unit.test.ts) - Reactive state updates - -**What to test:** -- Pure function returns -- Ref/reactive state changes -- Map operations (add, remove, get) -- Callback execution -- Logic branches - -**Anti-patterns:** -- ❌ Using `document`, `HTMLElement`, `querySelector` -- ❌ Adding/removing event listeners -- ❌ Testing DOM attributes or styles -- ❌ Mocking browser APIs -- ❌ Using vitest hooks (`beforeEach`, `afterEach`) - prefer explicit setup/cleanup functions - -**Correct pattern:** -```typescript -// ✅ Unit test - logic only -it('adds entry to map', () => { - const entry = { uid: '1', data: {} } - upsert(entry) - expect(byUid.get('1')).toBe(entry) -}) - -// ✅ Explicit cleanup - no hooks -it('removes entry from map', () => { - const entry = { uid: '1', data: {} } - upsert(entry) - remove(entry) - expect(byUid.get('1')).toBeUndefined() -}) - -// ❌ Don't do this in unit tests -it('renders element', () => { - const el = document.createElement('div') - // This is a browser test -}) -``` - -### Browser Tests (`.browser.test.ts`) - -**Environment:** Browser (Playwright runs in Chromium) - -**Examples from codebase:** -- [directive.browser.test.ts](../../packages/vueltip/src/directive.browser.test.ts) - Directive lifecycle, DOM attributes -- [listeners.browser.test.ts](../../packages/vueltip/src/listeners.browser.test.ts) - Event listener handling -- [utils.browser.test.ts](../../packages/vueltip/src/utils.browser.test.ts) - DOM utility functions - -**What to test:** -- Event listener attachment/removal -- DOM attributes or classes -- Element visibility/styling -- Directive hooks firing -- User interactions (click, hover, focus) -- DOM queries and traversal - -**Anti-patterns:** -- ❌ Testing pure logic that doesn't need DOM -- ❌ Excessive DOM setup (use unit tests for logic) -- ❌ Not cleaning up event listeners (leads to test leaks) -- ❌ Using vitest hooks (`beforeEach`, `afterEach`) - prefer explicit setup/cleanup functions - -**Correct pattern:** -```typescript -// ✅ Browser test - DOM interaction -it('adds listener on created hook', () => { - const el = document.createElement('div') - const spy = vi.spyOn(el, 'addEventListener') - vueltipDirective.created(el, { value: 'text' }) - expect(spy).toHaveBeenCalledWith('mouseenter', expect.any(Function)) -}) - -// ✅ Explicit cleanup - no hooks -it('removes listeners on unmount', () => { - const el = document.createElement('div') - const removeSpy = vi.spyOn(el, 'removeEventListener') - vueltipDirective.created(el, { value: 'text' }) - vueltipDirective.beforeUnmount(el) - expect(removeSpy).toHaveBeenCalledWith('mouseenter', expect.any(Function)) -}) - -// ✅ Explicit setup/teardown with helper functions -const setupDirective = () => { - const el = document.createElement('div') - document.body.appendChild(el) - setOptions({ keyAttribute: 'tooltip-key' }) - return el -} - -const teardownDirective = (el: HTMLElement) => { - el.remove() -} - -it('test with setup/teardown', () => { - const el = setupDirective() - vueltipDirective.created(el, { value: 'text' }) - expect(el.getAttribute('tooltip-key')).toBeTruthy() - teardownDirective(el) -}) -``` - ---- - ## Running Tests ```bash pnpm test --run # All tests (unit + browser) -pnpm vitest --ui # Interactive UI for debugging pnpm vitest --run --project unit # Only unit tests pnpm vitest --run --project browser # Only browser tests pnpm vitest --run src/my.unit.test.ts # Single test file ``` -**Debugging failing tests:** -```bash -pnpm vitest --ui # Open UI, click failing test, see output -``` +## Documentation See [vitest.config.ts](../../vitest.config.ts) for configuration. diff --git a/.github/skills/type-patterns.md b/.github/skills/type-patterns.md deleted file mode 100644 index 6f05ea5..0000000 --- a/.github/skills/type-patterns.md +++ /dev/null @@ -1,242 +0,0 @@ -# Type Patterns - -## Quick Reference - -| Pattern | Use Case | Example | -|---------|----------|---------| -| Type guard | Narrow type safely | `el is HTMLElement` | -| Branded type | Create distinct types | `uid` brand prevents mixing IDs | -| Ambient declaration | Extend Vue globally | `declare module 'vue'` | -| Generic constraint | Bind getter/setter types | `getOption` | -| Conditional type | Compute types | `T extends string ? string[] : T[]` | - ---- - -## Decision Tree - -**Do you need to safely narrow a type?** -- Yes → **Type guard** (`type is X`) with `instanceof` or property check -- No → Continue - -**Do you need to prevent mixing similar types?** -- Yes → **Branded type** (opaque type with unique brand) -- No → Continue - -**Do you need to extend Vue's type system?** -- Yes → **Ambient declaration** (`declare module 'vue'`) -- No → Continue - -**Do you need generic type safety?** -- Yes → **Generic constraint** (``) -- No → Use `any` only if absolutely necessary - ---- - -## Type Guards - -**Pattern: Safely narrow types at runtime** - -```typescript -// Type guard returns true and narrows type -export function isHtmlElement( - el: EventTarget, -): el is HTMLElement { - return el instanceof HTMLElement -} - -// Use with type narrowing -const event = new MouseEvent('mouseenter') -const target = event.target - -if (isHtmlElement(target)) { - // target is now HTMLElement, not EventTarget - target.addEventListener('click', () => {}) -} - -// For properties -export function isInputElement( - el: HTMLElement, -): el is HTMLInputElement { - return el instanceof HTMLInputElement -} - -// For interface checks -export function hasContent( - value: any, -): value is { text?: string } { - return 'text' in value -} -``` - -**Critical:** -- Always return `type is X` (not boolean) -- Use `instanceof` for classes -- Use `in` operator for properties -- Guard evaluated at runtime; assertion removed by TypeScript - -**Anti-patterns:** -- ❌ Type guard returning `boolean` (no narrowing) -- ❌ Guard that doesn't match returned type -- ❌ Overly complex guards (break into smaller functions) - ---- - -## Branded Types (Opaque Types) - -**Pattern: Create distinct types from the same underlying type** - -**Example:** [Vuebugger types.ts](../../packages/vuebugger/src/types.ts) - -```typescript -// Prevent mixing different ID types -export type uid = string & { readonly __brand: 'uid' } -export type groupId = string & { readonly __brand: 'groupId' } - -// Helper to create branded values -const uid = (value: string): uid => value as uid -const groupId = (value: string): groupId => value as groupId - -// Now these are distinct types - can't mix them -const entry: VuebuggerEntry = { - uid: uid('component/state-1'), - groupId: groupId('module-state'), -} - -// TypeScript prevents mixing: -// ❌ entry.uid = groupId('foo') // Error: Type 'groupId' is not assignable to type 'uid' -// ✅ entry.uid = uid('component/state-2') -``` - -**Benefits:** -- Prevents accidental mixing of similar IDs -- Self-documenting code -- Zero runtime overhead -- Compiler catches errors - -**When to use:** -- Multiple ID types in same module -- Distinguishing UIDs from groupIds -- Domain-specific identifiers - ---- - -## Ambient Type Declarations - -**Pattern: Extend Vue's global types** - -**Example:** [Vueltip types.ts](../../packages/vueltip/src/types.ts) - -```typescript -// Extend Vue's GlobalDirectives -declare module 'vue' { - export interface GlobalDirectives { - vTooltip: TooltipDirective - } -} - -// Now v-tooltip is recognized in templates: -//
-``` - -**Use cases:** -- Register custom directives in global type system -- Extend component props -- Add module augmentation - -**Vueltip custom data augmentation:** - -```typescript -declare module '@vingy/vueltip' { - interface CustomVueltipData { - userId?: number - severity?: 'info' | 'warning' | 'error' - } -} - -// Now content.custom is strongly typed: -// v-tooltip="{ text: 'Profile', custom: { userId: 1 } }" -``` - -**Anti-patterns:** -- ❌ Ambient declarations for private types -- ❌ Multiple ambient declarations in different files (consolidate in types.ts) - ---- - -## Generic Constraints - -**Pattern: Type-safe configuration getters/setters** - -**Example:** [Vueltip options.ts](../../packages/vueltip/src/options.ts) - -```typescript -// Define options interface -export interface Options { - showDelay: number - hideDelay: number - keyAttribute: string - placementAttribute: string -} - -// Generic getter with constraint - return type is inferred! -export const getOption = ( - key: T, -): Options[T] => options[key] - -// Usage - TypeScript knows return type: -const delay: number = getOption('showDelay') // ✅ Correct -const attr: string = getOption('keyAttribute') // ✅ Correct -// const bad = getOption('invalid') // ❌ Error at compile time! -``` - -**Benefits:** -- Autocomplete for option keys -- Return type inferred from key parameter -- Compile-time validation - -**Pattern:** -```typescript -// Generic setter with partial override -export const setOptions = (opts?: Partial) => { - options = { ...options, ...opts } -} -``` - ---- - -## Conditional Types - -**Pattern: Compute types based on conditions** - -```typescript -// Simple conditional -type Maybe = T | null | undefined - -// Checks at compile time: -type A = Maybe // string | null | undefined -type B = Maybe // number | null | undefined - -// More complex -type ExtractEvent = T extends HTMLElement ? Event : never - -// Use with generics -export function ensureKey( - el: HTMLElement, - fn: (key: string) => T, -): T | undefined { - const key = el.getAttribute(getOption('keyAttribute')) - if (!key) return undefined - return fn(key) -} -``` - ---- - -## Anti-patterns - -- ❌ Exporting `any` instead of proper types -- ❌ Type assertions with `as` when type guard would work -- ❌ Overly complex types that confuse developers -- ❌ Mixing branded and unbranded versions of same type -- ❌ Not using `Readonly` for immutable refs -- ❌ Generic constraints that are too loose (`extends any`)