feat(react): setup react player api (#372)

This commit is contained in:
rahim
2026-02-02 14:08:31 +11:00
committed by GitHub
parent 1bde6e950a
commit d28dda114c
15 changed files with 728 additions and 509 deletions
-32
View File
@@ -1,32 +0,0 @@
import type { ReactNode } from 'react';
import { createContext, useContext } from 'react';
import type { AnyStore } from '../core/store';
/**
* Internal shared context for store instances.
* All Providers write to this context.
*/
const StoreContext = createContext<AnyStore | null>(null);
/**
* Internal hook for primitive UI components.
* Accesses the nearest store from context without type information.
*
* @throws If used outside of a Provider
*/
export function useStoreContext(): AnyStore {
const store = useContext(StoreContext);
if (!store) {
throw new Error('useStoreContext must be used within a Provider');
}
return store;
}
/**
* Internal provider component that wraps children with store context.
*/
export function StoreContextProvider({ store, children }: { store: AnyStore; children: ReactNode }): ReactNode {
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
}
-107
View File
@@ -1,107 +0,0 @@
import { isUndefined } from '@videojs/utils/predicate';
import type { FC, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import type { StoreConfig } from '../core/config';
import type { AnyFeature } from '../core/feature';
import type { AnyStore, FeatureStore } from '../core/store';
import { createStore as createCoreStore } from '../core/store';
import { StoreContextProvider, useStoreContext } from './context';
import { useStore as useStoreBase } from './hooks/use-store';
export interface CreateStoreConfig<Features extends AnyFeature[]> extends StoreConfig<Features> {
displayName?: string;
}
export interface ProviderProps<S extends AnyStore> {
children: ReactNode;
store?: S;
}
export interface CreateStoreResult<S extends AnyStore> {
/** Provider component that creates and manages the store lifecycle. */
Provider: FC<ProviderProps<S>>;
/**
* Access store state and actions.
* Returns the store without subscribing to changes.
* Use selectors via `useSelector` for reactive updates.
*/
useStore: () => S;
/**
* Creates a new store instance.
* Useful for imperative access or creating a store before render.
*/
create: () => S;
}
// ----------------------------------------
// Implementation
// ----------------------------------------
/**
* Creates a store factory that returns a Provider and typed hooks.
*
* @param config - Store configuration including features and optional lifecycle hooks
* @returns An object containing Provider, hooks, and a create function
*
* @example
* ```tsx
* const { Provider, useStore, useQueue, create } = createStore({
* features: [playbackFeature, presentationFeature],
* });
* ```
*/
export function createStore<Features extends AnyFeature[]>(
config: CreateStoreConfig<Features>
): CreateStoreResult<FeatureStore<Features>> {
type Store = FeatureStore<Features>;
function create(): Store {
return createCoreStore(config);
}
/**
* Provider component that manages store lifecycle.
*
* If `store` prop is provided, uses that store (no cleanup on unmount).
* Otherwise, creates a new store and destroys it on unmount.
*/
function Provider({ children, store: providedStore }: ProviderProps<Store>): ReactNode {
const [store] = useState<Store>(() => {
if (!isUndefined(providedStore)) {
return providedStore;
}
return create();
});
const isOwner = isUndefined(providedStore);
useEffect(() => {
if (isOwner) {
return () => store.destroy();
}
return undefined;
}, [store, isOwner]);
return <StoreContextProvider store={store}>{children}</StoreContextProvider>;
}
// Set display name for React DevTools
if (config.displayName) {
Provider.displayName = `${config.displayName}.Provider`;
}
function useStore(): Store {
const store = useStoreContext();
return useStoreBase(store) as Store;
}
return {
Provider,
useStore,
create,
};
}
+2 -9
View File
@@ -1,9 +1,2 @@
export { useStoreContext } from './context';
export type {
CreateStoreConfig,
CreateStoreResult,
ProviderProps,
} from './create-store';
export { createStore } from './create-store';
export { useStore } from './hooks';
export { type Comparator, type Selector, useSelector } from './hooks/use-selector';
export { useStore } from './hooks/use-store';
@@ -1,44 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it } from 'vitest';
import { defineFeature } from '../../core/feature';
import { createStore as createCoreStore } from '../../core/store';
import { StoreContextProvider, useStoreContext } from '../context';
describe('context', () => {
// Mock target
class MockMedia extends EventTarget {
volume = 1;
}
const audioFeature = defineFeature<MockMedia>()({
state: () => ({
volume: 1,
}),
attach({ target, set }) {
set({ volume: target.volume });
},
});
describe('useStoreContext', () => {
it('throws when used outside of Provider', () => {
expect(() => {
renderHook(() => useStoreContext());
}).toThrow('useStoreContext must be used within a Provider');
});
it('returns store from context', () => {
const store = createCoreStore({ features: [audioFeature] });
const { result } = renderHook(() => useStoreContext(), {
wrapper: ({ children }: { children: ReactNode }) => (
<StoreContextProvider store={store}>{children}</StoreContextProvider>
),
});
expect(result.current).toBe(store);
});
});
});
@@ -1,151 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it } from 'vitest';
import { defineFeature } from '../../core/feature';
import { createStore } from '../create-store';
interface AudioState {
volume: number;
muted: boolean;
setVolume: (volume: number) => Promise<number>;
}
describe('createStore', () => {
// Mock target
class MockMedia extends EventTarget {
volume = 1;
muted = false;
}
const audioFeature = defineFeature<MockMedia>()({
state: ({ task }) => ({
volume: 1,
muted: false,
setVolume(volume: number) {
return task(({ target }) => {
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
});
},
}),
attach({ target, signal, set }) {
const sync = () =>
set({
volume: target.volume,
muted: target.muted,
});
sync();
target.addEventListener('volumechange', sync);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', sync);
});
},
});
describe('create', () => {
it('creates a store instance', () => {
const { create } = createStore({ features: [audioFeature] });
const store = create();
expect(store).toBeDefined();
expect(store.state).toMatchObject({ volume: 1, muted: false });
});
});
describe('provider', () => {
it('creates store on mount', () => {
const { Provider, useStore } = createStore({ features: [audioFeature] });
const { result } = renderHook(() => useStore() as AudioState, {
wrapper: ({ children }: { children: ReactNode }) => <Provider>{children}</Provider>,
});
expect(result.current).toBeDefined();
expect(result.current.volume).toBe(1);
expect(typeof result.current.setVolume).toBe('function');
});
it('uses provided store prop without destroying on unmount', () => {
const { Provider, useStore, create } = createStore({
features: [audioFeature],
});
const providedStore = create();
const { unmount } = renderHook(() => useStore(), {
wrapper: ({ children }: { children: ReactNode }) => <Provider store={providedStore}>{children}</Provider>,
});
unmount();
// Store should NOT be destroyed because it was provided
expect(providedStore.destroyed).toBe(false);
});
it('sets displayName on Provider', () => {
const { Provider } = createStore({
features: [audioFeature],
displayName: 'TestStore',
});
expect(Provider.displayName).toBe('TestStore.Provider');
});
});
describe('useStore', () => {
it('returns state and action functions from context', () => {
const { Provider, useStore, create } = createStore({
features: [audioFeature],
});
const store = create();
const target = new MockMedia();
store.attach(target);
const { result } = renderHook(() => useStore() as AudioState, {
wrapper: ({ children }: { children: ReactNode }) => <Provider store={store}>{children}</Provider>,
});
expect(result.current.volume).toBe(1);
expect(result.current.muted).toBe(false);
expect(typeof result.current.setVolume).toBe('function');
});
it('does not re-render on state change (no subscription)', async () => {
const { Provider, useStore, create } = createStore({
features: [audioFeature],
});
const store = create();
const target = new MockMedia();
store.attach(target);
let renderCount = 0;
const { result } = renderHook(
() => {
renderCount++;
return useStore() as AudioState;
},
{
wrapper: ({ children }: { children: ReactNode }) => <Provider store={store}>{children}</Provider>,
}
);
expect(renderCount).toBe(1);
expect(result.current.volume).toBe(1);
await act(async () => {
target.volume = 0.5;
target.dispatchEvent(new Event('volumechange'));
});
// Should NOT have re-rendered
expect(renderCount).toBe(1);
// But store state DID change
expect(store.state.volume).toBe(0.5);
});
});
});