mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(store): add reactive state primitives (#311)
This commit is contained in:
@@ -9,11 +9,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { Store } from '../core/store';
|
||||
import { StoreContextProvider, useParentStore, useStoreContext } from './context';
|
||||
import {
|
||||
useRequest as useRequestBase,
|
||||
useSelector as useSelectorBase,
|
||||
useTasks as useTasksBase,
|
||||
} from './hooks';
|
||||
import { useRequest as useRequestBase, useSnapshot as useSnapshotBase, useTasks as useTasksBase } from './hooks';
|
||||
|
||||
// ----------------------------------------
|
||||
// Types
|
||||
@@ -54,10 +50,10 @@ export interface CreateStoreResult<Slices extends AnySlice[]> {
|
||||
useStore: () => Store<UnionSliceTarget<Slices>, Slices>;
|
||||
|
||||
/**
|
||||
* Subscribes to a selected portion of state.
|
||||
* Re-renders only when the selected value changes.
|
||||
* Returns a snapshot of the store state.
|
||||
* Re-renders when state changes.
|
||||
*/
|
||||
useSelector: <T>(selector: (state: UnionSliceState<Slices>) => T) => T;
|
||||
useSnapshot: () => UnionSliceState<Slices>;
|
||||
|
||||
/**
|
||||
* Returns the request map or a specific request by name.
|
||||
@@ -92,7 +88,7 @@ export interface CreateStoreResult<Slices extends AnySlice[]> {
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { Provider, useStore, useSelector, useRequest, useTasks, create } = createStore({
|
||||
* const { Provider, useStore, useSnapshot, useRequest, useTasks, create } = createStore({
|
||||
* slices: [playbackSlice, presentationSlice],
|
||||
* });
|
||||
* ```
|
||||
@@ -155,9 +151,9 @@ export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig
|
||||
return useStoreContext() as StoreType;
|
||||
}
|
||||
|
||||
function useSelector<T>(selector: (state: State) => T): T {
|
||||
function useSnapshot(): State {
|
||||
const store = useStore();
|
||||
return useSelectorBase(store, selector);
|
||||
return useSnapshotBase(store.state) as State;
|
||||
}
|
||||
|
||||
function useRequest(): Requests;
|
||||
@@ -175,7 +171,7 @@ export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig
|
||||
return {
|
||||
Provider,
|
||||
useStore,
|
||||
useSelector,
|
||||
useSnapshot,
|
||||
useRequest: useRequest as CreateStoreResult<Slices>['useRequest'],
|
||||
useTasks,
|
||||
create,
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
export type {
|
||||
AsyncStatus,
|
||||
MutationError,
|
||||
MutationIdle,
|
||||
MutationPending,
|
||||
MutationResult,
|
||||
MutationSuccess,
|
||||
OptimisticError,
|
||||
OptimisticIdle,
|
||||
OptimisticPending,
|
||||
OptimisticResult,
|
||||
OptimisticSuccess,
|
||||
} from '../../shared/types';
|
||||
export type { AsyncStatus } from '../../shared/types';
|
||||
|
||||
export { useMutation } from './use-mutation';
|
||||
export { useOptimistic } from './use-optimistic';
|
||||
export { useRequest } from './use-request';
|
||||
export { useSelector } from './use-selector';
|
||||
export { useSnapshot } from './use-snapshot';
|
||||
export { useTasks } from './use-tasks';
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useMutation } from '../use-mutation';
|
||||
import { createAsyncTestStore, createTestStore } from './test-utils';
|
||||
|
||||
describe('useMutation', () => {
|
||||
it('returns mutation result with idle status initially', () => {
|
||||
const { store } = createAsyncTestStore();
|
||||
|
||||
const { result } = renderHook(() => useMutation(store, 'setVolume'));
|
||||
|
||||
expect(result.current.status).toBe('idle');
|
||||
expect(typeof result.current.mutate).toBe('function');
|
||||
expect(typeof result.current.reset).toBe('function');
|
||||
});
|
||||
|
||||
it('updates to success status after successful mutation', async () => {
|
||||
const { store } = createAsyncTestStore();
|
||||
|
||||
const { result } = renderHook(() => useMutation(store, 'setVolume'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutate(0.5);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe('success');
|
||||
if (result.current.status === 'success') {
|
||||
expect(result.current.data).toBe(0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates to error status after failed mutation', async () => {
|
||||
const { store } = createAsyncTestStore();
|
||||
|
||||
const { result } = renderHook(() => useMutation(store, 'failingRequest'));
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutate();
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe('error');
|
||||
if (result.current.status === 'error') {
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect((result.current.error as Error).message).toBe('Request failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('reset clears settled state', async () => {
|
||||
const { store } = createAsyncTestStore();
|
||||
|
||||
const { result } = renderHook(() => useMutation(store, 'setVolume'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutate(0.5);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe('success');
|
||||
|
||||
await act(async () => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('re-renders only when task status changes', async () => {
|
||||
const { store } = createAsyncTestStore();
|
||||
const renderCount = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
renderCount();
|
||||
return useMutation(store, 'setVolume');
|
||||
});
|
||||
|
||||
expect(renderCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutate(0.5);
|
||||
});
|
||||
|
||||
// Should have re-rendered for pending and success
|
||||
expect(renderCount.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('mutate function is stable across renders', () => {
|
||||
const { store } = createAsyncTestStore();
|
||||
|
||||
const { result, rerender } = renderHook(() => useMutation(store, 'setVolume'));
|
||||
|
||||
const firstMutate = result.current.mutate;
|
||||
rerender();
|
||||
|
||||
expect(result.current.mutate).toBe(firstMutate);
|
||||
});
|
||||
|
||||
it('works with synchronous requests', async () => {
|
||||
const { store } = createTestStore();
|
||||
|
||||
const { result } = renderHook(() => useMutation(store, 'setVolume'));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutate(0.5);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe('success');
|
||||
if (result.current.status === 'success') {
|
||||
expect(result.current.data).toBe(0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,321 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useSelector } from '../use-selector';
|
||||
import { createTestStore } from './test-utils';
|
||||
|
||||
describe('useSelector', () => {
|
||||
it('returns selected state', () => {
|
||||
const { store } = createTestStore();
|
||||
|
||||
const { result } = renderHook(() => useSelector(store, s => s.volume));
|
||||
|
||||
expect(result.current).toBe(1);
|
||||
});
|
||||
|
||||
it('re-renders when selected state changes', async () => {
|
||||
const { store, target } = createTestStore();
|
||||
|
||||
const { result } = renderHook(() => useSelector(store, s => s.volume));
|
||||
|
||||
expect(result.current).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
target.volume = 0.5;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(0.5);
|
||||
});
|
||||
|
||||
it('does not re-render when unrelated state changes', async () => {
|
||||
const { store, target } = createTestStore();
|
||||
const renderCount = vi.fn();
|
||||
|
||||
renderHook(() => {
|
||||
renderCount();
|
||||
return useSelector(store, s => s.volume);
|
||||
});
|
||||
|
||||
expect(renderCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
target.muted = true;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
});
|
||||
|
||||
// Should not re-render because volume didn't change
|
||||
expect(renderCount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { EnsureFunction } from '@videojs/utils/types';
|
||||
import type { AnyStore, InferStoreRequests } from '../../core/store';
|
||||
import type { Task } from '../../core/task';
|
||||
import type { MutationResult } from '../../shared/types';
|
||||
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Track a store request as a mutation with status, data, and error.
|
||||
*
|
||||
* Subscribes to the task queue and re-renders when the mutation's status changes.
|
||||
*
|
||||
* Returns a discriminated union — use `status` to narrow the type and access
|
||||
* `data` (on success) or `error` (on failure).
|
||||
*
|
||||
* @param store - The store instance containing the request
|
||||
* @param name - The request name to track (type-safe with autocomplete)
|
||||
* @returns A discriminated union with the mutation's current state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SourceSelector() {
|
||||
* const source = useMutation(store, 'setSource');
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <select
|
||||
* onChange={(e) => source.mutate({ src: e.target.value, type: 'video/mp4' })}
|
||||
* disabled={source.status === 'pending'}
|
||||
* >
|
||||
* <option value="/videos/720p.mp4">720p</option>
|
||||
* <option value="/videos/1080p.mp4">1080p</option>
|
||||
* </select>
|
||||
* {source.status === 'error' && (
|
||||
* <p className="error">Failed to load: {String(source.error)}</p>
|
||||
* )}
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMutation<
|
||||
Store extends AnyStore,
|
||||
Name extends keyof InferStoreRequests<Store>,
|
||||
Mutate extends InferStoreRequests<Store>[Name] = InferStoreRequests<Store>[Name],
|
||||
>(store: Store, name: Name): MutationResult<Mutate, Awaited<ReturnType<EnsureFunction<Mutate>>>> {
|
||||
type Data = Awaited<ReturnType<EnsureFunction<Mutate>>>;
|
||||
|
||||
const taskRef = useRef<Task | undefined>(store.queue.tasks[name]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) =>
|
||||
store.queue.subscribe((tasks) => {
|
||||
const newTask = tasks[name];
|
||||
if (newTask !== taskRef.current) {
|
||||
taskRef.current = newTask;
|
||||
onStoreChange();
|
||||
}
|
||||
}),
|
||||
[store, name],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => taskRef.current, []);
|
||||
|
||||
const task = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const resetRef = useRef(() => store.queue.reset(name));
|
||||
|
||||
const base = {
|
||||
mutate: store.request[name] as Mutate,
|
||||
reset: resetRef.current,
|
||||
};
|
||||
|
||||
if (task?.status === 'success') {
|
||||
return {
|
||||
status: 'success',
|
||||
...base,
|
||||
data: task.output as Data,
|
||||
};
|
||||
}
|
||||
|
||||
if (task?.status === 'error') {
|
||||
return {
|
||||
status: 'error',
|
||||
...base,
|
||||
error: task.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: task?.status ?? 'idle',
|
||||
...base,
|
||||
} as MutationResult<Mutate, Data>;
|
||||
}
|
||||
|
||||
export namespace useMutation {
|
||||
export type Result<Mutate, Data> = MutationResult<Mutate, Data>;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { EnsureFunction } from '@videojs/utils/types';
|
||||
import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store';
|
||||
import type { Task } from '../../core/task';
|
||||
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,34 +0,0 @@
|
||||
import type { AnyStore, InferStoreState } from '../../core/store';
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Subscribe to a slice of store state.
|
||||
*
|
||||
* Only re-renders when the selected value changes (shallow comparison).
|
||||
*
|
||||
* The selector function should return a stable reference for objects
|
||||
* to avoid unnecessary re-renders.
|
||||
*
|
||||
* @param store - The store instance to subscribe to
|
||||
* @param selector - Function that extracts the desired value from state
|
||||
* @returns The selected value, updated when it changes
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function VolumeDisplay() {
|
||||
* const volume = useSelector(store, (s) => s.volume);
|
||||
* return <span>{Math.round(volume * 100)}%</span>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSelector<S extends AnyStore, T>(store: S, selector: (state: InferStoreState<S>) => T): T {
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => store.subscribe(selector, onStoreChange),
|
||||
[store, selector],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => selector(store.state), [store, selector]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Reactive } from '../../core/state';
|
||||
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { track } from '../../core/state';
|
||||
|
||||
/**
|
||||
* Subscribe to reactive state and re-render when accessed properties change.
|
||||
*
|
||||
* Automatically tracks which properties are accessed during render and only
|
||||
* re-renders when those specific properties change.
|
||||
*
|
||||
* @param state - Reactive state created by `reactive()`
|
||||
* @returns The state, which triggers re-renders when accessed properties change
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function VolumeDisplay() {
|
||||
* const state = useSnapshot(store.state);
|
||||
* return <span>{Math.round(state.volume * 100)}%</span>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSnapshot<T extends object>(state: Reactive<T>): T {
|
||||
const [{ tracked, subscribe, getSnapshot, next }] = useState(() => track(state));
|
||||
|
||||
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
next();
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export namespace useSnapshot {
|
||||
export type Result<T extends object> = T;
|
||||
}
|
||||
@@ -3,15 +3,14 @@ import type { AnyStore, InferStoreTasks } from '../../core/store';
|
||||
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { subscribe } from '../../core/state';
|
||||
|
||||
/**
|
||||
* Subscribe to task queue state.
|
||||
*
|
||||
* Returns a record of all tasks keyed by request name.
|
||||
* Re-renders when any task is added, updated, or removed.
|
||||
*
|
||||
* For tracking a single mutation, prefer `useMutation` which provides a more ergonomic API with
|
||||
* status helpers.
|
||||
*
|
||||
* @param store - The store instance to subscribe to
|
||||
* @returns Record of tasks keyed by request name
|
||||
*
|
||||
@@ -33,18 +32,21 @@ import { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
* ```
|
||||
*/
|
||||
export function useTasks<S extends AnyStore>(store: S): TasksRecord<InferStoreTasks<S>> {
|
||||
const tasksRef = useRef(store.queue.tasks);
|
||||
const versionRef = useRef(0);
|
||||
|
||||
const subscribe = useCallback(
|
||||
const subscribeToQueue = useCallback(
|
||||
(onStoreChange: () => void) =>
|
||||
store.queue.subscribe((tasks) => {
|
||||
tasksRef.current = tasks;
|
||||
subscribe(store.queue.tasks, () => {
|
||||
versionRef.current++;
|
||||
onStoreChange();
|
||||
}),
|
||||
[store],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => tasksRef.current as TasksRecord<InferStoreTasks<S>>, []);
|
||||
const getSnapshot = useCallback(() => versionRef.current, []);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
useSyncExternalStore(subscribeToQueue, getSnapshot, getSnapshot);
|
||||
|
||||
// Return the tasks proxy directly
|
||||
return store.queue.tasks as TasksRecord<InferStoreTasks<S>>;
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@ export { useStoreContext } from './context';
|
||||
export { createStore } from './create-store';
|
||||
export type { CreateStoreConfig, CreateStoreResult, ProviderProps } from './create-store';
|
||||
|
||||
export { useMutation, useOptimistic, useRequest, useSelector, useTasks } from './hooks';
|
||||
export { useRequest, useSnapshot, useTasks } from './hooks';
|
||||
|
||||
@@ -143,38 +143,38 @@ describe('createStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSelector', () => {
|
||||
it('selects state from context store', () => {
|
||||
const { Provider, useSelector, create } = createStore({ slices: [audioSlice] });
|
||||
describe('useSnapshot', () => {
|
||||
it('returns state from context store', () => {
|
||||
const { Provider, useSnapshot, create } = createStore({ slices: [audioSlice] });
|
||||
const store = create();
|
||||
const target = new MockMedia();
|
||||
store.attach(target);
|
||||
|
||||
const { result } = renderHook(() => useSelector(s => s.volume), {
|
||||
const { result } = renderHook(() => useSnapshot(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => <Provider store={store}>{children}</Provider>,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(1);
|
||||
expect(result.current.volume).toBe(1);
|
||||
});
|
||||
|
||||
it('updates when state changes', async () => {
|
||||
const { Provider, useSelector, create } = createStore({ slices: [audioSlice] });
|
||||
const { Provider, useSnapshot, create } = createStore({ slices: [audioSlice] });
|
||||
const store = create();
|
||||
const target = new MockMedia();
|
||||
store.attach(target);
|
||||
|
||||
const { result } = renderHook(() => useSelector(s => s.volume), {
|
||||
const { result } = renderHook(() => useSnapshot(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => <Provider store={store}>{children}</Provider>,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(1);
|
||||
expect(result.current.volume).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
target.volume = 0.5;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(0.5);
|
||||
expect(result.current.volume).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user