mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): setup react player api (#372)
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user