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
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { cleanup, render } from '@testing-library/react';
import type { IndicatorLifecycleState } from '@videojs/core';
import { createState } from '@videojs/store';
import { type ReactNode, StrictMode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';

import { createPlayerWrapper, MockErrorBoundary } from '../../../testing/mocks';
import { useInputIndicatorRoot } from '../use-input-indicator-root';

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

function createCore() {
return {
state: createState<IndicatorLifecycleState>({
open: false,
generation: 0,
transitionStarting: false,
transitionEnding: false,
}),
setProps: vi.fn(),
destroy: vi.fn(),
close: vi.fn(),
processEvent: vi.fn(() => false),
};
}

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

return null;
}

describe('useInputIndicatorRoot', () => {
it('does not publish props from an abandoned render to the retained core', () => {
const core = createCore();
const { Wrapper } = createPlayerWrapper();

vi.spyOn(console, 'error').mockImplementation(() => {});

function Probe({ value }: { value: string }) {
useInputIndicatorRoot(() => core, { value });
return null;
}

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

expect(core.setProps).toHaveBeenCalledWith({ value: 'committed' });

rerender(
<Wrapper>
<MockErrorBoundary>
<Probe value="abandoned" />
<Thrower abandon />
</MockErrorBoundary>
</Wrapper>
);

expect(core.setProps).not.toHaveBeenCalledWith({ value: 'abandoned' });
});

it('publishes the latest committed props under StrictMode', () => {
const core = createCore();
const { Wrapper } = createPlayerWrapper();

function Probe({ value }: { value: string }) {
useInputIndicatorRoot(() => core, { value });
return null;
}

const { rerender } = render(
<StrictMode>
<Wrapper>
<Probe value="first" />
</Wrapper>
</StrictMode>
);

expect(core.setProps).toHaveBeenLastCalledWith({ value: 'first' });

rerender(
<StrictMode>
<Wrapper>
<Probe value="second" />
</Wrapper>
</StrictMode>
);

expect(core.setProps).toHaveBeenLastCalledWith({ value: 'second' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { getIndicatorVisibilityCoordinator } from '@videojs/core/dom';
import { useCallback, useEffect, useRef, useState } from 'react';

import { useContainer } from '../../player/context';
import { useLatestRef } from '../../utils/use-latest-ref';
import { useCommittedRef } from '../../utils/use-committed-ref';

export function useIndicatorVisibility(close: () => void): () => void {
const container = useContainer();
const closeRef = useLatestRef(close);
const closeRef = useCommittedRef(close);
const coordinatorRef = useRef<ReturnType<typeof getIndicatorVisibilityCoordinator> | null>(null);
const [handle] = useState<IndicatorVisibilityHandle>(() => ({
close: () => closeRef.current(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { getMediaSnapshot, subscribeToInputActions } from '@videojs/core/dom';
import { useEffect } from 'react';

import { useContainer, usePlayer } from '../../player/context';
import { useLatestRef } from '../../utils/use-latest-ref';
import { useCommittedRef } from '../../utils/use-committed-ref';

export function useInputActionSubscription(callback: (event: InputActionEvent, snapshot: MediaSnapshot) => void): void {
const container = useContainer();
const store = usePlayer();
const callbackRef = useLatestRef(callback);
const storeRef = useLatestRef(store);
const callbackRef = useCommittedRef(callback);
const storeRef = useCommittedRef(store);

useEffect(() => {
if (!container) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { State as StoreState } from '@videojs/store';
import { useState, useSyncExternalStore } from 'react';

import { useDestroy } from '../../utils/use-destroy';
import { useIsomorphicLayoutEffect } from '../../utils/use-isomorphic-layout-effect';
import { useIndicatorVisibility } from './use-indicator-visibility';
import { useInputActionSubscription } from './use-input-action-subscription';
import { type RenderedIndicatorOptions, useRenderedIndicatorState } from './use-rendered-indicator-state';
Expand All @@ -23,7 +24,10 @@ export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleS
const [core] = useState(createCore);

useDestroy(core);
core.setProps(props);

// Commit props before the passive subscriptions below install so the core never keeps props from an abandoned render.
useIsomorphicLayoutEffect(() => core.setProps(props), [core, props]);

const showIndicator = useIndicatorVisibility(() => core.close());

useInputActionSubscription((event, snapshot) => {
Expand Down
21 changes: 13 additions & 8 deletions packages/react/src/ui/status-announcer/status-announcer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useLocale, useTranslator } from '../../i18n/context';
import { useContainer, usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { useDestroy } from '../../utils/use-destroy';
import { useIsomorphicLayoutEffect } from '../../utils/use-isomorphic-layout-effect';
import { renderElement } from '../../utils/use-render';

export interface StatusAnnouncerProps
Expand All @@ -26,14 +27,18 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer(
const container = useContainer();

useDestroy(core);
core.setProps({
closeDelay,
labels: {
...createStatusAnnouncerLabels(translator, locale),
...labels,
},
shouldAnnounce: () => shouldAnnounceStatusChange(container),
});

// Commit props before the passive store subscription below so announcements never read props from an abandoned render.
useIsomorphicLayoutEffect(() => {
core.setProps({
closeDelay,
labels: {
...createStatusAnnouncerLabels(translator, locale),
...labels,
},
shouldAnnounce: () => shouldAnnounceStatusChange(container),
});
}, [core, closeDelay, translator, locale, labels, container]);

useEffect(() => subscribeToStatusAnnouncer(store, core), [core, store]);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { act, cleanup, render } from '@testing-library/react';
import { StatusAnnouncerCore } from '@videojs/core';
import type { UnknownStore } from '@videojs/store';
import type { ReactNode } from 'react';
import { isString } from '@videojs/utils/predicate';
import { type ReactNode, StrictMode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';

import { PlayerContextProvider, type PlayerContextValue } from '../../../player/context';
import { MockErrorBoundary } from '../../../testing/mocks';
import { StatusAnnouncer } from '../status-announcer';

afterEach(cleanup);
Expand Down Expand Up @@ -67,6 +70,68 @@ describe('StatusAnnouncer', () => {
expect(getByRole('status').textContent).toBe('Custom playing');
});

it('does not publish props from an abandoned render to the retained core', () => {
const labels: string[] = [];
const originalSetProps = StatusAnnouncerCore.prototype.setProps;
const setProps = vi
.spyOn(StatusAnnouncerCore.prototype, 'setProps')
.mockImplementation(function (this: StatusAnnouncerCore, props) {
if (isString(props.labels?.playing)) labels.push(props.labels.playing);

originalSetProps.call(this, props);
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const value = createPlayerContextValue(createTestStore().store);

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

return null;
}

const { rerender } = render(
<PlayerContextProvider value={value}>
<MockErrorBoundary>
<StatusAnnouncer labels={{ playing: 'Committed' }} />
<Thrower abandon={false} />
</MockErrorBoundary>
</PlayerContextProvider>
);

expect(labels).toContain('Committed');

rerender(
<PlayerContextProvider value={value}>
<MockErrorBoundary>
<StatusAnnouncer labels={{ playing: 'Abandoned' }} />
<Thrower abandon />
</MockErrorBoundary>
</PlayerContextProvider>
);

expect(labels).not.toContain('Abandoned');
setProps.mockRestore();
consoleError.mockRestore();
});

it('announces with the committed labels under StrictMode', async () => {
const { store, setState } = createTestStore({ paused: true });
const { getByRole } = render(
<StrictMode>
<PlayerContextProvider value={createPlayerContextValue(store)}>
<StatusAnnouncer labels={{ playing: 'Strict playing' }} />
</PlayerContextProvider>
</StrictMode>
);

await act(async () => {});

setState({ paused: false });
await act(async () => {});

expect(getByRole('status').textContent).toBe('Strict playing');
});

it('uses the next store snapshot as baseline when the store changes', async () => {
const first = createTestStore({ paused: false });
const second = createTestStore({ paused: false });
Expand Down
110 changes: 110 additions & 0 deletions packages/react/src/ui/tooltip/tests/tooltip-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { cleanup, render } from '@testing-library/react';
import type { TooltipGroupCore } from '@videojs/core';
import { type ReactNode, StrictMode, useLayoutEffect } from 'react';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';

import { MockErrorBoundary } from '../../../testing/mocks';
import { useTooltipGroup } from '../group-context';
import { TooltipProvider } from '../tooltip-provider';

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

function LayoutGroupDelay({ commit, observed }: { commit: string; observed: number[] }) {
const group = useTooltipGroup();

useLayoutEffect(() => {
if (commit && group) observed.push(group.delay);
}, [commit, group, observed]);

return null;
}

function CaptureGroup({ onGroup }: { onGroup: (group: TooltipGroupCore | undefined) => void }) {
onGroup(useTooltipGroup());
return null;
}

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

return null;
}

describe('TooltipProvider', () => {
it('publishes props before descendant layout effects', () => {
const observed: number[] = [];
const { rerender } = render(
<TooltipProvider delay={100}>
<LayoutGroupDelay commit="first" observed={observed} />
</TooltipProvider>
);

rerender(
<TooltipProvider delay={200}>
<LayoutGroupDelay commit="second" observed={observed} />
</TooltipProvider>
);

expect(observed).toEqual([100, 200]);
});

it('does not publish props from an abandoned render', () => {
let group: TooltipGroupCore | undefined;
const capture = (value: TooltipGroupCore | undefined) => {
group = value;
};

vi.spyOn(console, 'error').mockImplementation(() => {});
const { rerender } = render(
<MockErrorBoundary>
<TooltipProvider delay={100}>
<CaptureGroup onGroup={capture} />
<Thrower abandon={false} />
</TooltipProvider>
</MockErrorBoundary>
);

expect(group?.delay).toBe(100);

rerender(
<MockErrorBoundary>
<TooltipProvider delay={200}>
<CaptureGroup onGroup={capture} />
<Thrower abandon />
</TooltipProvider>
</MockErrorBoundary>
);

expect(group?.delay).toBe(100);
});

it('publishes the latest props under StrictMode', () => {
let group: TooltipGroupCore | undefined;
const capture = (value: TooltipGroupCore | undefined) => {
group = value;
};

const { rerender } = render(
<StrictMode>
<TooltipProvider delay={100}>
<CaptureGroup onGroup={capture} />
</TooltipProvider>
</StrictMode>
);

expect(group?.delay).toBe(100);

rerender(
<StrictMode>
<TooltipProvider delay={200}>
<CaptureGroup onGroup={capture} />
</TooltipProvider>
</StrictMode>
);

expect(group?.delay).toBe(200);
});
});
Loading
Loading