mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(skin): improve buffering, overlays, and input feedback (#1547)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { IndicatorLifecycleState, TransitionState } from '@videojs/core';
|
||||
import { createState } from '@videojs/store';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const transitionMock = vi.hoisted(() => ({
|
||||
createTransition: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@videojs/core/dom', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@videojs/core/dom')>()),
|
||||
createTransition: transitionMock.createTransition,
|
||||
}));
|
||||
|
||||
import { useRenderedIndicatorState } from '../use-rendered-indicator-state';
|
||||
|
||||
interface TestIndicatorState extends IndicatorLifecycleState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function makeState(generation: number): TestIndicatorState {
|
||||
return {
|
||||
open: true,
|
||||
generation,
|
||||
value: String(generation),
|
||||
transitionStarting: false,
|
||||
transitionEnding: false,
|
||||
};
|
||||
}
|
||||
|
||||
function mockTransition() {
|
||||
const state = createState<TransitionState>({ active: false, status: 'idle' });
|
||||
const open = vi.fn(() => {
|
||||
state.patch({ active: true, status: 'starting' });
|
||||
return Promise.resolve();
|
||||
});
|
||||
const close = vi.fn(() => Promise.resolve());
|
||||
const cancel = vi.fn();
|
||||
const destroy = vi.fn();
|
||||
|
||||
transitionMock.createTransition.mockReturnValue({ state, open, close, cancel, destroy });
|
||||
|
||||
return { state, open, close, cancel, destroy };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
transitionMock.createTransition.mockReset();
|
||||
});
|
||||
|
||||
describe('useRenderedIndicatorState', () => {
|
||||
it('replays the open transition on updates by default', async () => {
|
||||
const transition = mockTransition();
|
||||
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current), {
|
||||
initialProps: { current: makeState(1) },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(transition.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender({ current: makeState(2) });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(transition.open).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('skips replaying the open transition on updates when disabled', async () => {
|
||||
const transition = mockTransition();
|
||||
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), {
|
||||
initialProps: { current: makeState(1) },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(transition.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender({ current: makeState(2) });
|
||||
|
||||
expect(transition.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels an ending transition when replay is disabled and an update arrives', async () => {
|
||||
const transition = mockTransition();
|
||||
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), {
|
||||
initialProps: { current: makeState(1) },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(transition.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
transition.state.patch({ active: true, status: 'ending' });
|
||||
rerender({ current: makeState(2) });
|
||||
|
||||
expect(transition.open).toHaveBeenCalledTimes(1);
|
||||
expect(transition.cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { useState, useSyncExternalStore } from 'react';
|
||||
import { useDestroy } from '../../utils/use-destroy';
|
||||
import { useIndicatorVisibility } from './use-indicator-visibility';
|
||||
import { useInputActionSubscription } from './use-input-action-subscription';
|
||||
import { useRenderedIndicatorState } from './use-rendered-indicator-state';
|
||||
import { type RenderedIndicatorOptions, useRenderedIndicatorState } from './use-rendered-indicator-state';
|
||||
|
||||
interface InputIndicatorRootCore<IndicatorState extends IndicatorLifecycleState, Props> {
|
||||
readonly state: StoreState<IndicatorState>;
|
||||
@@ -19,7 +19,8 @@ interface InputIndicatorRootCore<IndicatorState extends IndicatorLifecycleState,
|
||||
|
||||
export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleState, Props>(
|
||||
createCore: () => InputIndicatorRootCore<IndicatorState, Props>,
|
||||
props: Props
|
||||
props: Props,
|
||||
options?: RenderedIndicatorOptions
|
||||
) {
|
||||
const [core] = useState(createCore);
|
||||
useDestroy(core);
|
||||
@@ -36,5 +37,5 @@ export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleS
|
||||
() => core.state.current
|
||||
);
|
||||
|
||||
return useRenderedIndicatorState(currentState);
|
||||
return useRenderedIndicatorState(currentState, options);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
import { getRenderedIndicatorState, type IndicatorLifecycleState, isIndicatorPresent } from '@videojs/core';
|
||||
import { createTransition } from '@videojs/core/dom';
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useDestroy } from '../../utils/use-destroy';
|
||||
|
||||
export function useRenderedIndicatorState<State extends IndicatorLifecycleState>(currentState: State) {
|
||||
export interface RenderedIndicatorOptions {
|
||||
replayOnUpdate?: boolean | undefined;
|
||||
}
|
||||
|
||||
export function useRenderedIndicatorState<State extends IndicatorLifecycleState>(
|
||||
currentState: State,
|
||||
options: RenderedIndicatorOptions = {}
|
||||
) {
|
||||
const elementRef = useRef<HTMLElement>(null);
|
||||
const currentStateRef = useRef(currentState);
|
||||
const snapshotRef = useRef(currentState);
|
||||
@@ -22,13 +29,18 @@ export function useRenderedIndicatorState<State extends IndicatorLifecycleState>
|
||||
|
||||
const { generation, open } = currentState;
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
const nextState = currentStateRef.current;
|
||||
if (nextState.generation !== generation) return;
|
||||
|
||||
snapshotRef.current = nextState;
|
||||
void transition.open();
|
||||
const transitionState = transition.state.current;
|
||||
if (!transitionState.active || options.replayOnUpdate !== false) {
|
||||
void transition.open(elementRef.current);
|
||||
} else if (transitionState.status === 'ending') {
|
||||
transition.cancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +48,7 @@ export function useRenderedIndicatorState<State extends IndicatorLifecycleState>
|
||||
if (active && status !== 'ending') {
|
||||
void transition.close(elementRef.current);
|
||||
}
|
||||
}, [generation, open, transition]);
|
||||
}, [generation, open, options.replayOnUpdate, transition]);
|
||||
|
||||
return {
|
||||
elementRef,
|
||||
|
||||
@@ -18,7 +18,11 @@ export const VolumeIndicatorRoot = forwardRef(function VolumeIndicatorRoot(
|
||||
forwardedRef: ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { render, className, style, closeDelay, ...elementProps } = componentProps;
|
||||
const { elementRef, present, state } = useInputIndicatorRoot(() => new VolumeIndicatorCore(), { closeDelay });
|
||||
const { elementRef, present, state } = useInputIndicatorRoot(
|
||||
() => new VolumeIndicatorCore(),
|
||||
{ closeDelay },
|
||||
{ replayOnUpdate: false }
|
||||
);
|
||||
|
||||
if (!present) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user