From 1022d3d8001dfc116ceee3857908b4339f8f8586 Mon Sep 17 00:00:00 2001 From: alphainfinitus Date: Mon, 5 Jan 2026 02:40:54 +0530 Subject: [PATCH] feat: Introduce `useSpeechInput` hook with dedicated tests and an SSR utility. --- eslint.config.js | 2 + package.json | 1 + plans/phase2.md | 877 +++++++++++++++++++++++++++ src/__tests__/useSpeechInput.test.ts | 567 +++++++++++++++++ src/hooks/index.ts | 5 + src/hooks/useIsSSR.ts | 26 + src/hooks/useSpeechInput.ts | 307 ++++++++++ src/index.ts | 6 + yarn.lock | 64 +- 9 files changed, 1853 insertions(+), 2 deletions(-) create mode 100644 plans/phase2.md create mode 100644 src/__tests__/useSpeechInput.test.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useIsSSR.ts create mode 100644 src/hooks/useSpeechInput.ts diff --git a/eslint.config.js b/eslint.config.js index 23e6947..eb793fc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,8 @@ export default [ window: 'readonly', document: 'readonly', navigator: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', Event: 'readonly', EventTarget: 'readonly', DOMException: 'readonly', diff --git a/package.json b/package.json index 25b531e..62ac28c 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@changesets/cli": "^2.27.0", "@eslint/js": "^9.0.0", "@rollup/plugin-babel": "^6.1.0", + "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/plans/phase2.md b/plans/phase2.md new file mode 100644 index 0000000..fa0e969 --- /dev/null +++ b/plans/phase2.md @@ -0,0 +1,877 @@ +# Phase 2: Primary Hook Implementation + +> **Goal:** Build the `useSpeechInput` hook with all core functionality including permission management, silence timeout, and SSR safety. + +**Estimated Time:** 2-3 days + +--- + +## Overview + +Phase 2 builds React hooks on top of the Phase 1 core modules: +- `useSpeechInput` — Primary hook for speech-to-text +- SSR safety utilities +- Comprehensive testing with @testing-library/react + +> [!IMPORTANT] +> This phase focuses on the hook API design. Cursor insertion comes in Phase 3. + +--- + +## 2.1 File Structure + +``` +src/ +├── hooks/ +│ ├── useSpeechInput.ts # Primary hook +│ ├── useIsSSR.ts # SSR detection utility +│ └── index.ts # Hook exports +└── __tests__/ + └── useSpeechInput.test.ts # Hook tests +``` + +--- + +## 2.2 Research: React Hook Best Practices + +### Hook Design Patterns + +| Pattern | Description | Usage | +|---------|-------------|-------| +| **Stable References** | Use `useCallback` for returned functions | Prevents unnecessary re-renders | +| **Refs for Mutable** | Use `useRef` for mutable values that don't trigger re-renders | Recognition instance, timeouts | +| **Effect Cleanup** | Always return cleanup from `useEffect` | Prevent memory leaks | +| **SSR Safety** | Check `typeof window` or use `useSyncExternalStore` | Next.js compatibility | + +### useSyncExternalStore for SSR + +```typescript +import { useSyncExternalStore } from 'react' + +// Detect SSR vs client +const subscribe = () => () => {} +const getSnapshot = () => false +const getServerSnapshot = () => true + +export function useIsSSR(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} +``` + +### Testing React Hooks + +From @testing-library/react best practices: + +1. **`renderHook`** — Test hooks in isolation +2. **`act`** — Wrap state changes and async operations +3. **`result.current`** — Access LIVE hook values (don't destructure early) +4. **`rerender`** — Test prop changes +5. **`waitFor`** — Test async state updates + +--- + +## 2.3 Hook Implementation + +### src/hooks/useIsSSR.ts + +```typescript +import { useSyncExternalStore } from 'react' + +/** + * Subscribe function that does nothing (client is always "subscribed") + */ +const subscribe = (): (() => void) => () => {} + +/** + * Client snapshot: we're NOT on the server + */ +const getSnapshot = (): boolean => false + +/** + * Server snapshot: we ARE on the server + */ +const getServerSnapshot = (): boolean => true + +/** + * Hook to detect if we're rendering on the server + * Uses useSyncExternalStore for proper React 18 SSR support + */ +export function useIsSSR(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} +``` + +### src/hooks/useSpeechInput.ts + +```typescript +import { + useState, + useRef, + useCallback, + useEffect, + useSyncExternalStore, +} from 'react' +import type { + UseSpeechInputOptions, + UseSpeechInputReturn, + MicPermissionState, + SpeechError, + SpeechRecognitionInstance, +} from '../types' +import { detectBrowserCapabilities } from '../core/browser' +import { + getMicPermissionState, + subscribeToPermissionChanges, + requestMicPermission, +} from '../core/permissions' +import { createRecognitionInstance } from '../core/recognition' + +/** + * Primary hook for speech-to-text functionality + * + * @param options - Configuration options + * @returns Speech input state and actions + * + * @example + * ```tsx + * const { transcript, isListening, start, stop } = useSpeechInput({ + * lang: 'en-US', + * continuous: false, + * silenceTimeout: 3000, + * }) + * ``` + */ +export function useSpeechInput( + options: UseSpeechInputOptions = {} +): UseSpeechInputReturn { + const { + lang, + continuous = false, + interimResults = true, + maxAlternatives = 1, + silenceTimeout = 3000, + autoRestart = false, + onResult, + onError, + onStart, + onEnd, + } = options + + // ============================================================================ + // State + // ============================================================================ + + const [transcript, setTranscript] = useState('') + const [interimTranscript, setInterimTranscript] = useState('') + const [isListening, setIsListening] = useState(false) + const [error, setError] = useState(null) + const [permissionState, setPermissionState] = + useState('prompt') + + // ============================================================================ + // Refs (mutable values that don't trigger re-renders) + // ============================================================================ + + const recognitionRef = useRef(null) + const silenceTimeoutRef = useRef | null>(null) + const isStartingRef = useRef(false) // Guard against React 18 Strict Mode double-mount + const shouldRestartRef = useRef(false) // Track if we should auto-restart + + // ============================================================================ + // Browser Capabilities (computed once, cached) + // ============================================================================ + + const capabilitiesRef = useRef(detectBrowserCapabilities()) + const isSupported = capabilitiesRef.current.isSupported + + // ============================================================================ + // Permission State Sync + // ============================================================================ + + useEffect(() => { + // Get initial permission state + getMicPermissionState().then(setPermissionState) + + // Subscribe to permission changes + const unsubscribe = subscribeToPermissionChanges(setPermissionState) + + return () => { + unsubscribe?.() + } + }, []) + + // ============================================================================ + // Silence Timeout Handler + // ============================================================================ + + const clearSilenceTimeout = useCallback(() => { + if (silenceTimeoutRef.current) { + clearTimeout(silenceTimeoutRef.current) + silenceTimeoutRef.current = null + } + }, []) + + const resetSilenceTimeout = useCallback(() => { + clearSilenceTimeout() + + if (silenceTimeout > 0 && isListening) { + silenceTimeoutRef.current = setTimeout(() => { + recognitionRef.current?.stop() + }, silenceTimeout) + } + }, [silenceTimeout, isListening, clearSilenceTimeout]) + + // Clear timeout when not listening + useEffect(() => { + if (!isListening) { + clearSilenceTimeout() + } + }, [isListening, clearSilenceTimeout]) + + // ============================================================================ + // Recognition Instance Management + // ============================================================================ + + const createRecognition = useCallback(() => { + // Abort any existing instance + if (recognitionRef.current) { + recognitionRef.current.abort() + recognitionRef.current = null + } + + recognitionRef.current = createRecognitionInstance( + { lang, continuous, interimResults, maxAlternatives }, + { + onResult: (text, isFinal) => { + if (isFinal) { + setTranscript((prev) => (prev ? prev + ' ' + text : text)) + setInterimTranscript('') + } else { + setInterimTranscript(text) + } + onResult?.(text, isFinal) + resetSilenceTimeout() + }, + onError: (err) => { + setError(err) + setIsListening(false) + onError?.(err) + + // Update permission state on denied + if (err.type === 'not-allowed') { + setPermissionState('denied') + } + + // Track if we should auto-restart on network errors + if (autoRestart && err.type === 'network') { + shouldRestartRef.current = true + } + }, + onStart: () => { + setIsListening(true) + setError(null) + shouldRestartRef.current = false + resetSilenceTimeout() + onStart?.() + }, + onEnd: () => { + setIsListening(false) + setInterimTranscript('') + clearSilenceTimeout() + onEnd?.() + + // Auto-restart if flagged + if (shouldRestartRef.current && autoRestart) { + shouldRestartRef.current = false + setTimeout(() => { + recognitionRef.current?.start() + }, 500) + } + }, + onSpeechStart: () => { + resetSilenceTimeout() + }, + onSpeechEnd: () => { + resetSilenceTimeout() + }, + } + ) + }, [ + lang, + continuous, + interimResults, + maxAlternatives, + autoRestart, + onResult, + onError, + onStart, + onEnd, + resetSilenceTimeout, + clearSilenceTimeout, + ]) + + // ============================================================================ + // Actions + // ============================================================================ + + const start = useCallback(async (): Promise => { + if (!isSupported) { + setError({ + type: 'browser-not-supported', + message: 'Speech recognition is not supported in this browser.', + }) + return + } + + // Guard against double-start (React 18 Strict Mode) + if (isStartingRef.current || isListening) { + return + } + isStartingRef.current = true + + try { + createRecognition() + recognitionRef.current?.start() + setPermissionState('granted') + } catch (e) { + // Handle "already started" race condition + if (e instanceof DOMException && e.name === 'InvalidStateError') { + // Already running, ignore + } else { + throw e + } + } finally { + isStartingRef.current = false + } + }, [isSupported, isListening, createRecognition]) + + const stop = useCallback((): void => { + shouldRestartRef.current = false // Prevent auto-restart + recognitionRef.current?.stop() + }, []) + + const abort = useCallback((): void => { + shouldRestartRef.current = false + recognitionRef.current?.abort() + setIsListening(false) + setInterimTranscript('') + }, []) + + const toggle = useCallback(async (): Promise => { + if (isListening) { + stop() + } else { + await start() + } + }, [isListening, start, stop]) + + const clear = useCallback((): void => { + setTranscript('') + setInterimTranscript('') + setError(null) + }, []) + + const requestPermissionAction = useCallback(async (): Promise => { + const state = await requestMicPermission() + setPermissionState(state) + return state + }, []) + + // ============================================================================ + // Cleanup on Unmount + // ============================================================================ + + useEffect(() => { + return () => { + // Use abort() for faster cleanup than stop() + recognitionRef.current?.abort() + clearSilenceTimeout() + } + }, [clearSilenceTimeout]) + + // ============================================================================ + // Return + // ============================================================================ + + return { + // State + transcript, + interimTranscript, + isListening, + isSupported, + permissionState, + error, + + // Actions + start, + stop, + toggle, + abort, + clear, + requestPermission: requestPermissionAction, + } +} +``` + +### src/hooks/index.ts + +```typescript +// Public exports +export { useSpeechInput } from './useSpeechInput' + +// Note: useIsSSR is internal-only, not exported to users +// Users should use framework-specific SSR handling (next/dynamic, 'use client', etc.) +``` + +--- + +## 2.4 Updated Main Export + +### src/index.ts (additions) + +```typescript +// ... existing exports ... + +// Hooks +export { useSpeechInput } from './hooks' +``` + +--- + +## 2.5 Testing Strategy + +### Testing with @testing-library/react + +```bash +# Already installed in Phase 0 +yarn add -D @testing-library/react +``` + +### src/__tests__/useSpeechInput.test.ts + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useSpeechInput } from '../hooks/useSpeechInput' +import { clearCapabilitiesCache } from '../core/browser' + +describe('useSpeechInput', () => { + beforeEach(() => { + clearCapabilitiesCache() + vi.useFakeTimers() + vi.unstubAllGlobals() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('initialization', () => { + it('returns isSupported: false when Speech API is not available', () => { + const { result } = renderHook(() => useSpeechInput()) + + expect(result.current.isSupported).toBe(false) + expect(result.current.isListening).toBe(false) + expect(result.current.transcript).toBe('') + }) + + it('returns isSupported: true when Speech API is available', () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + expect(result.current.isSupported).toBe(true) + }) + }) + + describe('start/stop', () => { + it('sets isListening to true when started', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + // Simulate onstart callback + mockInstance.onstart?.(new Event('start')) + }) + + expect(result.current.isListening).toBe(true) + }) + + it('sets isListening to false when stopped', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + result.current.stop() + mockInstance.onend?.(new Event('end')) + }) + + expect(result.current.isListening).toBe(false) + }) + }) + + describe('transcript handling', () => { + it('accumulates final transcripts', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + // Simulate recognition results + act(() => { + mockInstance.onresult?.(createMockResultEvent('Hello', true)) + }) + + expect(result.current.transcript).toBe('Hello') + + act(() => { + mockInstance.onresult?.(createMockResultEvent('World', true)) + }) + + expect(result.current.transcript).toBe('Hello World') + }) + + it('sets interim transcript for partial results', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + mockInstance.onresult?.(createMockResultEvent('Hel', false)) + }) + + expect(result.current.interimTranscript).toBe('Hel') + expect(result.current.transcript).toBe('') + }) + }) + + describe('clear', () => { + it('clears transcript and error', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + mockInstance.onresult?.(createMockResultEvent('Hello', true)) + }) + + expect(result.current.transcript).toBe('Hello') + + act(() => { + result.current.clear() + }) + + expect(result.current.transcript).toBe('') + expect(result.current.interimTranscript).toBe('') + }) + }) + + describe('toggle', () => { + it('toggles listening state', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + // Toggle on + await act(async () => { + await result.current.toggle() + mockInstance.onstart?.(new Event('start')) + }) + expect(result.current.isListening).toBe(true) + + // Toggle off + act(() => { + result.current.toggle() + mockInstance.onend?.(new Event('end')) + }) + expect(result.current.isListening).toBe(false) + }) + }) + + describe('silenceTimeout', () => { + it('stops recognition after silence timeout', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => + useSpeechInput({ silenceTimeout: 3000 }) + ) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + // Fast-forward past silence timeout + act(() => { + vi.advanceTimersByTime(3000) + }) + + expect(mockInstance.stop).toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('sets error state on recognition error', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + mockInstance.onerror?.({ + error: 'not-allowed', + message: 'Permission denied', + } as SpeechRecognitionErrorEvent) + }) + + expect(result.current.error?.type).toBe('not-allowed') + expect(result.current.permissionState).toBe('denied') + }) + }) + + describe('callbacks', () => { + it('calls onResult callback with transcript', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const onResult = vi.fn() + const { result } = renderHook(() => useSpeechInput({ onResult })) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + mockInstance.onresult?.(createMockResultEvent('Hello', true)) + }) + + expect(onResult).toHaveBeenCalledWith('Hello', true) + }) + + it('calls onStart and onEnd callbacks', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal('SpeechRecognition', vi.fn(() => mockInstance)) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const onStart = vi.fn() + const onEnd = vi.fn() + const { result } = renderHook(() => + useSpeechInput({ onStart, onEnd }) + ) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + expect(onStart).toHaveBeenCalled() + + act(() => { + result.current.stop() + mockInstance.onend?.(new Event('end')) + }) + + expect(onEnd).toHaveBeenCalled() + }) + }) +}) + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockRecognitionInstance() { + return { + lang: '', + continuous: false, + interimResults: true, + maxAlternatives: 1, + start: vi.fn(), + stop: vi.fn(), + abort: vi.fn(), + onstart: null as ((e: Event) => void) | null, + onend: null as ((e: Event) => void) | null, + onerror: null as ((e: SpeechRecognitionErrorEvent) => void) | null, + onresult: null as ((e: SpeechRecognitionEvent) => void) | null, + onspeechstart: null as ((e: Event) => void) | null, + onspeechend: null as ((e: Event) => void) | null, + onnomatch: null as ((e: Event) => void) | null, + } +} + +function createMockResultEvent( + transcript: string, + isFinal: boolean +): SpeechRecognitionEvent { + return { + resultIndex: 0, + results: { + length: 1, + item: () => ({ + length: 1, + isFinal, + item: () => ({ transcript, confidence: 0.9 }), + 0: { transcript, confidence: 0.9 }, + }), + 0: { + length: 1, + isFinal, + item: () => ({ transcript, confidence: 0.9 }), + 0: { transcript, confidence: 0.9 }, + }, + }, + } as unknown as SpeechRecognitionEvent +} +``` + +--- + +## 2.6 Phase 2 Deliverables Checklist + +| Deliverable | Status | File(s) | +|-------------|--------|---------| +| useSpeechInput hook | ⬜ | `src/hooks/useSpeechInput.ts` | +| useIsSSR utility (internal) | ⬜ | `src/hooks/useIsSSR.ts` | +| Hook exports | ⬜ | `src/hooks/index.ts` | +| Updated main exports | ⬜ | `src/index.ts` | +| Hook tests | ⬜ | `src/__tests__/useSpeechInput.test.ts` | + +--- + +## 2.7 Key Implementation Details + +### React 18 Strict Mode Guard + +React 18 double-mounts components in development, which can cause issues with `recognition.start()`. We use a ref guard: + +```typescript +const isStartingRef = useRef(false) + +const start = useCallback(async () => { + if (isStartingRef.current || isListening) return + isStartingRef.current = true + + try { + // ... start logic + } finally { + isStartingRef.current = false + } +}, [isListening]) +``` + +### Cleanup with abort() + +On unmount, we use `abort()` instead of `stop()` because it's faster and doesn't wait for final results: + +```typescript +useEffect(() => { + return () => { + recognitionRef.current?.abort() + clearSilenceTimeout() + } +}, [clearSilenceTimeout]) +``` + +### Transcript Accumulation + +Final transcripts are accumulated with space separation: + +```typescript +setTranscript((prev) => (prev ? prev + ' ' + text : text)) +``` + +--- + +## Verification Plan + +### Automated Tests + +```bash +# Run all tests +yarn test run + +# Run hook tests only +yarn test run src/__tests__/useSpeechInput.test.ts + +# Run with coverage +yarn test:coverage +``` + +### Type Check + +```bash +yarn typecheck +``` + +### Build Verification + +```bash +yarn build +``` + +--- + +## Summary + +Phase 2 delivers the production-ready `useSpeechInput` hook with: + +1. **Full State Management** — transcript, interimTranscript, isListening, error, permissionState +2. **All Actions** — start, stop, toggle, abort, clear, requestPermission +3. **Silence Timeout** — Auto-stop after configurable silence period +4. **Auto-Restart** — Optional restart on network errors +5. **SSR Safety** — useIsSSR utility for server rendering +6. **React 18 Ready** — Strict Mode compatible with guards +7. **Comprehensive Tests** — All states and transitions tested + +Phase 3 will add cursor-aware text insertion functionality. diff --git a/src/__tests__/useSpeechInput.test.ts b/src/__tests__/useSpeechInput.test.ts new file mode 100644 index 0000000..a7483cb --- /dev/null +++ b/src/__tests__/useSpeechInput.test.ts @@ -0,0 +1,567 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSpeechInput } from '../hooks/useSpeechInput' +import { clearCapabilitiesCache } from '../core/browser' + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockRecognitionInstance() { + return { + lang: '', + continuous: false, + interimResults: true, + maxAlternatives: 1, + start: vi.fn(), + stop: vi.fn(), + abort: vi.fn(), + onstart: null as ((e: Event) => void) | null, + onend: null as ((e: Event) => void) | null, + onerror: null as ((e: SpeechRecognitionErrorEvent) => void) | null, + onresult: null as ((e: SpeechRecognitionEvent) => void) | null, + onspeechstart: null as ((e: Event) => void) | null, + onspeechend: null as ((e: Event) => void) | null, + onnomatch: null as ((e: Event) => void) | null, + } +} + +function createMockResultEvent(transcript: string, isFinal: boolean): SpeechRecognitionEvent { + return { + resultIndex: 0, + results: { + length: 1, + item: () => ({ + length: 1, + isFinal, + item: () => ({ transcript, confidence: 0.9 }), + 0: { transcript, confidence: 0.9 }, + }), + 0: { + length: 1, + isFinal, + item: () => ({ transcript, confidence: 0.9 }), + 0: { transcript, confidence: 0.9 }, + }, + }, + } as unknown as SpeechRecognitionEvent +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('useSpeechInput', () => { + beforeEach(() => { + clearCapabilitiesCache() + vi.useFakeTimers() + vi.unstubAllGlobals() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('initialization', () => { + it('returns isSupported: false when Speech API is not available', () => { + const { result } = renderHook(() => useSpeechInput()) + + expect(result.current.isSupported).toBe(false) + expect(result.current.isListening).toBe(false) + expect(result.current.transcript).toBe('') + }) + + it('returns isSupported: true when Speech API is available', () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + expect(result.current.isSupported).toBe(true) + }) + + it('initializes with default values', () => { + const { result } = renderHook(() => useSpeechInput()) + + expect(result.current.transcript).toBe('') + expect(result.current.interimTranscript).toBe('') + expect(result.current.isListening).toBe(false) + expect(result.current.error).toBeNull() + }) + }) + + describe('start/stop', () => { + it('sets isListening to true when started', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + // Simulate onstart callback + mockInstance.onstart?.(new Event('start')) + }) + + expect(result.current.isListening).toBe(true) + }) + + it('sets isListening to false when stopped', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + result.current.stop() + mockInstance.onend?.(new Event('end')) + }) + + expect(result.current.isListening).toBe(false) + }) + + it('sets error when browser not supported and start called', async () => { + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + }) + + expect(result.current.error?.type).toBe('browser-not-supported') + }) + }) + + describe('transcript handling', () => { + it('accumulates final transcripts with space separator', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + // Simulate first recognition result + act(() => { + mockInstance.onresult?.(createMockResultEvent('Hello', true)) + }) + + expect(result.current.transcript).toBe('Hello') + + // Simulate second recognition result + act(() => { + mockInstance.onresult?.(createMockResultEvent('World', true)) + }) + + expect(result.current.transcript).toBe('Hello World') + }) + + it('sets interim transcript for partial results', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + mockInstance.onresult?.(createMockResultEvent('Hel', false)) + }) + + expect(result.current.interimTranscript).toBe('Hel') + expect(result.current.transcript).toBe('') + }) + + it('clears interim transcript on final result', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + // Interim result + act(() => { + mockInstance.onresult?.(createMockResultEvent('Hel', false)) + }) + expect(result.current.interimTranscript).toBe('Hel') + + // Final result + act(() => { + mockInstance.onresult?.(createMockResultEvent('Hello', true)) + }) + expect(result.current.interimTranscript).toBe('') + expect(result.current.transcript).toBe('Hello') + }) + }) + + describe('clear', () => { + it('clears transcript, interimTranscript, and error', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + mockInstance.onresult?.(createMockResultEvent('Hello', true)) + }) + + expect(result.current.transcript).toBe('Hello') + + act(() => { + result.current.clear() + }) + + expect(result.current.transcript).toBe('') + expect(result.current.interimTranscript).toBe('') + expect(result.current.error).toBeNull() + }) + }) + + describe('toggle', () => { + it('starts listening when not listening', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.toggle() + mockInstance.onstart?.(new Event('start')) + }) + + expect(result.current.isListening).toBe(true) + }) + + it('stops listening when already listening', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + // Start first + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + expect(result.current.isListening).toBe(true) + + // Toggle off + await act(async () => { + await result.current.toggle() + mockInstance.onend?.(new Event('end')) + }) + expect(result.current.isListening).toBe(false) + }) + }) + + describe('abort', () => { + it('aborts recognition and resets state', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + result.current.abort() + }) + + expect(mockInstance.abort).toHaveBeenCalled() + expect(result.current.isListening).toBe(false) + expect(result.current.interimTranscript).toBe('') + }) + }) + + describe('silenceTimeout', () => { + it('stops recognition after silence timeout', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput({ silenceTimeout: 3000 })) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + // Fast-forward past silence timeout + act(() => { + vi.advanceTimersByTime(3000) + }) + + expect(mockInstance.stop).toHaveBeenCalled() + }) + + it('does not stop when silenceTimeout is 0', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput({ silenceTimeout: 0 })) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + vi.advanceTimersByTime(10000) + }) + + expect(mockInstance.stop).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('sets error state on recognition error', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + mockInstance.onerror?.({ + error: 'not-allowed', + message: 'Permission denied', + } as unknown as SpeechRecognitionErrorEvent) + }) + + expect(result.current.error?.type).toBe('not-allowed') + expect(result.current.isListening).toBe(false) + }) + + it('updates permission state to denied on not-allowed error', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + mockInstance.onerror?.({ + error: 'not-allowed', + message: 'Permission denied', + } as unknown as SpeechRecognitionErrorEvent) + }) + + expect(result.current.permissionState).toBe('denied') + }) + }) + + describe('callbacks', () => { + it('calls onResult callback with transcript and isFinal', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const onResult = vi.fn() + const { result } = renderHook(() => useSpeechInput({ onResult })) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + mockInstance.onresult?.(createMockResultEvent('Hello', true)) + }) + + expect(onResult).toHaveBeenCalledWith('Hello', true) + }) + + it('calls onStart callback', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const onStart = vi.fn() + const { result } = renderHook(() => useSpeechInput({ onStart })) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + expect(onStart).toHaveBeenCalled() + }) + + it('calls onEnd callback', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const onEnd = vi.fn() + const { result } = renderHook(() => useSpeechInput({ onEnd })) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + result.current.stop() + mockInstance.onend?.(new Event('end')) + }) + + expect(onEnd).toHaveBeenCalled() + }) + + it('calls onError callback on error', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const onError = vi.fn() + const { result } = renderHook(() => useSpeechInput({ onError })) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + act(() => { + mockInstance.onerror?.({ + error: 'network', + message: 'Network error', + } as unknown as SpeechRecognitionErrorEvent) + }) + + expect(onError).toHaveBeenCalled() + expect(onError.mock.calls[0][0].type).toBe('network') + }) + }) + + describe('cleanup', () => { + it('aborts recognition on unmount', async () => { + const mockInstance = createMockRecognitionInstance() + vi.stubGlobal( + 'SpeechRecognition', + vi.fn(() => mockInstance) + ) + vi.stubGlobal('navigator', { userAgent: 'Chrome/120' }) + clearCapabilitiesCache() + + const { result, unmount } = renderHook(() => useSpeechInput()) + + await act(async () => { + await result.current.start() + mockInstance.onstart?.(new Event('start')) + }) + + unmount() + + expect(mockInstance.abort).toHaveBeenCalled() + }) + }) +}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..333231e --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,5 @@ +// Public exports +export { useSpeechInput } from './useSpeechInput' + +// Note: useIsSSR is internal-only, not exported to users +// Users should use framework-specific SSR handling (next/dynamic, 'use client', etc.) diff --git a/src/hooks/useIsSSR.ts b/src/hooks/useIsSSR.ts new file mode 100644 index 0000000..1aee6d4 --- /dev/null +++ b/src/hooks/useIsSSR.ts @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from 'react' + +/** + * Subscribe function that does nothing (client is always "subscribed") + */ +const subscribe = (): (() => void) => () => {} + +/** + * Client snapshot: we're NOT on the server + */ +const getSnapshot = (): boolean => false + +/** + * Server snapshot: we ARE on the server + */ +const getServerSnapshot = (): boolean => true + +/** + * Hook to detect if we're rendering on the server + * Uses useSyncExternalStore for proper React 18 SSR support + * + * @internal This hook is for internal use only + */ +export function useIsSSR(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} diff --git a/src/hooks/useSpeechInput.ts b/src/hooks/useSpeechInput.ts new file mode 100644 index 0000000..584d5c9 --- /dev/null +++ b/src/hooks/useSpeechInput.ts @@ -0,0 +1,307 @@ +import { useState, useRef, useCallback, useEffect } from 'react' +import type { + UseSpeechInputOptions, + UseSpeechInputReturn, + MicPermissionState, + SpeechError, + SpeechRecognitionInstance, +} from '../types' +import { detectBrowserCapabilities } from '../core/browser' +import { + getMicPermissionState, + subscribeToPermissionChanges, + requestMicPermission, +} from '../core/permissions' +import { createRecognitionInstance } from '../core/recognition' + +/** + * Primary hook for speech-to-text functionality + * + * @param options - Configuration options + * @returns Speech input state and actions + * + * @example + * ```tsx + * const { transcript, isListening, start, stop } = useSpeechInput({ + * lang: 'en-US', + * continuous: false, + * silenceTimeout: 3000, + * }) + * ``` + */ +export function useSpeechInput(options: UseSpeechInputOptions = {}): UseSpeechInputReturn { + const { + lang, + continuous = false, + interimResults = true, + maxAlternatives = 1, + silenceTimeout = 3000, + autoRestart = false, + onResult, + onError, + onStart, + onEnd, + } = options + + // ============================================================================ + // State + // ============================================================================ + + const [transcript, setTranscript] = useState('') + const [interimTranscript, setInterimTranscript] = useState('') + const [isListening, setIsListening] = useState(false) + const [error, setError] = useState(null) + const [permissionState, setPermissionState] = useState('prompt') + + // ============================================================================ + // Refs (mutable values that don't trigger re-renders) + // ============================================================================ + + const recognitionRef = useRef(null) + const silenceTimeoutRef = useRef | null>(null) + const isStartingRef = useRef(false) // Guard against React 18 Strict Mode double-mount + const shouldRestartRef = useRef(false) // Track if we should auto-restart + + // ============================================================================ + // Browser Capabilities (computed once, cached) + // ============================================================================ + + const capabilitiesRef = useRef(detectBrowserCapabilities()) + const isSupported = capabilitiesRef.current.isSupported + + // ============================================================================ + // Permission State Sync + // ============================================================================ + + useEffect(() => { + // Get initial permission state + getMicPermissionState().then(setPermissionState) + + // Subscribe to permission changes + const unsubscribe = subscribeToPermissionChanges(setPermissionState) + + return () => { + unsubscribe?.() + } + }, []) + + // ============================================================================ + // Silence Timeout Handler + // ============================================================================ + + const clearSilenceTimeout = useCallback(() => { + if (silenceTimeoutRef.current) { + clearTimeout(silenceTimeoutRef.current) + silenceTimeoutRef.current = null + } + }, []) + + // Start silence timeout unconditionally (for use in onStart where isListening isn't yet true) + const startSilenceTimeout = useCallback(() => { + clearSilenceTimeout() + + if (silenceTimeout > 0) { + silenceTimeoutRef.current = setTimeout(() => { + recognitionRef.current?.stop() + }, silenceTimeout) + } + }, [silenceTimeout, clearSilenceTimeout]) + + // Reset silence timeout (checks isListening for safety in other contexts) + const resetSilenceTimeout = useCallback(() => { + if (isListening) { + startSilenceTimeout() + } + }, [isListening, startSilenceTimeout]) + + // Clear timeout when not listening + useEffect(() => { + if (!isListening) { + clearSilenceTimeout() + } + }, [isListening, clearSilenceTimeout]) + + // ============================================================================ + // Recognition Instance Management + // ============================================================================ + + const createRecognition = useCallback(() => { + // Abort any existing instance + if (recognitionRef.current) { + recognitionRef.current.abort() + recognitionRef.current = null + } + + recognitionRef.current = createRecognitionInstance( + { lang, continuous, interimResults, maxAlternatives }, + { + onResult: (text, isFinal) => { + if (isFinal) { + setTranscript((prev) => (prev ? prev + ' ' + text : text)) + setInterimTranscript('') + } else { + setInterimTranscript(text) + } + onResult?.(text, isFinal) + resetSilenceTimeout() + }, + onError: (err) => { + setError(err) + setIsListening(false) + onError?.(err) + + // Update permission state on denied + if (err.type === 'not-allowed') { + setPermissionState('denied') + } + + // Track if we should auto-restart on network errors + if (autoRestart && err.type === 'network') { + shouldRestartRef.current = true + } + }, + onStart: () => { + setIsListening(true) + setError(null) + shouldRestartRef.current = false + startSilenceTimeout() + onStart?.() + }, + onEnd: () => { + setIsListening(false) + setInterimTranscript('') + clearSilenceTimeout() + onEnd?.() + + // Auto-restart if flagged + if (shouldRestartRef.current && autoRestart) { + shouldRestartRef.current = false + setTimeout(() => { + recognitionRef.current?.start() + }, 500) + } + }, + onSpeechStart: () => { + resetSilenceTimeout() + }, + onSpeechEnd: () => { + resetSilenceTimeout() + }, + } + ) + }, [ + lang, + continuous, + interimResults, + maxAlternatives, + autoRestart, + onResult, + onError, + onStart, + onEnd, + startSilenceTimeout, + resetSilenceTimeout, + clearSilenceTimeout, + ]) + + // ============================================================================ + // Actions + // ============================================================================ + + const start = useCallback(async (): Promise => { + if (!isSupported) { + setError({ + type: 'browser-not-supported', + message: 'Speech recognition is not supported in this browser.', + }) + return + } + + // Guard against double-start (React 18 Strict Mode) + if (isStartingRef.current || isListening) { + return + } + isStartingRef.current = true + + try { + createRecognition() + recognitionRef.current?.start() + setPermissionState('granted') + } catch (e) { + // Handle "already started" race condition + if (e instanceof DOMException && e.name === 'InvalidStateError') { + // Already running, ignore + } else { + throw e + } + } finally { + isStartingRef.current = false + } + }, [isSupported, isListening, createRecognition]) + + const stop = useCallback((): void => { + shouldRestartRef.current = false // Prevent auto-restart + recognitionRef.current?.stop() + }, []) + + const abort = useCallback((): void => { + shouldRestartRef.current = false + recognitionRef.current?.abort() + setIsListening(false) + setInterimTranscript('') + }, []) + + const toggle = useCallback(async (): Promise => { + if (isListening) { + stop() + } else { + await start() + } + }, [isListening, start, stop]) + + const clear = useCallback((): void => { + setTranscript('') + setInterimTranscript('') + setError(null) + }, []) + + const requestPermissionAction = useCallback(async (): Promise => { + const state = await requestMicPermission() + setPermissionState(state) + return state + }, []) + + // ============================================================================ + // Cleanup on Unmount + // ============================================================================ + + useEffect(() => { + return () => { + // Use abort() for faster cleanup than stop() + recognitionRef.current?.abort() + clearSilenceTimeout() + } + }, [clearSilenceTimeout]) + + // ============================================================================ + // Return + // ============================================================================ + + return { + // State + transcript, + interimTranscript, + isListening, + isSupported, + permissionState, + error, + + // Actions + start, + stop, + toggle, + abort, + clear, + requestPermission: requestPermissionAction, + } +} diff --git a/src/index.ts b/src/index.ts index 3764dce..252a365 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,12 @@ export { export { createRecognitionInstance, getErrorMessage, mapErrorType } from './core/recognition' +// ============================================================================ +// Hooks +// ============================================================================ + +export { useSpeechInput } from './hooks' + // ============================================================================ // Version // ============================================================================ diff --git a/yarn.lock b/yarn.lock index 868cff7..a68b651 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,7 +10,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -1019,6 +1019,20 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz#beacb356412eef5dc0164e9edfee51c563732054" integrity sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg== +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + "@testing-library/react@^16.0.0": version "16.3.1" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.1.tgz#60a9f1f6a930399d9e41b506a8bf68dbf4831fe8" @@ -1033,6 +1047,11 @@ dependencies: tslib "^2.4.0" +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/chai@^5.2.2": version "5.2.3" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" @@ -1298,6 +1317,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-styles@^6.0.0, ansi-styles@^6.1.0, ansi-styles@^6.2.1: version "6.2.3" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" @@ -1320,6 +1344,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" @@ -1551,6 +1582,11 @@ defu@^6.1.4: resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + detect-indent@^6.0.0: version "6.1.0" resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6" @@ -1563,6 +1599,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + dotenv@^8.1.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" @@ -2319,6 +2360,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + magic-string@^0.30.17: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" @@ -2566,7 +2612,7 @@ pathval@^2.0.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== -picocolors@^1.1.0, picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -2615,6 +2661,15 @@ prettier@^3.0.0: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f" integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -2642,6 +2697,11 @@ react-dom@^19.0.0: dependencies: scheduler "^0.27.0" +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react@^19.0.0: version "19.2.3" resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"