From 8da3ab1b352d595468d6bdea8f911dc0e4a451ec Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 7 Jan 2026 00:23:23 +1100 Subject: [PATCH] feat(store): useOptimistic hook for react (#291) --- .claude/plans/store-bindings.md | 22 +- packages/store/src/react/hooks/index.ts | 6 + .../store/src/react/hooks/tests/test-utils.ts | 65 ++++ .../react/hooks/tests/use-optimistic.test.tsx | 321 ++++++++++++++++++ .../store/src/react/hooks/use-optimistic.ts | 129 +++++++ packages/store/src/react/index.ts | 2 +- 6 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 packages/store/src/react/hooks/tests/use-optimistic.test.tsx create mode 100644 packages/store/src/react/hooks/use-optimistic.ts diff --git a/.claude/plans/store-bindings.md b/.claude/plans/store-bindings.md index 9d6e6038..bb41ade8 100644 --- a/.claude/plans/store-bindings.md +++ b/.claude/plans/store-bindings.md @@ -579,12 +579,14 @@ packages/store/src/ │ │ ├── use-request.ts │ │ ├── use-tasks.ts │ │ ├── use-mutation.ts +│ │ ├── use-optimistic.ts # DONE (PR #291) │ │ └── tests/ │ │ ├── test-utils.ts │ │ ├── use-selector.test.tsx │ │ ├── use-request.test.tsx │ │ ├── use-tasks.test.tsx -│ │ └── use-mutation.test.tsx +│ │ ├── use-mutation.test.tsx +│ │ └── use-optimistic.test.tsx # DONE (PR #291) │ └── index.ts └── lit/ ├── create-store.ts # DONE (StoreMixin, StoreProviderMixin, StoreAttachMixin, context) @@ -675,10 +677,11 @@ packages/html/src/ - Shared types: `MutationResult` discriminated union in `src/shared/types.ts` ✓ - Hooks split into `src/react/hooks/` directory ✓ -7. **Phase 5**: Optimistic Hooks/Controllers - - React: `useOptimistic(store, name, stateSelector)` - base hook - - Lit: `OptimisticController(host, store, name, stateSelector)` - - Shared types: `OptimisticResult` discriminated union +7. **Phase 5**: Optimistic Hooks/Controllers **[DONE - PR #291]** + - React: `useOptimistic(store, name, stateSelector)` - base hook ✓ + - Lit: `OptimisticController(host, store, name, stateSelector)` - already existed ✓ + - Shared types: `OptimisticResult` discriminated union in `src/shared/types.ts` ✓ + - Tests: 13 new tests for React hook ✓ 8. **Phase 6**: Skins - React skin (Provider, Skin, extendConfig) @@ -865,10 +868,11 @@ PR #290: Mutation Hooks/Controllers [DONE] ├── Hooks split into src/react/hooks/ directory ✓ └── References #228 -PR #291: Optimistic Hooks/Controllers -├── React: useOptimistic(store, name, stateSelector) -├── Lit: OptimisticController(host, store, name, stateSelector) -├── Shared types: OptimisticResult discriminated union +PR #291: Optimistic Hooks/Controllers [DONE] +├── React: useOptimistic(store, name, stateSelector) - base hook ✓ +├── Lit: OptimisticController(host, store, name, stateSelector) - already existed ✓ +├── Shared types: OptimisticResult discriminated union in src/shared/types.ts ✓ +├── Tests: 13 new tests for React hook ✓ └── Closes #228 PR F: Skins diff --git a/packages/store/src/react/hooks/index.ts b/packages/store/src/react/hooks/index.ts index 746ff42e..b2cb3708 100644 --- a/packages/store/src/react/hooks/index.ts +++ b/packages/store/src/react/hooks/index.ts @@ -5,9 +5,15 @@ export type { MutationPending, MutationResult, MutationSuccess, + OptimisticError, + OptimisticIdle, + OptimisticPending, + OptimisticResult, + OptimisticSuccess, } from '../../shared/types'; export { useMutation } from './use-mutation'; +export { useOptimistic } from './use-optimistic'; export { useRequest } from './use-request'; export { useSelector } from './use-selector'; export { useTasks } from './use-tasks'; diff --git a/packages/store/src/react/hooks/tests/test-utils.ts b/packages/store/src/react/hooks/tests/test-utils.ts index 9560a061..93d99c85 100644 --- a/packages/store/src/react/hooks/tests/test-utils.ts +++ b/packages/store/src/react/hooks/tests/test-utils.ts @@ -1,4 +1,5 @@ import { noop } from '@videojs/utils/function'; + import { createSlice } from '../../../core/slice'; import { createStore as createCoreStore } from '../../../core/store'; @@ -70,12 +71,26 @@ export const asyncAudioSlice = createSlice()({ return volume; }, }, + slowSetVolume: { + handler: async (volume: number, { target }) => { + await new Promise(resolve => setTimeout(resolve, 50)); + target.volume = volume; + target.dispatchEvent(new Event('volumechange')); + return volume; + }, + }, failingRequest: { handler: async () => { await Promise.resolve(); throw new Error('Request failed'); }, }, + failingSetVolume: { + handler: async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + throw new Error('Test error'); + }, + }, }, }); @@ -90,3 +105,53 @@ export function createAsyncTestStore() { return { store, target }; } + +/** Slice with custom keys (name !== key) for testing superseding behavior. */ +export const customKeySlice = createSlice()({ + initialState: { volume: 1, muted: false }, + getSnapshot: ({ target }) => ({ + volume: target.volume, + muted: target.muted, + }), + subscribe: ({ target, update, signal }) => { + const handler = () => update(); + target.addEventListener('volumechange', handler); + signal.addEventListener('abort', () => { + target.removeEventListener('volumechange', handler); + }); + }, + request: { + // name='adjustVolume', key='audio-settings' + adjustVolume: { + key: 'audio-settings', + handler: async (volume: number, { target }): Promise => { + await new Promise(resolve => setTimeout(resolve, 20)); + target.volume = volume; + target.dispatchEvent(new Event('volumechange')); + return volume; + }, + }, + // name='toggleMute', key='audio-settings' (same key - will supersede adjustVolume) + toggleMute: { + key: 'audio-settings', + handler: async (muted: boolean, { target }): Promise => { + await new Promise(resolve => setTimeout(resolve, 20)); + target.muted = muted; + target.dispatchEvent(new Event('volumechange')); + return muted; + }, + }, + }, +}); + +export function createCustomKeyTestStore() { + const store = createCoreStore({ + slices: [customKeySlice], + onError: noop, + }); + + const target = new MockMedia(); + store.attach(target); + + return { store, target }; +} diff --git a/packages/store/src/react/hooks/tests/use-optimistic.test.tsx b/packages/store/src/react/hooks/tests/use-optimistic.test.tsx new file mode 100644 index 00000000..98ca4de3 --- /dev/null +++ b/packages/store/src/react/hooks/tests/use-optimistic.test.tsx @@ -0,0 +1,321 @@ +import { act, renderHook } from '@testing-library/react'; + +import { describe, expect, it, vi } from 'vitest'; + +import { useOptimistic } from '../use-optimistic'; +import { createAsyncTestStore, createCustomKeyTestStore, createTestStore } from './test-utils'; + +describe('useOptimistic', () => { + it('returns actual value initially with idle status', () => { + const { store } = createAsyncTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); + + expect(result.current.value).toBe(1); + expect(result.current.status).toBe('idle'); + expect(typeof result.current.setValue).toBe('function'); + expect(typeof result.current.reset).toBe('function'); + }); + + it('shows optimistic value immediately while pending', async () => { + const { store } = createAsyncTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'slowSetVolume', s => s.volume)); + + // Start the request but don't await + let promise: Promise; + act(() => { + promise = result.current.setValue(0.3); + }); + + // Optimistic value shown immediately + expect(result.current.value).toBe(0.3); + + // Wait for task to start + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + expect(result.current.status).toBe('pending'); + expect(result.current.value).toBe(0.3); + + // Wait for completion + await act(async () => { + await promise; + }); + + expect(result.current.status).toBe('success'); + expect(result.current.value).toBe(0.3); + }); + + it('updates to actual value after mutation completes', async () => { + const { store } = createAsyncTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); + + await act(async () => { + await result.current.setValue(0.5); + }); + + expect(result.current.status).toBe('success'); + expect(result.current.value).toBe(0.5); + }); + + it('reverts to actual value on error', async () => { + const { store } = createAsyncTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'failingSetVolume', s => s.volume)); + + // Start the request + let promise: Promise; + act(() => { + promise = result.current.setValue(0.5); + }); + + // Optimistic value shown immediately + expect(result.current.value).toBe(0.5); + + // Wait for error + await act(async () => { + try { + await promise; + } catch { + // Expected + } + }); + + // After error, shows actual value (reverted) and error status + expect(result.current.status).toBe('error'); + expect(result.current.value).toBe(1); // Original value + if (result.current.status === 'error') { + expect(result.current.error).toBeInstanceOf(Error); + } + }); + + it('reset clears optimistic state', async () => { + const { store } = createAsyncTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); + + await act(async () => { + await result.current.setValue(0.5); + }); + + expect(result.current.status).toBe('success'); + + await act(async () => { + result.current.reset(); + }); + + expect(result.current.status).toBe('idle'); + expect(result.current.value).toBe(0.5); // Actual value after successful mutation + }); + + it('reset when idle is safe', () => { + const { store } = createAsyncTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); + + expect(result.current.status).toBe('idle'); + + // Reset when idle should not throw + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe('idle'); + expect(result.current.value).toBe(1); + }); + + it('triggers re-render when state changes externally', async () => { + const { store, target } = createAsyncTestStore(); + const renderCount = vi.fn(); + + const { result } = renderHook(() => { + renderCount(); + return useOptimistic(store, 'setVolume', s => s.volume); + }); + + expect(renderCount).toHaveBeenCalledTimes(1); + expect(result.current.value).toBe(1); + + // Change volume externally + await act(async () => { + target.volume = 0.8; + target.dispatchEvent(new Event('volumechange')); + }); + + expect(renderCount.mock.calls.length).toBeGreaterThan(1); + expect(result.current.value).toBe(0.8); + }); + + it('handles rapid setValue calls (superseding)', async () => { + const { store } = createAsyncTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'slowSetVolume', s => s.volume)); + + // Fire multiple rapid calls + let promise1: Promise; + let promise2: Promise; + let promise3: Promise; + + act(() => { + promise1 = result.current.setValue(0.3); + promise2 = result.current.setValue(0.5); + promise3 = result.current.setValue(0.7); + }); + + // Should show latest optimistic value + expect(result.current.value).toBe(0.7); + + // First two get superseded + await act(async () => { + await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' }); + await expect(promise2).rejects.toMatchObject({ code: 'SUPERSEDED' }); + await promise3; + }); + + expect(result.current.status).toBe('success'); + expect(result.current.value).toBe(0.7); + }); + + it('setValue function is stable across renders', () => { + const { store } = createAsyncTestStore(); + + const { result, rerender } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); + + const firstSetValue = result.current.setValue; + rerender(); + + expect(result.current.setValue).toBe(firstSetValue); + }); + + it('works with synchronous requests', async () => { + const { store } = createTestStore(); + + const { result } = renderHook(() => useOptimistic(store, 'setVolume', s => s.volume)); + + await act(async () => { + await result.current.setValue(0.5); + }); + + expect(result.current.status).toBe('success'); + expect(result.current.value).toBe(0.5); + }); + + describe('custom key (name !== key)', () => { + it('tracks task by name when key differs', async () => { + const { store } = createCustomKeyTestStore(); + + // adjustVolume has name='adjustVolume' but key='audio-settings' + const { result } = renderHook(() => useOptimistic(store, 'adjustVolume', s => s.volume)); + + let promise: Promise; + act(() => { + promise = result.current.setValue(0.5); + }); + + // Optimistic value shown immediately + expect(result.current.value).toBe(0.5); + + // Wait for task to start + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + + expect(result.current.status).toBe('pending'); + + await act(async () => { + await promise; + }); + + expect(result.current.status).toBe('success'); + expect(result.current.value).toBe(0.5); + }); + + it('tracks correct task when multiple requests share same key', async () => { + const { store } = createCustomKeyTestStore(); + + // Both adjustVolume and toggleMute have key='audio-settings' + const { result: volumeResult } = renderHook(() => useOptimistic(store, 'adjustVolume', s => s.volume)); + + const { result: muteResult } = renderHook(() => useOptimistic(store, 'toggleMute', s => s.muted)); + + // Start volume adjustment + let volumePromise: Promise; + act(() => { + volumePromise = volumeResult.current.setValue(0.5); + }); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + + // Volume controller should be pending + expect(volumeResult.current.status).toBe('pending'); + expect(volumeResult.current.value).toBe(0.5); // Optimistic + + // Mute controller should be idle (different name, even though same key) + expect(muteResult.current.status).toBe('idle'); + expect(muteResult.current.value).toBe(false); // Actual + + await act(async () => { + await volumePromise; + }); + + expect(volumeResult.current.status).toBe('success'); + expect(muteResult.current.status).toBe('idle'); + }); + + it('superseded task shows error status', async () => { + const { store } = createCustomKeyTestStore(); + + const { result: volumeResult } = renderHook(() => useOptimistic(store, 'adjustVolume', s => s.volume)); + + const { result: muteResult } = renderHook(() => useOptimistic(store, 'toggleMute', s => s.muted)); + + // Start volume adjustment + let volumePromise: Promise; + act(() => { + volumePromise = volumeResult.current.setValue(0.5); + }); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + + // Start mute toggle - this will supersede volume because same key + let mutePromise: Promise; + act(() => { + mutePromise = muteResult.current.setValue(true); + }); + + // Mute shows optimistic immediately + expect(muteResult.current.value).toBe(true); + + // Volume task was superseded + await act(async () => { + try { + await volumePromise; + } catch { + // Expected - task was superseded + } + }); + + // Wait for subscription callbacks to fire + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + // Tasks are keyed by name, so superseded task shows error status + expect(volumeResult.current.status).toBe('error'); + + // Complete the mute operation + await act(async () => { + await mutePromise; + }); + + expect(muteResult.current.status).toBe('success'); + }); + }); +}); diff --git a/packages/store/src/react/hooks/use-optimistic.ts b/packages/store/src/react/hooks/use-optimistic.ts new file mode 100644 index 00000000..158d79d0 --- /dev/null +++ b/packages/store/src/react/hooks/use-optimistic.ts @@ -0,0 +1,129 @@ +import type { EnsureFunction } from '@videojs/utils/types'; +import type { Task } from '../../core/queue'; +import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store'; +import type { OptimisticResult } from '../../shared/types'; + +import { useCallback, useReducer, useRef, useSyncExternalStore } from 'react'; + +/** + * Track a store request with optimistic updates. + * + * Shows the optimistic value immediately while the request is pending, + * then updates to the actual value on success or reverts on error. + * + * Returns a discriminated union — use `status` to narrow the type. + * + * @param store - The store instance containing the request + * @param name - The request name to track (type-safe with autocomplete) + * @param selector - Function to select the value from store state + * @returns A discriminated union with the current optimistic state + * + * @example + * ```tsx + * function VolumeSlider() { + * const result = useOptimistic(store, 'setVolume', s => s.volume); + * + * return ( + * result.setValue(Number(e.target.value))} + * style={{ opacity: result.status === 'pending' ? 0.5 : 1 }} + * /> + * ); + * } + * ``` + */ +export function useOptimistic< + Store extends AnyStore, + Name extends keyof InferStoreRequests, + Value, + Request extends InferStoreRequests[Name] = InferStoreRequests[Name], +>( + store: Store, + name: Name, + selector: (state: InferStoreState) => Value, +): OptimisticResult ReturnType>> { + // Force update mechanism for optimistic value changes + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + + // Track optimistic value (null = use actual state) + const optimisticRef = useRef(null); + const taskRef = useRef(store.queue.tasks[name]); + + // Subscribe to store state for actual value + const subscribeToState = useCallback( + (onStoreChange: () => void) => store.subscribe(selector, onStoreChange), + [store, selector], + ); + + const getStateSnapshot = useCallback(() => selector(store.state), [store, selector]); + + const actualValue = useSyncExternalStore(subscribeToState, getStateSnapshot, getStateSnapshot); + + // Subscribe to task queue for status + const subscribeToQueue = useCallback( + (onStoreChange: () => void) => + store.queue.subscribe((tasks) => { + const newTask = tasks[name]; + if (newTask !== taskRef.current) { + taskRef.current = newTask; + + // Clear optimistic value when task settles + if (optimisticRef.current !== null && newTask?.status !== 'pending') { + optimisticRef.current = null; + } + + onStoreChange(); + } + }), + [store, name], + ); + + const getQueueSnapshot = useCallback(() => taskRef.current, []); + + const task = useSyncExternalStore(subscribeToQueue, getQueueSnapshot, getQueueSnapshot); + + // setValue: set optimistic value and call request + const setValueRef = useRef((newValue: Value): ReturnType> => { + optimisticRef.current = newValue; + forceUpdate(); + + const request = store.request[name] as (value: Value) => ReturnType>; + return request(newValue); + }); + + // reset: clear optimistic value and reset task + const resetRef = useRef(() => { + optimisticRef.current = null; + forceUpdate(); + + taskRef.current = store.queue.tasks[name]; + if (taskRef.current) store.queue.reset(name); + }); + + // Build result with discriminated union + const value = optimisticRef.current !== null ? optimisticRef.current : actualValue; + const base = { + value, + setValue: setValueRef.current, + reset: resetRef.current, + }; + + if (task?.status === 'error') { + return { + status: 'error', + ...base, + error: task.error, + }; + } + + return { + status: task?.status ?? 'idle', + ...base, + }; +} + +export namespace useOptimistic { + export type Result = OptimisticResult; +} diff --git a/packages/store/src/react/index.ts b/packages/store/src/react/index.ts index 368adfa5..23076328 100644 --- a/packages/store/src/react/index.ts +++ b/packages/store/src/react/index.ts @@ -3,4 +3,4 @@ export { useStoreContext } from './context'; export { createStore } from './create-store'; export type { CreateStoreConfig, CreateStoreResult, ProviderProps } from './create-store'; -export { useMutation, useRequest, useSelector, useTasks } from './hooks'; +export { useMutation, useOptimistic, useRequest, useSelector, useTasks } from './hooks';