Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/react/src/ui/audio-track/use-audio-track-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ const useAudioTrackRadioOptions = createRadioOptionsHook({
* @param props - Optional `label`, `formatTrack`, and `disabled` overrides.
*/
export function useAudioTrackOptions(props?: AudioTrackOptionsProps): AudioTrackOptionsResult | null {
'use no memo';

return useAudioTrackRadioOptions(props);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ const useCaptionsRadioOptions = createRadioOptionsHook({
* @param props - Optional `label`, `formatTrack`, and `disabled` overrides.
*/
export function useCaptionsOptions(props?: CaptionsOptionsProps): CaptionsOptionsResult | null {
'use no memo';

const result = useCaptionsRadioOptions(props);
if (!result) return null;

Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/ui/controls/controls-root.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ControlsCore, ControlsDataAttrs } from '@videojs/core';
import { selectControls } from '@videojs/core/dom';
import type { ReactNode } from 'react';
import { useState } from 'react';

import { usePlayer } from '../../player/context';
import { useLogMissingFeature } from '../hooks/use-log-missing-feature';
Expand All @@ -14,12 +13,13 @@ export interface ControlsRootProps {
/** Manages controls state and provides it to the compound parts. Does not render an element. */
export function ControlsRoot({ children }: ControlsRootProps): ReactNode {
const controls = usePlayer(selectControls);
const [core] = useState(() => new ControlsCore());

useLogMissingFeature(!controls, 'Controls.Root', 'controls');

if (!controls) return null;

const core = new ControlsCore();

core.setMedia(controls);
const state = core.getState();

Expand Down
8 changes: 5 additions & 3 deletions packages/react/src/ui/create-media-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isText, translateText } from '@videojs/core/i18n';
import type { Selector } from '@videojs/store';
import { isUndefined } from '@videojs/utils/predicate';
import type { ForwardedRef, ForwardRefExoticComponent, RefAttributes } from 'react';
import { forwardRef, useLayoutEffect, useState } from 'react';
import { forwardRef, useLayoutEffect } from 'react';

import { useTranslator } from '../i18n/context';
import { useContainer, usePlayer } from '../player/context';
Expand Down Expand Up @@ -88,12 +88,14 @@ export function createMediaButton<Core extends Required<MediaButtonComponent>, P
const shortcut = useHotkeyShortcut(hotkeyAction, hotkeyValue?.(coreProps));
const translator = useTranslator();

const [core] = useState(() => new CoreClass());

if (corePropKeys.has('menuTrigger') && isUndefined(coreProps.menuTrigger)) {
coreProps.menuTrigger = menuTriggerChild;
}

// Project this render's props and media onto a render-local core so an abandoned render never mutates the
// committed one.
const core = new CoreClass();

core.setProps(coreProps);

const { getButtonProps, buttonRef } = useButton({
Expand Down
11 changes: 4 additions & 7 deletions packages/react/src/ui/dialog/use-dialog-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ export function useDialogRoot({
idPrefix = 'dialog',
interactionRoot,
}: UseDialogRootOptions): DialogContextValue {
const [core] = useState(coreFactory);

core.setProps({ open: controlledOpen, defaultOpen, closeOnEscape });

const isControlled = controlledOpen !== undefined;
const initialOpenRef = useRef(!isControlled && defaultOpen);
const onOpenChangeRef = useLatestRef(onOpenChangeProp);
Expand All @@ -52,9 +48,6 @@ export function useDialogRoot({
const titleId = useSafeId(`${idPrefix}-title`);
const descriptionId = useSafeId(`${idPrefix}-desc`);

core.setTitleId(titleId);
core.setDescriptionId(descriptionId);

useLayoutEffect(() => {
dialog.setInteractionRoot(interactionRoot ?? null);
}, [dialog, interactionRoot]);
Expand Down Expand Up @@ -83,8 +76,12 @@ export function useDialogRoot({

const input = useSnapshot(dialog.input);
const modality = useSnapshot(dialog.modality);
const core = coreFactory();

core.setProps({ open: controlledOpen, defaultOpen, closeOnEscape });
core.setInput(input);
core.setTitleId(titleId);
core.setDescriptionId(descriptionId);
core.setDocumentModal(modality.documentModal);

return {
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/ui/hooks/create-radio-options-hook.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { RadioOption, RadioOptionsState } from '@videojs/core';
import { type Text, type TextParams, translateText } from '@videojs/core/i18n';
import type { UnknownState } from '@videojs/store';
import { useCallback, useState } from 'react';

import { useTranslator } from '../../i18n/context';
import { usePlayer } from '../../player/context';
Expand Down Expand Up @@ -51,15 +50,16 @@ export function createRadioOptionsHook<Props, Media, State extends RadioOptionsS
props?: Props
) => RadioOptionsHookResult<StateOption<State>, State> | null {
return function useRadioOptions(props?: Props): RadioOptionsHookResult<StateOption<State>, State> | null {
'use no memo';

const media = usePlayer(selector);
const t = useTranslator();
const [core] = useState(createCore);

// A render-local core keeps the projection pure: it derives only from this render's props and media, so nothing
// leaks from abandoned renders and memoizing compilers may cache it safely.
const core = createCore();

core.setProps(props ?? ({} as Props));

const setValue = useCallback((value: string) => core.selectValue(media!, value), [core, media]);
const setValue = (value: string) => core.selectValue(media!, value);

useLogMissingFeature(!media, name, selector.displayName ?? feature);

Expand Down
6 changes: 2 additions & 4 deletions packages/react/src/ui/live-button/live-button.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LiveButtonCore, LiveButtonDataAttrs, type LiveButtonMediaState } from '@videojs/core';
import { selectBuffer, selectLive, selectTime } from '@videojs/core/dom';
import { translateText } from '@videojs/core/i18n';
import { forwardRef, type ReactNode, useLayoutEffect, useState } from 'react';
import { forwardRef, type ReactNode, useLayoutEffect } from 'react';

import { useTranslator } from '../../i18n/context';
import { usePlayer } from '../../player/context';
Expand Down Expand Up @@ -52,9 +52,7 @@ export const LiveButton = forwardRef<HTMLButtonElement, LiveButtonProps>(

const tooltipCtx = useOptionalTooltipContext();
const translator = useTranslator();
const [core] = useState(() => new LiveButtonCore());

core.setProps({ label, disabled });
const core = new LiveButtonCore({ label, disabled });

const { getButtonProps, buttonRef } = useButton({
displayName: DISPLAY_NAME,
Expand Down
12 changes: 6 additions & 6 deletions packages/react/src/ui/menu/menu-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ export function MenuRoot({
const isSubmenu = parentMenu !== null;
const { side, align, closeOnEscape, closeOnOutsideClick } = coreProps;

const [core] = useState(() => new MenuCore(coreProps));

const isControlled = controlledOpen !== undefined;
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const resolvedOpen = controlledOpen ?? uncontrolledOpen;
Expand Down Expand Up @@ -108,11 +106,13 @@ export function MenuRoot({
return controlsState?.requestControlsLock();
}, [controlsState?.requestControlsLock, input.active, isSubmenu]);

const preferredState = useMemo(() => {
core.setProps({ side, align, closeOnEscape, closeOnOutsideClick });
const projection = useMemo(() => {
const core = new MenuCore({ side, align, closeOnEscape, closeOnOutsideClick });

core.setInput({ ...input, isSubmenu });
return core.getState();
}, [core, input, side, align, closeOnEscape, closeOnOutsideClick, isSubmenu]);
return { core, state: core.getState() };
}, [input, side, align, closeOnEscape, closeOnOutsideClick, isSubmenu]);
const { core, state: preferredState } = projection;
const { state, preferredSide, setPositionedSide } = usePositionedState(preferredState);

const contextValue = useMemo(
Expand Down
57 changes: 56 additions & 1 deletion packages/react/src/ui/play-button/tests/play-button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { cleanup, render, screen } from '@testing-library/react';
import { PlayButtonCore } from '@videojs/core';
import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n';
import { isString } from '@videojs/utils/predicate';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';

import { createI18n, I18nProvider } from '../../../i18n';
import { createPlayerWrapper } from '../../../testing/mocks';
import { createPlayerWrapper, MockErrorBoundary } from '../../../testing/mocks';
import { PlayButton } from '../play-button';

afterEach(() => {
Expand Down Expand Up @@ -70,4 +73,56 @@ describe('PlayButton', () => {

expect(screen.getByTestId('play').getAttribute('aria-label')).toBe('Custom play');
});

it('projects each render onto its own core so an abandoned render cannot mutate the committed one', () => {
const cores = new Map<string, PlayButtonCore>();
const originalSetProps = PlayButtonCore.prototype.setProps;
const setProps = vi
.spyOn(PlayButtonCore.prototype, 'setProps')
.mockImplementation(function (this: PlayButtonCore, props) {
if (isString(props.label)) cores.set(props.label, this);

originalSetProps.call(this, props);
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const { Wrapper } = createPlayerWrapper({
paused: true,
ended: false,
started: false,
waiting: false,
play: vi.fn(),
pause: vi.fn(),
togglePaused: vi.fn(),
});

function Thrower({ abandon }: { abandon: boolean }): ReactNode {
if (abandon) throw new Error('abandon render');

return null;
}

const { rerender } = render(
<Wrapper>
<MockErrorBoundary>
<PlayButton label="committed" />
<Thrower abandon={false} />
</MockErrorBoundary>
</Wrapper>
);

rerender(
<Wrapper>
<MockErrorBoundary>
<PlayButton label="abandoned" />
<Thrower abandon />
</MockErrorBoundary>
</Wrapper>
);

expect(cores.get('committed')).toBeInstanceOf(PlayButtonCore);
expect(cores.get('abandoned')).toBeInstanceOf(PlayButtonCore);
expect(cores.get('abandoned')).not.toBe(cores.get('committed'));
setProps.mockRestore();
consoleError.mockRestore();
});
});
4 changes: 1 addition & 3 deletions packages/react/src/ui/popover/popover-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ export function PopoverRoot({
const container = useOptionalContainer();
const popupGroup = useOptionalPopupGroup();
const controls = useOptionalControlsContext();
const [core] = useState(() => new PopoverCore(coreProps));

core.setProps(coreProps);

const isControlled = !isUndefined(controlledOpen);
const initialOpenRef = useRef(!isControlled && defaultOpen);
Expand Down Expand Up @@ -120,6 +117,7 @@ export function PopoverRoot({
useDestroy(popover);

const input = useSnapshot(popover.input);
const core = new PopoverCore(coreProps);

core.setInput(input);
const { state, preferredSide, setPositionedSide } = usePositionedState(core.getState());
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/ui/poster/poster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const Poster = forwardRef(function Poster(
const playback = usePlayer(selectPlayback);
const metadata = usePlayer(selectMetadata);

const [core] = useState(() => new PosterCore());
const core = new PosterCore();

// The metadata feature is optional: without it nothing resolves a URL, and
// this stays a visibility wrapper around whatever `src` was passed.
Expand Down
2 changes: 0 additions & 2 deletions packages/react/src/ui/quality/use-quality-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ const useQualityRadioOptions = createRadioOptionsHook({
* @param props - Optional `label`, `formatRendition`, and `disabled` overrides.
*/
export function useQualityOptions(props?: QualityOptionsProps): QualityOptionsResult | null {
'use no memo';

return useQualityRadioOptions(props);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
} from '@videojs/core';
import { getStateDataAttrs } from '@videojs/core/dom';
import type { CSSProperties, ReactElement } from 'react';
import { Fragment, forwardRef, useMemo, useState } from 'react';
import { Fragment, forwardRef, useMemo } from 'react';

import type { HTMLProps, UIComponentProps } from '../../../utils/types';
import { renderElement } from '../../../utils/use-render';
import { useSliderContext, useSliderPointerValue } from '../../slider/context';

// `SliderSegmentsCore` holds no state, so one shared instance projects every render.
const segmentsCore = new SliderSegmentsCore();

type SegmentProps = Omit<HTMLProps<HTMLElement>, 'ref'>;

interface SliderSegmentsProps extends Omit<UIComponentProps<'div', SliderSegmentState>, 'children'> {
Expand All @@ -29,16 +32,14 @@ export const SliderSegments = forwardRef<HTMLDivElement, SliderSegmentsProps>(
const slider = useSliderContext();
const pointerValue = useSliderPointerValue();

const [core] = useState(() => new SliderSegmentsCore());

const geometry = useMemo(
() => core.getGeometry({ ranges, min, max, orientation: slider.state.orientation }),
[core, ranges, min, max, slider.state.orientation]
() => segmentsCore.getGeometry({ ranges, min, max, orientation: slider.state.orientation }),
[ranges, min, max, slider.state.orientation]
);
const sliderAttrs = getStateDataAttrs(slider.state, slider.stateAttrMap);

const segments = geometry.map((segment) => {
const state = core.getState(segment, slider.state, pointerValue);
const state = segmentsCore.getState(segment, slider.state, pointerValue);
const segmentStyle = {
[TimeSliderChapterCSSVars.start]: state.startPercent,
[TimeSliderChapterCSSVars.end]: state.endPercent,
Expand All @@ -59,7 +60,7 @@ export const SliderSegments = forwardRef<HTMLDivElement, SliderSegmentsProps>(
);
});

const state = geometry.length > 0 ? core.getState(geometry[0]!, slider.state, pointerValue) : null;
const state = geometry.length > 0 ? segmentsCore.getState(geometry[0]!, slider.state, pointerValue) : null;
if (!state) return null;

return renderElement(
Expand Down
4 changes: 1 addition & 3 deletions packages/react/src/ui/time/time-value.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ export const Value = forwardRef(function Value(
const translator = useTranslator();
const locale = useLocale();

const [core] = useState(() => new TimeCore());

const defaultType = type ?? TimeCore.defaultProps.type;
const [activeType, setActiveType] = useState(defaultType);

Expand All @@ -46,7 +44,7 @@ export const Value = forwardRef(function Value(
setActiveType(defaultType);
}, [defaultType, toggle]);

core.setProps({
const core = new TimeCore({
type: activeType,
negativeSign,
label,
Expand Down
9 changes: 5 additions & 4 deletions packages/react/src/ui/title/title.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
import { TitleCore, TitleDataAttrs } from '@videojs/core';
import { selectMetadata } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
import { forwardRef } from 'react';

import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useLogMissingFeature } from '../hooks/use-log-missing-feature';

// `TitleCore` holds no state, so one shared instance projects every render.
const titleCore = new TitleCore();

export interface TitleProps extends Omit<UIComponentProps<'span', TitleCore.State>, 'children'> {}

/**
Expand All @@ -34,13 +37,11 @@ export const Title = forwardRef(function Title(

const metadata = usePlayer(selectMetadata);

const [core] = useState(() => new TitleCore());

useLogMissingFeature(!metadata, 'Title', 'metadata');

if (!metadata) return null;

const state = core.getState(metadata);
const state = titleCore.getState(metadata);
if (state.hidden) return null;

return renderElement(
Expand Down
4 changes: 1 addition & 3 deletions packages/react/src/ui/tooltip/tooltip-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,6 @@ export function TooltipRoot({
const container = useOptionalContainer();
const popupGroup = useOptionalPopupGroup();
const controls = useOptionalControlsContext();
const [core] = useState(() => new TooltipCore(coreProps));

core.setProps(coreProps);

const isControlled = !isUndefined(controlledOpen);
const initialOpenRef = useRef(!isControlled && defaultOpen);
Expand Down Expand Up @@ -127,6 +124,7 @@ export function TooltipRoot({
useDestroy(tooltip);

const input = useSnapshot(tooltip.input);
const core = new TooltipCore(coreProps);

core.setInput(input);
const { state, preferredSide, setPositionedSide } = usePositionedState(core.getState());
Expand Down
Loading
Loading