refactor(packages): move store attach lifecycle to provider (#975)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rahim
2026-03-17 15:50:39 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7bacb1b2ae
commit d535282f03
26 changed files with 378 additions and 265 deletions
+2 -1
View File
@@ -13,8 +13,9 @@ export {
Container,
type ContainerProps,
type PlayerContextValue,
useContainerAttach,
useMedia,
useMediaRegistration,
useMediaAttach,
usePlayer,
usePlayerContext,
} from './player/context';
+4 -12
View File
@@ -1,24 +1,16 @@
'use client';
import type { AudioHTMLAttributes } from 'react';
import { forwardRef, useCallback } from 'react';
import { forwardRef } from 'react';
import { useMediaRegistration } from '../player/context';
import { useMediaAttach } from '../player/context';
import { useComposedRefs } from '../utils/use-composed-refs';
export interface AudioProps extends AudioHTMLAttributes<HTMLAudioElement> {}
export const Audio = forwardRef<HTMLAudioElement, AudioProps>(function Audio({ children, ...props }, ref) {
const setMedia = useMediaRegistration();
const mediaRef = useCallback(
(el: HTMLAudioElement | null) => {
setMedia?.(el);
},
[setMedia]
);
const composedRef = useComposedRefs(ref, mediaRef);
const setMedia = useMediaAttach();
const composedRef = useComposedRefs(ref, setMedia);
return (
<audio ref={composedRef} {...props}>
@@ -3,7 +3,7 @@
import type { VideoHTMLAttributes } from 'react';
import { forwardRef, useCallback } from 'react';
import { useMediaRegistration } from '../../player/context';
import { useMediaAttach } from '../../player/context';
import { useComposedRefs } from '../../utils/use-composed-refs';
export interface BackgroundVideoProps extends VideoHTMLAttributes<HTMLVideoElement> {}
@@ -12,7 +12,7 @@ export const BackgroundVideo = forwardRef<HTMLVideoElement, BackgroundVideoProps
{ children, ...props },
ref
) {
const setMedia = useMediaRegistration();
const setMedia = useMediaAttach();
const mediaRef = useCallback(
(el: HTMLVideoElement | null) => {
@@ -1,7 +1,7 @@
import { DashMedia } from '@videojs/core/dom/media/dash';
import type { PropsWithChildren, VideoHTMLAttributes } from 'react';
import { forwardRef, useMemo } from 'react';
import { useMediaRegistration } from '../../player/context';
import { useMediaAttach } from '../../player/context';
import { attachMediaElement } from '../../utils/attach-media-element';
import { mediaProps } from '../../utils/media-props';
import { useComposedRefs } from '../../utils/use-composed-refs';
@@ -11,13 +11,14 @@ export type DashVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVideoElem
export const DashVideo = forwardRef<HTMLVideoElement, DashVideoProps>(({ children, ...props }, ref) => {
const mediaApi = useMemo(() => new DashMedia(), []);
const setMedia = useMediaRegistration();
const setMedia = useMediaAttach();
useDestroy(mediaApi, () => {
setMedia?.(mediaApi);
});
const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref);
return (
<video ref={composedRef} {...mediaProps(mediaApi, props)}>
{children}
+3 -2
View File
@@ -1,7 +1,7 @@
import { HlsMedia } from '@videojs/core/dom/media/hls';
import type { PropsWithChildren, VideoHTMLAttributes } from 'react';
import { forwardRef, useMemo } from 'react';
import { useMediaRegistration } from '../../player/context';
import { useMediaAttach } from '../../player/context';
import { attachMediaElement } from '../../utils/attach-media-element';
import { mediaProps } from '../../utils/media-props';
import { useComposedRefs } from '../../utils/use-composed-refs';
@@ -11,13 +11,14 @@ export type HlsVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVideoEleme
export const HlsVideo = forwardRef<HTMLVideoElement, HlsVideoProps>(({ children, ...props }, ref) => {
const mediaApi = useMemo(() => new HlsMedia(), []);
const setMedia = useMediaRegistration();
const setMedia = useMediaAttach();
useDestroy(mediaApi, () => {
setMedia?.(mediaApi);
});
const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref);
return (
<video ref={composedRef} {...mediaProps(mediaApi, props)}>
{children}
@@ -1,7 +1,7 @@
import { SimpleHlsMedia } from '@videojs/core/dom/media/simple-hls';
import type { PropsWithChildren, VideoHTMLAttributes } from 'react';
import { forwardRef, useEffect, useMemo } from 'react';
import { useMediaRegistration } from '../../player/context';
import { useMediaAttach } from '../../player/context';
import { attachMediaElement } from '../../utils/attach-media-element';
import { mediaProps } from '../../utils/media-props';
import { useComposedRefs } from '../../utils/use-composed-refs';
@@ -12,13 +12,14 @@ export type SimpleHlsVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVide
export const SimpleHlsVideo = forwardRef<HTMLVideoElement, SimpleHlsVideoProps>(({ children, ...props }, ref) => {
const mediaApi = useMemo(() => new SimpleHlsMedia(), []);
const setMedia = useMediaRegistration();
const setMedia = useMediaAttach();
useEffect(() => {
setMedia?.(mediaApi);
}, [mediaApi, setMedia]);
const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref);
return (
<video ref={composedRef} {...mediaProps(mediaApi, props)}>
{children}
@@ -62,7 +62,7 @@ describe('Audio', () => {
it('calls setMedia on mount', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
render(<Audio />, { wrapper: createWrapper(value) });
@@ -72,7 +72,7 @@ describe('Audio', () => {
it('calls setMedia with null on unmount', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
const { unmount } = render(<Audio />, { wrapper: createWrapper(value) });
@@ -85,7 +85,7 @@ describe('Audio', () => {
it('forwards ref while also registering media', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
const ref = createRef<HTMLAudioElement>();
render(<Audio ref={ref} />, { wrapper: createWrapper(value) });
@@ -65,7 +65,7 @@ describe('Video', () => {
it('calls setMedia on mount', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
render(<Video />, { wrapper: createWrapper(value) });
@@ -75,7 +75,7 @@ describe('Video', () => {
it('calls setMedia with null on unmount', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
const { unmount } = render(<Video />, { wrapper: createWrapper(value) });
@@ -88,7 +88,7 @@ describe('Video', () => {
it('forwards ref while also registering media', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const value: PlayerContextValue = { store: store as any, media: null, setMedia, setContainer: vi.fn() };
const ref = createRef<HTMLVideoElement>();
render(<Video ref={ref} />, { wrapper: createWrapper(value) });
+4 -12
View File
@@ -1,24 +1,16 @@
'use client';
import type { VideoHTMLAttributes } from 'react';
import { forwardRef, useCallback } from 'react';
import { forwardRef } from 'react';
import { useMediaRegistration } from '../player/context';
import { useMediaAttach } from '../player/context';
import { useComposedRefs } from '../utils/use-composed-refs';
export interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {}
export const Video = forwardRef<HTMLVideoElement, VideoProps>(function Video({ children, ...props }, ref) {
const setMedia = useMediaRegistration();
const mediaRef = useCallback(
(el: HTMLVideoElement | null) => {
setMedia?.(el);
},
[setMedia]
);
const composedRef = useComposedRefs(ref, mediaRef);
const setMedia = useMediaAttach();
const composedRef = useComposedRefs(ref, setMedia);
return (
<video ref={composedRef} {...props}>
+13 -6
View File
@@ -12,6 +12,7 @@ export interface PlayerContextValue {
store: UnknownStore;
media: Media | null;
setMedia: Dispatch<SetStateAction<Media | null>>;
setContainer: Dispatch<SetStateAction<HTMLElement | null>>;
}
const PlayerContext = createContext<PlayerContextValue | null>(null);
@@ -79,25 +80,31 @@ export function useMedia(): Media | null {
return media;
}
/** Access the media registration setter for connecting a media element to the player. */
export function useMediaRegistration(): Dispatch<SetStateAction<Media | null>> | undefined {
/** Access the media attach setter for connecting a media element to the player. */
export function useMediaAttach(): Dispatch<SetStateAction<Media | null>> | undefined {
const ctx = useContext(PlayerContext);
return ctx?.setMedia;
}
/** Access the container attach setter for connecting a container element to the player. */
export function useContainerAttach(): Dispatch<SetStateAction<HTMLElement | null>> | undefined {
const ctx = useContext(PlayerContext);
return ctx?.setContainer;
}
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export const Container = forwardRef<HTMLDivElement, ContainerProps>(function Container({ children, ...props }, ref) {
const { store, media } = usePlayerContext();
const setContainer = useContainerAttach();
const internalRef = useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(ref, internalRef);
useEffect(() => {
if (!media) return;
return store.attach({ media, container: internalRef.current });
}, [media, store]);
setContainer?.(internalRef.current);
return () => setContainer?.(null);
}, [setContainer]);
return (
<div ref={composedRef} {...props}>
+8 -2
View File
@@ -15,7 +15,7 @@ import type { InferStoreState } from '@videojs/store';
import { combine, createStore } from '@videojs/store';
import { useStore } from '@videojs/store/react';
import type { FC, ReactNode } from 'react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useDestroy } from '../utils/use-destroy';
import { Container, PlayerContextProvider, useMedia, usePlayerContext } from './context';
@@ -71,10 +71,16 @@ export function createPlayer(config: CreatePlayerConfig<AnyPlayerFeature[]>): Cr
function Provider({ children }: ProviderProps): ReactNode {
const [store] = useState(() => createStore<PlayerTarget>()(combine(...config.features)));
const [media, setMedia] = useState<Media | null>(null);
const [container, setContainer] = useState<HTMLElement | null>(null);
useDestroy(store);
return <PlayerContextProvider value={{ store, media, setMedia }}>{children}</PlayerContextProvider>;
useEffect(() => {
if (!media) return;
return store.attach({ media, container });
}, [media, container, store]);
return <PlayerContextProvider value={{ store, media, setMedia, setContainer }}>{children}</PlayerContextProvider>;
}
if (__DEV__ && config.displayName) {
@@ -7,8 +7,9 @@ import {
Container,
PlayerContextProvider,
type PlayerContextValue,
useContainerAttach,
useMedia,
useMediaRegistration,
useMediaAttach,
useOptionalPlayer,
usePlayer,
usePlayerContext,
@@ -20,6 +21,16 @@ function createWrapper(value: PlayerContextValue) {
};
}
function createContextValue(overrides?: Partial<PlayerContextValue>): PlayerContextValue {
return {
store: createMockStore() as any,
media: null,
setMedia: vi.fn(),
setContainer: vi.fn(),
...overrides,
};
}
describe('usePlayerContext', () => {
it('throws outside Provider', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -33,7 +44,7 @@ describe('usePlayerContext', () => {
it('returns context value inside Provider', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const value = createContextValue({ store: store as any });
const { result } = renderHook(() => usePlayerContext(), {
wrapper: createWrapper(value),
@@ -44,18 +55,17 @@ describe('usePlayerContext', () => {
});
});
describe('useMediaRegistration', () => {
describe('useMediaAttach', () => {
it('returns undefined outside Provider', () => {
const { result } = renderHook(() => useMediaRegistration());
const { result } = renderHook(() => useMediaAttach());
expect(result.current).toBeUndefined();
});
it('returns setMedia inside Provider', () => {
const setMedia = vi.fn();
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia };
const value = createContextValue({ setMedia });
const { result } = renderHook(() => useMediaRegistration(), {
const { result } = renderHook(() => useMediaAttach(), {
wrapper: createWrapper(value),
});
@@ -63,10 +73,28 @@ describe('useMediaRegistration', () => {
});
});
describe('useContainerAttach', () => {
it('returns undefined outside Provider', () => {
const { result } = renderHook(() => useContainerAttach());
expect(result.current).toBeUndefined();
});
it('returns setContainer inside Provider', () => {
const setContainer = vi.fn();
const value = createContextValue({ setContainer });
const { result } = renderHook(() => useContainerAttach(), {
wrapper: createWrapper(value),
});
expect(result.current).toBe(setContainer);
});
});
describe('usePlayer', () => {
it('returns store without selector', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const value = createContextValue({ store: store as any });
const { result } = renderHook(() => usePlayer(), {
wrapper: createWrapper(value),
@@ -96,7 +124,7 @@ describe('useOptionalPlayer', () => {
it('returns store inside Provider', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const value = createContextValue({ store: store as any });
const { result } = renderHook(() => useOptionalPlayer(), {
wrapper: createWrapper(value),
@@ -107,7 +135,7 @@ describe('useOptionalPlayer', () => {
it('returns selected state inside Provider', () => {
const store = createMockStore({ paused: true });
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const value = createContextValue({ store: store as any });
const { result } = renderHook(() => useOptionalPlayer((state: any) => state.paused), {
wrapper: createWrapper(value),
@@ -119,9 +147,8 @@ describe('useOptionalPlayer', () => {
describe('useMedia', () => {
it('returns media from context', () => {
const store = createMockStore();
const media = document.createElement('video');
const value: PlayerContextValue = { store: store as any, media, setMedia: vi.fn() };
const value = createContextValue({ media });
const { result } = renderHook(() => useMedia(), {
wrapper: createWrapper(value),
@@ -131,8 +158,7 @@ describe('useMedia', () => {
});
it('returns null when no media', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const value = createContextValue();
const { result } = renderHook(() => useMedia(), {
wrapper: createWrapper(value),
@@ -144,8 +170,7 @@ describe('useMedia', () => {
describe('Container', () => {
it('renders children', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const value = createContextValue();
const { container } = render(
<PlayerContextProvider value={value}>
@@ -158,10 +183,9 @@ describe('Container', () => {
expect(container.querySelector('span')).toBeTruthy();
});
it('attaches media to store when media is set', () => {
const store = createMockStore();
const media = document.createElement('video');
const value: PlayerContextValue = { store: store as any, media, setMedia: vi.fn() };
it('registers container element via setContainer', () => {
const setContainer = vi.fn();
const value = createContextValue({ setContainer });
render(
<PlayerContextProvider value={value}>
@@ -169,15 +193,29 @@ describe('Container', () => {
</PlayerContextProvider>
);
expect(store.attach).toHaveBeenCalledWith({
media,
container: expect.any(HTMLDivElement),
});
expect(setContainer).toHaveBeenCalledWith(expect.any(HTMLDivElement));
});
it('does not attach when media is null', () => {
it('deregisters container on unmount', () => {
const setContainer = vi.fn();
const value = createContextValue({ setContainer });
const { unmount } = render(
<PlayerContextProvider value={value}>
<Container />
</PlayerContextProvider>
);
setContainer.mockClear();
unmount();
expect(setContainer).toHaveBeenCalledWith(null);
});
it('does not call store.attach directly', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const media = document.createElement('video');
const value = createContextValue({ store: store as any, media });
render(
<PlayerContextProvider value={value}>
+1 -1
View File
@@ -34,7 +34,7 @@ export function createMockStore(state: Record<string, unknown> = {}) {
*/
export function createPlayerWrapper(storeState: Record<string, unknown> = {}) {
const store = createMockStore(storeState);
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn(), setContainer: vi.fn() };
return {
store,