feat(store): useOptimistic hook for react (#291)

This commit is contained in:
rahim
2026-01-07 00:23:23 +11:00
committed by GitHub
parent ecd4940bc7
commit 8da3ab1b35
6 changed files with 535 additions and 10 deletions
+13 -9
View File
@@ -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
+6
View File
@@ -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';
@@ -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<AsyncMockMedia>()({
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<MockMedia>()({
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<number> => {
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<boolean> => {
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 };
}
@@ -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<number>;
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<unknown>;
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<number>;
let promise2: Promise<number>;
let promise3: Promise<number>;
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<number>;
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<number>;
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<number>;
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<boolean>;
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');
});
});
});
@@ -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 (
* <input
* type="range"
* value={result.value}
* onChange={(e) => result.setValue(Number(e.target.value))}
* style={{ opacity: result.status === 'pending' ? 0.5 : 1 }}
* />
* );
* }
* ```
*/
export function useOptimistic<
Store extends AnyStore,
Name extends keyof InferStoreRequests<Store>,
Value,
Request extends InferStoreRequests<Store>[Name] = InferStoreRequests<Store>[Name],
>(
store: Store,
name: Name,
selector: (state: InferStoreState<Store>) => Value,
): OptimisticResult<Value, (value: Value) => ReturnType<EnsureFunction<Request>>> {
// 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<Value | null>(null);
const taskRef = useRef<Task | undefined>(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<EnsureFunction<Request>> => {
optimisticRef.current = newValue;
forceUpdate();
const request = store.request[name] as (value: Value) => ReturnType<EnsureFunction<Request>>;
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<Value, SetValue> = OptimisticResult<Value, SetValue>;
}
+1 -1
View File
@@ -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';